diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 000000000..be739b618 --- /dev/null +++ b/.codecov.yml @@ -0,0 +1,16 @@ +codecov: + branch: dev +coverage: + status: + project: + default: + target: 90 + threshold: 0.09 + notify: + # Notify codecov room in Discord. The webhook URL (encrypted below) ends in /slack which is why we configure a Slack notification. + slack: + default: + url: "secret:TgWDUM4Jw0w7wMJxuxNF/yhSOHglIo1fGwInJnRLEVPy2P2aLimkoK1mtKCowH5TFw+baUXVXT3eAqefbdvIuM8BjRR4aRji95C6CYyD0QHy4N8i7nn1SQkWDPpS8IthYTg07rUDF7s5guurkKv2RrgoCdnnqjAMSzHoExMOF7xUmblMdhBTWJgBpWEhASJy85w/xxjlsE1xoTkzeJu9Q67pTXtRcn+5kb5/vIzPSYg=" +comment: + require_changes: yes + branches: master diff --git a/.coveragerc b/.coveragerc index 25765254c..fc7a4691e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -3,828 +3,827 @@ source = homeassistant omit = homeassistant/__main__.py + homeassistant/helpers/signal.py + homeassistant/helpers/typing.py homeassistant/scripts/*.py homeassistant/util/async.py - homeassistant/monkey_patch.py - homeassistant/helpers/typing.py - homeassistant/helpers/signal.py # omit pieces of code that rely on external devices being present - 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 - - homeassistant/components/amcrest.py - homeassistant/components/*/amcrest.py - - homeassistant/components/apcupsd.py - homeassistant/components/*/apcupsd.py - - homeassistant/components/apple_tv.py - homeassistant/components/*/apple_tv.py - - 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 - - homeassistant/components/arlo.py - homeassistant/components/*/arlo.py - - 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 - - homeassistant/components/bbb_gpio.py - homeassistant/components/*/bbb_gpio.py - - homeassistant/components/blink.py - homeassistant/components/*/blink.py - - homeassistant/components/bloomsky.py - homeassistant/components/*/bloomsky.py - - homeassistant/components/coinbase.py - homeassistant/components/sensor/coinbase.py - + homeassistant/components/abode/__init__.py + homeassistant/components/abode/alarm_control_panel.py + homeassistant/components/abode/binary_sensor.py + homeassistant/components/abode/camera.py + homeassistant/components/abode/cover.py + homeassistant/components/abode/light.py + homeassistant/components/abode/lock.py + homeassistant/components/abode/sensor.py + homeassistant/components/abode/switch.py + homeassistant/components/acer_projector/switch.py + homeassistant/components/actiontec/device_tracker.py + homeassistant/components/adguard/__init__.py + homeassistant/components/adguard/const.py + homeassistant/components/adguard/sensor.py + homeassistant/components/adguard/switch.py + homeassistant/components/ads/* + homeassistant/components/aftership/sensor.py + homeassistant/components/airly/__init__.py + homeassistant/components/airly/air_quality.py + homeassistant/components/airly/sensor.py + homeassistant/components/airly/const.py + homeassistant/components/airvisual/sensor.py + homeassistant/components/aladdin_connect/cover.py + homeassistant/components/alarm_control_panel/manual_mqtt.py + homeassistant/components/alarmdecoder/* + homeassistant/components/alarmdotcom/alarm_control_panel.py + homeassistant/components/alpha_vantage/sensor.py + homeassistant/components/amazon_polly/tts.py + homeassistant/components/ambiclimate/climate.py + homeassistant/components/ambient_station/* + homeassistant/components/amcrest/* + homeassistant/components/ampio/* + homeassistant/components/android_ip_webcam/* + homeassistant/components/anel_pwrctrl/switch.py + homeassistant/components/anthemav/media_player.py + homeassistant/components/apache_kafka/* + homeassistant/components/apcupsd/* + homeassistant/components/apple_tv/* + homeassistant/components/aqualogic/* + homeassistant/components/aquostv/media_player.py + homeassistant/components/arcam_fmj/media_player.py + homeassistant/components/arcam_fmj/__init__.py + homeassistant/components/arduino/* + homeassistant/components/arest/binary_sensor.py + homeassistant/components/arest/sensor.py + homeassistant/components/arest/switch.py + homeassistant/components/arlo/* + homeassistant/components/aruba/device_tracker.py + homeassistant/components/arwn/sensor.py + homeassistant/components/asterisk_cdr/mailbox.py + homeassistant/components/asterisk_mbox/* + homeassistant/components/asuswrt/device_tracker.py + homeassistant/components/aten_pe/* + homeassistant/components/atome/* + homeassistant/components/august/* + homeassistant/components/aurora_abb_powerone/sensor.py + homeassistant/components/automatic/device_tracker.py + homeassistant/components/avea/light.py + homeassistant/components/avion/light.py + homeassistant/components/azure_event_hub/* + homeassistant/components/azure_service_bus/* + homeassistant/components/baidu/tts.py + homeassistant/components/beewi_smartclim/sensor.py + homeassistant/components/bbb_gpio/* + homeassistant/components/bbox/device_tracker.py + homeassistant/components/bbox/sensor.py + homeassistant/components/bh1750/sensor.py + homeassistant/components/bitcoin/sensor.py + homeassistant/components/bizkaibus/sensor.py + homeassistant/components/blink/* + homeassistant/components/blinksticklight/light.py + homeassistant/components/blinkt/light.py + homeassistant/components/blockchain/sensor.py + homeassistant/components/bloomsky/* + homeassistant/components/bluesound/* + homeassistant/components/bluetooth_le_tracker/device_tracker.py + homeassistant/components/bluetooth_tracker/* + homeassistant/components/bme280/sensor.py + homeassistant/components/bme680/sensor.py + homeassistant/components/bmw_connected_drive/* + homeassistant/components/bom/camera.py + homeassistant/components/bom/sensor.py + homeassistant/components/bom/weather.py + homeassistant/components/braviatv/media_player.py + homeassistant/components/broadlink/remote.py + homeassistant/components/broadlink/sensor.py + homeassistant/components/broadlink/switch.py + homeassistant/components/brottsplatskartan/sensor.py + homeassistant/components/browser/* + homeassistant/components/brunt/cover.py + homeassistant/components/bt_home_hub_5/device_tracker.py + homeassistant/components/bt_smarthub/device_tracker.py + homeassistant/components/buienradar/sensor.py + homeassistant/components/buienradar/util.py + homeassistant/components/buienradar/weather.py + homeassistant/components/caldav/calendar.py + homeassistant/components/canary/alarm_control_panel.py + homeassistant/components/canary/camera.py homeassistant/components/cast/* - homeassistant/components/*/cast.py - - homeassistant/components/cloudflare.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 - - homeassistant/components/eight_sleep.py - homeassistant/components/*/eight_sleep.py - - homeassistant/components/ecobee.py - homeassistant/components/*/ecobee.py - - homeassistant/components/edp_redy.py - homeassistant/components/*/edp_redy.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/switch/fritzbox.py - - homeassistant/components/ecovacs.py - homeassistant/components/*/ecovacs.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/cert_expiry/sensor.py + homeassistant/components/cert_expiry/helper.py + homeassistant/components/channels/* + homeassistant/components/cisco_ios/device_tracker.py + homeassistant/components/cisco_mobility_express/device_tracker.py + homeassistant/components/cisco_webex_teams/notify.py + homeassistant/components/ciscospark/notify.py + homeassistant/components/citybikes/sensor.py + homeassistant/components/clementine/media_player.py + homeassistant/components/clickatell/notify.py + homeassistant/components/clicksend/notify.py + homeassistant/components/clicksend_tts/notify.py + homeassistant/components/cloudflare/* + homeassistant/components/cmus/media_player.py + homeassistant/components/co2signal/* + homeassistant/components/coinbase/* + homeassistant/components/comed_hourly_pricing/sensor.py + homeassistant/components/comfoconnect/* + homeassistant/components/concord232/alarm_control_panel.py + homeassistant/components/concord232/binary_sensor.py + homeassistant/components/coolmaster/__init__.py + homeassistant/components/coolmaster/climate.py + homeassistant/components/coolmaster/const.py + homeassistant/components/cppm_tracker/device_tracker.py + homeassistant/components/cpuspeed/sensor.py + homeassistant/components/crimereports/sensor.py + homeassistant/components/cups/sensor.py + homeassistant/components/currencylayer/sensor.py + homeassistant/components/daikin/* + homeassistant/components/danfoss_air/* + homeassistant/components/darksky/weather.py + homeassistant/components/ddwrt/device_tracker.py + homeassistant/components/decora/light.py + homeassistant/components/decora_wifi/light.py + homeassistant/components/delijn/* + homeassistant/components/deluge/sensor.py + homeassistant/components/deluge/switch.py + homeassistant/components/denon/media_player.py + homeassistant/components/denonavr/media_player.py + homeassistant/components/deutsche_bahn/sensor.py + homeassistant/components/dht/sensor.py + homeassistant/components/digital_ocean/* + homeassistant/components/digitalloggers/switch.py + homeassistant/components/directv/media_player.py + homeassistant/components/discogs/sensor.py + homeassistant/components/discord/notify.py + homeassistant/components/dlib_face_detect/image_processing.py + homeassistant/components/dlib_face_identify/image_processing.py + homeassistant/components/dlink/switch.py + homeassistant/components/dlna_dmr/media_player.py + homeassistant/components/dnsip/sensor.py + homeassistant/components/dominos/* + homeassistant/components/doods/* + homeassistant/components/doorbird/* + homeassistant/components/dovado/* + homeassistant/components/downloader/* + homeassistant/components/dsmr_reader/* + homeassistant/components/dte_energy_bridge/sensor.py + homeassistant/components/dublin_bus_transport/sensor.py + homeassistant/components/duke_energy/sensor.py + homeassistant/components/dunehd/media_player.py + homeassistant/components/dwd_weather_warnings/sensor.py + homeassistant/components/dweet/* + homeassistant/components/ebox/sensor.py + homeassistant/components/ebusd/* + homeassistant/components/ecoal_boiler/* + homeassistant/components/ecobee/__init__.py + homeassistant/components/ecobee/binary_sensor.py + homeassistant/components/ecobee/climate.py + homeassistant/components/ecobee/notify.py + homeassistant/components/ecobee/sensor.py + homeassistant/components/ecobee/weather.py + homeassistant/components/econet/* + homeassistant/components/ecovacs/* + homeassistant/components/eddystone_temperature/sensor.py + homeassistant/components/edimax/switch.py + homeassistant/components/egardia/* + homeassistant/components/eight_sleep/* + homeassistant/components/eliqonline/sensor.py + homeassistant/components/elkm1/* + homeassistant/components/elv/* + homeassistant/components/emby/media_player.py + homeassistant/components/emoncms/sensor.py + homeassistant/components/emoncms_history/* + homeassistant/components/emulated_hue/upnp.py + homeassistant/components/enigma2/media_player.py + homeassistant/components/enocean/* + homeassistant/components/enphase_envoy/sensor.py + homeassistant/components/entur_public_transport/* + homeassistant/components/environment_canada/* + homeassistant/components/envirophat/sensor.py + homeassistant/components/envisalink/* + homeassistant/components/ephember/climate.py + homeassistant/components/epson/const.py + homeassistant/components/epson/media_player.py + homeassistant/components/epsonworkforce/sensor.py + homeassistant/components/eq3btsmart/climate.py + homeassistant/components/esphome/__init__.py + homeassistant/components/esphome/binary_sensor.py + homeassistant/components/esphome/camera.py + homeassistant/components/esphome/climate.py + homeassistant/components/esphome/cover.py + homeassistant/components/esphome/entry_data.py + homeassistant/components/esphome/fan.py + homeassistant/components/esphome/light.py + homeassistant/components/esphome/sensor.py + homeassistant/components/esphome/switch.py + homeassistant/components/essent/sensor.py + homeassistant/components/etherscan/sensor.py + homeassistant/components/eufy/* + homeassistant/components/everlights/light.py + homeassistant/components/evohome/* + homeassistant/components/familyhub/camera.py + homeassistant/components/fastdotcom/* + homeassistant/components/ffmpeg/camera.py + homeassistant/components/fibaro/* + homeassistant/components/filesize/sensor.py + homeassistant/components/fints/sensor.py + homeassistant/components/fitbit/sensor.py + homeassistant/components/fixer/sensor.py + homeassistant/components/fleetgo/device_tracker.py + homeassistant/components/flexit/climate.py + homeassistant/components/flic/binary_sensor.py + homeassistant/components/flock/notify.py + homeassistant/components/flume/* + homeassistant/components/flunearyou/sensor.py + homeassistant/components/flux_led/light.py + homeassistant/components/folder/sensor.py + homeassistant/components/folder_watcher/* + homeassistant/components/foobot/sensor.py + homeassistant/components/fortios/device_tracker.py + homeassistant/components/fortigate/* + homeassistant/components/foscam/camera.py + homeassistant/components/foscam/const.py + homeassistant/components/foursquare/* + homeassistant/components/free_mobile/notify.py + homeassistant/components/freebox/* + homeassistant/components/fritz/device_tracker.py + homeassistant/components/fritzbox/* + homeassistant/components/fritzbox_callmonitor/sensor.py + homeassistant/components/fritzbox_netmonitor/sensor.py + homeassistant/components/fritzdect/switch.py + homeassistant/components/fronius/sensor.py + homeassistant/components/frontier_silicon/media_player.py + homeassistant/components/futurenow/light.py + homeassistant/components/garadget/cover.py + homeassistant/components/gc100/* + homeassistant/components/geniushub/* + homeassistant/components/gearbest/sensor.py + homeassistant/components/geizhals/sensor.py + homeassistant/components/github/sensor.py + homeassistant/components/gitlab_ci/sensor.py + homeassistant/components/gitter/sensor.py + homeassistant/components/glances/__init__.py + homeassistant/components/glances/sensor.py + homeassistant/components/gntp/notify.py + homeassistant/components/goalfeed/* + homeassistant/components/gogogate2/cover.py + homeassistant/components/google/* + homeassistant/components/google_cloud/tts.py + homeassistant/components/google_maps/device_tracker.py + homeassistant/components/google_travel_time/sensor.py + homeassistant/components/gpmdp/media_player.py + homeassistant/components/gpsd/sensor.py + homeassistant/components/greeneye_monitor/* + homeassistant/components/greeneye_monitor/sensor.py + homeassistant/components/greenwave/light.py + homeassistant/components/group/notify.py + homeassistant/components/growatt_server/sensor.py + homeassistant/components/gstreamer/media_player.py + homeassistant/components/gtfs/sensor.py homeassistant/components/habitica/* - homeassistant/components/*/habitica.py - + homeassistant/components/hangouts/* homeassistant/components/hangouts/__init__.py homeassistant/components/hangouts/const.py homeassistant/components/hangouts/hangouts_bot.py homeassistant/components/hangouts/hangups_utils.py - homeassistant/components/hangouts/intents.py - homeassistant/components/*/hangouts.py - - homeassistant/components/hdmi_cec.py - homeassistant/components/*/hdmi_cec.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/huawei_lte.py - homeassistant/components/*/huawei_lte.py - - homeassistant/components/hydrawise.py - homeassistant/components/*/hydrawise.py - + homeassistant/components/harman_kardon_avr/media_player.py + homeassistant/components/harmony/* + homeassistant/components/haveibeenpwned/sensor.py + homeassistant/components/hdmi_cec/* + homeassistant/components/heatmiser/climate.py + homeassistant/components/hikvision/binary_sensor.py + homeassistant/components/hikvisioncam/switch.py + homeassistant/components/hisense_aehw4a1/* + homeassistant/components/hitron_coda/device_tracker.py + homeassistant/components/hive/* + homeassistant/components/hlk_sw16/* + homeassistant/components/homematic/* + homeassistant/components/homematic/climate.py + homeassistant/components/homematic/cover.py + homeassistant/components/homematic/notify.py + homeassistant/components/homeworks/* + homeassistant/components/honeywell/climate.py + homeassistant/components/hook/switch.py + homeassistant/components/horizon/media_player.py + homeassistant/components/hp_ilo/sensor.py + homeassistant/components/htu21d/sensor.py + homeassistant/components/huawei_lte/* + homeassistant/components/huawei_router/device_tracker.py + homeassistant/components/hue/light.py + homeassistant/components/hunterdouglas_powerview/scene.py + homeassistant/components/hydrawise/* + homeassistant/components/hyperion/light.py + homeassistant/components/ialarm/alarm_control_panel.py + homeassistant/components/iaqualink/binary_sensor.py + homeassistant/components/iaqualink/climate.py + homeassistant/components/iaqualink/light.py + homeassistant/components/iaqualink/sensor.py + homeassistant/components/iaqualink/switch.py + homeassistant/components/icloud/__init__.py + homeassistant/components/icloud/device_tracker.py + homeassistant/components/icloud/sensor.py + homeassistant/components/izone/climate.py + homeassistant/components/izone/discovery.py + homeassistant/components/izone/__init__.py + homeassistant/components/idteck_prox/* + homeassistant/components/ifttt/* + homeassistant/components/iglo/light.py homeassistant/components/ihc/* - homeassistant/components/*/ihc.py - + homeassistant/components/imap/sensor.py + homeassistant/components/imap_email_content/sensor.py + homeassistant/components/influxdb/sensor.py homeassistant/components/insteon/* - homeassistant/components/*/insteon.py - - homeassistant/components/insteon_local.py - - 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 - - homeassistant/components/joaoapps_join.py - homeassistant/components/*/joaoapps_join.py - - homeassistant/components/juicenet.py - homeassistant/components/*/juicenet.py - - homeassistant/components/kira.py - homeassistant/components/*/kira.py - - 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 - - homeassistant/components/lutron_caseta.py - homeassistant/components/*/lutron_caseta.py - - homeassistant/components/mailgun.py - homeassistant/components/*/mailgun.py - - homeassistant/components/matrix.py - homeassistant/components/*/matrix.py - - homeassistant/components/maxcube.py - homeassistant/components/*/maxcube.py - - homeassistant/components/mochad.py - homeassistant/components/*/mochad.py - - homeassistant/components/modbus.py - homeassistant/components/*/modbus.py - - homeassistant/components/mychevy.py - homeassistant/components/*/mychevy.py - + homeassistant/components/incomfort/* + homeassistant/components/intesishome/* + homeassistant/components/ios/* + homeassistant/components/iota/* + homeassistant/components/iperf3/* + homeassistant/components/iqvia/* + homeassistant/components/irish_rail_transport/sensor.py + homeassistant/components/iss/binary_sensor.py + homeassistant/components/isy994/* + homeassistant/components/itach/remote.py + homeassistant/components/itunes/media_player.py + homeassistant/components/joaoapps_join/* + homeassistant/components/juicenet/* + homeassistant/components/kaiterra/* + homeassistant/components/kankun/switch.py + homeassistant/components/keba/* + homeassistant/components/keenetic_ndms2/device_tracker.py + homeassistant/components/keyboard/* + homeassistant/components/keyboard_remote/* + homeassistant/components/kira/* + homeassistant/components/kiwi/lock.py + homeassistant/components/knx/* + homeassistant/components/knx/climate.py + homeassistant/components/knx/cover.py + homeassistant/components/kodi/__init__.py + homeassistant/components/kodi/const.py + homeassistant/components/kodi/media_player.py + homeassistant/components/kodi/notify.py + homeassistant/components/konnected/* + homeassistant/components/kwb/sensor.py + homeassistant/components/lacrosse/sensor.py + homeassistant/components/lametric/* + homeassistant/components/lannouncer/notify.py + homeassistant/components/lastfm/sensor.py + homeassistant/components/launch_library/sensor.py + homeassistant/components/lcn/* + homeassistant/components/lg_netcast/media_player.py + homeassistant/components/lg_soundbar/media_player.py + homeassistant/components/life360/* + homeassistant/components/lifx/* + homeassistant/components/lifx_cloud/scene.py + homeassistant/components/lifx_legacy/light.py + homeassistant/components/lightwave/* + homeassistant/components/limitlessled/light.py + homeassistant/components/linksys_smart/device_tracker.py + homeassistant/components/linky/__init__.py + homeassistant/components/linky/sensor.py + homeassistant/components/linode/* + homeassistant/components/linux_battery/sensor.py + homeassistant/components/lirc/* + homeassistant/components/liveboxplaytv/media_player.py + homeassistant/components/llamalab_automate/notify.py + homeassistant/components/lockitron/lock.py + homeassistant/components/logi_circle/__init__.py + homeassistant/components/logi_circle/camera.py + homeassistant/components/logi_circle/const.py + homeassistant/components/logi_circle/sensor.py + homeassistant/components/london_underground/sensor.py + homeassistant/components/loopenergy/sensor.py + homeassistant/components/luci/device_tracker.py + homeassistant/components/luftdaten/* + homeassistant/components/lupusec/* + homeassistant/components/lutron/* + homeassistant/components/lutron_caseta/* + homeassistant/components/lw12wifi/light.py + homeassistant/components/lyft/sensor.py + homeassistant/components/magicseaweed/sensor.py + homeassistant/components/mailgun/notify.py + homeassistant/components/map/* + homeassistant/components/mastodon/notify.py + homeassistant/components/matrix/* + homeassistant/components/maxcube/* + homeassistant/components/mcp23017/* + homeassistant/components/media_extractor/* + homeassistant/components/mediaroom/media_player.py + homeassistant/components/message_bird/notify.py + homeassistant/components/met/weather.py + homeassistant/components/meteo_france/* + homeassistant/components/meteoalarm/* + homeassistant/components/metoffice/sensor.py + homeassistant/components/metoffice/weather.py + homeassistant/components/microsoft/tts.py + homeassistant/components/miflora/sensor.py + homeassistant/components/mikrotik/* + homeassistant/components/mill/climate.py + homeassistant/components/mill/const.py + homeassistant/components/minio/* + homeassistant/components/mitemp_bt/sensor.py + homeassistant/components/mjpeg/camera.py + homeassistant/components/mobile_app/* + homeassistant/components/mochad/* + homeassistant/components/modbus/* + homeassistant/components/modem_callerid/sensor.py + homeassistant/components/mopar/* + homeassistant/components/mpchc/media_player.py + homeassistant/components/mpd/media_player.py + homeassistant/components/mqtt_room/sensor.py + homeassistant/components/msteams/notify.py + homeassistant/components/mvglive/sensor.py + homeassistant/components/mychevy/* + homeassistant/components/mycroft/* + homeassistant/components/mycroft/notify.py + homeassistant/components/myq/cover.py homeassistant/components/mysensors/* - homeassistant/components/*/mysensors.py - - homeassistant/components/neato.py - homeassistant/components/*/neato.py - - homeassistant/components/nest/__init__.py - homeassistant/components/*/nest.py - - homeassistant/components/netatmo.py - homeassistant/components/*/netatmo.py - - homeassistant/components/netgear_lte.py - homeassistant/components/*/netgear_lte.py - - homeassistant/components/octoprint.py - homeassistant/components/*/octoprint.py - - homeassistant/components/opencv.py - homeassistant/components/*/opencv.py - + homeassistant/components/mystrom/binary_sensor.py + homeassistant/components/mystrom/light.py + homeassistant/components/mystrom/switch.py + homeassistant/components/n26/* + homeassistant/components/nad/media_player.py + homeassistant/components/nanoleaf/light.py + homeassistant/components/neato/camera.py + homeassistant/components/neato/sensor.py + homeassistant/components/neato/switch.py + homeassistant/components/neato/vacuum.py + homeassistant/components/nederlandse_spoorwegen/sensor.py + homeassistant/components/nello/lock.py + homeassistant/components/nest/* + homeassistant/components/netatmo/* + homeassistant/components/netatmo_public/sensor.py + homeassistant/components/netdata/sensor.py + homeassistant/components/netgear/device_tracker.py + homeassistant/components/netgear_lte/* + homeassistant/components/netio/switch.py + homeassistant/components/neurio_energy/sensor.py + homeassistant/components/nfandroidtv/notify.py + homeassistant/components/niko_home_control/light.py + homeassistant/components/nilu/air_quality.py + homeassistant/components/nissan_leaf/* + homeassistant/components/nmap_tracker/device_tracker.py + homeassistant/components/nmbs/sensor.py + homeassistant/components/notion/binary_sensor.py + homeassistant/components/notion/sensor.py + homeassistant/components/noaa_tides/sensor.py + homeassistant/components/norway_air/air_quality.py + homeassistant/components/nsw_fuel_station/sensor.py + homeassistant/components/nuimo_controller/* + homeassistant/components/nuki/lock.py + homeassistant/components/nut/sensor.py + homeassistant/components/nx584/alarm_control_panel.py + homeassistant/components/nzbget/__init__.py + homeassistant/components/nzbget/sensor.py + homeassistant/components/obihai/* + homeassistant/components/octoprint/* + homeassistant/components/oem/climate.py + homeassistant/components/oasa_telematics/sensor.py + homeassistant/components/ohmconnect/sensor.py + homeassistant/components/ombi/* + homeassistant/components/onewire/sensor.py + homeassistant/components/onkyo/media_player.py + homeassistant/components/onvif/camera.py + homeassistant/components/opencv/* + homeassistant/components/openevse/sensor.py + homeassistant/components/openexchangerates/sensor.py + homeassistant/components/opengarage/cover.py + homeassistant/components/openhome/media_player.py + homeassistant/components/opensensemap/air_quality.py + homeassistant/components/opensky/sensor.py + homeassistant/components/opentherm_gw/__init__.py + homeassistant/components/opentherm_gw/binary_sensor.py + homeassistant/components/opentherm_gw/climate.py + homeassistant/components/opentherm_gw/sensor.py homeassistant/components/openuv/__init__.py - homeassistant/components/*/openuv.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/* - homeassistant/components/*/rainmachine.py - - homeassistant/components/raspihats.py - homeassistant/components/*/raspihats.py - - homeassistant/components/rfxtrx.py - homeassistant/components/*/rfxtrx.py - - homeassistant/components/rpi_gpio.py - homeassistant/components/*/rpi_gpio.py - - 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/sisyphus.py - homeassistant/components/*/sisyphus.py - - homeassistant/components/skybell.py - homeassistant/components/*/skybell.py - - homeassistant/components/smappee.py - homeassistant/components/*/smappee.py - - homeassistant/components/sonos/__init__.py - homeassistant/components/*/sonos.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 - - homeassistant/components/tellstick.py - homeassistant/components/*/tellstick.py - - 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 - - homeassistant/components/twilio.py - 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 - - homeassistant/components/velbus.py - homeassistant/components/*/velbus.py - - homeassistant/components/velux.py - homeassistant/components/*/velux.py - - homeassistant/components/vera.py - homeassistant/components/*/vera.py - - homeassistant/components/verisure.py - homeassistant/components/*/verisure.py - - 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/* - homeassistant/components/*/wink.py - - homeassistant/components/wirelesstag.py - homeassistant/components/*/wirelesstag.py - - homeassistant/components/xiaomi_aqara.py - homeassistant/components/*/xiaomi_aqara.py - - homeassistant/components/*/xiaomi_miio.py - - homeassistant/components/zabbix.py - homeassistant/components/*/zabbix.py - - homeassistant/components/zha/__init__.py - homeassistant/components/zha/const.py - homeassistant/components/*/zha.py - - homeassistant/components/zigbee.py - homeassistant/components/*/zigbee.py - - homeassistant/components/zoneminder.py - homeassistant/components/*/zoneminder.py - - homeassistant/components/tuya.py - homeassistant/components/*/tuya.py - - homeassistant/components/spider.py - homeassistant/components/*/spider.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/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 - homeassistant/components/alarm_control_panel/totalconnect.py - homeassistant/components/alarm_control_panel/yale_smart_alarm.py - homeassistant/components/apiai.py - homeassistant/components/binary_sensor/arest.py - homeassistant/components/binary_sensor/concord232.py - homeassistant/components/binary_sensor/flic.py - homeassistant/components/binary_sensor/hikvision.py - homeassistant/components/binary_sensor/iss.py - homeassistant/components/binary_sensor/mystrom.py - homeassistant/components/binary_sensor/ping.py - homeassistant/components/binary_sensor/rest.py - homeassistant/components/binary_sensor/tapsaff.py - homeassistant/components/binary_sensor/uptimerobot.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/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/xiaomi.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/opentherm_gw.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/climate/zhong_hong.py - homeassistant/components/cover/aladdin_connect.py - homeassistant/components/cover/brunt.py - homeassistant/components/cover/garadget.py - homeassistant/components/cover/gogogate2.py - homeassistant/components/cover/homematic.py - homeassistant/components/cover/knx.py - homeassistant/components/cover/myq.py - homeassistant/components/cover/opengarage.py - homeassistant/components/cover/rpi_gpio.py - homeassistant/components/cover/ryobi_gdo.py - homeassistant/components/cover/scsgate.py - homeassistant/components/device_tracker/actiontec.py - homeassistant/components/device_tracker/aruba.py - homeassistant/components/device_tracker/asuswrt.py - homeassistant/components/device_tracker/automatic.py - homeassistant/components/device_tracker/bbox.py - homeassistant/components/device_tracker/bluetooth_le_tracker.py - 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/freebox.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 - homeassistant/components/device_tracker/mikrotik.py - homeassistant/components/device_tracker/netgear.py - homeassistant/components/device_tracker/nmap_tracker.py - homeassistant/components/device_tracker/ping.py - homeassistant/components/device_tracker/ritassist.py - 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/tplink.py - homeassistant/components/device_tracker/trackr.py - homeassistant/components/device_tracker/ubus.py - homeassistant/components/downloader.py - homeassistant/components/emoncms_history.py - homeassistant/components/emulated_hue/upnp.py - homeassistant/components/fan/mqtt.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_remote.py - homeassistant/components/keyboard.py - homeassistant/components/light/avion.py - homeassistant/components/light/blinksticklight.py - homeassistant/components/light/blinkt.py - homeassistant/components/light/decora_wifi.py - homeassistant/components/light/decora.py - homeassistant/components/light/flux_led.py - homeassistant/components/light/futurenow.py - homeassistant/components/light/greenwave.py - homeassistant/components/light/hue.py - homeassistant/components/light/hyperion.py - homeassistant/components/light/iglo.py - homeassistant/components/light/lifx_legacy.py - homeassistant/components/light/lifx.py - homeassistant/components/light/limitlessled.py - homeassistant/components/light/lw12wifi.py - homeassistant/components/light/mystrom.py - homeassistant/components/light/nanoleaf_aurora.py - homeassistant/components/light/osramlightify.py - homeassistant/components/light/piglow.py - homeassistant/components/light/rpi_gpio_pwm.py - 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/yeelight.py - homeassistant/components/light/yeelightsunflower.py - homeassistant/components/light/zengge.py - homeassistant/components/lirc.py - homeassistant/components/lock/kiwi.py - homeassistant/components/lock/lockitron.py - homeassistant/components/lock/nello.py - homeassistant/components/lock/nuki.py - homeassistant/components/lock/sesame.py - homeassistant/components/map.py - homeassistant/components/media_extractor.py - homeassistant/components/media_player/anthemav.py - homeassistant/components/media_player/aquostv.py - homeassistant/components/media_player/bluesound.py - homeassistant/components/media_player/braviatv.py - homeassistant/components/media_player/channels.py - homeassistant/components/media_player/clementine.py - homeassistant/components/media_player/cmus.py - homeassistant/components/media_player/denon.py - homeassistant/components/media_player/denonavr.py - homeassistant/components/media_player/directv.py - homeassistant/components/media_player/dlna_dmr.py - homeassistant/components/media_player/dunehd.py - homeassistant/components/media_player/emby.py - homeassistant/components/media_player/epson.py - homeassistant/components/media_player/firetv.py - homeassistant/components/media_player/frontier_silicon.py - homeassistant/components/media_player/gpmdp.py - homeassistant/components/media_player/gstreamer.py - homeassistant/components/media_player/horizon.py - homeassistant/components/media_player/itunes.py - 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 - homeassistant/components/media_player/nadtcp.py - homeassistant/components/media_player/onkyo.py - homeassistant/components/media_player/openhome.py - homeassistant/components/media_player/panasonic_viera.py - homeassistant/components/media_player/pandora.py - homeassistant/components/media_player/philips_js.py - homeassistant/components/media_player/pioneer.py - homeassistant/components/media_player/pjlink.py - homeassistant/components/media_player/plex.py - homeassistant/components/media_player/roku.py - homeassistant/components/media_player/russound_rio.py - homeassistant/components/media_player/russound_rnet.py - homeassistant/components/media_player/snapcast.py - homeassistant/components/media_player/songpal.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/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.py - homeassistant/components/notify/clicksend_tts.py - homeassistant/components/notify/discord.py - homeassistant/components/notify/flock.py - homeassistant/components/notify/free_mobile.py - homeassistant/components/notify/gntp.py - homeassistant/components/notify/group.py - homeassistant/components/notify/hipchat.py - homeassistant/components/notify/instapush.py - homeassistant/components/notify/kodi.py - homeassistant/components/notify/lannouncer.py - homeassistant/components/notify/llamalab_automate.py - homeassistant/components/notify/mastodon.py - homeassistant/components/notify/message_bird.py - homeassistant/components/notify/mycroft.py - homeassistant/components/notify/nfandroidtv.py - homeassistant/components/notify/prowl.py - homeassistant/components/notify/pushbullet.py - homeassistant/components/notify/pushetta.py - 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/openuv/binary_sensor.py + homeassistant/components/openuv/sensor.py + homeassistant/components/openweathermap/sensor.py + homeassistant/components/openweathermap/weather.py + homeassistant/components/opple/light.py + homeassistant/components/orangepi_gpio/* + homeassistant/components/oru/* + homeassistant/components/orvibo/switch.py + homeassistant/components/osramlightify/light.py + homeassistant/components/otp/sensor.py + homeassistant/components/owlet/* + homeassistant/components/panasonic_bluray/media_player.py + homeassistant/components/panasonic_viera/media_player.py + homeassistant/components/pandora/media_player.py + homeassistant/components/pcal9535a/* + homeassistant/components/pencom/switch.py + homeassistant/components/philips_js/media_player.py + homeassistant/components/pi_hole/sensor.py + homeassistant/components/picotts/tts.py + homeassistant/components/piglow/light.py + homeassistant/components/pilight/* + homeassistant/components/ping/binary_sensor.py + homeassistant/components/ping/device_tracker.py + homeassistant/components/pioneer/media_player.py + homeassistant/components/pjlink/media_player.py + homeassistant/components/plaato/* + homeassistant/components/plex/__init__.py + homeassistant/components/plex/media_player.py + homeassistant/components/plex/sensor.py + homeassistant/components/plex/server.py + homeassistant/components/plex/websockets.py + homeassistant/components/plugwise/* + homeassistant/components/plum_lightpad/* + homeassistant/components/pocketcasts/sensor.py + homeassistant/components/point/* + homeassistant/components/postnl/sensor.py + homeassistant/components/prezzibenzina/sensor.py + homeassistant/components/proliphix/climate.py + homeassistant/components/prometheus/* + homeassistant/components/prowl/notify.py + homeassistant/components/proxmoxve/* + homeassistant/components/proxy/camera.py + homeassistant/components/ptvsd/* + homeassistant/components/pulseaudio_loopback/switch.py + homeassistant/components/pushbullet/notify.py + homeassistant/components/pushbullet/sensor.py + homeassistant/components/pushetta/notify.py + homeassistant/components/pushover/notify.py + homeassistant/components/pushsafer/notify.py + homeassistant/components/pvoutput/sensor.py + homeassistant/components/pyload/sensor.py + homeassistant/components/qbittorrent/sensor.py + homeassistant/components/qnap/sensor.py + homeassistant/components/qrcode/image_processing.py + homeassistant/components/quantum_gateway/device_tracker.py + homeassistant/components/qwikswitch/* + homeassistant/components/rachio/* + homeassistant/components/radarr/sensor.py + homeassistant/components/radiotherm/climate.py + homeassistant/components/rainbird/* + homeassistant/components/rainbird/sensor.py + homeassistant/components/rainbird/switch.py + homeassistant/components/raincloud/* + homeassistant/components/rainmachine/__init__.py + homeassistant/components/rainmachine/binary_sensor.py + homeassistant/components/rainmachine/sensor.py + homeassistant/components/rainmachine/switch.py + homeassistant/components/rainforest_eagle/sensor.py + homeassistant/components/raspihats/* + homeassistant/components/raspyrfm/* + homeassistant/components/recollect_waste/sensor.py + homeassistant/components/recswitch/switch.py + homeassistant/components/reddit/* + homeassistant/components/rejseplanen/sensor.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 - homeassistant/components/sensor/bh1750.py - 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/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/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/duke_energy.py - homeassistant/components/sensor/dwd_weather_warnings.py - homeassistant/components/sensor/ebox.py - homeassistant/components/sensor/eddystone_temperature.py - homeassistant/components/sensor/eliqonline.py - homeassistant/components/sensor/emoncms.py - homeassistant/components/sensor/enphase_envoy.py - homeassistant/components/sensor/envirophat.py - homeassistant/components/sensor/etherscan.py - homeassistant/components/sensor/fastdotcom.py - homeassistant/components/sensor/fedex.py - homeassistant/components/sensor/filesize.py - homeassistant/components/sensor/fints.py - homeassistant/components/sensor/fitbit.py - homeassistant/components/sensor/fixer.py - homeassistant/components/sensor/folder.py - homeassistant/components/sensor/foobot.py - homeassistant/components/sensor/fritzbox_callmonitor.py - homeassistant/components/sensor/fritzbox_netmonitor.py - homeassistant/components/sensor/gearbest.py - homeassistant/components/sensor/geizhals.py - homeassistant/components/sensor/gitter.py - homeassistant/components/sensor/glances.py - homeassistant/components/sensor/google_travel_time.py - homeassistant/components/sensor/gpsd.py - homeassistant/components/sensor/gtfs.py - homeassistant/components/sensor/haveibeenpwned.py - homeassistant/components/sensor/hp_ilo.py - homeassistant/components/sensor/htu21d.py - homeassistant/components/sensor/imap_email_content.py - homeassistant/components/sensor/imap.py - homeassistant/components/sensor/influxdb.py - homeassistant/components/sensor/iperf3.py - homeassistant/components/sensor/irish_rail_transport.py - homeassistant/components/sensor/kwb.py - homeassistant/components/sensor/lacrosse.py - 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/magicseaweed.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/netdata_public.py - homeassistant/components/sensor/neurio_energy.py - homeassistant/components/sensor/noaa_tides.py - homeassistant/components/sensor/nsw_fuel_station.py - homeassistant/components/sensor/nut.py - homeassistant/components/sensor/nzbget.py - homeassistant/components/sensor/ohmconnect.py - homeassistant/components/sensor/onewire.py - homeassistant/components/sensor/openevse.py - homeassistant/components/sensor/openexchangerates.py - homeassistant/components/sensor/opensky.py - homeassistant/components/sensor/openweathermap.py - homeassistant/components/sensor/otp.py - 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/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/starlingbank.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/volkszaehler.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/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 - homeassistant/components/switch/fritzdect.py - homeassistant/components/switch/hikvisioncam.py - homeassistant/components/switch/hook.py - homeassistant/components/switch/kankun.py - homeassistant/components/switch/mystrom.py - homeassistant/components/switch/netio.py - homeassistant/components/switch/orvibo.py - homeassistant/components/switch/pulseaudio_loopback.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/switchbot.py - homeassistant/components/switch/switchmate.py - homeassistant/components/switch/telnet.py - homeassistant/components/switch/tplink.py - homeassistant/components/switch/transmission.py - homeassistant/components/switch/vesync.py + homeassistant/components/repetier/__init__.py + homeassistant/components/repetier/sensor.py + homeassistant/components/remote_rpi_gpio/* + homeassistant/components/rest/binary_sensor.py + homeassistant/components/rest/notify.py + homeassistant/components/rest/switch.py + homeassistant/components/rfxtrx/* + homeassistant/components/ring/camera.py + homeassistant/components/ripple/sensor.py + homeassistant/components/rocketchat/notify.py + homeassistant/components/roku/* + homeassistant/components/roomba/vacuum.py + homeassistant/components/route53/* + homeassistant/components/rova/sensor.py + homeassistant/components/rpi_camera/camera.py + homeassistant/components/rpi_gpio/* + homeassistant/components/rpi_gpio/cover.py + homeassistant/components/rpi_gpio_pwm/light.py + homeassistant/components/rpi_pfio/* + homeassistant/components/rpi_rf/switch.py + homeassistant/components/rtorrent/sensor.py + homeassistant/components/russound_rio/media_player.py + homeassistant/components/russound_rnet/media_player.py + homeassistant/components/sabnzbd/* + homeassistant/components/saj/sensor.py + homeassistant/components/satel_integra/* + homeassistant/components/scrape/sensor.py + homeassistant/components/scsgate/* + homeassistant/components/scsgate/cover.py + homeassistant/components/sendgrid/notify.py + homeassistant/components/sense/* + homeassistant/components/sensehat/light.py + homeassistant/components/sensehat/sensor.py + homeassistant/components/sensibo/climate.py + homeassistant/components/serial/sensor.py + homeassistant/components/serial_pm/sensor.py + homeassistant/components/sesame/lock.py + homeassistant/components/seven_segments/image_processing.py + homeassistant/components/seventeentrack/sensor.py + homeassistant/components/shiftr/* + homeassistant/components/shodan/sensor.py + homeassistant/components/sht31/sensor.py + homeassistant/components/sigfox/sensor.py + homeassistant/components/signal_messenger/__init__.py + homeassistant/components/signal_messenger/notify.py + homeassistant/components/simplepush/notify.py + homeassistant/components/simplisafe/__init__.py + homeassistant/components/simplisafe/alarm_control_panel.py + homeassistant/components/simplisafe/lock.py + homeassistant/components/simulated/sensor.py + homeassistant/components/sisyphus/* + homeassistant/components/sky_hub/device_tracker.py + homeassistant/components/skybeacon/sensor.py + homeassistant/components/skybell/* + homeassistant/components/slack/notify.py + homeassistant/components/sinch/* + homeassistant/components/slide/* + homeassistant/components/sma/sensor.py + homeassistant/components/smappee/* + homeassistant/components/smarty/* + homeassistant/components/smarthab/* + homeassistant/components/smtp/notify.py + homeassistant/components/snapcast/media_player.py + homeassistant/components/snmp/* + homeassistant/components/sochain/sensor.py + homeassistant/components/socialblade/sensor.py + homeassistant/components/solaredge/__init__.py + homeassistant/components/solaredge/sensor.py + homeassistant/components/solaredge_local/sensor.py + homeassistant/components/solarlog/* + homeassistant/components/solax/sensor.py + homeassistant/components/soma/cover.py + homeassistant/components/soma/__init__.py + homeassistant/components/somfy/* + homeassistant/components/somfy_mylink/* + homeassistant/components/sonarr/sensor.py + homeassistant/components/songpal/* + homeassistant/components/sonos/* + homeassistant/components/sony_projector/switch.py + homeassistant/components/spc/* + homeassistant/components/speedtestdotnet/* + homeassistant/components/spider/* + homeassistant/components/spotcrime/sensor.py + homeassistant/components/spotify/media_player.py + homeassistant/components/squeezebox/* + homeassistant/components/starline/* + homeassistant/components/starlingbank/sensor.py + homeassistant/components/steam_online/sensor.py + homeassistant/components/stiebel_eltron/* + homeassistant/components/streamlabswater/* + homeassistant/components/suez_water/* + homeassistant/components/supervisord/sensor.py + homeassistant/components/swiss_hydrological_data/sensor.py + homeassistant/components/swiss_public_transport/sensor.py + homeassistant/components/swisscom/device_tracker.py + homeassistant/components/switchbot/switch.py + homeassistant/components/switcher_kis/switch.py + homeassistant/components/switchmate/switch.py + homeassistant/components/syncthru/sensor.py + homeassistant/components/synology/camera.py + homeassistant/components/synology_chat/notify.py + homeassistant/components/synology_srm/device_tracker.py + homeassistant/components/synologydsm/sensor.py + homeassistant/components/syslog/notify.py + homeassistant/components/systemmonitor/sensor.py + homeassistant/components/tado/* + homeassistant/components/tado/device_tracker.py + homeassistant/components/tahoma/* + homeassistant/components/tank_utility/sensor.py + homeassistant/components/tapsaff/binary_sensor.py + homeassistant/components/tautulli/sensor.py + homeassistant/components/ted5000/sensor.py + homeassistant/components/telegram/notify.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/vacuum/mqtt.py - homeassistant/components/vacuum/roomba.py - homeassistant/components/watson_iot.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/zamg.py - homeassistant/components/zeroconf.py + homeassistant/components/tellduslive/* + homeassistant/components/tellstick/* + homeassistant/components/telnet/switch.py + homeassistant/components/temper/sensor.py + homeassistant/components/tensorflow/image_processing.py + homeassistant/components/tesla/* + homeassistant/components/tfiac/climate.py + homeassistant/components/thermoworks_smoke/sensor.py + homeassistant/components/thethingsnetwork/* + homeassistant/components/thingspeak/* + homeassistant/components/thinkingcleaner/* + homeassistant/components/thomson/device_tracker.py + homeassistant/components/tibber/* + homeassistant/components/tikteck/light.py + homeassistant/components/tile/device_tracker.py + homeassistant/components/time_date/sensor.py + homeassistant/components/todoist/calendar.py + homeassistant/components/todoist/const.py + homeassistant/components/tof/sensor.py + homeassistant/components/tomato/device_tracker.py + homeassistant/components/toon/* + homeassistant/components/torque/sensor.py + homeassistant/components/totalconnect/* + homeassistant/components/touchline/climate.py + homeassistant/components/tplink/device_tracker.py + homeassistant/components/tplink/switch.py + homeassistant/components/tplink_lte/* + homeassistant/components/traccar/device_tracker.py + homeassistant/components/traccar/const.py + homeassistant/components/trackr/device_tracker.py + homeassistant/components/tradfri/* + homeassistant/components/tradfri/light.py + homeassistant/components/tradfri/cover.py + homeassistant/components/tradfri/base_class.py + homeassistant/components/trafikverket_train/sensor.py + homeassistant/components/trafikverket_weatherstation/sensor.py + homeassistant/components/transmission/sensor.py + homeassistant/components/transmission/switch.py + homeassistant/components/transmission/const.py + homeassistant/components/transmission/errors.py + homeassistant/components/travisci/sensor.py + homeassistant/components/tuya/* + homeassistant/components/twentemilieu/const.py + homeassistant/components/twentemilieu/sensor.py + homeassistant/components/twilio_call/notify.py + homeassistant/components/twilio_sms/notify.py + homeassistant/components/twitch/sensor.py + homeassistant/components/twitter/notify.py + homeassistant/components/ubee/device_tracker.py + homeassistant/components/uber/sensor.py + homeassistant/components/ubus/device_tracker.py + homeassistant/components/ue_smart_radio/media_player.py + homeassistant/components/unifiled/* + homeassistant/components/upcloud/* + homeassistant/components/upnp/* + homeassistant/components/upc_connect/* + homeassistant/components/uptimerobot/binary_sensor.py + homeassistant/components/uscis/sensor.py + homeassistant/components/vallox/* + homeassistant/components/vasttrafik/sensor.py + homeassistant/components/velbus/__init__.py + homeassistant/components/velbus/binary_sensor.py + homeassistant/components/velbus/climate.py + homeassistant/components/velbus/const.py + homeassistant/components/velbus/cover.py + homeassistant/components/velbus/sensor.py + homeassistant/components/velbus/switch.py + homeassistant/components/velux/* + homeassistant/components/venstar/climate.py + homeassistant/components/verisure/* + homeassistant/components/versasense/* + homeassistant/components/vesync/__init__.py + homeassistant/components/vesync/common.py + homeassistant/components/vesync/const.py + homeassistant/components/vesync/switch.py + homeassistant/components/viaggiatreno/sensor.py + homeassistant/components/vicare/* + homeassistant/components/vivotek/camera.py + homeassistant/components/vizio/media_player.py + homeassistant/components/vlc/media_player.py + homeassistant/components/vlc_telnet/media_player.py + homeassistant/components/volkszaehler/sensor.py + homeassistant/components/volumio/media_player.py + homeassistant/components/volvooncall/* + homeassistant/components/w800rf32/* + homeassistant/components/waqi/sensor.py + homeassistant/components/waterfurnace/* + homeassistant/components/watson_iot/* + homeassistant/components/watson_tts/tts.py + homeassistant/components/waze_travel_time/sensor.py + homeassistant/components/webostv/* + homeassistant/components/wemo/* + homeassistant/components/whois/sensor.py + homeassistant/components/wink/* + homeassistant/components/wirelesstag/* + homeassistant/components/worldtidesinfo/sensor.py + homeassistant/components/worxlandroid/sensor.py + homeassistant/components/wunderlist/* + homeassistant/components/wwlln/__init__.py + homeassistant/components/wwlln/geo_location.py + homeassistant/components/x10/light.py + homeassistant/components/xbox_live/sensor.py + homeassistant/components/xeoma/camera.py + homeassistant/components/xfinity/device_tracker.py + homeassistant/components/xiaomi/camera.py + homeassistant/components/xiaomi_aqara/* + homeassistant/components/xiaomi_miio/* + homeassistant/components/xiaomi_tv/media_player.py + homeassistant/components/xmpp/notify.py + homeassistant/components/xs1/* + homeassistant/components/yale_smart_alarm/alarm_control_panel.py + homeassistant/components/yamaha_musiccast/media_player.py + homeassistant/components/yandex_transport/* + homeassistant/components/yeelight/* + homeassistant/components/yeelightsunflower/light.py + homeassistant/components/yi/camera.py + homeassistant/components/zabbix/* + homeassistant/components/zamg/sensor.py + homeassistant/components/zamg/weather.py + homeassistant/components/zengge/light.py + homeassistant/components/zeroconf/* + homeassistant/components/zestimate/sensor.py + homeassistant/components/zha/__init__.py + homeassistant/components/zha/api.py + homeassistant/components/zha/const.py + homeassistant/components/zha/core/channels/* + homeassistant/components/zha/core/const.py + homeassistant/components/zha/core/device.py + homeassistant/components/zha/core/gateway.py + homeassistant/components/zha/core/helpers.py + homeassistant/components/zha/core/patches.py + homeassistant/components/zha/core/registries.py + homeassistant/components/zha/device_entity.py + homeassistant/components/zha/entity.py + homeassistant/components/zha/light.py + homeassistant/components/zha/sensor.py + homeassistant/components/zhong_hong/climate.py + homeassistant/components/zigbee/* + homeassistant/components/ziggo_mediabox_xl/media_player.py + homeassistant/components/zoneminder/* + homeassistant/components/supla/* homeassistant/components/zwave/util.py [report] diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 000000000..7b56b66d0 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,33 @@ +{ + "name": "Home Assistant Dev", + "context": "..", + "dockerFile": "../Dockerfile.dev", + "postCreateCommand": "mkdir -p config && pip3 install -e .", + "appPort": 8123, + "runArgs": ["-e", "GIT_EDITOR=code --wait"], + "extensions": [ + "ms-python.python", + "visualstudioexptteam.vscodeintellicode", + "ms-azure-devops.azure-pipelines", + "redhat.vscode-yaml", + "esbenp.prettier-vscode" + ], + "settings": { + "python.pythonPath": "/usr/local/bin/python", + "python.linting.pylintEnabled": true, + "python.linting.enabled": true, + "python.formatting.provider": "black", + "editor.formatOnPaste": false, + "editor.formatOnSave": true, + "editor.formatOnType": true, + "files.trimTrailingWhitespace": true, + "terminal.integrated.shell.linux": "/bin/bash", + "yaml.customTags": [ + "!secret scalar", + "!include_dir_named scalar", + "!include_dir_list scalar", + "!include_dir_merge_list scalar", + "!include_dir_merge_named scalar" + ] + } +} diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index 8772a136e..1af7fc049 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -1,7 +1,9 @@ @@ -21,9 +23,9 @@ Please provide details about your environment. --> -**Component/platform:** +**Integration:** diff --git a/.github/ISSUE_TEMPLATE/Bug_report.md b/.github/ISSUE_TEMPLATE/Bug_report.md index 2c418c6f6..885164d7a 100644 --- a/.github/ISSUE_TEMPLATE/Bug_report.md +++ b/.github/ISSUE_TEMPLATE/Bug_report.md @@ -7,7 +7,9 @@ about: Create a report to help us improve @@ -27,9 +29,9 @@ about: Create a report to help us improve Please provide details about your environment. --> -**Component/platform:** +**Integration:** diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index c2f65f9a8..474dff86b 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,9 +1,13 @@ +## Breaking Change: + + + ## Description: **Related issue (if applicable):** fixes # -**Pull request in [home-assistant.github.io](https://github.com/home-assistant/home-assistant.github.io) with documentation (if applicable):** home-assistant/home-assistant.github.io# +**Pull request with documentation for [home-assistant.io](https://github.com/home-assistant/home-assistant.io) (if applicable):** home-assistant/home-assistant.io# ## Example entry for `configuration.yaml` (if applicable): ```yaml @@ -13,18 +17,19 @@ ## Checklist: - [ ] The code change is tested and works locally. - [ ] Local tests pass with `tox`. **Your PR cannot be merged unless tests pass** + - [ ] There is no commented out code in this PR. + - [ ] I have followed the [development checklist][dev-checklist] 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) + - [ ] Documentation added/updated in [home-assistant.io](https://github.com/home-assistant/home-assistant.io) If the code communicates with devices, web services, or third-party tools: - - [ ] New dependencies have been added to the `REQUIREMENTS` variable ([example][ex-requir]). - - [ ] New dependencies are only imported inside functions that use them ([example][ex-import]). - - [ ] New or updated dependencies have been added to `requirements_all.txt` by running `script/gen_requirements_all.py`. - - [ ] New files were added to `.coveragerc`. + - [ ] [_The manifest file_][manifest-docs] has all fields filled out correctly. Update and include derived files by running `python3 -m script.hassfest`. + - [ ] New or updated dependencies have been added to `requirements_all.txt` by running `python3 -m script.gen_requirements_all`. + - [ ] Untested files have been added to `.coveragerc`. If the code does not interact with devices: - [ ] 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 -[ex-import]: https://github.com/home-assistant/home-assistant/blob/dev/homeassistant/components/keyboard.py#L54 +[dev-checklist]: https://developers.home-assistant.io/docs/en/development_checklist.html +[manifest-docs]: https://developers.home-assistant.io/docs/en/creating_integration_manifest.html diff --git a/.github/lock.yml b/.github/lock.yml new file mode 100644 index 000000000..93666bc6e --- /dev/null +++ b/.github/lock.yml @@ -0,0 +1,27 @@ +# Configuration for Lock Threads - https://github.com/dessant/lock-threads + +# Number of days of inactivity before a closed issue or pull request is locked +daysUntilLock: 1 + +# Skip issues and pull requests created before a given timestamp. Timestamp must +# follow ISO 8601 (`YYYY-MM-DD`). Set to `false` to disable +skipCreatedBefore: 2019-07-01 + +# Issues and pull requests with these labels will be ignored. Set to `[]` to disable +exemptLabels: [] + +# Label to add before locking, such as `outdated`. Set to `false` to disable +lockLabel: false + +# Comment to post before locking. Set to `false` to disable +lockComment: false + +# Assign `resolved` as the reason for locking. Set to `false` to disable +setLockReason: false + +# Limit to only `issues` or `pulls` +only: pulls + +# Optionally, specify configuration settings just for `issues` or `pulls` +issues: + daysUntilLock: 30 diff --git a/.github/stale.yml b/.github/stale.yml new file mode 100644 index 000000000..44cd95e1f --- /dev/null +++ b/.github/stale.yml @@ -0,0 +1,55 @@ +# Configuration for probot-stale - https://github.com/probot/stale + +# Number of days of inactivity before an Issue or Pull Request becomes stale +daysUntilStale: 90 + +# Number of days of inactivity before an Issue or Pull Request with the stale label is closed. +# Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale. +daysUntilClose: 7 + +# Only issues or pull requests with all of these labels are check if stale. Defaults to `[]` (disabled) +onlyLabels: [] + +# Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable +exemptLabels: + - under investigation + - Help wanted + +# Set to true to ignore issues in a project (defaults to false) +exemptProjects: true + +# Set to true to ignore issues in a milestone (defaults to false) +exemptMilestones: true + +# Set to true to ignore issues with an assignee (defaults to false) +exemptAssignees: false + +# Label to use when marking as stale +staleLabel: stale + +# Comment to post when marking as stale. Set to `false` to disable +markComment: > + There hasn't been any activity on this issue recently. Due to the high number + of incoming GitHub notifications, we have to clean some of the old issues, + as many of them have already been resolved with the latest updates. + + Please make sure to update to the latest Home Assistant version and check + if that solves the issue. Let us know if that works for you by adding a + comment 👍 + + This issue now has been marked as stale and will be closed if no further + activity occurs. Thank you for your contributions. + +# Comment to post when removing the stale label. +# unmarkComment: > +# Your comment here. + +# Comment to post when closing a stale Issue or Pull Request. +# closeComment: > +# Your comment here. + +# Limit the number of actions per hour, from 1-30. Default is 30 +limitPerRun: 30 + +# Limit to only `issues` or `pulls` +only: issues diff --git a/.gitignore b/.gitignore index c2b0d964a..2473aeb4b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,13 @@ config/* +config2/* tests/testing_config/deps tests/testing_config/home-assistant.log +# hass-release +data/ +.token + # Hide sublime text stuff *.sublime-project *.sublime-workspace @@ -45,6 +50,7 @@ develop-eggs .installed.cfg lib lib64 +pip-wheel-metadata # Logs *.log @@ -53,8 +59,12 @@ pip-log.txt # Unit test / coverage reports .coverage .tox +coverage.xml nosetests.xml htmlcov/ +test-reports/ +test-results.xml +test-output.xml # Translations *.mo @@ -78,11 +88,12 @@ venv .venv Pipfile* share/* +Scripts/ # vimmy stuff *.swp *.swo - +tags ctags.tmp # vagrant stuff @@ -91,7 +102,10 @@ virtualization/vagrant/.vagrant virtualization/vagrant/config # Visual Studio Code -.vscode +.vscode/* +!.vscode/cSpell.json +!.vscode/extensions.json +!.vscode/tasks.json # Built docs docs/build @@ -104,9 +118,16 @@ desktop.ini # mypy /.mypy_cache/* +/.dmypy.json # Secrets .lokalise_token # monkeytype monkeytype.sqlite3 + +# This is left behind by Azure Restore Cache +tmp_cache + +# python-language-server / Rope +.ropeproject diff --git a/.isort.cfg b/.isort.cfg deleted file mode 100644 index 79a655082..000000000 --- a/.isort.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[settings] -multi_line_output=4 diff --git a/.pre-commit-config-all.yaml b/.pre-commit-config-all.yaml new file mode 100644 index 000000000..1eabfcb00 --- /dev/null +++ b/.pre-commit-config-all.yaml @@ -0,0 +1,59 @@ +# This configuration includes the full set of hooks we use. In +# addition to the defaults (see .pre-commit-config.yaml), this +# includes hooks that require our development and test dependencies +# installed and the virtualenv containing them active by the time +# pre-commit runs to produce correct results. +# +# If this is not a problem for your workflow, using this config is +# recommended, install it with +# pre-commit install --config .pre-commit-config-all.yaml +# Otherwise, see the default .pre-commit-config.yaml for a lighter one. + +repos: +- repo: https://github.com/psf/black + rev: 19.10b0 + hooks: + - id: black + args: + - --safe + - --quiet + files: ^((homeassistant|script|tests)/.+)?[^/]+\.py$ +- repo: https://github.com/PyCQA/flake8 + rev: 3.7.9 + hooks: + - id: flake8 + additional_dependencies: + - flake8-docstrings==1.5.0 + - pydocstyle==5.0.1 + files: ^(homeassistant|script|tests)/.+\.py$ +- repo: https://github.com/PyCQA/bandit + rev: 1.6.2 + hooks: + - id: bandit + args: + - --quiet + - --format=custom + - --configfile=tests/bandit.yaml + files: ^(homeassistant|script|tests)/.+\.py$ +- repo: https://github.com/pre-commit/mirrors-isort + rev: v4.3.21 + hooks: + - id: isort +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v2.4.0 + hooks: + - id: check-json +# Using a local "system" mypy instead of the mypy hook, because its +# results depend on what is installed. And the mypy hook runs in a +# virtualenv of its own, meaning we'd need to install and maintain +# another set of our dependencies there... no. Use the "system" one +# and reuse the environment that is set up anyway already instead. +- repo: local + hooks: + - id: mypy + name: mypy + entry: mypy + language: system + types: [python] + require_serial: true + files: ^homeassistant/.+\.py$ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..226708bb9 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,41 @@ +# This configuration includes the default, minimal set of hooks to be +# run on all commits. It requires no specific setup and one can just +# start using pre-commit with it. +# +# See .pre-commit-config-all.yaml for a more complete one that comes +# with a better coverage at the cost of some specific setup needed. + +repos: +- repo: https://github.com/psf/black + rev: 19.10b0 + hooks: + - id: black + args: + - --safe + - --quiet + files: ^((homeassistant|script|tests)/.+)?[^/]+\.py$ +- repo: https://gitlab.com/pycqa/flake8 + rev: 3.7.9 + hooks: + - id: flake8 + additional_dependencies: + - flake8-docstrings==1.5.0 + - pydocstyle==5.0.1 + files: ^(homeassistant|script|tests)/.+\.py$ +- repo: https://github.com/PyCQA/bandit + rev: 1.6.2 + hooks: + - id: bandit + args: + - --quiet + - --format=custom + - --configfile=tests/bandit.yaml + files: ^(homeassistant|script|tests)/.+\.py$ +- repo: https://github.com/pre-commit/mirrors-isort + rev: v4.3.21 + hooks: + - id: isort +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v2.4.0 + hooks: + - id: check-json diff --git a/.readthedocs.yml b/.readthedocs.yml new file mode 100644 index 000000000..0303f84d5 --- /dev/null +++ b/.readthedocs.yml @@ -0,0 +1,10 @@ +# .readthedocs.yml + +build: + image: latest + +python: + version: 3.7 + setup_py_install: true + +requirements_file: requirements_docs.txt diff --git a/.travis.yml b/.travis.yml index 920e8b570..6add8c15b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,48 +1,32 @@ sudo: false +dist: bionic addons: apt: packages: - libudev-dev + - libavformat-dev + - libavcodec-dev + - libavdevice-dev + - libavutil-dev + - libswscale-dev + - libswresample-dev + - libavfilter-dev matrix: fast_finish: true include: - - python: "3.5.3" + - python: "3.7.0" env: TOXENV=lint - - python: "3.5.3" - env: TOXENV=pylint - - python: "3.5.3" + - python: "3.7.0" + env: TOXENV=pylint PYLINT_ARGS=--jobs=0 TRAVIS_WAIT=30 + - python: "3.7.0" env: TOXENV=typing - - python: "3.5.3" - env: TOXENV=cov - after_success: coveralls - - python: "3.6" - env: TOXENV=py36 - - python: "3.7" + - python: "3.7.0" env: TOXENV=py37 - dist: xenial - - python: "3.8-dev" - env: TOXENV=py38 - dist: xenial - if: branch = dev AND type = push - allow_failures: - - python: "3.8-dev" - env: TOXENV=py38 - dist: xenial cache: + pip: true directories: - - $HOME/.cache/pip -install: pip install -U tox coveralls + - $HOME/.cache/pre-commit +install: pip install -U tox language: python -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 +script: ${TRAVIS_WAIT:+travis_wait $TRAVIS_WAIT} tox --develop diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 000000000..1a0bfb16a --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,105 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Preview", + "type": "shell", + "command": "hass -c ./config", + "group": { + "kind": "test", + "isDefault": true + }, + "presentation": { + "reveal": "always", + "panel": "new" + }, + "problemMatcher": [] + }, + { + "label": "Pytest", + "type": "shell", + "command": "pytest --timeout=10 tests", + "dependsOn": ["Install all Test Requirements"], + "group": { + "kind": "test", + "isDefault": true + }, + "presentation": { + "reveal": "always", + "panel": "new" + }, + "problemMatcher": [] + }, + { + "label": "Flake8", + "type": "shell", + "command": "pre-commit run flake8 --all-files", + "group": { + "kind": "test", + "isDefault": true + }, + "presentation": { + "reveal": "always", + "panel": "new" + }, + "problemMatcher": [] + }, + { + "label": "Pylint", + "type": "shell", + "command": "pylint homeassistant", + "dependsOn": ["Install all Requirements"], + "group": { + "kind": "test", + "isDefault": true + }, + "presentation": { + "reveal": "always", + "panel": "new" + }, + "problemMatcher": [] + }, + { + "label": "Generate Requirements", + "type": "shell", + "command": "./script/gen_requirements_all.py", + "group": { + "kind": "build", + "isDefault": true + }, + "presentation": { + "reveal": "always", + "panel": "new" + }, + "problemMatcher": [] + }, + { + "label": "Install all Requirements", + "type": "shell", + "command": "pip3 install -r requirements_all.txt -c homeassistant/package_constraints.txt", + "group": { + "kind": "build", + "isDefault": true + }, + "presentation": { + "reveal": "always", + "panel": "new" + }, + "problemMatcher": [] + }, + { + "label": "Install all Test Requirements", + "type": "shell", + "command": "pip3 install -r requirements_test_all.txt -c homeassistant/package_constraints.txt", + "group": { + "kind": "build", + "isDefault": true + }, + "presentation": { + "reveal": "always", + "panel": "new" + }, + "problemMatcher": [] + } + ] +} diff --git a/CODEOWNERS b/CODEOWNERS index b6ce8c049..4fbdca206 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,132 +1,391 @@ +# This file is generated by script/hassfest/codeowners.py # People marked here will be automatically requested for a review # when the code that they own is touched. # https://github.com/blog/2392-introducing-code-owners +# Home Assistant Core setup.py @home-assistant/core homeassistant/*.py @home-assistant/core homeassistant/helpers/* @home-assistant/core homeassistant/util/* @home-assistant/core -homeassistant/components/api.py @home-assistant/core -homeassistant/components/automation/* @home-assistant/core -homeassistant/components/configurator.py @home-assistant/core -homeassistant/components/group.py @home-assistant/core -homeassistant/components/history.py @home-assistant/core -homeassistant/components/http/* @home-assistant/core -homeassistant/components/input_*.py @home-assistant/core -homeassistant/components/introduction.py @home-assistant/core -homeassistant/components/logger.py @home-assistant/core -homeassistant/components/mqtt/* @home-assistant/core -homeassistant/components/panel_custom.py @home-assistant/core -homeassistant/components/panel_iframe.py @home-assistant/core -homeassistant/components/persistent_notification.py @home-assistant/core -homeassistant/components/scene/__init__.py @home-assistant/core -homeassistant/components/scene/hass.py @home-assistant/core -homeassistant/components/script.py @home-assistant/core -homeassistant/components/shell_command.py @home-assistant/core -homeassistant/components/sun.py @home-assistant/core -homeassistant/components/updater.py @home-assistant/core -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 - -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/lifx.py @amelchio -homeassistant/components/light/lifx_legacy.py @amelchio -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/scene/lifx_cloud.py @amelchio -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/jewish_calendar.py @tsvi -homeassistant/components/sensor/miflora.py @danielhiversen @ChristianKuehnel -homeassistant/components/sensor/nsw_fuel_station.py @nickw444 -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/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/ecovacs.py @OverloadUT -homeassistant/components/*/ecovacs.py @OverloadUT -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/huawei_lte.py @scop -homeassistant/components/*/huawei_lte.py @scop -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/openuv.py @bachya -homeassistant/components/*/openuv.py @bachya -homeassistant/components/qwikswitch.py @kellerza -homeassistant/components/*/qwikswitch.py @kellerza -homeassistant/components/rainmachine/* @bachya -homeassistant/components/*/rainmachine.py @bachya -homeassistant/components/*/rfxtrx.py @danielhiversen -homeassistant/components/tahoma.py @philklei -homeassistant/components/*/tahoma.py @philklei -homeassistant/components/tesla.py @zabuldon -homeassistant/components/*/tesla.py @zabuldon -homeassistant/components/tellduslive.py @molobrakos @fredrike -homeassistant/components/*/tellduslive.py @molobrakos @fredrike -homeassistant/components/*/tradfri.py @ggravlingen -homeassistant/components/upcloud.py @scop -homeassistant/components/*/upcloud.py @scop -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/components/zoneminder.py @rohankapoorcom -homeassistant/components/*/zoneminder.py @rohankapoorcom +# Other code homeassistant/scripts/check_config.py @kellerza + +# Integrations +homeassistant/components/abode/* @shred86 +homeassistant/components/adguard/* @frenck +homeassistant/components/airly/* @bieniu +homeassistant/components/airvisual/* @bachya +homeassistant/components/alexa/* @home-assistant/cloud @ochlocracy +homeassistant/components/almond/* @gcampax @balloob +homeassistant/components/alpha_vantage/* @fabaff +homeassistant/components/amazon_polly/* @robbiet480 +homeassistant/components/ambiclimate/* @danielhiversen +homeassistant/components/ambient_station/* @bachya +homeassistant/components/androidtv/* @JeffLIrion +homeassistant/components/apache_kafka/* @bachya +homeassistant/components/api/* @home-assistant/core +homeassistant/components/apprise/* @caronc +homeassistant/components/aprs/* @PhilRW +homeassistant/components/arcam_fmj/* @elupus +homeassistant/components/arduino/* @fabaff +homeassistant/components/arest/* @fabaff +homeassistant/components/asuswrt/* @kennedyshead +homeassistant/components/aten_pe/* @mtdcr +homeassistant/components/atome/* @baqs +homeassistant/components/aurora_abb_powerone/* @davet2001 +homeassistant/components/auth/* @home-assistant/core +homeassistant/components/automatic/* @armills +homeassistant/components/automation/* @home-assistant/core +homeassistant/components/avea/* @pattyland +homeassistant/components/awair/* @danielsjf +homeassistant/components/aws/* @awarecan @robbiet480 +homeassistant/components/axis/* @kane610 +homeassistant/components/azure_event_hub/* @eavanvalkenburg +homeassistant/components/azure_service_bus/* @hfurubotten +homeassistant/components/beewi_smartclim/* @alemuro +homeassistant/components/bitcoin/* @fabaff +homeassistant/components/bizkaibus/* @UgaitzEtxebarria +homeassistant/components/blink/* @fronzbot +homeassistant/components/bmw_connected_drive/* @gerard33 +homeassistant/components/braviatv/* @robbiet480 +homeassistant/components/broadlink/* @danielhiversen @felipediel +homeassistant/components/brunt/* @eavanvalkenburg +homeassistant/components/bt_smarthub/* @jxwolstenholme +homeassistant/components/buienradar/* @mjj4791 @ties +homeassistant/components/cert_expiry/* @Cereal2nd @jjlawren +homeassistant/components/cisco_ios/* @fbradyirl +homeassistant/components/cisco_mobility_express/* @fbradyirl +homeassistant/components/cisco_webex_teams/* @fbradyirl +homeassistant/components/ciscospark/* @fbradyirl +homeassistant/components/cloud/* @home-assistant/cloud +homeassistant/components/cloudflare/* @ludeeus +homeassistant/components/comfoconnect/* @michaelarnauts +homeassistant/components/config/* @home-assistant/core +homeassistant/components/configurator/* @home-assistant/core +homeassistant/components/conversation/* @home-assistant/core +homeassistant/components/coolmaster/* @OnFreund +homeassistant/components/counter/* @fabaff +homeassistant/components/cover/* @home-assistant/core +homeassistant/components/cpuspeed/* @fabaff +homeassistant/components/cups/* @fabaff +homeassistant/components/daikin/* @fredrike @rofrantz +homeassistant/components/darksky/* @fabaff +homeassistant/components/deconz/* @kane610 +homeassistant/components/delijn/* @bollewolle +homeassistant/components/demo/* @home-assistant/core +homeassistant/components/device_automation/* @home-assistant/core +homeassistant/components/digital_ocean/* @fabaff +homeassistant/components/discogs/* @thibmaek +homeassistant/components/doorbird/* @oblogic7 +homeassistant/components/dsmr_reader/* @depl0y +homeassistant/components/dweet/* @fabaff +homeassistant/components/ecobee/* @marthoc +homeassistant/components/ecovacs/* @OverloadUT +homeassistant/components/egardia/* @jeroenterheerdt +homeassistant/components/eight_sleep/* @mezz64 +homeassistant/components/elgato/* @frenck +homeassistant/components/elv/* @majuss +homeassistant/components/emby/* @mezz64 +homeassistant/components/emulated_hue/* @NobleKangaroo +homeassistant/components/enigma2/* @fbradyirl +homeassistant/components/enocean/* @bdurrer +homeassistant/components/entur_public_transport/* @hfurubotten +homeassistant/components/environment_canada/* @michaeldavie +homeassistant/components/ephember/* @ttroy50 +homeassistant/components/epsonworkforce/* @ThaStealth +homeassistant/components/eq3btsmart/* @rytilahti +homeassistant/components/esphome/* @OttoWinter +homeassistant/components/essent/* @TheLastProject +homeassistant/components/evohome/* @zxdavb +homeassistant/components/fastdotcom/* @rohankapoorcom +homeassistant/components/file/* @fabaff +homeassistant/components/filter/* @dgomes +homeassistant/components/fitbit/* @robbiet480 +homeassistant/components/fixer/* @fabaff +homeassistant/components/flock/* @fabaff +homeassistant/components/flume/* @ChrisMandich +homeassistant/components/flunearyou/* @bachya +homeassistant/components/fortigate/* @kifeo +homeassistant/components/fortios/* @kimfrellsen +homeassistant/components/foscam/* @skgsergio +homeassistant/components/foursquare/* @robbiet480 +homeassistant/components/freebox/* @snoof85 +homeassistant/components/fronius/* @nielstron +homeassistant/components/frontend/* @home-assistant/frontend +homeassistant/components/gearbest/* @HerrHofrat +homeassistant/components/geniushub/* @zxdavb +homeassistant/components/geo_rss_events/* @exxamalte +homeassistant/components/geonetnz_quakes/* @exxamalte +homeassistant/components/geonetnz_volcano/* @exxamalte +homeassistant/components/gitter/* @fabaff +homeassistant/components/glances/* @fabaff @engrbm87 +homeassistant/components/gntp/* @robbiet480 +homeassistant/components/google_assistant/* @home-assistant/cloud +homeassistant/components/google_cloud/* @lufton +homeassistant/components/google_translate/* @awarecan +homeassistant/components/google_travel_time/* @robbiet480 +homeassistant/components/gpsd/* @fabaff +homeassistant/components/group/* @home-assistant/core +homeassistant/components/growatt_server/* @indykoning +homeassistant/components/gtfs/* @robbiet480 +homeassistant/components/harmony/* @ehendrix23 +homeassistant/components/hassio/* @home-assistant/hass-io +homeassistant/components/heatmiser/* @andylockran +homeassistant/components/heos/* @andrewsayre +homeassistant/components/here_travel_time/* @eifinger +homeassistant/components/hikvision/* @mezz64 +homeassistant/components/hikvisioncam/* @fbradyirl +homeassistant/components/hisense_aehw4a1/* @bannhead +homeassistant/components/history/* @home-assistant/core +homeassistant/components/history_graph/* @andrey-git +homeassistant/components/hive/* @Rendili @KJonline +homeassistant/components/homeassistant/* @home-assistant/core +homeassistant/components/homekit_controller/* @Jc2k +homeassistant/components/homematic/* @pvizeli @danielperna84 +homeassistant/components/homematicip_cloud/* @SukramJ +homeassistant/components/honeywell/* @zxdavb +homeassistant/components/html5/* @robbiet480 +homeassistant/components/http/* @home-assistant/core +homeassistant/components/huawei_lte/* @scop +homeassistant/components/huawei_router/* @abmantis +homeassistant/components/hue/* @balloob +homeassistant/components/iaqualink/* @flz +homeassistant/components/icloud/* @Quentame +homeassistant/components/ign_sismologia/* @exxamalte +homeassistant/components/incomfort/* @zxdavb +homeassistant/components/influxdb/* @fabaff +homeassistant/components/input_boolean/* @home-assistant/core +homeassistant/components/input_datetime/* @home-assistant/core +homeassistant/components/input_number/* @home-assistant/core +homeassistant/components/input_select/* @home-assistant/core +homeassistant/components/input_text/* @home-assistant/core +homeassistant/components/integration/* @dgomes +homeassistant/components/intent/* @home-assistant/core +homeassistant/components/intesishome/* @jnimmo +homeassistant/components/ios/* @robbiet480 +homeassistant/components/iperf3/* @rohankapoorcom +homeassistant/components/ipma/* @dgomes +homeassistant/components/iqvia/* @bachya +homeassistant/components/irish_rail_transport/* @ttroy50 +homeassistant/components/izone/* @Swamp-Ig +homeassistant/components/jewish_calendar/* @tsvi +homeassistant/components/juicenet/* @jesserockz +homeassistant/components/kaiterra/* @Michsior14 +homeassistant/components/keba/* @dannerph +homeassistant/components/keenetic_ndms2/* @foxel +homeassistant/components/keyboard_remote/* @bendavid +homeassistant/components/knx/* @Julius2342 +homeassistant/components/kodi/* @armills +homeassistant/components/konnected/* @heythisisnate +homeassistant/components/lametric/* @robbiet480 +homeassistant/components/launch_library/* @ludeeus +homeassistant/components/lcn/* @alengwenus +homeassistant/components/life360/* @pnbruckner +homeassistant/components/linky/* @Quentame +homeassistant/components/linux_battery/* @fabaff +homeassistant/components/liveboxplaytv/* @pschmitt +homeassistant/components/logger/* @home-assistant/core +homeassistant/components/logi_circle/* @evanjd +homeassistant/components/lovelace/* @home-assistant/frontend +homeassistant/components/luci/* @fbradyirl @mzdrale +homeassistant/components/luftdaten/* @fabaff +homeassistant/components/lupusec/* @majuss +homeassistant/components/lutron/* @JonGilmore +homeassistant/components/mastodon/* @fabaff +homeassistant/components/matrix/* @tinloaf +homeassistant/components/mcp23017/* @jardiamj +homeassistant/components/mediaroom/* @dgomes +homeassistant/components/melissa/* @kennedyshead +homeassistant/components/met/* @danielhiversen +homeassistant/components/meteo_france/* @victorcerutti @oncleben31 +homeassistant/components/meteoalarm/* @rolfberkenbosch +homeassistant/components/miflora/* @danielhiversen @ChristianKuehnel +homeassistant/components/mill/* @danielhiversen +homeassistant/components/min_max/* @fabaff +homeassistant/components/minio/* @tkislan +homeassistant/components/mobile_app/* @robbiet480 +homeassistant/components/modbus/* @adamchengtkc +homeassistant/components/monoprice/* @etsinko +homeassistant/components/moon/* @fabaff +homeassistant/components/mpd/* @fabaff +homeassistant/components/mqtt/* @home-assistant/core +homeassistant/components/msteams/* @peroyvind +homeassistant/components/mysensors/* @MartinHjelmare +homeassistant/components/mystrom/* @fabaff +homeassistant/components/neato/* @dshokouhi @Santobert +homeassistant/components/nello/* @pschmitt +homeassistant/components/ness_alarm/* @nickw444 +homeassistant/components/nest/* @awarecan +homeassistant/components/netdata/* @fabaff +homeassistant/components/nextbus/* @vividboarder +homeassistant/components/nilu/* @hfurubotten +homeassistant/components/nissan_leaf/* @filcole +homeassistant/components/nmbs/* @thibmaek +homeassistant/components/no_ip/* @fabaff +homeassistant/components/notify/* @home-assistant/core +homeassistant/components/notion/* @bachya +homeassistant/components/nsw_fuel_station/* @nickw444 +homeassistant/components/nsw_rural_fire_service_feed/* @exxamalte +homeassistant/components/nuki/* @pvizeli +homeassistant/components/nws/* @MatthewFlamm +homeassistant/components/nzbget/* @chriscla +homeassistant/components/obihai/* @dshokouhi +homeassistant/components/ohmconnect/* @robbiet480 +homeassistant/components/ombi/* @larssont +homeassistant/components/onboarding/* @home-assistant/core +homeassistant/components/opentherm_gw/* @mvn23 +homeassistant/components/openuv/* @bachya +homeassistant/components/openweathermap/* @fabaff +homeassistant/components/orangepi_gpio/* @pascallj +homeassistant/components/oru/* @bvlaicu +homeassistant/components/owlet/* @oblogic7 +homeassistant/components/panel_custom/* @home-assistant/frontend +homeassistant/components/panel_iframe/* @home-assistant/frontend +homeassistant/components/pcal9535a/* @Shulyaka +homeassistant/components/persistent_notification/* @home-assistant/core +homeassistant/components/philips_js/* @elupus +homeassistant/components/pi_hole/* @fabaff @johnluetke +homeassistant/components/plaato/* @JohNan +homeassistant/components/plant/* @ChristianKuehnel +homeassistant/components/plex/* @jjlawren +homeassistant/components/plugwise/* @laetificat @CoMPaTech @bouwew +homeassistant/components/point/* @fredrike +homeassistant/components/proxmoxve/* @k4ds3 +homeassistant/components/ps4/* @ktnrg45 +homeassistant/components/ptvsd/* @swamp-ig +homeassistant/components/push/* @dgomes +homeassistant/components/pvoutput/* @fabaff +homeassistant/components/qld_bushfire/* @exxamalte +homeassistant/components/qnap/* @colinodell +homeassistant/components/quantum_gateway/* @cisasteelersfan +homeassistant/components/qwikswitch/* @kellerza +homeassistant/components/rainbird/* @konikvranik +homeassistant/components/raincloud/* @vanstinator +homeassistant/components/rainforest_eagle/* @gtdiehl +homeassistant/components/rainmachine/* @bachya +homeassistant/components/random/* @fabaff +homeassistant/components/repetier/* @MTrab +homeassistant/components/rfxtrx/* @danielhiversen +homeassistant/components/rmvtransport/* @cgtobi +homeassistant/components/roomba/* @pschmitt +homeassistant/components/saj/* @fredericvl +homeassistant/components/samsungtv/* @escoand +homeassistant/components/scene/* @home-assistant/core +homeassistant/components/scrape/* @fabaff +homeassistant/components/script/* @home-assistant/core +homeassistant/components/sense/* @kbickar +homeassistant/components/sensibo/* @andrey-git +homeassistant/components/serial/* @fabaff +homeassistant/components/seventeentrack/* @bachya +homeassistant/components/shell_command/* @home-assistant/core +homeassistant/components/shiftr/* @fabaff +homeassistant/components/shodan/* @fabaff +homeassistant/components/signal_messenger/* @bbernhard +homeassistant/components/simplisafe/* @bachya +homeassistant/components/sinch/* @bendikrb +homeassistant/components/slide/* @ualex73 +homeassistant/components/sma/* @kellerza +homeassistant/components/smarthab/* @outadoc +homeassistant/components/smartthings/* @andrewsayre +homeassistant/components/smarty/* @z0mbieprocess +homeassistant/components/smtp/* @fabaff +homeassistant/components/solaredge_local/* @drobtravels @scheric +homeassistant/components/solarlog/* @Ernst79 +homeassistant/components/solax/* @squishykid +homeassistant/components/soma/* @ratsept +homeassistant/components/somfy/* @tetienne +homeassistant/components/songpal/* @rytilahti +homeassistant/components/spaceapi/* @fabaff +homeassistant/components/speedtestdotnet/* @rohankapoorcom +homeassistant/components/spider/* @peternijssen +homeassistant/components/sql/* @dgomes +homeassistant/components/starline/* @anonym-tsk +homeassistant/components/statistics/* @fabaff +homeassistant/components/stiebel_eltron/* @fucm +homeassistant/components/stream/* @hunterjm +homeassistant/components/stt/* @pvizeli +homeassistant/components/suez_water/* @ooii +homeassistant/components/sun/* @Swamp-Ig +homeassistant/components/supla/* @mwegrzynek +homeassistant/components/swiss_hydrological_data/* @fabaff +homeassistant/components/swiss_public_transport/* @fabaff +homeassistant/components/switchbot/* @danielhiversen +homeassistant/components/switcher_kis/* @tomerfi +homeassistant/components/switchmate/* @danielhiversen +homeassistant/components/syncthru/* @nielstron +homeassistant/components/synology_srm/* @aerialls +homeassistant/components/syslog/* @fabaff +homeassistant/components/tado/* @michaelarnauts +homeassistant/components/tahoma/* @philklei +homeassistant/components/tautulli/* @ludeeus +homeassistant/components/tellduslive/* @fredrike +homeassistant/components/template/* @PhracturedBlue +homeassistant/components/tesla/* @zabuldon +homeassistant/components/tfiac/* @fredrike @mellado +homeassistant/components/thethingsnetwork/* @fabaff +homeassistant/components/threshold/* @fabaff +homeassistant/components/tibber/* @danielhiversen +homeassistant/components/tile/* @bachya +homeassistant/components/time_date/* @fabaff +homeassistant/components/todoist/* @boralyl +homeassistant/components/toon/* @frenck +homeassistant/components/tplink/* @rytilahti +homeassistant/components/traccar/* @ludeeus +homeassistant/components/tradfri/* @ggravlingen +homeassistant/components/trafikverket_train/* @endor-force +homeassistant/components/transmission/* @engrbm87 @JPHutchins +homeassistant/components/tts/* @robbiet480 +homeassistant/components/twentemilieu/* @frenck +homeassistant/components/twilio_call/* @robbiet480 +homeassistant/components/twilio_sms/* @robbiet480 +homeassistant/components/unifi/* @kane610 +homeassistant/components/unifiled/* @florisvdk +homeassistant/components/upc_connect/* @pvizeli +homeassistant/components/upcloud/* @scop +homeassistant/components/updater/* @home-assistant/core +homeassistant/components/upnp/* @robbiet480 +homeassistant/components/uptimerobot/* @ludeeus +homeassistant/components/usgs_earthquakes_feed/* @exxamalte +homeassistant/components/utility_meter/* @dgomes +homeassistant/components/velbus/* @cereal2nd +homeassistant/components/velux/* @Julius2342 +homeassistant/components/versasense/* @flamm3blemuff1n +homeassistant/components/version/* @fabaff +homeassistant/components/vesync/* @markperdue @webdjoe +homeassistant/components/vicare/* @oischinger +homeassistant/components/vivotek/* @HarlemSquirrel +homeassistant/components/vizio/* @raman325 +homeassistant/components/vlc_telnet/* @rodripf +homeassistant/components/waqi/* @andrey-git +homeassistant/components/watson_tts/* @rutkai +homeassistant/components/weather/* @fabaff +homeassistant/components/weblink/* @home-assistant/core +homeassistant/components/websocket_api/* @home-assistant/core +homeassistant/components/wemo/* @sqldiablo +homeassistant/components/withings/* @vangorra +homeassistant/components/wled/* @frenck +homeassistant/components/worldclock/* @fabaff +homeassistant/components/wwlln/* @bachya +homeassistant/components/xbox_live/* @MartinHjelmare +homeassistant/components/xfinity/* @cisasteelersfan +homeassistant/components/xiaomi_aqara/* @danielhiversen @syssi +homeassistant/components/xiaomi_miio/* @rytilahti @syssi +homeassistant/components/xiaomi_tv/* @simse +homeassistant/components/xmpp/* @fabaff @flowolf +homeassistant/components/yamaha_musiccast/* @jalmeroth +homeassistant/components/yandex_transport/* @rishatik92 +homeassistant/components/yeelight/* @rytilahti @zewelor +homeassistant/components/yeelightsunflower/* @lindsaymarkward +homeassistant/components/yessssms/* @flowolf +homeassistant/components/yi/* @bachya +homeassistant/components/yr/* @danielhiversen +homeassistant/components/zeroconf/* @robbiet480 @Kane610 +homeassistant/components/zha/* @dmulcahey @adminiuga +homeassistant/components/zone/* @home-assistant/core +homeassistant/components/zoneminder/* @rohankapoorcom +homeassistant/components/zwave/* @home-assistant/z-wave + +# Individual files +homeassistant/components/demo/weather @fabaff diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index c84e6162d..000000000 --- a/Dockerfile +++ /dev/null @@ -1,35 +0,0 @@ -# Notice: -# When updating this file, please also update virtualization/Docker/Dockerfile.dev -# This way, the development image and the production image are kept in sync. - -FROM python:3.6 -LABEL maintainer="Paulus Schoutsen " - -# Uncomment any of the following lines to disable the installation. -#ENV INSTALL_TELLSTICK no -#ENV INSTALL_OPENALPR no -#ENV INSTALL_FFMPEG no -#ENV INSTALL_LIBCEC no -#ENV INSTALL_SSOCR no -#ENV INSTALL_IPERF3 no - -VOLUME /config - -RUN mkdir -p /usr/src/app -WORKDIR /usr/src/app - -# Copy build scripts -COPY virtualization/Docker/ virtualization/Docker/ -RUN virtualization/Docker/setup_docker_prereqs - -# Install hass component dependencies -COPY requirements_all.txt requirements_all.txt -# 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 cython - -# Copy source -COPY . . - -CMD [ "python", "-m", "homeassistant", "--config", "/config" ] diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 000000000..fa90a84fc --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,32 @@ +FROM python:3.7 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + libudev-dev \ + libavformat-dev \ + libavcodec-dev \ + libavdevice-dev \ + libavutil-dev \ + libswscale-dev \ + libswresample-dev \ + libavfilter-dev \ + git \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /usr/src + +# Setup hass-release +RUN git clone --depth 1 https://github.com/home-assistant/hass-release \ + && cd hass-release \ + && pip3 install -e . + +WORKDIR /workspaces + +# Install Python dependencies from requirements +COPY requirements_test.txt requirements_test_pre_commit.txt homeassistant/package_constraints.txt ./ +RUN pip3 install -r requirements_test.txt -c package_constraints.txt \ + && rm -f requirements_test.txt package_constraints.txt requirements_test_pre_commit.txt + +# Set the default shell to bash instead of sh +ENV SHELL /bin/bash diff --git a/README.rst b/README.rst index 4f459162a..0de30d43c 100644 --- a/README.rst +++ b/README.rst @@ -1,14 +1,7 @@ -Home Assistant |Build Status| |Coverage Status| |Chat Status| |Reviewed by Hound| +Home Assistant |Chat Status| ================================================================================= -Home Assistant is a home automation platform running on Python 3. It is able to track and control all devices at home and offer a platform for automating control. - -To get started: - -.. code:: bash - - python3 -m pip install homeassistant - hass --open-ui +Open source home automation that puts local control and privacy first. Powered by a worldwide community of tinkerers and DIY enthusiasts. Perfect to run on a Raspberry Pi or a local server. Check out `home-assistant.io `__ for `a demo `__, `installation instructions `__, @@ -27,15 +20,9 @@ components `__ of our website for further help and information. -.. |Build Status| image:: https://travis-ci.org/home-assistant/home-assistant.svg?branch=master - :target: https://travis-ci.org/home-assistant/home-assistant -.. |Coverage Status| image:: https://img.shields.io/coveralls/home-assistant/home-assistant.svg - :target: https://coveralls.io/r/home-assistant/home-assistant?branch=master .. |Chat Status| image:: https://img.shields.io/discord/330944238910963714.svg :target: https://discord.gg/c5DvZ4e -.. |Reviewed by Hound| image:: https://img.shields.io/badge/Reviewed_by-Hound-8E64B0.svg - :target: https://houndci.com .. |screenshot-states| image:: https://raw.github.com/home-assistant/home-assistant/master/docs/screenshots.png :target: https://home-assistant.io/demo/ .. |screenshot-components| image:: https://raw.github.com/home-assistant/home-assistant/dev/docs/screenshot-components.png - :target: https://home-assistant.io/components/ + :target: https://home-assistant.io/integrations/ diff --git a/azure-pipelines-ci.yml b/azure-pipelines-ci.yml new file mode 100644 index 000000000..546b63950 --- /dev/null +++ b/azure-pipelines-ci.yml @@ -0,0 +1,197 @@ +# https://dev.azure.com/home-assistant + +trigger: + batch: true + branches: + include: + - rc + - dev + - master +pr: + - rc + - dev + - master + +resources: + containers: + - container: 37 + image: homeassistant/ci-azure:3.7 + repositories: + - repository: azure + type: github + name: 'home-assistant/ci-azure' + endpoint: 'home-assistant' +variables: + - name: PythonMain + value: '37' + - group: codecov + +stages: + +- stage: 'Overview' + jobs: + - job: 'Lint' + pool: + vmImage: 'ubuntu-latest' + container: $[ variables['PythonMain'] ] + steps: + - template: templates/azp-step-cache.yaml@azure + parameters: + keyfile: 'requirements_test.txt | homeassistant/package_constraints.txt' + build: | + python -m venv venv + + . venv/bin/activate + pip install -r requirements_test.txt -c homeassistant/package_constraints.txt + pre-commit install-hooks --config .pre-commit-config-all.yaml + - script: | + . venv/bin/activate + pre-commit run flake8 --all-files + displayName: 'Run flake8' + - script: | + . venv/bin/activate + pre-commit run bandit --all-files + displayName: 'Run bandit' + - script: | + . venv/bin/activate + pre-commit run isort --all-files --show-diff-on-failure + displayName: 'Run isort' + - script: | + . venv/bin/activate + pre-commit run check-json --all-files + displayName: 'Run check-json' + - job: 'Validate' + pool: + vmImage: 'ubuntu-latest' + container: $[ variables['PythonMain'] ] + steps: + - template: templates/azp-step-cache.yaml@azure + parameters: + keyfile: 'homeassistant/package_constraints.txt' + build: | + python -m venv venv + + . venv/bin/activate + pip install -e . + - script: | + . venv/bin/activate + python -m script.hassfest validate + displayName: 'Validate manifests' + - script: | + . venv/bin/activate + ./script/gen_requirements_all.py validate + displayName: 'requirements_all validate' + - job: 'CheckFormat' + pool: + vmImage: 'ubuntu-latest' + container: $[ variables['PythonMain'] ] + steps: + - template: templates/azp-step-cache.yaml@azure + parameters: + keyfile: 'requirements_test.txt | homeassistant/package_constraints.txt' + build: | + python -m venv venv + + . venv/bin/activate + pip install -r requirements_test.txt -c homeassistant/package_constraints.txt + pre-commit install-hooks --config .pre-commit-config-all.yaml + - script: | + . venv/bin/activate + pre-commit run black --all-files --show-diff-on-failure + displayName: 'Check Black formatting' + +- stage: 'Tests' + dependsOn: + - 'Overview' + jobs: + - job: 'PyTest' + pool: + vmImage: 'ubuntu-latest' + strategy: + maxParallel: 3 + matrix: + Python37: + python.container: '37' + container: $[ variables['python.container'] ] + steps: + - template: templates/azp-step-cache.yaml@azure + parameters: + keyfile: 'requirements_test_all.txt | homeassistant/package_constraints.txt' + build: | + set -e + python -m venv venv + + . venv/bin/activate + pip install -U pip setuptools pytest-azurepipelines pytest-xdist -c homeassistant/package_constraints.txt + pip install -r requirements_test_all.txt -c homeassistant/package_constraints.txt + # This is a TEMP. Eventually we should make sure our 4 dependencies drop typing. + # Find offending deps with `pipdeptree -r -p typing` + pip uninstall -y typing + - script: | + . venv/bin/activate + pip install -e . + displayName: 'Install Home Assistant' + - script: | + set -e + + . venv/bin/activate + pytest --timeout=9 --durations=10 -n auto --dist=loadfile -qq -o console_output_style=count -p no:sugar tests + script/check_dirty + displayName: 'Run pytest for python $(python.container)' + condition: and(succeeded(), ne(variables['python.container'], variables['PythonMain'])) + - script: | + set -e + + . venv/bin/activate + pytest --timeout=9 --durations=10 -n auto --dist=loadfile --cov homeassistant --cov-report html -qq -o console_output_style=count -p no:sugar tests + codecov --token $(codecovToken) + script/check_dirty + displayName: 'Run pytest for python $(python.container) / coverage' + condition: and(succeeded(), eq(variables['python.container'], variables['PythonMain'])) + +- stage: 'FullCheck' + dependsOn: + - 'Overview' + jobs: + - job: 'Pylint' + pool: + vmImage: 'ubuntu-latest' + container: $[ variables['PythonMain'] ] + steps: + - template: templates/azp-step-cache.yaml@azure + parameters: + keyfile: 'requirements_all.txt | requirements_test.txt | homeassistant/package_constraints.txt' + build: | + set -e + python -m venv venv + + . venv/bin/activate + pip install -U pip setuptools wheel + pip install -r requirements_all.txt -c homeassistant/package_constraints.txt + pip install -r requirements_test.txt -c homeassistant/package_constraints.txt + - script: | + . venv/bin/activate + pip install -e . + displayName: 'Install Home Assistant' + - script: | + . venv/bin/activate + pylint homeassistant + displayName: 'Run pylint' + - job: 'Mypy' + pool: + vmImage: 'ubuntu-latest' + container: $[ variables['PythonMain'] ] + steps: + - template: templates/azp-step-cache.yaml@azure + parameters: + keyfile: 'requirements_test.txt | setup.py | homeassistant/package_constraints.txt' + build: | + python -m venv venv + + . venv/bin/activate + pip install -e . -r requirements_test.txt -c homeassistant/package_constraints.txt + pre-commit install-hooks --config .pre-commit-config-all.yaml + - script: | + . venv/bin/activate + pre-commit run --config .pre-commit-config-all.yaml mypy --all-files + displayName: 'Run mypy' diff --git a/azure-pipelines-release.yml b/azure-pipelines-release.yml new file mode 100644 index 000000000..60eff8666 --- /dev/null +++ b/azure-pipelines-release.yml @@ -0,0 +1,277 @@ +# https://dev.azure.com/home-assistant + +trigger: + tags: + include: + - '*' +pr: none +schedules: + - cron: "0 1 * * *" + displayName: "nightly builds" + branches: + include: + - dev + always: true +variables: + - name: versionBuilder + value: '6.3' + - group: docker + - group: github + - group: twine +resources: + repositories: + - repository: azure + type: github + name: 'home-assistant/ci-azure' + endpoint: 'home-assistant' + +stages: + +- stage: 'Validate' + jobs: + - template: templates/azp-job-version.yaml@azure + parameters: + ignoreDev: true + - job: 'Permission' + pool: + vmImage: 'ubuntu-latest' + steps: + - script: | + sudo apt-get install -y --no-install-recommends \ + jq curl + + release="$(Build.SourceBranchName)" + created_by="$(curl -s https://api.github.com/repos/home-assistant/home-assistant/releases/tags/${release} | jq --raw-output '.author.login')" + + if [[ "${created_by}" =~ ^(balloob|pvizeli|fabaff|robbiet480|bramkragten)$ ]]; then + exit 0 + fi + + echo "${created_by} is not allowed to create an release!" + exit 1 + displayName: 'Check rights' + condition: and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags')) + +- stage: 'Build' + jobs: + - job: 'ReleasePython' + condition: startsWith(variables['Build.SourceBranch'], 'refs/tags') + pool: + vmImage: 'ubuntu-latest' + steps: + - task: UsePythonVersion@0 + displayName: 'Use Python 3.7' + inputs: + versionSpec: '3.7' + - script: pip install twine wheel + displayName: 'Install tools' + - script: python setup.py sdist bdist_wheel + displayName: 'Build package' + - script: | + export TWINE_USERNAME="$(twineUser)" + export TWINE_PASSWORD="$(twinePassword)" + + twine upload dist/* --skip-existing + displayName: 'Upload pypi' + - job: 'ReleaseDocker' + timeoutInMinutes: 240 + pool: + vmImage: 'ubuntu-latest' + strategy: + maxParallel: 5 + matrix: + amd64: + buildArch: 'amd64' + buildMachine: 'qemux86-64,intel-nuc' + i386: + buildArch: 'i386' + buildMachine: 'qemux86' + armhf: + buildArch: 'armhf' + buildMachine: 'qemuarm,raspberrypi' + armv7: + buildArch: 'armv7' + buildMachine: 'raspberrypi2,raspberrypi3,raspberrypi4,odroid-xu,tinker' + aarch64: + buildArch: 'aarch64' + buildMachine: 'qemuarm-64,raspberrypi3-64,raspberrypi4-64,odroid-c2,orangepi-prime' + steps: + - template: templates/azp-step-ha-version.yaml@azure + - script: | + docker login -u $(dockerUser) -p $(dockerPassword) + displayName: 'Docker hub login' + - script: docker pull homeassistant/amd64-builder:$(versionBuilder) + displayName: 'Install Builder' + - script: | + set -e + + docker run --rm --privileged \ + -v ~/.docker:/root/.docker:rw \ + -v /run/docker.sock:/run/docker.sock:rw \ + -v $(pwd):/homeassistant:ro \ + homeassistant/amd64-builder:$(versionBuilder) \ + --homeassistant $(homeassistantRelease) "--$(buildArch)" \ + -r https://github.com/home-assistant/hassio-homeassistant \ + -t generic --docker-hub homeassistant + + docker run --rm --privileged \ + -v ~/.docker:/root/.docker \ + -v /run/docker.sock:/run/docker.sock:rw \ + homeassistant/amd64-builder:$(versionBuilder) \ + --homeassistant-machine "$(homeassistantRelease)=$(buildMachine)" \ + -r https://github.com/home-assistant/hassio-homeassistant \ + -t machine --docker-hub homeassistant + displayName: 'Build Release' + +- stage: 'Publish' + jobs: + - job: 'ReleaseHassio' + pool: + vmImage: 'ubuntu-latest' + steps: + - template: templates/azp-step-ha-version.yaml@azure + - script: | + sudo apt-get install -y --no-install-recommends \ + git jq curl + + git config --global user.name "Pascal Vizeli" + git config --global user.email "pvizeli@syshack.ch" + git config --global credential.helper store + + echo "https://$(githubToken):x-oauth-basic@github.com" > $HOME/.git-credentials + displayName: 'Install requirements' + - script: | + set -e + + version="$(homeassistantRelease)" + + git clone https://github.com/home-assistant/hassio-version + cd hassio-version + + dev_version="$(jq --raw-output '.homeassistant.default' dev.json)" + beta_version="$(jq --raw-output '.homeassistant.default' beta.json)" + stable_version="$(jq --raw-output '.homeassistant.default' stable.json)" + + if [[ "$version" =~ d ]]; then + sed -i "s|$dev_version|$version|g" dev.json + elif [[ "$version" =~ b ]]; then + sed -i "s|$beta_version|$version|g" beta.json + else + sed -i "s|$beta_version|$version|g" beta.json + sed -i "s|$stable_version|$version|g" stable.json + fi + + git commit -am "Bump Home Assistant $version" + git push + displayName: 'Update version files' + - job: 'ReleaseDocker' + pool: + vmImage: 'ubuntu-latest' + steps: + - template: templates/azp-step-ha-version.yaml@azure + - script: | + docker login -u $(dockerUser) -p $(dockerPassword) + displayName: 'Docker login' + - script: | + set -e + export DOCKER_CLI_EXPERIMENTAL=enabled + + function create_manifest() { + local tag_l=$1 + local tag_r=$2 + + docker manifest create homeassistant/home-assistant:${tag_l} \ + homeassistant/amd64-homeassistant:${tag_r} \ + homeassistant/i386-homeassistant:${tag_r} \ + homeassistant/armhf-homeassistant:${tag_r} \ + homeassistant/armv7-homeassistant:${tag_r} \ + homeassistant/aarch64-homeassistant:${tag_r} + + docker manifest annotate homeassistant/home-assistant:${tag_l} \ + homeassistant/amd64-homeassistant:${tag_r} \ + --os linux --arch amd64 + + docker manifest annotate homeassistant/home-assistant:${tag_l} \ + homeassistant/i386-homeassistant:${tag_r} \ + --os linux --arch 386 + + docker manifest annotate homeassistant/home-assistant:${tag_l} \ + homeassistant/armhf-homeassistant:${tag_r} \ + --os linux --arch arm --variant=v6 + + docker manifest annotate homeassistant/home-assistant:${tag_l} \ + homeassistant/armv7-homeassistant:${tag_r} \ + --os linux --arch arm --variant=v7 + + docker manifest annotate homeassistant/home-assistant:${tag_l} \ + homeassistant/aarch64-homeassistant:${tag_r} \ + --os linux --arch arm64 --variant=v8 + + docker manifest push --purge homeassistant/home-assistant:${tag_l} + } + + docker pull homeassistant/amd64-homeassistant:$(homeassistantRelease) + docker pull homeassistant/i386-homeassistant:$(homeassistantRelease) + docker pull homeassistant/armhf-homeassistant:$(homeassistantRelease) + docker pull homeassistant/armv7-homeassistant:$(homeassistantRelease) + docker pull homeassistant/aarch64-homeassistant:$(homeassistantRelease) + + # Create version tag + create_manifest "$(homeassistantRelease)" "$(homeassistantRelease)" + + # Create general tags + if [[ "$(homeassistantRelease)" =~ d ]]; then + create_manifest "dev" "$(homeassistantRelease)" + elif [[ "$(homeassistantRelease)" =~ b ]]; then + create_manifest "beta" "$(homeassistantRelease)" + create_manifest "rc" "$(homeassistantRelease)" + else + create_manifest "stable" "$(homeassistantRelease)" + create_manifest "latest" "$(homeassistantRelease)" + create_manifest "beta" "$(homeassistantRelease)" + create_manifest "rc" "$(homeassistantRelease)" + fi + + displayName: 'Create Meta-Image' + +- stage: 'Addidional' + jobs: + - job: 'Updater' + pool: + vmImage: 'ubuntu-latest' + variables: + - group: gcloud + steps: + - template: templates/azp-step-ha-version.yaml@azure + - script: | + set -e + + export CLOUDSDK_CORE_DISABLE_PROMPTS=1 + + curl -o google-cloud-sdk.tar.gz https://dl.google.com/dl/cloudsdk/release/google-cloud-sdk.tar.gz + tar -C . -xvf google-cloud-sdk.tar.gz + rm -f google-cloud-sdk.tar.gz + ./google-cloud-sdk/install.sh + displayName: 'Setup gCloud' + condition: eq(variables['homeassistantReleaseStable'], 'true') + - script: | + set -e + + export CLOUDSDK_CORE_DISABLE_PROMPTS=1 + + echo "$(gcloudAnalytic)" > gcloud_auth.json + ./google-cloud-sdk/bin/gcloud auth activate-service-account --key-file gcloud_auth.json + rm -f gcloud_auth.json + displayName: 'Auth gCloud' + condition: eq(variables['homeassistantReleaseStable'], 'true') + - script: | + set -e + + export CLOUDSDK_CORE_DISABLE_PROMPTS=1 + + ./google-cloud-sdk/bin/gcloud functions deploy Analytics-Receiver \ + --project home-assistant-analytics \ + --update-env-vars VERSION=$(homeassistantRelease) \ + --source gs://analytics-src/function-source.zip + displayName: 'Push details to updater' + condition: eq(variables['homeassistantReleaseStable'], 'true') diff --git a/azure-pipelines-translation.yml b/azure-pipelines-translation.yml new file mode 100644 index 000000000..2fd49c056 --- /dev/null +++ b/azure-pipelines-translation.yml @@ -0,0 +1,66 @@ +# https://dev.azure.com/home-assistant + +trigger: + batch: true + branches: + include: + - dev +pr: none +schedules: + - cron: "30 0 * * *" + displayName: "translation update" + branches: + include: + - dev + always: true +variables: +- group: translation +resources: + repositories: + - repository: azure + type: github + name: 'home-assistant/ci-azure' + endpoint: 'home-assistant' + + +jobs: + +- job: 'Upload' + pool: + vmImage: 'ubuntu-latest' + steps: + - task: UsePythonVersion@0 + displayName: 'Use Python 3.7' + inputs: + versionSpec: '3.7' + - script: | + export LOKALISE_TOKEN="$(lokaliseToken)" + export AZURE_BRANCH="$(Build.SourceBranchName)" + + ./script/translations_upload + displayName: 'Upload Translation' + +- job: 'Download' + dependsOn: + - 'Upload' + condition: or(eq(variables['Build.Reason'], 'Schedule'), eq(variables['Build.Reason'], 'Manual')) + pool: + vmImage: 'ubuntu-latest' + steps: + - task: UsePythonVersion@0 + displayName: 'Use Python 3.7' + inputs: + versionSpec: '3.7' + - template: templates/azp-step-git-init.yaml@azure + - script: | + export LOKALISE_TOKEN="$(lokaliseToken)" + export AZURE_BRANCH="$(Build.SourceBranchName)" + + ./script/translations_download + displayName: 'Download Translation' + - script: | + git checkout dev + git add homeassistant + git commit -am "[ci skip] Translation update" + git push + displayName: 'Update translation' diff --git a/azure-pipelines-wheels.yml b/azure-pipelines-wheels.yml new file mode 100644 index 000000000..5092010c4 --- /dev/null +++ b/azure-pipelines-wheels.yml @@ -0,0 +1,76 @@ +# https://dev.azure.com/home-assistant + +trigger: + batch: true + branches: + include: + - dev + paths: + include: + - requirements_all.txt +pr: none +schedules: +- cron: '0 */4 * * *' + displayName: 'daily builds' + branches: + include: + - dev + always: true +variables: + - name: versionWheels + value: '1.4-3.7-alpine3.10' +resources: + repositories: + - repository: azure + type: github + name: 'home-assistant/ci-azure' + endpoint: 'home-assistant' + +jobs: +- template: templates/azp-job-wheels.yaml@azure + parameters: + builderVersion: '$(versionWheels)' + builderApk: 'build-base;cmake;git;linux-headers;bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;autoconf;automake;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev' + builderPip: 'Cython;numpy' + wheelsRequirement: 'requirements_wheels.txt' + wheelsRequirementDiff: 'requirements_diff.txt' + preBuild: + - script: | + cp requirements_all.txt requirements_wheels.txt + if [[ "$(Build.Reason)" =~ (Schedule|Manual) ]]; then + touch requirements_diff.txt + else + curl -s -o requirements_diff.txt https://raw.githubusercontent.com/home-assistant/home-assistant/master/requirements_all.txt + fi + + requirement_files="requirements_wheels.txt requirements_diff.txt" + for requirement_file in ${requirement_files}; do + sed -i "s|# pybluez|pybluez|g" ${requirement_file} + sed -i "s|# bluepy|bluepy|g" ${requirement_file} + sed -i "s|# beacontools|beacontools|g" ${requirement_file} + sed -i "s|# RPi.GPIO|RPi.GPIO|g" ${requirement_file} + sed -i "s|# raspihats|raspihats|g" ${requirement_file} + sed -i "s|# rpi-rf|rpi-rf|g" ${requirement_file} + sed -i "s|# blinkt|blinkt|g" ${requirement_file} + sed -i "s|# fritzconnection|fritzconnection|g" ${requirement_file} + sed -i "s|# pyuserinput|pyuserinput|g" ${requirement_file} + sed -i "s|# evdev|evdev|g" ${requirement_file} + sed -i "s|# smbus-cffi|smbus-cffi|g" ${requirement_file} + sed -i "s|# i2csense|i2csense|g" ${requirement_file} + sed -i "s|# python-eq3bt|python-eq3bt|g" ${requirement_file} + sed -i "s|# pycups|pycups|g" ${requirement_file} + sed -i "s|# homekit|homekit|g" ${requirement_file} + sed -i "s|# decora_wifi|decora_wifi|g" ${requirement_file} + sed -i "s|# decora|decora|g" ${requirement_file} + sed -i "s|# avion|avion|g" ${requirement_file} + sed -i "s|# PySwitchbot|PySwitchbot|g" ${requirement_file} + sed -i "s|# pySwitchmate|pySwitchmate|g" ${requirement_file} + sed -i "s|# face_recognition|face_recognition|g" ${requirement_file} + sed -i "s|# py_noaa|py_noaa|g" ${requirement_file} + sed -i "s|# bme680|bme680|g" ${requirement_file} + + if [[ "$(buildArch)" =~ arm ]]; then + sed -i "s|# VL53L1X|VL53L1X|g" ${requirement_file} + fi + done + displayName: 'Prepare requirements files for Hass.io' diff --git a/docs/source/_ext/edit_on_github.py b/docs/source/_ext/edit_on_github.py index eef249a3f..a31fb13eb 100644 --- a/docs/source/_ext/edit_on_github.py +++ b/docs/source/_ext/edit_on_github.py @@ -8,7 +8,6 @@ Loosely based on https://github.com/astropy/astropy/pull/347 import os import warnings - __licence__ = 'BSD (3 clause)' diff --git a/docs/source/api/helpers.rst b/docs/source/api/helpers.rst index af186fb13..8ad645b79 100644 --- a/docs/source/api/helpers.rst +++ b/docs/source/api/helpers.rst @@ -4,6 +4,23 @@ homeassistant.helpers package Submodules ---------- +homeassistant.helpers.aiohttp_client module +------------------------------------------- + +.. automodule:: homeassistant.helpers.aiohttp_client + :members: + :undoc-members: + :show-inheritance: + + +homeassistant.helpers.area_registry module +------------------------------------------ + +.. automodule:: homeassistant.helpers.area_registry + :members: + :undoc-members: + :show-inheritance: + homeassistant.helpers.condition module -------------------------------------- @@ -12,6 +29,14 @@ homeassistant.helpers.condition module :undoc-members: :show-inheritance: +homeassistant.helpers.config_entry_flow module +---------------------------------------------- + +.. automodule:: homeassistant.helpers.config_entry_flow + :members: + :undoc-members: + :show-inheritance: + homeassistant.helpers.config_validation module ---------------------------------------------- @@ -20,6 +45,30 @@ homeassistant.helpers.config_validation module :undoc-members: :show-inheritance: +homeassistant.helpers.data_entry_flow module +-------------------------------------------- + +.. automodule:: homeassistant.helpers.data_entry_flow + :members: + :undoc-members: + :show-inheritance: + +homeassistant.helpers.deprecation module +---------------------------------------- + +.. automodule:: homeassistant.helpers.deprecation + :members: + :undoc-members: + :show-inheritance: + +homeassistant.helpers.device_registry module +-------------------------------------------- + +.. automodule:: homeassistant.helpers.device_registry + :members: + :undoc-members: + :show-inheritance: + homeassistant.helpers.discovery module -------------------------------------- @@ -28,6 +77,14 @@ homeassistant.helpers.discovery module :undoc-members: :show-inheritance: +homeassistant.helpers.dispatcher module +--------------------------------------- + +.. automodule:: homeassistant.helpers.dispatcher + :members: + :undoc-members: + :show-inheritance: + homeassistant.helpers.entity module ----------------------------------- @@ -44,6 +101,38 @@ homeassistant.helpers.entity_component module :undoc-members: :show-inheritance: +homeassistant.helpers.entity_platform module +-------------------------------------------- + +.. automodule:: homeassistant.helpers.entity_platform + :members: + :undoc-members: + :show-inheritance: + +homeassistant.helpers.entity_registry module +-------------------------------------------- + +.. automodule:: homeassistant.helpers.entity_registry + :members: + :undoc-members: + :show-inheritance: + +homeassistant.helpers.entity_values module +------------------------------------------ + +.. automodule:: homeassistant.helpers.entity_values + :members: + :undoc-members: + :show-inheritance: + +homeassistant.helpers.entityfilter module +----------------------------------------- + +.. automodule:: homeassistant.helpers.entityfilter + :members: + :undoc-members: + :show-inheritance: + homeassistant.helpers.event module ---------------------------------- @@ -52,10 +141,26 @@ homeassistant.helpers.event module :undoc-members: :show-inheritance: -homeassistant.helpers.event_decorators module ---------------------------------------------- +homeassistant.helpers.icon module +--------------------------------- -.. automodule:: homeassistant.helpers.event_decorators +.. automodule:: homeassistant.helpers.icon + :members: + :undoc-members: + :show-inheritance: + +homeassistant.helpers.intent module +----------------------------------- + +.. automodule:: homeassistant.helpers.intent + :members: + :undoc-members: + :show-inheritance: + +homeassistant.helpers.json module +--------------------------------- + +.. automodule:: homeassistant.helpers.json :members: :undoc-members: :show-inheritance: @@ -68,6 +173,22 @@ homeassistant.helpers.location module :undoc-members: :show-inheritance: +homeassistant.helpers.logging module +------------------------------------ + +.. automodule:: homeassistant.helpers.logging + :members: + :undoc-members: + :show-inheritance: + +homeassistant.helpers.restore_state module +------------------------------------------ + +.. automodule:: homeassistant.helpers.restore_state + :members: + :undoc-members: + :show-inheritance: + homeassistant.helpers.script module ----------------------------------- @@ -84,6 +205,14 @@ homeassistant.helpers.service module :undoc-members: :show-inheritance: +homeassistant.helpers.signal module +----------------------------------- + +.. automodule:: homeassistant.helpers.signal + :members: + :undoc-members: + :show-inheritance: + homeassistant.helpers.state module ---------------------------------- @@ -92,6 +221,38 @@ homeassistant.helpers.state module :undoc-members: :show-inheritance: +homeassistant.helpers.storage module +------------------------------------ + +.. automodule:: homeassistant.helpers.storage + :members: + :undoc-members: + :show-inheritance: + +homeassistant.helpers.sun module +-------------------------------- + +.. automodule:: homeassistant.helpers.sun + :members: + :undoc-members: + :show-inheritance: + +homeassistant.helpers.system_info module +---------------------------------------- + +.. automodule:: homeassistant.helpers.system_info + :members: + :undoc-members: + :show-inheritance: + +homeassistant.helpers.temperature module +---------------------------------------- + +.. automodule:: homeassistant.helpers.temperature + :members: + :undoc-members: + :show-inheritance: + homeassistant.helpers.template module ------------------------------------- @@ -100,6 +261,14 @@ homeassistant.helpers.template module :undoc-members: :show-inheritance: +homeassistant.helpers.translation module +----------------------------------------- + +.. automodule:: homeassistant.helpers.translation + :members: + :undoc-members: + :show-inheritance: + homeassistant.helpers.typing module ----------------------------------- diff --git a/docs/source/conf.py b/docs/source/conf.py index b5428ede8..f36b5b812 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -17,11 +17,11 @@ # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # -import sys -import os import inspect +import os +import sys -from homeassistant.const import __version__, __short_version__ +from homeassistant.const import __short_version__, __version__ PROJECT_NAME = 'Home Assistant' PROJECT_PACKAGE_NAME = 'homeassistant' diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index af89564f1..bcc972522 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -1,60 +1,76 @@ """Start Home Assistant.""" -from __future__ import print_function - import argparse +import asyncio import os import platform import subprocess import sys import threading -from typing import List, Dict, Any # noqa pylint: disable=unused-import +from typing import TYPE_CHECKING, Any, Dict, List + +from homeassistant.const import REQUIRED_PYTHON_VER, RESTART_EXIT_CODE, __version__ + +if TYPE_CHECKING: + from homeassistant import core -from homeassistant import monkey_patch -from homeassistant.const import ( - __version__, - EVENT_HOMEASSISTANT_START, - REQUIRED_PYTHON_VER, - RESTART_EXIT_CODE, -) +def set_loop() -> None: + """Attempt to use different loop.""" + from asyncio.events import BaseDefaultEventLoopPolicy + if sys.platform == "win32": + if hasattr(asyncio, "WindowsProactorEventLoopPolicy"): + # pylint: disable=no-member + policy = asyncio.WindowsProactorEventLoopPolicy() + else: -def attempt_use_uvloop() -> None: - """Attempt to use uvloop.""" - import asyncio - try: - import uvloop - except ImportError: - pass - else: - asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) + class ProactorPolicy(BaseDefaultEventLoopPolicy): + """Event loop policy to create proactor loops.""" + + _loop_factory = asyncio.ProactorEventLoop + + policy = ProactorPolicy() + + asyncio.set_event_loop_policy(policy) def validate_python() -> None: """Validate that the right Python version is running.""" if sys.version_info[:3] < REQUIRED_PYTHON_VER: - print("Home Assistant requires at least Python {}.{}.{}".format( - *REQUIRED_PYTHON_VER)) + print( + "Home Assistant requires at least Python {}.{}.{}".format( + *REQUIRED_PYTHON_VER + ) + ) sys.exit(1) def ensure_config_path(config_dir: str) -> None: """Validate the configuration directory.""" import homeassistant.config as config_util - lib_dir = os.path.join(config_dir, 'deps') + + lib_dir = os.path.join(config_dir, "deps") # Test if configuration directory exists if not os.path.isdir(config_dir): if config_dir != config_util.get_default_config_dir(): - print(('Fatal Error: Specified configuration directory does ' - 'not exist {} ').format(config_dir)) + print( + ( + "Fatal Error: Specified configuration directory does " + "not exist {} " + ).format(config_dir) + ) sys.exit(1) try: os.mkdir(config_dir) except OSError: - print(('Fatal Error: Unable to create default configuration ' - 'directory {} ').format(config_dir)) + print( + ( + "Fatal Error: Unable to create default configuration " + "directory {} " + ).format(config_dir) + ) sys.exit(1) # Test if library directory exists @@ -62,18 +78,22 @@ def ensure_config_path(config_dir: str) -> None: try: os.mkdir(lib_dir) except OSError: - print(('Fatal Error: Unable to create library ' - 'directory {} ').format(lib_dir)) + print( + ("Fatal Error: Unable to create library " "directory {} ").format( + lib_dir + ) + ) sys.exit(1) -def ensure_config_file(config_dir: str) -> str: +async def ensure_config_file(hass: "core.HomeAssistant", config_dir: str) -> str: """Ensure configuration file exists.""" import homeassistant.config as config_util - config_path = config_util.ensure_config_exists(config_dir) + + config_path = await config_util.async_ensure_config_exists(hass, config_dir) if config_path is None: - print('Error getting configuration path') + print("Error getting configuration path") sys.exit(1) return config_path @@ -82,71 +102,72 @@ def ensure_config_file(config_dir: str) -> str: def get_arguments() -> argparse.Namespace: """Get parsed passed in arguments.""" import homeassistant.config as config_util + parser = argparse.ArgumentParser( - description="Home Assistant: Observe, Control, Automate.") - parser.add_argument('--version', action='version', version=__version__) + description="Home Assistant: Observe, Control, Automate." + ) + parser.add_argument("--version", action="version", version=__version__) parser.add_argument( - '-c', '--config', - metavar='path_to_config_dir', + "-c", + "--config", + metavar="path_to_config_dir", default=config_util.get_default_config_dir(), - help="Directory that contains the Home Assistant configuration") + help="Directory that contains the Home Assistant configuration", + ) parser.add_argument( - '--demo-mode', - action='store_true', - help='Start Home Assistant in demo mode') + "--demo-mode", action="store_true", help="Start Home Assistant in demo mode" + ) parser.add_argument( - '--debug', - action='store_true', - help='Start Home Assistant in debug mode') + "--debug", action="store_true", help="Start Home Assistant in debug mode" + ) parser.add_argument( - '--open-ui', - action='store_true', - help='Open the webinterface in a browser') + "--open-ui", action="store_true", help="Open the webinterface in a browser" + ) parser.add_argument( - '--skip-pip', - action='store_true', - help='Skips pip install of required packages on startup') + "--skip-pip", + action="store_true", + help="Skips pip install of required packages on startup", + ) parser.add_argument( - '-v', '--verbose', - action='store_true', - help="Enable verbose logging to file.") + "-v", "--verbose", action="store_true", help="Enable verbose logging to file." + ) parser.add_argument( - '--pid-file', - metavar='path_to_pid_file', + "--pid-file", + metavar="path_to_pid_file", default=None, - help='Path to PID file useful for running as daemon') + help="Path to PID file useful for running as daemon", + ) parser.add_argument( - '--log-rotate-days', + "--log-rotate-days", type=int, default=None, - help='Enables daily log rotation and keeps up to the specified days') + help="Enables daily log rotation and keeps up to the specified days", + ) parser.add_argument( - '--log-file', + "--log-file", type=str, default=None, - help='Log file to write to. If not set, CONFIG/home-assistant.log ' - 'is used') + 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") + "--log-no-color", action="store_true", help="Disable color logs" + ) parser.add_argument( - '--runner', - action='store_true', - help='On restart exit with code {}'.format(RESTART_EXIT_CODE)) + "--runner", + action="store_true", + help=f"On restart exit with code {RESTART_EXIT_CODE}", + ) parser.add_argument( - '--script', - nargs=argparse.REMAINDER, - help='Run one of the embedded scripts') + "--script", nargs=argparse.REMAINDER, help="Run one of the embedded scripts" + ) if os.name == "posix": parser.add_argument( - '--daemon', - action='store_true', - help='Run Home Assistant as daemon') + "--daemon", action="store_true", help="Run Home Assistant as daemon" + ) arguments = parser.parse_args() if os.name != "posix" or arguments.debug or arguments.runner: - setattr(arguments, 'daemon', False) + setattr(arguments, "daemon", False) return arguments @@ -167,8 +188,8 @@ def daemonize() -> None: sys.exit(0) # redirect standard file descriptors to devnull - infd = open(os.devnull, 'r') - outfd = open(os.devnull, 'a+') + infd = open(os.devnull, "r") + outfd = open(os.devnull, "a+") sys.stdout.flush() sys.stderr.flush() os.dup2(infd.fileno(), sys.stdin.fileno()) @@ -180,9 +201,9 @@ def check_pid(pid_file: str) -> None: """Check that Home Assistant is not already running.""" # Check pid file try: - with open(pid_file, 'r') as file: + with open(pid_file, "r") as file: pid = int(file.readline()) - except IOError: + except OSError: # PID File does not exist return @@ -195,7 +216,7 @@ def check_pid(pid_file: str) -> None: except OSError: # PID does not exist return - print('Fatal Error: HomeAssistant is already running.') + print("Fatal Error: HomeAssistant is already running.") sys.exit(1) @@ -203,10 +224,10 @@ def write_pid(pid_file: str) -> None: """Create a PID File.""" pid = os.getpid() try: - with open(pid_file, 'w') as file: + with open(pid_file, "w") as file: file.write(str(pid)) - except IOError: - print('Fatal Error: Unable to write pid file {}'.format(pid_file)) + except OSError: + print(f"Fatal Error: Unable to write pid file {pid_file}") sys.exit(1) @@ -224,71 +245,55 @@ def closefds_osx(min_fd: int, max_fd: int) -> None: val = fcntl(_fd, F_GETFD) if not val & FD_CLOEXEC: fcntl(_fd, F_SETFD, val | FD_CLOEXEC) - except IOError: + except OSError: pass def cmdline() -> List[str]: """Collect path and arguments to re-execute the current hass instance.""" - if os.path.basename(sys.argv[0]) == '__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 - arg != '--daemon'] + os.environ["PYTHONPATH"] = os.path.dirname(modulepath) + return [sys.executable] + [arg for arg in sys.argv if arg != "--daemon"] - return [arg for arg in sys.argv if arg != '--daemon'] + return [arg for arg in sys.argv if arg != "--daemon"] -async def setup_and_run_hass(config_dir: str, - args: argparse.Namespace) -> int: +async def setup_and_run_hass(config_dir: str, args: argparse.Namespace) -> int: """Set up HASS and run.""" from homeassistant import bootstrap, core - # Run a simple daemon runner process on Windows to handle restarts - if os.name == 'nt' and '--runner' not in sys.argv: - nt_args = cmdline() + ['--runner'] - while True: - try: - subprocess.check_call(nt_args) - sys.exit(0) - except subprocess.CalledProcessError as exc: - if exc.returncode != RESTART_EXIT_CODE: - sys.exit(exc.returncode) - hass = core.HomeAssistant() if args.demo_mode: - config = { - 'frontend': {}, - 'demo': {} - } # type: Dict[str, Any] + config: Dict[str, Any] = {"frontend": {}, "demo": {}} bootstrap.async_from_config_dict( - config, hass, config_dir=config_dir, verbose=args.verbose, - skip_pip=args.skip_pip, log_rotate_days=args.log_rotate_days, - log_file=args.log_file, log_no_color=args.log_no_color) - else: - config_file = ensure_config_file(config_dir) - print('Config directory:', config_dir) - await bootstrap.async_from_config_file( - config_file, hass, verbose=args.verbose, skip_pip=args.skip_pip, - log_rotate_days=args.log_rotate_days, log_file=args.log_file, - log_no_color=args.log_no_color) - - if args.open_ui: - # Imported here to avoid importing asyncio before monkey patch - from homeassistant.util.async_ import run_callback_threadsafe - - def open_browser(_: Any) -> None: - """Open the web interface in a browser.""" - if hass.config.api is not None: - import webbrowser - webbrowser.open(hass.config.api.base_url) - - run_callback_threadsafe( - hass.loop, - hass.bus.async_listen_once, - EVENT_HOMEASSISTANT_START, open_browser + config, + hass, + config_dir=config_dir, + verbose=args.verbose, + skip_pip=args.skip_pip, + log_rotate_days=args.log_rotate_days, + log_file=args.log_file, + log_no_color=args.log_no_color, ) + else: + config_file = await ensure_config_file(hass, config_dir) + print("Config directory:", config_dir) + await bootstrap.async_from_config_file( + config_file, + hass, + verbose=args.verbose, + skip_pip=args.skip_pip, + log_rotate_days=args.log_rotate_days, + log_file=args.log_file, + log_no_color=args.log_no_color, + ) + + if args.open_ui and hass.config.api is not None: + import webbrowser + + hass.add_job(webbrowser.open, hass.config.api.base_url) return await hass.async_run() @@ -297,17 +302,17 @@ def try_to_restart() -> None: """Attempt to clean up state and start a new Home Assistant instance.""" # Things should be mostly shut down already at this point, now just try # to clean up things that may have been left behind. - sys.stderr.write('Home Assistant attempting to restart.\n') + sys.stderr.write("Home Assistant attempting to restart.\n") # Count remaining threads, ideally there should only be one non-daemonized # thread left (which is us). Nothing we really do with it, but it might be # useful when debugging shutdown/restart issues. try: - nthreads = sum(thread.is_alive() and not thread.daemon - for thread in threading.enumerate()) + nthreads = sum( + thread.is_alive() and not thread.daemon for thread in threading.enumerate() + ) if nthreads > 1: - sys.stderr.write( - "Found {} non-daemonic threads.\n".format(nthreads)) + sys.stderr.write(f"Found {nthreads} non-daemonic threads.\n") # Somehow we sometimes seem to trigger an assertion in the python threading # module. It seems we find threads that have no associated OS level thread @@ -321,7 +326,7 @@ def try_to_restart() -> None: except ValueError: max_fd = 256 - if platform.system() == 'Darwin': + if platform.system() == "Darwin": closefds_osx(3, max_fd) else: os.closerange(3, max_fd) @@ -339,18 +344,26 @@ def main() -> int: """Start Home Assistant.""" validate_python() - 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() + set_loop() - attempt_use_uvloop() + # Run a simple daemon runner process on Windows to handle restarts + if os.name == "nt" and "--runner" not in sys.argv: + nt_args = cmdline() + ["--runner"] + while True: + try: + subprocess.check_call(nt_args) + sys.exit(0) + except KeyboardInterrupt: + sys.exit(0) + except subprocess.CalledProcessError as exc: + if exc.returncode != RESTART_EXIT_CODE: + sys.exit(exc.returncode) args = get_arguments() if args.script is not None: from homeassistant import scripts + return scripts.run(args.script) config_dir = os.path.join(os.getcwd(), args.config) @@ -364,12 +377,11 @@ def main() -> int: if args.pid_file: write_pid(args.pid_file) - from homeassistant.util.async_ import asyncio_run - exit_code = asyncio_run(setup_and_run_hass(config_dir, args)) + exit_code = asyncio.run(setup_and_run_hass(config_dir, args)) if exit_code == RESTART_EXIT_CODE and not args.runner: try_to_restart() - return exit_code # type: ignore # mypy cannot yet infer it + return exit_code if __name__ == "__main__": diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index c6f978640..e4437bea8 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -1,20 +1,24 @@ """Provide an authentication layer for Home Assistant.""" import asyncio -import logging from collections import OrderedDict from datetime import timedelta +import logging from typing import Any, Dict, List, Optional, Tuple, cast import jwt from homeassistant import data_entry_flow from homeassistant.auth.const import ACCESS_TOKEN_EXPIRATION -from homeassistant.core import callback, HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.util import dt as dt_util from . import auth_store, models -from .mfa_modules import auth_mfa_module_from_config, MultiFactorAuthModule -from .providers import auth_provider_from_config, AuthProvider, LoginFlow +from .const import GROUP_ID_ADMIN +from .mfa_modules import MultiFactorAuthModule, auth_mfa_module_from_config +from .providers import AuthProvider, LoginFlow, auth_provider_from_config + +EVENT_USER_ADDED = "user_added" +EVENT_USER_REMOVED = "user_removed" _LOGGER = logging.getLogger(__name__) _MfaModuleDict = Dict[str, MultiFactorAuthModule] @@ -23,9 +27,10 @@ _ProviderDict = Dict[_ProviderKey, AuthProvider] async def auth_manager_from_config( - hass: HomeAssistant, - provider_configs: List[Dict[str, Any]], - module_configs: List[Dict[str, Any]]) -> 'AuthManager': + hass: HomeAssistant, + provider_configs: List[Dict[str, Any]], + module_configs: List[Dict[str, Any]], +) -> "AuthManager": """Initialize an auth manager from config. CORE_CONFIG_SCHEMA will make sure do duplicated auth providers or @@ -34,24 +39,27 @@ async def auth_manager_from_config( store = auth_store.AuthStore(hass) if provider_configs: providers = await asyncio.gather( - *[auth_provider_from_config(hass, store, config) - for config in provider_configs]) + *( + auth_provider_from_config(hass, store, config) + for config in provider_configs + ) + ) else: - providers = () + providers = [] # So returned auth providers are in same order as config - provider_hash = OrderedDict() # type: _ProviderDict + provider_hash: _ProviderDict = OrderedDict() for provider in providers: key = (provider.type, provider.id) provider_hash[key] = provider if module_configs: modules = await asyncio.gather( - *[auth_mfa_module_from_config(hass, config) - for config in module_configs]) + *(auth_mfa_module_from_config(hass, config) for config in module_configs) + ) else: - modules = () + modules = [] # So returned auth modules are in same order as config - module_hash = OrderedDict() # type: _MfaModuleDict + module_hash: _MfaModuleDict = OrderedDict() for module in modules: module_hash[module.id] = module @@ -62,34 +70,21 @@ async def auth_manager_from_config( class AuthManager: """Manage the authentication for Home Assistant.""" - def __init__(self, hass: HomeAssistant, store: auth_store.AuthStore, - providers: _ProviderDict, mfa_modules: _MfaModuleDict) \ - -> None: + def __init__( + self, + hass: HomeAssistant, + store: auth_store.AuthStore, + providers: _ProviderDict, + mfa_modules: _MfaModuleDict, + ) -> None: """Initialize the auth manager.""" self.hass = hass self._store = store self._providers = providers self._mfa_modules = mfa_modules self.login_flow = data_entry_flow.FlowManager( - hass, self._async_create_login_flow, - self._async_finish_login_flow) - - @property - def active(self) -> bool: - """Return if any auth providers are registered.""" - return bool(self._providers) - - @property - def support_legacy(self) -> bool: - """ - Return if legacy_api_password auth providers are registered. - - Should be removed when we removed legacy_api_password auth providers. - """ - for provider_type, _ in self._providers: - if provider_type == 'legacy_api_password': - return True - return False + hass, self._async_create_login_flow, self._async_finish_login_flow + ) @property def auth_providers(self) -> List[AuthProvider]: @@ -101,9 +96,22 @@ class AuthManager: """Return a list of available auth modules.""" return list(self._mfa_modules.values()) - def get_auth_mfa_module(self, module_id: str) \ - -> Optional[MultiFactorAuthModule]: - """Return an multi-factor auth module, None if not found.""" + def get_auth_provider( + self, provider_type: str, provider_id: str + ) -> Optional[AuthProvider]: + """Return an auth provider, None if not found.""" + return self._providers.get((provider_type, provider_id)) + + def get_auth_providers(self, provider_type: str) -> List[AuthProvider]: + """Return a List of auth provider of one type, Empty if not found.""" + return [ + provider + for (p_type, _), provider in self._providers.items() + if p_type == provider_type + ] + + def get_auth_mfa_module(self, module_id: str) -> Optional[MultiFactorAuthModule]: + """Return a multi-factor auth module, None if not found.""" return self._mfa_modules.get(module_id) async def async_get_users(self) -> List[models.User]: @@ -114,8 +122,18 @@ class AuthManager: """Retrieve a user.""" return await self._store.async_get_user(user_id) + async def async_get_owner(self) -> Optional[models.User]: + """Retrieve the owner.""" + users = await self.async_get_users() + return next((user for user in users if user.is_owner), None) + + async def async_get_group(self, group_id: str) -> Optional[models.Group]: + """Retrieve all groups.""" + return await self._store.async_get_group(group_id) + async def async_get_user_by_credentials( - self, credentials: models.Credentials) -> Optional[models.User]: + self, credentials: models.Credentials + ) -> Optional[models.User]: """Get a user by credential, return None if not found.""" for user in await self.async_get_users(): for creds in user.credentials: @@ -124,52 +142,66 @@ class AuthManager: return None - async def async_create_system_user(self, name: str) -> models.User: + async def async_create_system_user( + self, name: str, group_ids: Optional[List[str]] = None + ) -> models.User: """Create a system user.""" - return await self._store.async_create_user( - name=name, - system_generated=True, - is_active=True, + user = await self._store.async_create_user( + name=name, system_generated=True, is_active=True, group_ids=group_ids or [] ) + self.hass.bus.async_fire(EVENT_USER_ADDED, {"user_id": user.id}) + + return user + async def async_create_user(self, name: str) -> models.User: """Create a user.""" - kwargs = { - 'name': name, - 'is_active': True, - } # type: Dict[str, Any] + kwargs: Dict[str, Any] = { + "name": name, + "is_active": True, + "group_ids": [GROUP_ID_ADMIN], + } if await self._user_should_be_owner(): - kwargs['is_owner'] = True + kwargs["is_owner"] = True - return await self._store.async_create_user(**kwargs) + user = await self._store.async_create_user(**kwargs) - async def async_get_or_create_user(self, credentials: models.Credentials) \ - -> models.User: + self.hass.bus.async_fire(EVENT_USER_ADDED, {"user_id": user.id}) + + return user + + async def async_get_or_create_user( + self, credentials: models.Credentials + ) -> models.User: """Get or create a user.""" if not credentials.is_new: user = await self.async_get_user_by_credentials(credentials) if user is None: - raise ValueError('Unable to find the user.') - else: - return user + raise ValueError("Unable to find the user.") + return user auth_provider = self._async_get_auth_provider(credentials) if auth_provider is None: - raise RuntimeError('Credential with unknown provider encountered') + raise RuntimeError("Credential with unknown provider encountered") - info = await auth_provider.async_user_meta_for_credentials( - credentials) + info = await auth_provider.async_user_meta_for_credentials(credentials) - return await self._store.async_create_user( + user = await self._store.async_create_user( credentials=credentials, name=info.name, is_active=info.is_active, + group_ids=[GROUP_ID_ADMIN], ) - async def async_link_user(self, user: models.User, - credentials: models.Credentials) -> None: + self.hass.bus.async_fire(EVENT_USER_ADDED, {"user_id": user.id}) + + return user + + async def async_link_user( + self, user: models.User, credentials: models.Credentials + ) -> None: """Link credentials to an existing user.""" await self._store.async_link_user(user, credentials) @@ -185,6 +217,22 @@ class AuthManager: await self._store.async_remove_user(user) + self.hass.bus.async_fire(EVENT_USER_REMOVED, {"user_id": user.id}) + + async def async_update_user( + self, + user: models.User, + name: Optional[str] = None, + group_ids: Optional[List[str]] = None, + ) -> None: + """Update a user.""" + kwargs: Dict[str, Any] = {} + if name is not None: + kwargs["name"] = name + if group_ids is not None: + kwargs["group_ids"] = group_ids + await self._store.async_update_user(user, **kwargs) + async def async_activate_user(self, user: models.User) -> None: """Activate a user.""" await self._store.async_activate_user(user) @@ -192,73 +240,77 @@ class AuthManager: async def async_deactivate_user(self, user: models.User) -> None: """Deactivate a user.""" if user.is_owner: - raise ValueError('Unable to deactive the owner') + raise ValueError("Unable to deactive the owner") await self._store.async_deactivate_user(user) - async def async_remove_credentials( - self, credentials: models.Credentials) -> None: + async def async_remove_credentials(self, credentials: models.Credentials) -> None: """Remove credentials.""" provider = self._async_get_auth_provider(credentials) - if (provider is not None and - hasattr(provider, 'async_will_remove_credentials')): + if provider is not None and hasattr(provider, "async_will_remove_credentials"): # https://github.com/python/mypy/issues/1424 await provider.async_will_remove_credentials( # type: ignore - credentials) + credentials + ) await self._store.async_remove_credentials(credentials) - async def async_enable_user_mfa(self, user: models.User, - mfa_module_id: str, data: Any) -> None: + async def async_enable_user_mfa( + self, user: models.User, mfa_module_id: str, data: Any + ) -> None: """Enable a multi-factor auth module for user.""" if user.system_generated: - raise ValueError('System generated users cannot enable ' - 'multi-factor auth module.') + raise ValueError( + "System generated users cannot enable multi-factor auth module." + ) module = self.get_auth_mfa_module(mfa_module_id) if module is None: - raise ValueError('Unable find multi-factor auth module: {}' - .format(mfa_module_id)) + raise ValueError(f"Unable find multi-factor auth module: {mfa_module_id}") await module.async_setup_user(user.id, data) - async def async_disable_user_mfa(self, user: models.User, - mfa_module_id: str) -> None: + async def async_disable_user_mfa( + self, user: models.User, mfa_module_id: str + ) -> None: """Disable a multi-factor auth module for user.""" if user.system_generated: - raise ValueError('System generated users cannot disable ' - 'multi-factor auth module.') + raise ValueError( + "System generated users cannot disable multi-factor auth module." + ) module = self.get_auth_mfa_module(mfa_module_id) if module is None: - raise ValueError('Unable find multi-factor auth module: {}' - .format(mfa_module_id)) + raise ValueError(f"Unable find multi-factor auth module: {mfa_module_id}") await module.async_depose_user(user.id) async def async_get_enabled_mfa(self, user: models.User) -> Dict[str, str]: """List enabled mfa modules for user.""" - modules = OrderedDict() # type: Dict[str, str] + modules: Dict[str, str] = OrderedDict() for module_id, module in self._mfa_modules.items(): if await module.async_is_user_setup(user.id): modules[module_id] = module.name return modules async def async_create_refresh_token( - self, user: models.User, client_id: Optional[str] = None, - client_name: Optional[str] = None, - client_icon: Optional[str] = None, - token_type: Optional[str] = None, - access_token_expiration: timedelta = ACCESS_TOKEN_EXPIRATION) \ - -> models.RefreshToken: + self, + user: models.User, + client_id: Optional[str] = None, + client_name: Optional[str] = None, + client_icon: Optional[str] = None, + token_type: Optional[str] = None, + access_token_expiration: timedelta = ACCESS_TOKEN_EXPIRATION, + ) -> models.RefreshToken: """Create a new refresh token for a user.""" if not user.is_active: - raise ValueError('User is not active') + raise ValueError("User is not active") if user.system_generated and client_id is not None: raise ValueError( - 'System generated users cannot have refresh tokens connected ' - 'to a client.') + "System generated users cannot have refresh tokens connected " + "to a client." + ) if token_type is None: if user.system_generated: @@ -268,62 +320,76 @@ class AuthManager: if user.system_generated != (token_type == models.TOKEN_TYPE_SYSTEM): raise ValueError( - 'System generated users can only have system type ' - 'refresh tokens') + "System generated users can only have system type refresh tokens" + ) if token_type == models.TOKEN_TYPE_NORMAL and client_id is None: - raise ValueError('Client is required to generate a refresh token.') + raise ValueError("Client is required to generate a refresh token.") - if (token_type == models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN and - client_name is None): - raise ValueError('Client_name is required for long-lived access ' - 'token') + if ( + token_type == models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN + and client_name is None + ): + raise ValueError("Client_name is required for long-lived access token") if token_type == models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN: for token in user.refresh_tokens.values(): - if (token.client_name == client_name and token.token_type == - models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN): + if ( + token.client_name == client_name + and token.token_type == models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN + ): # Each client_name can only have one # long_lived_access_token type of refresh token - raise ValueError('{} already exists'.format(client_name)) + raise ValueError(f"{client_name} already exists") return await self._store.async_create_refresh_token( - user, client_id, client_name, client_icon, - token_type, access_token_expiration) + user, + client_id, + client_name, + client_icon, + token_type, + access_token_expiration, + ) async def async_get_refresh_token( - self, token_id: str) -> Optional[models.RefreshToken]: + self, token_id: str + ) -> Optional[models.RefreshToken]: """Get refresh token by id.""" return await self._store.async_get_refresh_token(token_id) async def async_get_refresh_token_by_token( - self, token: str) -> Optional[models.RefreshToken]: + self, token: str + ) -> Optional[models.RefreshToken]: """Get refresh token by token.""" return await self._store.async_get_refresh_token_by_token(token) - async def async_remove_refresh_token(self, - refresh_token: models.RefreshToken) \ - -> None: + async def async_remove_refresh_token( + self, refresh_token: models.RefreshToken + ) -> None: """Delete a refresh token.""" await self._store.async_remove_refresh_token(refresh_token) @callback - def async_create_access_token(self, - refresh_token: models.RefreshToken, - remote_ip: Optional[str] = None) -> str: + def async_create_access_token( + self, refresh_token: models.RefreshToken, remote_ip: Optional[str] = None + ) -> str: """Create a new access token.""" self._store.async_log_refresh_token_usage(refresh_token, remote_ip) - # pylint: disable=no-self-use now = dt_util.utcnow() - return jwt.encode({ - 'iss': refresh_token.id, - 'iat': now, - 'exp': now + refresh_token.access_token_expiration, - }, refresh_token.jwt_key, algorithm='HS256').decode() + return jwt.encode( + { + "iss": refresh_token.id, + "iat": now, + "exp": now + refresh_token.access_token_expiration, + }, + refresh_token.jwt_key, + algorithm="HS256", + ).decode() async def async_validate_access_token( - self, token: str) -> Optional[models.RefreshToken]: + self, token: str + ) -> Optional[models.RefreshToken]: """Return refresh token if an access token is valid.""" try: unverif_claims = jwt.decode(token, verify=False) @@ -331,23 +397,18 @@ class AuthManager: return None refresh_token = await self.async_get_refresh_token( - cast(str, unverif_claims.get('iss'))) + cast(str, unverif_claims.get("iss")) + ) if refresh_token is None: - jwt_key = '' - issuer = '' + jwt_key = "" + issuer = "" else: jwt_key = refresh_token.jwt_key issuer = refresh_token.id try: - jwt.decode( - token, - jwt_key, - leeway=10, - issuer=issuer, - algorithms=['HS256'] - ) + jwt.decode(token, jwt_key, leeway=10, issuer=issuer, algorithms=["HS256"]) except jwt.InvalidTokenError: return None @@ -357,31 +418,32 @@ class AuthManager: return refresh_token async def _async_create_login_flow( - self, handler: _ProviderKey, *, context: Optional[Dict], - data: Optional[Any]) -> data_entry_flow.FlowHandler: + self, handler: _ProviderKey, *, context: Optional[Dict], data: Optional[Any] + ) -> data_entry_flow.FlowHandler: """Create a login flow.""" auth_provider = self._providers[handler] return await auth_provider.async_login_flow(context) async def _async_finish_login_flow( - self, flow: LoginFlow, result: Dict[str, Any]) \ - -> Dict[str, Any]: + self, flow: LoginFlow, result: Dict[str, Any] + ) -> Dict[str, Any]: """Return a user as result of login flow.""" - if result['type'] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY: + if result["type"] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY: return result # we got final result - if isinstance(result['data'], models.User): - result['result'] = result['data'] + if isinstance(result["data"], models.User): + result["result"] = result["data"] return result - auth_provider = self._providers[result['handler']] + auth_provider = self._providers[result["handler"]] credentials = await auth_provider.async_get_or_create_credentials( - result['data']) + result["data"] + ) - if flow.context is not None and flow.context.get('credential_only'): - result['result'] = credentials + if flow.context.get("credential_only"): + result["result"] = credentials return result # multi-factor module cannot enabled for new credential @@ -396,15 +458,18 @@ class AuthManager: flow.available_mfa_modules = modules return await flow.async_step_select_mfa_module() - result['result'] = await self.async_get_or_create_user(credentials) + result["result"] = await self.async_get_or_create_user(credentials) return result @callback def _async_get_auth_provider( - self, credentials: models.Credentials) -> Optional[AuthProvider]: + self, credentials: models.Credentials + ) -> Optional[AuthProvider]: """Get auth provider from a set of credentials.""" - auth_provider_key = (credentials.auth_provider_type, - credentials.auth_provider_id) + auth_provider_key = ( + credentials.auth_provider_type, + credentials.auth_provider_id, + ) return self._providers.get(auth_provider_key) async def _user_should_be_owner(self) -> bool: diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py index fb4700c80..57ec9ee63 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -1,18 +1,25 @@ """Storage for auth models.""" +import asyncio from collections import OrderedDict from datetime import timedelta -from logging import getLogger -from typing import Any, Dict, List, Optional # noqa: F401 import hmac +from logging import getLogger +from typing import Any, Dict, List, Optional from homeassistant.auth.const import ACCESS_TOKEN_EXPIRATION from homeassistant.core import HomeAssistant, callback from homeassistant.util import dt as dt_util from . import models +from .const import GROUP_ID_ADMIN, GROUP_ID_READ_ONLY, GROUP_ID_USER +from .permissions import PermissionLookup, system_policies +from .permissions.types import PolicyType STORAGE_VERSION = 1 -STORAGE_KEY = 'auth' +STORAGE_KEY = "auth" +GROUP_NAME_ADMIN = "Administrators" +GROUP_NAME_USER = "Users" +GROUP_NAME_READ_ONLY = "Read Only" class AuthStore: @@ -27,8 +34,29 @@ class AuthStore: def __init__(self, hass: HomeAssistant) -> None: """Initialize the auth store.""" self.hass = hass - self._users = None # type: Optional[Dict[str, models.User]] - self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + self._users: Optional[Dict[str, models.User]] = None + self._groups: Optional[Dict[str, models.Group]] = None + self._perm_lookup: Optional[PermissionLookup] = None + self._store = hass.helpers.storage.Store( + STORAGE_VERSION, STORAGE_KEY, private=True + ) + self._lock = asyncio.Lock() + + async def async_get_groups(self) -> List[models.Group]: + """Retrieve all users.""" + if self._groups is None: + await self._async_load() + assert self._groups is not None + + return list(self._groups.values()) + + async def async_get_group(self, group_id: str) -> Optional[models.Group]: + """Retrieve all users.""" + if self._groups is None: + await self._async_load() + assert self._groups is not None + + return self._groups.get(group_id) async def async_get_users(self) -> List[models.User]: """Retrieve all users.""" @@ -47,27 +75,44 @@ class AuthStore: return self._users.get(user_id) async def async_create_user( - self, name: Optional[str], is_owner: Optional[bool] = None, - is_active: Optional[bool] = None, - system_generated: Optional[bool] = None, - credentials: Optional[models.Credentials] = None) -> models.User: + self, + name: Optional[str], + is_owner: Optional[bool] = None, + is_active: Optional[bool] = None, + system_generated: Optional[bool] = None, + credentials: Optional[models.Credentials] = None, + group_ids: Optional[List[str]] = None, + ) -> models.User: """Create a new user.""" if self._users is None: await self._async_load() - assert self._users is not None - kwargs = { - 'name': name - } # type: Dict[str, Any] + assert self._users is not None + assert self._groups is not None + + groups = [] + for group_id in group_ids or []: + group = self._groups.get(group_id) + if group is None: + raise ValueError(f"Invalid group specified {group_id}") + groups.append(group) + + kwargs: Dict[str, Any] = { + "name": name, + # Until we get group management, we just put everyone in the + # same group. + "groups": groups, + "perm_lookup": self._perm_lookup, + } if is_owner is not None: - kwargs['is_owner'] = is_owner + kwargs["is_owner"] = is_owner if is_active is not None: - kwargs['is_active'] = is_active + kwargs["is_active"] = is_active if system_generated is not None: - kwargs['system_generated'] = system_generated + kwargs["system_generated"] = system_generated new_user = models.User(**kwargs) @@ -81,8 +126,9 @@ class AuthStore: await self.async_link_user(new_user, credentials) return new_user - async def async_link_user(self, user: models.User, - credentials: models.Credentials) -> None: + async def async_link_user( + self, user: models.User, credentials: models.Credentials + ) -> None: """Add credentials to an existing user.""" user.credentials.append(credentials) self._async_schedule_save() @@ -97,6 +143,33 @@ class AuthStore: self._users.pop(user.id) self._async_schedule_save() + async def async_update_user( + self, + user: models.User, + name: Optional[str] = None, + is_active: Optional[bool] = None, + group_ids: Optional[List[str]] = None, + ) -> None: + """Update a user.""" + assert self._groups is not None + + if group_ids is not None: + groups = [] + for grid in group_ids: + group = self._groups.get(grid) + if group is None: + raise ValueError("Invalid group specified.") + groups.append(group) + + user.groups = groups + user.invalidate_permission_cache() + + for attr_name, value in (("name", name), ("is_active", is_active)): + if value is not None: + setattr(user, attr_name, value) + + self._async_schedule_save() + async def async_activate_user(self, user: models.User) -> None: """Activate a user.""" user.is_active = True @@ -107,8 +180,7 @@ class AuthStore: user.is_active = False self._async_schedule_save() - async def async_remove_credentials( - self, credentials: models.Credentials) -> None: + async def async_remove_credentials(self, credentials: models.Credentials) -> None: """Remove credentials.""" if self._users is None: await self._async_load() @@ -129,23 +201,25 @@ class AuthStore: self._async_schedule_save() async def async_create_refresh_token( - self, user: models.User, client_id: Optional[str] = None, - client_name: Optional[str] = None, - client_icon: Optional[str] = None, - token_type: str = models.TOKEN_TYPE_NORMAL, - access_token_expiration: timedelta = ACCESS_TOKEN_EXPIRATION) \ - -> models.RefreshToken: + self, + user: models.User, + client_id: Optional[str] = None, + client_name: Optional[str] = None, + client_icon: Optional[str] = None, + token_type: str = models.TOKEN_TYPE_NORMAL, + access_token_expiration: timedelta = ACCESS_TOKEN_EXPIRATION, + ) -> models.RefreshToken: """Create a new token for a user.""" - kwargs = { - 'user': user, - 'client_id': client_id, - 'token_type': token_type, - 'access_token_expiration': access_token_expiration - } # type: Dict[str, Any] + kwargs: Dict[str, Any] = { + "user": user, + "client_id": client_id, + "token_type": token_type, + "access_token_expiration": access_token_expiration, + } if client_name: - kwargs['client_name'] = client_name + kwargs["client_name"] = client_name if client_icon: - kwargs['client_icon'] = client_icon + kwargs["client_icon"] = client_icon refresh_token = models.RefreshToken(**kwargs) user.refresh_tokens[refresh_token.id] = refresh_token @@ -154,7 +228,8 @@ class AuthStore: return refresh_token async def async_remove_refresh_token( - self, refresh_token: models.RefreshToken) -> None: + self, refresh_token: models.RefreshToken + ) -> None: """Remove a refresh token.""" if self._users is None: await self._async_load() @@ -166,7 +241,8 @@ class AuthStore: break async def async_get_refresh_token( - self, token_id: str) -> Optional[models.RefreshToken]: + self, token_id: str + ) -> Optional[models.RefreshToken]: """Get refresh token by id.""" if self._users is None: await self._async_load() @@ -180,7 +256,8 @@ class AuthStore: return None async def async_get_refresh_token_by_token( - self, token: str) -> Optional[models.RefreshToken]: + self, token: str + ) -> Optional[models.RefreshToken]: """Get refresh token by token.""" if self._users is None: await self._async_load() @@ -197,8 +274,8 @@ class AuthStore: @callback def async_log_refresh_token_usage( - self, refresh_token: models.RefreshToken, - remote_ip: Optional[str] = None) -> None: + self, refresh_token: models.RefreshToken, remote_ip: Optional[str] = None + ) -> None: """Update refresh token last used information.""" refresh_token.last_used_at = dt_util.utcnow() refresh_token.last_used_ip = remote_ip @@ -206,75 +283,196 @@ class AuthStore: async def _async_load(self) -> None: """Load the users.""" - data = await self._store.async_load() + async with self._lock: + if self._users is not None: + return + await self._async_load_task() + + async def _async_load_task(self) -> None: + """Load the users.""" + [ent_reg, dev_reg, data] = await asyncio.gather( + self.hass.helpers.entity_registry.async_get_registry(), + self.hass.helpers.device_registry.async_get_registry(), + self._store.async_load(), + ) # Make sure that we're not overriding data if 2 loads happened at the # same time if self._users is not None: return - users = OrderedDict() # type: Dict[str, models.User] + self._perm_lookup = perm_lookup = PermissionLookup(ent_reg, dev_reg) if data is None: - self._users = users + self._set_defaults() return - for user_dict in data['users']: - users[user_dict['id']] = models.User(**user_dict) + users: Dict[str, models.User] = OrderedDict() + groups: Dict[str, models.Group] = OrderedDict() - for cred_dict in data['credentials']: - users[cred_dict['user_id']].credentials.append(models.Credentials( - id=cred_dict['id'], - is_new=False, - auth_provider_type=cred_dict['auth_provider_type'], - auth_provider_id=cred_dict['auth_provider_id'], - data=cred_dict['data'], - )) + # Soft-migrating data as we load. We are going to make sure we have a + # read only group and an admin group. There are two states that we can + # migrate from: + # 1. Data from a recent version which has a single group without policy + # 2. Data from old version which has no groups + has_admin_group = False + has_user_group = False + has_read_only_group = False + group_without_policy = None - for rt_dict in data['refresh_tokens']: - # Filter out the old keys that don't have jwt_key (pre-0.76) - if 'jwt_key' not in rt_dict: + # When creating objects we mention each attribute explicitly. This + # prevents crashing if user rolls back HA version after a new property + # was added. + + for group_dict in data.get("groups", []): + policy: Optional[PolicyType] = None + + if group_dict["id"] == GROUP_ID_ADMIN: + has_admin_group = True + + name = GROUP_NAME_ADMIN + policy = system_policies.ADMIN_POLICY + system_generated = True + + elif group_dict["id"] == GROUP_ID_USER: + has_user_group = True + + name = GROUP_NAME_USER + policy = system_policies.USER_POLICY + system_generated = True + + elif group_dict["id"] == GROUP_ID_READ_ONLY: + has_read_only_group = True + + name = GROUP_NAME_READ_ONLY + policy = system_policies.READ_ONLY_POLICY + system_generated = True + + else: + name = group_dict["name"] + policy = group_dict.get("policy") + system_generated = False + + # We don't want groups without a policy that are not system groups + # This is part of migrating from state 1 + if policy is None: + group_without_policy = group_dict["id"] continue - created_at = dt_util.parse_datetime(rt_dict['created_at']) + groups[group_dict["id"]] = models.Group( + id=group_dict["id"], + name=name, + policy=policy, + system_generated=system_generated, + ) + + # If there are no groups, add all existing users to the admin group. + # This is part of migrating from state 2 + migrate_users_to_admin_group = not groups and group_without_policy is None + + # If we find a no_policy_group, we need to migrate all users to the + # admin group. We only do this if there are no other groups, as is + # the expected state. If not expected state, not marking people admin. + # This is part of migrating from state 1 + if groups and group_without_policy is not None: + group_without_policy = None + + # This is part of migrating from state 1 and 2 + if not has_admin_group: + admin_group = _system_admin_group() + groups[admin_group.id] = admin_group + + # This is part of migrating from state 1 and 2 + if not has_read_only_group: + read_only_group = _system_read_only_group() + groups[read_only_group.id] = read_only_group + + if not has_user_group: + user_group = _system_user_group() + groups[user_group.id] = user_group + + for user_dict in data["users"]: + # Collect the users group. + user_groups = [] + for group_id in user_dict.get("group_ids", []): + # This is part of migrating from state 1 + if group_id == group_without_policy: + group_id = GROUP_ID_ADMIN + user_groups.append(groups[group_id]) + + # This is part of migrating from state 2 + if not user_dict["system_generated"] and migrate_users_to_admin_group: + user_groups.append(groups[GROUP_ID_ADMIN]) + + users[user_dict["id"]] = models.User( + name=user_dict["name"], + groups=user_groups, + id=user_dict["id"], + is_owner=user_dict["is_owner"], + is_active=user_dict["is_active"], + system_generated=user_dict["system_generated"], + perm_lookup=perm_lookup, + ) + + for cred_dict in data["credentials"]: + users[cred_dict["user_id"]].credentials.append( + models.Credentials( + id=cred_dict["id"], + is_new=False, + auth_provider_type=cred_dict["auth_provider_type"], + auth_provider_id=cred_dict["auth_provider_id"], + data=cred_dict["data"], + ) + ) + + for rt_dict in data["refresh_tokens"]: + # Filter out the old keys that don't have jwt_key (pre-0.76) + if "jwt_key" not in rt_dict: + continue + + created_at = dt_util.parse_datetime(rt_dict["created_at"]) if created_at is None: getLogger(__name__).error( - 'Ignoring refresh token %(id)s with invalid created_at ' - '%(created_at)s for user_id %(user_id)s', rt_dict) + "Ignoring refresh token %(id)s with invalid created_at " + "%(created_at)s for user_id %(user_id)s", + rt_dict, + ) continue - token_type = rt_dict.get('token_type') + token_type = rt_dict.get("token_type") if token_type is None: - if rt_dict['client_id'] is None: + if rt_dict["client_id"] is None: token_type = models.TOKEN_TYPE_SYSTEM else: token_type = models.TOKEN_TYPE_NORMAL # old refresh_token don't have last_used_at (pre-0.78) - last_used_at_str = rt_dict.get('last_used_at') + last_used_at_str = rt_dict.get("last_used_at") if last_used_at_str: last_used_at = dt_util.parse_datetime(last_used_at_str) else: last_used_at = None token = models.RefreshToken( - id=rt_dict['id'], - user=users[rt_dict['user_id']], - client_id=rt_dict['client_id'], + id=rt_dict["id"], + user=users[rt_dict["user_id"]], + client_id=rt_dict["client_id"], # use dict.get to keep backward compatibility - client_name=rt_dict.get('client_name'), - client_icon=rt_dict.get('client_icon'), + client_name=rt_dict.get("client_name"), + client_icon=rt_dict.get("client_icon"), token_type=token_type, created_at=created_at, access_token_expiration=timedelta( - seconds=rt_dict['access_token_expiration']), - token=rt_dict['token'], - jwt_key=rt_dict['jwt_key'], + seconds=rt_dict["access_token_expiration"] + ), + token=rt_dict["token"], + jwt_key=rt_dict["jwt_key"], last_used_at=last_used_at, - last_used_ip=rt_dict.get('last_used_ip'), + last_used_ip=rt_dict.get("last_used_ip"), ) - users[rt_dict['user_id']].refresh_tokens[token.id] = token + users[rt_dict["user_id"]].refresh_tokens[token.id] = token + self._groups = groups self._users = users @callback @@ -289,25 +487,40 @@ class AuthStore: def _data_to_save(self) -> Dict: """Return the data to store.""" assert self._users is not None + assert self._groups is not None users = [ { - 'id': user.id, - 'is_owner': user.is_owner, - 'is_active': user.is_active, - 'name': user.name, - 'system_generated': user.system_generated, + "id": user.id, + "group_ids": [group.id for group in user.groups], + "is_owner": user.is_owner, + "is_active": user.is_active, + "name": user.name, + "system_generated": user.system_generated, } for user in self._users.values() ] + groups = [] + for group in self._groups.values(): + g_dict: Dict[str, Any] = { + "id": group.id, + # Name not read for sys groups. Kept here for backwards compat + "name": group.name, + } + + if not group.system_generated: + g_dict["policy"] = group.policy + + groups.append(g_dict) + credentials = [ { - 'id': credential.id, - 'user_id': user.id, - 'auth_provider_type': credential.auth_provider_type, - 'auth_provider_id': credential.auth_provider_id, - 'data': credential.data, + "id": credential.id, + "user_id": user.id, + "auth_provider_type": credential.auth_provider_type, + "auth_provider_id": credential.auth_provider_id, + "data": credential.data, } for user in self._users.values() for credential in user.credentials @@ -315,28 +528,71 @@ class AuthStore: refresh_tokens = [ { - 'id': refresh_token.id, - 'user_id': user.id, - 'client_id': refresh_token.client_id, - 'client_name': refresh_token.client_name, - 'client_icon': refresh_token.client_icon, - 'token_type': refresh_token.token_type, - 'created_at': refresh_token.created_at.isoformat(), - 'access_token_expiration': - refresh_token.access_token_expiration.total_seconds(), - 'token': refresh_token.token, - 'jwt_key': refresh_token.jwt_key, - 'last_used_at': - refresh_token.last_used_at.isoformat() - if refresh_token.last_used_at else None, - 'last_used_ip': refresh_token.last_used_ip, + "id": refresh_token.id, + "user_id": user.id, + "client_id": refresh_token.client_id, + "client_name": refresh_token.client_name, + "client_icon": refresh_token.client_icon, + "token_type": refresh_token.token_type, + "created_at": refresh_token.created_at.isoformat(), + "access_token_expiration": refresh_token.access_token_expiration.total_seconds(), + "token": refresh_token.token, + "jwt_key": refresh_token.jwt_key, + "last_used_at": refresh_token.last_used_at.isoformat() + if refresh_token.last_used_at + else None, + "last_used_ip": refresh_token.last_used_ip, } for user in self._users.values() for refresh_token in user.refresh_tokens.values() ] return { - 'users': users, - 'credentials': credentials, - 'refresh_tokens': refresh_tokens, + "users": users, + "groups": groups, + "credentials": credentials, + "refresh_tokens": refresh_tokens, } + + def _set_defaults(self) -> None: + """Set default values for auth store.""" + self._users = OrderedDict() + + groups: Dict[str, models.Group] = OrderedDict() + admin_group = _system_admin_group() + groups[admin_group.id] = admin_group + user_group = _system_user_group() + groups[user_group.id] = user_group + read_only_group = _system_read_only_group() + groups[read_only_group.id] = read_only_group + self._groups = groups + + +def _system_admin_group() -> models.Group: + """Create system admin group.""" + return models.Group( + name=GROUP_NAME_ADMIN, + id=GROUP_ID_ADMIN, + policy=system_policies.ADMIN_POLICY, + system_generated=True, + ) + + +def _system_user_group() -> models.Group: + """Create system user group.""" + return models.Group( + name=GROUP_NAME_USER, + id=GROUP_ID_USER, + policy=system_policies.USER_POLICY, + system_generated=True, + ) + + +def _system_read_only_group() -> models.Group: + """Create read only group.""" + return models.Group( + name=GROUP_NAME_READ_ONLY, + id=GROUP_ID_READ_ONLY, + policy=system_policies.READ_ONLY_POLICY, + system_generated=True, + ) diff --git a/homeassistant/auth/const.py b/homeassistant/auth/const.py index 082d89662..5e17e752b 100644 --- a/homeassistant/auth/const.py +++ b/homeassistant/auth/const.py @@ -2,3 +2,8 @@ from datetime import timedelta ACCESS_TOKEN_EXPIRATION = timedelta(minutes=30) +MFA_SESSION_EXPIRATION = timedelta(minutes=5) + +GROUP_ID_ADMIN = "system-admin" +GROUP_ID_USER = "system-users" +GROUP_ID_READ_ONLY = "system-read-only" diff --git a/homeassistant/auth/mfa_modules/__init__.py b/homeassistant/auth/mfa_modules/__init__.py index 603ca6ff3..fd9e61b9d 100644 --- a/homeassistant/auth/mfa_modules/__init__.py +++ b/homeassistant/auth/mfa_modules/__init__.py @@ -1,5 +1,4 @@ """Plugable auth modules for Home Assistant.""" -from datetime import timedelta import importlib import logging import types @@ -8,7 +7,7 @@ from typing import Any, Dict, Optional import voluptuous as vol from voluptuous.humanize import humanize_error -from homeassistant import requirements, data_entry_flow +from homeassistant import data_entry_flow, requirements from homeassistant.const import CONF_ID, CONF_NAME, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -16,16 +15,17 @@ from homeassistant.util.decorator import Registry MULTI_FACTOR_AUTH_MODULES = Registry() -MULTI_FACTOR_AUTH_MODULE_SCHEMA = vol.Schema({ - vol.Required(CONF_TYPE): str, - vol.Optional(CONF_NAME): str, - # Specify ID if you have two mfa auth module for same type. - vol.Optional(CONF_ID): str, -}, extra=vol.ALLOW_EXTRA) +MULTI_FACTOR_AUTH_MODULE_SCHEMA = vol.Schema( + { + vol.Required(CONF_TYPE): str, + vol.Optional(CONF_NAME): str, + # Specify ID if you have two mfa auth module for same type. + vol.Optional(CONF_ID): str, + }, + extra=vol.ALLOW_EXTRA, +) -SESSION_EXPIRATION = timedelta(minutes=5) - -DATA_REQS = 'mfa_auth_module_reqs_processed' +DATA_REQS = "mfa_auth_module_reqs_processed" _LOGGER = logging.getLogger(__name__) @@ -33,7 +33,8 @@ _LOGGER = logging.getLogger(__name__) class MultiFactorAuthModule: """Multi-factor Auth Module of validation function.""" - DEFAULT_TITLE = 'Unnamed auth module' + DEFAULT_TITLE = "Unnamed auth module" + MAX_RETRY_TIME = 3 def __init__(self, hass: HomeAssistant, config: Dict[str, Any]) -> None: """Initialize an auth module.""" @@ -41,7 +42,7 @@ class MultiFactorAuthModule: self.config = config @property - def id(self) -> str: # pylint: disable=invalid-name + def id(self) -> str: """Return id of the auth module. Default is same as type @@ -65,7 +66,7 @@ class MultiFactorAuthModule: """Return a voluptuous schema to define mfa auth module's input.""" raise NotImplementedError - async def async_setup_flow(self, user_id: str) -> 'SetupFlow': + async def async_setup_flow(self, user_id: str) -> "SetupFlow": """Return a data entry flow handler for setup module. Mfa module should extend SetupFlow @@ -84,8 +85,7 @@ class MultiFactorAuthModule: """Return whether user is setup.""" raise NotImplementedError - async def async_validation( - self, user_id: str, user_input: Dict[str, Any]) -> bool: + async def async_validate(self, user_id: str, user_input: Dict[str, Any]) -> bool: """Return True if validation passed.""" raise NotImplementedError @@ -93,42 +93,38 @@ class MultiFactorAuthModule: class SetupFlow(data_entry_flow.FlowHandler): """Handler for the setup flow.""" - def __init__(self, auth_module: MultiFactorAuthModule, - setup_schema: vol.Schema, - user_id: str) -> None: + def __init__( + self, auth_module: MultiFactorAuthModule, setup_schema: vol.Schema, user_id: str + ) -> None: """Initialize the setup flow.""" self._auth_module = auth_module self._setup_schema = setup_schema self._user_id = user_id async def async_step_init( - self, user_input: Optional[Dict[str, str]] = None) \ - -> Dict[str, Any]: + self, user_input: Optional[Dict[str, str]] = None + ) -> Dict[str, Any]: """Handle the first step of setup flow. - Return self.async_show_form(step_id='init') if user_input == None. + Return self.async_show_form(step_id='init') if user_input is None. Return self.async_create_entry(data={'result': result}) if finish. """ - errors = {} # type: Dict[str, str] + errors: Dict[str, str] = {} if user_input: - result = await self._auth_module.async_setup_user( - self._user_id, user_input) + result = await self._auth_module.async_setup_user(self._user_id, user_input) return self.async_create_entry( - title=self._auth_module.name, - data={'result': result} + title=self._auth_module.name, data={"result": result} ) return self.async_show_form( - step_id='init', - data_schema=self._setup_schema, - errors=errors + step_id="init", data_schema=self._setup_schema, errors=errors ) async def auth_mfa_module_from_config( - hass: HomeAssistant, config: Dict[str, Any]) \ - -> MultiFactorAuthModule: + hass: HomeAssistant, config: Dict[str, Any] +) -> MultiFactorAuthModule: """Initialize an auth module from a config.""" module_name = config[CONF_TYPE] module = await _load_mfa_module(hass, module_name) @@ -136,26 +132,27 @@ async def auth_mfa_module_from_config( try: config = module.CONFIG_SCHEMA(config) # type: ignore except vol.Invalid as err: - _LOGGER.error('Invalid configuration for multi-factor module %s: %s', - module_name, humanize_error(config, err)) + _LOGGER.error( + "Invalid configuration for multi-factor module %s: %s", + module_name, + humanize_error(config, err), + ) raise return MULTI_FACTOR_AUTH_MODULES[module_name](hass, config) # type: ignore -async def _load_mfa_module(hass: HomeAssistant, module_name: str) \ - -> types.ModuleType: +async def _load_mfa_module(hass: HomeAssistant, module_name: str) -> types.ModuleType: """Load an mfa auth module.""" - module_path = 'homeassistant.auth.mfa_modules.{}'.format(module_name) + module_path = f"homeassistant.auth.mfa_modules.{module_name}" try: module = importlib.import_module(module_path) except ImportError as err: - _LOGGER.error('Unable to load mfa module %s: %s', module_name, err) - raise HomeAssistantError('Unable to load mfa module {}: {}'.format( - module_name, err)) + _LOGGER.error("Unable to load mfa module %s: %s", module_name, err) + raise HomeAssistantError(f"Unable to load mfa module {module_name}: {err}") - if hass.config.skip_pip or not hasattr(module, 'REQUIREMENTS'): + if hass.config.skip_pip or not hasattr(module, "REQUIREMENTS"): return module processed = hass.data.get(DATA_REQS) @@ -165,13 +162,9 @@ async def _load_mfa_module(hass: HomeAssistant, module_name: str) \ processed = hass.data[DATA_REQS] = set() # https://github.com/python/mypy/issues/1424 - req_success = await requirements.async_process_requirements( - hass, module_path, module.REQUIREMENTS) # type: ignore - - if not req_success: - raise HomeAssistantError( - 'Unable to process requirements of mfa module {}'.format( - module_name)) + await requirements.async_process_requirements( + hass, module_path, module.REQUIREMENTS # type: ignore + ) processed.add(module_name) return module diff --git a/homeassistant/auth/mfa_modules/insecure_example.py b/homeassistant/auth/mfa_modules/insecure_example.py index 9c72111ef..45cc07ae5 100644 --- a/homeassistant/auth/mfa_modules/insecure_example.py +++ b/homeassistant/auth/mfa_modules/insecure_example.py @@ -6,39 +6,45 @@ import voluptuous as vol from homeassistant.core import HomeAssistant -from . import MultiFactorAuthModule, MULTI_FACTOR_AUTH_MODULES, \ - MULTI_FACTOR_AUTH_MODULE_SCHEMA, SetupFlow +from . import ( + MULTI_FACTOR_AUTH_MODULE_SCHEMA, + MULTI_FACTOR_AUTH_MODULES, + MultiFactorAuthModule, + SetupFlow, +) -CONFIG_SCHEMA = MULTI_FACTOR_AUTH_MODULE_SCHEMA.extend({ - vol.Required('data'): [vol.Schema({ - vol.Required('user_id'): str, - vol.Required('pin'): str, - })] -}, extra=vol.PREVENT_EXTRA) +CONFIG_SCHEMA = MULTI_FACTOR_AUTH_MODULE_SCHEMA.extend( + { + vol.Required("data"): [ + vol.Schema({vol.Required("user_id"): str, vol.Required("pin"): str}) + ] + }, + extra=vol.PREVENT_EXTRA, +) _LOGGER = logging.getLogger(__name__) -@MULTI_FACTOR_AUTH_MODULES.register('insecure_example') +@MULTI_FACTOR_AUTH_MODULES.register("insecure_example") class InsecureExampleModule(MultiFactorAuthModule): """Example auth module validate pin.""" - DEFAULT_TITLE = 'Insecure Personal Identify Number' + DEFAULT_TITLE = "Insecure Personal Identify Number" def __init__(self, hass: HomeAssistant, config: Dict[str, Any]) -> None: """Initialize the user data store.""" super().__init__(hass, config) - self._data = config['data'] + self._data = config["data"] @property def input_schema(self) -> vol.Schema: """Validate login flow input data.""" - return vol.Schema({'pin': str}) + return vol.Schema({"pin": str}) @property def setup_schema(self) -> vol.Schema: """Validate async_setup_user input data.""" - return vol.Schema({'pin': str}) + return vol.Schema({"pin": str}) async def async_setup_flow(self, user_id: str) -> SetupFlow: """Return a data entry flow handler for setup module. @@ -50,21 +56,21 @@ class InsecureExampleModule(MultiFactorAuthModule): async def async_setup_user(self, user_id: str, setup_data: Any) -> Any: """Set up user to use mfa module.""" # data shall has been validate in caller - pin = setup_data['pin'] + pin = setup_data["pin"] for data in self._data: - if data['user_id'] == user_id: + if data["user_id"] == user_id: # already setup, override - data['pin'] = pin + data["pin"] = pin return - self._data.append({'user_id': user_id, 'pin': pin}) + self._data.append({"user_id": user_id, "pin": pin}) async def async_depose_user(self, user_id: str) -> None: """Remove user from mfa module.""" found = None for data in self._data: - if data['user_id'] == user_id: + if data["user_id"] == user_id: found = data break if found: @@ -73,17 +79,16 @@ class InsecureExampleModule(MultiFactorAuthModule): async def async_is_user_setup(self, user_id: str) -> bool: """Return whether user is setup.""" for data in self._data: - if data['user_id'] == user_id: + if data["user_id"] == user_id: return True return False - async def async_validation( - self, user_id: str, user_input: Dict[str, Any]) -> bool: + async def async_validate(self, user_id: str, user_input: Dict[str, Any]) -> bool: """Return True if validation passed.""" for data in self._data: - if data['user_id'] == user_id: + if data["user_id"] == user_id: # user_input has been validate in caller - if data['pin'] == user_input['pin']: + if data["pin"] == user_input["pin"]: return True return False diff --git a/homeassistant/auth/mfa_modules/notify.py b/homeassistant/auth/mfa_modules/notify.py new file mode 100644 index 000000000..46cc634bc --- /dev/null +++ b/homeassistant/auth/mfa_modules/notify.py @@ -0,0 +1,356 @@ +"""HMAC-based One-time Password auth module. + +Sending HOTP through notify service +""" +import asyncio +from collections import OrderedDict +import logging +from typing import Any, Dict, List, Optional + +import attr +import voluptuous as vol + +from homeassistant.const import CONF_EXCLUDE, CONF_INCLUDE +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ServiceNotFound +from homeassistant.helpers import config_validation as cv + +from . import ( + MULTI_FACTOR_AUTH_MODULE_SCHEMA, + MULTI_FACTOR_AUTH_MODULES, + MultiFactorAuthModule, + SetupFlow, +) + +REQUIREMENTS = ["pyotp==2.3.0"] + +CONF_MESSAGE = "message" + +CONFIG_SCHEMA = MULTI_FACTOR_AUTH_MODULE_SCHEMA.extend( + { + vol.Optional(CONF_INCLUDE): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_EXCLUDE): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_MESSAGE, default="{} is your Home Assistant login code"): str, + }, + extra=vol.PREVENT_EXTRA, +) + +STORAGE_VERSION = 1 +STORAGE_KEY = "auth_module.notify" +STORAGE_USERS = "users" +STORAGE_USER_ID = "user_id" + +INPUT_FIELD_CODE = "code" + +_LOGGER = logging.getLogger(__name__) + + +def _generate_secret() -> str: + """Generate a secret.""" + import pyotp + + return str(pyotp.random_base32()) + + +def _generate_random() -> int: + """Generate a 8 digit number.""" + import pyotp + + return int(pyotp.random_base32(length=8, chars=list("1234567890"))) + + +def _generate_otp(secret: str, count: int) -> str: + """Generate one time password.""" + import pyotp + + return str(pyotp.HOTP(secret).at(count)) + + +def _verify_otp(secret: str, otp: str, count: int) -> bool: + """Verify one time password.""" + import pyotp + + return bool(pyotp.HOTP(secret).verify(otp, count)) + + +@attr.s(slots=True) +class NotifySetting: + """Store notify setting for one user.""" + + secret = attr.ib(type=str, factory=_generate_secret) # not persistent + counter = attr.ib(type=int, factory=_generate_random) # not persistent + notify_service = attr.ib(type=Optional[str], default=None) + target = attr.ib(type=Optional[str], default=None) + + +_UsersDict = Dict[str, NotifySetting] + + +@MULTI_FACTOR_AUTH_MODULES.register("notify") +class NotifyAuthModule(MultiFactorAuthModule): + """Auth module send hmac-based one time password by notify service.""" + + DEFAULT_TITLE = "Notify One-Time Password" + + def __init__(self, hass: HomeAssistant, config: Dict[str, Any]) -> None: + """Initialize the user data store.""" + super().__init__(hass, config) + self._user_settings: Optional[_UsersDict] = None + self._user_store = hass.helpers.storage.Store( + STORAGE_VERSION, STORAGE_KEY, private=True + ) + self._include = config.get(CONF_INCLUDE, []) + self._exclude = config.get(CONF_EXCLUDE, []) + self._message_template = config[CONF_MESSAGE] + self._init_lock = asyncio.Lock() + + @property + def input_schema(self) -> vol.Schema: + """Validate login flow input data.""" + return vol.Schema({INPUT_FIELD_CODE: str}) + + async def _async_load(self) -> None: + """Load stored data.""" + async with self._init_lock: + if self._user_settings is not None: + return + + data = await self._user_store.async_load() + + if data is None: + data = {STORAGE_USERS: {}} + + self._user_settings = { + user_id: NotifySetting(**setting) + for user_id, setting in data.get(STORAGE_USERS, {}).items() + } + + async def _async_save(self) -> None: + """Save data.""" + if self._user_settings is None: + return + + await self._user_store.async_save( + { + STORAGE_USERS: { + user_id: attr.asdict( + notify_setting, + filter=attr.filters.exclude( + attr.fields(NotifySetting).secret, + attr.fields(NotifySetting).counter, + ), + ) + for user_id, notify_setting in self._user_settings.items() + } + } + ) + + @callback + def aync_get_available_notify_services(self) -> List[str]: + """Return list of notify services.""" + unordered_services = set() + + for service in self.hass.services.async_services().get("notify", {}): + if service not in self._exclude: + unordered_services.add(service) + + if self._include: + unordered_services &= set(self._include) + + return sorted(unordered_services) + + async def async_setup_flow(self, user_id: str) -> SetupFlow: + """Return a data entry flow handler for setup module. + + Mfa module should extend SetupFlow + """ + return NotifySetupFlow( + self, self.input_schema, user_id, self.aync_get_available_notify_services() + ) + + async def async_setup_user(self, user_id: str, setup_data: Any) -> Any: + """Set up auth module for user.""" + if self._user_settings is None: + await self._async_load() + assert self._user_settings is not None + + self._user_settings[user_id] = NotifySetting( + notify_service=setup_data.get("notify_service"), + target=setup_data.get("target"), + ) + + await self._async_save() + + async def async_depose_user(self, user_id: str) -> None: + """Depose auth module for user.""" + if self._user_settings is None: + await self._async_load() + assert self._user_settings is not None + + if self._user_settings.pop(user_id, None): + await self._async_save() + + async def async_is_user_setup(self, user_id: str) -> bool: + """Return whether user is setup.""" + if self._user_settings is None: + await self._async_load() + assert self._user_settings is not None + + return user_id in self._user_settings + + async def async_validate(self, user_id: str, user_input: Dict[str, Any]) -> bool: + """Return True if validation passed.""" + if self._user_settings is None: + await self._async_load() + assert self._user_settings is not None + + notify_setting = self._user_settings.get(user_id, None) + if notify_setting is None: + return False + + # user_input has been validate in caller + return await self.hass.async_add_executor_job( + _verify_otp, + notify_setting.secret, + user_input.get(INPUT_FIELD_CODE, ""), + notify_setting.counter, + ) + + async def async_initialize_login_mfa_step(self, user_id: str) -> None: + """Generate code and notify user.""" + if self._user_settings is None: + await self._async_load() + assert self._user_settings is not None + + notify_setting = self._user_settings.get(user_id, None) + if notify_setting is None: + raise ValueError("Cannot find user_id") + + def generate_secret_and_one_time_password() -> str: + """Generate and send one time password.""" + assert notify_setting + # secret and counter are not persistent + notify_setting.secret = _generate_secret() + notify_setting.counter = _generate_random() + return _generate_otp(notify_setting.secret, notify_setting.counter) + + code = await self.hass.async_add_executor_job( + generate_secret_and_one_time_password + ) + + await self.async_notify_user(user_id, code) + + async def async_notify_user(self, user_id: str, code: str) -> None: + """Send code by user's notify service.""" + if self._user_settings is None: + await self._async_load() + assert self._user_settings is not None + + notify_setting = self._user_settings.get(user_id, None) + if notify_setting is None: + _LOGGER.error("Cannot find user %s", user_id) + return + + await self.async_notify( + code, + notify_setting.notify_service, # type: ignore + notify_setting.target, + ) + + async def async_notify( + self, code: str, notify_service: str, target: Optional[str] = None + ) -> None: + """Send code by notify service.""" + data = {"message": self._message_template.format(code)} + if target: + data["target"] = [target] + + await self.hass.services.async_call("notify", notify_service, data) + + +class NotifySetupFlow(SetupFlow): + """Handler for the setup flow.""" + + def __init__( + self, + auth_module: NotifyAuthModule, + setup_schema: vol.Schema, + user_id: str, + available_notify_services: List[str], + ) -> None: + """Initialize the setup flow.""" + super().__init__(auth_module, setup_schema, user_id) + # to fix typing complaint + self._auth_module: NotifyAuthModule = auth_module + self._available_notify_services = available_notify_services + self._secret: Optional[str] = None + self._count: Optional[int] = None + self._notify_service: Optional[str] = None + self._target: Optional[str] = None + + async def async_step_init( + self, user_input: Optional[Dict[str, str]] = None + ) -> Dict[str, Any]: + """Let user select available notify services.""" + errors: Dict[str, str] = {} + + hass = self._auth_module.hass + if user_input: + self._notify_service = user_input["notify_service"] + self._target = user_input.get("target") + self._secret = await hass.async_add_executor_job(_generate_secret) + self._count = await hass.async_add_executor_job(_generate_random) + + return await self.async_step_setup() + + if not self._available_notify_services: + return self.async_abort(reason="no_available_service") + + schema: Dict[str, Any] = OrderedDict() + schema["notify_service"] = vol.In(self._available_notify_services) + schema["target"] = vol.Optional(str) + + return self.async_show_form( + step_id="init", data_schema=vol.Schema(schema), errors=errors + ) + + async def async_step_setup( + self, user_input: Optional[Dict[str, str]] = None + ) -> Dict[str, Any]: + """Verify user can recevie one-time password.""" + errors: Dict[str, str] = {} + + hass = self._auth_module.hass + if user_input: + verified = await hass.async_add_executor_job( + _verify_otp, self._secret, user_input["code"], self._count + ) + if verified: + await self._auth_module.async_setup_user( + self._user_id, + {"notify_service": self._notify_service, "target": self._target}, + ) + return self.async_create_entry(title=self._auth_module.name, data={}) + + errors["base"] = "invalid_code" + + # generate code every time, no retry logic + assert self._secret and self._count + code = await hass.async_add_executor_job( + _generate_otp, self._secret, self._count + ) + + assert self._notify_service + try: + await self._auth_module.async_notify( + code, self._notify_service, self._target + ) + except ServiceNotFound: + return self.async_abort(reason="notify_service_not_exist") + + return self.async_show_form( + step_id="setup", + data_schema=self._setup_schema, + description_placeholders={"notify_service": self._notify_service}, + errors=errors, + ) diff --git a/homeassistant/auth/mfa_modules/totp.py b/homeassistant/auth/mfa_modules/totp.py index 50cd9d334..6abddd212 100644 --- a/homeassistant/auth/mfa_modules/totp.py +++ b/homeassistant/auth/mfa_modules/totp.py @@ -1,30 +1,34 @@ """Time-based One Time Password auth module.""" -import logging +import asyncio from io import BytesIO -from typing import Any, Dict, Optional, Tuple # noqa: F401 +import logging +from typing import Any, Dict, Optional, Tuple import voluptuous as vol from homeassistant.auth.models import User from homeassistant.core import HomeAssistant -from . import MultiFactorAuthModule, MULTI_FACTOR_AUTH_MODULES, \ - MULTI_FACTOR_AUTH_MODULE_SCHEMA, SetupFlow +from . import ( + MULTI_FACTOR_AUTH_MODULE_SCHEMA, + MULTI_FACTOR_AUTH_MODULES, + MultiFactorAuthModule, + SetupFlow, +) -REQUIREMENTS = ['pyotp==2.2.6', 'PyQRCode==1.2.1'] +REQUIREMENTS = ["pyotp==2.3.0", "PyQRCode==1.2.1"] -CONFIG_SCHEMA = MULTI_FACTOR_AUTH_MODULE_SCHEMA.extend({ -}, extra=vol.PREVENT_EXTRA) +CONFIG_SCHEMA = MULTI_FACTOR_AUTH_MODULE_SCHEMA.extend({}, extra=vol.PREVENT_EXTRA) STORAGE_VERSION = 1 -STORAGE_KEY = 'auth_module.totp' -STORAGE_USERS = 'users' -STORAGE_USER_ID = 'user_id' -STORAGE_OTA_SECRET = 'ota_secret' +STORAGE_KEY = "auth_module.totp" +STORAGE_USERS = "users" +STORAGE_USER_ID = "user_id" +STORAGE_OTA_SECRET = "ota_secret" -INPUT_FIELD_CODE = 'code' +INPUT_FIELD_CODE = "code" -DUMMY_SECRET = 'FPPTH34D4E3MI2HG' +DUMMY_SECRET = "FPPTH34D4E3MI2HG" _LOGGER = logging.getLogger(__name__) @@ -37,10 +41,15 @@ def _generate_qr_code(data: str) -> str: with BytesIO() as buffer: qr_code.svg(file=buffer, scale=4) - return '{}'.format( - buffer.getvalue().decode("ascii").replace('\n', '') - .replace('' - '' + ' Tuple[str, str, str]: ota_secret = pyotp.random_base32() url = pyotp.totp.TOTP(ota_secret).provisioning_uri( - username, issuer_name="Home Assistant") + username, issuer_name="Home Assistant" + ) image = _generate_qr_code(url) return ota_secret, url, image -@MULTI_FACTOR_AUTH_MODULES.register('totp') +@MULTI_FACTOR_AUTH_MODULES.register("totp") class TotpAuthModule(MultiFactorAuthModule): """Auth module validate time-based one time password.""" - DEFAULT_TITLE = 'Time-based One Time Password' + DEFAULT_TITLE = "Time-based One Time Password" + MAX_RETRY_TIME = 5 def __init__(self, hass: HomeAssistant, config: Dict[str, Any]) -> None: """Initialize the user data store.""" super().__init__(hass, config) - self._users = None # type: Optional[Dict[str, str]] + self._users: Optional[Dict[str, str]] = None self._user_store = hass.helpers.storage.Store( - STORAGE_VERSION, STORAGE_KEY) + STORAGE_VERSION, STORAGE_KEY, private=True + ) + self._init_lock = asyncio.Lock() @property def input_schema(self) -> vol.Schema: @@ -75,25 +88,28 @@ class TotpAuthModule(MultiFactorAuthModule): async def _async_load(self) -> None: """Load stored data.""" - data = await self._user_store.async_load() + async with self._init_lock: + if self._users is not None: + return - if data is None: - data = {STORAGE_USERS: {}} + data = await self._user_store.async_load() - self._users = data.get(STORAGE_USERS, {}) + if data is None: + data = {STORAGE_USERS: {}} + + self._users = data.get(STORAGE_USERS, {}) async def _async_save(self) -> None: """Save data.""" await self._user_store.async_save({STORAGE_USERS: self._users}) - def _add_ota_secret(self, user_id: str, - secret: Optional[str] = None) -> str: + def _add_ota_secret(self, user_id: str, secret: Optional[str] = None) -> str: """Create a ota_secret for user.""" import pyotp - ota_secret = secret or pyotp.random_base32() # type: str + ota_secret: str = secret or pyotp.random_base32() - self._users[user_id] = ota_secret # type: ignore + self._users[user_id] = ota_secret # type: ignore return ota_secret async def async_setup_flow(self, user_id: str) -> SetupFlow: @@ -101,7 +117,7 @@ class TotpAuthModule(MultiFactorAuthModule): Mfa module should extend SetupFlow """ - user = await self.hass.auth.async_get_user(user_id) # type: ignore + user = await self.hass.auth.async_get_user(user_id) # type: ignore return TotpSetupFlow(self, self.input_schema, user) async def async_setup_user(self, user_id: str, setup_data: Any) -> str: @@ -110,7 +126,8 @@ class TotpAuthModule(MultiFactorAuthModule): await self._async_load() result = await self.hass.async_add_executor_job( - self._add_ota_secret, user_id, setup_data.get('secret')) + self._add_ota_secret, user_id, setup_data.get("secret") + ) await self._async_save() return result @@ -120,7 +137,7 @@ class TotpAuthModule(MultiFactorAuthModule): if self._users is None: await self._async_load() - if self._users.pop(user_id, None): # type: ignore + if self._users.pop(user_id, None): # type: ignore await self._async_save() async def async_is_user_setup(self, user_id: str) -> bool: @@ -128,10 +145,9 @@ class TotpAuthModule(MultiFactorAuthModule): if self._users is None: await self._async_load() - return user_id in self._users # type: ignore + return user_id in self._users # type: ignore - async def async_validation( - self, user_id: str, user_input: Dict[str, Any]) -> bool: + async def async_validate(self, user_id: str, user_input: Dict[str, Any]) -> bool: """Return True if validation passed.""" if self._users is None: await self._async_load() @@ -139,7 +155,8 @@ class TotpAuthModule(MultiFactorAuthModule): # user_input has been validate in caller # set INPUT_FIELD_CODE as vol.Required is not user friendly return await self.hass.async_add_executor_job( - self._validate_2fa, user_id, user_input.get(INPUT_FIELD_CODE, '')) + self._validate_2fa, user_id, user_input.get(INPUT_FIELD_CODE, "") + ) def _validate_2fa(self, user_id: str, code: str) -> bool: """Validate two factor authentication code.""" @@ -158,56 +175,62 @@ class TotpAuthModule(MultiFactorAuthModule): class TotpSetupFlow(SetupFlow): """Handler for the setup flow.""" - def __init__(self, auth_module: TotpAuthModule, - setup_schema: vol.Schema, - user: User) -> None: + def __init__( + self, auth_module: TotpAuthModule, setup_schema: vol.Schema, user: User + ) -> None: """Initialize the setup flow.""" super().__init__(auth_module, setup_schema, user.id) # to fix typing complaint - self._auth_module = auth_module # type: TotpAuthModule + self._auth_module: TotpAuthModule = auth_module self._user = user - self._ota_secret = None # type: Optional[str] + self._ota_secret: Optional[str] = None self._url = None # type Optional[str] self._image = None # type Optional[str] async def async_step_init( - self, user_input: Optional[Dict[str, str]] = None) \ - -> Dict[str, Any]: + self, user_input: Optional[Dict[str, str]] = None + ) -> Dict[str, Any]: """Handle the first step of setup flow. - Return self.async_show_form(step_id='init') if user_input == None. + Return self.async_show_form(step_id='init') if user_input is None. Return self.async_create_entry(data={'result': result}) if finish. """ import pyotp - errors = {} # type: Dict[str, str] + errors: Dict[str, str] = {} if user_input: verified = await self.hass.async_add_executor_job( # type: ignore - pyotp.TOTP(self._ota_secret).verify, user_input['code']) + pyotp.TOTP(self._ota_secret).verify, user_input["code"] + ) if verified: result = await self._auth_module.async_setup_user( - self._user_id, {'secret': self._ota_secret}) + self._user_id, {"secret": self._ota_secret} + ) return self.async_create_entry( - title=self._auth_module.name, - data={'result': result} + title=self._auth_module.name, data={"result": result} ) - errors['base'] = 'invalid_code' + errors["base"] = "invalid_code" else: hass = self._auth_module.hass - self._ota_secret, self._url, self._image = \ - await hass.async_add_executor_job( # type: ignore - _generate_secret_and_qr_code, str(self._user.name)) + ( + self._ota_secret, + self._url, + self._image, + ) = await hass.async_add_executor_job( + _generate_secret_and_qr_code, # type: ignore + str(self._user.name), + ) return self.async_show_form( - step_id='init', + step_id="init", data_schema=self._setup_schema, description_placeholders={ - 'code': self._ota_secret, - 'url': self._url, - 'qr_code': self._image + "code": self._ota_secret, + "url": self._url, + "qr_code": self._image, }, - errors=errors + errors=errors, ) diff --git a/homeassistant/auth/models.py b/homeassistant/auth/models.py index b0f4024c3..08f2f375b 100644 --- a/homeassistant/auth/models.py +++ b/homeassistant/auth/models.py @@ -1,38 +1,81 @@ """Auth models.""" from datetime import datetime, timedelta -from typing import Dict, List, NamedTuple, Optional # noqa: F401 +import secrets +from typing import Dict, List, NamedTuple, Optional import uuid import attr from homeassistant.util import dt as dt_util -from .util import generate_secret +from . import permissions as perm_mdl +from .const import GROUP_ID_ADMIN -TOKEN_TYPE_NORMAL = 'normal' -TOKEN_TYPE_SYSTEM = 'system' -TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN = 'long_lived_access_token' +TOKEN_TYPE_NORMAL = "normal" +TOKEN_TYPE_SYSTEM = "system" +TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN = "long_lived_access_token" + + +@attr.s(slots=True) +class Group: + """A group.""" + + name = attr.ib(type=Optional[str]) + policy = attr.ib(type=perm_mdl.PolicyType) + id = attr.ib(type=str, factory=lambda: uuid.uuid4().hex) + system_generated = attr.ib(type=bool, default=False) @attr.s(slots=True) class User: """A user.""" - name = attr.ib(type=str) # type: Optional[str] - id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex)) + name = attr.ib(type=Optional[str]) + perm_lookup = attr.ib(type=perm_mdl.PermissionLookup, cmp=False) + id = attr.ib(type=str, factory=lambda: uuid.uuid4().hex) is_owner = attr.ib(type=bool, default=False) is_active = attr.ib(type=bool, default=False) system_generated = attr.ib(type=bool, default=False) + groups = attr.ib(type=List[Group], factory=list, cmp=False) + # List of credentials of a user. - credentials = attr.ib( - type=list, default=attr.Factory(list), cmp=False - ) # type: List[Credentials] + credentials = attr.ib(type=List["Credentials"], factory=list, cmp=False) # Tokens associated with a user. - refresh_tokens = attr.ib( - type=dict, default=attr.Factory(dict), cmp=False - ) # type: Dict[str, RefreshToken] + refresh_tokens = attr.ib(type=Dict[str, "RefreshToken"], factory=dict, cmp=False) + + _permissions = attr.ib( + type=Optional[perm_mdl.PolicyPermissions], init=False, cmp=False, default=None + ) + + @property + def permissions(self) -> perm_mdl.AbstractPermissions: + """Return permissions object for user.""" + if self.is_owner: + return perm_mdl.OwnerPermissions + + if self._permissions is not None: + return self._permissions + + self._permissions = perm_mdl.PolicyPermissions( + perm_mdl.merge_policies([group.policy for group in self.groups]), + self.perm_lookup, + ) + + return self._permissions + + @property + def is_admin(self) -> bool: + """Return if user is part of the admin group.""" + if self.is_owner: + return True + + return self.is_active and any(gr.id == GROUP_ID_ADMIN for gr in self.groups) + + def invalidate_permission_cache(self) -> None: + """Invalidate permission cache.""" + self._permissions = None @attr.s(slots=True) @@ -44,16 +87,17 @@ class RefreshToken: access_token_expiration = attr.ib(type=timedelta) client_name = attr.ib(type=Optional[str], default=None) client_icon = attr.ib(type=Optional[str], default=None) - token_type = attr.ib(type=str, default=TOKEN_TYPE_NORMAL, - validator=attr.validators.in_(( - TOKEN_TYPE_NORMAL, TOKEN_TYPE_SYSTEM, - TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN))) - id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex)) - created_at = attr.ib(type=datetime, default=attr.Factory(dt_util.utcnow)) - token = attr.ib(type=str, - default=attr.Factory(lambda: generate_secret(64))) - jwt_key = attr.ib(type=str, - default=attr.Factory(lambda: generate_secret(64))) + token_type = attr.ib( + type=str, + default=TOKEN_TYPE_NORMAL, + validator=attr.validators.in_( + (TOKEN_TYPE_NORMAL, TOKEN_TYPE_SYSTEM, TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN) + ), + ) + id = attr.ib(type=str, factory=lambda: uuid.uuid4().hex) + created_at = attr.ib(type=datetime, factory=dt_util.utcnow) + token = attr.ib(type=str, factory=lambda: secrets.token_hex(64)) + jwt_key = attr.ib(type=str, factory=lambda: secrets.token_hex(64)) last_used_at = attr.ib(type=Optional[datetime], default=None) last_used_ip = attr.ib(type=Optional[str], default=None) @@ -69,9 +113,8 @@ class Credentials: # 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)) + id = attr.ib(type=str, factory=lambda: uuid.uuid4().hex) is_new = attr.ib(type=bool, default=True) -UserMeta = NamedTuple("UserMeta", - [('name', Optional[str]), ('is_active', bool)]) +UserMeta = NamedTuple("UserMeta", [("name", Optional[str]), ("is_active", bool)]) diff --git a/homeassistant/auth/permissions/__init__.py b/homeassistant/auth/permissions/__init__.py new file mode 100644 index 000000000..92d02c75b --- /dev/null +++ b/homeassistant/auth/permissions/__init__.py @@ -0,0 +1,75 @@ +"""Permissions for Home Assistant.""" +import logging +from typing import Any, Callable, Optional + +import voluptuous as vol + +from .const import CAT_ENTITIES +from .entities import ENTITY_POLICY_SCHEMA, compile_entities +from .merge import merge_policies # noqa: F401 +from .models import PermissionLookup +from .types import PolicyType +from .util import test_all + +POLICY_SCHEMA = vol.Schema({vol.Optional(CAT_ENTITIES): ENTITY_POLICY_SCHEMA}) + +_LOGGER = logging.getLogger(__name__) + + +class AbstractPermissions: + """Default permissions class.""" + + _cached_entity_func: Optional[Callable[[str, str], bool]] = None + + def _entity_func(self) -> Callable[[str, str], bool]: + """Return a function that can test entity access.""" + raise NotImplementedError + + def access_all_entities(self, key: str) -> bool: + """Check if we have a certain access to all entities.""" + raise NotImplementedError + + def check_entity(self, entity_id: str, key: str) -> bool: + """Check if we can access entity.""" + entity_func = self._cached_entity_func + + if entity_func is None: + entity_func = self._cached_entity_func = self._entity_func() + + return entity_func(entity_id, key) + + +class PolicyPermissions(AbstractPermissions): + """Handle permissions.""" + + def __init__(self, policy: PolicyType, perm_lookup: PermissionLookup) -> None: + """Initialize the permission class.""" + self._policy = policy + self._perm_lookup = perm_lookup + + def access_all_entities(self, key: str) -> bool: + """Check if we have a certain access to all entities.""" + return test_all(self._policy.get(CAT_ENTITIES), key) + + def _entity_func(self) -> Callable[[str, str], bool]: + """Return a function that can test entity access.""" + return compile_entities(self._policy.get(CAT_ENTITIES), self._perm_lookup) + + def __eq__(self, other: Any) -> bool: + """Equals check.""" + return isinstance(other, PolicyPermissions) and other._policy == self._policy + + +class _OwnerPermissions(AbstractPermissions): + """Owner permissions.""" + + def access_all_entities(self, key: str) -> bool: + """Check if we have a certain access to all entities.""" + return True + + def _entity_func(self) -> Callable[[str, str], bool]: + """Return a function that can test entity access.""" + return lambda entity_id, key: True + + +OwnerPermissions = _OwnerPermissions() # pylint: disable=invalid-name diff --git a/homeassistant/auth/permissions/const.py b/homeassistant/auth/permissions/const.py new file mode 100644 index 000000000..e6c44036a --- /dev/null +++ b/homeassistant/auth/permissions/const.py @@ -0,0 +1,8 @@ +"""Permission constants.""" +CAT_ENTITIES = "entities" +CAT_CONFIG_ENTRIES = "config_entries" +SUBCAT_ALL = "all" + +POLICY_READ = "read" +POLICY_CONTROL = "control" +POLICY_EDIT = "edit" diff --git a/homeassistant/auth/permissions/entities.py b/homeassistant/auth/permissions/entities.py new file mode 100644 index 000000000..be30c7bf6 --- /dev/null +++ b/homeassistant/auth/permissions/entities.py @@ -0,0 +1,98 @@ +"""Entity permissions.""" +from collections import OrderedDict +from typing import Callable, Optional + +import voluptuous as vol + +from .const import POLICY_CONTROL, POLICY_EDIT, POLICY_READ, SUBCAT_ALL +from .models import PermissionLookup +from .types import CategoryType, SubCategoryDict, ValueType +from .util import SubCatLookupType, compile_policy, lookup_all + +SINGLE_ENTITY_SCHEMA = vol.Any( + True, + vol.Schema( + { + vol.Optional(POLICY_READ): True, + vol.Optional(POLICY_CONTROL): True, + vol.Optional(POLICY_EDIT): True, + } + ), +) + +ENTITY_DOMAINS = "domains" +ENTITY_AREAS = "area_ids" +ENTITY_DEVICE_IDS = "device_ids" +ENTITY_ENTITY_IDS = "entity_ids" + +ENTITY_VALUES_SCHEMA = vol.Any(True, vol.Schema({str: SINGLE_ENTITY_SCHEMA})) + +ENTITY_POLICY_SCHEMA = vol.Any( + True, + vol.Schema( + { + vol.Optional(SUBCAT_ALL): SINGLE_ENTITY_SCHEMA, + vol.Optional(ENTITY_AREAS): ENTITY_VALUES_SCHEMA, + vol.Optional(ENTITY_DEVICE_IDS): ENTITY_VALUES_SCHEMA, + vol.Optional(ENTITY_DOMAINS): ENTITY_VALUES_SCHEMA, + vol.Optional(ENTITY_ENTITY_IDS): ENTITY_VALUES_SCHEMA, + } + ), +) + + +def _lookup_domain( + perm_lookup: PermissionLookup, domains_dict: SubCategoryDict, entity_id: str +) -> Optional[ValueType]: + """Look up entity permissions by domain.""" + return domains_dict.get(entity_id.split(".", 1)[0]) + + +def _lookup_area( + perm_lookup: PermissionLookup, area_dict: SubCategoryDict, entity_id: str +) -> Optional[ValueType]: + """Look up entity permissions by area.""" + entity_entry = perm_lookup.entity_registry.async_get(entity_id) + + if entity_entry is None or entity_entry.device_id is None: + return None + + device_entry = perm_lookup.device_registry.async_get(entity_entry.device_id) + + if device_entry is None or device_entry.area_id is None: + return None + + return area_dict.get(device_entry.area_id) + + +def _lookup_device( + perm_lookup: PermissionLookup, devices_dict: SubCategoryDict, entity_id: str +) -> Optional[ValueType]: + """Look up entity permissions by device.""" + entity_entry = perm_lookup.entity_registry.async_get(entity_id) + + if entity_entry is None or entity_entry.device_id is None: + return None + + return devices_dict.get(entity_entry.device_id) + + +def _lookup_entity_id( + perm_lookup: PermissionLookup, entities_dict: SubCategoryDict, entity_id: str +) -> Optional[ValueType]: + """Look up entity permission by entity id.""" + return entities_dict.get(entity_id) + + +def compile_entities( + policy: CategoryType, perm_lookup: PermissionLookup +) -> Callable[[str, str], bool]: + """Compile policy into a function that tests policy.""" + subcategories: SubCatLookupType = OrderedDict() + subcategories[ENTITY_ENTITY_IDS] = _lookup_entity_id + subcategories[ENTITY_DEVICE_IDS] = _lookup_device + subcategories[ENTITY_AREAS] = _lookup_area + subcategories[ENTITY_DOMAINS] = _lookup_domain + subcategories[SUBCAT_ALL] = lookup_all + + return compile_policy(policy, subcategories, perm_lookup) diff --git a/homeassistant/auth/permissions/merge.py b/homeassistant/auth/permissions/merge.py new file mode 100644 index 000000000..fad98b3f2 --- /dev/null +++ b/homeassistant/auth/permissions/merge.py @@ -0,0 +1,65 @@ +"""Merging of policies.""" +from typing import Dict, List, Set, cast + +from .types import CategoryType, PolicyType + + +def merge_policies(policies: List[PolicyType]) -> PolicyType: + """Merge policies.""" + new_policy: Dict[str, CategoryType] = {} + seen: Set[str] = set() + for policy in policies: + for category in policy: + if category in seen: + continue + seen.add(category) + new_policy[category] = _merge_policies( + [policy.get(category) for policy in policies] + ) + cast(PolicyType, new_policy) + return new_policy + + +def _merge_policies(sources: List[CategoryType]) -> CategoryType: + """Merge a policy.""" + # When merging policies, the most permissive wins. + # This means we order it like this: + # True > Dict > None + # + # True: allow everything + # Dict: specify more granular permissions + # None: no opinion + # + # If there are multiple sources with a dict as policy, we recursively + # merge each key in the source. + + policy: CategoryType = None + seen: Set[str] = set() + for source in sources: + if source is None: + continue + + # A source that's True will always win. Shortcut return. + if source is True: + return True + + assert isinstance(source, dict) + + if policy is None: + policy = cast(CategoryType, {}) + + assert isinstance(policy, dict) + + for key in source: + if key in seen: + continue + seen.add(key) + + key_sources = [] + for src in sources: + if isinstance(src, dict): + key_sources.append(src.get(key)) + + policy[key] = _merge_policies(key_sources) + + return policy diff --git a/homeassistant/auth/permissions/models.py b/homeassistant/auth/permissions/models.py new file mode 100644 index 000000000..1224ea07b --- /dev/null +++ b/homeassistant/auth/permissions/models.py @@ -0,0 +1,17 @@ +"""Models for permissions.""" +from typing import TYPE_CHECKING + +import attr + +if TYPE_CHECKING: + # pylint: disable=unused-import + from homeassistant.helpers import entity_registry as ent_reg # noqa: F401 + from homeassistant.helpers import device_registry as dev_reg # noqa: F401 + + +@attr.s(slots=True) +class PermissionLookup: + """Class to hold data for permission lookups.""" + + entity_registry = attr.ib(type="ent_reg.EntityRegistry") + device_registry = attr.ib(type="dev_reg.DeviceRegistry") diff --git a/homeassistant/auth/permissions/system_policies.py b/homeassistant/auth/permissions/system_policies.py new file mode 100644 index 000000000..b45984653 --- /dev/null +++ b/homeassistant/auth/permissions/system_policies.py @@ -0,0 +1,8 @@ +"""System policies.""" +from .const import CAT_ENTITIES, POLICY_READ, SUBCAT_ALL + +ADMIN_POLICY = {CAT_ENTITIES: True} + +USER_POLICY = {CAT_ENTITIES: True} + +READ_ONLY_POLICY = {CAT_ENTITIES: {SUBCAT_ALL: {POLICY_READ: True}}} diff --git a/homeassistant/auth/permissions/types.py b/homeassistant/auth/permissions/types.py new file mode 100644 index 000000000..6ce394ebb --- /dev/null +++ b/homeassistant/auth/permissions/types.py @@ -0,0 +1,28 @@ +"""Common code for permissions.""" +from typing import Mapping, Union + +# MyPy doesn't support recursion yet. So writing it out as far as we need. + +ValueType = Union[ + # Example: entities.all = { read: true, control: true } + Mapping[str, bool], + bool, + None, +] + +# Example: entities.domains = { light: … } +SubCategoryDict = Mapping[str, ValueType] + +SubCategoryType = Union[SubCategoryDict, bool, None] + +CategoryType = Union[ + # Example: entities.domains + Mapping[str, SubCategoryType], + # Example: entities.all + Mapping[str, ValueType], + bool, + None, +] + +# Example: { entities: … } +PolicyType = Mapping[str, CategoryType] diff --git a/homeassistant/auth/permissions/util.py b/homeassistant/auth/permissions/util.py new file mode 100644 index 000000000..11bbd878e --- /dev/null +++ b/homeassistant/auth/permissions/util.py @@ -0,0 +1,110 @@ +"""Helpers to deal with permissions.""" +from functools import wraps +from typing import Callable, Dict, List, Optional, cast + +from .const import SUBCAT_ALL +from .models import PermissionLookup +from .types import CategoryType, SubCategoryDict, ValueType + +LookupFunc = Callable[[PermissionLookup, SubCategoryDict, str], Optional[ValueType]] +SubCatLookupType = Dict[str, LookupFunc] + + +def lookup_all( + perm_lookup: PermissionLookup, lookup_dict: SubCategoryDict, object_id: str +) -> ValueType: + """Look up permission for all.""" + # In case of ALL category, lookup_dict IS the schema. + return cast(ValueType, lookup_dict) + + +def compile_policy( + policy: CategoryType, subcategories: SubCatLookupType, perm_lookup: PermissionLookup +) -> Callable[[str, str], bool]: + """Compile policy into a function that tests policy. + + Subcategories are mapping key -> lookup function, ordered by highest + priority first. + """ + # None, False, empty dict + if not policy: + + def apply_policy_deny_all(entity_id: str, key: str) -> bool: + """Decline all.""" + return False + + return apply_policy_deny_all + + if policy is True: + + def apply_policy_allow_all(entity_id: str, key: str) -> bool: + """Approve all.""" + return True + + return apply_policy_allow_all + + assert isinstance(policy, dict) + + funcs: List[Callable[[str, str], Optional[bool]]] = [] + + for key, lookup_func in subcategories.items(): + lookup_value = policy.get(key) + + # If any lookup value is `True`, it will always be positive + if isinstance(lookup_value, bool): + return lambda object_id, key: True + + if lookup_value is not None: + funcs.append(_gen_dict_test_func(perm_lookup, lookup_func, lookup_value)) + + if len(funcs) == 1: + func = funcs[0] + + @wraps(func) + def apply_policy_func(object_id: str, key: str) -> bool: + """Apply a single policy function.""" + return func(object_id, key) is True + + return apply_policy_func + + def apply_policy_funcs(object_id: str, key: str) -> bool: + """Apply several policy functions.""" + for func in funcs: + result = func(object_id, key) + if result is not None: + return result + return False + + return apply_policy_funcs + + +def _gen_dict_test_func( + perm_lookup: PermissionLookup, lookup_func: LookupFunc, lookup_dict: SubCategoryDict +) -> Callable[[str, str], Optional[bool]]: + """Generate a lookup function.""" + + def test_value(object_id: str, key: str) -> Optional[bool]: + """Test if permission is allowed based on the keys.""" + schema: ValueType = lookup_func(perm_lookup, lookup_dict, object_id) + + if schema is None or isinstance(schema, bool): + return schema + + assert isinstance(schema, dict) + + return schema.get(key) + + return test_value + + +def test_all(policy: CategoryType, key: str) -> bool: + """Test if a policy has an ALL access for a specific key.""" + if not isinstance(policy, dict): + return bool(policy) + + all_policy = policy.get(SUBCAT_ALL) + + if not isinstance(all_policy, dict): + return bool(all_policy) + + return all_policy.get(key, False) diff --git a/homeassistant/auth/providers/__init__.py b/homeassistant/auth/providers/__init__.py index 3cb1c6b12..bb0fc55b5 100644 --- a/homeassistant/auth/providers/__init__.py +++ b/homeassistant/auth/providers/__init__.py @@ -8,43 +8,47 @@ import voluptuous as vol from voluptuous.humanize import humanize_error from homeassistant import data_entry_flow, requirements -from homeassistant.core import callback, HomeAssistant from homeassistant.const import CONF_ID, CONF_NAME, CONF_TYPE +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.util import dt as dt_util from homeassistant.util.decorator import Registry from ..auth_store import AuthStore -from ..models import Credentials, User, UserMeta # noqa: F401 -from ..mfa_modules import SESSION_EXPIRATION +from ..const import MFA_SESSION_EXPIRATION +from ..models import Credentials, User, UserMeta _LOGGER = logging.getLogger(__name__) -DATA_REQS = 'auth_prov_reqs_processed' +DATA_REQS = "auth_prov_reqs_processed" 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) +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, +) class AuthProvider: """Provider of user authentication.""" - DEFAULT_TITLE = 'Unnamed auth provider' + DEFAULT_TITLE = "Unnamed auth provider" - def __init__(self, hass: HomeAssistant, store: AuthStore, - config: Dict[str, Any]) -> None: + def __init__( + self, hass: HomeAssistant, store: AuthStore, config: Dict[str, Any] + ) -> None: """Initialize an auth provider.""" self.hass = hass self.store = store self.config = config @property - def id(self) -> Optional[str]: # pylint: disable=invalid-name + def id(self) -> Optional[str]: """Return id of the auth provider. Optional, can be None. @@ -73,22 +77,22 @@ class AuthProvider: credentials for user in users for credentials in user.credentials - if (credentials.auth_provider_type == self.type and - credentials.auth_provider_id == self.id) + if ( + credentials.auth_provider_type == self.type + and credentials.auth_provider_id == self.id + ) ] @callback def async_create_credentials(self, data: Dict[str, str]) -> Credentials: """Create credentials.""" return Credentials( - auth_provider_type=self.type, - auth_provider_id=self.id, - data=data, + auth_provider_type=self.type, auth_provider_id=self.id, data=data ) # Implement by extending class - async def async_login_flow(self, context: Optional[Dict]) -> 'LoginFlow': + async def async_login_flow(self, context: Optional[Dict]) -> "LoginFlow": """Return the data flow for logging in with auth provider. Auth provider should extend LoginFlow and return an instance. @@ -96,22 +100,28 @@ class AuthProvider: raise NotImplementedError async def async_get_or_create_credentials( - self, flow_result: Dict[str, str]) -> Credentials: + self, flow_result: Dict[str, str] + ) -> Credentials: """Get credentials based on the flow result.""" raise NotImplementedError async def async_user_meta_for_credentials( - self, credentials: Credentials) -> UserMeta: + self, credentials: Credentials + ) -> UserMeta: """Return extra user metadata for credentials. Will be used to populate info when creating a new user. """ raise NotImplementedError + async def async_initialize(self) -> None: + """Initialize the auth provider.""" + pass + async def auth_provider_from_config( - hass: HomeAssistant, store: AuthStore, - config: Dict[str, Any]) -> AuthProvider: + hass: HomeAssistant, store: AuthStore, config: Dict[str, Any] +) -> AuthProvider: """Initialize an auth provider from a config.""" provider_name = config[CONF_TYPE] module = await load_auth_provider_module(hass, provider_name) @@ -119,25 +129,27 @@ async def auth_provider_from_config( try: config = module.CONFIG_SCHEMA(config) # type: ignore except vol.Invalid as err: - _LOGGER.error('Invalid configuration for auth provider %s: %s', - provider_name, humanize_error(config, err)) + _LOGGER.error( + "Invalid configuration for auth provider %s: %s", + provider_name, + humanize_error(config, err), + ) raise return AUTH_PROVIDERS[provider_name](hass, store, config) # type: ignore async def load_auth_provider_module( - hass: HomeAssistant, provider: str) -> types.ModuleType: + hass: HomeAssistant, provider: str +) -> types.ModuleType: """Load an auth provider.""" try: - module = importlib.import_module( - 'homeassistant.auth.providers.{}'.format(provider)) + module = importlib.import_module(f"homeassistant.auth.providers.{provider}") except ImportError as err: - _LOGGER.error('Unable to load auth provider %s: %s', provider, err) - raise HomeAssistantError('Unable to load auth provider {}: {}'.format( - provider, err)) + _LOGGER.error("Unable to load auth provider %s: %s", provider, err) + raise HomeAssistantError(f"Unable to load auth provider {provider}: {err}") - if hass.config.skip_pip or not hasattr(module, 'REQUIREMENTS'): + if hass.config.skip_pip or not hasattr(module, "REQUIREMENTS"): return module processed = hass.data.get(DATA_REQS) @@ -149,13 +161,9 @@ async def load_auth_provider_module( # https://github.com/python/mypy/issues/1424 reqs = module.REQUIREMENTS # type: ignore - req_success = await requirements.async_process_requirements( - hass, 'auth provider {}'.format(provider), reqs) - - if not req_success: - raise HomeAssistantError( - 'Unable to process requirements of auth provider {}'.format( - provider)) + await requirements.async_process_requirements( + hass, f"auth provider {provider}", reqs + ) processed.add(provider) return module @@ -167,82 +175,93 @@ class LoginFlow(data_entry_flow.FlowHandler): def __init__(self, auth_provider: AuthProvider) -> None: """Initialize the login flow.""" self._auth_provider = auth_provider - self._auth_module_id = None # type: Optional[str] + self._auth_module_id: Optional[str] = None self._auth_manager = auth_provider.hass.auth # type: ignore - self.available_mfa_modules = {} # type: Dict[str, str] + self.available_mfa_modules: Dict[str, str] = {} self.created_at = dt_util.utcnow() - self.user = None # type: Optional[User] + self.invalid_mfa_times = 0 + self.user: Optional[User] = None async def async_step_init( - self, user_input: Optional[Dict[str, str]] = None) \ - -> Dict[str, Any]: + self, user_input: Optional[Dict[str, str]] = None + ) -> Dict[str, Any]: """Handle the first step of login flow. - Return self.async_show_form(step_id='init') if user_input == None. + Return self.async_show_form(step_id='init') if user_input is None. Return await self.async_finish(flow_result) if login init step pass. """ raise NotImplementedError async def async_step_select_mfa_module( - self, user_input: Optional[Dict[str, str]] = None) \ - -> Dict[str, Any]: + self, user_input: Optional[Dict[str, str]] = None + ) -> Dict[str, Any]: """Handle the step of select mfa module.""" errors = {} if user_input is not None: - auth_module = user_input.get('multi_factor_auth_module') + auth_module = user_input.get("multi_factor_auth_module") if auth_module in self.available_mfa_modules: self._auth_module_id = auth_module return await self.async_step_mfa() - errors['base'] = 'invalid_auth_module' + errors["base"] = "invalid_auth_module" if len(self.available_mfa_modules) == 1: self._auth_module_id = list(self.available_mfa_modules.keys())[0] return await self.async_step_mfa() return self.async_show_form( - step_id='select_mfa_module', - data_schema=vol.Schema({ - 'multi_factor_auth_module': vol.In(self.available_mfa_modules) - }), + step_id="select_mfa_module", + data_schema=vol.Schema( + {"multi_factor_auth_module": vol.In(self.available_mfa_modules)} + ), errors=errors, ) async def async_step_mfa( - self, user_input: Optional[Dict[str, str]] = None) \ - -> Dict[str, Any]: + self, user_input: Optional[Dict[str, str]] = None + ) -> Dict[str, Any]: """Handle the step of mfa validation.""" + assert self.user + errors = {} - auth_module = self._auth_manager.get_auth_mfa_module( - self._auth_module_id) + auth_module = self._auth_manager.get_auth_mfa_module(self._auth_module_id) if auth_module is None: # Given an invalid input to async_step_select_mfa_module # will show invalid_auth_module error return await self.async_step_select_mfa_module(user_input={}) - if user_input is not None: - expires = self.created_at + SESSION_EXPIRATION - if dt_util.utcnow() > expires: - return self.async_abort( - reason='login_expired' - ) + if user_input is None and hasattr( + auth_module, "async_initialize_login_mfa_step" + ): + try: + await auth_module.async_initialize_login_mfa_step(self.user.id) + except HomeAssistantError: + _LOGGER.exception("Error initializing MFA step") + return self.async_abort(reason="unknown_error") - result = await auth_module.async_validation( - self.user.id, user_input) # type: ignore + if user_input is not None: + expires = self.created_at + MFA_SESSION_EXPIRATION + if dt_util.utcnow() > expires: + return self.async_abort(reason="login_expired") + + result = await auth_module.async_validate(self.user.id, user_input) if not result: - errors['base'] = 'invalid_code' + errors["base"] = "invalid_code" + self.invalid_mfa_times += 1 + if self.invalid_mfa_times >= auth_module.MAX_RETRY_TIME > 0: + return self.async_abort(reason="too_many_retry") if not errors: return await self.async_finish(self.user) - description_placeholders = { - 'mfa_module_name': auth_module.name, - 'mfa_module_id': auth_module.id - } # type: Dict[str, str] + description_placeholders: Dict[str, Optional[str]] = { + "mfa_module_name": auth_module.name, + "mfa_module_id": auth_module.id, + } return self.async_show_form( - step_id='mfa', + step_id="mfa", data_schema=auth_module.input_schema, description_placeholders=description_placeholders, errors=errors, @@ -250,7 +269,4 @@ class LoginFlow(data_entry_flow.FlowHandler): async def async_finish(self, flow_result: Any) -> Dict: """Handle the pass of login flow.""" - return self.async_create_entry( - title=self._auth_provider.name, - data=flow_result - ) + return self.async_create_entry(title=self._auth_provider.name, data=flow_result) diff --git a/homeassistant/auth/providers/command_line.py b/homeassistant/auth/providers/command_line.py new file mode 100644 index 000000000..203bc1911 --- /dev/null +++ b/homeassistant/auth/providers/command_line.py @@ -0,0 +1,153 @@ +"""Auth provider that validates credentials via an external command.""" + +import asyncio.subprocess +import collections +import logging +import os +from typing import Any, Dict, Optional, cast + +import voluptuous as vol + +from homeassistant.exceptions import HomeAssistantError + +from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow +from ..models import Credentials, UserMeta + +CONF_COMMAND = "command" +CONF_ARGS = "args" +CONF_META = "meta" + +CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend( + { + vol.Required(CONF_COMMAND): vol.All( + str, os.path.normpath, msg="must be an absolute path" + ), + vol.Optional(CONF_ARGS, default=None): vol.Any(vol.DefaultTo(list), [str]), + vol.Optional(CONF_META, default=False): bool, + }, + extra=vol.PREVENT_EXTRA, +) + +_LOGGER = logging.getLogger(__name__) + + +class InvalidAuthError(HomeAssistantError): + """Raised when authentication with given credentials fails.""" + + +@AUTH_PROVIDERS.register("command_line") +class CommandLineAuthProvider(AuthProvider): + """Auth provider validating credentials by calling a command.""" + + DEFAULT_TITLE = "Command Line Authentication" + + # which keys to accept from a program's stdout + ALLOWED_META_KEYS = ("name",) + + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Extend parent's __init__. + + Adds self._user_meta dictionary to hold the user-specific + attributes provided by external programs. + """ + super().__init__(*args, **kwargs) + self._user_meta: Dict[str, Dict[str, Any]] = {} + + async def async_login_flow(self, context: Optional[dict]) -> LoginFlow: + """Return a flow to login.""" + return CommandLineLoginFlow(self) + + async def async_validate_login(self, username: str, password: str) -> None: + """Validate a username and password.""" + env = {"username": username, "password": password} + try: + # pylint: disable=no-member + process = await asyncio.subprocess.create_subprocess_exec( + self.config[CONF_COMMAND], + *self.config[CONF_ARGS], + env=env, + stdout=asyncio.subprocess.PIPE if self.config[CONF_META] else None, + ) + stdout, _ = await process.communicate() + except OSError as err: + # happens when command doesn't exist or permission is denied + _LOGGER.error("Error while authenticating %r: %s", username, err) + raise InvalidAuthError + + if process.returncode != 0: + _LOGGER.error( + "User %r failed to authenticate, command exited " "with code %d.", + username, + process.returncode, + ) + raise InvalidAuthError + + if self.config[CONF_META]: + meta: Dict[str, str] = {} + for _line in stdout.splitlines(): + try: + line = _line.decode().lstrip() + if line.startswith("#"): + continue + key, value = line.split("=", 1) + except ValueError: + # malformed line + continue + key = key.strip() + value = value.strip() + if key in self.ALLOWED_META_KEYS: + meta[key] = value + self._user_meta[username] = meta + + async def async_get_or_create_credentials( + self, flow_result: Dict[str, str] + ) -> Credentials: + """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: Credentials + ) -> UserMeta: + """Return extra user metadata for credentials. + + Currently, only name is supported. + """ + meta = self._user_meta.get(credentials.data["username"], {}) + return UserMeta(name=meta.get("name"), is_active=True) + + +class CommandLineLoginFlow(LoginFlow): + """Handler for the login flow.""" + + async def async_step_init( + self, user_input: Optional[Dict[str, str]] = None + ) -> Dict[str, Any]: + """Handle the step of the form.""" + errors = {} + + if user_input is not None: + user_input["username"] = user_input["username"].strip() + try: + await cast( + CommandLineAuthProvider, self._auth_provider + ).async_validate_login(user_input["username"], user_input["password"]) + except InvalidAuthError: + errors["base"] = "invalid_auth" + + if not errors: + user_input.pop("password") + return await self.async_finish(user_input) + + schema: Dict[str, type] = collections.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/homeassistant.py b/homeassistant/auth/providers/homeassistant.py index c743a5b7f..9ddbf4189 100644 --- a/homeassistant/auth/providers/homeassistant.py +++ b/homeassistant/auth/providers/homeassistant.py @@ -1,33 +1,28 @@ """Home Assistant auth provider.""" +import asyncio import base64 from collections import OrderedDict -import hashlib -import hmac -from typing import Any, Dict, List, Optional, cast +import logging +from typing import Any, Dict, List, Optional, Set, cast import bcrypt import voluptuous as vol from homeassistant.const import CONF_ID -from homeassistant.core import callback, HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.util.async_ import run_coroutine_threadsafe - -from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, LoginFlow +from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow from ..models import Credentials, UserMeta -from ..util import generate_secret - STORAGE_VERSION = 1 -STORAGE_KEY = 'auth_provider.homeassistant' +STORAGE_KEY = "auth_provider.homeassistant" def _disallow_id(conf: Dict[str, Any]) -> Dict[str, Any]: """Disallow ID in config.""" if CONF_ID in conf: - raise vol.Invalid( - 'ID is not allowed for the homeassistant auth provider.') + raise vol.Invalid("ID is not allowed for the homeassistant auth provider.") return conf @@ -52,105 +47,130 @@ class Data: def __init__(self, hass: HomeAssistant) -> None: """Initialize the user data store.""" self.hass = hass - self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) - self._data = None # type: Optional[Dict[str, Any]] + self._store = hass.helpers.storage.Store( + STORAGE_VERSION, STORAGE_KEY, private=True + ) + self._data: Optional[Dict[str, Any]] = None + # Legacy mode will allow usernames to start/end with whitespace + # and will compare usernames case-insensitive. + # Remove in 2020 or when we launch 1.0. + self.is_legacy = False + + @callback + def normalize_username(self, username: str) -> str: + """Normalize a username based on the mode.""" + if self.is_legacy: + return username + + return username.strip().casefold() async def async_load(self) -> None: """Load stored data.""" data = await self._store.async_load() if data is None: - data = { - 'salt': generate_secret(), - 'users': [] - } + data = {"users": []} + + seen: Set[str] = set() + + for user in data["users"]: + username = user["username"] + + # check if we have duplicates + folded = username.casefold() + + if folded in seen: + self.is_legacy = True + + logging.getLogger(__name__).warning( + "Home Assistant auth provider is running in legacy mode " + "because we detected usernames that are case-insensitive" + "equivalent. Please change the username: '%s'.", + username, + ) + + break + + seen.add(folded) + + # check if we have unstripped usernames + if username != username.strip(): + self.is_legacy = True + + logging.getLogger(__name__).warning( + "Home Assistant auth provider is running in legacy mode " + "because we detected usernames that start or end in a " + "space. Please change the username: '%s'.", + username, + ) + + break self._data = data @property def users(self) -> List[Dict[str, str]]: """Return users.""" - return self._data['users'] # type: ignore + return self._data["users"] # type: ignore def validate_login(self, username: str, password: str) -> None: """Validate a username and password. Raises InvalidAuth if auth invalid. """ - dummy = b'$2b$12$CiuFGszHx9eNHxPuQcwBWez4CwDTOcLTX5CbOpV6gef2nYuXkY7BO' + username = self.normalize_username(username) + dummy = b"$2b$12$CiuFGszHx9eNHxPuQcwBWez4CwDTOcLTX5CbOpV6gef2nYuXkY7BO" found = None # Compare all users to avoid timing attacks. for user in self.users: - if username == user['username']: + if self.normalize_username(user["username"]) == username: found = user if found is None: # check a hash to make timing the same as if user was found - bcrypt.checkpw(b'foo', - dummy) + bcrypt.checkpw(b"foo", dummy) raise InvalidAuth - user_hash = base64.b64decode(found['password']) - - # if the hash is not a bcrypt hash... - # provide a transparant upgrade for old pbkdf2 hash format - if not (user_hash.startswith(b'$2a$') - or user_hash.startswith(b'$2b$') - or user_hash.startswith(b'$2x$') - or user_hash.startswith(b'$2y$')): - # IMPORTANT! validate the login, bail if invalid - hashed = self.legacy_hash_password(password) - if not hmac.compare_digest(hashed, user_hash): - raise InvalidAuth - # then re-hash the valid password with bcrypt - self.change_password(found['username'], password) - run_coroutine_threadsafe( - self.async_save(), self.hass.loop - ).result() - user_hash = base64.b64decode(found['password']) + user_hash = base64.b64decode(found["password"]) # bcrypt.checkpw is timing-safe - if not bcrypt.checkpw(password.encode(), - user_hash): + if not bcrypt.checkpw(password.encode(), user_hash): raise InvalidAuth - def legacy_hash_password(self, password: str, - for_storage: bool = False) -> bytes: - """LEGACY password encoding.""" - # We're no longer storing salts in data, but if one exists we - # should be able to retrieve it. - salt = self._data['salt'].encode() # type: ignore - hashed = hashlib.pbkdf2_hmac('sha512', password.encode(), salt, 100000) - if for_storage: - hashed = base64.b64encode(hashed) - return hashed - # pylint: disable=no-self-use def hash_password(self, password: str, for_storage: bool = False) -> bytes: """Encode a password.""" - hashed = bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12)) \ - # type: bytes + hashed: bytes = bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12)) + if for_storage: hashed = base64.b64encode(hashed) return hashed def add_auth(self, username: str, password: str) -> None: """Add a new authenticated user/pass.""" - if any(user['username'] == username for user in self.users): + username = self.normalize_username(username) + + if any( + self.normalize_username(user["username"]) == username for user in self.users + ): raise InvalidUser - self.users.append({ - 'username': username, - 'password': self.hash_password(password, True).decode(), - }) + self.users.append( + { + "username": username, + "password": self.hash_password(password, True).decode(), + } + ) @callback def async_remove_auth(self, username: str) -> None: """Remove authentication.""" + username = self.normalize_username(username) + index = None for i, user in enumerate(self.users): - if user['username'] == username: + if self.normalize_username(user["username"]) == username: index = i break @@ -164,10 +184,11 @@ class Data: Raises InvalidUser if user cannot be found. """ + username = self.normalize_username(username) + for user in self.users: - if user['username'] == username: - user['password'] = self.hash_password( - new_password, True).decode() + if self.normalize_username(user["username"]) == username: + user["password"] = self.hash_password(new_password, True).decode() break else: raise InvalidUser @@ -177,24 +198,29 @@ class Data: await self._store.async_save(self._data) -@AUTH_PROVIDERS.register('homeassistant') +@AUTH_PROVIDERS.register("homeassistant") class HassAuthProvider(AuthProvider): """Auth provider based on a local storage of users in HASS config dir.""" - DEFAULT_TITLE = 'Home Assistant Local' + DEFAULT_TITLE = "Home Assistant Local" - data = None + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Initialize an Home Assistant auth provider.""" + super().__init__(*args, **kwargs) + self.data: Optional[Data] = None + self._init_lock = asyncio.Lock() async def async_initialize(self) -> None: """Initialize the auth provider.""" - if self.data is not None: - return + async with self._init_lock: + if self.data is not None: + return - self.data = Data(self.hass) - await self.data.async_load() + data = Data(self.hass) + await data.async_load() + self.data = data - async def async_login_flow( - self, context: Optional[Dict]) -> LoginFlow: + async def async_login_flow(self, context: Optional[Dict]) -> LoginFlow: """Return a flow to login.""" return HassLoginFlow(self) @@ -205,36 +231,41 @@ class HassAuthProvider(AuthProvider): assert self.data is not None await self.hass.async_add_executor_job( - self.data.validate_login, username, password) + self.data.validate_login, username, password + ) async def async_get_or_create_credentials( - self, flow_result: Dict[str, str]) -> Credentials: + self, flow_result: Dict[str, str] + ) -> Credentials: """Get credentials based on the flow result.""" - username = flow_result['username'] + if self.data is None: + await self.async_initialize() + assert self.data is not None + + norm_username = self.data.normalize_username + username = norm_username(flow_result["username"]) for credential in await self.async_credentials(): - if credential.data['username'] == username: + if norm_username(credential.data["username"]) == username: return credential # Create new credentials. - return self.async_create_credentials({ - 'username': username - }) + return self.async_create_credentials({"username": username}) async def async_user_meta_for_credentials( - self, credentials: Credentials) -> UserMeta: + self, credentials: Credentials + ) -> UserMeta: """Get extra info for this credential.""" - return UserMeta(name=credentials.data['username'], is_active=True) + return UserMeta(name=credentials.data["username"], is_active=True) - async def async_will_remove_credentials( - self, credentials: Credentials) -> None: + async def async_will_remove_credentials(self, credentials: Credentials) -> None: """When credentials get removed, also remove the auth.""" if self.data is None: await self.async_initialize() assert self.data is not None try: - self.data.async_remove_auth(credentials.data['username']) + self.data.async_remove_auth(credentials.data["username"]) await self.data.async_save() except InvalidUser: # Can happen if somehow we didn't clean up a credential @@ -245,29 +276,27 @@ class HassLoginFlow(LoginFlow): """Handler for the login flow.""" async def async_step_init( - self, user_input: Optional[Dict[str, str]] = None) \ - -> Dict[str, Any]: + self, user_input: Optional[Dict[str, str]] = None + ) -> Dict[str, Any]: """Handle the step of the form.""" errors = {} if user_input is not None: try: - await cast(HassAuthProvider, self._auth_provider)\ - .async_validate_login(user_input['username'], - user_input['password']) + await cast(HassAuthProvider, self._auth_provider).async_validate_login( + user_input["username"], user_input["password"] + ) except InvalidAuth: - errors['base'] = 'invalid_auth' + errors["base"] = "invalid_auth" if not errors: - user_input.pop('password') + user_input.pop("password") return await self.async_finish(user_input) - schema = OrderedDict() # type: Dict[str, type] - schema['username'] = str - schema['password'] = str + schema: Dict[str, type] = OrderedDict() + schema["username"] = str + schema["password"] = str return self.async_show_form( - step_id='init', - data_schema=vol.Schema(schema), - errors=errors, + 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 index 72e3dfe14..70014a236 100644 --- a/homeassistant/auth/providers/insecure_example.py +++ b/homeassistant/auth/providers/insecure_example.py @@ -5,30 +5,31 @@ from typing import Any, Dict, Optional, cast import voluptuous as vol -from homeassistant.exceptions import HomeAssistantError from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError -from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, LoginFlow +from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow from ..models import Credentials, UserMeta - -USER_SCHEMA = vol.Schema({ - vol.Required('username'): str, - vol.Required('password'): str, - vol.Optional('name'): str, -}) +USER_SCHEMA = vol.Schema( + { + vol.Required("username"): str, + vol.Required("password"): str, + vol.Optional("name"): str, + } +) -CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend({ - vol.Required('users'): [USER_SCHEMA] -}, extra=vol.PREVENT_EXTRA) +CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend( + {vol.Required("users"): [USER_SCHEMA]}, extra=vol.PREVENT_EXTRA +) class InvalidAuthError(HomeAssistantError): """Raised when submitting invalid authentication.""" -@AUTH_PROVIDERS.register('insecure_example') +@AUTH_PROVIDERS.register("insecure_example") class ExampleAuthProvider(AuthProvider): """Example auth provider based on hardcoded usernames and passwords.""" @@ -42,47 +43,48 @@ class ExampleAuthProvider(AuthProvider): 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')): + 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')) + 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')): + 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: Dict[str, str]) -> Credentials: + self, flow_result: Dict[str, str] + ) -> Credentials: """Get credentials based on the flow result.""" - username = flow_result['username'] + username = flow_result["username"] for credential in await self.async_credentials(): - if credential.data['username'] == username: + if credential.data["username"] == username: return credential # Create new credentials. - return self.async_create_credentials({ - 'username': username - }) + return self.async_create_credentials({"username": username}) async def async_user_meta_for_credentials( - self, credentials: Credentials) -> UserMeta: + self, credentials: Credentials + ) -> UserMeta: """Return extra user metadata for credentials. Will be used to populate info when creating a new user. """ - username = credentials.data['username'] + username = credentials.data["username"] name = None - for user in self.config['users']: - if user['username'] == username: - name = user.get('name') + for user in self.config["users"]: + if user["username"] == username: + name = user.get("name") break return UserMeta(name=name, is_active=True) @@ -92,29 +94,27 @@ class ExampleLoginFlow(LoginFlow): """Handler for the login flow.""" async def async_step_init( - self, user_input: Optional[Dict[str, str]] = None) \ - -> Dict[str, Any]: + self, user_input: Optional[Dict[str, str]] = None + ) -> Dict[str, Any]: """Handle the step of the form.""" errors = {} if user_input is not None: try: - cast(ExampleAuthProvider, self._auth_provider)\ - .async_validate_login(user_input['username'], - user_input['password']) + cast(ExampleAuthProvider, self._auth_provider).async_validate_login( + user_input["username"], user_input["password"] + ) except InvalidAuthError: - errors['base'] = 'invalid_auth' + errors["base"] = "invalid_auth" if not errors: - user_input.pop('password') + user_input.pop("password") return await self.async_finish(user_input) - schema = OrderedDict() # type: Dict[str, type] - schema['username'] = str - schema['password'] = str + schema: Dict[str, type] = OrderedDict() + schema["username"] = str + schema["password"] = str return self.async_show_form( - step_id='init', - data_schema=vol.Schema(schema), - errors=errors, + step_id="init", data_schema=vol.Schema(schema), errors=errors ) diff --git a/homeassistant/auth/providers/legacy_api_password.py b/homeassistant/auth/providers/legacy_api_password.py index 111b9e7d3..15ba1dfc1 100644 --- a/homeassistant/auth/providers/legacy_api_password.py +++ b/homeassistant/auth/providers/legacy_api_password.py @@ -8,34 +8,55 @@ from typing import Any, Dict, Optional, cast import voluptuous as vol -from homeassistant.components.http import HomeAssistantHTTP # noqa: F401 -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +import homeassistant.helpers.config_validation as cv -from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, LoginFlow -from ..models import Credentials, UserMeta +from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow +from .. import AuthManager +from ..models import Credentials, User, UserMeta +AUTH_PROVIDER_TYPE = "legacy_api_password" +CONF_API_PASSWORD = "api_password" -USER_SCHEMA = vol.Schema({ - vol.Required('username'): str, -}) +CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend( + {vol.Required(CONF_API_PASSWORD): cv.string}, extra=vol.PREVENT_EXTRA +) - -CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend({ -}, extra=vol.PREVENT_EXTRA) - -LEGACY_USER_NAME = 'Legacy API password user' +LEGACY_USER_NAME = "Legacy API password user" class InvalidAuthError(HomeAssistantError): """Raised when submitting invalid authentication.""" -@AUTH_PROVIDERS.register('legacy_api_password') -class LegacyApiPasswordAuthProvider(AuthProvider): - """Example auth provider based on hardcoded usernames and passwords.""" +async def async_validate_password(hass: HomeAssistant, password: str) -> Optional[User]: + """Return a user if password is valid. None if not.""" + auth = cast(AuthManager, hass.auth) # type: ignore + providers = auth.get_auth_providers(AUTH_PROVIDER_TYPE) + if not providers: + raise ValueError("Legacy API password provider not found") - DEFAULT_TITLE = 'Legacy API Password' + try: + provider = cast(LegacyApiPasswordAuthProvider, providers[0]) + provider.async_validate_login(password) + return await auth.async_get_or_create_user( + await provider.async_get_or_create_credentials({}) + ) + except InvalidAuthError: + return None + + +@AUTH_PROVIDERS.register(AUTH_PROVIDER_TYPE) +class LegacyApiPasswordAuthProvider(AuthProvider): + """An auth provider support legacy api_password.""" + + DEFAULT_TITLE = "Legacy API Password" + + @property + def api_password(self) -> str: + """Return api_password.""" + return str(self.config[CONF_API_PASSWORD]) async def async_login_flow(self, context: Optional[Dict]) -> LoginFlow: """Return a flow to login.""" @@ -43,15 +64,17 @@ class LegacyApiPasswordAuthProvider(AuthProvider): @callback def async_validate_login(self, password: str) -> None: - """Validate a username and password.""" - hass_http = getattr(self.hass, 'http', None) # type: HomeAssistantHTTP + """Validate password.""" + api_password = str(self.config[CONF_API_PASSWORD]) - if not hmac.compare_digest(hass_http.api_password.encode('utf-8'), - password.encode('utf-8')): + if not hmac.compare_digest( + api_password.encode("utf-8"), password.encode("utf-8") + ): raise InvalidAuthError async def async_get_or_create_credentials( - self, flow_result: Dict[str, str]) -> Credentials: + self, flow_result: Dict[str, str] + ) -> Credentials: """Return credentials for this login.""" credentials = await self.async_credentials() if credentials: @@ -60,7 +83,8 @@ class LegacyApiPasswordAuthProvider(AuthProvider): return self.async_create_credentials({}) async def async_user_meta_for_credentials( - self, credentials: Credentials) -> UserMeta: + self, credentials: Credentials + ) -> UserMeta: """ Return info for the user. @@ -73,29 +97,22 @@ class LegacyLoginFlow(LoginFlow): """Handler for the login flow.""" async def async_step_init( - self, user_input: Optional[Dict[str, str]] = None) \ - -> Dict[str, Any]: + self, user_input: Optional[Dict[str, str]] = None + ) -> Dict[str, Any]: """Handle the step of the form.""" errors = {} - hass_http = getattr(self.hass, 'http', None) - if hass_http is None or not hass_http.api_password: - return self.async_abort( - reason='no_api_password_set' - ) - if user_input is not None: try: - cast(LegacyApiPasswordAuthProvider, self._auth_provider)\ - .async_validate_login(user_input['password']) + cast( + LegacyApiPasswordAuthProvider, self._auth_provider + ).async_validate_login(user_input["password"]) except InvalidAuthError: - errors['base'] = 'invalid_auth' + errors["base"] = "invalid_auth" if not errors: return await self.async_finish({}) return self.async_show_form( - step_id='init', - data_schema=vol.Schema({'password': str}), - errors=errors, + step_id="init", data_schema=vol.Schema({"password": str}), errors=errors ) diff --git a/homeassistant/auth/providers/trusted_networks.py b/homeassistant/auth/providers/trusted_networks.py index 8a7e1d67c..bc995368f 100644 --- a/homeassistant/auth/providers/trusted_networks.py +++ b/homeassistant/auth/providers/trusted_networks.py @@ -3,19 +3,47 @@ It shows list of users if access from trusted network. Abort login flow if not access from trusted network. """ -from typing import Any, Dict, Optional, cast +from ipaddress import IPv4Address, IPv4Network, IPv6Address, IPv6Network, ip_network +from typing import Any, Dict, List, Optional, Union, cast import voluptuous as vol -from homeassistant.components.http import HomeAssistantHTTP # noqa: F401 from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError +import homeassistant.helpers.config_validation as cv -from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, LoginFlow +from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow from ..models import Credentials, UserMeta -CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend({ -}, extra=vol.PREVENT_EXTRA) +IPAddress = Union[IPv4Address, IPv6Address] +IPNetwork = Union[IPv4Network, IPv6Network] + +CONF_TRUSTED_NETWORKS = "trusted_networks" +CONF_TRUSTED_USERS = "trusted_users" +CONF_GROUP = "group" +CONF_ALLOW_BYPASS_LOGIN = "allow_bypass_login" + +CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend( + { + vol.Required(CONF_TRUSTED_NETWORKS): vol.All(cv.ensure_list, [ip_network]), + vol.Optional(CONF_TRUSTED_USERS, default={}): vol.Schema( + # we only validate the format of user_id or group_id + { + ip_network: vol.All( + cv.ensure_list, + [ + vol.Or( + cv.uuid4_hex, + vol.Schema({vol.Required(CONF_GROUP): cv.uuid4_hex}), + ) + ], + ) + } + ), + vol.Optional(CONF_ALLOW_BYPASS_LOGIN, default=False): cv.boolean, + }, + extra=vol.PREVENT_EXTRA, +) class InvalidAuthError(HomeAssistantError): @@ -26,14 +54,24 @@ class InvalidUserError(HomeAssistantError): """Raised when try to login as invalid user.""" -@AUTH_PROVIDERS.register('trusted_networks') +@AUTH_PROVIDERS.register("trusted_networks") class TrustedNetworksAuthProvider(AuthProvider): """Trusted Networks auth provider. Allow passwordless access from trusted network. """ - DEFAULT_TITLE = 'Trusted Networks' + DEFAULT_TITLE = "Trusted Networks" + + @property + def trusted_networks(self) -> List[IPNetwork]: + """Return trusted networks.""" + return cast(List[IPNetwork], self.config[CONF_TRUSTED_NETWORKS]) + + @property + def trusted_users(self) -> Dict[IPNetwork, Any]: + """Return trusted users per network.""" + return cast(Dict[IPNetwork, Any], self.config[CONF_TRUSTED_USERS]) @property def support_mfa(self) -> bool: @@ -43,28 +81,58 @@ class TrustedNetworksAuthProvider(AuthProvider): async def async_login_flow(self, context: Optional[Dict]) -> LoginFlow: """Return a flow to login.""" assert context is not None + ip_addr = cast(IPAddress, context.get("ip_address")) users = await self.store.async_get_users() - available_users = {user.id: user.name - for user in users - if not user.system_generated and user.is_active} + available_users = [ + user for user in users if not user.system_generated and user.is_active + ] + for ip_net, user_or_group_list in self.trusted_users.items(): + if ip_addr in ip_net: + user_list = [ + user_id + for user_id in user_or_group_list + if isinstance(user_id, str) + ] + group_list = [ + group[CONF_GROUP] + for group in user_or_group_list + if isinstance(group, dict) + ] + flattened_group_list = [ + group for sublist in group_list for group in sublist + ] + available_users = [ + user + for user in available_users + if ( + user.id in user_list + or any( + [group.id in flattened_group_list for group in user.groups] + ) + ) + ] + break return TrustedNetworksLoginFlow( - self, cast(str, context.get('ip_address')), available_users) + self, + ip_addr, + {user.id: user.name for user in available_users}, + self.config[CONF_ALLOW_BYPASS_LOGIN], + ) async def async_get_or_create_credentials( - self, flow_result: Dict[str, str]) -> Credentials: + self, flow_result: Dict[str, str] + ) -> Credentials: """Get credentials based on the flow result.""" - user_id = flow_result['user'] + user_id = flow_result["user"] users = await self.store.async_get_users() for user in users: - if (not user.system_generated and - user.is_active and - user.id == user_id): + if not user.system_generated and user.is_active and user.id == user_id: for credential in await self.async_credentials(): - if credential.data['user_id'] == user_id: + if credential.data["user_id"] == user_id: return credential - cred = self.async_create_credentials({'user_id': user_id}) + cred = self.async_create_credentials({"user_id": user_id}) await self.store.async_link_user(user, cred) return cred @@ -72,7 +140,8 @@ class TrustedNetworksAuthProvider(AuthProvider): raise InvalidUserError async def async_user_meta_for_credentials( - self, credentials: Credentials) -> UserMeta: + self, credentials: Credentials + ) -> UserMeta: """Return extra user metadata for credentials. Trusted network auth provider should never create new user. @@ -80,50 +149,58 @@ class TrustedNetworksAuthProvider(AuthProvider): raise NotImplementedError @callback - def async_validate_access(self, ip_address: str) -> None: + def async_validate_access(self, ip_addr: IPAddress) -> None: """Make sure the access from trusted networks. Raise InvalidAuthError if not. Raise InvalidAuthError if trusted_networks is not configured. """ - hass_http = getattr(self.hass, 'http', None) # type: HomeAssistantHTTP + if not self.trusted_networks: + raise InvalidAuthError("trusted_networks is not configured") - if not hass_http or not hass_http.trusted_networks: - raise InvalidAuthError('trusted_networks is not configured') - - if not any(ip_address in trusted_network for trusted_network - in hass_http.trusted_networks): - raise InvalidAuthError('Not in trusted_networks') + if not any( + ip_addr in trusted_network for trusted_network in self.trusted_networks + ): + raise InvalidAuthError("Not in trusted_networks") class TrustedNetworksLoginFlow(LoginFlow): """Handler for the login flow.""" - def __init__(self, auth_provider: TrustedNetworksAuthProvider, - ip_address: str, available_users: Dict[str, Optional[str]]) \ - -> None: + def __init__( + self, + auth_provider: TrustedNetworksAuthProvider, + ip_addr: IPAddress, + available_users: Dict[str, Optional[str]], + allow_bypass_login: bool, + ) -> None: """Initialize the login flow.""" super().__init__(auth_provider) self._available_users = available_users - self._ip_address = ip_address + self._ip_address = ip_addr + self._allow_bypass_login = allow_bypass_login async def async_step_init( - self, user_input: Optional[Dict[str, str]] = None) \ - -> Dict[str, Any]: + self, user_input: Optional[Dict[str, str]] = None + ) -> Dict[str, Any]: """Handle the step of the form.""" try: - cast(TrustedNetworksAuthProvider, self._auth_provider)\ - .async_validate_access(self._ip_address) + cast( + TrustedNetworksAuthProvider, self._auth_provider + ).async_validate_access(self._ip_address) except InvalidAuthError: - return self.async_abort( - reason='not_whitelisted' - ) + return self.async_abort(reason="not_whitelisted") if user_input is not None: return await self.async_finish(user_input) + if self._allow_bypass_login and len(self._available_users) == 1: + return await self.async_finish( + {"user": next(iter(self._available_users.keys()))} + ) + return self.async_show_form( - step_id='init', - data_schema=vol.Schema({'user': vol.In(self._available_users)}), + step_id="init", + data_schema=vol.Schema({"user": vol.In(self._available_users)}), ) diff --git a/homeassistant/auth/util.py b/homeassistant/auth/util.py deleted file mode 100644 index 402caae46..000000000 --- a/homeassistant/auth/util.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Auth utils.""" -import binascii -import os - - -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') diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 0676cec7f..12fbc6f23 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -1,77 +1,58 @@ """Provide methods to bootstrap a Home Assistant instance.""" +import asyncio +from collections import OrderedDict import logging import logging.handlers import os import sys from time import time -from collections import OrderedDict -from typing import Any, Optional, Dict +from typing import Any, Dict, Optional, Set import voluptuous as vol -from homeassistant import ( - core, config as conf_util, config_entries, components as core_components) -from homeassistant.components import persistent_notification -from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE +from homeassistant import config as conf_util, config_entries, core, loader +from homeassistant.const import ( + EVENT_HOMEASSISTANT_CLOSE, + REQUIRED_NEXT_PYTHON_DATE, + REQUIRED_NEXT_PYTHON_VER, +) +from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component from homeassistant.util.logging import AsyncHandler from homeassistant.util.package import async_get_user_site, is_virtual_env from homeassistant.util.yaml import clear_secret_cache -from homeassistant.exceptions import HomeAssistantError _LOGGER = logging.getLogger(__name__) -ERROR_LOG_FILENAME = 'home-assistant.log' +ERROR_LOG_FILENAME = "home-assistant.log" # hass.data key for logging information. -DATA_LOGGING = 'logging' +DATA_LOGGING = "logging" -FIRST_INIT_COMPONENT = {'system_log', 'recorder', 'mqtt', 'mqtt_eventstream', - 'logger', 'introduction', 'frontend', 'history'} +DEBUGGER_INTEGRATIONS = {"ptvsd"} +CORE_INTEGRATIONS = ("homeassistant", "persistent_notification") +LOGGING_INTEGRATIONS = {"logger", "system_log"} +STAGE_1_INTEGRATIONS = { + # To record data + "recorder", + # To make sure we forward data to other instances + "mqtt_eventstream", + # To provide account link implementations + "cloud", +} -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, - log_file: Any = None, - log_no_color: bool = False) \ - -> Optional[core.HomeAssistant]: - """Try to configure Home Assistant from a configuration dictionary. - - Dynamically loads required components and its dependencies. - """ - if hass is None: - hass = core.HomeAssistant() - if config_dir is not None: - config_dir = os.path.abspath(config_dir) - hass.config.config_dir = config_dir - if not is_virtual_env(): - hass.loop.run_until_complete( - async_mount_local_lib_path(config_dir)) - - # run task - hass = hass.loop.run_until_complete( - async_from_config_dict( - config, hass, config_dir, enable_log, verbose, skip_pip, - log_rotate_days, log_file, log_no_color) - ) - return hass - - -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]: +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. Dynamically loads required components and its dependencies. @@ -80,121 +61,70 @@ async def async_from_config_dict(config: Dict[str, Any], start = time() if enable_log: - async_enable_logging(hass, verbose, log_rotate_days, log_file, - log_no_color) - - core_config = config.get(core.DOMAIN, {}) - has_api_password = bool((config.get('http') or {}).get('api_password')) - has_trusted_networks = bool((config.get('http') or {}) - .get('trusted_networks')) - - try: - await conf_util.async_process_ha_core_config( - hass, core_config, has_api_password, has_trusted_networks) - except vol.Invalid as config_err: - conf_util.async_log_exception( - config_err, 'homeassistant', core_config, hass) - return None - except HomeAssistantError: - _LOGGER.error("Home Assistant core failed to initialize. " - "Further initialization aborted") - return None - - await hass.async_add_executor_job( - conf_util.process_ha_config_upgrade, hass) + async_enable_logging(hass, verbose, log_rotate_days, log_file, log_no_color) hass.config.skip_pip = skip_pip if skip_pip: - _LOGGER.warning("Skipping pip installation of required modules. " - "This may cause issues") + _LOGGER.warning( + "Skipping pip installation of required modules. " "This may cause issues" + ) + + core_config = config.get(core.DOMAIN, {}) + + try: + await conf_util.async_process_ha_core_config(hass, core_config) + except vol.Invalid as config_err: + conf_util.async_log_exception(config_err, "homeassistant", core_config, hass) + return None + except HomeAssistantError: + _LOGGER.error( + "Home Assistant core failed to initialize. " + "Further initialization aborted" + ) + return None # Make a copy because we are mutating it. config = OrderedDict(config) # Merge packages - conf_util.merge_packages_config( - hass, config, core_config.get(conf_util.CONF_PACKAGES, {})) - - # Ensure we have no None values after merge - for key, value in config.items(): - 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 - res = await core_components.async_setup(hass, config) - if not res: - _LOGGER.error("Home Assistant core failed to initialize. " - "Further initialization aborted") - return hass - - await persistent_notification.async_setup(hass, config) - - _LOGGER.info("Home Assistant core initialized") - - # stage 1 - for component in components: - if component not in FIRST_INIT_COMPONENT: - continue - hass.async_create_task(async_setup_component(hass, component, config)) - - await hass.async_block_till_done() - - # stage 2 - for component in components: - if component in FIRST_INIT_COMPONENT: - continue - hass.async_create_task(async_setup_component(hass, component, config)) - - await hass.async_block_till_done() - - stop = time() - _LOGGER.info("Home Assistant initialized in %.2fs", stop-start) - - return hass - - -def from_config_file(config_path: str, - 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)\ - -> Optional[core.HomeAssistant]: - """Read the configuration file and try to start all the functionality. - - Will add functionality to 'hass' parameter if given, - instantiates a new Home Assistant object if 'hass' is not given. - """ - if hass is None: - hass = core.HomeAssistant() - - # run task - hass = hass.loop.run_until_complete( - async_from_config_file( - config_path, hass, verbose, skip_pip, - log_rotate_days, log_file, log_no_color) + await conf_util.merge_packages_config( + hass, config, core_config.get(conf_util.CONF_PACKAGES, {}) ) + hass.config_entries = config_entries.ConfigEntries(hass, config) + await hass.config_entries.async_initialize() + + await _async_set_up_integrations(hass, config) + + stop = time() + _LOGGER.info("Home Assistant initialized in %.2fs", stop - start) + + if REQUIRED_NEXT_PYTHON_DATE and sys.version_info[:3] < REQUIRED_NEXT_PYTHON_VER: + msg = ( + "Support for the running Python version " + f"{'.'.join(str(x) for x in sys.version_info[:3])} is deprecated and will " + f"be removed in the first release after {REQUIRED_NEXT_PYTHON_DATE}. " + "Please upgrade Python to " + f"{'.'.join(str(x) for x in REQUIRED_NEXT_PYTHON_VER)} or " + "higher." + ) + _LOGGER.warning(msg) + hass.components.persistent_notification.async_create( + msg, "Python version", "python_version" + ) + return hass -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)\ - -> Optional[core.HomeAssistant]: +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, +) -> Optional[core.HomeAssistant]: """Read the configuration file and try to start all the functionality. Will add functionality to 'hass' parameter. @@ -207,12 +137,14 @@ async def async_from_config_file(config_path: str, if not is_virtual_env(): await async_mount_local_lib_path(config_dir) - async_enable_logging(hass, verbose, log_rotate_days, log_file, - log_no_color) + async_enable_logging(hass, verbose, log_rotate_days, log_file, log_no_color) + + await hass.async_add_executor_job(conf_util.process_ha_config_upgrade, hass) try: config_dict = await hass.async_add_executor_job( - conf_util.load_yaml_config_file, config_path) + conf_util.load_yaml_config_file, config_path + ) except HomeAssistantError as err: _LOGGER.error("Error loading %s: %s", config_path, err) return None @@ -220,43 +152,48 @@ async def async_from_config_file(config_path: str, clear_secret_cache() return await async_from_config_dict( - config_dict, hass, enable_log=False, skip_pip=skip_pip) + config_dict, hass, enable_log=False, skip_pip=skip_pip + ) @core.callback -def async_enable_logging(hass: core.HomeAssistant, - verbose: bool = False, - log_rotate_days: Optional[int] = None, - log_file: Optional[str] = None, - log_no_color: bool = False) -> None: +def async_enable_logging( + hass: core.HomeAssistant, + verbose: bool = False, + log_rotate_days: Optional[int] = None, + log_file: Optional[str] = None, + log_no_color: bool = False, +) -> None: """Set up the logging. This method must be run in the event loop. """ - fmt = ("%(asctime)s %(levelname)s (%(threadName)s) " - "[%(name)s] %(message)s") - datefmt = '%Y-%m-%d %H:%M:%S' + fmt = "%(asctime)s %(levelname)s (%(threadName)s) " "[%(name)s] %(message)s" + 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', - } - )) + colorfmt = f"%(log_color)s{fmt}%(reset)s" + 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 @@ -265,9 +202,9 @@ def async_enable_logging(hass: core.HomeAssistant, 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) + logging.getLogger("requests").setLevel(logging.WARNING) + logging.getLogger("urllib3").setLevel(logging.WARNING) + logging.getLogger("aiohttp.access").setLevel(logging.WARNING) # Log errors to a file if we have write access to file or config dir if log_file is None: @@ -280,16 +217,16 @@ def async_enable_logging(hass: core.HomeAssistant, # 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(err_dir, os.W_OK)): + if (err_path_exists and os.access(err_log_path, os.W_OK)) or ( + 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) # type: logging.FileHandler + err_handler: logging.FileHandler = logging.handlers.TimedRotatingFileHandler( + err_log_path, when="midnight", backupCount=log_rotate_days + ) else: - err_handler = logging.FileHandler( - err_log_path, mode='w', delay=True) + err_handler = logging.FileHandler(err_log_path, mode="w", delay=True) err_handler.setLevel(logging.INFO if verbose else logging.WARNING) err_handler.setFormatter(logging.Formatter(fmt, datefmt=datefmt)) @@ -298,21 +235,19 @@ def async_enable_logging(hass: core.HomeAssistant, async def async_stop_async_handler(_: Any) -> None: """Cleanup async handler.""" - logging.getLogger('').removeHandler(async_handler) # type: ignore + logging.getLogger("").removeHandler(async_handler) # type: ignore await async_handler.async_close(blocking=True) - hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_CLOSE, async_stop_async_handler) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_CLOSE, async_stop_async_handler) - logger = logging.getLogger('') + logger = logging.getLogger("") 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 set up error log %s (access denied)", err_log_path) + _LOGGER.error("Unable to set up error log %s (access denied)", err_log_path) async def async_mount_local_lib_path(config_dir: str) -> str: @@ -320,8 +255,143 @@ async def async_mount_local_lib_path(config_dir: str) -> str: This function is a coroutine. """ - deps_dir = os.path.join(config_dir, 'deps') + deps_dir = os.path.join(config_dir, "deps") lib_dir = await async_get_user_site(deps_dir) if lib_dir not in sys.path: sys.path.insert(0, lib_dir) return deps_dir + + +@core.callback +def _get_domains(hass: core.HomeAssistant, config: Dict[str, Any]) -> Set[str]: + """Get domains of components to set up.""" + # Filter out the repeating and common config section [homeassistant] + domains = set(key.split(" ")[0] for key in config.keys() if key != core.DOMAIN) + + # Add config entry domains + domains.update(hass.config_entries.async_domains()) + + # Make sure the Hass.io component is loaded + if "HASSIO" in os.environ: + domains.add("hassio") + + return domains + + +async def _async_set_up_integrations( + hass: core.HomeAssistant, config: Dict[str, Any] +) -> None: + """Set up all the integrations.""" + domains = _get_domains(hass, config) + + # Start up debuggers. Start these first in case they want to wait. + debuggers = domains & DEBUGGER_INTEGRATIONS + if debuggers: + _LOGGER.debug("Starting up debuggers %s", debuggers) + await asyncio.gather( + *(async_setup_component(hass, domain, config) for domain in debuggers) + ) + domains -= DEBUGGER_INTEGRATIONS + + # Resolve all dependencies of all components so we can find the logging + # and integrations that need faster initialization. + resolved_domains_task = asyncio.gather( + *(loader.async_component_dependencies(hass, domain) for domain in domains), + return_exceptions=True, + ) + + # Set up core. + _LOGGER.debug("Setting up %s", CORE_INTEGRATIONS) + + if not all( + await asyncio.gather( + *( + async_setup_component(hass, domain, config) + for domain in CORE_INTEGRATIONS + ) + ) + ): + _LOGGER.error( + "Home Assistant core failed to initialize. " + "Further initialization aborted" + ) + return + + _LOGGER.debug("Home Assistant core initialized") + + # Finish resolving domains + for dep_domains in await resolved_domains_task: + # Result is either a set or an exception. We ignore exceptions + # It will be properly handled during setup of the domain. + if isinstance(dep_domains, set): + domains.update(dep_domains) + + # setup components + logging_domains = domains & LOGGING_INTEGRATIONS + stage_1_domains = domains & STAGE_1_INTEGRATIONS + stage_2_domains = domains - logging_domains - stage_1_domains + + if logging_domains: + _LOGGER.info("Setting up %s", logging_domains) + + await asyncio.gather( + *(async_setup_component(hass, domain, config) for domain in logging_domains) + ) + + # Kick off loading the registries. They don't need to be awaited. + asyncio.gather( + hass.helpers.device_registry.async_get_registry(), + hass.helpers.entity_registry.async_get_registry(), + hass.helpers.area_registry.async_get_registry(), + ) + + if stage_1_domains: + await asyncio.gather( + *(async_setup_component(hass, domain, config) for domain in stage_1_domains) + ) + + # Load all integrations + after_dependencies: Dict[str, Set[str]] = {} + + for int_or_exc in await asyncio.gather( + *(loader.async_get_integration(hass, domain) for domain in stage_2_domains), + return_exceptions=True, + ): + # Exceptions are handled in async_setup_component. + if isinstance(int_or_exc, loader.Integration) and int_or_exc.after_dependencies: + after_dependencies[int_or_exc.domain] = set(int_or_exc.after_dependencies) + + last_load = None + while stage_2_domains: + domains_to_load = set() + + for domain in stage_2_domains: + after_deps = after_dependencies.get(domain) + # Load if integration has no after_dependencies or they are + # all loaded + if not after_deps or not after_deps - hass.config.components: + domains_to_load.add(domain) + + if not domains_to_load or domains_to_load == last_load: + break + + _LOGGER.debug("Setting up %s", domains_to_load) + + await asyncio.gather( + *(async_setup_component(hass, domain, config) for domain in domains_to_load) + ) + + last_load = domains_to_load + stage_2_domains -= domains_to_load + + # These are stage 2 domains that never have their after_dependencies + # satisfied. + if stage_2_domains: + _LOGGER.debug("Final set up: %s", stage_2_domains) + + await asyncio.gather( + *(async_setup_component(hass, domain, config) for domain in stage_2_domains) + ) + + # Wrap up startup + await hass.async_block_till_done() diff --git a/homeassistant/components/__init__.py b/homeassistant/components/__init__.py index bf1577cbf..90e0f3222 100644 --- a/homeassistant/components/__init__.py +++ b/homeassistant/components/__init__.py @@ -7,26 +7,14 @@ Component design guidelines: format ".". - Each component should publish services only under its own domain. """ -import asyncio -import itertools as it import logging -from typing import Awaitable -import homeassistant.core as ha -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, - RESTART_EXIT_CODE) +from homeassistant.core import split_entity_id + +# mypy: allow-untyped-defs _LOGGER = logging.getLogger(__name__) -SERVICE_RELOAD_CORE_CONFIG = 'reload_core_config' -SERVICE_CHECK_CONFIG = 'check_config' - def is_on(hass, entity_id=None): """Load up the module to call the is_on method. @@ -39,173 +27,20 @@ def is_on(hass, entity_id=None): entity_ids = hass.states.entity_ids() for ent_id in entity_ids: - domain = ha.split_entity_id(ent_id)[0] + domain = split_entity_id(ent_id)[0] try: component = getattr(hass.components, domain) except ImportError: - _LOGGER.error('Failed to call %s.is_on: component not found', - domain) + _LOGGER.error("Failed to call %s.is_on: component not found", domain) continue - if not hasattr(component, 'is_on'): - _LOGGER.warning("Component %s has no is_on method.", domain) + if not hasattr(component, "is_on"): + _LOGGER.warning("Integration %s has no is_on method.", domain) continue if component.is_on(ent_id): return True return False - - -def turn_on(hass, entity_id=None, **service_data): - """Turn specified entity on if possible.""" - if entity_id is not None: - service_data[ATTR_ENTITY_ID] = entity_id - - hass.services.call(ha.DOMAIN, SERVICE_TURN_ON, service_data) - - -def turn_off(hass, entity_id=None, **service_data): - """Turn specified entity off.""" - if entity_id is not None: - service_data[ATTR_ENTITY_ID] = entity_id - - hass.services.call(ha.DOMAIN, SERVICE_TURN_OFF, service_data) - - -def toggle(hass, entity_id=None, **service_data): - """Toggle specified entity.""" - if entity_id is not None: - service_data[ATTR_ENTITY_ID] = entity_id - - hass.services.call(ha.DOMAIN, SERVICE_TOGGLE, service_data) - - -def stop(hass): - """Stop Home Assistant.""" - hass.services.call(ha.DOMAIN, SERVICE_HOMEASSISTANT_STOP) - - -def restart(hass): - """Stop Home Assistant.""" - hass.services.call(ha.DOMAIN, SERVICE_HOMEASSISTANT_RESTART) - - -def check_config(hass): - """Check the config files.""" - hass.services.call(ha.DOMAIN, SERVICE_CHECK_CONFIG) - - -def reload_core_config(hass): - """Reload the core config.""" - hass.services.call(ha.DOMAIN, SERVICE_RELOAD_CORE_CONFIG) - - -@asyncio.coroutine -def async_reload_core_config(hass): - """Reload the core config.""" - yield from hass.services.async_call(ha.DOMAIN, SERVICE_RELOAD_CORE_CONFIG) - - -@asyncio.coroutine -def async_setup(hass: ha.HomeAssistant, config: dict) -> Awaitable[bool]: - """Set up general services related to Home Assistant.""" - @asyncio.coroutine - def async_handle_turn_service(service): - """Handle calls to homeassistant.turn_on/off.""" - entity_ids = extract_entity_ids(hass, service) - - # Generic turn on/off method requires entity id - if not entity_ids: - _LOGGER.error( - "homeassistant/%s cannot be called without entity_id", - service.service) - return - - # Group entity_ids by domain. groupby requires sorted data. - by_domain = it.groupby(sorted(entity_ids), - lambda item: ha.split_entity_id(item)[0]) - - tasks = [] - - for domain, ent_ids in by_domain: - # We want to block for all calls and only return when all calls - # 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 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) - - # Create a new dict for this call - data = dict(service.data) - - # ent_ids is a generator, convert it to a list. - data[ATTR_ENTITY_ID] = list(ent_ids) - - tasks.append(hass.services.async_call( - domain, service.service, data, blocking)) - - yield from asyncio.wait(tasks, loop=hass.loop) - - hass.services.async_register( - ha.DOMAIN, SERVICE_TURN_OFF, async_handle_turn_service) - hass.services.async_register( - 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): - """Service handler for handling core services.""" - if call.service == SERVICE_HOMEASSISTANT_STOP: - hass.async_create_task(hass.async_stop()) - return - - try: - errors = yield from conf_util.async_check_ha_config_file(hass) - except HomeAssistantError: - return - - if errors: - _LOGGER.error(errors) - hass.components.persistent_notification.async_create( - "Config error. See dev-info panel for details.", - "Config validating", "{0}.check_config".format(ha.DOMAIN)) - return - - if call.service == SERVICE_HOMEASSISTANT_RESTART: - hass.async_create_task(hass.async_stop(RESTART_EXIT_CODE)) - - hass.services.async_register( - ha.DOMAIN, SERVICE_HOMEASSISTANT_STOP, async_handle_core_service) - hass.services.async_register( - ha.DOMAIN, SERVICE_HOMEASSISTANT_RESTART, async_handle_core_service) - hass.services.async_register( - ha.DOMAIN, SERVICE_CHECK_CONFIG, async_handle_core_service) - - @asyncio.coroutine - def async_handle_reload_config(call): - """Service handler for reloading core config.""" - try: - conf = yield from conf_util.async_hass_config_yaml(hass) - except HomeAssistantError as err: - _LOGGER.error(err) - return - - yield from conf_util.async_process_ha_core_config( - hass, conf.get(ha.DOMAIN) or {}) - - hass.services.async_register( - ha.DOMAIN, SERVICE_RELOAD_CORE_CONFIG, async_handle_reload_config) - - return True diff --git a/homeassistant/components/abode.py b/homeassistant/components/abode.py deleted file mode 100644 index bafbc0781..000000000 --- a/homeassistant/components/abode.py +++ /dev/null @@ -1,347 +0,0 @@ -""" -This component provides basic support for Abode Home Security system. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/abode/ -""" -import asyncio -import logging -from functools import partial -from requests.exceptions import HTTPError, ConnectTimeout - -import voluptuous as vol - -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 - -REQUIREMENTS = ['abodepy==0.13.1'] - -_LOGGER = logging.getLogger(__name__) - -CONF_ATTRIBUTION = "Data provided by goabode.com" -CONF_POLLING = 'polling' - -DOMAIN = 'abode' -DEFAULT_CACHEDB = './abodepy_cache.pickle' - -NOTIFICATION_ID = 'abode_notification' -NOTIFICATION_TITLE = 'Abode Security Setup' - -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): 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', - 'camera', 'light', 'sensor' -] - - -class AbodeSystem: - """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.""" - 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: - 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.' - ''.format(ex), - title=NOTIFICATION_TITLE, - 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.""" - 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 setup_abode_events(hass): - """Event callbacks.""" - import abodepy.helpers.timeline as TIMELINE - - 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, data, device): - """Initialize a sensor for Abode device.""" - self._data = data - self._device = device - - @asyncio.coroutine - def async_added_to_hass(self): - """Subscribe Abode events.""" - self.hass.async_add_job( - self._data.abode.events.add_device_callback, - self._device.device_id, self._update_callback - ) - - @property - def should_poll(self): - """Return the polling state.""" - return self._data.polling - - def update(self): - """Update automation state.""" - self._device.refresh() - - @property - def name(self): - """Return the name of the sensor.""" - return self._device.name - - @property - def device_state_attributes(self): - """Return the state attributes.""" - return { - ATTR_ATTRIBUTION: CONF_ATTRIBUTION, - 'device_id': self._device.device_id, - 'battery_low': self._device.battery_low, - '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/abode/.translations/bg.json b/homeassistant/components/abode/.translations/bg.json new file mode 100644 index 000000000..29e3f342c --- /dev/null +++ b/homeassistant/components/abode/.translations/bg.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u0420\u0430\u0437\u0440\u0435\u0448\u0435\u043d\u0430 \u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f \u043d\u0430 Abode." + }, + "error": { + "connection_error": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u0441 Abode.", + "identifier_exists": "\u041f\u0440\u043e\u0444\u0438\u043b\u044a\u0442 \u0435 \u0432\u0435\u0447\u0435 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0430\u043d.", + "invalid_credentials": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u0438 \u0438\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u043e\u043d\u043d\u0438 \u0434\u0430\u043d\u043d\u0438." + }, + "step": { + "user": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "username": "E-mail \u0430\u0434\u0440\u0435\u0441" + }, + "title": "\u041f\u043e\u043f\u044a\u043b\u043d\u0435\u0442\u0435 \u0412\u0430\u0448\u0430\u0442\u0430 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f \u0437\u0430 \u0432\u0445\u043e\u0434 \u0432 Abode" + } + }, + "title": "Abode" + } +} \ No newline at end of file diff --git a/homeassistant/components/abode/.translations/ca.json b/homeassistant/components/abode/.translations/ca.json new file mode 100644 index 000000000..2424fd9b5 --- /dev/null +++ b/homeassistant/components/abode/.translations/ca.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Nom\u00e9s es permet una \u00fanica configuraci\u00f3 d'Abode." + }, + "error": { + "connection_error": "No es pot connectar amb Abode.", + "identifier_exists": "Compte ja registrat.", + "invalid_credentials": "Credencials inv\u00e0lides." + }, + "step": { + "user": { + "data": { + "password": "Contrasenya", + "username": "Correu electr\u00f2nic" + }, + "title": "Introdueix la teva informaci\u00f3 d'inici de sessi\u00f3 a Abode." + } + }, + "title": "Abode" + } +} \ No newline at end of file diff --git a/homeassistant/components/abode/.translations/cs.json b/homeassistant/components/abode/.translations/cs.json new file mode 100644 index 000000000..75c65f01e --- /dev/null +++ b/homeassistant/components/abode/.translations/cs.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Je povolena pouze jedna konfigurace Abode." + }, + "error": { + "connection_error": "Nelze se p\u0159ipojit k Abode.", + "identifier_exists": "\u00da\u010det je ji\u017e zaregistrov\u00e1n.", + "invalid_credentials": "Neplatn\u00e9 p\u0159ihla\u0161ovac\u00ed \u00fadaje." + }, + "step": { + "user": { + "data": { + "password": "Heslo", + "username": "E-mailov\u00e1 adresa" + }, + "title": "Vypl\u0148te p\u0159ihla\u0161ovac\u00ed \u00fadaje Abode" + } + }, + "title": "Abode" + } +} \ No newline at end of file diff --git a/homeassistant/components/abode/.translations/da.json b/homeassistant/components/abode/.translations/da.json new file mode 100644 index 000000000..3f094cb93 --- /dev/null +++ b/homeassistant/components/abode/.translations/da.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Det er kun n\u00f8dvendigt med en ops\u00e6tning af Abode." + }, + "error": { + "connection_error": "Kunne ikke oprette forbindelse til Abode.", + "identifier_exists": "Konto er allerede registreret.", + "invalid_credentials": "Ugyldige legitimationsoplysninger." + }, + "step": { + "user": { + "data": { + "password": "Adgangskode", + "username": "Email adresse" + }, + "title": "Udfyld dine Abode-loginoplysninger" + } + }, + "title": "Abode" + } +} \ No newline at end of file diff --git a/homeassistant/components/abode/.translations/de.json b/homeassistant/components/abode/.translations/de.json new file mode 100644 index 000000000..ed5ec85a5 --- /dev/null +++ b/homeassistant/components/abode/.translations/de.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Es ist nur eine einzige Konfiguration von Abode erlaubt." + }, + "error": { + "connection_error": "Es kann keine Verbindung zu Abode hergestellt werden.", + "identifier_exists": "Das Konto ist bereits registriert.", + "invalid_credentials": "Ung\u00fcltige Anmeldeinformationen" + }, + "step": { + "user": { + "data": { + "password": "Passwort", + "username": "E-Mail-Adresse" + }, + "title": "Gib deine Abode-Anmeldeinformationen ein" + } + }, + "title": "Abode" + } +} \ No newline at end of file diff --git a/homeassistant/components/abode/.translations/en.json b/homeassistant/components/abode/.translations/en.json new file mode 100644 index 000000000..e8daeb22c --- /dev/null +++ b/homeassistant/components/abode/.translations/en.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Only a single configuration of Abode is allowed." + }, + "error": { + "connection_error": "Unable to connect to Abode.", + "identifier_exists": "Account already registered.", + "invalid_credentials": "Invalid credentials." + }, + "step": { + "user": { + "data": { + "password": "Password", + "username": "Email Address" + }, + "title": "Fill in your Abode login information" + } + }, + "title": "Abode" + } +} \ No newline at end of file diff --git a/homeassistant/components/abode/.translations/es.json b/homeassistant/components/abode/.translations/es.json new file mode 100644 index 000000000..908e8f0fb --- /dev/null +++ b/homeassistant/components/abode/.translations/es.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Solo se permite una \u00fanica configuraci\u00f3n de Abode." + }, + "error": { + "connection_error": "No se puede conectar a Abode.", + "identifier_exists": "Cuenta ya registrada.", + "invalid_credentials": "Credenciales inv\u00e1lidas." + }, + "step": { + "user": { + "data": { + "password": "Contrase\u00f1a", + "username": "Direcci\u00f3n de correo electr\u00f3nico" + }, + "title": "Rellene la informaci\u00f3n de acceso Abode" + } + }, + "title": "Abode" + } +} \ No newline at end of file diff --git a/homeassistant/components/abode/.translations/fr.json b/homeassistant/components/abode/.translations/fr.json new file mode 100644 index 000000000..c0c2a3508 --- /dev/null +++ b/homeassistant/components/abode/.translations/fr.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Une seule configuration d'Abode est autoris\u00e9e." + }, + "error": { + "connection_error": "Impossible de se connecter \u00e0 Abode.", + "identifier_exists": "Compte d\u00e9j\u00e0 enregistr\u00e9.", + "invalid_credentials": "Informations d'identification invalides." + }, + "step": { + "user": { + "data": { + "password": "Mot de passe", + "username": "Adresse e-mail" + }, + "title": "Remplissez vos informations de connexion Abode" + } + }, + "title": "Abode" + } +} \ No newline at end of file diff --git a/homeassistant/components/abode/.translations/it.json b/homeassistant/components/abode/.translations/it.json new file mode 100644 index 000000000..af51aca8a --- /dev/null +++ b/homeassistant/components/abode/.translations/it.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u00c8 consentita una sola configurazione di Abode." + }, + "error": { + "connection_error": "Impossibile connettersi ad Abode.", + "identifier_exists": "Account gi\u00e0 registrato", + "invalid_credentials": "Credenziali non valide" + }, + "step": { + "user": { + "data": { + "password": "Password", + "username": "Indirizzo email" + }, + "title": "Inserisci le tue informazioni di accesso Abode" + } + }, + "title": "Abode" + } +} \ No newline at end of file diff --git a/homeassistant/components/abode/.translations/ko.json b/homeassistant/components/abode/.translations/ko.json new file mode 100644 index 000000000..9560dde6b --- /dev/null +++ b/homeassistant/components/abode/.translations/ko.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\ud558\ub098\uc758 Abode \ub9cc \uad6c\uc131 \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4." + }, + "error": { + "connection_error": "Abode \uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.", + "identifier_exists": "\uacc4\uc815\uc774 \uc774\ubbf8 \ub4f1\ub85d\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "invalid_credentials": "\uc0ac\uc6a9\uc790 \uc774\ub984 \ud639\uc740 \ube44\ubc00\ubc88\ud638\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "password": "\ube44\ubc00\ubc88\ud638", + "username": "\uc774\uba54\uc77c \uc8fc\uc18c" + }, + "title": "Abode \uc0ac\uc6a9\uc790 \uc815\ubcf4\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694" + } + }, + "title": "Abode" + } +} \ No newline at end of file diff --git a/homeassistant/components/abode/.translations/lb.json b/homeassistant/components/abode/.translations/lb.json new file mode 100644 index 000000000..ed65a5df7 --- /dev/null +++ b/homeassistant/components/abode/.translations/lb.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "N\u00ebmmen eng eenzeg Konfiguratioun vun ZHA ass erlaabt." + }, + "error": { + "connection_error": "Kann sech net mat Abode verbannen.", + "identifier_exists": "Konto ass scho registr\u00e9iert", + "invalid_credentials": "Ong\u00eblteg Login Informatioune" + }, + "step": { + "user": { + "data": { + "password": "Passwuert", + "username": "E-Mail Adress" + }, + "title": "F\u00ebllt \u00e4r Abode Login Informatiounen aus." + } + }, + "title": "Abode" + } +} \ No newline at end of file diff --git a/homeassistant/components/abode/.translations/nl.json b/homeassistant/components/abode/.translations/nl.json new file mode 100644 index 000000000..89b5ae0c4 --- /dev/null +++ b/homeassistant/components/abode/.translations/nl.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Slechts een enkele configuratie van Abode is toegestaan." + }, + "error": { + "connection_error": "Kan geen verbinding maken met Abode.", + "identifier_exists": "Account is al geregistreerd.", + "invalid_credentials": "Ongeldige inloggegevens." + }, + "step": { + "user": { + "data": { + "password": "Wachtwoord", + "username": "E-mailadres" + }, + "title": "Vul uw Abode-inloggegevens in" + } + }, + "title": "Abode" + } +} \ No newline at end of file diff --git a/homeassistant/components/abode/.translations/nn.json b/homeassistant/components/abode/.translations/nn.json new file mode 100644 index 000000000..e0c1b6d6a --- /dev/null +++ b/homeassistant/components/abode/.translations/nn.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Abode" + } +} \ No newline at end of file diff --git a/homeassistant/components/abode/.translations/no.json b/homeassistant/components/abode/.translations/no.json new file mode 100644 index 000000000..542381cbb --- /dev/null +++ b/homeassistant/components/abode/.translations/no.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Bare en enkelt konfigurasjon av Abode er tillatt." + }, + "error": { + "connection_error": "Kan ikke koble til Abode.", + "identifier_exists": "Kontoen er allerede registrert.", + "invalid_credentials": "Ugyldig brukerinformasjon" + }, + "step": { + "user": { + "data": { + "password": "Passord", + "username": "E-postadresse" + }, + "title": "Fyll ut innloggingsinformasjonen for Abode" + } + }, + "title": "Abode" + } +} \ No newline at end of file diff --git a/homeassistant/components/abode/.translations/pl.json b/homeassistant/components/abode/.translations/pl.json new file mode 100644 index 000000000..c3f3b8f2c --- /dev/null +++ b/homeassistant/components/abode/.translations/pl.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Dozwolona jest tylko jedna konfiguracja Abode." + }, + "error": { + "connection_error": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z Abode.", + "identifier_exists": "Konto zosta\u0142o ju\u017c zarejestrowane", + "invalid_credentials": "Nieprawid\u0142owe dane uwierzytelniaj\u0105ce" + }, + "step": { + "user": { + "data": { + "password": "Has\u0142o", + "username": "Adres e-mail" + }, + "title": "Wprowad\u017a informacje logowania Abode" + } + }, + "title": "Abode" + } +} \ No newline at end of file diff --git a/homeassistant/components/abode/.translations/pt-BR.json b/homeassistant/components/abode/.translations/pt-BR.json new file mode 100644 index 000000000..30980103b --- /dev/null +++ b/homeassistant/components/abode/.translations/pt-BR.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Somente uma \u00fanica configura\u00e7\u00e3o de Abode \u00e9 permitida." + }, + "error": { + "connection_error": "N\u00e3o foi poss\u00edvel conectar ao Abode.", + "identifier_exists": "Conta j\u00e1 cadastrada.", + "invalid_credentials": "Credenciais inv\u00e1lidas." + }, + "step": { + "user": { + "data": { + "password": "Senha", + "username": "Endere\u00e7o de e-mail" + } + } + }, + "title": "" + } +} \ No newline at end of file diff --git a/homeassistant/components/abode/.translations/pt.json b/homeassistant/components/abode/.translations/pt.json new file mode 100644 index 000000000..4a371c706 --- /dev/null +++ b/homeassistant/components/abode/.translations/pt.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "identifier_exists": "Conta j\u00e1 registada" + }, + "step": { + "user": { + "data": { + "password": "Palavra-passe", + "username": "Endere\u00e7o de e-mail" + } + } + }, + "title": "" + } +} \ No newline at end of file diff --git a/homeassistant/components/abode/.translations/ru.json b/homeassistant/components/abode/.translations/ru.json new file mode 100644 index 000000000..590f76627 --- /dev/null +++ b/homeassistant/components/abode/.translations/ru.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430." + }, + "error": { + "connection_error": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a Abode.", + "identifier_exists": "\u0423\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d\u0430.", + "invalid_credentials": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0435 \u0443\u0447\u0451\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435." + }, + "step": { + "user": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "username": "\u0410\u0434\u0440\u0435\u0441 \u044d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0439 \u043f\u043e\u0447\u0442\u044b" + }, + "title": "Abode" + } + }, + "title": "Abode" + } +} \ No newline at end of file diff --git a/homeassistant/components/abode/.translations/sl.json b/homeassistant/components/abode/.translations/sl.json new file mode 100644 index 000000000..b840913b7 --- /dev/null +++ b/homeassistant/components/abode/.translations/sl.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Dovoljena je samo ena konfiguracija Abode." + }, + "error": { + "connection_error": "Ni mogo\u010de vzpostaviti povezave z Abode.", + "identifier_exists": "Ra\u010dun je \u017ee registriran.", + "invalid_credentials": "Neveljavne poverilnice." + }, + "step": { + "user": { + "data": { + "password": "Geslo", + "username": "E-po\u0161tni naslov" + }, + "title": "Izpolnite svoje podatke za prijavo v Abode" + } + }, + "title": "Abode" + } +} \ No newline at end of file diff --git a/homeassistant/components/abode/.translations/zh-Hant.json b/homeassistant/components/abode/.translations/zh-Hant.json new file mode 100644 index 000000000..5bc9efc36 --- /dev/null +++ b/homeassistant/components/abode/.translations/zh-Hant.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u50c5\u5141\u8a31\u8a2d\u5b9a\u4e00\u7d44 Abode\u3002" + }, + "error": { + "connection_error": "\u7121\u6cd5\u9023\u7dda\u81f3 Abode\u3002", + "identifier_exists": "\u5e33\u865f\u5df2\u8a3b\u518a\u3002", + "invalid_credentials": "\u6191\u8b49\u7121\u6548\u3002" + }, + "step": { + "user": { + "data": { + "password": "\u5bc6\u78bc", + "username": "\u96fb\u5b50\u90f5\u4ef6\u5730\u5740" + }, + "title": "\u586b\u5beb Abode \u767b\u5165\u8cc7\u8a0a" + } + }, + "title": "Abode" + } +} \ No newline at end of file diff --git a/homeassistant/components/abode/__init__.py b/homeassistant/components/abode/__init__.py new file mode 100644 index 000000000..6d23701d0 --- /dev/null +++ b/homeassistant/components/abode/__init__.py @@ -0,0 +1,401 @@ +"""Support for the Abode Security System.""" +from asyncio import gather +from copy import deepcopy +from functools import partial +import logging + +from abodepy import Abode +from abodepy.exceptions import AbodeException +import abodepy.helpers.timeline as TIMELINE +from requests.exceptions import ConnectTimeout, HTTPError +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_DATE, + ATTR_ENTITY_ID, + ATTR_TIME, + CONF_PASSWORD, + CONF_USERNAME, + EVENT_HOMEASSISTANT_STOP, +) +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.dispatcher import dispatcher_send +from homeassistant.helpers.entity import Entity + +from .const import ( + ATTRIBUTION, + DEFAULT_CACHEDB, + DOMAIN, + SIGNAL_CAPTURE_IMAGE, + SIGNAL_TRIGGER_QUICK_ACTION, +) + +_LOGGER = logging.getLogger(__name__) + +CONF_POLLING = "polling" + +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_APP_TYPE = "app_type" +ATTR_EVENT_BY = "event_by" +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_POLLING, default=False): cv.boolean, + } + ) + }, + 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", + "camera", + "light", + "sensor", +] + + +class AbodeSystem: + """Abode System class.""" + + def __init__(self, abode, polling): + """Initialize the system.""" + + self.abode = abode + self.polling = polling + self.entity_ids = set() + self.logout_listener = None + + +async def async_setup(hass, config): + """Set up Abode integration.""" + if DOMAIN not in config: + return True + + conf = config[DOMAIN] + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=deepcopy(conf) + ) + ) + + return True + + +async def async_setup_entry(hass, config_entry): + """Set up Abode integration from a config entry.""" + username = config_entry.data.get(CONF_USERNAME) + password = config_entry.data.get(CONF_PASSWORD) + polling = config_entry.data.get(CONF_POLLING) + + try: + cache = hass.config.path(DEFAULT_CACHEDB) + abode = await hass.async_add_executor_job( + Abode, username, password, True, True, True, cache + ) + hass.data[DOMAIN] = AbodeSystem(abode, polling) + + except (AbodeException, ConnectTimeout, HTTPError) as ex: + _LOGGER.error("Unable to connect to Abode: %s", str(ex)) + return False + + for platform in ABODE_PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, platform) + ) + + await setup_hass_events(hass) + await hass.async_add_executor_job(setup_hass_services, hass) + await hass.async_add_executor_job(setup_abode_events, hass) + + return True + + +async def async_unload_entry(hass, config_entry): + """Unload a config entry.""" + hass.services.async_remove(DOMAIN, SERVICE_SETTINGS) + hass.services.async_remove(DOMAIN, SERVICE_CAPTURE_IMAGE) + hass.services.async_remove(DOMAIN, SERVICE_TRIGGER) + + tasks = [] + + for platform in ABODE_PLATFORMS: + tasks.append( + hass.config_entries.async_forward_entry_unload(config_entry, platform) + ) + + await gather(*tasks) + + await hass.async_add_executor_job(hass.data[DOMAIN].abode.events.stop) + await hass.async_add_executor_job(hass.data[DOMAIN].abode.logout) + + hass.data[DOMAIN].logout_listener() + hass.data.pop(DOMAIN) + + return True + + +def setup_hass_services(hass): + """Home assistant services.""" + + 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_entities = [ + entity_id + for entity_id in hass.data[DOMAIN].entity_ids + if entity_id in entity_ids + ] + + for entity_id in target_entities: + signal = SIGNAL_CAPTURE_IMAGE.format(entity_id) + dispatcher_send(hass, signal) + + def trigger_quick_action(call): + """Trigger a quick action.""" + entity_ids = call.data.get(ATTR_ENTITY_ID, None) + + target_entities = [ + entity_id + for entity_id in hass.data[DOMAIN].entity_ids + if entity_id in entity_ids + ] + + for entity_id in target_entities: + signal = SIGNAL_TRIGGER_QUICK_ACTION.format(entity_id) + dispatcher_send(hass, signal) + + 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 + ) + + +async def setup_hass_events(hass): + """Home Assistant start and stop callbacks.""" + + def logout(event): + """Logout of Abode.""" + 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: + await hass.async_add_executor_job(hass.data[DOMAIN].abode.events.start) + + hass.data[DOMAIN].logout_listener = hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, logout + ) + + +def setup_abode_events(hass): + """Event callbacks.""" + + 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_APP_TYPE: event_json.get(ATTR_APP_TYPE, ""), + ATTR_EVENT_BY: event_json.get(ATTR_EVENT_BY, ""), + 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, + TIMELINE.DISARM_GROUP, + TIMELINE.ARM_GROUP, + TIMELINE.TEST_GROUP, + TIMELINE.CAPTURE_GROUP, + TIMELINE.DEVICE_GROUP, + TIMELINE.AUTOMATION_EDIT_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, data, device): + """Initialize Abode device.""" + self._data = data + self._device = device + + async def async_added_to_hass(self): + """Subscribe to device events.""" + self.hass.async_add_job( + self._data.abode.events.add_device_callback, + self._device.device_id, + self._update_callback, + ) + self.hass.data[DOMAIN].entity_ids.add(self.entity_id) + + async def async_will_remove_from_hass(self): + """Unsubscribe from device events.""" + self.hass.async_add_job( + self._data.abode.events.remove_all_device_callbacks, self._device.device_id + ) + + @property + def should_poll(self): + """Return the polling state.""" + return self._data.polling + + def update(self): + """Update device and automation states.""" + self._device.refresh() + + @property + def name(self): + """Return the name of the device.""" + return self._device.name + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + ATTR_ATTRIBUTION: ATTRIBUTION, + "device_id": self._device.device_id, + "battery_low": self._device.battery_low, + "no_response": self._device.no_response, + "device_type": self._device.type, + } + + @property + def unique_id(self): + """Return a unique ID to use for this device.""" + return self._device.device_uuid + + @property + def device_info(self): + """Return device registry information for this entity.""" + return { + "identifiers": {(DOMAIN, self._device.device_id)}, + "manufacturer": "Abode", + "name": self._device.name, + "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 + + async def async_added_to_hass(self): + """Subscribe to a group of Abode timeline events.""" + if self._event: + self.hass.async_add_job( + self._data.abode.events.add_event_callback, + self._event, + self._update_callback, + ) + self.hass.data[DOMAIN].entity_ids.add(self.entity_id) + + @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 automation.""" + return self._automation.name + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + ATTR_ATTRIBUTION: ATTRIBUTION, + "automation_id": self._automation.automation_id, + "type": self._automation.type, + "sub_type": self._automation.sub_type, + } + + def _update_callback(self, device): + """Update the automation state.""" + self._automation.refresh() + self.schedule_update_ha_state() diff --git a/homeassistant/components/abode/alarm_control_panel.py b/homeassistant/components/abode/alarm_control_panel.py new file mode 100644 index 000000000..88a072bd7 --- /dev/null +++ b/homeassistant/components/abode/alarm_control_panel.py @@ -0,0 +1,83 @@ +"""Support for Abode Security System alarm control panels.""" +import logging + +import homeassistant.components.alarm_control_panel as alarm +from homeassistant.components.alarm_control_panel.const import ( + SUPPORT_ALARM_ARM_AWAY, + SUPPORT_ALARM_ARM_HOME, +) +from homeassistant.const import ( + ATTR_ATTRIBUTION, + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_DISARMED, +) + +from . import AbodeDevice +from .const import ATTRIBUTION, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +ICON = "mdi:security" + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Platform uses config entry setup.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Abode alarm control panel device.""" + data = hass.data[DOMAIN] + async_add_entities( + [AbodeAlarm(data, await hass.async_add_executor_job(data.abode.get_alarm))] + ) + + +class AbodeAlarm(AbodeDevice, alarm.AlarmControlPanel): + """An alarm_control_panel implementation for Abode.""" + + @property + def icon(self): + """Return the icon.""" + return ICON + + @property + def state(self): + """Return the state of the device.""" + if self._device.is_standby: + state = STATE_ALARM_DISARMED + elif self._device.is_away: + state = STATE_ALARM_ARMED_AWAY + elif self._device.is_home: + state = STATE_ALARM_ARMED_HOME + else: + state = None + return state + + @property + def supported_features(self) -> int: + """Return the list of supported features.""" + return SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY + + def alarm_disarm(self, code=None): + """Send disarm command.""" + self._device.set_standby() + + def alarm_arm_home(self, code=None): + """Send arm home command.""" + self._device.set_home() + + def alarm_arm_away(self, code=None): + """Send arm away command.""" + self._device.set_away() + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + ATTR_ATTRIBUTION: ATTRIBUTION, + "device_id": self._device.device_id, + "battery_backup": self._device.battery, + "cellular_backup": self._device.is_cellular, + } diff --git a/homeassistant/components/abode/binary_sensor.py b/homeassistant/components/abode/binary_sensor.py new file mode 100644 index 000000000..56c7bbcc1 --- /dev/null +++ b/homeassistant/components/abode/binary_sensor.py @@ -0,0 +1,78 @@ +"""Support for Abode Security System binary sensors.""" +import logging + +import abodepy.helpers.constants as CONST +import abodepy.helpers.timeline as TIMELINE + +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from . import AbodeAutomation, AbodeDevice +from .const import DOMAIN, SIGNAL_TRIGGER_QUICK_ACTION + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Platform uses config entry setup.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Abode binary sensor devices.""" + data = hass.data[DOMAIN] + + device_types = [ + CONST.TYPE_CONNECTIVITY, + CONST.TYPE_MOISTURE, + CONST.TYPE_MOTION, + CONST.TYPE_OCCUPANCY, + CONST.TYPE_OPENING, + ] + + entities = [] + + for device in data.abode.get_devices(generic_type=device_types): + entities.append(AbodeBinarySensor(data, device)) + + for automation in data.abode.get_automations(generic_type=CONST.TYPE_QUICK_ACTION): + entities.append( + AbodeQuickActionBinarySensor( + data, automation, TIMELINE.AUTOMATION_EDIT_GROUP + ) + ) + + async_add_entities(entities) + + +class AbodeBinarySensor(AbodeDevice, BinarySensorDevice): + """A binary sensor implementation for Abode device.""" + + @property + def is_on(self): + """Return True if the binary sensor is on.""" + return self._device.is_on + + @property + def device_class(self): + """Return the class of the binary sensor.""" + return self._device.generic_type + + +class AbodeQuickActionBinarySensor(AbodeAutomation, BinarySensorDevice): + """A binary sensor implementation for Abode quick action automations.""" + + async def async_added_to_hass(self): + """Subscribe Abode events.""" + await super().async_added_to_hass() + signal = SIGNAL_TRIGGER_QUICK_ACTION.format(self.entity_id) + async_dispatcher_connect(self.hass, signal, self.trigger) + + 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/abode/camera.py b/homeassistant/components/abode/camera.py new file mode 100644 index 000000000..c6f366e0e --- /dev/null +++ b/homeassistant/components/abode/camera.py @@ -0,0 +1,97 @@ +"""Support for Abode Security System cameras.""" +from datetime import timedelta +import logging + +import abodepy.helpers.constants as CONST +import abodepy.helpers.timeline as TIMELINE +import requests + +from homeassistant.components.camera import Camera +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.util import Throttle + +from . import AbodeDevice +from .const import DOMAIN, SIGNAL_CAPTURE_IMAGE + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=90) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Platform uses config entry setup.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Abode camera devices.""" + data = hass.data[DOMAIN] + + entities = [] + + for device in data.abode.get_devices(generic_type=CONST.TYPE_CAMERA): + entities.append(AbodeCamera(data, device, TIMELINE.CAPTURE_IMAGE)) + + async_add_entities(entities) + + +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 + + async def async_added_to_hass(self): + """Subscribe Abode events.""" + await super().async_added_to_hass() + + self.hass.async_add_job( + self._data.abode.events.add_timeline_callback, + self._event, + self._capture_callback, + ) + + signal = SIGNAL_CAPTURE_IMAGE.format(self.entity_id) + async_dispatcher_connect(self.hass, signal, self.capture) + + 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/abode/config_flow.py b/homeassistant/components/abode/config_flow.py new file mode 100644 index 000000000..89b389798 --- /dev/null +++ b/homeassistant/components/abode/config_flow.py @@ -0,0 +1,82 @@ +"""Config flow for the Abode Security System component.""" +import logging + +from abodepy import Abode +from abodepy.exceptions import AbodeException +from requests.exceptions import ConnectTimeout, HTTPError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import callback + +from .const import DEFAULT_CACHEDB, DOMAIN # pylint: disable=unused-import + +CONF_POLLING = "polling" + +_LOGGER = logging.getLogger(__name__) + + +class AbodeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Config flow for Abode.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + def __init__(self): + """Initialize.""" + self.data_schema = { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + if not user_input: + return self._show_form() + + username = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + polling = user_input.get(CONF_POLLING, False) + cache = self.hass.config.path(DEFAULT_CACHEDB) + + try: + await self.hass.async_add_executor_job( + Abode, username, password, True, True, True, cache + ) + + except (AbodeException, ConnectTimeout, HTTPError) as ex: + _LOGGER.error("Unable to connect to Abode: %s", str(ex)) + if ex.errcode == 400: + return self._show_form({"base": "invalid_credentials"}) + return self._show_form({"base": "connection_error"}) + + return self.async_create_entry( + title=user_input[CONF_USERNAME], + data={ + CONF_USERNAME: username, + CONF_PASSWORD: password, + CONF_POLLING: polling, + }, + ) + + @callback + def _show_form(self, errors=None): + """Show the form to the user.""" + return self.async_show_form( + step_id="user", + data_schema=vol.Schema(self.data_schema), + errors=errors if errors else {}, + ) + + async def async_step_import(self, import_config): + """Import a config entry from configuration.yaml.""" + if self._async_current_entries(): + _LOGGER.warning("Only one configuration of abode is allowed.") + return self.async_abort(reason="single_instance_allowed") + + return await self.async_step_user(import_config) diff --git a/homeassistant/components/abode/const.py b/homeassistant/components/abode/const.py new file mode 100644 index 000000000..267eb04f7 --- /dev/null +++ b/homeassistant/components/abode/const.py @@ -0,0 +1,8 @@ +"""Constants for the Abode Security System component.""" +DOMAIN = "abode" +ATTRIBUTION = "Data provided by goabode.com" + +DEFAULT_CACHEDB = "abodepy_cache.pickle" + +SIGNAL_CAPTURE_IMAGE = "abode_camera_capture_{}" +SIGNAL_TRIGGER_QUICK_ACTION = "abode_trigger_quick_action_{}" diff --git a/homeassistant/components/abode/cover.py b/homeassistant/components/abode/cover.py new file mode 100644 index 000000000..a4fce7e7b --- /dev/null +++ b/homeassistant/components/abode/cover.py @@ -0,0 +1,45 @@ +"""Support for Abode Security System covers.""" +import logging + +import abodepy.helpers.constants as CONST + +from homeassistant.components.cover import CoverDevice + +from . import AbodeDevice +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Platform uses config entry setup.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Abode cover devices.""" + data = hass.data[DOMAIN] + + entities = [] + + for device in data.abode.get_devices(generic_type=CONST.TYPE_COVER): + entities.append(AbodeCover(data, device)) + + async_add_entities(entities) + + +class AbodeCover(AbodeDevice, CoverDevice): + """Representation of an Abode cover.""" + + @property + def is_closed(self): + """Return true if cover is closed, else False.""" + return not self._device.is_open + + def close_cover(self, **kwargs): + """Issue close command to cover.""" + self._device.close_cover() + + def open_cover(self, **kwargs): + """Issue open command to cover.""" + self._device.open_cover() diff --git a/homeassistant/components/abode/light.py b/homeassistant/components/abode/light.py new file mode 100644 index 000000000..f29270d26 --- /dev/null +++ b/homeassistant/components/abode/light.py @@ -0,0 +1,103 @@ +"""Support for Abode Security System lights.""" +import logging +from math import ceil + +import abodepy.helpers.constants as CONST + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP, + ATTR_HS_COLOR, + SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, + SUPPORT_COLOR_TEMP, + Light, +) +from homeassistant.util.color import ( + color_temperature_kelvin_to_mired, + color_temperature_mired_to_kelvin, +) + +from . import AbodeDevice +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Platform uses config entry setup.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Abode light devices.""" + data = hass.data[DOMAIN] + + entities = [] + + for device in data.abode.get_devices(generic_type=CONST.TYPE_LIGHT): + entities.append(AbodeLight(data, device)) + + async_add_entities(entities) + + +class AbodeLight(AbodeDevice, Light): + """Representation of an Abode light.""" + + def turn_on(self, **kwargs): + """Turn on the light.""" + if ATTR_COLOR_TEMP in kwargs and self._device.is_color_capable: + self._device.set_color_temp( + int(color_temperature_mired_to_kelvin(kwargs[ATTR_COLOR_TEMP])) + ) + + if ATTR_HS_COLOR in kwargs and self._device.is_color_capable: + self._device.set_color(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 color_temp(self): + """Return the color temp of the light.""" + if self._device.has_color: + return color_temperature_kelvin_to_mired(self._device.color_temp) + + @property + def hs_color(self): + """Return the color of the light.""" + if self._device.has_color: + return self._device.color + + @property + def supported_features(self): + """Flag supported features.""" + if self._device.is_dimmable and self._device.is_color_capable: + return SUPPORT_BRIGHTNESS | SUPPORT_COLOR | SUPPORT_COLOR_TEMP + if self._device.is_dimmable: + return SUPPORT_BRIGHTNESS + return 0 diff --git a/homeassistant/components/abode/lock.py b/homeassistant/components/abode/lock.py new file mode 100644 index 000000000..e7ed40849 --- /dev/null +++ b/homeassistant/components/abode/lock.py @@ -0,0 +1,45 @@ +"""Support for the Abode Security System locks.""" +import logging + +import abodepy.helpers.constants as CONST + +from homeassistant.components.lock import LockDevice + +from . import AbodeDevice +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Platform uses config entry setup.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Abode lock devices.""" + data = hass.data[DOMAIN] + + entities = [] + + for device in data.abode.get_devices(generic_type=CONST.TYPE_LOCK): + entities.append(AbodeLock(data, device)) + + async_add_entities(entities) + + +class AbodeLock(AbodeDevice, LockDevice): + """Representation of an Abode lock.""" + + def lock(self, **kwargs): + """Lock the device.""" + self._device.lock() + + def unlock(self, **kwargs): + """Unlock the device.""" + self._device.unlock() + + @property + def is_locked(self): + """Return true if device is on.""" + return self._device.is_locked diff --git a/homeassistant/components/abode/manifest.json b/homeassistant/components/abode/manifest.json new file mode 100644 index 000000000..0a4307ff7 --- /dev/null +++ b/homeassistant/components/abode/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "abode", + "name": "Abode", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/abode", + "requirements": [ + "abodepy==0.16.7" + ], + "dependencies": [], + "codeowners": [ + "@shred86" + ] +} \ No newline at end of file diff --git a/homeassistant/components/abode/sensor.py b/homeassistant/components/abode/sensor.py new file mode 100644 index 000000000..573df6d49 --- /dev/null +++ b/homeassistant/components/abode/sensor.py @@ -0,0 +1,90 @@ +"""Support for Abode Security System sensors.""" +import logging + +import abodepy.helpers.constants as CONST + +from homeassistant.const import ( + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_TEMPERATURE, +) + +from . import AbodeDevice +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +# Sensor types: Name, icon +SENSOR_TYPES = { + CONST.TEMP_STATUS_KEY: ["Temperature", DEVICE_CLASS_TEMPERATURE], + CONST.HUMI_STATUS_KEY: ["Humidity", DEVICE_CLASS_HUMIDITY], + CONST.LUX_STATUS_KEY: ["Lux", DEVICE_CLASS_ILLUMINANCE], +} + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Platform uses config entry setup.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Abode sensor devices.""" + data = hass.data[DOMAIN] + + entities = [] + + for device in data.abode.get_devices(generic_type=CONST.TYPE_SENSOR): + for sensor_type in SENSOR_TYPES: + if sensor_type not in device.get_value(CONST.STATUSES_KEY): + continue + entities.append(AbodeSensor(data, device, sensor_type)) + + async_add_entities(entities) + + +class AbodeSensor(AbodeDevice): + """A sensor implementation for Abode devices.""" + + def __init__(self, data, device, sensor_type): + """Initialize a sensor for an Abode device.""" + super().__init__(data, 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] + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def device_class(self): + """Return the device class.""" + return self._device_class + + @property + def unique_id(self): + """Return a unique ID to use for this device.""" + return f"{self._device.device_uuid}-{self._sensor_type}" + + @property + def state(self): + """Return the state of the sensor.""" + if self._sensor_type == CONST.TEMP_STATUS_KEY: + return self._device.temp + if self._sensor_type == CONST.HUMI_STATUS_KEY: + return self._device.humidity + if self._sensor_type == CONST.LUX_STATUS_KEY: + return self._device.lux + + @property + def unit_of_measurement(self): + """Return the units of measurement.""" + if self._sensor_type == CONST.TEMP_STATUS_KEY: + return self._device.temp_unit + if self._sensor_type == CONST.HUMI_STATUS_KEY: + return self._device.humidity_unit + if self._sensor_type == CONST.LUX_STATUS_KEY: + return self._device.lux_unit diff --git a/homeassistant/components/abode/services.yaml b/homeassistant/components/abode/services.yaml new file mode 100644 index 000000000..ad0bb076d --- /dev/null +++ b/homeassistant/components/abode/services.yaml @@ -0,0 +1,13 @@ +capture_image: + description: Request a new image capture from a camera device. + fields: + entity_id: {description: Entity id of the camera to request an image., example: camera.downstairs_motion_camera} +change_setting: + description: Change an Abode system setting. + fields: + setting: {description: Setting to change., example: beeper_mute} + value: {description: Value of the setting., example: '1'} +trigger_quick_action: + description: Trigger an Abode quick action. + fields: + entity_id: {description: Entity id of the quick action to trigger., example: binary_sensor.home_quick_action} diff --git a/homeassistant/components/abode/strings.json b/homeassistant/components/abode/strings.json new file mode 100644 index 000000000..bf7e768f6 --- /dev/null +++ b/homeassistant/components/abode/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "title": "Abode", + "step": { + "user": { + "title": "Fill in your Abode login information", + "data": { + "username": "Email Address", + "password": "Password" + } + } + }, + "error": { + "identifier_exists": "Account already registered.", + "invalid_credentials": "Invalid credentials.", + "connection_error": "Unable to connect to Abode." + }, + "abort": { + "single_instance_allowed": "Only a single configuration of Abode is allowed." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/abode/switch.py b/homeassistant/components/abode/switch.py new file mode 100644 index 000000000..c092c1ef3 --- /dev/null +++ b/homeassistant/components/abode/switch.py @@ -0,0 +1,68 @@ +"""Support for Abode Security System switches.""" +import logging + +import abodepy.helpers.constants as CONST +import abodepy.helpers.timeline as TIMELINE + +from homeassistant.components.switch import SwitchDevice + +from . import AbodeAutomation, AbodeDevice +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Platform uses config entry setup.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Abode switch devices.""" + data = hass.data[DOMAIN] + + entities = [] + + for device in data.abode.get_devices(generic_type=CONST.TYPE_SWITCH): + entities.append(AbodeSwitch(data, device)) + + for automation in data.abode.get_automations(generic_type=CONST.TYPE_AUTOMATION): + entities.append( + AbodeAutomationSwitch(data, automation, TIMELINE.AUTOMATION_EDIT_GROUP) + ) + + async_add_entities(entities) + + +class AbodeSwitch(AbodeDevice, SwitchDevice): + """Representation of an Abode switch.""" + + def turn_on(self, **kwargs): + """Turn on the device.""" + self._device.switch_on() + + def turn_off(self, **kwargs): + """Turn off the device.""" + self._device.switch_off() + + @property + def is_on(self): + """Return true if device is on.""" + return self._device.is_on + + +class AbodeAutomationSwitch(AbodeAutomation, SwitchDevice): + """A switch implementation for Abode automations.""" + + def turn_on(self, **kwargs): + """Turn on the device.""" + self._automation.set_active(True) + + def turn_off(self, **kwargs): + """Turn off the device.""" + self._automation.set_active(False) + + @property + def is_on(self): + """Return True if the binary sensor is on.""" + return self._automation.is_active diff --git a/homeassistant/components/acer_projector/__init__.py b/homeassistant/components/acer_projector/__init__.py new file mode 100644 index 000000000..39896d203 --- /dev/null +++ b/homeassistant/components/acer_projector/__init__.py @@ -0,0 +1 @@ +"""The acer_projector component.""" diff --git a/homeassistant/components/acer_projector/manifest.json b/homeassistant/components/acer_projector/manifest.json new file mode 100644 index 000000000..9f712ba5c --- /dev/null +++ b/homeassistant/components/acer_projector/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "acer_projector", + "name": "Acer projector", + "documentation": "https://www.home-assistant.io/integrations/acer_projector", + "requirements": [ + "pyserial==3.1.1" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/acer_projector/switch.py b/homeassistant/components/acer_projector/switch.py new file mode 100644 index 000000000..b28d67562 --- /dev/null +++ b/homeassistant/components/acer_projector/switch.py @@ -0,0 +1,170 @@ +"""Use serial protocol of Acer projector to obtain state of the projector.""" +import logging +import re + +import serial +import voluptuous as vol + +from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice +from homeassistant.const import ( + CONF_FILENAME, + CONF_NAME, + STATE_OFF, + STATE_ON, + STATE_UNKNOWN, +) +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +CONF_TIMEOUT = "timeout" +CONF_WRITE_TIMEOUT = "write_timeout" + +DEFAULT_NAME = "Acer Projector" +DEFAULT_TIMEOUT = 1 +DEFAULT_WRITE_TIMEOUT = 1 + +ECO_MODE = "ECO Mode" + +ICON = "mdi:projector" + +INPUT_SOURCE = "Input Source" + +LAMP = "Lamp" +LAMP_HOURS = "Lamp Hours" + +MODEL = "Model" + +# Commands known to the projector +CMD_DICT = { + LAMP: "* 0 Lamp ?\r", + LAMP_HOURS: "* 0 Lamp\r", + INPUT_SOURCE: "* 0 Src ?\r", + ECO_MODE: "* 0 IR 052\r", + MODEL: "* 0 IR 035\r", + STATE_ON: "* 0 IR 001\r", + STATE_OFF: "* 0 IR 002\r", +} + + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_FILENAME): cv.isdevice, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + vol.Optional( + CONF_WRITE_TIMEOUT, default=DEFAULT_WRITE_TIMEOUT + ): cv.positive_int, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Connect with serial port and return Acer Projector.""" + serial_port = config.get(CONF_FILENAME) + name = config.get(CONF_NAME) + timeout = config.get(CONF_TIMEOUT) + write_timeout = config.get(CONF_WRITE_TIMEOUT) + + add_entities([AcerSwitch(serial_port, name, timeout, write_timeout)], True) + + +class AcerSwitch(SwitchDevice): + """Represents an Acer Projector as a switch.""" + + def __init__(self, serial_port, name, timeout, write_timeout, **kwargs): + """Init of the Acer projector.""" + + self.ser = serial.Serial( + port=serial_port, timeout=timeout, write_timeout=write_timeout, **kwargs + ) + self._serial_port = serial_port + self._name = name + self._state = False + self._available = False + self._attributes = { + LAMP_HOURS: STATE_UNKNOWN, + INPUT_SOURCE: STATE_UNKNOWN, + ECO_MODE: STATE_UNKNOWN, + } + + def _write_read(self, msg): + """Write to the projector and read the return.""" + + ret = "" + # Sometimes the projector won't answer for no reason or the projector + # was disconnected during runtime. + # This way the projector can be reconnected and will still work + try: + if not self.ser.is_open: + self.ser.open() + msg = msg.encode("utf-8") + self.ser.write(msg) + # Size is an experience value there is no real limit. + # AFAIK there is no limit and no end character so we will usually + # need to wait for timeout + ret = self.ser.read_until(size=20).decode("utf-8") + except serial.SerialException: + _LOGGER.error("Problem communicating with %s", self._serial_port) + self.ser.close() + return ret + + def _write_read_format(self, msg): + """Write msg, obtain answer and format output.""" + # answers are formatted as ***\answer\r*** + awns = self._write_read(msg) + match = re.search(r"\r(.+)\r", awns) + if match: + return match.group(1) + return STATE_UNKNOWN + + @property + def available(self): + """Return if projector is available.""" + return self._available + + @property + def name(self): + """Return name of the projector.""" + return self._name + + @property + def is_on(self): + """Return if the projector is turned on.""" + return self._state + + @property + def state_attributes(self): + """Return state attributes.""" + return self._attributes + + def update(self): + """Get the latest state from the projector.""" + msg = CMD_DICT[LAMP] + awns = self._write_read_format(msg) + if awns == "Lamp 1": + self._state = True + self._available = True + elif awns == "Lamp 0": + self._state = False + self._available = True + else: + self._available = False + + for key in self._attributes: + msg = CMD_DICT.get(key, None) + if msg: + awns = self._write_read_format(msg) + self._attributes[key] = awns + + def turn_on(self, **kwargs): + """Turn the projector on.""" + msg = CMD_DICT[STATE_ON] + self._write_read(msg) + self._state = STATE_ON + + def turn_off(self, **kwargs): + """Turn the projector off.""" + msg = CMD_DICT[STATE_OFF] + self._write_read(msg) + self._state = STATE_OFF diff --git a/homeassistant/components/actiontec/__init__.py b/homeassistant/components/actiontec/__init__.py new file mode 100644 index 000000000..fa59cc870 --- /dev/null +++ b/homeassistant/components/actiontec/__init__.py @@ -0,0 +1 @@ +"""The actiontec component.""" diff --git a/homeassistant/components/actiontec/device_tracker.py b/homeassistant/components/actiontec/device_tracker.py new file mode 100644 index 000000000..302a8d561 --- /dev/null +++ b/homeassistant/components/actiontec/device_tracker.py @@ -0,0 +1,123 @@ +"""Support for Actiontec MI424WR (Verizon FIOS) routers.""" +from collections import namedtuple +import logging +import re +import telnetlib + +import voluptuous as vol + +from homeassistant.components.device_tracker import ( + DOMAIN, + PLATFORM_SCHEMA, + DeviceScanner, +) +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +import homeassistant.helpers.config_validation as cv +import homeassistant.util.dt as dt_util + +_LOGGER = logging.getLogger(__name__) + +_LEASES_REGEX = re.compile( + r"(?P([0-9]{1,3}[\.]){3}[0-9]{1,3})" + + r"\smac:\s(?P([0-9a-f]{2}[:-]){5}([0-9a-f]{2}))" + + r"\svalid\sfor:\s(?P(-?\d+))" + + r"\ssec" +) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, + } +) + + +def get_scanner(hass, config): + """Validate the configuration and return an Actiontec scanner.""" + scanner = ActiontecDeviceScanner(config[DOMAIN]) + return scanner if scanner.success_init else None + + +Device = namedtuple("Device", ["mac", "ip", "last_update"]) + + +class ActiontecDeviceScanner(DeviceScanner): + """This class queries an actiontec router for connected devices.""" + + def __init__(self, config): + """Initialize the scanner.""" + self.host = config[CONF_HOST] + self.username = config[CONF_USERNAME] + self.password = config[CONF_PASSWORD] + self.last_results = [] + data = self.get_actiontec_data() + self.success_init = data is not None + _LOGGER.info("canner initialized") + + 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] + + 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: + return None + for client in self.last_results: + if client.mac == device: + return client.ip + return None + + def _update_info(self): + """Ensure the information from the router is up to date. + + Return boolean if scanning successful. + """ + _LOGGER.info("Scanning") + if not self.success_init: + return False + + now = dt_util.now() + actiontec_data = self.get_actiontec_data() + if not actiontec_data: + return False + self.last_results = [ + Device(data["mac"], name, now) + for name, data in actiontec_data.items() + if data["timevalid"] > -60 + ] + _LOGGER.info("Scan successful") + return True + + def get_actiontec_data(self): + """Retrieve data from Actiontec MI424WR and return parsed result.""" + try: + telnet = telnetlib.Telnet(self.host) + telnet.read_until(b"Username: ") + telnet.write((self.username + "\n").encode("ascii")) + telnet.read_until(b"Password: ") + telnet.write((self.password + "\n").encode("ascii")) + prompt = telnet.read_until(b"Wireless Broadband Router> ").split(b"\n")[-1] + telnet.write("firewall mac_cache_dump\n".encode("ascii")) + telnet.write("\n".encode("ascii")) + telnet.read_until(prompt) + leases_result = telnet.read_until(prompt).split(b"\n")[1:-1] + telnet.write("exit\n".encode("ascii")) + except EOFError: + _LOGGER.exception("Unexpected response from router") + return + except ConnectionRefusedError: + _LOGGER.exception("Connection refused by router. Telnet enabled?") + return None + + devices = {} + for lease in leases_result: + match = _LEASES_REGEX.search(lease.decode("utf-8")) + if match is not None: + devices[match.group("ip")] = { + "ip": match.group("ip"), + "mac": match.group("mac").upper(), + "timevalid": int(match.group("timevalid")), + } + return devices diff --git a/homeassistant/components/actiontec/manifest.json b/homeassistant/components/actiontec/manifest.json new file mode 100644 index 000000000..ddb495479 --- /dev/null +++ b/homeassistant/components/actiontec/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "actiontec", + "name": "Actiontec", + "documentation": "https://www.home-assistant.io/integrations/actiontec", + "requirements": [], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/adguard/.translations/bg.json b/homeassistant/components/adguard/.translations/bg.json new file mode 100644 index 000000000..398927d37 --- /dev/null +++ b/homeassistant/components/adguard/.translations/bg.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "adguard_home_addon_outdated": "\u0422\u0430\u0437\u0438 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f \u0438\u0437\u0438\u0441\u043a\u0432\u0430 AdGuard Home {minimal_version} \u0438\u043b\u0438 \u043f\u043e-\u043d\u043e\u0432\u0430 {minimal_version}, \u0438\u043c\u0430\u0442\u0435 {current_version}. \u041c\u043e\u043b\u044f, \u0430\u043a\u0442\u0443\u0430\u043b\u0438\u0437\u0438\u0440\u0430\u0439\u0442\u0435 \u0432\u0430\u0448\u0430\u0442\u0430 \u0434\u043e\u0431\u0430\u0432\u043a\u0430 \u0437\u0430 Hass.io AdGuard Home.", + "adguard_home_outdated": "\u0422\u0430\u0437\u0438 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f \u0438\u0437\u0438\u0441\u043a\u0432\u0430 AdGuard Home {minimal_version} \u0438\u043b\u0438 \u043f\u043e-\u043d\u043e\u0432\u0430 {minimal_version}, \u0438\u043c\u0430\u0442\u0435 {current_version}.", + "existing_instance_updated": "\u0410\u043a\u0442\u0443\u0430\u043b\u0438\u0437\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u0441\u044a\u0449\u0435\u0441\u0442\u0432\u0443\u0432\u0430\u0449\u0430\u0442\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f.", + "single_instance_allowed": "\u0420\u0430\u0437\u0440\u0435\u0448\u0435\u043d\u0430 \u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f \u043d\u0430 AdGuard Home." + }, + "error": { + "connection_error": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435." + }, + "step": { + "hassio_confirm": { + "description": "\u0418\u0441\u043a\u0430\u0442\u0435 \u043b\u0438 \u0434\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u0442\u0435 Home Assistant \u0434\u0430 \u0441\u0435 \u0441\u0432\u044a\u0440\u0437\u0432\u0430 \u0441 AdGuard Home, \u043f\u0440\u0435\u0434\u043e\u0441\u0442\u0430\u0432\u0435\u043d \u043e\u0442 Hass.io \u0434\u043e\u0431\u0430\u0432\u043a\u0430\u0442\u0430: {addon} ?", + "title": "AdGuard Home \u0447\u0440\u0435\u0437 Hass.io \u0434\u043e\u0431\u0430\u0432\u043a\u0430" + }, + "user": { + "data": { + "host": "\u0410\u0434\u0440\u0435\u0441", + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "port": "\u041f\u043e\u0440\u0442", + "ssl": "AdGuard Home \u0438\u0437\u043f\u043e\u043b\u0437\u0432\u0430 SSL \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442", + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435", + "verify_ssl": "AdGuard Home \u0438\u0437\u043f\u043e\u043b\u0437\u0432\u0430 \u043d\u0430\u0434\u0435\u0436\u0434\u0435\u043d \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442" + }, + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 \u0412\u0430\u0448\u0438\u044f AdGuard Home, \u0437\u0430 \u0434\u0430 \u043f\u043e\u0437\u0432\u043e\u043b\u0438\u0442\u0435 \u043d\u0430\u0431\u043b\u044e\u0434\u0435\u043d\u0438\u0435 \u0438 \u043a\u043e\u043d\u0442\u0440\u043e\u043b.", + "title": "\u0421\u0432\u044a\u0440\u0436\u0435\u0442\u0435 \u0412\u0430\u0448\u0438\u044f AdGuard Home." + } + }, + "title": "AdGuard Home" + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/.translations/ca.json b/homeassistant/components/adguard/.translations/ca.json new file mode 100644 index 000000000..9b7b3c39b --- /dev/null +++ b/homeassistant/components/adguard/.translations/ca.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "adguard_home_addon_outdated": "Aquesta integraci\u00f3 necessita la versi\u00f3 d'AdGuard Home {minimal_version} o una superior, tens la {current_version}. Actualitza el complement de Hass.io d'AdGuard Home.", + "adguard_home_outdated": "Aquesta integraci\u00f3 necessita la versi\u00f3 d'AdGuard Home {minimal_version} o una superior, tens la {current_version}.", + "existing_instance_updated": "S'ha actualitzat la configuraci\u00f3 existent.", + "single_instance_allowed": "Nom\u00e9s es permet una \u00fanica configuraci\u00f3 d'AdGuard Home." + }, + "error": { + "connection_error": "No s'ha pogut connectar." + }, + "step": { + "hassio_confirm": { + "description": "Vols configurar Home Assistant perqu\u00e8 es connecti amb l'AdGuard Home proporcionat pel complement de Hass.io: {addon}?", + "title": "AdGuard Home (complement de Hass.io)" + }, + "user": { + "data": { + "host": "Amfitri\u00f3", + "password": "Contrasenya", + "port": "Port", + "ssl": "AdGuard Home utilitza un certificat SSL", + "username": "Nom d'usuari", + "verify_ssl": "AdGuard Home utilitza un certificat adequat" + }, + "description": "Configuraci\u00f3 de la inst\u00e0ncia d'AdGuard Home, permet el control i la monitoritzaci\u00f3.", + "title": "Enlla\u00e7ar AdGuard Home." + } + }, + "title": "AdGuard Home" + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/.translations/da.json b/homeassistant/components/adguard/.translations/da.json new file mode 100644 index 000000000..813405cec --- /dev/null +++ b/homeassistant/components/adguard/.translations/da.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "existing_instance_updated": "Opdaterede eksisterende konfiguration.", + "single_instance_allowed": "Det er kun n\u00f8dvendigt med en ops\u00e6tning af AdGuard Home." + }, + "error": { + "connection_error": "Forbindelse mislykkedes." + }, + "step": { + "hassio_confirm": { + "description": "Vil du konfigurere Home Assistant til at oprette forbindelse til AdGuard Home, der leveres af Hass.io add-on: {addon}?", + "title": "AdGuard Home via Hass.io add-on" + }, + "user": { + "data": { + "host": "V\u00e6rt", + "password": "Adgangskode", + "port": "Port", + "ssl": "AdGuard Home bruger et SSL-certifikat", + "username": "Brugernavn", + "verify_ssl": "AdGuard Home bruger et korrekt certifikat" + }, + "description": "Konfigurer din AdGuard Home instans for at tillade overv\u00e5gning og kontrol.", + "title": "Link AdGuard Home." + } + }, + "title": "AdGuard Home" + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/.translations/de.json b/homeassistant/components/adguard/.translations/de.json new file mode 100644 index 000000000..b1fbbfa85 --- /dev/null +++ b/homeassistant/components/adguard/.translations/de.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "existing_instance_updated": "Bestehende Konfiguration wurde aktualisiert.", + "single_instance_allowed": "Es ist nur eine einzige Konfiguration von AdGuard Home zul\u00e4ssig." + }, + "error": { + "connection_error": "Fehler beim Herstellen einer Verbindung." + }, + "step": { + "hassio_confirm": { + "description": "M\u00f6chtest du Home Assistant so konfigurieren, dass eine Verbindung mit AdGuard Home als Hass.io-Add-On hergestellt wird: {addon}?", + "title": "AdGuard Home \u00fcber das Hass.io Add-on" + }, + "user": { + "data": { + "host": "Host", + "password": "Passwort", + "port": "Port", + "ssl": "AdGuard Home verwendet ein SSL-Zertifikat", + "username": "Benutzername", + "verify_ssl": "AdGuard Home verwendet ein richtiges Zertifikat" + }, + "description": "Richte deine AdGuard Home-Instanz ein um sie zu \u00dcberwachen und zu Steuern.", + "title": "Verkn\u00fcpfe AdGuard Home." + } + }, + "title": "AdGuard Home" + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/.translations/en.json b/homeassistant/components/adguard/.translations/en.json new file mode 100644 index 000000000..00d048c33 --- /dev/null +++ b/homeassistant/components/adguard/.translations/en.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "adguard_home_addon_outdated": "This integration requires AdGuard Home {minimal_version} or higher, you have {current_version}. Please update your Hass.io AdGuard Home add-on.", + "adguard_home_outdated": "This integration requires AdGuard Home {minimal_version} or higher, you have {current_version}.", + "existing_instance_updated": "Updated existing configuration.", + "single_instance_allowed": "Only a single configuration of AdGuard Home is allowed." + }, + "error": { + "connection_error": "Failed to connect." + }, + "step": { + "hassio_confirm": { + "description": "Do you want to configure Home Assistant to connect to the AdGuard Home provided by the Hass.io add-on: {addon}?", + "title": "AdGuard Home via Hass.io add-on" + }, + "user": { + "data": { + "host": "Host", + "password": "Password", + "port": "Port", + "ssl": "AdGuard Home uses a SSL certificate", + "username": "Username", + "verify_ssl": "AdGuard Home uses a proper certificate" + }, + "description": "Set up your AdGuard Home instance to allow monitoring and control.", + "title": "Link your AdGuard Home." + } + }, + "title": "AdGuard Home" + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/.translations/es-419.json b/homeassistant/components/adguard/.translations/es-419.json new file mode 100644 index 000000000..ed8e0c3a3 --- /dev/null +++ b/homeassistant/components/adguard/.translations/es-419.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "existing_instance_updated": "Se actualiz\u00f3 la configuraci\u00f3n existente.", + "single_instance_allowed": "Solo se permite una \u00fanica configuraci\u00f3n de AdGuard Home." + }, + "error": { + "connection_error": "Error al conectar." + }, + "step": { + "hassio_confirm": { + "description": "\u00bfDesea configurar Home Assistant para conectarse a la p\u00e1gina principal de AdGuard proporcionada por el complemento Hass.io: {addon}?", + "title": "AdGuard Home a trav\u00e9s del complemento Hass.io" + }, + "user": { + "data": { + "password": "Contrase\u00f1a", + "port": "Puerto", + "ssl": "AdGuard Home utiliza un certificado SSL", + "username": "Nombre de usuario", + "verify_ssl": "AdGuard Home utiliza un certificado adecuado" + }, + "description": "Configure su instancia de AdGuard Home para permitir la supervisi\u00f3n y el control.", + "title": "Enlace su AdGuard Home." + } + }, + "title": "AdGuard Home" + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/.translations/es.json b/homeassistant/components/adguard/.translations/es.json new file mode 100644 index 000000000..c6946ab61 --- /dev/null +++ b/homeassistant/components/adguard/.translations/es.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "adguard_home_addon_outdated": "Esta integraci\u00f3n requiere AdGuard Home {minimal_version} o superior, usted tiene {current_version}. Por favor, actualice su complemento Hass.io AdGuard Home.", + "adguard_home_outdated": "Esta integraci\u00f3n requiere AdGuard Home {minimal_version} o superior, usted tiene {current_version}.", + "existing_instance_updated": "Se ha actualizado la configuraci\u00f3n existente.", + "single_instance_allowed": "S\u00f3lo se permite una \u00fanica configuraci\u00f3n de AdGuard Home." + }, + "error": { + "connection_error": "No se conect\u00f3." + }, + "step": { + "hassio_confirm": { + "description": "\u00bfDesea configurar Home Assistant para conectarse al AdGuard Home proporcionado por el complemento Hass.io: {addon} ?", + "title": "AdGuard Home a trav\u00e9s del complemento Hass.io" + }, + "user": { + "data": { + "host": "Host", + "password": "Contrase\u00f1a", + "port": "Puerto", + "ssl": "AdGuard Home utiliza un certificado SSL", + "username": "Nombre de usuario", + "verify_ssl": "AdGuard Home utiliza un certificado apropiado" + }, + "description": "Configure su instancia de AdGuard Home para permitir la supervisi\u00f3n y el control.", + "title": "Enlace su AdGuard Home." + } + }, + "title": "AdGuard Home" + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/.translations/fr.json b/homeassistant/components/adguard/.translations/fr.json new file mode 100644 index 000000000..749ba7d9c --- /dev/null +++ b/homeassistant/components/adguard/.translations/fr.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "adguard_home_addon_outdated": "Cette int\u00e9gration n\u00e9cessite AdGuard Home {minimal_version} ou une version ult\u00e9rieure, vous disposez de {current_version}. Veuillez mettre \u00e0 jour votre compl\u00e9ment Hass.io AdGuard Home.", + "adguard_home_outdated": "Cette int\u00e9gration n\u00e9cessite AdGuard Home {minimal_version} ou une version ult\u00e9rieure, vous disposez de {current_version}.", + "existing_instance_updated": "La configuration existante a \u00e9t\u00e9 mise \u00e0 jour.", + "single_instance_allowed": "Une seule configuration d'AdGuard Home est autoris\u00e9e." + }, + "error": { + "connection_error": "\u00c9chec de connexion." + }, + "step": { + "hassio_confirm": { + "description": "Voulez-vous configurer Home Assistant pour qu'il se connecte \u00e0 AdGuard Home fourni par le module compl\u00e9mentaire Hass.io: {addon} ?", + "title": "AdGuard Home via le module compl\u00e9mentaire Hass.io" + }, + "user": { + "data": { + "host": "H\u00f4te", + "password": "Mot de passe", + "port": "Port", + "ssl": "AdGuard Home utilise un certificat SSL", + "username": "Nom d'utilisateur", + "verify_ssl": "AdGuard Home utilise un certificat appropri\u00e9" + }, + "description": "Configurez votre instance AdGuard Home pour permettre la surveillance et le contr\u00f4le.", + "title": "Liez votre AdGuard Home." + } + }, + "title": "AdGuard Home" + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/.translations/hr.json b/homeassistant/components/adguard/.translations/hr.json new file mode 100644 index 000000000..869cc46ea --- /dev/null +++ b/homeassistant/components/adguard/.translations/hr.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "existing_instance_updated": "Postoje\u0107a konfiguracija je a\u017eurirana." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/.translations/hu.json b/homeassistant/components/adguard/.translations/hu.json new file mode 100644 index 000000000..34b601027 --- /dev/null +++ b/homeassistant/components/adguard/.translations/hu.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Jelsz\u00f3", + "port": "Port", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/.translations/id.json b/homeassistant/components/adguard/.translations/id.json new file mode 100644 index 000000000..3548361e3 --- /dev/null +++ b/homeassistant/components/adguard/.translations/id.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "connection_error": "Gagal terhubung." + }, + "step": { + "user": { + "data": { + "password": "Kata sandi", + "port": "Port" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/.translations/it.json b/homeassistant/components/adguard/.translations/it.json new file mode 100644 index 000000000..6dc6ae18d --- /dev/null +++ b/homeassistant/components/adguard/.translations/it.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "adguard_home_addon_outdated": "Questa integrazione richiede AdGuard Home {minimal_version} o versione successiva, si dispone di {current_version}. Aggiorna il componente aggiuntivo AdGuard Home di Hass.io.", + "adguard_home_outdated": "Questa integrazione richiede AdGuard Home {minimal_version} o versione successiva, si dispone di {current_version}.", + "existing_instance_updated": "Configurazione esistente aggiornata.", + "single_instance_allowed": "\u00c8 consentita solo una singola configurazione di AdGuard Home." + }, + "error": { + "connection_error": "Impossibile connettersi." + }, + "step": { + "hassio_confirm": { + "description": "Vuoi configurare Home Assistant per connettersi alla AdGuard Home fornita dal componente aggiuntivo di Hass.io: {addon}?", + "title": "AdGuard Home tramite il componente aggiuntivo di Hass.io" + }, + "user": { + "data": { + "host": "Host", + "password": "Password", + "port": "Porta", + "ssl": "AdGuard Home utilizza un certificato SSL", + "username": "Nome utente", + "verify_ssl": "AdGuard Home utilizza un certificato appropriato" + }, + "description": "Configura l'istanza di AdGuard Home per consentire il monitoraggio e il controllo.", + "title": "Collega la tua AdGuard Home." + } + }, + "title": "AdGuard Home" + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/.translations/ko.json b/homeassistant/components/adguard/.translations/ko.json new file mode 100644 index 000000000..e1f392592 --- /dev/null +++ b/homeassistant/components/adguard/.translations/ko.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "adguard_home_addon_outdated": "\uc774 \ud1b5\ud569 \uad6c\uc131\uc694\uc18c\ub294 AdGuard Home {minimal_version} \uc774\uc0c1\uc774 \ud544\uc694\ud569\ub2c8\ub2e4. \ud604\uc7ac \ubc84\uc804\uc740 {current_version} \uc785\ub2c8\ub2e4. Hass.io AdGuard Home \uc560\ub4dc\uc628\uc744 \uc5c5\ub370\uc774\ud2b8 \ud574\uc8fc\uc138\uc694.", + "adguard_home_outdated": "\uc774 \ud1b5\ud569 \uad6c\uc131\uc694\uc18c\ub294 AdGuard Home {minimal_version} \uc774\uc0c1\uc774 \ud544\uc694\ud569\ub2c8\ub2e4. \ud604\uc7ac \ubc84\uc804\uc740 {current_version} \uc785\ub2c8\ub2e4.", + "existing_instance_updated": "\uae30\uc874 \uad6c\uc131\uc744 \uc5c5\ub370\uc774\ud2b8\ud588\uc2b5\ub2c8\ub2e4.", + "single_instance_allowed": "\ud558\ub098\uc758 AdGuard Home \ub9cc \uad6c\uc131 \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4." + }, + "error": { + "connection_error": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4." + }, + "step": { + "hassio_confirm": { + "description": "Hass.io {addon} \uc560\ub4dc\uc628\uc73c\ub85c AdGuard Home \uc5d0 \uc5f0\uacb0\ud558\ub3c4\ub85d Home Assistant \ub97c \uad6c\uc131 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "title": "Hass.io \uc560\ub4dc\uc628\uc758 AdGuard Home" + }, + "user": { + "data": { + "host": "\ud638\uc2a4\ud2b8", + "password": "\ube44\ubc00\ubc88\ud638", + "port": "\ud3ec\ud2b8", + "ssl": "AdGuard Home \uc740 SSL \uc778\uc99d\uc11c\ub97c \uc0ac\uc6a9\ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984", + "verify_ssl": "AdGuard Home \uc740 \uc62c\ubc14\ub978 \uc778\uc99d\uc11c\ub97c \uc0ac\uc6a9\ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4" + }, + "description": "\ubaa8\ub2c8\ud130\ub9c1 \ubc0f \uc81c\uc5b4\uac00 \uac00\ub2a5\ud558\ub3c4\ub85d AdGuard Home \uc778\uc2a4\ud134\uc2a4\ub97c \uc124\uc815\ud574\uc8fc\uc138\uc694.", + "title": "AdGuard Home \uc5f0\uacb0" + } + }, + "title": "AdGuard Home" + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/.translations/lb.json b/homeassistant/components/adguard/.translations/lb.json new file mode 100644 index 000000000..e449f668f --- /dev/null +++ b/homeassistant/components/adguard/.translations/lb.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "adguard_home_addon_outdated": "D\u00ebs Integratioun ben\u00e9idegt AdgGuard Home {minimal_version} oder m\u00e9i, dir hutt {current_version}. Aktualis\u00e9iert w.e.g. \u00e4ren Hass.io AdGuard Home Add-on.", + "adguard_home_outdated": "D\u00ebs Integratioun ben\u00e9idegt AdgGuard Home {minimal_version} oder m\u00e9i, dir hutt {current_version}.", + "existing_instance_updated": "D\u00e9i bestehend Konfiguratioun ass ge\u00e4nnert.", + "single_instance_allowed": "N\u00ebmmen eng eenzeg Konfiguratioun vun AdGuard Home ass erlaabt." + }, + "error": { + "connection_error": "Feeler beim verbannen." + }, + "step": { + "hassio_confirm": { + "description": "W\u00ebllt dir Home Assistant konfigur\u00e9iere fir sech mam AdGuard Home ze verbannen dee vum hass.io add-on {addon} bereet gestallt g\u00ebtt?", + "title": "AdGuard Home via Hass.io add-on" + }, + "user": { + "data": { + "host": "Apparat", + "password": "Passwuert", + "port": "Port", + "ssl": "AdGuard Home benotzt een SSL Zertifikat", + "username": "Benotzernumm", + "verify_ssl": "AdGuard Home benotzt een eegenen Zertifikat" + }, + "description": "Konfigur\u00e9iert \u00e4r AdGuard Home Instanz fir d'Iwwerwaachung an d'Kontroll z'erlaben.", + "title": "Verbannt \u00e4ren AdGuard Home" + } + }, + "title": "AdGuard Home" + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/.translations/nl.json b/homeassistant/components/adguard/.translations/nl.json new file mode 100644 index 000000000..bd0dcc5fa --- /dev/null +++ b/homeassistant/components/adguard/.translations/nl.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "adguard_home_addon_outdated": "Deze integratie vereist AdGuard Home {minimal_version} of hoger, u heeft {current_version}. Update uw Hass.io AdGuard Home-add-on.", + "adguard_home_outdated": "Deze integratie vereist AdGuard Home {minimal_version} of hoger, u heeft {current_version}.", + "existing_instance_updated": "Bestaande configuratie bijgewerkt.", + "single_instance_allowed": "Slechts \u00e9\u00e9n configuratie van AdGuard Home is toegestaan." + }, + "error": { + "connection_error": "Kon niet verbinden." + }, + "step": { + "hassio_confirm": { + "description": "Wilt u Home Assistant configureren om verbinding te maken met AdGuard Home van de Hass.io-add-on: {addon}?", + "title": "AdGuard Home via Hass.io add-on" + }, + "user": { + "data": { + "host": "Host", + "password": "Wachtwoord", + "port": "Poort", + "ssl": "AdGuard Home maakt gebruik van een SSL certificaat", + "username": "Gebruikersnaam", + "verify_ssl": "AdGuard Home maakt gebruik van een goed certificaat" + }, + "description": "Stel uw AdGuard Home-instantie in om toezicht en controle mogelijk te maken.", + "title": "Link uw AdGuard Home." + } + }, + "title": "AdGuard Home" + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/.translations/nn.json b/homeassistant/components/adguard/.translations/nn.json new file mode 100644 index 000000000..0e2e82437 --- /dev/null +++ b/homeassistant/components/adguard/.translations/nn.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Brukarnamn" + } + } + }, + "title": "AdGuard Home" + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/.translations/no.json b/homeassistant/components/adguard/.translations/no.json new file mode 100644 index 000000000..22a8c2364 --- /dev/null +++ b/homeassistant/components/adguard/.translations/no.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "adguard_home_addon_outdated": "Denne integrasjonen krever AdGuard Home {minimal_version} eller h\u00f8yere, du har {current_version}. Vennligst oppdater Hass.io AdGuard Home-tillegget.", + "adguard_home_outdated": "Denne integrasjonen krever AdGuard Home {minimal_version} eller h\u00f8yere, du har {current_version}.", + "existing_instance_updated": "Oppdatert eksisterende konfigurasjon.", + "single_instance_allowed": "Kun en konfigurasjon av AdGuard Hjemer tillatt." + }, + "error": { + "connection_error": "Tilkobling mislyktes." + }, + "step": { + "hassio_confirm": { + "description": "Vil du konfigurere Home Assistant til \u00e5 koble til AdGuard Hjem gitt av hass.io tillegget {addon}?", + "title": "AdGuard Hjem via Hass.io tillegg" + }, + "user": { + "data": { + "host": "Vert", + "password": "Passord", + "port": "Port", + "ssl": "AdGuard Hjem bruker et SSL-sertifikat", + "username": "Brukernavn", + "verify_ssl": "AdGuard Home bruker et riktig sertifikat" + }, + "description": "Sett opp din AdGuard Hjem instans for \u00e5 tillate overv\u00e5king og kontroll.", + "title": "Koble til ditt AdGuard Hjem." + } + }, + "title": "AdGuard Hjem" + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/.translations/pl.json b/homeassistant/components/adguard/.translations/pl.json new file mode 100644 index 000000000..69ba6b024 --- /dev/null +++ b/homeassistant/components/adguard/.translations/pl.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "adguard_home_addon_outdated": "Ta integracja wymaga AdGuard Home {minimal_version} lub nowszej wersji, masz {current_version}. Zaktualizuj sw\u00f3j dodatek Hass.io AdGuard Home.", + "adguard_home_outdated": "Ta integracja wymaga AdGuard Home {minimal_version} lub nowszej wersji, masz {current_version}.", + "existing_instance_updated": "Zaktualizowano istniej\u0105c\u0105 konfiguracj\u0119.", + "single_instance_allowed": "Dozwolona jest tylko jedna konfiguracja AdGuard Home." + }, + "error": { + "connection_error": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia." + }, + "step": { + "hassio_confirm": { + "description": "Czy chcesz skonfigurowa\u0107 Home Assistant, aby po\u0142\u0105czy\u0142 si\u0119 z AdGuard Home przez dodatek Hass.io {addon}?", + "title": "AdGuard Home przez dodatek Hass.io" + }, + "user": { + "data": { + "host": "Host", + "password": "Has\u0142o", + "port": "Port", + "ssl": "AdGuard Home u\u017cywa certyfikatu SSL", + "username": "Nazwa u\u017cytkownika", + "verify_ssl": "AdGuard Home u\u017cywa odpowiedniego certyfikatu." + }, + "description": "Skonfiguruj instancj\u0119 AdGuard Home, aby umo\u017cliwi\u0107 monitorowanie i kontrol\u0119.", + "title": "Po\u0142\u0105cz AdGuard Home" + } + }, + "title": "AdGuard Home" + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/.translations/pt-BR.json b/homeassistant/components/adguard/.translations/pt-BR.json new file mode 100644 index 000000000..690947364 --- /dev/null +++ b/homeassistant/components/adguard/.translations/pt-BR.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "existing_instance_updated": "Configura\u00e7\u00e3o existente atualizada.", + "single_instance_allowed": "Apenas uma \u00fanica configura\u00e7\u00e3o do AdGuard Home \u00e9 permitida." + }, + "error": { + "connection_error": "Falhou ao conectar." + }, + "step": { + "hassio_confirm": { + "description": "Deseja configurar o Home Assistant para se conectar ao AdGuard Home fornecido pelo complemento Hass.io: {addon} ?", + "title": "AdGuard Home via add-on Hass.io" + }, + "user": { + "data": { + "host": "Host", + "password": "Senha", + "port": "Porta", + "ssl": "O AdGuard Home usa um certificado SSL", + "username": "Nome de usu\u00e1rio", + "verify_ssl": "O AdGuard Home usa um certificado apropriado" + }, + "description": "Configure sua inst\u00e2ncia do AdGuard Home para permitir o monitoramento e o controle.", + "title": "Vincule o seu AdGuard Home." + } + }, + "title": "AdGuard Home" + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/.translations/pt.json b/homeassistant/components/adguard/.translations/pt.json new file mode 100644 index 000000000..77ce7025f --- /dev/null +++ b/homeassistant/components/adguard/.translations/pt.json @@ -0,0 +1,14 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "Servidor", + "password": "Palavra-passe", + "port": "Porta", + "username": "Nome de Utilizador" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/.translations/ru.json b/homeassistant/components/adguard/.translations/ru.json new file mode 100644 index 000000000..eca46d7db --- /dev/null +++ b/homeassistant/components/adguard/.translations/ru.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "adguard_home_addon_outdated": "\u042d\u0442\u0430 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f \u043d\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442 AdGuard Home \u0432\u0435\u0440\u0441\u0438\u0438 {current_version}. \u0414\u043b\u044f \u043a\u043e\u0440\u0440\u0435\u043a\u0442\u043d\u043e\u0439 \u0440\u0430\u0431\u043e\u0442\u044b \u0442\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u0432\u0435\u0440\u0441\u0438\u044f {minimal_version}, \u0438\u043b\u0438 \u0431\u043e\u043b\u0435\u0435 \u043d\u043e\u0432\u0430\u044f. \u0414\u043b\u044f \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f \u043d\u043e\u0432\u043e\u0439 \u0432\u0435\u0440\u0441\u0438\u0438, \u043e\u0431\u043d\u043e\u0432\u0438\u0442\u0435 \u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0435 Hass.io.", + "adguard_home_outdated": "\u042d\u0442\u0430 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f \u043d\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442 AdGuard Home \u0432\u0435\u0440\u0441\u0438\u0438 {current_version}. \u0418\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0439\u0442\u0435 \u0432\u0435\u0440\u0441\u0438\u044e {minimal_version} \u0438\u043b\u0438 \u0431\u043e\u043b\u0435\u0435 \u043d\u043e\u0432\u0443\u044e.", + "existing_instance_updated": "\u041a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0430.", + "single_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430." + }, + "error": { + "connection_error": "\u041e\u0448\u0438\u0431\u043a\u0430 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f." + }, + "step": { + "hassio_confirm": { + "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043a AdGuard Home (\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0435 \u0434\u043b\u044f Hass.io \"{addon}\")?", + "title": "AdGuard Home (\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0435 \u0434\u043b\u044f Hass.io)" + }, + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "port": "\u041f\u043e\u0440\u0442", + "ssl": "AdGuard Home \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442 \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442 SSL", + "username": "\u041b\u043e\u0433\u0438\u043d", + "verify_ssl": "AdGuard Home \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442 \u0441\u043e\u0431\u0441\u0442\u0432\u0435\u043d\u043d\u044b\u0439 \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442" + }, + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 \u044d\u0442\u043e\u0442 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442 \u0434\u043b\u044f \u043c\u043e\u043d\u0438\u0442\u043e\u0440\u0438\u043d\u0433\u0430 \u0438 \u043a\u043e\u043d\u0442\u0440\u043e\u043b\u044f AdGuard Home.", + "title": "AdGuard Home" + } + }, + "title": "AdGuard Home" + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/.translations/sl.json b/homeassistant/components/adguard/.translations/sl.json new file mode 100644 index 000000000..974524c93 --- /dev/null +++ b/homeassistant/components/adguard/.translations/sl.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "adguard_home_addon_outdated": "Za to integracijo je potrebna AdGuard Home {minimal_version} ali vi\u0161ja, vi imate {current_version}. Prosimo posodobite va\u0161 hass.io AdGuard Home dodatek.", + "adguard_home_outdated": "Za to integracijo je potrebna AdGuard Home {minimal_version} ali vi\u0161ja, vi imate {current_version}.", + "existing_instance_updated": "Posodobljena obstoje\u010da konfiguracija.", + "single_instance_allowed": "Dovoljena je samo ena konfiguracija AdGuard Home." + }, + "error": { + "connection_error": "Povezava ni uspela." + }, + "step": { + "hassio_confirm": { + "description": "\u017delite konfigurirati Home Assistant-a za povezavo z AdGuard Home, ki ga ponuja Hass.io add-on {addon} ?", + "title": "AdGuard Home preko dodatka Hass.io" + }, + "user": { + "data": { + "host": "Gostitelj", + "password": "Geslo", + "port": "Vrata", + "ssl": "AdGuard Home uporablja SSL certifikat", + "username": "Uporabni\u0161ko ime", + "verify_ssl": "AdGuard Home uporablja ustrezen certifikat" + }, + "description": "Nastavite primerek AdGuard Home, da omogo\u010dite spremljanje in nadzor.", + "title": "Pove\u017eite svoj AdGuard Home." + } + }, + "title": "AdGuard Home" + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/.translations/sv.json b/homeassistant/components/adguard/.translations/sv.json new file mode 100644 index 000000000..22bd81e3e --- /dev/null +++ b/homeassistant/components/adguard/.translations/sv.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "existing_instance_updated": "Uppdaterade existerande konfiguration.", + "single_instance_allowed": "Endast en enda konfiguration av AdGuard Home \u00e4r till\u00e5ten." + }, + "error": { + "connection_error": "Det gick inte att ansluta." + }, + "step": { + "hassio_confirm": { + "description": "Vill du konfigurera Home Assistant f\u00f6r att ansluta till AdGuard Home som tillhandah\u00e5lls av Hass.io Add-on: {addon}?", + "title": "AdGuard Home via Hass.io-till\u00e4gget" + }, + "user": { + "data": { + "host": "V\u00e4rd", + "password": "L\u00f6senord", + "port": "Port", + "ssl": "AdGuard Home anv\u00e4nder ett SSL-certifikat", + "username": "Anv\u00e4ndarnamn", + "verify_ssl": "AdGuard Home anv\u00e4nder ett korrekt certifikat" + }, + "description": "St\u00e4ll in din AdGuard Home-instans f\u00f6r att till\u00e5ta \u00f6vervakning och kontroll.", + "title": "L\u00e4nka din AdGuard Home." + } + }, + "title": "AdGuard Home" + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/.translations/vi.json b/homeassistant/components/adguard/.translations/vi.json new file mode 100644 index 000000000..1b76fef56 --- /dev/null +++ b/homeassistant/components/adguard/.translations/vi.json @@ -0,0 +1,14 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "\u0110\u1ecba ch\u1ec9", + "password": "M\u1eadt kh\u1ea9u", + "port": "C\u1ed5ng", + "username": "T\u00ean \u0111\u0103ng nh\u1eadp" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/.translations/zh-Hans.json b/homeassistant/components/adguard/.translations/zh-Hans.json new file mode 100644 index 000000000..7c52a9d1a --- /dev/null +++ b/homeassistant/components/adguard/.translations/zh-Hans.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "existing_instance_updated": "\u66f4\u65b0\u4e86\u73b0\u6709\u914d\u7f6e\u3002" + }, + "step": { + "user": { + "data": { + "password": "\u5bc6\u7801", + "port": "\u7aef\u53e3", + "username": "\u7528\u6237\u540d" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/.translations/zh-Hant.json b/homeassistant/components/adguard/.translations/zh-Hant.json new file mode 100644 index 000000000..d08a5715a --- /dev/null +++ b/homeassistant/components/adguard/.translations/zh-Hant.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "adguard_home_addon_outdated": "\u6574\u5408\u9700\u8981 AdGuard Home {minimal_version} \u6216\u66f4\u65b0\u7248\u672c\uff0c\u60a8\u76ee\u524d\u4f7f\u7528\u7248\u672c\u70ba {current_version}\u3002\u8acb\u66f4\u65b0 Hass.io AdGuard Home \u5143\u4ef6\u3002", + "adguard_home_outdated": "\u6574\u5408\u9700\u8981 AdGuard Home {minimal_version} \u6216\u66f4\u65b0\u7248\u672c\uff0c\u60a8\u76ee\u524d\u4f7f\u7528\u7248\u672c\u70ba {current_version}\u3002", + "existing_instance_updated": "\u5df2\u66f4\u65b0\u73fe\u6709\u8a2d\u5b9a\u3002", + "single_instance_allowed": "\u50c5\u5141\u8a31\u8a2d\u5b9a\u4e00\u7d44 AdGuard Home\u3002" + }, + "error": { + "connection_error": "\u9023\u7dda\u5931\u6557\u3002" + }, + "step": { + "hassio_confirm": { + "description": "\u662f\u5426\u8981\u8a2d\u5b9a Home Assistant \u4ee5\u4f7f\u7528 Hass.io \u9644\u52a0\u7d44\u4ef6\uff1a{addon} \u9023\u7dda\u81f3 AdGuard Home\uff1f", + "title": "\u4f7f\u7528 Hass.io \u9644\u52a0\u7d44\u4ef6 AdGuard Home" + }, + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef", + "password": "\u5bc6\u78bc", + "port": "\u901a\u8a0a\u57e0", + "ssl": "AdGuard Home \u4f7f\u7528 SSL \u8a8d\u8b49", + "username": "\u4f7f\u7528\u8005\u540d\u7a31", + "verify_ssl": "AdGuard Home \u4f7f\u7528\u5c0d\u61c9\u8a8d\u8b49" + }, + "description": "\u8a2d\u5b9a AdGuard Home \u4ee5\u9032\u884c\u76e3\u63a7\u3002", + "title": "\u9023\u7d50 AdGuard Home\u3002" + } + }, + "title": "AdGuard Home" + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/__init__.py b/homeassistant/components/adguard/__init__.py new file mode 100644 index 000000000..bb53d00aa --- /dev/null +++ b/homeassistant/components/adguard/__init__.py @@ -0,0 +1,199 @@ +"""Support for AdGuard Home.""" +from distutils.version import LooseVersion +import logging +from typing import Any, Dict + +from adguardhome import AdGuardHome, AdGuardHomeConnectionError, AdGuardHomeError +import voluptuous as vol + +from homeassistant.components.adguard.const import ( + CONF_FORCE, + DATA_ADGUARD_CLIENT, + DATA_ADGUARD_VERION, + DOMAIN, + MIN_ADGUARD_HOME_VERSION, + SERVICE_ADD_URL, + SERVICE_DISABLE_URL, + SERVICE_ENABLE_URL, + SERVICE_REFRESH, + SERVICE_REMOVE_URL, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_URL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import ConfigType, HomeAssistantType + +_LOGGER = logging.getLogger(__name__) + +SERVICE_URL_SCHEMA = vol.Schema({vol.Required(CONF_URL): cv.url}) +SERVICE_ADD_URL_SCHEMA = vol.Schema( + {vol.Required(CONF_NAME): cv.string, vol.Required(CONF_URL): cv.url} +) +SERVICE_REFRESH_SCHEMA = vol.Schema( + {vol.Optional(CONF_FORCE, default=False): cv.boolean} +) + + +async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: + """Set up the AdGuard Home components.""" + return True + + +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: + """Set up AdGuard Home from a config entry.""" + session = async_get_clientsession(hass, entry.data[CONF_VERIFY_SSL]) + adguard = AdGuardHome( + entry.data[CONF_HOST], + port=entry.data[CONF_PORT], + username=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + tls=entry.data[CONF_SSL], + verify_ssl=entry.data[CONF_VERIFY_SSL], + loop=hass.loop, + session=session, + ) + + hass.data.setdefault(DOMAIN, {})[DATA_ADGUARD_CLIENT] = adguard + + try: + version = await adguard.version() + except AdGuardHomeConnectionError as exception: + raise ConfigEntryNotReady from exception + + if LooseVersion(MIN_ADGUARD_HOME_VERSION) > LooseVersion(version): + _LOGGER.error( + "This integration requires AdGuard Home v0.99.0 or higher to work correctly" + ) + raise ConfigEntryNotReady + + for component in "sensor", "switch": + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + async def add_url(call) -> None: + """Service call to add a new filter subscription to AdGuard Home.""" + await adguard.filtering.add_url( + call.data.get(CONF_NAME), call.data.get(CONF_URL) + ) + + async def remove_url(call) -> None: + """Service call to remove a filter subscription from AdGuard Home.""" + await adguard.filtering.remove_url(call.data.get(CONF_URL)) + + async def enable_url(call) -> None: + """Service call to enable a filter subscription in AdGuard Home.""" + await adguard.filtering.enable_url(call.data.get(CONF_URL)) + + async def disable_url(call) -> None: + """Service call to disable a filter subscription in AdGuard Home.""" + await adguard.filtering.disable_url(call.data.get(CONF_URL)) + + async def refresh(call) -> None: + """Service call to refresh the filter subscriptions in AdGuard Home.""" + await adguard.filtering.refresh(call.data.get(CONF_FORCE)) + + hass.services.async_register( + DOMAIN, SERVICE_ADD_URL, add_url, schema=SERVICE_ADD_URL_SCHEMA + ) + hass.services.async_register( + DOMAIN, SERVICE_REMOVE_URL, remove_url, schema=SERVICE_URL_SCHEMA + ) + hass.services.async_register( + DOMAIN, SERVICE_ENABLE_URL, enable_url, schema=SERVICE_URL_SCHEMA + ) + hass.services.async_register( + DOMAIN, SERVICE_DISABLE_URL, disable_url, schema=SERVICE_URL_SCHEMA + ) + hass.services.async_register( + DOMAIN, SERVICE_REFRESH, refresh, schema=SERVICE_REFRESH_SCHEMA + ) + + return True + + +async def async_unload_entry(hass: HomeAssistantType, entry: ConfigType) -> bool: + """Unload AdGuard Home config entry.""" + hass.services.async_remove(DOMAIN, SERVICE_ADD_URL) + hass.services.async_remove(DOMAIN, SERVICE_REMOVE_URL) + hass.services.async_remove(DOMAIN, SERVICE_ENABLE_URL) + hass.services.async_remove(DOMAIN, SERVICE_DISABLE_URL) + hass.services.async_remove(DOMAIN, SERVICE_REFRESH) + + for component in "sensor", "switch": + await hass.config_entries.async_forward_entry_unload(entry, component) + + del hass.data[DOMAIN] + + return True + + +class AdGuardHomeEntity(Entity): + """Defines a base AdGuard Home entity.""" + + def __init__(self, adguard, name: str, icon: str) -> None: + """Initialize the AdGuard Home entity.""" + self._name = name + self._icon = icon + self._available = True + self.adguard = adguard + + @property + def name(self) -> str: + """Return the name of the entity.""" + return self._name + + @property + def icon(self) -> str: + """Return the mdi icon of the entity.""" + return self._icon + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._available + + async def async_update(self) -> None: + """Update AdGuard Home entity.""" + try: + await self._adguard_update() + self._available = True + except AdGuardHomeError: + if self._available: + _LOGGER.debug( + "An error occurred while updating AdGuard Home sensor.", + exc_info=True, + ) + self._available = False + + async def _adguard_update(self) -> None: + """Update AdGuard Home entity.""" + raise NotImplementedError() + + +class AdGuardHomeDeviceEntity(AdGuardHomeEntity): + """Defines a AdGuard Home device entity.""" + + @property + def device_info(self) -> Dict[str, Any]: + """Return device information about this AdGuard Home instance.""" + return { + "identifiers": { + (DOMAIN, self.adguard.host, self.adguard.port, self.adguard.base_path) + }, + "name": "AdGuard Home", + "manufacturer": "AdGuard Team", + "sw_version": self.hass.data[DOMAIN].get(DATA_ADGUARD_VERION), + } diff --git a/homeassistant/components/adguard/config_flow.py b/homeassistant/components/adguard/config_flow.py new file mode 100644 index 000000000..9f5645edb --- /dev/null +++ b/homeassistant/components/adguard/config_flow.py @@ -0,0 +1,193 @@ +"""Config flow to configure the AdGuard Home integration.""" +from distutils.version import LooseVersion +import logging + +from adguardhome import AdGuardHome, AdGuardHomeConnectionError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.adguard.const import DOMAIN, MIN_ADGUARD_HOME_VERSION +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +_LOGGER = logging.getLogger(__name__) + + +@config_entries.HANDLERS.register(DOMAIN) +class AdGuardHomeFlowHandler(ConfigFlow): + """Handle a AdGuard Home config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + _hassio_discovery = None + + def __init__(self): + """Initialize AgGuard Home flow.""" + pass + + async def _show_setup_form(self, errors=None): + """Show the setup form to the user.""" + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=3000): vol.Coerce(int), + vol.Optional(CONF_USERNAME): str, + vol.Optional(CONF_PASSWORD): str, + vol.Required(CONF_SSL, default=True): bool, + vol.Required(CONF_VERIFY_SSL, default=True): bool, + } + ), + errors=errors or {}, + ) + + async def _show_hassio_form(self, errors=None): + """Show the Hass.io confirmation form to the user.""" + return self.async_show_form( + step_id="hassio_confirm", + description_placeholders={"addon": self._hassio_discovery["addon"]}, + data_schema=vol.Schema({}), + errors=errors or {}, + ) + + async def async_step_user(self, user_input=None): + """Handle a flow initiated by the user.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + if user_input is None: + return await self._show_setup_form(user_input) + + errors = {} + + session = async_get_clientsession(self.hass, user_input[CONF_VERIFY_SSL]) + + adguard = AdGuardHome( + user_input[CONF_HOST], + port=user_input[CONF_PORT], + username=user_input.get(CONF_USERNAME), + password=user_input.get(CONF_PASSWORD), + tls=user_input[CONF_SSL], + verify_ssl=user_input[CONF_VERIFY_SSL], + loop=self.hass.loop, + session=session, + ) + + try: + version = await adguard.version() + except AdGuardHomeConnectionError: + errors["base"] = "connection_error" + return await self._show_setup_form(errors) + + if LooseVersion(MIN_ADGUARD_HOME_VERSION) > LooseVersion(version): + return self.async_abort( + reason="adguard_home_outdated", + description_placeholders={ + "current_version": version, + "minimal_version": MIN_ADGUARD_HOME_VERSION, + }, + ) + + return self.async_create_entry( + title=user_input[CONF_HOST], + data={ + CONF_HOST: user_input[CONF_HOST], + CONF_PASSWORD: user_input.get(CONF_PASSWORD), + CONF_PORT: user_input[CONF_PORT], + CONF_SSL: user_input[CONF_SSL], + CONF_USERNAME: user_input.get(CONF_USERNAME), + CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL], + }, + ) + + async def async_step_hassio(self, user_input=None): + """Prepare configuration for a Hass.io AdGuard Home add-on. + + This flow is triggered by the discovery component. + """ + entries = self._async_current_entries() + + if not entries: + self._hassio_discovery = user_input + return await self.async_step_hassio_confirm() + + cur_entry = entries[0] + + if ( + cur_entry.data[CONF_HOST] == user_input[CONF_HOST] + and cur_entry.data[CONF_PORT] == user_input[CONF_PORT] + ): + return self.async_abort(reason="single_instance_allowed") + + is_loaded = cur_entry.state == config_entries.ENTRY_STATE_LOADED + + if is_loaded: + await self.hass.config_entries.async_unload(cur_entry.entry_id) + + self.hass.config_entries.async_update_entry( + cur_entry, + data={ + **cur_entry.data, + CONF_HOST: user_input[CONF_HOST], + CONF_PORT: user_input[CONF_PORT], + }, + ) + + if is_loaded: + await self.hass.config_entries.async_setup(cur_entry.entry_id) + + return self.async_abort(reason="existing_instance_updated") + + async def async_step_hassio_confirm(self, user_input=None): + """Confirm Hass.io discovery.""" + if user_input is None: + return await self._show_hassio_form() + + errors = {} + + session = async_get_clientsession(self.hass, False) + + adguard = AdGuardHome( + self._hassio_discovery[CONF_HOST], + port=self._hassio_discovery[CONF_PORT], + tls=False, + loop=self.hass.loop, + session=session, + ) + + try: + version = await adguard.version() + except AdGuardHomeConnectionError: + errors["base"] = "connection_error" + return await self._show_hassio_form(errors) + + if LooseVersion(MIN_ADGUARD_HOME_VERSION) > LooseVersion(version): + return self.async_abort( + reason="adguard_home_addon_outdated", + description_placeholders={ + "current_version": version, + "minimal_version": MIN_ADGUARD_HOME_VERSION, + }, + ) + + return self.async_create_entry( + title=self._hassio_discovery["addon"], + data={ + CONF_HOST: self._hassio_discovery[CONF_HOST], + CONF_PORT: self._hassio_discovery[CONF_PORT], + CONF_PASSWORD: None, + CONF_SSL: False, + CONF_USERNAME: None, + CONF_VERIFY_SSL: True, + }, + ) diff --git a/homeassistant/components/adguard/const.py b/homeassistant/components/adguard/const.py new file mode 100644 index 000000000..eb12a9c16 --- /dev/null +++ b/homeassistant/components/adguard/const.py @@ -0,0 +1,16 @@ +"""Constants for the AdGuard Home integration.""" + +DOMAIN = "adguard" + +DATA_ADGUARD_CLIENT = "adguard_client" +DATA_ADGUARD_VERION = "adguard_version" + +CONF_FORCE = "force" + +MIN_ADGUARD_HOME_VERSION = "v0.99.0" + +SERVICE_ADD_URL = "add_url" +SERVICE_DISABLE_URL = "disable_url" +SERVICE_ENABLE_URL = "enable_url" +SERVICE_REFRESH = "refresh" +SERVICE_REMOVE_URL = "remove_url" diff --git a/homeassistant/components/adguard/manifest.json b/homeassistant/components/adguard/manifest.json new file mode 100644 index 000000000..45fd21f4f --- /dev/null +++ b/homeassistant/components/adguard/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "adguard", + "name": "AdGuard Home", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/adguard", + "requirements": [ + "adguardhome==0.3.0" + ], + "dependencies": [], + "codeowners": [ + "@frenck" + ] +} \ No newline at end of file diff --git a/homeassistant/components/adguard/sensor.py b/homeassistant/components/adguard/sensor.py new file mode 100644 index 000000000..e0c86e42d --- /dev/null +++ b/homeassistant/components/adguard/sensor.py @@ -0,0 +1,222 @@ +"""Support for AdGuard Home sensors.""" +from datetime import timedelta +import logging + +from adguardhome import AdGuardHomeConnectionError + +from homeassistant.components.adguard import AdGuardHomeDeviceEntity +from homeassistant.components.adguard.const import ( + DATA_ADGUARD_CLIENT, + DATA_ADGUARD_VERION, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.typing import HomeAssistantType + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=300) +PARALLEL_UPDATES = 4 + + +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: + """Set up AdGuard Home sensor based on a config entry.""" + adguard = hass.data[DOMAIN][DATA_ADGUARD_CLIENT] + + try: + version = await adguard.version() + except AdGuardHomeConnectionError as exception: + raise PlatformNotReady from exception + + hass.data[DOMAIN][DATA_ADGUARD_VERION] = version + + sensors = [ + AdGuardHomeDNSQueriesSensor(adguard), + AdGuardHomeBlockedFilteringSensor(adguard), + AdGuardHomePercentageBlockedSensor(adguard), + AdGuardHomeReplacedParentalSensor(adguard), + AdGuardHomeReplacedSafeBrowsingSensor(adguard), + AdGuardHomeReplacedSafeSearchSensor(adguard), + AdGuardHomeAverageProcessingTimeSensor(adguard), + AdGuardHomeRulesCountSensor(adguard), + ] + + async_add_entities(sensors, True) + + +class AdGuardHomeSensor(AdGuardHomeDeviceEntity): + """Defines a AdGuard Home sensor.""" + + def __init__( + self, adguard, name: str, icon: str, measurement: str, unit_of_measurement: str + ) -> None: + """Initialize AdGuard Home sensor.""" + self._state = None + self._unit_of_measurement = unit_of_measurement + self.measurement = measurement + + super().__init__(adguard, name, icon) + + @property + def unique_id(self) -> str: + """Return the unique ID for this sensor.""" + return "_".join( + [ + DOMAIN, + self.adguard.host, + str(self.adguard.port), + "sensor", + self.measurement, + ] + ) + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self) -> str: + """Return the unit this state is expressed in.""" + return self._unit_of_measurement + + +class AdGuardHomeDNSQueriesSensor(AdGuardHomeSensor): + """Defines a AdGuard Home DNS Queries sensor.""" + + def __init__(self, adguard): + """Initialize AdGuard Home sensor.""" + super().__init__( + adguard, "AdGuard DNS Queries", "mdi:magnify", "dns_queries", "queries" + ) + + async def _adguard_update(self) -> None: + """Update AdGuard Home entity.""" + self._state = await self.adguard.stats.dns_queries() + + +class AdGuardHomeBlockedFilteringSensor(AdGuardHomeSensor): + """Defines a AdGuard Home blocked by filtering sensor.""" + + def __init__(self, adguard): + """Initialize AdGuard Home sensor.""" + super().__init__( + adguard, + "AdGuard DNS Queries Blocked", + "mdi:magnify-close", + "blocked_filtering", + "queries", + ) + + async def _adguard_update(self) -> None: + """Update AdGuard Home entity.""" + self._state = await self.adguard.stats.blocked_filtering() + + +class AdGuardHomePercentageBlockedSensor(AdGuardHomeSensor): + """Defines a AdGuard Home blocked percentage sensor.""" + + def __init__(self, adguard): + """Initialize AdGuard Home sensor.""" + super().__init__( + adguard, + "AdGuard DNS Queries Blocked Ratio", + "mdi:magnify-close", + "blocked_percentage", + "%", + ) + + async def _adguard_update(self) -> None: + """Update AdGuard Home entity.""" + percentage = await self.adguard.stats.blocked_percentage() + self._state = f"{percentage:.2f}" + + +class AdGuardHomeReplacedParentalSensor(AdGuardHomeSensor): + """Defines a AdGuard Home replaced by parental control sensor.""" + + def __init__(self, adguard): + """Initialize AdGuard Home sensor.""" + super().__init__( + adguard, + "AdGuard Parental Control Blocked", + "mdi:human-male-girl", + "blocked_parental", + "requests", + ) + + async def _adguard_update(self) -> None: + """Update AdGuard Home entity.""" + self._state = await self.adguard.stats.replaced_parental() + + +class AdGuardHomeReplacedSafeBrowsingSensor(AdGuardHomeSensor): + """Defines a AdGuard Home replaced by safe browsing sensor.""" + + def __init__(self, adguard): + """Initialize AdGuard Home sensor.""" + super().__init__( + adguard, + "AdGuard Safe Browsing Blocked", + "mdi:shield-half-full", + "blocked_safebrowsing", + "requests", + ) + + async def _adguard_update(self) -> None: + """Update AdGuard Home entity.""" + self._state = await self.adguard.stats.replaced_safebrowsing() + + +class AdGuardHomeReplacedSafeSearchSensor(AdGuardHomeSensor): + """Defines a AdGuard Home replaced by safe search sensor.""" + + def __init__(self, adguard): + """Initialize AdGuard Home sensor.""" + super().__init__( + adguard, + "Searches Safe Search Enforced", + "mdi:shield-search", + "enforced_safesearch", + "requests", + ) + + async def _adguard_update(self) -> None: + """Update AdGuard Home entity.""" + self._state = await self.adguard.stats.replaced_safesearch() + + +class AdGuardHomeAverageProcessingTimeSensor(AdGuardHomeSensor): + """Defines a AdGuard Home average processing time sensor.""" + + def __init__(self, adguard): + """Initialize AdGuard Home sensor.""" + super().__init__( + adguard, + "AdGuard Average Processing Speed", + "mdi:speedometer", + "average_speed", + "ms", + ) + + async def _adguard_update(self) -> None: + """Update AdGuard Home entity.""" + average = await self.adguard.stats.avg_processing_time() + self._state = f"{average:.2f}" + + +class AdGuardHomeRulesCountSensor(AdGuardHomeSensor): + """Defines a AdGuard Home rules count sensor.""" + + def __init__(self, adguard): + """Initialize AdGuard Home sensor.""" + super().__init__( + adguard, "AdGuard Rules Count", "mdi:counter", "rules_count", "rules" + ) + + async def _adguard_update(self) -> None: + """Update AdGuard Home entity.""" + self._state = await self.adguard.filtering.rules_count() diff --git a/homeassistant/components/adguard/services.yaml b/homeassistant/components/adguard/services.yaml new file mode 100644 index 000000000..736acdd92 --- /dev/null +++ b/homeassistant/components/adguard/services.yaml @@ -0,0 +1,37 @@ +add_url: + description: Add a new filter subscription to AdGuard Home. + fields: + name: + description: The name of the filter subscription. + example: Example + url: + description: The filter URL to subscribe to, containing the filter rules. + example: https://www.example.com/filter/1.txt + +remove_url: + description: Removes a filter subscription from AdGuard Home. + fields: + url: + description: The filter subscription URL to remove. + example: https://www.example.com/filter/1.txt + +enable_url: + description: Enables a filter subscription in AdGuard Home. + fields: + url: + description: The filter subscription URL to enable. + example: https://www.example.com/filter/1.txt + +disable_url: + description: Disables a filter subscription in AdGuard Home. + fields: + url: + description: The filter subscription URL to disable. + example: https://www.example.com/filter/1.txt + +refresh: + description: Refresh all filter subscriptions in AdGuard Home. + fields: + force: + description: Force update (by passes AdGuard Home throttling). + example: '"true" to force, "false" or omit for a regular refresh.' diff --git a/homeassistant/components/adguard/strings.json b/homeassistant/components/adguard/strings.json new file mode 100644 index 000000000..d33ba2b39 --- /dev/null +++ b/homeassistant/components/adguard/strings.json @@ -0,0 +1,32 @@ +{ + "config": { + "title": "AdGuard Home", + "step": { + "user": { + "title": "Link your AdGuard Home.", + "description": "Set up your AdGuard Home instance to allow monitoring and control.", + "data": { + "host": "Host", + "password": "Password", + "port": "Port", + "username": "Username", + "ssl": "AdGuard Home uses a SSL certificate", + "verify_ssl": "AdGuard Home uses a proper certificate" + } + }, + "hassio_confirm": { + "title": "AdGuard Home via Hass.io add-on", + "description": "Do you want to configure Home Assistant to connect to the AdGuard Home provided by the Hass.io add-on: {addon}?" + } + }, + "error": { + "connection_error": "Failed to connect." + }, + "abort": { + "adguard_home_outdated": "This integration requires AdGuard Home {minimal_version} or higher, you have {current_version}.", + "adguard_home_addon_outdated": "This integration requires AdGuard Home {minimal_version} or higher, you have {current_version}. Please update your Hass.io AdGuard Home add-on.", + "existing_instance_updated": "Updated existing configuration.", + "single_instance_allowed": "Only a single configuration of AdGuard Home is allowed." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/switch.py b/homeassistant/components/adguard/switch.py new file mode 100644 index 000000000..39cd1ef02 --- /dev/null +++ b/homeassistant/components/adguard/switch.py @@ -0,0 +1,219 @@ +"""Support for AdGuard Home switches.""" +from datetime import timedelta +import logging + +from adguardhome import AdGuardHomeConnectionError, AdGuardHomeError + +from homeassistant.components.adguard import AdGuardHomeDeviceEntity +from homeassistant.components.adguard.const import ( + DATA_ADGUARD_CLIENT, + DATA_ADGUARD_VERION, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.entity import ToggleEntity +from homeassistant.helpers.typing import HomeAssistantType + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=10) +PARALLEL_UPDATES = 1 + + +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: + """Set up AdGuard Home switch based on a config entry.""" + adguard = hass.data[DOMAIN][DATA_ADGUARD_CLIENT] + + try: + version = await adguard.version() + except AdGuardHomeConnectionError as exception: + raise PlatformNotReady from exception + + hass.data[DOMAIN][DATA_ADGUARD_VERION] = version + + switches = [ + AdGuardHomeProtectionSwitch(adguard), + AdGuardHomeFilteringSwitch(adguard), + AdGuardHomeParentalSwitch(adguard), + AdGuardHomeSafeBrowsingSwitch(adguard), + AdGuardHomeSafeSearchSwitch(adguard), + AdGuardHomeQueryLogSwitch(adguard), + ] + async_add_entities(switches, True) + + +class AdGuardHomeSwitch(ToggleEntity, AdGuardHomeDeviceEntity): + """Defines a AdGuard Home switch.""" + + def __init__(self, adguard, name: str, icon: str, key: str): + """Initialize AdGuard Home switch.""" + self._state = False + self._key = key + super().__init__(adguard, name, icon) + + @property + def unique_id(self) -> str: + """Return the unique ID for this sensor.""" + return "_".join( + [DOMAIN, self.adguard.host, str(self.adguard.port), "switch", self._key] + ) + + @property + def is_on(self) -> bool: + """Return the state of the switch.""" + return self._state + + async def async_turn_off(self, **kwargs) -> None: + """Turn off the switch.""" + try: + await self._adguard_turn_off() + except AdGuardHomeError: + _LOGGER.error("An error occurred while turning off AdGuard Home switch.") + self._available = False + + async def _adguard_turn_off(self) -> None: + """Turn off the switch.""" + raise NotImplementedError() + + async def async_turn_on(self, **kwargs) -> None: + """Turn on the switch.""" + try: + await self._adguard_turn_on() + except AdGuardHomeError: + _LOGGER.error("An error occurred while turning on AdGuard Home switch.") + self._available = False + + async def _adguard_turn_on(self) -> None: + """Turn on the switch.""" + raise NotImplementedError() + + +class AdGuardHomeProtectionSwitch(AdGuardHomeSwitch): + """Defines a AdGuard Home protection switch.""" + + def __init__(self, adguard) -> None: + """Initialize AdGuard Home switch.""" + super().__init__( + adguard, "AdGuard Protection", "mdi:shield-check", "protection" + ) + + async def _adguard_turn_off(self) -> None: + """Turn off the switch.""" + await self.adguard.disable_protection() + + async def _adguard_turn_on(self) -> None: + """Turn on the switch.""" + await self.adguard.enable_protection() + + async def _adguard_update(self) -> None: + """Update AdGuard Home entity.""" + self._state = await self.adguard.protection_enabled() + + +class AdGuardHomeParentalSwitch(AdGuardHomeSwitch): + """Defines a AdGuard Home parental control switch.""" + + def __init__(self, adguard) -> None: + """Initialize AdGuard Home switch.""" + super().__init__( + adguard, "AdGuard Parental Control", "mdi:shield-check", "parental" + ) + + async def _adguard_turn_off(self) -> None: + """Turn off the switch.""" + await self.adguard.parental.disable() + + async def _adguard_turn_on(self) -> None: + """Turn on the switch.""" + await self.adguard.parental.enable() + + async def _adguard_update(self) -> None: + """Update AdGuard Home entity.""" + self._state = await self.adguard.parental.enabled() + + +class AdGuardHomeSafeSearchSwitch(AdGuardHomeSwitch): + """Defines a AdGuard Home safe search switch.""" + + def __init__(self, adguard) -> None: + """Initialize AdGuard Home switch.""" + super().__init__( + adguard, "AdGuard Safe Search", "mdi:shield-check", "safesearch" + ) + + async def _adguard_turn_off(self) -> None: + """Turn off the switch.""" + await self.adguard.safesearch.disable() + + async def _adguard_turn_on(self) -> None: + """Turn on the switch.""" + await self.adguard.safesearch.enable() + + async def _adguard_update(self) -> None: + """Update AdGuard Home entity.""" + self._state = await self.adguard.safesearch.enabled() + + +class AdGuardHomeSafeBrowsingSwitch(AdGuardHomeSwitch): + """Defines a AdGuard Home safe search switch.""" + + def __init__(self, adguard) -> None: + """Initialize AdGuard Home switch.""" + super().__init__( + adguard, "AdGuard Safe Browsing", "mdi:shield-check", "safebrowsing" + ) + + async def _adguard_turn_off(self) -> None: + """Turn off the switch.""" + await self.adguard.safebrowsing.disable() + + async def _adguard_turn_on(self) -> None: + """Turn on the switch.""" + await self.adguard.safebrowsing.enable() + + async def _adguard_update(self) -> None: + """Update AdGuard Home entity.""" + self._state = await self.adguard.safebrowsing.enabled() + + +class AdGuardHomeFilteringSwitch(AdGuardHomeSwitch): + """Defines a AdGuard Home filtering switch.""" + + def __init__(self, adguard) -> None: + """Initialize AdGuard Home switch.""" + super().__init__(adguard, "AdGuard Filtering", "mdi:shield-check", "filtering") + + async def _adguard_turn_off(self) -> None: + """Turn off the switch.""" + await self.adguard.filtering.disable() + + async def _adguard_turn_on(self) -> None: + """Turn on the switch.""" + await self.adguard.filtering.enable() + + async def _adguard_update(self) -> None: + """Update AdGuard Home entity.""" + self._state = await self.adguard.filtering.enabled() + + +class AdGuardHomeQueryLogSwitch(AdGuardHomeSwitch): + """Defines a AdGuard Home query log switch.""" + + def __init__(self, adguard) -> None: + """Initialize AdGuard Home switch.""" + super().__init__(adguard, "AdGuard Query Log", "mdi:shield-check", "querylog") + + async def _adguard_turn_off(self) -> None: + """Turn off the switch.""" + await self.adguard.querylog.disable() + + async def _adguard_turn_on(self) -> None: + """Turn on the switch.""" + await self.adguard.querylog.enable() + + async def _adguard_update(self) -> None: + """Update AdGuard Home entity.""" + self._state = await self.adguard.querylog.enabled() diff --git a/homeassistant/components/ads/__init__.py b/homeassistant/components/ads/__init__.py index 100444c02..adaaaa08b 100644 --- a/homeassistant/components/ads/__init__.py +++ b/homeassistant/components/ads/__init__.py @@ -1,60 +1,85 @@ -""" -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 +"""Support for Automation Device Specification (ADS).""" +import asyncio 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 +import ctypes +import logging +import struct +import threading -REQUIREMENTS = ['pyads==2.2.6'] +import async_timeout +import pyads +import voluptuous as vol + +from homeassistant.const import ( + CONF_DEVICE, + CONF_IP_ADDRESS, + CONF_PORT, + EVENT_HOMEASSISTANT_STOP, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) -DATA_ADS = 'data_ads' +DATA_ADS = "data_ads" # Supported Types -ADSTYPE_INT = 'int' -ADSTYPE_UINT = 'uint' -ADSTYPE_BYTE = 'byte' -ADSTYPE_BOOL = 'bool' +ADSTYPE_BOOL = "bool" +ADSTYPE_BYTE = "byte" +ADSTYPE_DINT = "dint" +ADSTYPE_INT = "int" +ADSTYPE_UDINT = "udint" +ADSTYPE_UINT = "uint" -DOMAIN = 'ads' +CONF_ADS_FACTOR = "factor" +CONF_ADS_TYPE = "adstype" +CONF_ADS_VALUE = "value" +CONF_ADS_VAR = "adsvar" +CONF_ADS_VAR_BRIGHTNESS = "adsvar_brightness" +CONF_ADS_VAR_POSITION = "adsvar_position" -CONF_ADS_VAR = 'adsvar' -CONF_ADS_VAR_BRIGHTNESS = 'adsvar_brightness' -CONF_ADS_TYPE = 'adstype' -CONF_ADS_FACTOR = 'factor' -CONF_ADS_VALUE = 'value' +STATE_KEY_STATE = "state" +STATE_KEY_BRIGHTNESS = "brightness" +STATE_KEY_POSITION = "position" -SERVICE_WRITE_DATA_BY_NAME = 'write_data_by_name' +DOMAIN = "ads" -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) +SERVICE_WRITE_DATA_BY_NAME = "write_data_by_name" -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, -}) +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, + ADSTYPE_BOOL, + ADSTYPE_DINT, + ADSTYPE_UDINT, + ] + ), + vol.Required(CONF_ADS_VALUE): vol.Coerce(int), + 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) @@ -66,21 +91,29 @@ def setup(hass, config): AdsHub.ADS_TYPEMAP = { ADSTYPE_BOOL: pyads.PLCTYPE_BOOL, ADSTYPE_BYTE: pyads.PLCTYPE_BYTE, + ADSTYPE_DINT: pyads.PLCTYPE_DINT, ADSTYPE_INT: pyads.PLCTYPE_INT, + ADSTYPE_UDINT: pyads.PLCTYPE_UDINT, ADSTYPE_UINT: pyads.PLCTYPE_UINT, } + AdsHub.ADSError = pyads.ADSError AdsHub.PLCTYPE_BOOL = pyads.PLCTYPE_BOOL AdsHub.PLCTYPE_BYTE = pyads.PLCTYPE_BYTE + AdsHub.PLCTYPE_DINT = pyads.PLCTYPE_DINT AdsHub.PLCTYPE_INT = pyads.PLCTYPE_INT + AdsHub.PLCTYPE_UDINT = pyads.PLCTYPE_UDINT AdsHub.PLCTYPE_UINT = pyads.PLCTYPE_UINT - AdsHub.ADSError = pyads.ADSError try: ads = AdsHub(client) - except pyads.pyads.ADSError: + except pyads.ADSError: _LOGGER.error( - "Could not connect to ADS host (netid=%s, port=%s)", net_id, port) + "Could not connect to ADS host (netid=%s, ip=%s, port=%s)", + net_id, + ip_address, + port, + ) return False hass.data[DATA_ADS] = ads @@ -98,15 +131,18 @@ def setup(hass, config): _LOGGER.error(err) hass.services.register( - DOMAIN, SERVICE_WRITE_DATA_BY_NAME, handle_write_data_by_name, - schema=SCHEMA_SERVICE_WRITE_DATA_BY_NAME) + 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' + "NotificationItem", "hnotify huser name plc_datatype callback" ) @@ -125,16 +161,24 @@ class AdsHub: 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() + notification_item.hnotify, + notification_item.huser, + ) + try: + self._client.del_device_notification( + notification_item.hnotify, notification_item.huser + ) + except pyads.ADSError as err: + _LOGGER.error(err) + try: + self._client.close() + except pyads.ADSError as err: + _LOGGER.error(err) def register_device(self, device): """Register a new device.""" @@ -142,31 +186,45 @@ class AdsHub: 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) + try: + return self._client.write_by_name(name, value, plc_datatype) + except pyads.ADSError as err: + _LOGGER.error("Error writing %s: %s", name, err) 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) + try: + return self._client.read_by_name(name, plc_datatype) + except pyads.ADSError as err: + _LOGGER.error("Error reading %s: %s", name, err) 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)) + + attr = pyads.NotificationAttrib(ctypes.sizeof(plc_datatype)) with self._lock: - hnotify, huser = self._client.add_device_notification( - name, attr, self._device_notification_callback) - hnotify = int(hnotify) + try: + hnotify, huser = self._client.add_device_notification( + name, attr, self._device_notification_callback + ) + except pyads.ADSError as err: + _LOGGER.error("Error subscribing to %s: %s", name, err) + else: + hnotify = int(hnotify) + self._notification_items[hnotify] = NotificationItem( + hnotify, huser, name, plc_datatype, callback + ) - _LOGGER.debug( - "Added device notification %d for variable %s", hnotify, name) + _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): + def _device_notification_callback(self, notification, name): """Handle device notifications.""" contents = notification.contents @@ -175,22 +233,93 @@ class AdsHub: data = contents.data try: - notification_item = self._notification_items[hnotify] + with self._lock: + notification_item = self._notification_items[hnotify] except KeyError: - _LOGGER.debug("Unknown device notification handle: %d", hnotify) + _LOGGER.error("Unknown device notification handle: %d", hnotify) return # Parse data to desired datatype if notification_item.plc_datatype == self.PLCTYPE_BOOL: - value = bool(struct.unpack(' bool: + """Set up configured Airly.""" + hass.data[DOMAIN] = {} + hass.data[DOMAIN][DATA_CLIENT] = {} + return True + + +async def async_setup_entry(hass, config_entry): + """Set up Airly as config entry.""" + api_key = config_entry.data[CONF_API_KEY] + latitude = config_entry.data[CONF_LATITUDE] + longitude = config_entry.data[CONF_LONGITUDE] + + websession = async_get_clientsession(hass) + + airly = AirlyData(websession, api_key, latitude, longitude) + + await airly.async_update() + + hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] = airly + + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, "air_quality") + ) + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, "sensor") + ) + return True + + +async def async_unload_entry(hass, config_entry): + """Unload a config entry.""" + hass.data[DOMAIN][DATA_CLIENT].pop(config_entry.entry_id) + await hass.config_entries.async_forward_entry_unload(config_entry, "air_quality") + await hass.config_entries.async_forward_entry_unload(config_entry, "sensor") + return True + + +class AirlyData: + """Define an object to hold Airly data.""" + + def __init__(self, session, api_key, latitude, longitude): + """Initialize.""" + self.latitude = latitude + self.longitude = longitude + self.airly = Airly(api_key, session) + self.data = {} + + @Throttle(DEFAULT_SCAN_INTERVAL) + async def async_update(self): + """Update Airly data.""" + + try: + with async_timeout.timeout(20): + measurements = self.airly.create_measurements_session_point( + self.latitude, self.longitude + ) + await measurements.update() + + values = measurements.current["values"] + index = measurements.current["indexes"][0] + standards = measurements.current["standards"] + + if index["description"] == NO_AIRLY_SENSORS: + _LOGGER.error("Can't retrieve data: no Airly sensors in this area") + return + for value in values: + self.data[value["name"]] = value["value"] + for standard in standards: + self.data[f"{standard['pollutant']}_LIMIT"] = standard["limit"] + self.data[f"{standard['pollutant']}_PERCENT"] = standard["percent"] + self.data[ATTR_API_CAQI] = index["value"] + self.data[ATTR_API_CAQI_LEVEL] = index["level"].lower().replace("_", " ") + self.data[ATTR_API_CAQI_DESCRIPTION] = index["description"] + self.data[ATTR_API_ADVICE] = index["advice"] + _LOGGER.debug("Data retrieved from Airly") + except asyncio.TimeoutError: + _LOGGER.error("Asyncio Timeout Error") + except (ValueError, AirlyError, ClientConnectorError) as error: + _LOGGER.error(error) + self.data = {} diff --git a/homeassistant/components/airly/air_quality.py b/homeassistant/components/airly/air_quality.py new file mode 100644 index 000000000..b48a360da --- /dev/null +++ b/homeassistant/components/airly/air_quality.py @@ -0,0 +1,141 @@ +"""Support for the Airly air_quality service.""" +from homeassistant.components.air_quality import ( + ATTR_AQI, + ATTR_PM_2_5, + ATTR_PM_10, + AirQualityEntity, +) +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME + +from .const import ( + ATTR_API_ADVICE, + ATTR_API_CAQI, + ATTR_API_CAQI_DESCRIPTION, + ATTR_API_CAQI_LEVEL, + ATTR_API_PM10, + ATTR_API_PM10_LIMIT, + ATTR_API_PM10_PERCENT, + ATTR_API_PM25, + ATTR_API_PM25_LIMIT, + ATTR_API_PM25_PERCENT, + DATA_CLIENT, + DOMAIN, +) + +ATTRIBUTION = "Data provided by Airly" + +LABEL_ADVICE = "advice" +LABEL_AQI_LEVEL = f"{ATTR_AQI}_level" +LABEL_PM_2_5_LIMIT = f"{ATTR_PM_2_5}_limit" +LABEL_PM_2_5_PERCENT = f"{ATTR_PM_2_5}_percent_of_limit" +LABEL_PM_10_LIMIT = f"{ATTR_PM_10}_limit" +LABEL_PM_10_PERCENT = f"{ATTR_PM_10}_percent_of_limit" + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Airly air_quality entity based on a config entry.""" + name = config_entry.data[CONF_NAME] + latitude = config_entry.data[CONF_LATITUDE] + longitude = config_entry.data[CONF_LONGITUDE] + unique_id = f"{latitude}-{longitude}" + + data = hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] + + async_add_entities([AirlyAirQuality(data, name, unique_id)], True) + + +def round_state(func): + """Round state.""" + + def _decorator(self): + res = func(self) + if isinstance(res, float): + return round(res) + return res + + return _decorator + + +class AirlyAirQuality(AirQualityEntity): + """Define an Airly air quality.""" + + def __init__(self, airly, name, unique_id): + """Initialize.""" + self.airly = airly + self.data = airly.data + self._name = name + self._unique_id = unique_id + self._pm_2_5 = None + self._pm_10 = None + self._aqi = None + self._icon = "mdi:blur" + self._attrs = {} + + @property + def name(self): + """Return the name.""" + return self._name + + @property + def icon(self): + """Return the icon.""" + return self._icon + + @property + @round_state + def air_quality_index(self): + """Return the air quality index.""" + return self._aqi + + @property + @round_state + def particulate_matter_2_5(self): + """Return the particulate matter 2.5 level.""" + return self._pm_2_5 + + @property + @round_state + def particulate_matter_10(self): + """Return the particulate matter 10 level.""" + return self._pm_10 + + @property + def attribution(self): + """Return the attribution.""" + return ATTRIBUTION + + @property + def state(self): + """Return the CAQI description.""" + return self.data[ATTR_API_CAQI_DESCRIPTION] + + @property + def unique_id(self): + """Return a unique_id for this entity.""" + return self._unique_id + + @property + def available(self): + """Return True if entity is available.""" + return bool(self.data) + + @property + def device_state_attributes(self): + """Return the state attributes.""" + self._attrs[LABEL_ADVICE] = self.data[ATTR_API_ADVICE] + self._attrs[LABEL_AQI_LEVEL] = self.data[ATTR_API_CAQI_LEVEL] + self._attrs[LABEL_PM_2_5_LIMIT] = self.data[ATTR_API_PM25_LIMIT] + self._attrs[LABEL_PM_2_5_PERCENT] = round(self.data[ATTR_API_PM25_PERCENT]) + self._attrs[LABEL_PM_10_LIMIT] = self.data[ATTR_API_PM10_LIMIT] + self._attrs[LABEL_PM_10_PERCENT] = round(self.data[ATTR_API_PM10_PERCENT]) + return self._attrs + + async def async_update(self): + """Update the entity.""" + await self.airly.async_update() + + if self.airly.data: + self.data = self.airly.data + self._pm_10 = self.data[ATTR_API_PM10] + self._pm_2_5 = self.data[ATTR_API_PM25] + self._aqi = self.data[ATTR_API_CAQI] diff --git a/homeassistant/components/airly/config_flow.py b/homeassistant/components/airly/config_flow.py new file mode 100644 index 000000000..31cfec7e7 --- /dev/null +++ b/homeassistant/components/airly/config_flow.py @@ -0,0 +1,114 @@ +"""Adds config flow for Airly.""" +from airly import Airly +from airly.exceptions import AirlyError +import async_timeout +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.core import callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv + +from .const import DEFAULT_NAME, DOMAIN, NO_AIRLY_SENSORS + + +@callback +def configured_instances(hass): + """Return a set of configured Airly instances.""" + return set( + entry.data[CONF_NAME] for entry in hass.config_entries.async_entries(DOMAIN) + ) + + +class AirlyFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Config flow for Airly.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Initialize.""" + self._errors = {} + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + self._errors = {} + + websession = async_get_clientsession(self.hass) + + if user_input is not None: + if user_input[CONF_NAME] in configured_instances(self.hass): + self._errors[CONF_NAME] = "name_exists" + api_key_valid = await self._test_api_key(websession, user_input["api_key"]) + if not api_key_valid: + self._errors["base"] = "auth" + else: + location_valid = await self._test_location( + websession, + user_input["api_key"], + user_input["latitude"], + user_input["longitude"], + ) + if not location_valid: + self._errors["base"] = "wrong_location" + + if not self._errors: + return self.async_create_entry( + title=user_input[CONF_NAME], data=user_input + ) + + return self._show_config_form( + name=DEFAULT_NAME, + api_key="", + latitude=self.hass.config.latitude, + longitude=self.hass.config.longitude, + ) + + def _show_config_form(self, name=None, api_key=None, latitude=None, longitude=None): + """Show the configuration form to edit data.""" + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_API_KEY, default=api_key): str, + vol.Optional( + CONF_LATITUDE, default=self.hass.config.latitude + ): cv.latitude, + vol.Optional( + CONF_LONGITUDE, default=self.hass.config.longitude + ): cv.longitude, + vol.Optional(CONF_NAME, default=name): str, + } + ), + errors=self._errors, + ) + + async def _test_api_key(self, client, api_key): + """Return true if api_key is valid.""" + + with async_timeout.timeout(10): + airly = Airly(api_key, client) + measurements = airly.create_measurements_session_point( + latitude=52.24131, longitude=20.99101 + ) + try: + await measurements.update() + except AirlyError: + return False + return True + + async def _test_location(self, client, api_key, latitude, longitude): + """Return true if location is valid.""" + + with async_timeout.timeout(10): + airly = Airly(api_key, client) + measurements = airly.create_measurements_session_point( + latitude=latitude, longitude=longitude + ) + + await measurements.update() + current = measurements.current + if current["indexes"][0]["description"] == NO_AIRLY_SENSORS: + return False + return True diff --git a/homeassistant/components/airly/const.py b/homeassistant/components/airly/const.py new file mode 100644 index 000000000..2040faea6 --- /dev/null +++ b/homeassistant/components/airly/const.py @@ -0,0 +1,19 @@ +"""Constants for Airly integration.""" +ATTR_API_ADVICE = "ADVICE" +ATTR_API_CAQI = "CAQI" +ATTR_API_CAQI_DESCRIPTION = "DESCRIPTION" +ATTR_API_CAQI_LEVEL = "LEVEL" +ATTR_API_HUMIDITY = "HUMIDITY" +ATTR_API_PM1 = "PM1" +ATTR_API_PM10 = "PM10" +ATTR_API_PM10_LIMIT = "PM10_LIMIT" +ATTR_API_PM10_PERCENT = "PM10_PERCENT" +ATTR_API_PM25 = "PM25" +ATTR_API_PM25_LIMIT = "PM25_LIMIT" +ATTR_API_PM25_PERCENT = "PM25_PERCENT" +ATTR_API_PRESSURE = "PRESSURE" +ATTR_API_TEMPERATURE = "TEMPERATURE" +DATA_CLIENT = "client" +DEFAULT_NAME = "Airly" +DOMAIN = "airly" +NO_AIRLY_SENSORS = "There are no Airly sensors in this area yet." diff --git a/homeassistant/components/airly/manifest.json b/homeassistant/components/airly/manifest.json new file mode 100644 index 000000000..1859f084b --- /dev/null +++ b/homeassistant/components/airly/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "airly", + "name": "Airly", + "documentation": "https://www.home-assistant.io/integrations/airly", + "dependencies": [], + "codeowners": ["@bieniu"], + "requirements": ["airly==0.0.2"], + "config_flow": true +} diff --git a/homeassistant/components/airly/sensor.py b/homeassistant/components/airly/sensor.py new file mode 100644 index 000000000..af0eac39c --- /dev/null +++ b/homeassistant/components/airly/sensor.py @@ -0,0 +1,157 @@ +"""Support for the Airly sensor service.""" +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_DEVICE_CLASS, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_NAME, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_TEMPERATURE, + PRESSURE_HPA, + TEMP_CELSIUS, +) +from homeassistant.helpers.entity import Entity + +from .const import ( + ATTR_API_HUMIDITY, + ATTR_API_PM1, + ATTR_API_PRESSURE, + ATTR_API_TEMPERATURE, + DATA_CLIENT, + DOMAIN, +) + +ATTRIBUTION = "Data provided by Airly" + +ATTR_ICON = "icon" +ATTR_LABEL = "label" +ATTR_UNIT = "unit" + +HUMI_PERCENT = "%" +VOLUME_MICROGRAMS_PER_CUBIC_METER = "µg/m³" + +SENSOR_TYPES = { + ATTR_API_PM1: { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:blur", + ATTR_LABEL: ATTR_API_PM1, + ATTR_UNIT: VOLUME_MICROGRAMS_PER_CUBIC_METER, + }, + ATTR_API_HUMIDITY: { + ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, + ATTR_ICON: None, + ATTR_LABEL: ATTR_API_HUMIDITY.capitalize(), + ATTR_UNIT: HUMI_PERCENT, + }, + ATTR_API_PRESSURE: { + ATTR_DEVICE_CLASS: DEVICE_CLASS_PRESSURE, + ATTR_ICON: None, + ATTR_LABEL: ATTR_API_PRESSURE.capitalize(), + ATTR_UNIT: PRESSURE_HPA, + }, + ATTR_API_TEMPERATURE: { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_ICON: None, + ATTR_LABEL: ATTR_API_TEMPERATURE.capitalize(), + ATTR_UNIT: TEMP_CELSIUS, + }, +} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Airly sensor entities based on a config entry.""" + name = config_entry.data[CONF_NAME] + latitude = config_entry.data[CONF_LATITUDE] + longitude = config_entry.data[CONF_LONGITUDE] + + data = hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] + + sensors = [] + for sensor in SENSOR_TYPES: + unique_id = f"{latitude}-{longitude}-{sensor.lower()}" + sensors.append(AirlySensor(data, name, sensor, unique_id)) + + async_add_entities(sensors, True) + + +def round_state(func): + """Round state.""" + + def _decorator(self): + res = func(self) + if isinstance(res, float): + return round(res) + return res + + return _decorator + + +class AirlySensor(Entity): + """Define an Airly sensor.""" + + def __init__(self, airly, name, kind, unique_id): + """Initialize.""" + self.airly = airly + self.data = airly.data + self._name = name + self._unique_id = unique_id + self.kind = kind + self._device_class = None + self._state = None + self._icon = None + self._unit_of_measurement = None + self._attrs = {ATTR_ATTRIBUTION: ATTRIBUTION} + + @property + def name(self): + """Return the name.""" + return f"{self._name} {SENSOR_TYPES[self.kind][ATTR_LABEL]}" + + @property + def state(self): + """Return the state.""" + self._state = self.data[self.kind] + if self.kind in [ATTR_API_PM1, ATTR_API_PRESSURE]: + self._state = round(self._state) + if self.kind in [ATTR_API_TEMPERATURE, ATTR_API_HUMIDITY]: + self._state = round(self._state, 1) + return self._state + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._attrs + + @property + def icon(self): + """Return the icon.""" + self._icon = SENSOR_TYPES[self.kind][ATTR_ICON] + return self._icon + + @property + def device_class(self): + """Return the device_class.""" + return SENSOR_TYPES[self.kind][ATTR_DEVICE_CLASS] + + @property + def unique_id(self): + """Return a unique_id for this entity.""" + return self._unique_id + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return SENSOR_TYPES[self.kind][ATTR_UNIT] + + @property + def available(self): + """Return True if entity is available.""" + return bool(self.data) + + async def async_update(self): + """Update the sensor.""" + await self.airly.async_update() + + if self.airly.data: + self.data = self.airly.data diff --git a/homeassistant/components/airly/strings.json b/homeassistant/components/airly/strings.json new file mode 100644 index 000000000..116b6df83 --- /dev/null +++ b/homeassistant/components/airly/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "title": "Airly", + "step": { + "user": { + "title": "Airly", + "description": "Set up Airly air quality integration. To generate API key go to https://developer.airly.eu/register", + "data": { + "name": "Name of the integration", + "api_key": "Airly API key", + "latitude": "Latitude", + "longitude": "Longitude" + } + } + }, + "error": { + "name_exists": "Name already exists.", + "wrong_location": "No Airly measuring stations in this area.", + "auth": "API key is not correct." + } + } +} diff --git a/homeassistant/components/airvisual/__init__.py b/homeassistant/components/airvisual/__init__.py new file mode 100644 index 000000000..b1f79d172 --- /dev/null +++ b/homeassistant/components/airvisual/__init__.py @@ -0,0 +1 @@ +"""The airvisual component.""" diff --git a/homeassistant/components/airvisual/manifest.json b/homeassistant/components/airvisual/manifest.json new file mode 100644 index 000000000..e7ea23a43 --- /dev/null +++ b/homeassistant/components/airvisual/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "airvisual", + "name": "Airvisual", + "documentation": "https://www.home-assistant.io/integrations/airvisual", + "requirements": [ + "pyairvisual==3.0.1" + ], + "dependencies": [], + "codeowners": [ + "@bachya" + ] +} diff --git a/homeassistant/components/airvisual/sensor.py b/homeassistant/components/airvisual/sensor.py new file mode 100644 index 000000000..888d6ae6e --- /dev/null +++ b/homeassistant/components/airvisual/sensor.py @@ -0,0 +1,273 @@ +"""Support for AirVisual air quality sensors.""" +from datetime import timedelta +from logging import getLogger + +from pyairvisual import Client +from pyairvisual.errors import AirVisualError +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_LATITUDE, + ATTR_LONGITUDE, + CONF_API_KEY, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_MONITORED_CONDITIONS, + CONF_SCAN_INTERVAL, + CONF_SHOW_ON_MAP, + CONF_STATE, +) +from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +_LOGGER = getLogger(__name__) + +ATTR_CITY = "city" +ATTR_COUNTRY = "country" +ATTR_POLLUTANT_SYMBOL = "pollutant_symbol" +ATTR_POLLUTANT_UNIT = "pollutant_unit" +ATTR_REGION = "region" + +CONF_CITY = "city" +CONF_COUNTRY = "country" + +DEFAULT_ATTRIBUTION = "Data provided by AirVisual" +DEFAULT_SCAN_INTERVAL = timedelta(minutes=10) + +MASS_PARTS_PER_MILLION = "ppm" +MASS_PARTS_PER_BILLION = "ppb" +VOLUME_MICROGRAMS_PER_CUBIC_METER = "µg/m3" + +SENSOR_TYPE_LEVEL = "air_pollution_level" +SENSOR_TYPE_AQI = "air_quality_index" +SENSOR_TYPE_POLLUTANT = "main_pollutant" +SENSORS = [ + (SENSOR_TYPE_LEVEL, "Air Pollution Level", "mdi:gauge", None), + (SENSOR_TYPE_AQI, "Air Quality Index", "mdi:chart-line", "AQI"), + (SENSOR_TYPE_POLLUTANT, "Main Pollutant", "mdi:chemical-weapon", None), +] + +POLLUTANT_LEVEL_MAPPING = [ + {"label": "Good", "icon": "mdi:emoticon-excited", "minimum": 0, "maximum": 50}, + {"label": "Moderate", "icon": "mdi:emoticon-happy", "minimum": 51, "maximum": 100}, + { + "label": "Unhealthy for sensitive groups", + "icon": "mdi:emoticon-neutral", + "minimum": 101, + "maximum": 150, + }, + {"label": "Unhealthy", "icon": "mdi:emoticon-sad", "minimum": 151, "maximum": 200}, + { + "label": "Very Unhealthy", + "icon": "mdi:emoticon-dead", + "minimum": 201, + "maximum": 300, + }, + {"label": "Hazardous", "icon": "mdi:biohazard", "minimum": 301, "maximum": 10000}, +] + +POLLUTANT_MAPPING = { + "co": {"label": "Carbon Monoxide", "unit": MASS_PARTS_PER_MILLION}, + "n2": {"label": "Nitrogen Dioxide", "unit": MASS_PARTS_PER_BILLION}, + "o3": {"label": "Ozone", "unit": MASS_PARTS_PER_BILLION}, + "p1": {"label": "PM10", "unit": VOLUME_MICROGRAMS_PER_CUBIC_METER}, + "p2": {"label": "PM2.5", "unit": VOLUME_MICROGRAMS_PER_CUBIC_METER}, + "s2": {"label": "Sulfur Dioxide", "unit": MASS_PARTS_PER_BILLION}, +} + +SENSOR_LOCALES = {"cn": "Chinese", "us": "U.S."} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_MONITORED_CONDITIONS, default=list(SENSOR_LOCALES)): vol.All( + cv.ensure_list, [vol.In(SENSOR_LOCALES)] + ), + vol.Inclusive(CONF_CITY, "city"): cv.string, + vol.Inclusive(CONF_COUNTRY, "city"): cv.string, + vol.Inclusive(CONF_LATITUDE, "coords"): cv.latitude, + vol.Inclusive(CONF_LONGITUDE, "coords"): cv.longitude, + vol.Optional(CONF_SHOW_ON_MAP, default=True): cv.boolean, + vol.Inclusive(CONF_STATE, "city"): cv.string, + vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): cv.time_period, + } +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Configure the platform and add the sensors.""" + + city = config.get(CONF_CITY) + state = config.get(CONF_STATE) + country = config.get(CONF_COUNTRY) + + latitude = config.get(CONF_LATITUDE, hass.config.latitude) + longitude = config.get(CONF_LONGITUDE, hass.config.longitude) + + websession = aiohttp_client.async_get_clientsession(hass) + + if city and state and country: + _LOGGER.debug( + "Using city, state, and country: %s, %s, %s", city, state, country + ) + location_id = ",".join((city, state, country)) + data = AirVisualData( + Client(websession, api_key=config[CONF_API_KEY]), + city=city, + state=state, + country=country, + show_on_map=config[CONF_SHOW_ON_MAP], + scan_interval=config[CONF_SCAN_INTERVAL], + ) + else: + _LOGGER.debug("Using latitude and longitude: %s, %s", latitude, longitude) + location_id = ",".join((str(latitude), str(longitude))) + data = AirVisualData( + Client(websession, api_key=config[CONF_API_KEY]), + latitude=latitude, + longitude=longitude, + show_on_map=config[CONF_SHOW_ON_MAP], + scan_interval=config[CONF_SCAN_INTERVAL], + ) + + await data.async_update() + + sensors = [] + for locale in config[CONF_MONITORED_CONDITIONS]: + for kind, name, icon, unit in SENSORS: + sensors.append( + AirVisualSensor(data, kind, name, icon, unit, locale, location_id) + ) + + async_add_entities(sensors, True) + + +class AirVisualSensor(Entity): + """Define an AirVisual sensor.""" + + def __init__(self, airvisual, kind, name, icon, unit, locale, location_id): + """Initialize.""" + self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} + self._icon = icon + self._locale = locale + self._location_id = location_id + self._name = name + self._state = None + self._type = kind + self._unit = unit + self.airvisual = airvisual + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + if self.airvisual.show_on_map: + self._attrs[ATTR_LATITUDE] = self.airvisual.latitude + self._attrs[ATTR_LONGITUDE] = self.airvisual.longitude + else: + self._attrs["lati"] = self.airvisual.latitude + self._attrs["long"] = self.airvisual.longitude + + return self._attrs + + @property + def available(self): + """Return True if entity is available.""" + return bool(self.airvisual.pollution_info) + + @property + def icon(self): + """Return the icon.""" + return self._icon + + @property + def name(self): + """Return the name.""" + return "{0} {1}".format(SENSOR_LOCALES[self._locale], self._name) + + @property + def state(self): + """Return the state.""" + return self._state + + @property + def unique_id(self): + """Return a unique, HASS-friendly identifier for this entity.""" + return f"{self._location_id}_{self._locale}_{self._type}" + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self._unit + + async def async_update(self): + """Update the sensor.""" + await self.airvisual.async_update() + data = self.airvisual.pollution_info + + if not data: + return + + if self._type == SENSOR_TYPE_LEVEL: + aqi = data[f"aqi{self._locale}"] + [level] = [ + i + for i in POLLUTANT_LEVEL_MAPPING + if i["minimum"] <= aqi <= i["maximum"] + ] + self._state = level["label"] + self._icon = level["icon"] + elif self._type == SENSOR_TYPE_AQI: + self._state = data[f"aqi{self._locale}"] + elif self._type == SENSOR_TYPE_POLLUTANT: + symbol = data[f"main{self._locale}"] + self._state = POLLUTANT_MAPPING[symbol]["label"] + self._attrs.update( + { + ATTR_POLLUTANT_SYMBOL: symbol, + ATTR_POLLUTANT_UNIT: POLLUTANT_MAPPING[symbol]["unit"], + } + ) + + +class AirVisualData: + """Define an object to hold sensor data.""" + + def __init__(self, client, **kwargs): + """Initialize.""" + self._client = client + self.city = kwargs.get(CONF_CITY) + self.country = kwargs.get(CONF_COUNTRY) + self.latitude = kwargs.get(CONF_LATITUDE) + self.longitude = kwargs.get(CONF_LONGITUDE) + self.pollution_info = {} + self.show_on_map = kwargs.get(CONF_SHOW_ON_MAP) + self.state = kwargs.get(CONF_STATE) + + self.async_update = Throttle(kwargs[CONF_SCAN_INTERVAL])(self._async_update) + + async def _async_update(self): + """Update AirVisual data.""" + + try: + if self.city and self.state and self.country: + resp = await self._client.api.city(self.city, self.state, self.country) + self.longitude, self.latitude = resp["location"]["coordinates"] + else: + resp = await self._client.api.nearest_city( + self.latitude, self.longitude + ) + + _LOGGER.debug("New data retrieved: %s", resp) + + self.pollution_info = resp["current"]["pollution"] + except (KeyError, AirVisualError) as err: + if self.city and self.state and self.country: + location = (self.city, self.state, self.country) + else: + location = (self.latitude, self.longitude) + + _LOGGER.error("Can't retrieve data for location: %s (%s)", location, err) + self.pollution_info = {} diff --git a/homeassistant/components/aladdin_connect/__init__.py b/homeassistant/components/aladdin_connect/__init__.py new file mode 100644 index 000000000..90196616d --- /dev/null +++ b/homeassistant/components/aladdin_connect/__init__.py @@ -0,0 +1 @@ +"""The aladdin_connect component.""" diff --git a/homeassistant/components/aladdin_connect/cover.py b/homeassistant/components/aladdin_connect/cover.py new file mode 100644 index 000000000..4cfcd5403 --- /dev/null +++ b/homeassistant/components/aladdin_connect/cover.py @@ -0,0 +1,123 @@ +"""Platform for the Aladdin Connect cover component.""" +import logging + +from aladdin_connect import AladdinConnectClient +import voluptuous as vol + +from homeassistant.components.cover import ( + PLATFORM_SCHEMA, + SUPPORT_CLOSE, + SUPPORT_OPEN, + CoverDevice, +) +from homeassistant.const import ( + CONF_PASSWORD, + CONF_USERNAME, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, +) +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +NOTIFICATION_ID = "aladdin_notification" +NOTIFICATION_TITLE = "Aladdin Connect Cover Setup" + +STATES_MAP = { + "open": STATE_OPEN, + "opening": STATE_OPENING, + "closed": STATE_CLOSED, + "closing": STATE_CLOSING, +} + +SUPPORTED_FEATURES = SUPPORT_OPEN | SUPPORT_CLOSE + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + {vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string} +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Aladdin Connect platform.""" + + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + acc = AladdinConnectClient(username, password) + + try: + if not acc.login(): + raise ValueError("Username or Password is incorrect") + add_entities(AladdinDevice(acc, door) for door in acc.get_doors()) + 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 AladdinDevice(CoverDevice): + """Representation of Aladdin Connect cover.""" + + def __init__(self, acc, device): + """Initialize the cover.""" + self._acc = acc + self._device_id = device["device_id"] + self._number = device["door_number"] + self._name = device["name"] + self._status = STATES_MAP.get(device["status"]) + + @property + def device_class(self): + """Define this cover as a garage door.""" + return "garage" + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORTED_FEATURES + + @property + def unique_id(self): + """Return a unique ID.""" + return f"{self._device_id}-{self._number}" + + @property + def name(self): + """Return the name of the garage door.""" + return self._name + + @property + def is_opening(self): + """Return if the cover is opening or not.""" + return self._status == STATE_OPENING + + @property + def is_closing(self): + """Return if the cover is closing or not.""" + return self._status == STATE_CLOSING + + @property + def is_closed(self): + """Return None if status is unknown, True if closed, else False.""" + if self._status is None: + return None + return self._status == STATE_CLOSED + + def close_cover(self, **kwargs): + """Issue close command to cover.""" + self._acc.close_door(self._device_id, self._number) + + def open_cover(self, **kwargs): + """Issue open command to cover.""" + self._acc.open_door(self._device_id, self._number) + + def update(self): + """Update status of cover.""" + acc_status = self._acc.get_door_status(self._device_id, self._number) + self._status = STATES_MAP.get(acc_status) diff --git a/homeassistant/components/aladdin_connect/manifest.json b/homeassistant/components/aladdin_connect/manifest.json new file mode 100644 index 000000000..7d4404074 --- /dev/null +++ b/homeassistant/components/aladdin_connect/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "aladdin_connect", + "name": "Aladdin connect", + "documentation": "https://www.home-assistant.io/integrations/aladdin_connect", + "requirements": [ + "aladdin_connect==0.3" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/alarm_control_panel/.translations/bg.json b/homeassistant/components/alarm_control_panel/.translations/bg.json new file mode 100644 index 000000000..a9342c8c4 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/.translations/bg.json @@ -0,0 +1,18 @@ +{ + "device_automation": { + "action_type": { + "arm_away": "\u0421\u043b\u043e\u0436\u0438 {entity_name} \u043f\u043e\u0434 \u043e\u0445\u0440\u0430\u043d\u0430 \u0432 \u0440\u0435\u0436\u0438\u043c \u043e\u0442\u0441\u044a\u0441\u0442\u0432\u0438\u0435", + "arm_home": "\u0421\u043b\u043e\u0436\u0438 {entity_name} \u043f\u043e\u0434 \u043e\u0445\u0440\u0430\u043d\u0430 \u0432 \u0440\u0435\u0436\u0438\u043c \u0432\u043a\u044a\u0449\u0438", + "arm_night": "\u0421\u043b\u043e\u0436\u0438 {entity_name} \u043f\u043e\u0434 \u043e\u0445\u0440\u0430\u043d\u0430 \u0432 \u043d\u043e\u0449\u0435\u043d \u0440\u0435\u0436\u0438\u043c", + "disarm": "\u0414\u0435\u0430\u043a\u0442\u0438\u0432\u0438\u0440\u0430\u0439 {entity_name}", + "trigger": "\u0417\u0430\u0434\u0435\u0439\u0441\u0442\u0432\u0430\u043d\u0435 {entity_name}" + }, + "trigger_type": { + "armed_away": "{entity_name} \u043f\u043e\u0434 \u043e\u0445\u0440\u0430\u043d\u0430", + "armed_home": "{entity_name} \u043f\u043e\u0434 \u043e\u0445\u0440\u0430\u043d\u0430 - \u0432\u043a\u044a\u0449\u0438", + "armed_night": "{entity_name} \u043f\u043e\u0434 \u043e\u0445\u0440\u0430\u043d\u0430 - \u043d\u043e\u0449", + "disarmed": "{entity_name} \u0434\u0435\u0430\u043a\u0442\u0438\u0432\u0438\u0440\u0430\u043d\u0430", + "triggered": "{entity_name} \u0437\u0430\u0434\u0435\u0439\u0441\u0442\u0432\u0430\u043d\u0430" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/.translations/ca.json b/homeassistant/components/alarm_control_panel/.translations/ca.json new file mode 100644 index 000000000..d60cf3173 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/.translations/ca.json @@ -0,0 +1,18 @@ +{ + "device_automation": { + "action_type": { + "arm_away": "Activa {entity_name} fora", + "arm_home": "Activa {entity_name} a casa", + "arm_night": "Activa {entity_name} nocturn", + "disarm": "Desactiva {entity_name}", + "trigger": "Dispara {entity_name}" + }, + "trigger_type": { + "armed_away": "{entity_name} activada en mode a fora", + "armed_home": "{entity_name} activada en mode a casa", + "armed_night": "{entity_name} activada en mode nocturn", + "disarmed": "{entity_name} desactivada", + "triggered": "{entity_name} disparat/ada" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/.translations/cs.json b/homeassistant/components/alarm_control_panel/.translations/cs.json new file mode 100644 index 000000000..247a4e96d --- /dev/null +++ b/homeassistant/components/alarm_control_panel/.translations/cs.json @@ -0,0 +1,11 @@ +{ + "device_automation": { + "action_type": { + "arm_away": "Aktivovat {entity_name} v re\u017eimu mimo domov", + "arm_home": "Aktivovat {entity_name} v re\u017eimu doma", + "arm_night": "Aktivovat {entity_name} v re\u017eimu noc", + "disarm": "Deaktivovat {entity_name}", + "trigger": "Spustit {entity_name}" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/.translations/da.json b/homeassistant/components/alarm_control_panel/.translations/da.json new file mode 100644 index 000000000..74e02e10d --- /dev/null +++ b/homeassistant/components/alarm_control_panel/.translations/da.json @@ -0,0 +1,7 @@ +{ + "device_automation": { + "action_type": { + "trigger": "Udl\u00f8s {entity_name}" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/.translations/en.json b/homeassistant/components/alarm_control_panel/.translations/en.json new file mode 100644 index 000000000..a00e81feb --- /dev/null +++ b/homeassistant/components/alarm_control_panel/.translations/en.json @@ -0,0 +1,18 @@ +{ + "device_automation": { + "action_type": { + "arm_away": "Arm {entity_name} away", + "arm_home": "Arm {entity_name} home", + "arm_night": "Arm {entity_name} night", + "disarm": "Disarm {entity_name}", + "trigger": "Trigger {entity_name}" + }, + "trigger_type": { + "armed_away": "{entity_name} armed away", + "armed_home": "{entity_name} armed home", + "armed_night": "{entity_name} armed night", + "disarmed": "{entity_name} disarmed", + "triggered": "{entity_name} triggered" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/.translations/es.json b/homeassistant/components/alarm_control_panel/.translations/es.json new file mode 100644 index 000000000..8200755de --- /dev/null +++ b/homeassistant/components/alarm_control_panel/.translations/es.json @@ -0,0 +1,18 @@ +{ + "device_automation": { + "action_type": { + "arm_away": "Armar {entity_name} exterior", + "arm_home": "Armar {entity_name} modo casa", + "arm_night": "Armar {entity_name} por la noche", + "disarm": "Desarmar {entity_name}", + "trigger": "Lanzar {entity_name}" + }, + "trigger_type": { + "armed_away": "{entity_name} armado fuera", + "armed_home": "{entity_name} armado en casa", + "armed_night": "{entity_name} armado modo noche", + "disarmed": "{entity_name} desarmado", + "triggered": "{entity_name} activado" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/.translations/fr.json b/homeassistant/components/alarm_control_panel/.translations/fr.json new file mode 100644 index 000000000..fbdc6a560 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/.translations/fr.json @@ -0,0 +1,18 @@ +{ + "device_automation": { + "action_type": { + "arm_away": "Armer {entity_name} en mode \"sortie\"", + "arm_home": "Armer {entity_name} en mode \"maison\"", + "arm_night": "Armer {entity_name} en mode \"nuit\"", + "disarm": "D\u00e9sarmer {entity_name}", + "trigger": "D\u00e9clencheur {entity_name}" + }, + "trigger_type": { + "armed_away": "Armer {entity_name} en mode \"sortie\"", + "armed_home": "Armer {entity_name} en mode \"maison\"", + "armed_night": "Armer {entity_name} en mode \"nuit\"", + "disarmed": "{entity_name} d\u00e9sarm\u00e9", + "triggered": "{entity_name} d\u00e9clench\u00e9" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/.translations/hu.json b/homeassistant/components/alarm_control_panel/.translations/hu.json new file mode 100644 index 000000000..b249a16c9 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/.translations/hu.json @@ -0,0 +1,18 @@ +{ + "device_automation": { + "action_type": { + "arm_away": "{entity_name} \u00e9les\u00edt\u00e9se t\u00e1voz\u00f3 m\u00f3dban", + "arm_home": "{entity_name} \u00e9les\u00edt\u00e9se otthon marad\u00f3 m\u00f3dban", + "arm_night": "{entity_name} \u00e9les\u00edt\u00e9se \u00e9jszakai m\u00f3dban", + "disarm": "{entity_name} hat\u00e1stalan\u00edt\u00e1sa", + "trigger": "{entity_name} riaszt\u00e1si esem\u00e9ny ind\u00edt\u00e1sa" + }, + "trigger_type": { + "armed_away": "{entity_name} t\u00e1voz\u00f3 m\u00f3dban lett \u00e9les\u00edtve", + "armed_home": "{entity_name} otthon marad\u00f3 m\u00f3dban lett \u00e9les\u00edtve", + "armed_night": "{entity_name} \u00e9jszakai m\u00f3dban lett \u00e9les\u00edtve", + "disarmed": "{entity_name} hat\u00e1stalan\u00edtva lett", + "triggered": "{entity_name} riaszt\u00e1sba ker\u00fclt" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/.translations/it.json b/homeassistant/components/alarm_control_panel/.translations/it.json new file mode 100644 index 000000000..78a3f0b07 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/.translations/it.json @@ -0,0 +1,18 @@ +{ + "device_automation": { + "action_type": { + "arm_away": "Armare {entity_name} uscito", + "arm_home": "Armare {entity_name} casa", + "arm_night": "Armare {entity_name} notte", + "disarm": "Disarmare {entity_name}", + "trigger": "Attivazione {entity_name}" + }, + "trigger_type": { + "armed_away": "{entity_name} armata modalit\u00e0 fuori casa", + "armed_home": "{entity_name} armata modalit\u00e0 a casa", + "armed_night": "{entity_name} armata modalit\u00e0 notte", + "disarmed": "{entity_name} disarmato", + "triggered": "{entity_name} attivato" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/.translations/ko.json b/homeassistant/components/alarm_control_panel/.translations/ko.json new file mode 100644 index 000000000..5d6caa5fe --- /dev/null +++ b/homeassistant/components/alarm_control_panel/.translations/ko.json @@ -0,0 +1,11 @@ +{ + "device_automation": { + "action_type": { + "arm_away": "{entity_name} \uc678\ucd9c\uacbd\ube44", + "arm_home": "{entity_name} \uc7ac\uc2e4\uacbd\ube44", + "arm_night": "{entity_name} \uc57c\uac04\uacbd\ube44", + "disarm": "{entity_name} \uacbd\ube44\ud574\uc81c", + "trigger": "{entity_name} \ud2b8\ub9ac\uac70" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/.translations/lb.json b/homeassistant/components/alarm_control_panel/.translations/lb.json new file mode 100644 index 000000000..add11f5b8 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/.translations/lb.json @@ -0,0 +1,18 @@ +{ + "device_automation": { + "action_type": { + "arm_away": "{entity_name} fir \u00ebnnerwee uschalten", + "arm_home": "{entity_name} fir doheem uschalten", + "arm_night": "{entity_name} fir Nuecht uschalten", + "disarm": "{entity_name} entsch\u00e4rfen", + "trigger": "{entity_name} ausl\u00e9isen" + }, + "trigger_type": { + "armed_away": "{entity_name} ugeschalt fir Ennerwee", + "armed_home": "{entity_name} ugeschalt fir Doheem", + "armed_night": "{entity_name} ugeschalt fir Nuecht", + "disarmed": "{entity_name} entsch\u00e4rft", + "triggered": "{entity_name} ausgel\u00e9ist" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/.translations/nl.json b/homeassistant/components/alarm_control_panel/.translations/nl.json new file mode 100644 index 000000000..9329a089d --- /dev/null +++ b/homeassistant/components/alarm_control_panel/.translations/nl.json @@ -0,0 +1,11 @@ +{ + "device_automation": { + "action_type": { + "arm_away": "Inschakelen {entity_name} afwezig", + "arm_home": "Inschakelen {entity_name} thuis", + "arm_night": "Inschakelen {entity_name} nacht", + "disarm": "Uitschakelen {entity_name}", + "trigger": "Trigger {entity_name}" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/.translations/no.json b/homeassistant/components/alarm_control_panel/.translations/no.json new file mode 100644 index 000000000..108d273a0 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/.translations/no.json @@ -0,0 +1,18 @@ +{ + "device_automation": { + "action_type": { + "arm_away": "Aktiver {entity_name} borte", + "arm_home": "Aktiver {entity_name} hjemme", + "arm_night": "Aktiver {entity_name} natt", + "disarm": "Deaktiver {entity_name}", + "trigger": "Utl\u00f8ser {entity_name}" + }, + "trigger_type": { + "armed_away": "{entity_name} borte sikkring ", + "armed_home": "{entity_name} hjemme sikkring", + "armed_night": "{entity_name} natt sikkring", + "disarmed": "{entity_name} deaktivert", + "triggered": "{entity_name} utl\u00f8st" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/.translations/pl.json b/homeassistant/components/alarm_control_panel/.translations/pl.json new file mode 100644 index 000000000..024a0861c --- /dev/null +++ b/homeassistant/components/alarm_control_panel/.translations/pl.json @@ -0,0 +1,18 @@ +{ + "device_automation": { + "action_type": { + "arm_away": "uzbr\u00f3j (poza domem) {entity_name}", + "arm_home": "uzbr\u00f3j (w domu) {entity_name}", + "arm_night": "uzbr\u00f3j (noc) {entity_name}", + "disarm": "rozbr\u00f3j {entity_name}", + "trigger": "wyzw\u00f3l {entity_name}" + }, + "trigger_type": { + "armed_away": "{entity_name} zostanie uzbrojony (poza domem)", + "armed_home": "{entity_name} zostanie uzbrojony (w domu)", + "armed_night": "{entity_name} zostanie uzbrojony (noc)", + "disarmed": "{entity_name} zostanie rozbrojony", + "triggered": "{entity_name} zostanie wyzwolony" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/.translations/pt-BR.json b/homeassistant/components/alarm_control_panel/.translations/pt-BR.json new file mode 100644 index 000000000..032756f48 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/.translations/pt-BR.json @@ -0,0 +1,18 @@ +{ + "device_automation": { + "action_type": { + "arm_away": "Armar {entity_name} longe", + "arm_home": "Armar {entity_name} casa", + "arm_night": "Armar {entity_name} noite", + "disarm": "Desarmar {entity_name}", + "trigger": "Disparar {entidade_nome}" + }, + "trigger_type": { + "armed_away": "{entity_name} armado modo longe", + "armed_home": "{entidade_nome} armadado modo casa ", + "armed_night": "{entity_name} armadado para noite", + "disarmed": "{entity_name} desarmado", + "triggered": "{entity_name} acionado" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/.translations/pt.json b/homeassistant/components/alarm_control_panel/.translations/pt.json new file mode 100644 index 000000000..90b9b1d43 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/.translations/pt.json @@ -0,0 +1,9 @@ +{ + "device_automation": { + "action_type": { + "arm_home": "Armar casa {entity_name}", + "arm_night": "Armar noite {entity_name}", + "disarm": "Desarmar {entity_name}" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/.translations/ru.json b/homeassistant/components/alarm_control_panel/.translations/ru.json new file mode 100644 index 000000000..f9a0e859e --- /dev/null +++ b/homeassistant/components/alarm_control_panel/.translations/ru.json @@ -0,0 +1,18 @@ +{ + "device_automation": { + "action_type": { + "arm_away": "\u0412\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u0440\u0435\u0436\u0438\u043c \u043e\u0445\u0440\u0430\u043d\u044b \"\u041d\u0435 \u0434\u043e\u043c\u0430\" \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}", + "arm_home": "\u0412\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u0440\u0435\u0436\u0438\u043c \u043e\u0445\u0440\u0430\u043d\u044b \"\u0414\u043e\u043c\u0430\" \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}", + "arm_night": "\u0412\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u0440\u0435\u0436\u0438\u043c \u043e\u0445\u0440\u0430\u043d\u044b \"\u041d\u043e\u0447\u044c\" \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}", + "disarm": "\u041e\u0442\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u043e\u0445\u0440\u0430\u043d\u0443 \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}", + "trigger": "{entity_name} \u0441\u0440\u0430\u0431\u0430\u0442\u044b\u0432\u0430\u0435\u0442" + }, + "trigger_type": { + "armed_away": "\u0412\u043a\u043b\u044e\u0447\u0435\u043d \u0440\u0435\u0436\u0438\u043c \u043e\u0445\u0440\u0430\u043d\u044b \"\u041d\u0435 \u0434\u043e\u043c\u0430\" \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}", + "armed_home": "\u0412\u043a\u043b\u044e\u0447\u0435\u043d \u0440\u0435\u0436\u0438\u043c \u043e\u0445\u0440\u0430\u043d\u044b \"\u0414\u043e\u043c\u0430\" \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}", + "armed_night": "\u0412\u043a\u043b\u044e\u0447\u0435\u043d \u0440\u0435\u0436\u0438\u043c \u043e\u0445\u0440\u0430\u043d\u044b \"\u041d\u043e\u0447\u044c\" \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}", + "disarmed": "\u041e\u0442\u043a\u043b\u044e\u0447\u0435\u043d\u0430 \u043e\u0445\u0440\u0430\u043d\u0430 \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}", + "triggered": "{entity_name} \u0441\u0440\u0430\u0431\u0430\u0442\u044b\u0432\u0430\u0435\u0442" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/.translations/sl.json b/homeassistant/components/alarm_control_panel/.translations/sl.json new file mode 100644 index 000000000..855c50ab8 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/.translations/sl.json @@ -0,0 +1,18 @@ +{ + "device_automation": { + "action_type": { + "arm_away": "Vklju\u010di {entity_name} zdoma", + "arm_home": "Vklju\u010di {entity_name} doma", + "arm_night": "Vklju\u010di {entity_name} no\u010d", + "disarm": "Razoro\u017ei {entity_name}", + "trigger": "Spro\u017ei {entity_name}" + }, + "trigger_type": { + "armed_away": "{entity_name} oboro\u017een - zdoma", + "armed_home": "{entity_name} oboro\u017een - dom", + "armed_night": "{entity_name} oboro\u017een - no\u010d", + "disarmed": "{entity_name} razoro\u017een", + "triggered": "{entity_name} spro\u017een" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/.translations/zh-Hant.json b/homeassistant/components/alarm_control_panel/.translations/zh-Hant.json new file mode 100644 index 000000000..72c0b6543 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/.translations/zh-Hant.json @@ -0,0 +1,18 @@ +{ + "device_automation": { + "action_type": { + "arm_away": "\u8a2d\u5b9a {entity_name} \u5916\u51fa\u6a21\u5f0f", + "arm_home": "\u8a2d\u5b9a {entity_name} \u8fd4\u5bb6\u6a21\u5f0f", + "arm_night": "\u8a2d\u5b9a {entity_name} \u591c\u9593\u6a21\u5f0f", + "disarm": "\u89e3\u9664 {entity_name}", + "trigger": "\u89f8\u767c {entity_name}" + }, + "trigger_type": { + "armed_away": "{entity_name} \u8a2d\u5b9a\u5916\u51fa", + "armed_home": "{entity_name} \u8a2d\u5b9a\u5728\u5bb6", + "armed_night": "{entity_name} \u8a2d\u5b9a\u591c\u9593", + "disarmed": "{entity_name} \u5df2\u89e3\u9664", + "triggered": "{entity_name} \u5df2\u89f8\u767c" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py index 63977ed88..5fb44a18a 100644 --- a/homeassistant/components/alarm_control_panel/__init__.py +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -1,140 +1,89 @@ -""" -Component to interface with an alarm control panel. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/alarm_control_panel/ -""" -import asyncio +"""Component to interface with an alarm control panel.""" +from abc import abstractmethod from datetime import timedelta import logging import voluptuous as vol from homeassistant.const import ( - ATTR_CODE, ATTR_CODE_FORMAT, ATTR_ENTITY_ID, SERVICE_ALARM_TRIGGER, - SERVICE_ALARM_DISARM, SERVICE_ALARM_ARM_HOME, SERVICE_ALARM_ARM_AWAY, - SERVICE_ALARM_ARM_NIGHT, SERVICE_ALARM_ARM_CUSTOM_BYPASS) -from homeassistant.loader import bind_hass -from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa + ATTR_CODE, + ATTR_CODE_FORMAT, + SERVICE_ALARM_ARM_AWAY, + SERVICE_ALARM_ARM_CUSTOM_BYPASS, + SERVICE_ALARM_ARM_HOME, + SERVICE_ALARM_ARM_NIGHT, + SERVICE_ALARM_DISARM, + SERVICE_ALARM_TRIGGER, +) import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.config_validation import ( # noqa: F401 + PLATFORM_SCHEMA, + PLATFORM_SCHEMA_BASE, + make_entity_service_schema, +) from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent -DOMAIN = 'alarm_control_panel' +from .const import ( + SUPPORT_ALARM_ARM_AWAY, + SUPPORT_ALARM_ARM_CUSTOM_BYPASS, + SUPPORT_ALARM_ARM_HOME, + SUPPORT_ALARM_ARM_NIGHT, + SUPPORT_ALARM_TRIGGER, +) + +DOMAIN = "alarm_control_panel" SCAN_INTERVAL = timedelta(seconds=30) -ATTR_CHANGED_BY = 'changed_by' +ATTR_CHANGED_BY = "changed_by" +FORMAT_TEXT = "text" +FORMAT_NUMBER = "number" +ATTR_CODE_ARM_REQUIRED = "code_arm_required" -ENTITY_ID_FORMAT = DOMAIN + '.{}' +ENTITY_ID_FORMAT = DOMAIN + ".{}" -ALARM_SERVICE_SCHEMA = vol.Schema({ - vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, - vol.Optional(ATTR_CODE): cv.string, -}) +ALARM_SERVICE_SCHEMA = make_entity_service_schema({vol.Optional(ATTR_CODE): cv.string}) -@bind_hass -def alarm_disarm(hass, code=None, entity_id=None): - """Send the alarm the command for disarm.""" - data = {} - if code: - data[ATTR_CODE] = code - if entity_id: - data[ATTR_ENTITY_ID] = entity_id - - hass.services.call(DOMAIN, SERVICE_ALARM_DISARM, data) - - -@bind_hass -def alarm_arm_home(hass, code=None, entity_id=None): - """Send the alarm the command for arm home.""" - data = {} - if code: - data[ATTR_CODE] = code - if entity_id: - data[ATTR_ENTITY_ID] = entity_id - - hass.services.call(DOMAIN, SERVICE_ALARM_ARM_HOME, data) - - -@bind_hass -def alarm_arm_away(hass, code=None, entity_id=None): - """Send the alarm the command for arm away.""" - data = {} - if code: - data[ATTR_CODE] = code - if entity_id: - data[ATTR_ENTITY_ID] = entity_id - - hass.services.call(DOMAIN, SERVICE_ALARM_ARM_AWAY, data) - - -@bind_hass -def alarm_arm_night(hass, code=None, entity_id=None): - """Send the alarm the command for arm night.""" - data = {} - if code: - data[ATTR_CODE] = code - if entity_id: - data[ATTR_ENTITY_ID] = entity_id - - hass.services.call(DOMAIN, SERVICE_ALARM_ARM_NIGHT, data) - - -@bind_hass -def alarm_trigger(hass, code=None, entity_id=None): - """Send the alarm the command for trigger.""" - data = {} - if code: - data[ATTR_CODE] = code - if entity_id: - data[ATTR_ENTITY_ID] = entity_id - - hass.services.call(DOMAIN, SERVICE_ALARM_TRIGGER, data) - - -@bind_hass -def alarm_arm_custom_bypass(hass, code=None, entity_id=None): - """Send the alarm the command for arm custom bypass.""" - data = {} - if code: - data[ATTR_CODE] = code - if entity_id: - data[ATTR_ENTITY_ID] = entity_id - - hass.services.call(DOMAIN, SERVICE_ALARM_ARM_CUSTOM_BYPASS, data) - - -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Track states and offer events for sensors.""" component = hass.data[DOMAIN] = EntityComponent( - logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL) + logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL + ) - yield from component.async_setup(config) + await component.async_setup(config) component.async_register_entity_service( - SERVICE_ALARM_DISARM, ALARM_SERVICE_SCHEMA, - 'async_alarm_disarm' + SERVICE_ALARM_DISARM, ALARM_SERVICE_SCHEMA, "async_alarm_disarm" ) component.async_register_entity_service( - SERVICE_ALARM_ARM_HOME, ALARM_SERVICE_SCHEMA, - 'async_alarm_arm_home' + SERVICE_ALARM_ARM_HOME, + ALARM_SERVICE_SCHEMA, + "async_alarm_arm_home", + [SUPPORT_ALARM_ARM_HOME], ) component.async_register_entity_service( - SERVICE_ALARM_ARM_AWAY, ALARM_SERVICE_SCHEMA, - 'async_alarm_arm_away' + SERVICE_ALARM_ARM_AWAY, + ALARM_SERVICE_SCHEMA, + "async_alarm_arm_away", + [SUPPORT_ALARM_ARM_AWAY], ) component.async_register_entity_service( - SERVICE_ALARM_ARM_NIGHT, ALARM_SERVICE_SCHEMA, - 'async_alarm_arm_night' + SERVICE_ALARM_ARM_NIGHT, + ALARM_SERVICE_SCHEMA, + "async_alarm_arm_night", + [SUPPORT_ALARM_ARM_NIGHT], ) component.async_register_entity_service( - SERVICE_ALARM_ARM_CUSTOM_BYPASS, ALARM_SERVICE_SCHEMA, - 'async_alarm_arm_custom_bypass' + SERVICE_ALARM_ARM_CUSTOM_BYPASS, + ALARM_SERVICE_SCHEMA, + "async_alarm_arm_custom_bypass", + [SUPPORT_ALARM_ARM_CUSTOM_BYPASS], ) component.async_register_entity_service( - SERVICE_ALARM_TRIGGER, ALARM_SERVICE_SCHEMA, - 'async_alarm_trigger' + SERVICE_ALARM_TRIGGER, + ALARM_SERVICE_SCHEMA, + "async_alarm_trigger", + [SUPPORT_ALARM_TRIGGER], ) return True @@ -150,7 +99,6 @@ async def async_unload_entry(hass, entry): return await hass.data[DOMAIN].async_unload_entry(entry) -# pylint: disable=no-self-use class AlarmControlPanel(Entity): """An abstract class for alarm control devices.""" @@ -164,6 +112,11 @@ class AlarmControlPanel(Entity): """Last change triggered by.""" return None + @property + def code_arm_required(self): + """Whether the code is required for arm actions.""" + return True + def alarm_disarm(self, code=None): """Send disarm command.""" raise NotImplementedError() @@ -228,14 +181,19 @@ class AlarmControlPanel(Entity): This method must be run in the event loop and returns a coroutine. """ - return self.hass.async_add_executor_job( - self.alarm_arm_custom_bypass, code) + return self.hass.async_add_executor_job(self.alarm_arm_custom_bypass, code) + + @property + @abstractmethod + def supported_features(self) -> int: + """Return the list of supported features.""" @property def state_attributes(self): """Return the state attributes.""" state_attr = { ATTR_CODE_FORMAT: self.code_format, - ATTR_CHANGED_BY: self.changed_by + ATTR_CHANGED_BY: self.changed_by, + ATTR_CODE_ARM_REQUIRED: self.code_arm_required, } return state_attr diff --git a/homeassistant/components/alarm_control_panel/abode.py b/homeassistant/components/alarm_control_panel/abode.py deleted file mode 100644 index c57666d4f..000000000 --- a/homeassistant/components/alarm_control_panel/abode.py +++ /dev/null @@ -1,85 +0,0 @@ -""" -This component provides HA alarm_control_panel support for Abode System. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/alarm_control_panel.abode/ -""" -import logging - -from homeassistant.components.abode import CONF_ATTRIBUTION, AbodeDevice -from homeassistant.components.abode import DOMAIN as ABODE_DOMAIN -from homeassistant.components.alarm_control_panel import AlarmControlPanel -from homeassistant.const import ( - ATTR_ATTRIBUTION, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, - STATE_ALARM_DISARMED) - -DEPENDENCIES = ['abode'] - -_LOGGER = logging.getLogger(__name__) - -ICON = 'mdi:security' - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up an alarm control panel for an Abode device.""" - data = hass.data[ABODE_DOMAIN] - - alarm_devices = [AbodeAlarm(data, data.abode.get_alarm(), data.name)] - - data.devices.extend(alarm_devices) - - add_entities(alarm_devices) - - -class AbodeAlarm(AbodeDevice, AlarmControlPanel): - """An alarm_control_panel implementation for Abode.""" - - def __init__(self, data, device, name): - """Initialize the alarm control panel.""" - super().__init__(data, device) - self._name = name - - @property - def icon(self): - """Return the icon.""" - return ICON - - @property - def state(self): - """Return the state of the device.""" - if self._device.is_standby: - state = STATE_ALARM_DISARMED - elif self._device.is_away: - state = STATE_ALARM_ARMED_AWAY - elif self._device.is_home: - state = STATE_ALARM_ARMED_HOME - else: - state = None - return state - - def alarm_disarm(self, code=None): - """Send disarm command.""" - self._device.set_standby() - - def alarm_arm_home(self, code=None): - """Send arm home command.""" - self._device.set_home() - - def alarm_arm_away(self, code=None): - """Send arm away command.""" - self._device.set_away() - - @property - def name(self): - """Return the name of the alarm.""" - return self._name or super().name - - @property - def device_state_attributes(self): - """Return the state attributes.""" - return { - ATTR_ATTRIBUTION: CONF_ATTRIBUTION, - 'device_id': self._device.device_id, - 'battery_backup': self._device.battery, - 'cellular_backup': self._device.is_cellular, - } diff --git a/homeassistant/components/alarm_control_panel/alarmdecoder.py b/homeassistant/components/alarm_control_panel/alarmdecoder.py deleted file mode 100644 index 5606209d1..000000000 --- a/homeassistant/components/alarm_control_panel/alarmdecoder.py +++ /dev/null @@ -1,144 +0,0 @@ -""" -Support for AlarmDecoder-based alarm control panels (Honeywell/DSC). - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/alarm_control_panel.alarmdecoder/ -""" -import asyncio -import logging - -import voluptuous as vol - -import homeassistant.components.alarm_control_panel as alarm -from homeassistant.components.alarmdecoder import DATA_AD, SIGNAL_PANEL_MESSAGE -from homeassistant.const import ( - ATTR_CODE, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, - STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED) -import homeassistant.helpers.config_validation as cv - -_LOGGER = logging.getLogger(__name__) - -DEPENDENCIES = ['alarmdecoder'] - -SERVICE_ALARM_TOGGLE_CHIME = 'alarmdecoder_alarm_toggle_chime' -ALARM_TOGGLE_CHIME_SCHEMA = vol.Schema({ - vol.Required(ATTR_CODE): cv.string, -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up for AlarmDecoder alarm panels.""" - device = AlarmDecoderAlarmPanel() - add_entities([device]) - - def alarm_toggle_chime_handler(service): - """Register toggle chime handler.""" - code = service.data.get(ATTR_CODE) - device.alarm_toggle_chime(code) - - hass.services.register( - alarm.DOMAIN, SERVICE_ALARM_TOGGLE_CHIME, alarm_toggle_chime_handler, - schema=ALARM_TOGGLE_CHIME_SCHEMA) - - -class AlarmDecoderAlarmPanel(alarm.AlarmControlPanel): - """Representation of an AlarmDecoder-based alarm panel.""" - - def __init__(self): - """Initialize the alarm panel.""" - self._display = "" - self._name = "Alarm Panel" - self._state = None - self._ac_power = None - self._backlight_on = None - self._battery_low = None - self._check_zone = None - self._chime = None - self._entry_delay_off = None - self._programming_mode = None - self._ready = None - self._zone_bypassed = None - - @asyncio.coroutine - def async_added_to_hass(self): - """Register callbacks.""" - self.hass.helpers.dispatcher.async_dispatcher_connect( - SIGNAL_PANEL_MESSAGE, self._message_callback) - - def _message_callback(self, message): - """Handle received messages.""" - if message.alarm_sounding or message.fire_alarm: - self._state = STATE_ALARM_TRIGGERED - elif message.armed_away: - self._state = STATE_ALARM_ARMED_AWAY - elif message.armed_home: - self._state = STATE_ALARM_ARMED_HOME - else: - self._state = STATE_ALARM_DISARMED - - self._ac_power = message.ac_power - self._backlight_on = message.backlight_on - self._battery_low = message.battery_low - self._check_zone = message.check_zone - self._chime = message.chime_on - self._entry_delay_off = message.entry_delay_off - self._programming_mode = message.programming_mode - self._ready = message.ready - self._zone_bypassed = message.zone_bypassed - - self.schedule_update_ha_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 one or more digits/characters.""" - return 'Number' - - @property - def state(self): - """Return the state of the device.""" - return self._state - - @property - def device_state_attributes(self): - """Return the state attributes.""" - return { - 'ac_power': self._ac_power, - 'backlight_on': self._backlight_on, - 'battery_low': self._battery_low, - 'check_zone': self._check_zone, - 'chime': self._chime, - 'entry_delay_off': self._entry_delay_off, - 'programming_mode': self._programming_mode, - 'ready': self._ready, - 'zone_bypassed': self._zone_bypassed, - } - - def alarm_disarm(self, code=None): - """Send disarm command.""" - if code: - self.hass.data[DATA_AD].send("{!s}1".format(code)) - - def alarm_arm_away(self, code=None): - """Send arm away command.""" - if code: - self.hass.data[DATA_AD].send("{!s}2".format(code)) - - def alarm_arm_home(self, code=None): - """Send arm home command.""" - if code: - self.hass.data[DATA_AD].send("{!s}3".format(code)) - - def alarm_toggle_chime(self, code=None): - """Send toggle chime command.""" - if code: - self.hass.data[DATA_AD].send("{!s}9".format(code)) diff --git a/homeassistant/components/alarm_control_panel/alarmdotcom.py b/homeassistant/components/alarm_control_panel/alarmdotcom.py deleted file mode 100644 index 98766deb3..000000000 --- a/homeassistant/components/alarm_control_panel/alarmdotcom.py +++ /dev/null @@ -1,132 +0,0 @@ -""" -Interfaces with Alarm.com alarm control panels. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/alarm_control_panel.alarmdotcom/ -""" -import asyncio -import logging -import re - -import voluptuous as vol - -import homeassistant.components.alarm_control_panel as alarm -from homeassistant.components.alarm_control_panel import PLATFORM_SCHEMA -from homeassistant.const import ( - CONF_CODE, CONF_NAME, CONF_PASSWORD, CONF_USERNAME, STATE_ALARM_ARMED_AWAY, - STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, STATE_UNKNOWN) -from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv - -REQUIREMENTS = ['pyalarmdotcom==0.3.2'] - -_LOGGER = logging.getLogger(__name__) - -DEFAULT_NAME = 'Alarm.com' - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Optional(CONF_CODE): cv.positive_int, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, -}) - - -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): - """Set up a Alarm.com control panel.""" - name = config.get(CONF_NAME) - code = config.get(CONF_CODE) - username = config.get(CONF_USERNAME) - password = config.get(CONF_PASSWORD) - - alarmdotcom = AlarmDotCom(hass, name, code, username, password) - yield from alarmdotcom.async_login() - async_add_entities([alarmdotcom]) - - -class AlarmDotCom(alarm.AlarmControlPanel): - """Representation of an Alarm.com status.""" - - def __init__(self, hass, name, code, username, password): - """Initialize the Alarm.com status.""" - from pyalarmdotcom import Alarmdotcom - _LOGGER.debug('Setting up Alarm.com...') - self._hass = hass - self._name = name - self._code = str(code) if code else None - self._username = username - self._password = password - self._websession = async_get_clientsession(self._hass) - self._state = STATE_UNKNOWN - self._alarm = Alarmdotcom( - username, password, self._websession, hass.loop) - - @asyncio.coroutine - def async_login(self): - """Login to Alarm.com.""" - yield from self._alarm.async_login() - - @asyncio.coroutine - def async_update(self): - """Fetch the latest state.""" - yield from self._alarm.async_update() - return self._alarm.state - - @property - def name(self): - """Return the name of the alarm.""" - return self._name - - @property - def code_format(self): - """Return one or more digits/characters.""" - if self._code is None: - return None - if isinstance(self._code, str) and re.search('^\\d+$', self._code): - return 'Number' - return 'Any' - - @property - def state(self): - """Return the state of the device.""" - if self._alarm.state.lower() == 'disarmed': - return STATE_ALARM_DISARMED - if self._alarm.state.lower() == 'armed stay': - return STATE_ALARM_ARMED_HOME - if self._alarm.state.lower() == 'armed away': - return STATE_ALARM_ARMED_AWAY - return STATE_UNKNOWN - - @property - def device_state_attributes(self): - """Return the state attributes.""" - return { - 'sensor_status': self._alarm.sensor_status - } - - @asyncio.coroutine - def async_alarm_disarm(self, code=None): - """Send disarm command.""" - if self._validate_code(code): - yield from self._alarm.async_alarm_disarm() - - @asyncio.coroutine - def async_alarm_arm_home(self, code=None): - """Send arm hom command.""" - if self._validate_code(code): - yield from self._alarm.async_alarm_arm_home() - - @asyncio.coroutine - def async_alarm_arm_away(self, code=None): - """Send arm away command.""" - if self._validate_code(code): - yield from self._alarm.async_alarm_arm_away() - - def _validate_code(self, code): - """Validate given code.""" - check = self._code is None or code == self._code - if not check: - _LOGGER.warning("Wrong code entered") - return check diff --git a/homeassistant/components/alarm_control_panel/arlo.py b/homeassistant/components/alarm_control_panel/arlo.py deleted file mode 100644 index 8842c710a..000000000 --- a/homeassistant/components/alarm_control_panel/arlo.py +++ /dev/null @@ -1,131 +0,0 @@ -""" -Support for Arlo Alarm Control Panels. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/alarm_control_panel.arlo/ -""" -import logging - -import voluptuous as vol - -import homeassistant.helpers.config_validation as cv -from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.components.alarm_control_panel import ( - AlarmControlPanel, PLATFORM_SCHEMA) -from homeassistant.components.arlo import ( - DATA_ARLO, CONF_ATTRIBUTION, SIGNAL_UPDATE_ARLO) -from homeassistant.const import ( - ATTR_ATTRIBUTION, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, - STATE_ALARM_DISARMED) - -_LOGGER = logging.getLogger(__name__) - -ARMED = 'armed' - -CONF_HOME_MODE_NAME = 'home_mode_name' -CONF_AWAY_MODE_NAME = 'away_mode_name' - -DEPENDENCIES = ['arlo'] - -DISARMED = 'disarmed' - -ICON = 'mdi:security' - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_HOME_MODE_NAME, default=ARMED): cv.string, - vol.Optional(CONF_AWAY_MODE_NAME, default=ARMED): cv.string, -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Arlo Alarm Control Panels.""" - arlo = hass.data[DATA_ARLO] - - if not arlo.base_stations: - return - - home_mode_name = config.get(CONF_HOME_MODE_NAME) - away_mode_name = config.get(CONF_AWAY_MODE_NAME) - base_stations = [] - for base_station in arlo.base_stations: - base_stations.append(ArloBaseStation(base_station, home_mode_name, - away_mode_name)) - add_entities(base_stations, True) - - -class ArloBaseStation(AlarmControlPanel): - """Representation of an Arlo Alarm Control Panel.""" - - def __init__(self, data, home_mode_name, away_mode_name): - """Initialize the alarm control panel.""" - self._base_station = data - self._home_mode_name = home_mode_name - self._away_mode_name = away_mode_name - self._state = None - - @property - def icon(self): - """Return icon.""" - return ICON - - async def async_added_to_hass(self): - """Register callbacks.""" - async_dispatcher_connect( - self.hass, SIGNAL_UPDATE_ARLO, self._update_callback) - - @callback - def _update_callback(self): - """Call update method.""" - self.async_schedule_update_ha_state(True) - - @property - def state(self): - """Return the state of the device.""" - return self._state - - def update(self): - """Update the state of the device.""" - _LOGGER.debug("Updating Arlo Alarm Control Panel %s", self.name) - mode = self._base_station.mode - if mode: - self._state = self._get_state_from_mode(mode) - else: - self._state = None - - async def async_alarm_disarm(self, code=None): - """Send disarm command.""" - self._base_station.mode = DISARMED - - async def async_alarm_arm_away(self, code=None): - """Send arm away command. Uses custom mode.""" - self._base_station.mode = self._away_mode_name - - async def async_alarm_arm_home(self, code=None): - """Send arm home command. Uses custom mode.""" - self._base_station.mode = self._home_mode_name - - @property - def name(self): - """Return the name of the base station.""" - return self._base_station.name - - @property - def device_state_attributes(self): - """Return the state attributes.""" - return { - ATTR_ATTRIBUTION: CONF_ATTRIBUTION, - 'device_id': self._base_station.device_id - } - - def _get_state_from_mode(self, mode): - """Convert Arlo mode to Home Assistant state.""" - if mode == ARMED: - return STATE_ALARM_ARMED_AWAY - if mode == DISARMED: - return STATE_ALARM_DISARMED - if mode == self._home_mode_name: - return STATE_ALARM_ARMED_HOME - if mode == self._away_mode_name: - return STATE_ALARM_ARMED_AWAY - return mode diff --git a/homeassistant/components/alarm_control_panel/canary.py b/homeassistant/components/alarm_control_panel/canary.py deleted file mode 100644 index b22a76fdb..000000000 --- a/homeassistant/components/alarm_control_panel/canary.py +++ /dev/null @@ -1,91 +0,0 @@ -""" -Support for Canary alarm. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/alarm_control_panel.canary/ -""" -import logging - -from homeassistant.components.alarm_control_panel import AlarmControlPanel -from homeassistant.components.canary import DATA_CANARY -from homeassistant.const import STATE_ALARM_DISARMED, STATE_ALARM_ARMED_AWAY, \ - STATE_ALARM_ARMED_NIGHT, STATE_ALARM_ARMED_HOME - -DEPENDENCIES = ['canary'] - -_LOGGER = logging.getLogger(__name__) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Canary alarms.""" - data = hass.data[DATA_CANARY] - devices = [] - - for location in data.locations: - devices.append(CanaryAlarm(data, location.location_id)) - - add_entities(devices, True) - - -class CanaryAlarm(AlarmControlPanel): - """Representation of a Canary alarm control panel.""" - - def __init__(self, data, location_id): - """Initialize a Canary security camera.""" - self._data = data - self._location_id = location_id - - @property - def name(self): - """Return the name of the alarm.""" - location = self._data.get_location(self._location_id) - return location.name - - @property - def state(self): - """Return the state of the device.""" - from canary.api import LOCATION_MODE_AWAY, LOCATION_MODE_HOME, \ - LOCATION_MODE_NIGHT - - location = self._data.get_location(self._location_id) - - if location.is_private: - return STATE_ALARM_DISARMED - - mode = location.mode - if mode.name == LOCATION_MODE_AWAY: - return STATE_ALARM_ARMED_AWAY - if mode.name == LOCATION_MODE_HOME: - return STATE_ALARM_ARMED_HOME - if mode.name == LOCATION_MODE_NIGHT: - return STATE_ALARM_ARMED_NIGHT - return None - - @property - def device_state_attributes(self): - """Return the state attributes.""" - location = self._data.get_location(self._location_id) - return { - 'private': location.is_private - } - - def alarm_disarm(self, code=None): - """Send disarm command.""" - location = self._data.get_location(self._location_id) - self._data.set_location_mode(self._location_id, location.mode.name, - True) - - def alarm_arm_home(self, code=None): - """Send arm home command.""" - from canary.api import LOCATION_MODE_HOME - self._data.set_location_mode(self._location_id, LOCATION_MODE_HOME) - - def alarm_arm_away(self, code=None): - """Send arm away command.""" - from canary.api import LOCATION_MODE_AWAY - self._data.set_location_mode(self._location_id, LOCATION_MODE_AWAY) - - def alarm_arm_night(self, code=None): - """Send arm night command.""" - from canary.api import LOCATION_MODE_NIGHT - self._data.set_location_mode(self._location_id, LOCATION_MODE_NIGHT) diff --git a/homeassistant/components/alarm_control_panel/concord232.py b/homeassistant/components/alarm_control_panel/concord232.py deleted file mode 100644 index e3c2b4a7e..000000000 --- a/homeassistant/components/alarm_control_panel/concord232.py +++ /dev/null @@ -1,124 +0,0 @@ -""" -Support for Concord232 alarm control panels. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/alarm_control_panel.concord232/ -""" -import datetime -from datetime import timedelta -import logging - -import requests -import voluptuous as vol - -import homeassistant.components.alarm_control_panel as alarm -from homeassistant.components.alarm_control_panel import PLATFORM_SCHEMA -from homeassistant.const import ( - 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 = ['concord232==0.15'] - -_LOGGER = logging.getLogger(__name__) - -DEFAULT_HOST = 'localhost' -DEFAULT_NAME = 'CONCORD232' -DEFAULT_PORT = 5007 - -SCAN_INTERVAL = timedelta(seconds=10) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Concord232 alarm control panel platform.""" - name = config.get(CONF_NAME) - host = config.get(CONF_HOST) - port = config.get(CONF_PORT) - - url = 'http://{}:{}'.format(host, port) - - try: - add_entities([Concord232Alarm(hass, url, name)]) - except requests.exceptions.ConnectionError as ex: - _LOGGER.error("Unable to connect to Concord232: %s", str(ex)) - return - - -class Concord232Alarm(alarm.AlarmControlPanel): - """Representation of the Concord232-based alarm panel.""" - - def __init__(self, hass, url, name): - """Initialize the Concord232 alarm panel.""" - from concord232 import client as concord232_client - - self._state = STATE_UNKNOWN - self._hass = hass - self._name = name - self._url = url - - try: - client = concord232_client.Client(self._url) - except requests.exceptions.ConnectionError as ex: - _LOGGER.error("Unable to connect to Concord232: %s", str(ex)) - - self._alarm = client - self._alarm.partitions = self._alarm.list_partitions() - self._alarm.last_partition_update = datetime.datetime.now() - self.update() - - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def code_format(self): - """Return the characters if code is defined.""" - return 'Number' - - @property - def state(self): - """Return the state of the device.""" - return self._state - - def update(self): - """Update values from API.""" - try: - part = self._alarm.list_partitions()[0] - except requests.exceptions.ConnectionError as ex: - _LOGGER.error("Unable to connect to %(host)s: %(reason)s", - dict(host=self._url, reason=ex)) - newstate = STATE_UNKNOWN - except IndexError: - _LOGGER.error("Concord232 reports no partitions") - newstate = STATE_UNKNOWN - - if part['arming_level'] == 'Off': - newstate = STATE_ALARM_DISARMED - elif 'Home' in part['arming_level']: - newstate = STATE_ALARM_ARMED_HOME - else: - newstate = STATE_ALARM_ARMED_AWAY - - if not newstate == self._state: - _LOGGER.info("State change from %s to %s", self._state, newstate) - self._state = newstate - return self._state - - def alarm_disarm(self, code=None): - """Send disarm command.""" - self._alarm.disarm(code) - - def alarm_arm_home(self, code=None): - """Send arm home command.""" - self._alarm.arm('stay') - - def alarm_arm_away(self, code=None): - """Send arm away command.""" - self._alarm.arm('away') diff --git a/homeassistant/components/alarm_control_panel/const.py b/homeassistant/components/alarm_control_panel/const.py new file mode 100644 index 000000000..77f7846fc --- /dev/null +++ b/homeassistant/components/alarm_control_panel/const.py @@ -0,0 +1,7 @@ +"""Provides the constants needed for component.""" + +SUPPORT_ALARM_ARM_HOME = 1 +SUPPORT_ALARM_ARM_AWAY = 2 +SUPPORT_ALARM_ARM_NIGHT = 4 +SUPPORT_ALARM_TRIGGER = 8 +SUPPORT_ALARM_ARM_CUSTOM_BYPASS = 16 diff --git a/homeassistant/components/alarm_control_panel/demo.py b/homeassistant/components/alarm_control_panel/demo.py deleted file mode 100644 index a3fbe4947..000000000 --- a/homeassistant/components/alarm_control_panel/demo.py +++ /dev/null @@ -1,48 +0,0 @@ -""" -Demo platform that has two fake alarm control panels. - -For more details about this platform, please refer to the documentation -https://home-assistant.io/components/demo/ -""" -import datetime -from homeassistant.components.alarm_control_panel import manual -from homeassistant.const import ( - STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_CUSTOM_BYPASS, - STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, - STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, CONF_DELAY_TIME, - CONF_PENDING_TIME, CONF_TRIGGER_TIME) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Demo alarm control panel platform.""" - add_entities([ - manual.ManualAlarm(hass, 'Alarm', '1234', None, False, { - STATE_ALARM_ARMED_AWAY: { - CONF_DELAY_TIME: datetime.timedelta(seconds=0), - CONF_PENDING_TIME: datetime.timedelta(seconds=5), - CONF_TRIGGER_TIME: datetime.timedelta(seconds=10), - }, - STATE_ALARM_ARMED_HOME: { - CONF_DELAY_TIME: datetime.timedelta(seconds=0), - CONF_PENDING_TIME: datetime.timedelta(seconds=5), - CONF_TRIGGER_TIME: datetime.timedelta(seconds=10), - }, - STATE_ALARM_ARMED_NIGHT: { - CONF_DELAY_TIME: datetime.timedelta(seconds=0), - CONF_PENDING_TIME: datetime.timedelta(seconds=5), - CONF_TRIGGER_TIME: datetime.timedelta(seconds=10), - }, - STATE_ALARM_DISARMED: { - CONF_DELAY_TIME: datetime.timedelta(seconds=0), - CONF_TRIGGER_TIME: datetime.timedelta(seconds=10), - }, - STATE_ALARM_ARMED_CUSTOM_BYPASS: { - CONF_DELAY_TIME: datetime.timedelta(seconds=0), - CONF_PENDING_TIME: datetime.timedelta(seconds=5), - CONF_TRIGGER_TIME: datetime.timedelta(seconds=10), - }, - STATE_ALARM_TRIGGERED: { - CONF_PENDING_TIME: datetime.timedelta(seconds=5), - }, - }), - ]) diff --git a/homeassistant/components/alarm_control_panel/device_action.py b/homeassistant/components/alarm_control_panel/device_action.py new file mode 100644 index 000000000..81e444ae1 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/device_action.py @@ -0,0 +1,146 @@ +"""Provides device automations for Alarm control panel.""" +from typing import List, Optional + +import voluptuous as vol + +from homeassistant.const import ( + ATTR_CODE, + ATTR_ENTITY_ID, + CONF_CODE, + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_ENTITY_ID, + CONF_TYPE, + SERVICE_ALARM_ARM_AWAY, + SERVICE_ALARM_ARM_HOME, + SERVICE_ALARM_ARM_NIGHT, + SERVICE_ALARM_DISARM, + SERVICE_ALARM_TRIGGER, +) +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import entity_registry +import homeassistant.helpers.config_validation as cv + +from . import ATTR_CODE_ARM_REQUIRED, DOMAIN +from .const import ( + SUPPORT_ALARM_ARM_AWAY, + SUPPORT_ALARM_ARM_HOME, + SUPPORT_ALARM_ARM_NIGHT, + SUPPORT_ALARM_TRIGGER, +) + +ACTION_TYPES = {"arm_away", "arm_home", "arm_night", "disarm", "trigger"} + +ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): vol.In(ACTION_TYPES), + vol.Required(CONF_ENTITY_ID): cv.entity_domain(DOMAIN), + vol.Optional(CONF_CODE): cv.string, + } +) + + +async def async_get_actions(hass: HomeAssistant, device_id: str) -> List[dict]: + """List device actions for Alarm control panel devices.""" + registry = await entity_registry.async_get_registry(hass) + actions = [] + + # Get all the integrations entities for this device + for entry in entity_registry.async_entries_for_device(registry, device_id): + if entry.domain != DOMAIN: + continue + + state = hass.states.get(entry.entity_id) + + # We need a state or else we can't populate the HVAC and preset modes. + if state is None: + continue + + supported_features = state.attributes["supported_features"] + + # Add actions for each entity that belongs to this integration + if supported_features & SUPPORT_ALARM_ARM_AWAY: + actions.append( + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "arm_away", + } + ) + if supported_features & SUPPORT_ALARM_ARM_HOME: + actions.append( + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "arm_home", + } + ) + if supported_features & SUPPORT_ALARM_ARM_NIGHT: + actions.append( + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "arm_night", + } + ) + actions.append( + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "disarm", + } + ) + if supported_features & SUPPORT_ALARM_TRIGGER: + actions.append( + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "trigger", + } + ) + + return actions + + +async def async_call_action_from_config( + hass: HomeAssistant, config: dict, variables: dict, context: Optional[Context] +) -> None: + """Execute a device action.""" + config = ACTION_SCHEMA(config) + + service_data = {ATTR_ENTITY_ID: config[CONF_ENTITY_ID]} + if CONF_CODE in config: + service_data[ATTR_CODE] = config[CONF_CODE] + + if config[CONF_TYPE] == "arm_away": + service = SERVICE_ALARM_ARM_AWAY + elif config[CONF_TYPE] == "arm_home": + service = SERVICE_ALARM_ARM_HOME + elif config[CONF_TYPE] == "arm_night": + service = SERVICE_ALARM_ARM_NIGHT + elif config[CONF_TYPE] == "disarm": + service = SERVICE_ALARM_DISARM + elif config[CONF_TYPE] == "trigger": + service = SERVICE_ALARM_TRIGGER + + await hass.services.async_call( + DOMAIN, service, service_data, blocking=True, context=context + ) + + +async def async_get_action_capabilities(hass, config): + """List action capabilities.""" + state = hass.states.get(config[CONF_ENTITY_ID]) + code_required = state.attributes.get(ATTR_CODE_ARM_REQUIRED) if state else False + + if config[CONF_TYPE] == "trigger" or ( + config[CONF_TYPE] != "disarm" and not code_required + ): + return {} + + return {"extra_fields": vol.Schema({vol.Optional(CONF_CODE): str})} diff --git a/homeassistant/components/alarm_control_panel/device_trigger.py b/homeassistant/components/alarm_control_panel/device_trigger.py new file mode 100644 index 000000000..95ae17aaa --- /dev/null +++ b/homeassistant/components/alarm_control_panel/device_trigger.py @@ -0,0 +1,151 @@ +"""Provides device automations for Alarm control panel.""" +from typing import List + +import voluptuous as vol + +from homeassistant.components.alarm_control_panel.const import ( + SUPPORT_ALARM_ARM_AWAY, + SUPPORT_ALARM_ARM_HOME, + SUPPORT_ALARM_ARM_NIGHT, +) +from homeassistant.components.automation import AutomationActionType, state +from homeassistant.components.device_automation import TRIGGER_BASE_SCHEMA +from homeassistant.const import ( + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_ENTITY_ID, + CONF_PLATFORM, + CONF_TYPE, + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_DISARMED, + STATE_ALARM_PENDING, + STATE_ALARM_TRIGGERED, +) +from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers.typing import ConfigType + +from . import DOMAIN + +TRIGGER_TYPES = { + "triggered", + "disarmed", + "armed_home", + "armed_away", + "armed_night", +} + +TRIGGER_SCHEMA = TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TYPE): vol.In(TRIGGER_TYPES), + } +) + + +async def async_get_triggers(hass: HomeAssistant, device_id: str) -> List[dict]: + """List device triggers for Alarm control panel devices.""" + registry = await entity_registry.async_get_registry(hass) + triggers = [] + + # Get all the integrations entities for this device + for entry in entity_registry.async_entries_for_device(registry, device_id): + if entry.domain != DOMAIN: + continue + + entity_state = hass.states.get(entry.entity_id) + + # We need a state or else we can't populate the HVAC and preset modes. + if entity_state is None: + continue + + supported_features = entity_state.attributes["supported_features"] + + # Add triggers for each entity that belongs to this integration + triggers += [ + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "disarmed", + }, + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "triggered", + }, + ] + if supported_features & SUPPORT_ALARM_ARM_HOME: + triggers.append( + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "armed_home", + } + ) + if supported_features & SUPPORT_ALARM_ARM_AWAY: + triggers.append( + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "armed_away", + } + ) + if supported_features & SUPPORT_ALARM_ARM_NIGHT: + triggers.append( + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "armed_night", + } + ) + + return triggers + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: AutomationActionType, + automation_info: dict, +) -> CALLBACK_TYPE: + """Attach a trigger.""" + config = TRIGGER_SCHEMA(config) + + if config[CONF_TYPE] == "triggered": + from_state = STATE_ALARM_PENDING + to_state = STATE_ALARM_TRIGGERED + elif config[CONF_TYPE] == "disarmed": + from_state = STATE_ALARM_TRIGGERED + to_state = STATE_ALARM_DISARMED + elif config[CONF_TYPE] == "armed_home": + from_state = STATE_ALARM_PENDING + to_state = STATE_ALARM_ARMED_HOME + elif config[CONF_TYPE] == "armed_away": + from_state = STATE_ALARM_PENDING + to_state = STATE_ALARM_ARMED_AWAY + elif config[CONF_TYPE] == "armed_night": + from_state = STATE_ALARM_PENDING + to_state = STATE_ALARM_ARMED_NIGHT + + state_config = { + state.CONF_PLATFORM: "state", + CONF_ENTITY_ID: config[CONF_ENTITY_ID], + state.CONF_FROM: from_state, + state.CONF_TO: to_state, + } + state_config = state.TRIGGER_SCHEMA(state_config) + return await state.async_attach_trigger( + hass, state_config, action, automation_info, platform_type="device" + ) diff --git a/homeassistant/components/alarm_control_panel/egardia.py b/homeassistant/components/alarm_control_panel/egardia.py deleted file mode 100644 index 4e278c10e..000000000 --- a/homeassistant/components/alarm_control_panel/egardia.py +++ /dev/null @@ -1,144 +0,0 @@ -""" -Interfaces with Egardia/Woonveilig alarm control panel. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/alarm_control_panel.egardia/ -""" -import asyncio -import logging - -import requests - -import homeassistant.components.alarm_control_panel as alarm -from homeassistant.const import ( - STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME, - STATE_ALARM_ARMED_AWAY, STATE_ALARM_TRIGGERED, - STATE_ALARM_ARMED_NIGHT) -from homeassistant.components.egardia import ( - EGARDIA_DEVICE, EGARDIA_SERVER, - REPORT_SERVER_CODES_IGNORE, CONF_REPORT_SERVER_CODES, - CONF_REPORT_SERVER_ENABLED, CONF_REPORT_SERVER_PORT - ) -DEPENDENCIES = ['egardia'] - -_LOGGER = logging.getLogger(__name__) - -STATES = { - 'ARM': STATE_ALARM_ARMED_AWAY, - 'DAY HOME': STATE_ALARM_ARMED_HOME, - 'DISARM': STATE_ALARM_DISARMED, - 'ARMHOME': STATE_ALARM_ARMED_HOME, - 'HOME': STATE_ALARM_ARMED_HOME, - 'NIGHT HOME': STATE_ALARM_ARMED_NIGHT, - 'TRIGGERED': STATE_ALARM_TRIGGERED -} - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Egardia platform.""" - if discovery_info is None: - return - device = EgardiaAlarm( - discovery_info['name'], - hass.data[EGARDIA_DEVICE], - discovery_info[CONF_REPORT_SERVER_ENABLED], - discovery_info.get(CONF_REPORT_SERVER_CODES), - discovery_info[CONF_REPORT_SERVER_PORT]) - # add egardia alarm device - add_entities([device], True) - - -class EgardiaAlarm(alarm.AlarmControlPanel): - """Representation of a Egardia alarm.""" - - def __init__(self, name, egardiasystem, - rs_enabled=False, rs_codes=None, rs_port=52010): - """Initialize the Egardia alarm.""" - self._name = name - self._egardiasystem = egardiasystem - self._status = None - self._rs_enabled = rs_enabled - self._rs_codes = rs_codes - self._rs_port = rs_port - - @asyncio.coroutine - def async_added_to_hass(self): - """Add Egardiaserver callback if enabled.""" - if self._rs_enabled: - _LOGGER.debug("Registering callback to Egardiaserver") - self.hass.data[EGARDIA_SERVER].register_callback( - self.handle_status_event) - - @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._status - - @property - def should_poll(self): - """Poll if no report server is enabled.""" - if not self._rs_enabled: - return True - return False - - def handle_status_event(self, event): - """Handle the Egardia system status event.""" - statuscode = event.get('status') - if statuscode is not None: - status = self.lookupstatusfromcode(statuscode) - self.parsestatus(status) - self.schedule_update_ha_state() - - def lookupstatusfromcode(self, statuscode): - """Look at the rs_codes and returns the status from the code.""" - status = next(( - status_group.upper() for status_group, codes - in self._rs_codes.items() for code in codes - if statuscode == code), 'UNKNOWN') - return status - - def parsestatus(self, status): - """Parse the status.""" - _LOGGER.debug("Parsing status %s", status) - # Ignore the statuscode if it is IGNORE - if status.lower().strip() != REPORT_SERVER_CODES_IGNORE: - _LOGGER.debug("Not ignoring status %s", status) - newstatus = STATES.get(status.upper()) - _LOGGER.debug("newstatus %s", newstatus) - self._status = newstatus - else: - _LOGGER.error("Ignoring status") - - def update(self): - """Update the alarm status.""" - status = self._egardiasystem.getstate() - self.parsestatus(status) - - def alarm_disarm(self, code=None): - """Send disarm command.""" - try: - self._egardiasystem.alarm_disarm() - except requests.exceptions.RequestException as err: - _LOGGER.error("Egardia device exception occurred when " - "sending disarm command: %s", err) - - def alarm_arm_home(self, code=None): - """Send arm home command.""" - try: - self._egardiasystem.alarm_arm_home() - except requests.exceptions.RequestException as err: - _LOGGER.error("Egardia device exception occurred when " - "sending arm home command: %s", err) - - def alarm_arm_away(self, code=None): - """Send arm away command.""" - try: - self._egardiasystem.alarm_arm_away() - except requests.exceptions.RequestException as err: - _LOGGER.error("Egardia device exception occurred when " - "sending arm away command: %s", err) diff --git a/homeassistant/components/alarm_control_panel/envisalink.py b/homeassistant/components/alarm_control_panel/envisalink.py deleted file mode 100644 index df91884b3..000000000 --- a/homeassistant/components/alarm_control_panel/envisalink.py +++ /dev/null @@ -1,171 +0,0 @@ -""" -Support for Envisalink-based alarm control panels (Honeywell/DSC). - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/alarm_control_panel.envisalink/ -""" -import asyncio -import logging - -import voluptuous as vol - -from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect -import homeassistant.components.alarm_control_panel as alarm -import homeassistant.helpers.config_validation as cv -from homeassistant.components.envisalink import ( - DATA_EVL, EnvisalinkDevice, PARTITION_SCHEMA, CONF_CODE, CONF_PANIC, - CONF_PARTITIONNAME, SIGNAL_KEYPAD_UPDATE, SIGNAL_PARTITION_UPDATE) -from homeassistant.const import ( - STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, - STATE_UNKNOWN, STATE_ALARM_TRIGGERED, STATE_ALARM_PENDING, ATTR_ENTITY_ID) - -_LOGGER = logging.getLogger(__name__) - -DEPENDENCIES = ['envisalink'] - -SERVICE_ALARM_KEYPRESS = 'envisalink_alarm_keypress' -ATTR_KEYPRESS = 'keypress' -ALARM_KEYPRESS_SCHEMA = vol.Schema({ - vol.Required(ATTR_ENTITY_ID): cv.entity_ids, - vol.Required(ATTR_KEYPRESS): cv.string -}) - - -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): - """Perform the setup for Envisalink alarm panels.""" - configured_partitions = discovery_info['partitions'] - code = discovery_info[CONF_CODE] - panic_type = discovery_info[CONF_PANIC] - - devices = [] - for part_num in configured_partitions: - device_config_data = PARTITION_SCHEMA(configured_partitions[part_num]) - device = EnvisalinkAlarm( - hass, - part_num, - device_config_data[CONF_PARTITIONNAME], - code, - panic_type, - hass.data[DATA_EVL].alarm_state['partition'][part_num], - hass.data[DATA_EVL] - ) - devices.append(device) - - async_add_entities(devices) - - @callback - def alarm_keypress_handler(service): - """Map services to methods on Alarm.""" - entity_ids = service.data.get(ATTR_ENTITY_ID) - keypress = service.data.get(ATTR_KEYPRESS) - - target_devices = [device for device in devices - if device.entity_id in entity_ids] - - for device in target_devices: - device.async_alarm_keypress(keypress) - - hass.services.async_register( - alarm.DOMAIN, SERVICE_ALARM_KEYPRESS, alarm_keypress_handler, - schema=ALARM_KEYPRESS_SCHEMA) - - return True - - -class EnvisalinkAlarm(EnvisalinkDevice, alarm.AlarmControlPanel): - """Representation of an Envisalink-based alarm panel.""" - - def __init__(self, hass, partition_number, alarm_name, code, panic_type, - info, controller): - """Initialize the alarm panel.""" - self._partition_number = partition_number - self._code = code - self._panic_type = panic_type - - _LOGGER.debug("Setting up alarm: %s", alarm_name) - super().__init__(alarm_name, info, controller) - - @asyncio.coroutine - def async_added_to_hass(self): - """Register callbacks.""" - async_dispatcher_connect( - self.hass, SIGNAL_KEYPAD_UPDATE, self._update_callback) - async_dispatcher_connect( - self.hass, SIGNAL_PARTITION_UPDATE, self._update_callback) - - @callback - def _update_callback(self, partition): - """Update Home Assistant state, if needed.""" - if partition is None or int(partition) == self._partition_number: - self.async_schedule_update_ha_state() - - @property - def code_format(self): - """Regex for code format or None if no code is required.""" - if self._code: - return None - return 'Number' - - @property - def state(self): - """Return the state of the device.""" - state = STATE_UNKNOWN - - if self._info['status']['alarm']: - state = STATE_ALARM_TRIGGERED - elif self._info['status']['armed_away']: - state = STATE_ALARM_ARMED_AWAY - elif self._info['status']['armed_stay']: - state = STATE_ALARM_ARMED_HOME - elif self._info['status']['exit_delay']: - state = STATE_ALARM_PENDING - elif self._info['status']['entry_delay']: - state = STATE_ALARM_PENDING - elif self._info['status']['alpha']: - state = STATE_ALARM_DISARMED - return state - - @asyncio.coroutine - def async_alarm_disarm(self, code=None): - """Send disarm command.""" - if code: - self.hass.data[DATA_EVL].disarm_partition( - str(code), self._partition_number) - else: - self.hass.data[DATA_EVL].disarm_partition( - str(self._code), self._partition_number) - - @asyncio.coroutine - def async_alarm_arm_home(self, code=None): - """Send arm home command.""" - if code: - self.hass.data[DATA_EVL].arm_stay_partition( - str(code), self._partition_number) - else: - self.hass.data[DATA_EVL].arm_stay_partition( - str(self._code), self._partition_number) - - @asyncio.coroutine - def async_alarm_arm_away(self, code=None): - """Send arm away command.""" - if code: - self.hass.data[DATA_EVL].arm_away_partition( - str(code), self._partition_number) - else: - self.hass.data[DATA_EVL].arm_away_partition( - str(self._code), self._partition_number) - - @asyncio.coroutine - def async_alarm_trigger(self, code=None): - """Alarm trigger command. Will be used to trigger a panic alarm.""" - self.hass.data[DATA_EVL].panic_alarm(self._panic_type) - - @callback - def async_alarm_keypress(self, keypress=None): - """Send custom keypress.""" - if keypress: - self.hass.data[DATA_EVL].keypresses_to_partition( - self._partition_number, keypress) diff --git a/homeassistant/components/alarm_control_panel/homematicip_cloud.py b/homeassistant/components/alarm_control_panel/homematicip_cloud.py deleted file mode 100644 index 8c4831216..000000000 --- a/homeassistant/components/alarm_control_panel/homematicip_cloud.py +++ /dev/null @@ -1,83 +0,0 @@ -""" -Support for HomematicIP Cloud alarm control panel. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/alarm_control_panel.homematicip_cloud/ -""" - -import logging - -from homeassistant.components.alarm_control_panel import AlarmControlPanel -from homeassistant.components.homematicip_cloud import ( - HMIPC_HAPID, HomematicipGenericDevice) -from homeassistant.components.homematicip_cloud import DOMAIN as HMIPC_DOMAIN -from homeassistant.const import ( - STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, - STATE_ALARM_TRIGGERED) - -_LOGGER = logging.getLogger(__name__) - -DEPENDENCIES = ['homematicip_cloud'] - -HMIP_ZONE_AWAY = 'EXTERNAL' -HMIP_ZONE_HOME = 'INTERNAL' - - -async def async_setup_platform( - hass, config, async_add_entities, discovery_info=None): - """Set up the HomematicIP Cloud alarm control devices.""" - pass - - -async def async_setup_entry(hass, config_entry, async_add_entities): - """Set up the HomematicIP alarm control panel from a config entry.""" - from homematicip.aio.group import AsyncSecurityZoneGroup - - home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home - devices = [] - for group in home.groups: - if isinstance(group, AsyncSecurityZoneGroup): - devices.append(HomematicipSecurityZone(home, group)) - - if devices: - async_add_entities(devices) - - -class HomematicipSecurityZone(HomematicipGenericDevice, AlarmControlPanel): - """Representation of an HomematicIP Cloud security zone group.""" - - def __init__(self, home, device): - """Initialize the security zone group.""" - device.modelType = 'Group-SecurityZone' - device.windowState = '' - super().__init__(home, device) - - @property - def state(self): - """Return the state of the device.""" - from homematicip.base.enums import WindowState - - if self._device.active: - if (self._device.sabotage or self._device.motionDetected or - self._device.windowState == WindowState.OPEN): - return STATE_ALARM_TRIGGERED - - active = self._home.get_security_zones_activation() - if active == (True, True): - return STATE_ALARM_ARMED_AWAY - if active == (False, True): - return STATE_ALARM_ARMED_HOME - - return STATE_ALARM_DISARMED - - async def async_alarm_disarm(self, code=None): - """Send disarm command.""" - await self._home.set_security_zones_activation(False, False) - - async def async_alarm_arm_home(self, code=None): - """Send arm home command.""" - await self._home.set_security_zones_activation(True, False) - - async def async_alarm_arm_away(self, code=None): - """Send arm away command.""" - await self._home.set_security_zones_activation(True, True) diff --git a/homeassistant/components/alarm_control_panel/ialarm.py b/homeassistant/components/alarm_control_panel/ialarm.py deleted file mode 100644 index 3f41ee579..000000000 --- a/homeassistant/components/alarm_control_panel/ialarm.py +++ /dev/null @@ -1,107 +0,0 @@ -""" -Interfaces with iAlarm control panels. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/alarm_control_panel.ialarm/ -""" -import logging - -import voluptuous as vol - -import homeassistant.components.alarm_control_panel as alarm -from homeassistant.components.alarm_control_panel import PLATFORM_SCHEMA -from homeassistant.const import ( - CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME, STATE_ALARM_ARMED_AWAY, - STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED) -import homeassistant.helpers.config_validation as cv - -REQUIREMENTS = ['pyialarm==0.2'] - -_LOGGER = logging.getLogger(__name__) - -DEFAULT_NAME = 'iAlarm' - - -def no_application_protocol(value): - """Validate that value is without the application protocol.""" - protocol_separator = "://" - if not value or protocol_separator in value: - raise vol.Invalid( - 'Invalid host, {} is not allowed'.format(protocol_separator)) - - return value - - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_HOST): vol.All(cv.string, no_application_protocol), - 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_entities, discovery_info=None): - """Set up an iAlarm control panel.""" - name = config.get(CONF_NAME) - username = config.get(CONF_USERNAME) - password = config.get(CONF_PASSWORD) - host = config.get(CONF_HOST) - - url = 'http://{}'.format(host) - ialarm = IAlarmPanel(name, username, password, url) - add_entities([ialarm], True) - - -class IAlarmPanel(alarm.AlarmControlPanel): - """Representation of an iAlarm status.""" - - def __init__(self, name, username, password, url): - """Initialize the iAlarm status.""" - from pyialarm import IAlarm - - self._name = name - self._username = username - self._password = password - self._url = url - self._state = None - self._client = IAlarm(username, password, url) - - @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 - - def update(self): - """Return the state of the device.""" - status = self._client.get_status() - _LOGGER.debug('iAlarm status: %s', status) - if status: - status = int(status) - - if status == self._client.DISARMED: - state = STATE_ALARM_DISARMED - elif status == self._client.ARMED_AWAY: - state = STATE_ALARM_ARMED_AWAY - elif status == self._client.ARMED_STAY: - state = STATE_ALARM_ARMED_HOME - else: - state = None - - self._state = state - - def alarm_disarm(self, code=None): - """Send disarm command.""" - self._client.disarm() - - def alarm_arm_away(self, code=None): - """Send arm away command.""" - self._client.arm_away() - - def alarm_arm_home(self, code=None): - """Send arm home command.""" - self._client.arm_stay() diff --git a/homeassistant/components/alarm_control_panel/ifttt.py b/homeassistant/components/alarm_control_panel/ifttt.py deleted file mode 100644 index 49c5dc488..000000000 --- a/homeassistant/components/alarm_control_panel/ifttt.py +++ /dev/null @@ -1,175 +0,0 @@ -""" -Interfaces with alarm control panels that have to be controlled through IFTTT. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/alarm_control_panel.ifttt/ -""" -import logging -import re - -import voluptuous as vol - -import homeassistant.components.alarm_control_panel as alarm -from homeassistant.components.alarm_control_panel import ( - DOMAIN, PLATFORM_SCHEMA) -from homeassistant.components.ifttt import ( - ATTR_EVENT, DOMAIN as IFTTT_DOMAIN, SERVICE_TRIGGER) -from homeassistant.const import ( - ATTR_ENTITY_ID, ATTR_STATE, CONF_NAME, CONF_CODE, - CONF_OPTIMISTIC, STATE_ALARM_DISARMED, STATE_ALARM_ARMED_NIGHT, - STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_AWAY) -import homeassistant.helpers.config_validation as cv - -DEPENDENCIES = ['ifttt'] - -_LOGGER = logging.getLogger(__name__) - -ALLOWED_STATES = [ - STATE_ALARM_DISARMED, STATE_ALARM_ARMED_NIGHT, - STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME] - -DATA_IFTTT_ALARM = 'ifttt_alarm' -DEFAULT_NAME = "Home" - -CONF_EVENT_AWAY = "event_arm_away" -CONF_EVENT_HOME = "event_arm_home" -CONF_EVENT_NIGHT = "event_arm_night" -CONF_EVENT_DISARM = "event_disarm" - -DEFAULT_EVENT_AWAY = "alarm_arm_away" -DEFAULT_EVENT_HOME = "alarm_arm_home" -DEFAULT_EVENT_NIGHT = "alarm_arm_night" -DEFAULT_EVENT_DISARM = "alarm_disarm" - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_CODE): cv.string, - vol.Optional(CONF_EVENT_AWAY, default=DEFAULT_EVENT_AWAY): cv.string, - vol.Optional(CONF_EVENT_HOME, default=DEFAULT_EVENT_HOME): cv.string, - vol.Optional(CONF_EVENT_NIGHT, default=DEFAULT_EVENT_NIGHT): cv.string, - vol.Optional(CONF_EVENT_DISARM, default=DEFAULT_EVENT_DISARM): cv.string, - vol.Optional(CONF_OPTIMISTIC, default=False): cv.boolean, -}) - -SERVICE_PUSH_ALARM_STATE = "ifttt_push_alarm_state" - -PUSH_ALARM_STATE_SERVICE_SCHEMA = vol.Schema({ - vol.Required(ATTR_ENTITY_ID): cv.entity_ids, - vol.Required(ATTR_STATE): cv.string, -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up a control panel managed through IFTTT.""" - if DATA_IFTTT_ALARM not in hass.data: - hass.data[DATA_IFTTT_ALARM] = [] - - name = config.get(CONF_NAME) - code = config.get(CONF_CODE) - event_away = config.get(CONF_EVENT_AWAY) - event_home = config.get(CONF_EVENT_HOME) - event_night = config.get(CONF_EVENT_NIGHT) - event_disarm = config.get(CONF_EVENT_DISARM) - optimistic = config.get(CONF_OPTIMISTIC) - - alarmpanel = IFTTTAlarmPanel(name, code, event_away, event_home, - event_night, event_disarm, optimistic) - hass.data[DATA_IFTTT_ALARM].append(alarmpanel) - add_entities([alarmpanel]) - - async def push_state_update(service): - """Set the service state as device state attribute.""" - entity_ids = service.data.get(ATTR_ENTITY_ID) - state = service.data.get(ATTR_STATE) - devices = hass.data[DATA_IFTTT_ALARM] - if entity_ids: - devices = [d for d in devices if d.entity_id in entity_ids] - - for device in devices: - device.push_alarm_state(state) - device.async_schedule_update_ha_state() - - hass.services.register(DOMAIN, SERVICE_PUSH_ALARM_STATE, push_state_update, - schema=PUSH_ALARM_STATE_SERVICE_SCHEMA) - - -class IFTTTAlarmPanel(alarm.AlarmControlPanel): - """Representation of an alarm control panel controlled through IFTTT.""" - - def __init__(self, name, code, event_away, event_home, event_night, - event_disarm, optimistic): - """Initialize the alarm control panel.""" - self._name = name - self._code = code - self._event_away = event_away - self._event_home = event_home - self._event_night = event_night - self._event_disarm = event_disarm - self._optimistic = optimistic - self._state = None - - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def state(self): - """Return the state of the device.""" - return self._state - - @property - def assumed_state(self): - """Notify that this platform return an assumed state.""" - return True - - @property - def code_format(self): - """Return one or more digits/characters.""" - if self._code is None: - return None - if isinstance(self._code, str) and re.search('^\\d+$', self._code): - return 'Number' - return 'Any' - - def alarm_disarm(self, code=None): - """Send disarm command.""" - if not self._check_code(code): - return - self.set_alarm_state(self._event_disarm, STATE_ALARM_DISARMED) - - def alarm_arm_away(self, code=None): - """Send arm away command.""" - if not self._check_code(code): - return - self.set_alarm_state(self._event_away, STATE_ALARM_ARMED_AWAY) - - def alarm_arm_home(self, code=None): - """Send arm home command.""" - if not self._check_code(code): - return - self.set_alarm_state(self._event_home, STATE_ALARM_ARMED_HOME) - - def alarm_arm_night(self, code=None): - """Send arm night command.""" - if not self._check_code(code): - return - self.set_alarm_state(self._event_night, STATE_ALARM_ARMED_NIGHT) - - def set_alarm_state(self, event, state): - """Call the IFTTT trigger service to change the alarm state.""" - data = {ATTR_EVENT: event} - - self.hass.services.call(IFTTT_DOMAIN, SERVICE_TRIGGER, data) - _LOGGER.debug("Called IFTTT component to trigger event %s", event) - if self._optimistic: - self._state = state - - def push_alarm_state(self, value): - """Push the alarm state to the given value.""" - if value in ALLOWED_STATES: - _LOGGER.debug("Pushed the alarm state to %s", value) - self._state = value - - def _check_code(self, code): - return self._code is None or self._code == code diff --git a/homeassistant/components/alarm_control_panel/manifest.json b/homeassistant/components/alarm_control_panel/manifest.json new file mode 100644 index 000000000..e877fe90a --- /dev/null +++ b/homeassistant/components/alarm_control_panel/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "alarm_control_panel", + "name": "Alarm control panel", + "documentation": "https://www.home-assistant.io/integrations/alarm_control_panel", + "requirements": [], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/alarm_control_panel/manual.py b/homeassistant/components/alarm_control_panel/manual.py deleted file mode 100644 index 41f7d6988..000000000 --- a/homeassistant/components/alarm_control_panel/manual.py +++ /dev/null @@ -1,308 +0,0 @@ -""" -Support for manual alarms. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/alarm_control_panel.manual/ -""" -import copy -import datetime -import logging -import re - -import voluptuous as vol - -import homeassistant.components.alarm_control_panel as alarm -from homeassistant.const import ( - CONF_CODE, CONF_DELAY_TIME, CONF_DISARM_AFTER_TRIGGER, CONF_NAME, - CONF_PENDING_TIME, CONF_PLATFORM, CONF_TRIGGER_TIME, - STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_CUSTOM_BYPASS, - STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, STATE_ALARM_DISARMED, - STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED) -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.event import track_point_in_time -import homeassistant.util.dt as dt_util - -_LOGGER = logging.getLogger(__name__) - -CONF_CODE_TEMPLATE = 'code_template' - -DEFAULT_ALARM_NAME = 'HA Alarm' -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 - -SUPPORTED_STATES = [STATE_ALARM_DISARMED, STATE_ALARM_ARMED_AWAY, - STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, - STATE_ALARM_ARMED_CUSTOM_BYPASS, 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) - - -PLATFORM_SCHEMA = vol.Schema(vol.All({ - vol.Required(CONF_PLATFORM): 'manual', - vol.Optional(CONF_NAME, default=DEFAULT_ALARM_NAME): 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(cv.time_period, cv.positive_timedelta), - vol.Optional(CONF_TRIGGER_TIME, default=DEFAULT_TRIGGER_TIME): - 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_ARMED_CUSTOM_BYPASS, default={}): - _state_schema(STATE_ALARM_ARMED_CUSTOM_BYPASS), - vol.Optional(STATE_ALARM_DISARMED, default={}): - _state_schema(STATE_ALARM_DISARMED), - vol.Optional(STATE_ALARM_TRIGGERED, default={}): - _state_schema(STATE_ALARM_TRIGGERED), -}, _state_validator)) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the manual alarm platform.""" - add_entities([ManualAlarm( - hass, - config[CONF_NAME], - config.get(CONF_CODE), - config.get(CONF_CODE_TEMPLATE), - config.get(CONF_DISARM_AFTER_TRIGGER, DEFAULT_DISARM_AFTER_TRIGGER), - config - )]) - - -class ManualAlarm(alarm.AlarmControlPanel): - """ - Representation of an alarm status. - - When armed, will be pending for 'pending_time', after that armed. - 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, code_template, - disarm_after_trigger, config): - """Init the manual alarm panel.""" - self._state = STATE_ALARM_DISARMED - self._hass = hass - self._name = name - 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._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} - - @property - def should_poll(self): - """Return the polling state.""" - return False - - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def state(self): - """Return the state of the device.""" - if self._state == STATE_ALARM_TRIGGERED: - if self._within_pending_time(self._state): - return STATE_ALARM_PENDING - 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 - 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): - """Return one or more digits/characters.""" - if self._code is None: - return None - if isinstance(self._code, str) and re.search('^\\d+$', self._code): - return 'Number' - return 'Any' - - def alarm_disarm(self, code=None): - """Send disarm command.""" - if not self._validate_code(code, STATE_ALARM_DISARMED): - return - - self._state = STATE_ALARM_DISARMED - self._state_ts = dt_util.utcnow() - self.schedule_update_ha_state() - - def alarm_arm_home(self, code=None): - """Send arm home command.""" - if not self._validate_code(code, STATE_ALARM_ARMED_HOME): - return - - 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._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._update_state(STATE_ALARM_ARMED_NIGHT) - - 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, 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() - - 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 + 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 + pending_time) - - def _validate_code(self, code, state): - """Validate given 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 diff --git a/homeassistant/components/alarm_control_panel/manual_mqtt.py b/homeassistant/components/alarm_control_panel/manual_mqtt.py deleted file mode 100644 index 7bf944342..000000000 --- a/homeassistant/components/alarm_control_panel/manual_mqtt.py +++ /dev/null @@ -1,370 +0,0 @@ -""" -Support for manual alarms controllable via MQTT. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/alarm_control_panel.manual_mqtt/ -""" -import asyncio -import copy -import datetime -import logging -import re - -import voluptuous as vol - -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_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) -from homeassistant.components import mqtt - -from homeassistant.helpers.event import async_track_state_change -from homeassistant.core import callback - -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_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 = 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.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(cv.time_period, cv.positive_timedelta), - vol.Optional(CONF_TRIGGER_TIME, default=DEFAULT_TRIGGER_TIME): - 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, -}), _state_validator)) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the manual MQTT alarm platform.""" - add_entities([ManualMQTTAlarm( - hass, - config[CONF_NAME], - config.get(CONF_CODE), - 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_NIGHT), - config)]) - - -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 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, 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 - 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._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): - """Return the polling state.""" - return False - - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def state(self): - """Return the state of the device.""" - if self._state == STATE_ALARM_TRIGGERED: - if self._within_pending_time(self._state): - return STATE_ALARM_PENDING - 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 - 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): - """Return one or more digits/characters.""" - if self._code is None: - return None - if isinstance(self._code, str) and re.search('^\\d+$', self._code): - return 'Number' - return 'Any' - - def alarm_disarm(self, code=None): - """Send disarm command.""" - if not self._validate_code(code, STATE_ALARM_DISARMED): - return - - self._state = STATE_ALARM_DISARMED - self._state_ts = dt_util.utcnow() - self.schedule_update_ha_state() - - def alarm_arm_home(self, code=None): - """Send arm home command.""" - if not self._validate_code(code, STATE_ALARM_ARMED_HOME): - return - - 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._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._update_state(STATE_ALARM_ARMED_NIGHT) - - def alarm_trigger(self, code=None): - """ - 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() - - 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 + 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 + pending_time) - - def _validate_code(self, code, state): - """Validate given 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 to MQTT events. - - This method must be run in the event loop and returns a coroutine. - """ - async_track_state_change( - self.hass, self.entity_id, self._async_state_changed_listener - ) - - @callback - def message_received(topic, payload, qos): - """Run when new MQTT message has been received.""" - if payload == self._payload_disarm: - self.async_alarm_disarm(self._code) - elif payload == self._payload_arm_home: - 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 - - return mqtt.async_subscribe( - self.hass, self._command_topic, message_received, self._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) diff --git a/homeassistant/components/alarm_control_panel/mqtt.py b/homeassistant/components/alarm_control_panel/mqtt.py deleted file mode 100644 index e5ad54c41..000000000 --- a/homeassistant/components/alarm_control_panel/mqtt.py +++ /dev/null @@ -1,175 +0,0 @@ -""" -This platform enables the possibility to control a MQTT alarm. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/alarm_control_panel.mqtt/ -""" -import asyncio -import logging -import re - -import voluptuous as vol - -from homeassistant.core import callback -import homeassistant.components.alarm_control_panel as alarm -from homeassistant.components import mqtt -from homeassistant.const import ( - STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, - STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED, STATE_UNKNOWN, - CONF_NAME, CONF_CODE) -from homeassistant.components.mqtt import ( - CONF_AVAILABILITY_TOPIC, CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, - CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, - CONF_RETAIN, MqttAvailability) -import homeassistant.helpers.config_validation as cv - -_LOGGER = logging.getLogger(__name__) - -CONF_PAYLOAD_DISARM = 'payload_disarm' -CONF_PAYLOAD_ARM_HOME = 'payload_arm_home' -CONF_PAYLOAD_ARM_AWAY = 'payload_arm_away' - -DEFAULT_ARM_AWAY = 'ARM_AWAY' -DEFAULT_ARM_HOME = 'ARM_HOME' -DEFAULT_DISARM = 'DISARM' -DEFAULT_NAME = 'MQTT Alarm' -DEPENDENCIES = ['mqtt'] - -PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ - vol.Required(CONF_COMMAND_TOPIC): mqtt.valid_publish_topic, - vol.Required(CONF_STATE_TOPIC): mqtt.valid_subscribe_topic, - vol.Optional(CONF_CODE): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - 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 -def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): - """Set up the MQTT Alarm Control Panel platform.""" - if discovery_info is not None: - config = PLATFORM_SCHEMA(discovery_info) - - async_add_entities([MqttAlarm( - config.get(CONF_NAME), - config.get(CONF_STATE_TOPIC), - config.get(CONF_COMMAND_TOPIC), - config.get(CONF_QOS), - config.get(CONF_RETAIN), - config.get(CONF_PAYLOAD_DISARM), - config.get(CONF_PAYLOAD_ARM_HOME), - config.get(CONF_PAYLOAD_ARM_AWAY), - config.get(CONF_CODE), - config.get(CONF_AVAILABILITY_TOPIC), - config.get(CONF_PAYLOAD_AVAILABLE), - config.get(CONF_PAYLOAD_NOT_AVAILABLE))]) - - -class MqttAlarm(MqttAvailability, alarm.AlarmControlPanel): - """Representation of a MQTT alarm status.""" - - def __init__(self, name, state_topic, command_topic, qos, retain, - payload_disarm, 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 - self._command_topic = command_topic - self._qos = qos - self._retain = retain - self._payload_disarm = payload_disarm - self._payload_arm_home = payload_arm_home - self._payload_arm_away = payload_arm_away - self._code = code - - @asyncio.coroutine - def async_added_to_hass(self): - """Subscribe mqtt events.""" - yield from super().async_added_to_hass() - - @callback - def message_received(topic, payload, qos): - """Run when new MQTT message has been received.""" - if payload not in (STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME, - STATE_ALARM_ARMED_AWAY, STATE_ALARM_PENDING, - STATE_ALARM_TRIGGERED): - _LOGGER.warning("Received unexpected payload: %s", payload) - return - self._state = payload - self.async_schedule_update_ha_state() - - yield from mqtt.async_subscribe( - self.hass, self._state_topic, message_received, self._qos) - - @property - def should_poll(self): - """No polling needed.""" - return False - - @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 code_format(self): - """Return one or more digits/characters.""" - if self._code is None: - return None - if isinstance(self._code, str) and re.search('^\\d+$', self._code): - return 'Number' - return 'Any' - - @asyncio.coroutine - def async_alarm_disarm(self, code=None): - """Send disarm command. - - This method is a coroutine. - """ - if not self._validate_code(code, 'disarming'): - return - mqtt.async_publish( - self.hass, self._command_topic, self._payload_disarm, self._qos, - self._retain) - - @asyncio.coroutine - def async_alarm_arm_home(self, code=None): - """Send arm home command. - - This method is a coroutine. - """ - if not self._validate_code(code, 'arming home'): - return - mqtt.async_publish( - self.hass, self._command_topic, self._payload_arm_home, self._qos, - self._retain) - - @asyncio.coroutine - def async_alarm_arm_away(self, code=None): - """Send arm away command. - - This method is a coroutine. - """ - if not self._validate_code(code, 'arming away'): - return - mqtt.async_publish( - self.hass, self._command_topic, self._payload_arm_away, self._qos, - self._retain) - - def _validate_code(self, code, state): - """Validate given code.""" - check = self._code is None or code == self._code - if not check: - _LOGGER.warning('Wrong code entered for %s', state) - return check diff --git a/homeassistant/components/alarm_control_panel/nx584.py b/homeassistant/components/alarm_control_panel/nx584.py deleted file mode 100644 index 67ec73bce..000000000 --- a/homeassistant/components/alarm_control_panel/nx584.py +++ /dev/null @@ -1,120 +0,0 @@ -""" -Support for NX584 alarm control panels. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/alarm_control_panel.nx584/ -""" -import logging - -import requests -import voluptuous as vol - -import homeassistant.components.alarm_control_panel as alarm -from homeassistant.components.alarm_control_panel import PLATFORM_SCHEMA -from homeassistant.const import ( - 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'] - -_LOGGER = logging.getLogger(__name__) - -DEFAULT_HOST = 'localhost' -DEFAULT_NAME = 'NX584' -DEFAULT_PORT = 5007 - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the NX584 platform.""" - name = config.get(CONF_NAME) - host = config.get(CONF_HOST) - port = config.get(CONF_PORT) - - url = 'http://{}:{}'.format(host, port) - - try: - add_entities([NX584Alarm(hass, url, name)]) - except requests.exceptions.ConnectionError as ex: - _LOGGER.error("Unable to connect to NX584: %s", str(ex)) - return False - - -class NX584Alarm(alarm.AlarmControlPanel): - """Representation of a NX584-based alarm panel.""" - - def __init__(self, hass, url, name): - """Init the nx584 alarm panel.""" - from nx584 import client - self._hass = hass - self._name = name - self._url = url - self._alarm = client.Client(self._url) - # Do an initial list operation so that we will try to actually - # talk to the API and trigger a requests exception for setup_platform() - # to catch - self._alarm.list_zones() - self._state = STATE_UNKNOWN - - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def code_format(self): - """Return one or more digits/characters.""" - return 'Number' - - @property - def state(self): - """Return the state of the device.""" - return self._state - - def update(self): - """Process new events from panel.""" - try: - part = self._alarm.list_partitions()[0] - zones = self._alarm.list_zones() - except requests.exceptions.ConnectionError as ex: - _LOGGER.error("Unable to connect to %(host)s: %(reason)s", - dict(host=self._url, reason=ex)) - self._state = STATE_UNKNOWN - zones = [] - except IndexError: - _LOGGER.error("NX584 reports no partitions") - self._state = STATE_UNKNOWN - zones = [] - - bypassed = False - for zone in zones: - if zone['bypassed']: - _LOGGER.debug("Zone %(zone)s is bypassed, assuming HOME", - dict(zone=zone['number'])) - bypassed = True - break - - if not part['armed']: - self._state = STATE_ALARM_DISARMED - elif bypassed: - self._state = STATE_ALARM_ARMED_HOME - else: - self._state = STATE_ALARM_ARMED_AWAY - - def alarm_disarm(self, code=None): - """Send disarm command.""" - self._alarm.disarm(code) - - def alarm_arm_home(self, code=None): - """Send arm home command.""" - self._alarm.arm('stay') - - def alarm_arm_away(self, code=None): - """Send arm away command.""" - self._alarm.arm('exit') diff --git a/homeassistant/components/alarm_control_panel/reproduce_state.py b/homeassistant/components/alarm_control_panel/reproduce_state.py new file mode 100644 index 000000000..705bca608 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/reproduce_state.py @@ -0,0 +1,84 @@ +"""Reproduce an Alarm control panel state.""" +import asyncio +import logging +from typing import Iterable, Optional + +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_ALARM_ARM_AWAY, + SERVICE_ALARM_ARM_CUSTOM_BYPASS, + SERVICE_ALARM_ARM_HOME, + SERVICE_ALARM_ARM_NIGHT, + SERVICE_ALARM_DISARM, + SERVICE_ALARM_TRIGGER, + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_CUSTOM_BYPASS, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_DISARMED, + STATE_ALARM_TRIGGERED, +) +from homeassistant.core import Context, State +from homeassistant.helpers.typing import HomeAssistantType + +from . import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +VALID_STATES = { + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_CUSTOM_BYPASS, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_DISARMED, + STATE_ALARM_TRIGGERED, +} + + +async def _async_reproduce_state( + hass: HomeAssistantType, state: State, context: Optional[Context] = None +) -> None: + """Reproduce a single state.""" + cur_state = hass.states.get(state.entity_id) + + if cur_state is None: + _LOGGER.warning("Unable to find entity %s", state.entity_id) + return + + if state.state not in VALID_STATES: + _LOGGER.warning( + "Invalid state specified for %s: %s", state.entity_id, state.state + ) + return + + # Return if we are already at the right state. + if cur_state.state == state.state: + return + + service_data = {ATTR_ENTITY_ID: state.entity_id} + + if state.state == STATE_ALARM_ARMED_AWAY: + service = SERVICE_ALARM_ARM_AWAY + elif state.state == STATE_ALARM_ARMED_CUSTOM_BYPASS: + service = SERVICE_ALARM_ARM_CUSTOM_BYPASS + elif state.state == STATE_ALARM_ARMED_HOME: + service = SERVICE_ALARM_ARM_HOME + elif state.state == STATE_ALARM_ARMED_NIGHT: + service = SERVICE_ALARM_ARM_NIGHT + elif state.state == STATE_ALARM_DISARMED: + service = SERVICE_ALARM_DISARM + elif state.state == STATE_ALARM_TRIGGERED: + service = SERVICE_ALARM_TRIGGER + + await hass.services.async_call( + DOMAIN, service, service_data, context=context, blocking=True + ) + + +async def async_reproduce_states( + hass: HomeAssistantType, states: Iterable[State], context: Optional[Context] = None +) -> None: + """Reproduce Alarm control panel states.""" + await asyncio.gather( + *(_async_reproduce_state(hass, state, context) for state in states) + ) diff --git a/homeassistant/components/alarm_control_panel/satel_integra.py b/homeassistant/components/alarm_control_panel/satel_integra.py deleted file mode 100644 index 866037633..000000000 --- a/homeassistant/components/alarm_control_panel/satel_integra.py +++ /dev/null @@ -1,94 +0,0 @@ -""" -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_entities, - 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_entities([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 'Number' - - @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 391de2033..b31cb718b 100644 --- a/homeassistant/components/alarm_control_panel/services.yaml +++ b/homeassistant/components/alarm_control_panel/services.yaml @@ -10,6 +10,16 @@ alarm_disarm: description: An optional code to disarm the alarm control panel with. example: 1234 +alarm_arm_custom_bypass: + description: Send arm custom bypass command. + fields: + entity_id: + description: Name of alarm control panel to arm custom bypass. + example: 'alarm_control_panel.downstairs' + code: + description: An optional code to arm custom bypass the alarm control panel with. + example: 1234 + alarm_arm_home: description: Send the alarm the command for arm home. fields: @@ -49,33 +59,3 @@ alarm_trigger: 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 deleted file mode 100644 index 2c3b25330..000000000 --- a/homeassistant/components/alarm_control_panel/simplisafe.py +++ /dev/null @@ -1,145 +0,0 @@ -""" -Interfaces with SimpliSafe alarm control panel. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/alarm_control_panel.simplisafe/ -""" -import logging -import re - -import voluptuous as vol - -from homeassistant.components.alarm_control_panel import ( - PLATFORM_SCHEMA, AlarmControlPanel) -from homeassistant.const import ( - CONF_CODE, CONF_NAME, CONF_PASSWORD, CONF_USERNAME, - STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, - STATE_ALARM_DISARMED, STATE_UNKNOWN) -import homeassistant.helpers.config_validation as cv - -REQUIREMENTS = ['simplisafe-python==2.0.2'] - -_LOGGER = logging.getLogger(__name__) - -DEFAULT_NAME = 'SimpliSafe' - -ATTR_ALARM_ACTIVE = "alarm_active" -ATTR_TEMPERATURE = "temperature" - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Optional(CONF_CODE): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the SimpliSafe platform.""" - from simplipy.api import SimpliSafeApiInterface, SimpliSafeAPIException - name = config.get(CONF_NAME) - code = config.get(CONF_CODE) - username = config.get(CONF_USERNAME) - password = config.get(CONF_PASSWORD) - - try: - simplisafe = SimpliSafeApiInterface(username, password) - except SimpliSafeAPIException: - _LOGGER.error("Failed to set up SimpliSafe") - return - - systems = [] - - for system in simplisafe.get_systems(): - systems.append(SimpliSafeAlarm(system, name, code)) - - add_entities(systems) - - -class SimpliSafeAlarm(AlarmControlPanel): - """Representation of a SimpliSafe alarm.""" - - def __init__(self, simplisafe, name, code): - """Initialize the SimpliSafe alarm.""" - self.simplisafe = simplisafe - self._name = name - self._code = str(code) if code else None - - @property - def unique_id(self): - """Return the unique ID.""" - return self.simplisafe.location_id - - @property - def name(self): - """Return the name of the device.""" - if self._name is not None: - return self._name - return 'Alarm {}'.format(self.simplisafe.location_id) - - @property - def code_format(self): - """Return one or more digits/characters.""" - if self._code is None: - return None - if isinstance(self._code, str) and re.search('^\\d+$', self._code): - return 'Number' - return 'Any' - - @property - def state(self): - """Return the state of the device.""" - status = self.simplisafe.state - if status.lower() == 'off': - state = STATE_ALARM_DISARMED - elif status.lower() == 'home' or status.lower() == 'home_count': - state = STATE_ALARM_ARMED_HOME - elif (status.lower() == 'away' or status.lower() == 'exitDelay' or - status.lower() == 'away_count'): - state = STATE_ALARM_ARMED_AWAY - else: - state = STATE_UNKNOWN - return state - - @property - def device_state_attributes(self): - """Return the state attributes.""" - attributes = {} - - attributes[ATTR_ALARM_ACTIVE] = self.simplisafe.alarm_active - if self.simplisafe.temperature is not None: - attributes[ATTR_TEMPERATURE] = self.simplisafe.temperature - - return attributes - - def update(self): - """Update alarm status.""" - self.simplisafe.update() - - def alarm_disarm(self, code=None): - """Send disarm command.""" - if not self._validate_code(code, 'disarming'): - return - self.simplisafe.set_state('off') - _LOGGER.info("SimpliSafe alarm disarming") - - def alarm_arm_home(self, code=None): - """Send arm home command.""" - if not self._validate_code(code, 'arming home'): - return - self.simplisafe.set_state('home') - _LOGGER.info("SimpliSafe alarm arming home") - - def alarm_arm_away(self, code=None): - """Send arm away command.""" - if not self._validate_code(code, 'arming away'): - return - self.simplisafe.set_state('away') - _LOGGER.info("SimpliSafe alarm arming away") - - def _validate_code(self, code, state): - """Validate given code.""" - check = self._code is None or code == self._code - if not check: - _LOGGER.warning("Wrong code entered for %s", state) - return check diff --git a/homeassistant/components/alarm_control_panel/spc.py b/homeassistant/components/alarm_control_panel/spc.py deleted file mode 100644 index 2aa157a5c..000000000 --- a/homeassistant/components/alarm_control_panel/spc.py +++ /dev/null @@ -1,109 +0,0 @@ -""" -Support for Vanderbilt (formerly Siemens) SPC alarm systems. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/alarm_control_panel.spc/ -""" -import asyncio -import logging - -import homeassistant.components.alarm_control_panel as alarm -from homeassistant.components.spc import ( - 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, -} - - -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): - """Set up the SPC alarm control panel platform.""" - if (discovery_info is None or - discovery_info[ATTR_DISCOVER_AREAS] is None): - return - - api = hass.data[DATA_API] - devices = [SpcAlarm(api, area) - for area in discovery_info[ATTR_DISCOVER_AREAS]] - - async_add_entities(devices) - - -class SpcAlarm(alarm.AlarmControlPanel): - """Representation of the SPC alarm panel.""" - - def __init__(self, api, area): - """Initialize the SPC alarm panel.""" - 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 - - @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, extra): - """Update the alarm panel with a new state.""" - self._state = state - self._changed_by = extra.get('changed_by', 'unknown') - self.async_schedule_update_ha_state() - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - 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.""" - return self._state - - @asyncio.coroutine - def async_alarm_disarm(self, code=None): - """Send disarm command.""" - yield from self._api.send_area_command( - self._area_id, SpcWebGateway.AREA_COMMAND_UNSET) - - @asyncio.coroutine - def async_alarm_arm_home(self, code=None): - """Send arm home command.""" - yield from self._api.send_area_command( - self._area_id, SpcWebGateway.AREA_COMMAND_PART_SET) - - @asyncio.coroutine - def async_alarm_arm_away(self, code=None): - """Send arm away command.""" - yield from self._api.send_area_command( - self._area_id, SpcWebGateway.AREA_COMMAND_SET) diff --git a/homeassistant/components/alarm_control_panel/strings.json b/homeassistant/components/alarm_control_panel/strings.json new file mode 100644 index 000000000..cbca15c8c --- /dev/null +++ b/homeassistant/components/alarm_control_panel/strings.json @@ -0,0 +1,18 @@ +{ + "device_automation": { + "action_type": { + "arm_away": "Arm {entity_name} away", + "arm_home": "Arm {entity_name} home", + "arm_night": "Arm {entity_name} night", + "disarm": "Disarm {entity_name}", + "trigger": "Trigger {entity_name}" + }, + "trigger_type": { + "triggered": "{entity_name} triggered", + "disarmed": "{entity_name} disarmed", + "armed_home": "{entity_name} armed home", + "armed_away": "{entity_name} armed away", + "armed_night": "{entity_name} armed night" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/totalconnect.py b/homeassistant/components/alarm_control_panel/totalconnect.py deleted file mode 100644 index f594a798d..000000000 --- a/homeassistant/components/alarm_control_panel/totalconnect.py +++ /dev/null @@ -1,106 +0,0 @@ -""" -Interfaces with TotalConnect alarm control panels. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/alarm_control_panel.totalconnect/ -""" -import logging - -import voluptuous as vol - -import homeassistant.helpers.config_validation as cv -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_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_ARMED_CUSTOM_BYPASS) - - -REQUIREMENTS = ['total_connect_client==0.18'] - -_LOGGER = logging.getLogger(__name__) - -DEFAULT_NAME = 'Total Connect' - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - 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_entities, discovery_info=None): - """Set up a TotalConnect control panel.""" - name = config.get(CONF_NAME) - username = config.get(CONF_USERNAME) - password = config.get(CONF_PASSWORD) - - total_connect = TotalConnect(name, username, password) - add_entities([total_connect], True) - - -class TotalConnect(alarm.AlarmControlPanel): - """Represent an TotalConnect status.""" - - def __init__(self, name, username, password): - """Initialize the TotalConnect status.""" - from total_connect_client import TotalConnectClient - - _LOGGER.debug("Setting up TotalConnect...") - self._name = name - self._username = username - self._password = password - self._state = STATE_UNKNOWN - self._client = TotalConnectClient.TotalConnectClient( - username, password) - - @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 - - def update(self): - """Return the state of the device.""" - status = self._client.get_armed_status() - - if status == self._client.DISARMED: - state = STATE_ALARM_DISARMED - elif status == self._client.ARMED_STAY: - state = STATE_ALARM_ARMED_HOME - elif status == self._client.ARMED_AWAY: - 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: - state = STATE_ALARM_DISARMING - else: - state = STATE_UNKNOWN - - self._state = state - - def alarm_disarm(self, code=None): - """Send disarm command.""" - self._client.disarm() - - def alarm_arm_home(self, code=None): - """Send arm home command.""" - self._client.arm_stay() - - def alarm_arm_away(self, code=None): - """Send arm away command.""" - self._client.arm_away() - - def alarm_arm_night(self, code=None): - """Send arm night command.""" - self._client.arm_stay_night() diff --git a/homeassistant/components/alarm_control_panel/verisure.py b/homeassistant/components/alarm_control_panel/verisure.py deleted file mode 100644 index f5a631df3..000000000 --- a/homeassistant/components/alarm_control_panel/verisure.py +++ /dev/null @@ -1,95 +0,0 @@ -""" -Interfaces with Verisure alarm control panel. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/alarm_control_panel.verisure/ -""" -import logging -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.const import ( - STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, - STATE_UNKNOWN) - -_LOGGER = logging.getLogger(__name__) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Verisure platform.""" - alarms = [] - if int(hub.config.get(CONF_ALARM, 1)): - hub.update_overview() - alarms.append(VerisureAlarm()) - add_entities(alarms) - - -def set_arm_state(state, code=None): - """Send set arm state command.""" - transaction_id = hub.session.set_arm_state(code, state)[ - 'armStateChangeTransactionId'] - _LOGGER.info('verisure set arm state %s', state) - transaction = {} - while 'result' not in transaction: - sleep(0.5) - transaction = hub.session.get_arm_state_transaction(transaction_id) - # pylint: disable=unexpected-keyword-arg - hub.update_overview(no_throttle=True) - - -class VerisureAlarm(alarm.AlarmControlPanel): - """Representation of a Verisure alarm status.""" - - def __init__(self): - """Initialize the Verisure alarm panel.""" - self._state = STATE_UNKNOWN - self._digits = hub.config.get(CONF_CODE_DIGITS) - self._changed_by = None - - @property - def name(self): - """Return the name of the device.""" - return '{} alarm'.format(hub.session.installations[0]['alias']) - - @property - def state(self): - """Return the state of the device.""" - return self._state - - @property - def code_format(self): - """Return one or more digits/characters.""" - return 'Number' - - @property - def changed_by(self): - """Return the last change triggered by.""" - return self._changed_by - - def update(self): - """Update alarm status.""" - hub.update_overview() - status = hub.get_first("$.armState.statusType") - if status == 'DISARMED': - self._state = STATE_ALARM_DISARMED - elif status == 'ARMED_HOME': - self._state = STATE_ALARM_ARMED_HOME - elif status == 'ARMED_AWAY': - self._state = STATE_ALARM_ARMED_AWAY - elif status != 'PENDING': - _LOGGER.error('Unknown alarm state %s', status) - self._changed_by = hub.get_first("$.armState.name") - - def alarm_disarm(self, code=None): - """Send disarm command.""" - set_arm_state('DISARMED', code) - - def alarm_arm_home(self, code=None): - """Send arm home command.""" - set_arm_state('ARMED_HOME', code) - - def alarm_arm_away(self, code=None): - """Send arm away command.""" - set_arm_state('ARMED_AWAY', code) diff --git a/homeassistant/components/alarm_control_panel/wink.py b/homeassistant/components/alarm_control_panel/wink.py deleted file mode 100644 index d75fad30c..000000000 --- a/homeassistant/components/alarm_control_panel/wink.py +++ /dev/null @@ -1,77 +0,0 @@ -""" -Interfaces with Wink Cameras. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/alarm_control_panel.wink/ -""" -import asyncio -import logging - -import homeassistant.components.alarm_control_panel as alarm -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__) - -DEPENDENCIES = ['wink'] - -STATE_ALARM_PRIVACY = 'Private' - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Wink platform.""" - import pywink - - for camera in pywink.get_cameras(): - # get_cameras returns multiple device types. - # Only add those that aren't sensors. - try: - camera.capability() - except AttributeError: - _id = camera.object_id() + camera.name() - if _id not in hass.data[DOMAIN]['unique_ids']: - add_entities([WinkCameraDevice(camera, hass)]) - - -class WinkCameraDevice(WinkDevice, alarm.AlarmControlPanel): - """Representation a Wink camera alarm.""" - - @asyncio.coroutine - def async_added_to_hass(self): - """Call when entity is added to hass.""" - self.hass.data[DOMAIN]['entities']['alarm_control_panel'].append(self) - - @property - def state(self): - """Return the state of the device.""" - wink_state = self.wink.state() - if wink_state == "away": - state = STATE_ALARM_ARMED_AWAY - elif wink_state == "home": - state = STATE_ALARM_DISARMED - elif wink_state == "night": - state = STATE_ALARM_ARMED_HOME - else: - state = STATE_UNKNOWN - return state - - def alarm_disarm(self, code=None): - """Send disarm command.""" - self.wink.set_mode("home") - - def alarm_arm_home(self, code=None): - """Send arm home command.""" - self.wink.set_mode("night") - - def alarm_arm_away(self, code=None): - """Send arm away command.""" - self.wink.set_mode("away") - - @property - def device_state_attributes(self): - """Return the state attributes.""" - return { - 'private': self.wink.private() - } diff --git a/homeassistant/components/alarm_control_panel/yale_smart_alarm.py b/homeassistant/components/alarm_control_panel/yale_smart_alarm.py deleted file mode 100755 index e512d15fc..000000000 --- a/homeassistant/components/alarm_control_panel/yale_smart_alarm.py +++ /dev/null @@ -1,98 +0,0 @@ -""" -Yale Smart Alarm client for interacting with the Yale Smart Alarm System API. - -For more details about this platform, please refer to the documentation at -https://www.home-assistant.io/components/alarm_control_panel.yale_smart_alarm -""" -import logging - -import voluptuous as vol - -from homeassistant.components.alarm_control_panel import ( - AlarmControlPanel, PLATFORM_SCHEMA) -from homeassistant.const import ( - CONF_PASSWORD, CONF_USERNAME, CONF_NAME, - STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED) -import homeassistant.helpers.config_validation as cv - -REQUIREMENTS = ['yalesmartalarmclient==0.1.4'] - -CONF_AREA_ID = 'area_id' - -DEFAULT_NAME = 'Yale Smart Alarm' - -DEFAULT_AREA_ID = '1' - -_LOGGER = logging.getLogger(__name__) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_AREA_ID, default=DEFAULT_AREA_ID): cv.string, -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the alarm platform.""" - name = config[CONF_NAME] - username = config[CONF_USERNAME] - password = config[CONF_PASSWORD] - area_id = config[CONF_AREA_ID] - - from yalesmartalarmclient.client import ( - YaleSmartAlarmClient, AuthenticationError) - try: - client = YaleSmartAlarmClient(username, password, area_id) - except AuthenticationError: - _LOGGER.error("Authentication failed. Check credentials") - return - - add_entities([YaleAlarmDevice(name, client)], True) - - -class YaleAlarmDevice(AlarmControlPanel): - """Represent a Yale Smart Alarm.""" - - def __init__(self, name, client): - """Initialize the Yale Alarm Device.""" - self._name = name - self._client = client - self._state = None - - from yalesmartalarmclient.client import (YALE_STATE_DISARM, - YALE_STATE_ARM_PARTIAL, - YALE_STATE_ARM_FULL) - self._state_map = { - YALE_STATE_DISARM: STATE_ALARM_DISARMED, - YALE_STATE_ARM_PARTIAL: STATE_ALARM_ARMED_HOME, - YALE_STATE_ARM_FULL: STATE_ALARM_ARMED_AWAY - } - - @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 - - def update(self): - """Return the state of the device.""" - armed_status = self._client.get_armed_status() - - self._state = self._state_map.get(armed_status) - - def alarm_disarm(self, code=None): - """Send disarm command.""" - self._client.disarm() - - def alarm_arm_home(self, code=None): - """Send arm home command.""" - self._client.arm_partial() - - def alarm_arm_away(self, code=None): - """Send arm away command.""" - self._client.arm_full() diff --git a/homeassistant/components/alarmdecoder.py b/homeassistant/components/alarmdecoder.py deleted file mode 100644 index 1377b2a6c..000000000 --- a/homeassistant/components/alarmdecoder.py +++ /dev/null @@ -1,203 +0,0 @@ -""" -Support for AlarmDecoder devices. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/alarmdecoder/ -""" -import logging - -from datetime import timedelta -import voluptuous as vol - -import homeassistant.helpers.config_validation as cv -from homeassistant.const import EVENT_HOMEASSISTANT_STOP -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==1.13.2'] - -_LOGGER = logging.getLogger(__name__) - -DOMAIN = 'alarmdecoder' - -DATA_AD = 'alarmdecoder' - -CONF_DEVICE = 'device' -CONF_DEVICE_BAUD = 'baudrate' -CONF_DEVICE_HOST = 'host' -CONF_DEVICE_PATH = 'path' -CONF_DEVICE_PORT = 'port' -CONF_DEVICE_TYPE = 'type' -CONF_PANEL_DISPLAY = 'panel_display' -CONF_ZONE_NAME = 'name' -CONF_ZONE_TYPE = 'type' -CONF_ZONE_RFID = 'rfid' -CONF_ZONES = 'zones' -CONF_RELAY_ADDR = 'relayaddr' -CONF_RELAY_CHAN = 'relaychan' - -DEFAULT_DEVICE_TYPE = 'socket' -DEFAULT_DEVICE_HOST = 'localhost' -DEFAULT_DEVICE_PORT = 10000 -DEFAULT_DEVICE_PATH = '/dev/ttyUSB0' -DEFAULT_DEVICE_BAUD = 115200 - -DEFAULT_PANEL_DISPLAY = False - -DEFAULT_ZONE_TYPE = 'opening' - -SIGNAL_PANEL_MESSAGE = 'alarmdecoder.panel_message' -SIGNAL_PANEL_ARM_AWAY = 'alarmdecoder.panel_arm_away' -SIGNAL_PANEL_ARM_HOME = 'alarmdecoder.panel_arm_home' -SIGNAL_PANEL_DISARM = 'alarmdecoder.panel_disarm' - -SIGNAL_ZONE_FAULT = 'alarmdecoder.zone_fault' -SIGNAL_ZONE_RESTORE = 'alarmdecoder.zone_restore' -SIGNAL_RFX_MESSAGE = 'alarmdecoder.rfx_message' -SIGNAL_REL_MESSAGE = 'alarmdecoder.rel_message' - -DEVICE_SOCKET_SCHEMA = vol.Schema({ - vol.Required(CONF_DEVICE_TYPE): 'socket', - vol.Optional(CONF_DEVICE_HOST, default=DEFAULT_DEVICE_HOST): cv.string, - vol.Optional(CONF_DEVICE_PORT, default=DEFAULT_DEVICE_PORT): cv.port}) - -DEVICE_SERIAL_SCHEMA = vol.Schema({ - vol.Required(CONF_DEVICE_TYPE): 'serial', - vol.Optional(CONF_DEVICE_PATH, default=DEFAULT_DEVICE_PATH): cv.string, - vol.Optional(CONF_DEVICE_BAUD, default=DEFAULT_DEVICE_BAUD): cv.string}) - -DEVICE_USB_SCHEMA = vol.Schema({ - vol.Required(CONF_DEVICE_TYPE): 'usb'}) - -ZONE_SCHEMA = vol.Schema({ - vol.Required(CONF_ZONE_NAME): cv.string, - vol.Optional(CONF_ZONE_TYPE, - default=DEFAULT_ZONE_TYPE): vol.Any(DEVICE_CLASSES_SCHEMA), - vol.Optional(CONF_ZONE_RFID): cv.string, - vol.Inclusive(CONF_RELAY_ADDR, 'relaylocation', - 'Relay address and channel must exist together'): cv.byte, - vol.Inclusive(CONF_RELAY_CHAN, 'relaylocation', - 'Relay address and channel must exist together'): cv.byte}) - -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.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}, - }), -}, extra=vol.ALLOW_EXTRA) - - -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) - - device_type = device.get(CONF_DEVICE_TYPE) - host = DEFAULT_DEVICE_HOST - port = DEFAULT_DEVICE_PORT - path = DEFAULT_DEVICE_PATH - baud = DEFAULT_DEVICE_BAUD - - def stop_alarmdecoder(event): - """Handle the shutdown of AlarmDecoder.""" - _LOGGER.debug("Shutting down alarmdecoder") - nonlocal restart - restart = False - controller.close() - - 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.""" - 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.""" - hass.helpers.dispatcher.dispatcher_send( - SIGNAL_ZONE_FAULT, zone) - - def zone_restore_callback(sender, zone): - """Handle zone restore from AlarmDecoder.""" - hass.helpers.dispatcher.dispatcher_send( - SIGNAL_ZONE_RESTORE, zone) - - def handle_rel_message(sender, message): - """Handle relay message from AlarmDecoder.""" - hass.helpers.dispatcher.dispatcher_send( - SIGNAL_REL_MESSAGE, message) - - controller = False - if device_type == 'socket': - host = device.get(CONF_DEVICE_HOST) - port = device.get(CONF_DEVICE_PORT) - controller = AlarmDecoder(SocketDevice(interface=(host, port))) - elif device_type == 'serial': - path = device.get(CONF_DEVICE_PATH) - baud = device.get(CONF_DEVICE_BAUD) - controller = AlarmDecoder(SerialDevice(interface=path)) - elif device_type == 'usb': - AlarmDecoder(USBDevice.find()) - return False - - 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 - controller.on_relay_changed += handle_rel_message - - hass.data[DATA_AD] = controller - - open_connection() - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_alarmdecoder) - - load_platform(hass, 'alarm_control_panel', DOMAIN, conf, config) - - if zones: - load_platform( - hass, 'binary_sensor', DOMAIN, {CONF_ZONES: zones}, config) - - if display: - load_platform(hass, 'sensor', DOMAIN, conf, config) - - return True diff --git a/homeassistant/components/alarmdecoder/__init__.py b/homeassistant/components/alarmdecoder/__init__.py new file mode 100644 index 000000000..93c0746a8 --- /dev/null +++ b/homeassistant/components/alarmdecoder/__init__.py @@ -0,0 +1,212 @@ +"""Support for AlarmDecoder devices.""" +from datetime import timedelta +import logging + +from alarmdecoder import AlarmDecoder +from alarmdecoder.devices import SerialDevice, SocketDevice, USBDevice +from alarmdecoder.util import NoDeviceError +import voluptuous as vol + +from homeassistant.components.binary_sensor import DEVICE_CLASSES_SCHEMA +from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.discovery import load_platform +from homeassistant.util import dt as dt_util + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "alarmdecoder" + +DATA_AD = "alarmdecoder" + +CONF_DEVICE = "device" +CONF_DEVICE_BAUD = "baudrate" +CONF_DEVICE_PATH = "path" +CONF_DEVICE_PORT = "port" +CONF_DEVICE_TYPE = "type" +CONF_PANEL_DISPLAY = "panel_display" +CONF_ZONE_NAME = "name" +CONF_ZONE_TYPE = "type" +CONF_ZONE_LOOP = "loop" +CONF_ZONE_RFID = "rfid" +CONF_ZONES = "zones" +CONF_RELAY_ADDR = "relayaddr" +CONF_RELAY_CHAN = "relaychan" + +DEFAULT_DEVICE_TYPE = "socket" +DEFAULT_DEVICE_HOST = "localhost" +DEFAULT_DEVICE_PORT = 10000 +DEFAULT_DEVICE_PATH = "/dev/ttyUSB0" +DEFAULT_DEVICE_BAUD = 115200 + +DEFAULT_PANEL_DISPLAY = False + +DEFAULT_ZONE_TYPE = "opening" + +SIGNAL_PANEL_MESSAGE = "alarmdecoder.panel_message" +SIGNAL_PANEL_ARM_AWAY = "alarmdecoder.panel_arm_away" +SIGNAL_PANEL_ARM_HOME = "alarmdecoder.panel_arm_home" +SIGNAL_PANEL_DISARM = "alarmdecoder.panel_disarm" + +SIGNAL_ZONE_FAULT = "alarmdecoder.zone_fault" +SIGNAL_ZONE_RESTORE = "alarmdecoder.zone_restore" +SIGNAL_RFX_MESSAGE = "alarmdecoder.rfx_message" +SIGNAL_REL_MESSAGE = "alarmdecoder.rel_message" + +DEVICE_SOCKET_SCHEMA = vol.Schema( + { + vol.Required(CONF_DEVICE_TYPE): "socket", + vol.Optional(CONF_HOST, default=DEFAULT_DEVICE_HOST): cv.string, + vol.Optional(CONF_DEVICE_PORT, default=DEFAULT_DEVICE_PORT): cv.port, + } +) + +DEVICE_SERIAL_SCHEMA = vol.Schema( + { + vol.Required(CONF_DEVICE_TYPE): "serial", + vol.Optional(CONF_DEVICE_PATH, default=DEFAULT_DEVICE_PATH): cv.string, + vol.Optional(CONF_DEVICE_BAUD, default=DEFAULT_DEVICE_BAUD): cv.string, + } +) + +DEVICE_USB_SCHEMA = vol.Schema({vol.Required(CONF_DEVICE_TYPE): "usb"}) + +ZONE_SCHEMA = vol.Schema( + { + vol.Required(CONF_ZONE_NAME): cv.string, + vol.Optional(CONF_ZONE_TYPE, default=DEFAULT_ZONE_TYPE): vol.Any( + DEVICE_CLASSES_SCHEMA + ), + vol.Optional(CONF_ZONE_RFID): cv.string, + vol.Optional(CONF_ZONE_LOOP): vol.All(vol.Coerce(int), vol.Range(min=1, max=4)), + vol.Inclusive( + CONF_RELAY_ADDR, + "relaylocation", + "Relay address and channel must exist together", + ): cv.byte, + vol.Inclusive( + CONF_RELAY_CHAN, + "relaylocation", + "Relay address and channel must exist together", + ): cv.byte, + } +) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.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}, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +def setup(hass, config): + """Set up for the AlarmDecoder devices.""" + conf = config.get(DOMAIN) + + restart = False + device = conf.get(CONF_DEVICE) + display = conf.get(CONF_PANEL_DISPLAY) + zones = conf.get(CONF_ZONES) + + device_type = device.get(CONF_DEVICE_TYPE) + host = DEFAULT_DEVICE_HOST + port = DEFAULT_DEVICE_PORT + path = DEFAULT_DEVICE_PATH + baud = DEFAULT_DEVICE_BAUD + + def stop_alarmdecoder(event): + """Handle the shutdown of AlarmDecoder.""" + _LOGGER.debug("Shutting down alarmdecoder") + nonlocal restart + restart = False + controller.close() + + def open_connection(now=None): + """Open a connection to AlarmDecoder.""" + 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.""" + 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.""" + hass.helpers.dispatcher.dispatcher_send(SIGNAL_ZONE_FAULT, zone) + + def zone_restore_callback(sender, zone): + """Handle zone restore from AlarmDecoder.""" + hass.helpers.dispatcher.dispatcher_send(SIGNAL_ZONE_RESTORE, zone) + + def handle_rel_message(sender, message): + """Handle relay or zone expander message from AlarmDecoder.""" + hass.helpers.dispatcher.dispatcher_send(SIGNAL_REL_MESSAGE, message) + + controller = False + if device_type == "socket": + host = device.get(CONF_HOST) + port = device.get(CONF_DEVICE_PORT) + controller = AlarmDecoder(SocketDevice(interface=(host, port))) + elif device_type == "serial": + path = device.get(CONF_DEVICE_PATH) + baud = device.get(CONF_DEVICE_BAUD) + controller = AlarmDecoder(SerialDevice(interface=path)) + elif device_type == "usb": + AlarmDecoder(USBDevice.find()) + return False + + 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 + controller.on_expander_message += handle_rel_message + + hass.data[DATA_AD] = controller + + open_connection() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_alarmdecoder) + + load_platform(hass, "alarm_control_panel", DOMAIN, conf, config) + + if zones: + load_platform(hass, "binary_sensor", DOMAIN, {CONF_ZONES: zones}, config) + + if display: + load_platform(hass, "sensor", DOMAIN, conf, config) + + return True diff --git a/homeassistant/components/alarmdecoder/alarm_control_panel.py b/homeassistant/components/alarmdecoder/alarm_control_panel.py new file mode 100644 index 000000000..66960ca30 --- /dev/null +++ b/homeassistant/components/alarmdecoder/alarm_control_panel.py @@ -0,0 +1,181 @@ +"""Support for AlarmDecoder-based alarm control panels (Honeywell/DSC).""" +import logging + +import voluptuous as vol + +from homeassistant.components.alarm_control_panel import ( + FORMAT_NUMBER, + AlarmControlPanel, +) +from homeassistant.components.alarm_control_panel.const import ( + SUPPORT_ALARM_ARM_AWAY, + SUPPORT_ALARM_ARM_HOME, + SUPPORT_ALARM_ARM_NIGHT, +) +from homeassistant.const import ( + ATTR_CODE, + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_DISARMED, + STATE_ALARM_TRIGGERED, +) +import homeassistant.helpers.config_validation as cv + +from . import DATA_AD, DOMAIN, SIGNAL_PANEL_MESSAGE + +_LOGGER = logging.getLogger(__name__) + +SERVICE_ALARM_TOGGLE_CHIME = "alarm_toggle_chime" +ALARM_TOGGLE_CHIME_SCHEMA = vol.Schema({vol.Required(ATTR_CODE): cv.string}) + +SERVICE_ALARM_KEYPRESS = "alarm_keypress" +ATTR_KEYPRESS = "keypress" +ALARM_KEYPRESS_SCHEMA = vol.Schema({vol.Required(ATTR_KEYPRESS): cv.string}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up for AlarmDecoder alarm panels.""" + device = AlarmDecoderAlarmPanel() + add_entities([device]) + + def alarm_toggle_chime_handler(service): + """Register toggle chime handler.""" + code = service.data.get(ATTR_CODE) + device.alarm_toggle_chime(code) + + hass.services.register( + DOMAIN, + SERVICE_ALARM_TOGGLE_CHIME, + alarm_toggle_chime_handler, + schema=ALARM_TOGGLE_CHIME_SCHEMA, + ) + + def alarm_keypress_handler(service): + """Register keypress handler.""" + keypress = service.data[ATTR_KEYPRESS] + device.alarm_keypress(keypress) + + hass.services.register( + DOMAIN, + SERVICE_ALARM_KEYPRESS, + alarm_keypress_handler, + schema=ALARM_KEYPRESS_SCHEMA, + ) + + +class AlarmDecoderAlarmPanel(AlarmControlPanel): + """Representation of an AlarmDecoder-based alarm panel.""" + + def __init__(self): + """Initialize the alarm panel.""" + self._display = "" + self._name = "Alarm Panel" + self._state = None + self._ac_power = None + self._backlight_on = None + self._battery_low = None + self._check_zone = None + self._chime = None + self._entry_delay_off = None + self._programming_mode = None + self._ready = None + self._zone_bypassed = None + + async def async_added_to_hass(self): + """Register callbacks.""" + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_PANEL_MESSAGE, self._message_callback + ) + + def _message_callback(self, message): + """Handle received messages.""" + if message.alarm_sounding or message.fire_alarm: + self._state = STATE_ALARM_TRIGGERED + elif message.armed_away: + self._state = STATE_ALARM_ARMED_AWAY + elif message.armed_home: + self._state = STATE_ALARM_ARMED_HOME + else: + self._state = STATE_ALARM_DISARMED + + self._ac_power = message.ac_power + self._backlight_on = message.backlight_on + self._battery_low = message.battery_low + self._check_zone = message.check_zone + self._chime = message.chime_on + self._entry_delay_off = message.entry_delay_off + self._programming_mode = message.programming_mode + self._ready = message.ready + self._zone_bypassed = message.zone_bypassed + + self.schedule_update_ha_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 one or more digits/characters.""" + return FORMAT_NUMBER + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @property + def supported_features(self) -> int: + """Return the list of supported features.""" + return SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY | SUPPORT_ALARM_ARM_NIGHT + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + "ac_power": self._ac_power, + "backlight_on": self._backlight_on, + "battery_low": self._battery_low, + "check_zone": self._check_zone, + "chime": self._chime, + "entry_delay_off": self._entry_delay_off, + "programming_mode": self._programming_mode, + "ready": self._ready, + "zone_bypassed": self._zone_bypassed, + } + + def alarm_disarm(self, code=None): + """Send disarm command.""" + if code: + self.hass.data[DATA_AD].send(f"{code!s}1") + + def alarm_arm_away(self, code=None): + """Send arm away command.""" + if code: + self.hass.data[DATA_AD].send(f"{code!s}2") + + def alarm_arm_home(self, code=None): + """Send arm home command.""" + if code: + self.hass.data[DATA_AD].send(f"{code!s}3") + + def alarm_arm_night(self, code=None): + """Send arm night command.""" + if code: + self.hass.data[DATA_AD].send(f"{code!s}33") + + def alarm_toggle_chime(self, code=None): + """Send toggle chime command.""" + if code: + self.hass.data[DATA_AD].send(f"{code!s}9") + + def alarm_keypress(self, keypress): + """Send custom keypresses.""" + if keypress: + self.hass.data[DATA_AD].send(keypress) diff --git a/homeassistant/components/alarmdecoder/binary_sensor.py b/homeassistant/components/alarmdecoder/binary_sensor.py new file mode 100644 index 000000000..dc3f16b7d --- /dev/null +++ b/homeassistant/components/alarmdecoder/binary_sensor.py @@ -0,0 +1,165 @@ +"""Support for AlarmDecoder zone states- represented as binary sensors.""" +import logging + +from homeassistant.components.binary_sensor import BinarySensorDevice + +from . import ( + CONF_RELAY_ADDR, + CONF_RELAY_CHAN, + CONF_ZONE_LOOP, + CONF_ZONE_NAME, + CONF_ZONE_RFID, + CONF_ZONE_TYPE, + CONF_ZONES, + SIGNAL_REL_MESSAGE, + SIGNAL_RFX_MESSAGE, + SIGNAL_ZONE_FAULT, + SIGNAL_ZONE_RESTORE, + ZONE_SCHEMA, +) + +_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" + + +def setup_platform(hass, config, add_entities, 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) + zone_loop = device_config_data.get(CONF_ZONE_LOOP) + relay_addr = device_config_data.get(CONF_RELAY_ADDR) + relay_chan = device_config_data.get(CONF_RELAY_CHAN) + device = AlarmDecoderBinarySensor( + zone_num, zone_name, zone_type, zone_rfid, zone_loop, relay_addr, relay_chan + ) + devices.append(device) + + add_entities(devices) + + return True + + +class AlarmDecoderBinarySensor(BinarySensorDevice): + """Representation of an AlarmDecoder binary sensor.""" + + def __init__( + self, + zone_number, + zone_name, + zone_type, + zone_rfid, + zone_loop, + relay_addr, + relay_chan, + ): + """Initialize the binary_sensor.""" + self._zone_number = zone_number + self._zone_type = zone_type + self._state = None + self._name = zone_name + self._rfid = zone_rfid + self._loop = zone_loop + self._rfstate = None + self._relay_addr = relay_addr + self._relay_chan = relay_chan + + async def async_added_to_hass(self): + """Register callbacks.""" + 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 + ) + + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_RFX_MESSAGE, self._rfx_message_callback + ) + + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_REL_MESSAGE, self._rel_message_callback + ) + + @property + def name(self): + """Return the name of the entity.""" + return self._name + + @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] = bool(self._rfstate & 0x01) + attr[ATTR_RF_LOW_BAT] = bool(self._rfstate & 0x02) + attr[ATTR_RF_SUPERVISED] = bool(self._rfstate & 0x04) + attr[ATTR_RF_BIT3] = bool(self._rfstate & 0x08) + attr[ATTR_RF_LOOP3] = bool(self._rfstate & 0x10) + attr[ATTR_RF_LOOP2] = bool(self._rfstate & 0x20) + attr[ATTR_RF_LOOP4] = bool(self._rfstate & 0x40) + attr[ATTR_RF_LOOP1] = bool(self._rfstate & 0x80) + return attr + + @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 + + 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.schedule_update_ha_state() + + 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.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 + if self._loop: + self._state = 1 if message.loop[self._loop - 1] else 0 + self.schedule_update_ha_state() + + def _rel_message_callback(self, message): + """Update relay / expander state.""" + + if self._relay_addr == message.address and self._relay_chan == message.channel: + _LOGGER.debug( + "%s %d:%d value:%d", + "Relay" if message.type == message.RELAY else "ZoneExpander", + message.address, + message.channel, + message.value, + ) + self._state = message.value + self.schedule_update_ha_state() diff --git a/homeassistant/components/alarmdecoder/manifest.json b/homeassistant/components/alarmdecoder/manifest.json new file mode 100644 index 000000000..5ab69d94c --- /dev/null +++ b/homeassistant/components/alarmdecoder/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "alarmdecoder", + "name": "Alarmdecoder", + "documentation": "https://www.home-assistant.io/integrations/alarmdecoder", + "requirements": [ + "alarmdecoder==1.13.2" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/alarmdecoder/sensor.py b/homeassistant/components/alarmdecoder/sensor.py new file mode 100644 index 000000000..196e8d704 --- /dev/null +++ b/homeassistant/components/alarmdecoder/sensor.py @@ -0,0 +1,59 @@ +"""Support for AlarmDecoder sensors (Shows Panel Display).""" +import logging + +from homeassistant.helpers.entity import Entity + +from . import SIGNAL_PANEL_MESSAGE + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up for AlarmDecoder sensor devices.""" + _LOGGER.debug("AlarmDecoderSensor: setup_platform") + + device = AlarmDecoderSensor(hass) + + add_entities([device]) + + +class AlarmDecoderSensor(Entity): + """Representation of an AlarmDecoder keypad.""" + + def __init__(self, hass): + """Initialize the alarm panel.""" + self._display = "" + self._state = None + self._icon = "mdi:alarm-check" + self._name = "Alarm Panel Display" + + async def async_added_to_hass(self): + """Register callbacks.""" + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_PANEL_MESSAGE, self._message_callback + ) + + def _message_callback(self, message): + if self._display != message.text: + self._display = message.text + self.schedule_update_ha_state() + + @property + def icon(self): + """Return the icon if any.""" + return self._icon + + @property + def state(self): + """Return the overall state.""" + return self._display + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def should_poll(self): + """No polling needed.""" + return False diff --git a/homeassistant/components/alarmdecoder/services.yaml b/homeassistant/components/alarmdecoder/services.yaml new file mode 100644 index 000000000..12268d48b --- /dev/null +++ b/homeassistant/components/alarmdecoder/services.yaml @@ -0,0 +1,19 @@ +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.' + example: '*71' + +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 diff --git a/homeassistant/components/alarmdotcom/__init__.py b/homeassistant/components/alarmdotcom/__init__.py new file mode 100644 index 000000000..0a715230e --- /dev/null +++ b/homeassistant/components/alarmdotcom/__init__.py @@ -0,0 +1 @@ +"""The alarmdotcom component.""" diff --git a/homeassistant/components/alarmdotcom/alarm_control_panel.py b/homeassistant/components/alarmdotcom/alarm_control_panel.py new file mode 100644 index 000000000..dd6b12722 --- /dev/null +++ b/homeassistant/components/alarmdotcom/alarm_control_panel.py @@ -0,0 +1,132 @@ +"""Interfaces with Alarm.com alarm control panels.""" +import logging +import re + +from pyalarmdotcom import Alarmdotcom +import voluptuous as vol + +import homeassistant.components.alarm_control_panel as alarm +from homeassistant.components.alarm_control_panel import PLATFORM_SCHEMA +from homeassistant.components.alarm_control_panel.const import ( + SUPPORT_ALARM_ARM_AWAY, + SUPPORT_ALARM_ARM_HOME, +) +from homeassistant.const import ( + CONF_CODE, + CONF_NAME, + CONF_PASSWORD, + CONF_USERNAME, + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_DISARMED, +) +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = "Alarm.com" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Optional(CONF_CODE): cv.positive_int, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + } +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up a Alarm.com control panel.""" + name = config.get(CONF_NAME) + code = config.get(CONF_CODE) + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + + alarmdotcom = AlarmDotCom(hass, name, code, username, password) + await alarmdotcom.async_login() + async_add_entities([alarmdotcom]) + + +class AlarmDotCom(alarm.AlarmControlPanel): + """Representation of an Alarm.com status.""" + + def __init__(self, hass, name, code, username, password): + """Initialize the Alarm.com status.""" + + _LOGGER.debug("Setting up Alarm.com...") + self._hass = hass + self._name = name + self._code = str(code) if code else None + self._username = username + self._password = password + self._websession = async_get_clientsession(self._hass) + self._state = None + self._alarm = Alarmdotcom(username, password, self._websession, hass.loop) + + async def async_login(self): + """Login to Alarm.com.""" + await self._alarm.async_login() + + async def async_update(self): + """Fetch the latest state.""" + await self._alarm.async_update() + return self._alarm.state + + @property + def name(self): + """Return the name of the alarm.""" + return self._name + + @property + def code_format(self): + """Return one or more digits/characters.""" + if self._code is None: + return None + if isinstance(self._code, str) and re.search("^\\d+$", self._code): + return alarm.FORMAT_NUMBER + return alarm.FORMAT_TEXT + + @property + def state(self): + """Return the state of the device.""" + if self._alarm.state.lower() == "disarmed": + return STATE_ALARM_DISARMED + if self._alarm.state.lower() == "armed stay": + return STATE_ALARM_ARMED_HOME + if self._alarm.state.lower() == "armed away": + return STATE_ALARM_ARMED_AWAY + return None + + @property + def supported_features(self) -> int: + """Return the list of supported features.""" + return SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return {"sensor_status": self._alarm.sensor_status} + + async def async_alarm_disarm(self, code=None): + """Send disarm command.""" + if self._validate_code(code): + await self._alarm.async_alarm_disarm() + + async def async_alarm_arm_home(self, code=None): + """Send arm hom command.""" + if self._validate_code(code): + await self._alarm.async_alarm_arm_home() + + async def async_alarm_arm_away(self, code=None): + """Send arm away command.""" + if self._validate_code(code): + await self._alarm.async_alarm_arm_away() + + def _validate_code(self, code): + """Validate given code.""" + check = self._code is None or code == self._code + if not check: + _LOGGER.warning("Wrong code entered") + return check diff --git a/homeassistant/components/alarmdotcom/manifest.json b/homeassistant/components/alarmdotcom/manifest.json new file mode 100644 index 000000000..fd5c3010a --- /dev/null +++ b/homeassistant/components/alarmdotcom/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "alarmdotcom", + "name": "Alarmdotcom", + "documentation": "https://www.home-assistant.io/integrations/alarmdotcom", + "requirements": [ + "pyalarmdotcom==0.3.2" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/alert.py b/homeassistant/components/alert.py deleted file mode 100644 index 3ec01fc6a..000000000 --- a/homeassistant/components/alert.py +++ /dev/null @@ -1,285 +0,0 @@ -""" -Support for repeating alerts when conditions are met. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/alert/ -""" -import asyncio -from datetime import datetime, timedelta -import logging - -import voluptuous as vol - -from homeassistant.core import callback -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) -from homeassistant.helpers.entity import ToggleEntity -from homeassistant.helpers import service, event -import homeassistant.helpers.config_validation as cv - -_LOGGER = logging.getLogger(__name__) - -DOMAIN = 'alert' -ENTITY_ID_FORMAT = DOMAIN + '.{}' - -CONF_DONE_MESSAGE = 'done_message' -CONF_CAN_ACK = 'can_acknowledge' -CONF_NOTIFIERS = 'notifiers' -CONF_REPEAT = 'repeat' -CONF_SKIP_FIRST = 'skip_first' - -DEFAULT_CAN_ACK = True -DEFAULT_SKIP_FIRST = False - -ALERT_SCHEMA = vol.Schema({ - vol.Required(CONF_NAME): 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)]), - vol.Required(CONF_CAN_ACK, default=DEFAULT_CAN_ACK): cv.boolean, - vol.Required(CONF_SKIP_FIRST, default=DEFAULT_SKIP_FIRST): cv.boolean, - vol.Required(CONF_NOTIFIERS): cv.ensure_list}) - -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - cv.slug: ALERT_SCHEMA, - }), -}, extra=vol.ALLOW_EXTRA) - - -ALERT_SERVICE_SCHEMA = vol.Schema({ - vol.Required(ATTR_ENTITY_ID): cv.entity_ids, -}) - - -def is_on(hass, entity_id): - """Return if the alert is firing and not acknowledged.""" - return hass.states.is_state(entity_id, STATE_ON) - - -def turn_on(hass, entity_id): - """Reset the alert.""" - hass.add_job(async_turn_on, hass, entity_id) - - -@callback -def async_turn_on(hass, entity_id): - """Async reset the alert.""" - data = {ATTR_ENTITY_ID: entity_id} - hass.async_create_task( - hass.services.async_call(DOMAIN, SERVICE_TURN_ON, data)) - - -def turn_off(hass, entity_id): - """Acknowledge alert.""" - hass.add_job(async_turn_off, hass, entity_id) - - -@callback -def async_turn_off(hass, entity_id): - """Async acknowledge the alert.""" - data = {ATTR_ENTITY_ID: entity_id} - hass.async_create_task( - hass.services.async_call(DOMAIN, SERVICE_TURN_OFF, data)) - - -def toggle(hass, entity_id): - """Toggle acknowledgement of alert.""" - hass.add_job(async_toggle, hass, entity_id) - - -@callback -def async_toggle(hass, entity_id): - """Async toggle acknowledgement of alert.""" - data = {ATTR_ENTITY_ID: entity_id} - hass.async_create_task( - hass.services.async_call(DOMAIN, SERVICE_TOGGLE, data)) - - -@asyncio.coroutine -def async_setup(hass, config): - """Set up the Alert component.""" - alerts = config.get(DOMAIN) - all_alerts = {} - - @asyncio.coroutine - def async_handle_alert_service(service_call): - """Handle calls to alert services.""" - alert_ids = service.extract_entity_ids(hass, service_call) - - for alert_id in alert_ids: - alert = all_alerts[alert_id] - alert.async_set_context(service_call.context) - if service_call.service == SERVICE_TURN_ON: - yield from alert.async_turn_on() - elif service_call.service == SERVICE_TOGGLE: - yield from alert.async_toggle() - else: - yield from alert.async_turn_off() - - # Setup alerts - for entity_id, alert in alerts.items(): - entity = Alert(hass, entity_id, - 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 - - # Setup service calls - hass.services.async_register( - DOMAIN, SERVICE_TURN_OFF, async_handle_alert_service, - schema=ALERT_SERVICE_SCHEMA) - hass.services.async_register( - DOMAIN, SERVICE_TURN_ON, async_handle_alert_service, - schema=ALERT_SERVICE_SCHEMA) - hass.services.async_register( - DOMAIN, SERVICE_TOGGLE, async_handle_alert_service, - schema=ALERT_SERVICE_SCHEMA) - - tasks = [alert.async_update_ha_state() for alert in all_alerts.values()] - if tasks: - yield from asyncio.wait(tasks, loop=hass.loop) - - return True - - -class Alert(ToggleEntity): - """Representation of an alert.""" - - def __init__(self, hass, entity_id, name, done_message, watched_entity_id, - state, repeat, skip_first, notifiers, can_ack): - """Initialize the alert.""" - self.hass = hass - self._name = name - self._alert_state = state - self._skip_first = skip_first - self._notifiers = notifiers - self._can_ack = can_ack - self._done_message = done_message - - self._delay = [timedelta(minutes=val) for val in repeat] - self._next_delay = 0 - - self._firing = False - self._ack = False - self._cancel = None - self._send_done_message = False - self.entity_id = ENTITY_ID_FORMAT.format(entity_id) - - event.async_track_state_change( - hass, watched_entity_id, self.watched_entity_change) - - @property - def name(self): - """Return the name of the alert.""" - return self._name - - @property - def should_poll(self): - """HASS need not poll these entities.""" - return False - - @property - def state(self): - """Return the alert status.""" - if self._firing: - if self._ack: - return STATE_OFF - return STATE_ON - return STATE_IDLE - - @property - def hidden(self): - """Hide the alert when it is not firing.""" - return not self._can_ack or not self._firing - - @asyncio.coroutine - def watched_entity_change(self, entity, from_state, to_state): - """Determine if the alert should start or stop.""" - _LOGGER.debug("Watched entity (%s) has changed", entity) - if to_state.state == self._alert_state and not self._firing: - yield from self.begin_alerting() - if to_state.state != self._alert_state and self._firing: - yield from self.end_alerting() - - @asyncio.coroutine - def begin_alerting(self): - """Begin the alert procedures.""" - _LOGGER.debug("Beginning Alert: %s", self._name) - self._ack = False - self._firing = True - self._next_delay = 0 - - if not self._skip_first: - yield from self._notify() - else: - yield from self._schedule_notify() - - self.async_schedule_update_ha_state() - - @asyncio.coroutine - def end_alerting(self): - """End the alert procedures.""" - _LOGGER.debug("Ending Alert: %s", self._name) - self._cancel() - self._ack = False - self._firing = False - if self._done_message and self._send_done_message: - yield from self._notify_done_message() - self.async_schedule_update_ha_state() - - @asyncio.coroutine - def _schedule_notify(self): - """Schedule a notification.""" - delay = self._delay[self._next_delay] - next_msg = datetime.now() + delay - self._cancel = \ - event.async_track_point_in_time(self.hass, self._notify, next_msg) - self._next_delay = min(self._next_delay + 1, len(self._delay) - 1) - - @asyncio.coroutine - def _notify(self, *args): - """Send the alert notification.""" - if not self._firing: - return - - if not self._ack: - _LOGGER.info("Alerting: %s", self._name) - self._send_done_message = True - for target in self._notifiers: - yield from self.hass.services.async_call( - 'notify', target, {'message': self._name}) - yield from self._schedule_notify() - - @asyncio.coroutine - def _notify_done_message(self, *args): - """Send notification of complete alert.""" - _LOGGER.info("Alerting: %s", self._done_message) - self._send_done_message = False - for target in self._notifiers: - yield from self.hass.services.async_call( - 'notify', target, {'message': self._done_message}) - - @asyncio.coroutine - def async_turn_on(self, **kwargs): - """Async Unacknowledge alert.""" - _LOGGER.debug("Reset Alert: %s", self._name) - self._ack = False - yield from self.async_update_ha_state() - - @asyncio.coroutine - def async_turn_off(self, **kwargs): - """Async Acknowledge alert.""" - _LOGGER.debug("Acknowledged Alert: %s", self._name) - self._ack = True - yield from self.async_update_ha_state() - - @asyncio.coroutine - def async_toggle(self, **kwargs): - """Async toggle alert.""" - if self._ack: - return self.async_turn_on() - return self.async_turn_off() diff --git a/homeassistant/components/alert/__init__.py b/homeassistant/components/alert/__init__.py new file mode 100644 index 000000000..09e2883c3 --- /dev/null +++ b/homeassistant/components/alert/__init__.py @@ -0,0 +1,334 @@ +"""Support for repeating alerts when conditions are met.""" +import asyncio +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.components.notify import ( + ATTR_DATA, + ATTR_MESSAGE, + ATTR_TITLE, + DOMAIN as DOMAIN_NOTIFY, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_ENTITY_ID, + CONF_NAME, + CONF_STATE, + SERVICE_TOGGLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_IDLE, + STATE_OFF, + STATE_ON, +) +from homeassistant.helpers import event, service +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import ToggleEntity +from homeassistant.util.dt import now + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "alert" +ENTITY_ID_FORMAT = DOMAIN + ".{}" + +CONF_CAN_ACK = "can_acknowledge" +CONF_NOTIFIERS = "notifiers" +CONF_REPEAT = "repeat" +CONF_SKIP_FIRST = "skip_first" +CONF_ALERT_MESSAGE = "message" +CONF_DONE_MESSAGE = "done_message" +CONF_TITLE = "title" +CONF_DATA = "data" + +DEFAULT_CAN_ACK = True +DEFAULT_SKIP_FIRST = False + +ALERT_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAME): 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)]), + vol.Required(CONF_CAN_ACK, default=DEFAULT_CAN_ACK): cv.boolean, + vol.Required(CONF_SKIP_FIRST, default=DEFAULT_SKIP_FIRST): cv.boolean, + vol.Optional(CONF_ALERT_MESSAGE): cv.template, + vol.Optional(CONF_DONE_MESSAGE): cv.template, + vol.Optional(CONF_TITLE): cv.template, + vol.Optional(CONF_DATA): dict, + vol.Required(CONF_NOTIFIERS): cv.ensure_list, + } +) + +CONFIG_SCHEMA = vol.Schema( + {DOMAIN: cv.schema_with_slug_keys(ALERT_SCHEMA)}, extra=vol.ALLOW_EXTRA +) + +ALERT_SERVICE_SCHEMA = vol.Schema({vol.Required(ATTR_ENTITY_ID): cv.entity_ids}) + + +def is_on(hass, entity_id): + """Return if the alert is firing and not acknowledged.""" + return hass.states.is_state(entity_id, STATE_ON) + + +async def async_setup(hass, config): + """Set up the Alert component.""" + entities = [] + + for object_id, cfg in config[DOMAIN].items(): + if not cfg: + cfg = {} + + name = cfg.get(CONF_NAME) + watched_entity_id = cfg.get(CONF_ENTITY_ID) + alert_state = cfg.get(CONF_STATE) + repeat = cfg.get(CONF_REPEAT) + skip_first = cfg.get(CONF_SKIP_FIRST) + message_template = cfg.get(CONF_ALERT_MESSAGE) + done_message_template = cfg.get(CONF_DONE_MESSAGE) + notifiers = cfg.get(CONF_NOTIFIERS) + can_ack = cfg.get(CONF_CAN_ACK) + title_template = cfg.get(CONF_TITLE) + data = cfg.get(CONF_DATA) + + entities.append( + Alert( + hass, + object_id, + name, + watched_entity_id, + alert_state, + repeat, + skip_first, + message_template, + done_message_template, + notifiers, + can_ack, + title_template, + data, + ) + ) + + if not entities: + return False + + async def async_handle_alert_service(service_call): + """Handle calls to alert services.""" + alert_ids = await service.async_extract_entity_ids(hass, service_call) + + for alert_id in alert_ids: + for alert in entities: + if alert.entity_id != alert_id: + continue + + alert.async_set_context(service_call.context) + if service_call.service == SERVICE_TURN_ON: + await alert.async_turn_on() + elif service_call.service == SERVICE_TOGGLE: + await alert.async_toggle() + else: + await alert.async_turn_off() + + # Setup service calls + hass.services.async_register( + DOMAIN, + SERVICE_TURN_OFF, + async_handle_alert_service, + schema=ALERT_SERVICE_SCHEMA, + ) + hass.services.async_register( + DOMAIN, SERVICE_TURN_ON, async_handle_alert_service, schema=ALERT_SERVICE_SCHEMA + ) + hass.services.async_register( + DOMAIN, SERVICE_TOGGLE, async_handle_alert_service, schema=ALERT_SERVICE_SCHEMA + ) + + tasks = [alert.async_update_ha_state() for alert in entities] + if tasks: + await asyncio.wait(tasks) + + return True + + +class Alert(ToggleEntity): + """Representation of an alert.""" + + def __init__( + self, + hass, + entity_id, + name, + watched_entity_id, + state, + repeat, + skip_first, + message_template, + done_message_template, + notifiers, + can_ack, + title_template, + data, + ): + """Initialize the alert.""" + self.hass = hass + self._name = name + self._alert_state = state + self._skip_first = skip_first + self._data = data + + self._message_template = message_template + if self._message_template is not None: + self._message_template.hass = hass + + self._done_message_template = done_message_template + if self._done_message_template is not None: + self._done_message_template.hass = hass + + self._title_template = title_template + if self._title_template is not None: + self._title_template.hass = hass + + self._notifiers = notifiers + self._can_ack = can_ack + + self._delay = [timedelta(minutes=val) for val in repeat] + self._next_delay = 0 + + self._firing = False + self._ack = False + self._cancel = None + self._send_done_message = False + self.entity_id = ENTITY_ID_FORMAT.format(entity_id) + + event.async_track_state_change( + hass, watched_entity_id, self.watched_entity_change + ) + + @property + def name(self): + """Return the name of the alert.""" + return self._name + + @property + def should_poll(self): + """HASS need not poll these entities.""" + return False + + @property + def state(self): + """Return the alert status.""" + if self._firing: + if self._ack: + return STATE_OFF + return STATE_ON + return STATE_IDLE + + @property + def hidden(self): + """Hide the alert when it is not firing.""" + return not self._can_ack or not self._firing + + async def watched_entity_change(self, entity, from_state, to_state): + """Determine if the alert should start or stop.""" + _LOGGER.debug("Watched entity (%s) has changed", entity) + if to_state.state == self._alert_state and not self._firing: + await self.begin_alerting() + if to_state.state != self._alert_state and self._firing: + await self.end_alerting() + + async def begin_alerting(self): + """Begin the alert procedures.""" + _LOGGER.debug("Beginning Alert: %s", self._name) + self._ack = False + self._firing = True + self._next_delay = 0 + + if not self._skip_first: + await self._notify() + else: + await self._schedule_notify() + + self.async_schedule_update_ha_state() + + async def end_alerting(self): + """End the alert procedures.""" + _LOGGER.debug("Ending Alert: %s", self._name) + self._cancel() + self._ack = False + self._firing = False + if self._send_done_message: + await self._notify_done_message() + self.async_schedule_update_ha_state() + + async def _schedule_notify(self): + """Schedule a notification.""" + delay = self._delay[self._next_delay] + next_msg = now() + delay + self._cancel = event.async_track_point_in_time( + self.hass, self._notify, next_msg + ) + self._next_delay = min(self._next_delay + 1, len(self._delay) - 1) + + async def _notify(self, *args): + """Send the alert notification.""" + if not self._firing: + return + + if not self._ack: + _LOGGER.info("Alerting: %s", self._name) + self._send_done_message = True + + if self._message_template is not None: + message = self._message_template.async_render() + else: + message = self._name + + await self._send_notification_message(message) + await self._schedule_notify() + + async def _notify_done_message(self, *args): + """Send notification of complete alert.""" + _LOGGER.info("Alerting: %s", self._done_message_template) + self._send_done_message = False + + if self._done_message_template is None: + return + + message = self._done_message_template.async_render() + + await self._send_notification_message(message) + + async def _send_notification_message(self, message): + + msg_payload = {ATTR_MESSAGE: message} + + if self._title_template is not None: + title = self._title_template.async_render() + msg_payload.update({ATTR_TITLE: title}) + if self._data: + msg_payload.update({ATTR_DATA: self._data}) + + _LOGGER.debug(msg_payload) + + for target in self._notifiers: + await self.hass.services.async_call(DOMAIN_NOTIFY, target, msg_payload) + + async def async_turn_on(self, **kwargs): + """Async Unacknowledge alert.""" + _LOGGER.debug("Reset Alert: %s", self._name) + self._ack = False + await self.async_update_ha_state() + + async def async_turn_off(self, **kwargs): + """Async Acknowledge alert.""" + _LOGGER.debug("Acknowledged Alert: %s", self._name) + self._ack = True + await self.async_update_ha_state() + + async def async_toggle(self, **kwargs): + """Async toggle alert.""" + if self._ack: + return await self.async_turn_on() + return await self.async_turn_off() diff --git a/homeassistant/components/alert/manifest.json b/homeassistant/components/alert/manifest.json new file mode 100644 index 000000000..062693907 --- /dev/null +++ b/homeassistant/components/alert/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "alert", + "name": "Alert", + "documentation": "https://www.home-assistant.io/integrations/alert", + "requirements": [], + "dependencies": [], + "after_dependencies": [ + "notify" + ], + "codeowners": [] +} diff --git a/homeassistant/components/alert/services.yaml b/homeassistant/components/alert/services.yaml new file mode 100644 index 000000000..1cdd1f02e --- /dev/null +++ b/homeassistant/components/alert/services.yaml @@ -0,0 +1,12 @@ +toggle: + description: Toggle alert's notifications. + fields: + entity_id: {description: Name of the alert to toggle., example: alert.garage_door_open} +turn_off: + description: Silence alert's notifications. + fields: + entity_id: {description: Name of the alert to silence., example: alert.garage_door_open} +turn_on: + description: Reset alert's notifications. + fields: + entity_id: {description: Name of the alert to reset., example: alert.garage_door_open} diff --git a/homeassistant/components/alexa/__init__.py b/homeassistant/components/alexa/__init__.py index d12027065..e9bcccb35 100644 --- a/homeassistant/components/alexa/__init__.py +++ b/homeassistant/components/alexa/__init__.py @@ -1,61 +1,79 @@ -""" -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 +"""Support for Alexa skill service end point.""" import logging import voluptuous as vol -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers import entityfilter +from homeassistant.const import CONF_NAME +from homeassistant.helpers import config_validation as cv, entityfilter -from . import flash_briefings, intent, smart_home +from . import flash_briefings, intent, smart_home_http from .const import ( - CONF_AUDIO, CONF_DISPLAY_URL, CONF_TEXT, CONF_TITLE, CONF_UID, DOMAIN, - CONF_FILTER, CONF_ENTITY_CONFIG) + CONF_AUDIO, + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + CONF_DESCRIPTION, + CONF_DISPLAY_CATEGORIES, + CONF_DISPLAY_URL, + CONF_ENDPOINT, + CONF_ENTITY_CONFIG, + CONF_FILTER, + CONF_TEXT, + CONF_TITLE, + CONF_UID, + DOMAIN, +) _LOGGER = logging.getLogger(__name__) -CONF_FLASH_BRIEFINGS = 'flash_briefings' -CONF_SMART_HOME = 'smart_home' +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), +ALEXA_ENTITY_SCHEMA = vol.Schema( + { + vol.Optional(CONF_DESCRIPTION): cv.string, + vol.Optional(CONF_DISPLAY_CATEGORIES): cv.string, + vol.Optional(CONF_NAME): cv.string, } -}, extra=vol.ALLOW_EXTRA) +) + +SMART_HOME_SCHEMA = vol.Schema( + { + vol.Optional(CONF_ENDPOINT): cv.string, + vol.Optional(CONF_CLIENT_ID): cv.string, + vol.Optional(CONF_CLIENT_SECRET): cv.string, + 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.""" +async def async_setup(hass, config): + """Activate the Alexa component.""" config = config.get(DOMAIN, {}) flash_briefings_config = config.get(CONF_FLASH_BRIEFINGS) @@ -70,6 +88,6 @@ def async_setup(hass, config): pass else: smart_home_config = smart_home_config or SMART_HOME_SCHEMA({}) - smart_home.async_setup(hass, smart_home_config) + await smart_home_http.async_setup(hass, smart_home_config) return True diff --git a/homeassistant/components/alexa/auth.py b/homeassistant/components/alexa/auth.py new file mode 100644 index 000000000..33c25b73d --- /dev/null +++ b/homeassistant/components/alexa/auth.py @@ -0,0 +1,161 @@ +"""Support for Alexa skill auth.""" +import asyncio +from datetime import timedelta +import json +import logging + +import aiohttp +import async_timeout + +from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client +from homeassistant.util import dt + +_LOGGER = logging.getLogger(__name__) + +LWA_TOKEN_URI = "https://api.amazon.com/auth/o2/token" +LWA_HEADERS = {"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8"} + +PREEMPTIVE_REFRESH_TTL_IN_SECONDS = 300 +STORAGE_KEY = "alexa_auth" +STORAGE_VERSION = 1 +STORAGE_EXPIRE_TIME = "expire_time" +STORAGE_ACCESS_TOKEN = "access_token" +STORAGE_REFRESH_TOKEN = "refresh_token" + + +class Auth: + """Handle authentication to send events to Alexa.""" + + def __init__(self, hass, client_id, client_secret): + """Initialize the Auth class.""" + self.hass = hass + + self.client_id = client_id + self.client_secret = client_secret + + self._prefs = None + self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + + self._get_token_lock = asyncio.Lock() + + async def async_do_auth(self, accept_grant_code): + """Do authentication with an AcceptGrant code.""" + # access token not retrieved yet for the first time, so this should + # be an access token request + + lwa_params = { + "grant_type": "authorization_code", + "code": accept_grant_code, + "client_id": self.client_id, + "client_secret": self.client_secret, + } + _LOGGER.debug( + "Calling LWA to get the access token (first time), " "with: %s", + json.dumps(lwa_params), + ) + + return await self._async_request_new_token(lwa_params) + + @callback + def async_invalidate_access_token(self): + """Invalidate access token.""" + self._prefs[STORAGE_ACCESS_TOKEN] = None + + async def async_get_access_token(self): + """Perform access token or token refresh request.""" + async with self._get_token_lock: + if self._prefs is None: + await self.async_load_preferences() + + if self.is_token_valid(): + _LOGGER.debug("Token still valid, using it.") + return self._prefs[STORAGE_ACCESS_TOKEN] + + if self._prefs[STORAGE_REFRESH_TOKEN] is None: + _LOGGER.debug("Token invalid and no refresh token available.") + return None + + lwa_params = { + "grant_type": "refresh_token", + "refresh_token": self._prefs[STORAGE_REFRESH_TOKEN], + "client_id": self.client_id, + "client_secret": self.client_secret, + } + + _LOGGER.debug("Calling LWA to refresh the access token.") + return await self._async_request_new_token(lwa_params) + + @callback + def is_token_valid(self): + """Check if a token is already loaded and if it is still valid.""" + if not self._prefs[STORAGE_ACCESS_TOKEN]: + return False + + expire_time = dt.parse_datetime(self._prefs[STORAGE_EXPIRE_TIME]) + preemptive_expire_time = expire_time - timedelta( + seconds=PREEMPTIVE_REFRESH_TTL_IN_SECONDS + ) + + return dt.utcnow() < preemptive_expire_time + + async def _async_request_new_token(self, lwa_params): + + try: + session = aiohttp_client.async_get_clientsession(self.hass) + with async_timeout.timeout(10): + response = await session.post( + LWA_TOKEN_URI, + headers=LWA_HEADERS, + data=lwa_params, + allow_redirects=True, + ) + + except (asyncio.TimeoutError, aiohttp.ClientError): + _LOGGER.error("Timeout calling LWA to get auth token.") + return None + + _LOGGER.debug("LWA response header: %s", response.headers) + _LOGGER.debug("LWA response status: %s", response.status) + + if response.status != 200: + _LOGGER.error("Error calling LWA to get auth token.") + return None + + response_json = await response.json() + _LOGGER.debug("LWA response body : %s", response_json) + + access_token = response_json["access_token"] + refresh_token = response_json["refresh_token"] + expires_in = response_json["expires_in"] + expire_time = dt.utcnow() + timedelta(seconds=expires_in) + + await self._async_update_preferences( + access_token, refresh_token, expire_time.isoformat() + ) + + return access_token + + async def async_load_preferences(self): + """Load preferences with stored tokens.""" + self._prefs = await self._store.async_load() + + if self._prefs is None: + self._prefs = { + STORAGE_ACCESS_TOKEN: None, + STORAGE_REFRESH_TOKEN: None, + STORAGE_EXPIRE_TIME: None, + } + + async def _async_update_preferences(self, access_token, refresh_token, expire_time): + """Update user preferences.""" + if self._prefs is None: + await self.async_load_preferences() + + if access_token is not None: + self._prefs[STORAGE_ACCESS_TOKEN] = access_token + if refresh_token is not None: + self._prefs[STORAGE_REFRESH_TOKEN] = refresh_token + if expire_time is not None: + self._prefs[STORAGE_EXPIRE_TIME] = expire_time + await self._store.async_save(self._prefs) diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py new file mode 100644 index 000000000..938101a75 --- /dev/null +++ b/homeassistant/components/alexa/capabilities.py @@ -0,0 +1,1332 @@ +"""Alexa capabilities.""" +import logging + +from homeassistant.components import cover, fan, image_processing, light +from homeassistant.components.alarm_control_panel import ATTR_CODE_FORMAT, FORMAT_NUMBER +import homeassistant.components.climate.const as climate +import homeassistant.components.media_player.const as media_player +from homeassistant.const import ( + ATTR_SUPPORTED_FEATURES, + ATTR_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT, + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_CUSTOM_BYPASS, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, + STATE_LOCKED, + STATE_OFF, + STATE_ON, + STATE_PAUSED, + STATE_PLAYING, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + STATE_UNLOCKED, +) +import homeassistant.util.color as color_util +import homeassistant.util.dt as dt_util + +from .const import ( + API_TEMP_UNITS, + API_THERMOSTAT_MODES, + API_THERMOSTAT_PRESETS, + DATE_FORMAT, + PERCENTAGE_FAN_MAP, + RANGE_FAN_MAP, + Inputs, +) +from .errors import UnsupportedProperty +from .resources import ( + AlexaCapabilityResource, + AlexaGlobalCatalog, + AlexaModeResource, + AlexaPresetResource, + AlexaSemantics, +) + +_LOGGER = logging.getLogger(__name__) + + +class AlexaCapability: + """Base class for Alexa capability interfaces. + + The Smart Home Skills API defines a number of "capability interfaces", + roughly analogous to domains in Home Assistant. The supported interfaces + describe what actions can be performed on a particular device. + + https://developer.amazon.com/docs/device-apis/message-guide.html + """ + + def __init__(self, entity, instance=None): + """Initialize an Alexa capability.""" + self.entity = entity + self.instance = instance + + 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 properties_non_controllable(): + """Return True if non controllable.""" + return None + + @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 + + @staticmethod + def capability_proactively_reported(): + """Return True if the capability is proactively reported. + + Set properties_proactively_reported() for proactively reported properties. + Applicable to DoorbellEventSource. + """ + return None + + @staticmethod + def capability_resources(): + """Return the capability object. + + Applicable to ToggleController, RangeController, and ModeController interfaces. + """ + return [] + + @staticmethod + def configuration(): + """Return the configuration object.""" + return [] + + @staticmethod + def inputs(): + """Applicable only to media players.""" + return [] + + @staticmethod + def semantics(): + """Return the semantics object. + + Applicable to ToggleController, RangeController, and ModeController interfaces. + """ + return [] + + @staticmethod + def supported_operations(): + """Return the supportedOperations object.""" + return [] + + def serialize_discovery(self): + """Serialize according to the Discovery API.""" + result = {"type": "AlexaInterface", "interface": self.name(), "version": "3"} + + instance = self.instance + if instance is not None: + result["instance"] = instance + + properties_supported = self.properties_supported() + if properties_supported: + result["properties"] = { + "supported": self.properties_supported(), + "proactivelyReported": self.properties_proactively_reported(), + "retrievable": self.properties_retrievable(), + } + + proactively_reported = self.capability_proactively_reported() + if proactively_reported is not None: + result["proactivelyReported"] = proactively_reported + + non_controllable = self.properties_non_controllable() + if non_controllable is not None: + result["properties"]["nonControllable"] = non_controllable + + supports_deactivation = self.supports_deactivation() + if supports_deactivation is not None: + result["supportsDeactivation"] = supports_deactivation + + capability_resources = self.capability_resources() + if capability_resources: + result["capabilityResources"] = capability_resources + + configuration = self.configuration() + if configuration: + result["configuration"] = configuration + + semantics = self.semantics() + if semantics: + result["semantics"] = semantics + + supported_operations = self.supported_operations() + if supported_operations: + result["supportedOperations"] = supported_operations + + inputs = self.inputs() + if inputs: + result["inputs"] = inputs + + return result + + def serialize_properties(self): + """Return properties serialized for an API response.""" + for prop in self.properties_supported(): + prop_name = prop["name"] + # pylint: disable=assignment-from-no-return + prop_value = self.get_property(prop_name) + if prop_value is not None: + result = { + "name": prop_name, + "namespace": self.name(), + "value": prop_value, + "timeOfSample": dt_util.utcnow().strftime(DATE_FORMAT), + "uncertaintyInMilliseconds": 0, + } + instance = self.instance + if instance is not None: + result["instance"] = instance + + yield result + + +class Alexa(AlexaCapability): + """Implements Alexa Interface. + + Although endpoints implement this interface implicitly, + The API suggests you should explicitly include this interface. + + https://developer.amazon.com/docs/device-apis/alexa-interface.html + """ + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa" + + +class AlexaEndpointHealth(AlexaCapability): + """Implements Alexa.EndpointHealth. + + https://developer.amazon.com/docs/smarthome/state-reporting-for-a-smart-home-skill.html#report-state-when-alexa-requests-it + """ + + def __init__(self, hass, entity): + """Initialize the entity.""" + super().__init__(entity) + self.hass = hass + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.EndpointHealth" + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{"name": "connectivity"}] + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != "connectivity": + raise UnsupportedProperty(name) + + if self.entity.state == STATE_UNAVAILABLE: + return {"value": "UNREACHABLE"} + return {"value": "OK"} + + +class AlexaPowerController(AlexaCapability): + """Implements Alexa.PowerController. + + https://developer.amazon.com/docs/device-apis/alexa-powercontroller.html + """ + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.PowerController" + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{"name": "powerState"}] + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != "powerState": + raise UnsupportedProperty(name) + + if self.entity.domain == climate.DOMAIN: + is_on = self.entity.state != climate.HVAC_MODE_OFF + + else: + is_on = self.entity.state != STATE_OFF + + return "ON" if is_on else "OFF" + + +class AlexaLockController(AlexaCapability): + """Implements Alexa.LockController. + + https://developer.amazon.com/docs/device-apis/alexa-lockcontroller.html + """ + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.LockController" + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{"name": "lockState"}] + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != "lockState": + raise UnsupportedProperty(name) + + if self.entity.state == STATE_LOCKED: + return "LOCKED" + if self.entity.state == STATE_UNLOCKED: + return "UNLOCKED" + return "JAMMED" + + +class AlexaSceneController(AlexaCapability): + """Implements Alexa.SceneController. + + https://developer.amazon.com/docs/device-apis/alexa-scenecontroller.html + """ + + def __init__(self, entity, supports_deactivation): + """Initialize the entity.""" + super().__init__(entity) + self.supports_deactivation = lambda: supports_deactivation + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.SceneController" + + +class AlexaBrightnessController(AlexaCapability): + """Implements Alexa.BrightnessController. + + https://developer.amazon.com/docs/device-apis/alexa-brightnesscontroller.html + """ + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.BrightnessController" + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{"name": "brightness"}] + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + 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(AlexaCapability): + """Implements Alexa.ColorController. + + https://developer.amazon.com/docs/device-apis/alexa-colorcontroller.html + """ + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.ColorController" + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{"name": "color"}] + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != "color": + raise UnsupportedProperty(name) + + hue, saturation = self.entity.attributes.get(light.ATTR_HS_COLOR, (0, 0)) + + return { + "hue": hue, + "saturation": saturation / 100.0, + "brightness": self.entity.attributes.get(light.ATTR_BRIGHTNESS, 0) / 255.0, + } + + +class AlexaColorTemperatureController(AlexaCapability): + """Implements Alexa.ColorTemperatureController. + + https://developer.amazon.com/docs/device-apis/alexa-colortemperaturecontroller.html + """ + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.ColorTemperatureController" + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{"name": "colorTemperatureInKelvin"}] + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != "colorTemperatureInKelvin": + raise UnsupportedProperty(name) + if "color_temp" in self.entity.attributes: + return color_util.color_temperature_mired_to_kelvin( + self.entity.attributes["color_temp"] + ) + return None + + +class AlexaPercentageController(AlexaCapability): + """Implements Alexa.PercentageController. + + https://developer.amazon.com/docs/device-apis/alexa-percentagecontroller.html + """ + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.PercentageController" + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{"name": "percentage"}] + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != "percentage": + raise UnsupportedProperty(name) + + if self.entity.domain == fan.DOMAIN: + speed = self.entity.attributes.get(fan.ATTR_SPEED) + + return PERCENTAGE_FAN_MAP.get(speed, 0) + + if self.entity.domain == cover.DOMAIN: + return self.entity.attributes.get(cover.ATTR_CURRENT_POSITION, 0) + + return 0 + + +class AlexaSpeaker(AlexaCapability): + """Implements Alexa.Speaker. + + https://developer.amazon.com/docs/device-apis/alexa-speaker.html + """ + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.Speaker" + + +class AlexaStepSpeaker(AlexaCapability): + """Implements Alexa.StepSpeaker. + + https://developer.amazon.com/docs/device-apis/alexa-stepspeaker.html + """ + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.StepSpeaker" + + +class AlexaPlaybackController(AlexaCapability): + """Implements Alexa.PlaybackController. + + https://developer.amazon.com/docs/device-apis/alexa-playbackcontroller.html + """ + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.PlaybackController" + + def supported_operations(self): + """Return the supportedOperations object. + + Supported Operations: FastForward, Next, Pause, Play, Previous, Rewind, StartOver, Stop + """ + supported_features = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + + operations = { + media_player.SUPPORT_NEXT_TRACK: "Next", + media_player.SUPPORT_PAUSE: "Pause", + media_player.SUPPORT_PLAY: "Play", + media_player.SUPPORT_PREVIOUS_TRACK: "Previous", + media_player.SUPPORT_STOP: "Stop", + } + + supported_operations = [] + for operation in operations: + if operation & supported_features: + supported_operations.append(operations[operation]) + + return supported_operations + + +class AlexaInputController(AlexaCapability): + """Implements Alexa.InputController. + + https://developer.amazon.com/docs/device-apis/alexa-inputcontroller.html + """ + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.InputController" + + def inputs(self): + """Return the list of valid supported inputs.""" + source_list = self.entity.attributes.get( + media_player.ATTR_INPUT_SOURCE_LIST, [] + ) + input_list = [] + for source in source_list: + formatted_source = ( + source.lower().replace("-", "").replace("_", "").replace(" ", "") + ) + if formatted_source in Inputs.VALID_SOURCE_NAME_MAP.keys(): + input_list.append( + {"name": Inputs.VALID_SOURCE_NAME_MAP[formatted_source]} + ) + + return input_list + + +class AlexaTemperatureSensor(AlexaCapability): + """Implements Alexa.TemperatureSensor. + + https://developer.amazon.com/docs/device-apis/alexa-temperaturesensor.html + """ + + def __init__(self, hass, entity): + """Initialize the entity.""" + super().__init__(entity) + self.hass = hass + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.TemperatureSensor" + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{"name": "temperature"}] + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != "temperature": + raise UnsupportedProperty(name) + + unit = self.entity.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + temp = self.entity.state + if self.entity.domain == climate.DOMAIN: + unit = self.hass.config.units.temperature_unit + temp = self.entity.attributes.get(climate.ATTR_CURRENT_TEMPERATURE) + + if temp in (STATE_UNAVAILABLE, STATE_UNKNOWN, None): + return None + + try: + temp = float(temp) + except ValueError: + _LOGGER.warning("Invalid temp value %s for %s", temp, self.entity.entity_id) + return None + + return {"value": temp, "scale": API_TEMP_UNITS[unit]} + + +class AlexaContactSensor(AlexaCapability): + """Implements Alexa.ContactSensor. + + The Alexa.ContactSensor interface describes the properties and events used + to report the state of an endpoint that detects contact between two + surfaces. For example, a contact sensor can report whether a door or window + is open. + + https://developer.amazon.com/docs/device-apis/alexa-contactsensor.html + """ + + def __init__(self, hass, entity): + """Initialize the entity.""" + super().__init__(entity) + self.hass = hass + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.ContactSensor" + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{"name": "detectionState"}] + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != "detectionState": + raise UnsupportedProperty(name) + + if self.entity.state == STATE_ON: + return "DETECTED" + return "NOT_DETECTED" + + +class AlexaMotionSensor(AlexaCapability): + """Implements Alexa.MotionSensor. + + https://developer.amazon.com/docs/device-apis/alexa-motionsensor.html + """ + + def __init__(self, hass, entity): + """Initialize the entity.""" + super().__init__(entity) + self.hass = hass + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.MotionSensor" + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{"name": "detectionState"}] + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != "detectionState": + raise UnsupportedProperty(name) + + if self.entity.state == STATE_ON: + return "DETECTED" + return "NOT_DETECTED" + + +class AlexaThermostatController(AlexaCapability): + """Implements Alexa.ThermostatController. + + https://developer.amazon.com/docs/device-apis/alexa-thermostatcontroller.html + """ + + def __init__(self, hass, entity): + """Initialize the entity.""" + super().__init__(entity) + self.hass = hass + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.ThermostatController" + + def properties_supported(self): + """Return what properties this entity supports.""" + properties = [{"name": "thermostatMode"}] + 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_RANGE: + properties.append({"name": "lowerSetpoint"}) + properties.append({"name": "upperSetpoint"}) + return properties + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if self.entity.state == STATE_UNAVAILABLE: + return None + + if name == "thermostatMode": + preset = self.entity.attributes.get(climate.ATTR_PRESET_MODE) + + if preset in API_THERMOSTAT_PRESETS: + mode = API_THERMOSTAT_PRESETS[preset] + else: + mode = API_THERMOSTAT_MODES.get(self.entity.state) + if mode is None: + _LOGGER.error( + "%s (%s) has unsupported state value '%s'", + self.entity.entity_id, + type(self.entity), + self.entity.state, + ) + raise UnsupportedProperty(name) + return mode + + unit = self.hass.config.units.temperature_unit + 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) + else: + raise UnsupportedProperty(name) + + if temp is None: + return None + + try: + temp = float(temp) + except ValueError: + _LOGGER.warning( + "Invalid temp value %s for %s in %s", temp, name, self.entity.entity_id + ) + return None + + return {"value": temp, "scale": API_TEMP_UNITS[unit]} + + def configuration(self): + """Return configuration object. + + Translates climate HVAC_MODES and PRESETS to supported Alexa ThermostatMode Values. + ThermostatMode Value must be AUTO, COOL, HEAT, ECO, OFF, or CUSTOM. + """ + supported_modes = [] + hvac_modes = self.entity.attributes.get(climate.ATTR_HVAC_MODES) + for mode in hvac_modes: + thermostat_mode = API_THERMOSTAT_MODES.get(mode) + if thermostat_mode: + supported_modes.append(thermostat_mode) + + preset_modes = self.entity.attributes.get(climate.ATTR_PRESET_MODES) + if preset_modes: + for mode in preset_modes: + thermostat_mode = API_THERMOSTAT_PRESETS.get(mode) + if thermostat_mode: + supported_modes.append(thermostat_mode) + + # Return False for supportsScheduling until supported with event listener in handler. + configuration = {"supportsScheduling": False} + + if supported_modes: + configuration["supportedModes"] = supported_modes + + return configuration + + +class AlexaPowerLevelController(AlexaCapability): + """Implements Alexa.PowerLevelController. + + https://developer.amazon.com/docs/device-apis/alexa-powerlevelcontroller.html + """ + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.PowerLevelController" + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{"name": "powerLevel"}] + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != "powerLevel": + raise UnsupportedProperty(name) + + if self.entity.domain == fan.DOMAIN: + speed = self.entity.attributes.get(fan.ATTR_SPEED) + + return PERCENTAGE_FAN_MAP.get(speed, None) + + return None + + +class AlexaSecurityPanelController(AlexaCapability): + """Implements Alexa.SecurityPanelController. + + https://developer.amazon.com/docs/device-apis/alexa-securitypanelcontroller.html + """ + + def __init__(self, hass, entity): + """Initialize the entity.""" + super().__init__(entity) + self.hass = hass + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.SecurityPanelController" + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{"name": "armState"}] + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != "armState": + raise UnsupportedProperty(name) + + arm_state = self.entity.state + if arm_state == STATE_ALARM_ARMED_HOME: + return "ARMED_STAY" + if arm_state == STATE_ALARM_ARMED_AWAY: + return "ARMED_AWAY" + if arm_state == STATE_ALARM_ARMED_NIGHT: + return "ARMED_NIGHT" + if arm_state == STATE_ALARM_ARMED_CUSTOM_BYPASS: + return "ARMED_STAY" + return "DISARMED" + + def configuration(self): + """Return configuration object with supported authorization types.""" + code_format = self.entity.attributes.get(ATTR_CODE_FORMAT) + + if code_format == FORMAT_NUMBER: + return {"supportedAuthorizationTypes": [{"type": "FOUR_DIGIT_PIN"}]} + return None + + +class AlexaModeController(AlexaCapability): + """Implements Alexa.ModeController. + + https://developer.amazon.com/docs/device-apis/alexa-modecontroller.html + """ + + def __init__(self, entity, instance, non_controllable=False): + """Initialize the entity.""" + super().__init__(entity, instance) + self._resource = None + self._semantics = None + self.properties_non_controllable = lambda: non_controllable + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.ModeController" + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{"name": "mode"}] + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != "mode": + raise UnsupportedProperty(name) + + # Fan Direction + if self.instance == f"{fan.DOMAIN}.{fan.ATTR_DIRECTION}": + mode = self.entity.attributes.get(fan.ATTR_DIRECTION, None) + if mode in (fan.DIRECTION_FORWARD, fan.DIRECTION_REVERSE, STATE_UNKNOWN): + return f"{fan.ATTR_DIRECTION}.{mode}" + + # Cover Position + if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": + # Return state instead of position when using ModeController. + mode = self.entity.state + if mode in ( + cover.STATE_OPEN, + cover.STATE_OPENING, + cover.STATE_CLOSED, + cover.STATE_CLOSING, + STATE_UNKNOWN, + ): + return f"{cover.ATTR_POSITION}.{mode}" + + return None + + def configuration(self): + """Return configuration with modeResources.""" + if isinstance(self._resource, AlexaCapabilityResource): + return self._resource.serialize_configuration() + + return None + + def capability_resources(self): + """Return capabilityResources object.""" + + # Fan Direction Resource + if self.instance == f"{fan.DOMAIN}.{fan.ATTR_DIRECTION}": + self._resource = AlexaModeResource( + [AlexaGlobalCatalog.SETTING_DIRECTION], False + ) + self._resource.add_mode( + f"{fan.ATTR_DIRECTION}.{fan.DIRECTION_FORWARD}", [fan.DIRECTION_FORWARD] + ) + self._resource.add_mode( + f"{fan.ATTR_DIRECTION}.{fan.DIRECTION_REVERSE}", [fan.DIRECTION_REVERSE] + ) + return self._resource.serialize_capability_resources() + + # Cover Position Resources + if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": + self._resource = AlexaModeResource( + ["Position", AlexaGlobalCatalog.SETTING_OPENING], False + ) + self._resource.add_mode( + f"{cover.ATTR_POSITION}.{cover.STATE_OPEN}", + [AlexaGlobalCatalog.VALUE_OPEN], + ) + self._resource.add_mode( + f"{cover.ATTR_POSITION}.{cover.STATE_CLOSED}", + [AlexaGlobalCatalog.VALUE_CLOSE], + ) + self._resource.add_mode(f"{cover.ATTR_POSITION}.custom", ["Custom"]) + return self._resource.serialize_capability_resources() + + return None + + def semantics(self): + """Build and return semantics object.""" + + # Cover Position + if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": + self._semantics = AlexaSemantics() + self._semantics.add_action_to_directive( + [AlexaSemantics.ACTION_CLOSE, AlexaSemantics.ACTION_LOWER], + "SetMode", + {"mode": f"{cover.ATTR_POSITION}.{cover.STATE_CLOSED}"}, + ) + self._semantics.add_action_to_directive( + [AlexaSemantics.ACTION_OPEN, AlexaSemantics.ACTION_RAISE], + "SetMode", + {"mode": f"{cover.ATTR_POSITION}.{cover.STATE_OPEN}"}, + ) + self._semantics.add_states_to_value( + [AlexaSemantics.STATES_CLOSED], + f"{cover.ATTR_POSITION}.{cover.STATE_CLOSED}", + ) + self._semantics.add_states_to_value( + [AlexaSemantics.STATES_OPEN], + f"{cover.ATTR_POSITION}.{cover.STATE_OPEN}", + ) + return self._semantics.serialize_semantics() + + return None + + +class AlexaRangeController(AlexaCapability): + """Implements Alexa.RangeController. + + https://developer.amazon.com/docs/device-apis/alexa-rangecontroller.html + """ + + def __init__(self, entity, instance, non_controllable=False): + """Initialize the entity.""" + super().__init__(entity, instance) + self._resource = None + self._semantics = None + self.properties_non_controllable = lambda: non_controllable + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.RangeController" + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{"name": "rangeValue"}] + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != "rangeValue": + raise UnsupportedProperty(name) + + # Fan Speed + if self.instance == f"{fan.DOMAIN}.{fan.ATTR_SPEED}": + speed = self.entity.attributes.get(fan.ATTR_SPEED) + return RANGE_FAN_MAP.get(speed, 0) + + # Cover Position + if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": + return self.entity.attributes.get(cover.ATTR_CURRENT_POSITION) + + # Cover Tilt Position + if self.instance == f"{cover.DOMAIN}.{cover.ATTR_TILT_POSITION}": + return self.entity.attributes.get(cover.ATTR_CURRENT_TILT_POSITION) + + return None + + def configuration(self): + """Return configuration with presetResources.""" + if isinstance(self._resource, AlexaCapabilityResource): + return self._resource.serialize_configuration() + + return None + + def capability_resources(self): + """Return capabilityResources object.""" + + # Fan Speed Resources + if self.instance == f"{fan.DOMAIN}.{fan.ATTR_SPEED}": + self._resource = AlexaPresetResource( + labels=[AlexaGlobalCatalog.SETTING_FAN_SPEED], + min_value=1, + max_value=3, + precision=1, + ) + self._resource.add_preset( + value=1, + labels=[AlexaGlobalCatalog.VALUE_LOW, AlexaGlobalCatalog.VALUE_MINIMUM], + ) + self._resource.add_preset(value=2, labels=[AlexaGlobalCatalog.VALUE_MEDIUM]) + self._resource.add_preset( + value=3, + labels=[ + AlexaGlobalCatalog.VALUE_HIGH, + AlexaGlobalCatalog.VALUE_MAXIMUM, + ], + ) + return self._resource.serialize_capability_resources() + + # Cover Position Resources + if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": + self._resource = AlexaPresetResource( + ["Position", AlexaGlobalCatalog.SETTING_OPENING], + min_value=0, + max_value=100, + precision=1, + unit=AlexaGlobalCatalog.UNIT_PERCENT, + ) + return self._resource.serialize_capability_resources() + + # Cover Tilt Position Resources + if self.instance == f"{cover.DOMAIN}.{cover.ATTR_TILT_POSITION}": + self._resource = AlexaPresetResource( + ["Tilt Position", AlexaGlobalCatalog.SETTING_OPENING], + min_value=0, + max_value=100, + precision=1, + unit=AlexaGlobalCatalog.UNIT_PERCENT, + ) + return self._resource.serialize_capability_resources() + + return None + + def semantics(self): + """Build and return semantics object.""" + + # Cover Position + if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": + self._semantics = AlexaSemantics() + self._semantics.add_action_to_directive( + [AlexaSemantics.ACTION_LOWER], "SetRangeValue", {"rangeValue": 0} + ) + self._semantics.add_action_to_directive( + [AlexaSemantics.ACTION_RAISE], "SetRangeValue", {"rangeValue": 100} + ) + self._semantics.add_states_to_value([AlexaSemantics.STATES_CLOSED], value=0) + self._semantics.add_states_to_range( + [AlexaSemantics.STATES_OPEN], min_value=1, max_value=100 + ) + return self._semantics.serialize_semantics() + + # Cover Tilt Position + if self.instance == f"{cover.DOMAIN}.{cover.ATTR_TILT_POSITION}": + self._semantics = AlexaSemantics() + self._semantics.add_action_to_directive( + [AlexaSemantics.ACTION_CLOSE], "SetRangeValue", {"rangeValue": 0} + ) + self._semantics.add_action_to_directive( + [AlexaSemantics.ACTION_OPEN], "SetRangeValue", {"rangeValue": 100} + ) + self._semantics.add_states_to_value([AlexaSemantics.STATES_CLOSED], value=0) + self._semantics.add_states_to_range( + [AlexaSemantics.STATES_OPEN], min_value=1, max_value=100 + ) + return self._semantics.serialize_semantics() + + return None + + +class AlexaToggleController(AlexaCapability): + """Implements Alexa.ToggleController. + + https://developer.amazon.com/docs/device-apis/alexa-togglecontroller.html + """ + + def __init__(self, entity, instance, non_controllable=False): + """Initialize the entity.""" + super().__init__(entity, instance) + self._resource = None + self._semantics = None + self.properties_non_controllable = lambda: non_controllable + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.ToggleController" + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{"name": "toggleState"}] + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != "toggleState": + raise UnsupportedProperty(name) + + # Fan Oscillating + if self.instance == f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}": + is_on = bool(self.entity.attributes.get(fan.ATTR_OSCILLATING)) + return "ON" if is_on else "OFF" + + return None + + def capability_resources(self): + """Return capabilityResources object.""" + + # Fan Oscillating Resource + if self.instance == f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}": + self._resource = AlexaCapabilityResource( + [AlexaGlobalCatalog.SETTING_OSCILLATE, "Rotate", "Rotation"] + ) + return self._resource.serialize_capability_resources() + + return None + + +class AlexaChannelController(AlexaCapability): + """Implements Alexa.ChannelController. + + https://developer.amazon.com/docs/device-apis/alexa-channelcontroller.html + """ + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.ChannelController" + + +class AlexaDoorbellEventSource(AlexaCapability): + """Implements Alexa.DoorbellEventSource. + + https://developer.amazon.com/docs/device-apis/alexa-doorbelleventsource.html + """ + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.DoorbellEventSource" + + def capability_proactively_reported(self): + """Return True for proactively reported capability.""" + return True + + +class AlexaPlaybackStateReporter(AlexaCapability): + """Implements Alexa.PlaybackStateReporter. + + https://developer.amazon.com/docs/device-apis/alexa-playbackstatereporter.html + """ + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.PlaybackStateReporter" + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{"name": "playbackState"}] + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != "playbackState": + raise UnsupportedProperty(name) + + playback_state = self.entity.state + if playback_state == STATE_PLAYING: + return {"state": "PLAYING"} + if playback_state == STATE_PAUSED: + return {"state": "PAUSED"} + + return {"state": "STOPPED"} + + +class AlexaSeekController(AlexaCapability): + """Implements Alexa.SeekController. + + https://developer.amazon.com/docs/device-apis/alexa-seekcontroller.html + """ + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.SeekController" + + +class AlexaEventDetectionSensor(AlexaCapability): + """Implements Alexa.EventDetectionSensor. + + https://developer.amazon.com/docs/device-apis/alexa-eventdetectionsensor.html + """ + + def __init__(self, hass, entity): + """Initialize the entity.""" + super().__init__(entity) + self.hass = hass + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.EventDetectionSensor" + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{"name": "humanPresenceDetectionState"}] + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != "humanPresenceDetectionState": + raise UnsupportedProperty(name) + + human_presence = "NOT_DETECTED" + state = self.entity.state + + # Return None for unavailable and unknown states. + # Allows the Alexa.EndpointHealth Interface to handle the unavailable state in a stateReport. + if state in (STATE_UNAVAILABLE, STATE_UNKNOWN, None): + return None + + if self.entity.domain == image_processing.DOMAIN: + if int(state): + human_presence = "DETECTED" + elif state == STATE_ON: + human_presence = "DETECTED" + + return {"value": human_presence} + + def configuration(self): + """Return supported detection types.""" + return { + "detectionMethods": ["AUDIO", "VIDEO"], + "detectionModes": { + "humanPresence": { + "featureAvailability": "ENABLED", + "supportsNotDetected": True, + } + }, + } diff --git a/homeassistant/components/alexa/config.py b/homeassistant/components/alexa/config.py new file mode 100644 index 000000000..f98337d71 --- /dev/null +++ b/homeassistant/components/alexa/config.py @@ -0,0 +1,77 @@ +"""Config helpers for Alexa.""" +from homeassistant.core import callback + +from .state_report import async_enable_proactive_mode + + +class AbstractConfig: + """Hold the configuration for Alexa.""" + + _unsub_proactive_report = None + + def __init__(self, hass): + """Initialize abstract config.""" + self.hass = hass + + @property + def supports_auth(self): + """Return if config supports auth.""" + return False + + @property + def should_report_state(self): + """Return if states should be proactively reported.""" + return False + + @property + def endpoint(self): + """Endpoint for report state.""" + return None + + @property + def entity_config(self): + """Return entity config.""" + return {} + + @property + def is_reporting_states(self): + """Return if proactive mode is enabled.""" + return self._unsub_proactive_report is not None + + async def async_enable_proactive_mode(self): + """Enable proactive mode.""" + if self._unsub_proactive_report is None: + self._unsub_proactive_report = self.hass.async_create_task( + async_enable_proactive_mode(self.hass, self) + ) + try: + await self._unsub_proactive_report + except Exception: # pylint: disable=broad-except + self._unsub_proactive_report = None + raise + + async def async_disable_proactive_mode(self): + """Disable proactive mode.""" + unsub_func = await self._unsub_proactive_report + if unsub_func: + unsub_func() + self._unsub_proactive_report = None + + @callback + def should_expose(self, entity_id): + """If an entity should be exposed.""" + # pylint: disable=no-self-use + return False + + @callback + def async_invalidate_access_token(self): + """Invalidate access token.""" + raise NotImplementedError + + async def async_get_access_token(self): + """Get an access token.""" + raise NotImplementedError + + async def async_accept_grant(self, code): + """Accept a grant.""" + raise NotImplementedError diff --git a/homeassistant/components/alexa/const.py b/homeassistant/components/alexa/const.py index 7d6489b53..f1a86859d 100644 --- a/homeassistant/components/alexa/const.py +++ b/homeassistant/components/alexa/const.py @@ -1,23 +1,198 @@ """Constants for the Alexa integration.""" -DOMAIN = 'alexa' +from collections import OrderedDict + +from homeassistant.components import fan +from homeassistant.components.climate import const as climate +from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT + +DOMAIN = "alexa" # Flash briefing constants -CONF_UID = 'uid' -CONF_TITLE = 'title' -CONF_AUDIO = 'audio' -CONF_TEXT = 'text' -CONF_DISPLAY_URL = 'display_url' +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' +CONF_FILTER = "filter" +CONF_ENTITY_CONFIG = "entity_config" +CONF_ENDPOINT = "endpoint" +CONF_CLIENT_ID = "client_id" +CONF_CLIENT_SECRET = "client_secret" -ATTR_UID = 'uid' -ATTR_UPDATE_DATE = 'updateDate' -ATTR_TITLE_TEXT = 'titleText' -ATTR_STREAM_URL = 'streamUrl' -ATTR_MAIN_TEXT = 'mainText' -ATTR_REDIRECTION_URL = 'redirectionURL' +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' +SYN_RESOLUTION_MATCH = "ER_SUCCESS_MATCH" -DATE_FORMAT = '%Y-%m-%dT%H:%M:%S.0Z' +DATE_FORMAT = "%Y-%m-%dT%H:%M:%S.0Z" + +API_DIRECTIVE = "directive" +API_ENDPOINT = "endpoint" +API_EVENT = "event" +API_CONTEXT = "context" +API_HEADER = "header" +API_PAYLOAD = "payload" +API_SCOPE = "scope" +API_CHANGE = "change" + +CONF_DESCRIPTION = "description" +CONF_DISPLAY_CATEGORIES = "display_categories" + +API_TEMP_UNITS = {TEMP_FAHRENHEIT: "FAHRENHEIT", TEMP_CELSIUS: "CELSIUS"} + +# Needs to be ordered dict for `async_api_set_thermostat_mode` which does a +# reverse mapping of this dict and we want to map the first occurrence of OFF +# back to HA state. +API_THERMOSTAT_MODES = OrderedDict( + [ + (climate.HVAC_MODE_HEAT, "HEAT"), + (climate.HVAC_MODE_COOL, "COOL"), + (climate.HVAC_MODE_HEAT_COOL, "AUTO"), + (climate.HVAC_MODE_AUTO, "AUTO"), + (climate.HVAC_MODE_OFF, "OFF"), + (climate.HVAC_MODE_FAN_ONLY, "OFF"), + (climate.HVAC_MODE_DRY, "CUSTOM"), + ] +) +API_THERMOSTAT_MODES_CUSTOM = {climate.HVAC_MODE_DRY: "DEHUMIDIFY"} +API_THERMOSTAT_PRESETS = {climate.PRESET_ECO: "ECO"} + +PERCENTAGE_FAN_MAP = { + fan.SPEED_OFF: 0, + fan.SPEED_LOW: 33, + fan.SPEED_MEDIUM: 66, + fan.SPEED_HIGH: 100, +} + +RANGE_FAN_MAP = { + fan.SPEED_OFF: 0, + fan.SPEED_LOW: 1, + fan.SPEED_MEDIUM: 2, + fan.SPEED_HIGH: 3, +} + +SPEED_FAN_MAP = { + 0: fan.SPEED_OFF, + 1: fan.SPEED_LOW, + 2: fan.SPEED_MEDIUM, + 3: fan.SPEED_HIGH, +} + + +class Cause: + """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 Inputs: + """Valid names for the InputController. + + https://developer.amazon.com/docs/device-apis/alexa-property-schemas.html#input + """ + + VALID_SOURCE_NAME_MAP = { + "aux": "AUX 1", + "aux1": "AUX 1", + "aux2": "AUX 2", + "aux3": "AUX 3", + "aux4": "AUX 4", + "aux5": "AUX 5", + "aux6": "AUX 6", + "aux7": "AUX 7", + "bluray": "BLURAY", + "cable": "CABLE", + "cd": "CD", + "coax": "COAX 1", + "coax1": "COAX 1", + "coax2": "COAX 2", + "composite": "COMPOSITE 1", + "composite1": "COMPOSITE 1", + "dvd": "DVD", + "game": "GAME", + "gameconsole": "GAME", + "hdradio": "HD RADIO", + "hdmi": "HDMI 1", + "hdmi1": "HDMI 1", + "hdmi2": "HDMI 2", + "hdmi3": "HDMI 3", + "hdmi4": "HDMI 4", + "hdmi5": "HDMI 5", + "hdmi6": "HDMI 6", + "hdmi7": "HDMI 7", + "hdmi8": "HDMI 8", + "hdmi9": "HDMI 9", + "hdmi10": "HDMI 10", + "hdmiarc": "HDMI ARC", + "input": "INPUT 1", + "input1": "INPUT 1", + "input2": "INPUT 2", + "input3": "INPUT 3", + "input4": "INPUT 4", + "input5": "INPUT 5", + "input6": "INPUT 6", + "input7": "INPUT 7", + "input8": "INPUT 8", + "input9": "INPUT 9", + "input10": "INPUT 10", + "ipod": "IPOD", + "line": "LINE 1", + "line1": "LINE 1", + "line2": "LINE 2", + "line3": "LINE 3", + "line4": "LINE 4", + "line5": "LINE 5", + "line6": "LINE 6", + "line7": "LINE 7", + "mediaplayer": "MEDIA PLAYER", + "optical": "OPTICAL 1", + "optical1": "OPTICAL 1", + "optical2": "OPTICAL 2", + "phono": "PHONO", + "playstation": "PLAYSTATION", + "playstation3": "PLAYSTATION 3", + "playstation4": "PLAYSTATION 4", + "satellite": "SATELLITE", + "satellitetv": "SATELLITE", + "smartcast": "SMARTCAST", + "tuner": "TUNER", + "tv": "TV", + "usbdac": "USB DAC", + "video": "VIDEO 1", + "video1": "VIDEO 1", + "video2": "VIDEO 2", + "video3": "VIDEO 3", + "xbox": "XBOX", + } diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py new file mode 100644 index 000000000..89ca64689 --- /dev/null +++ b/homeassistant/components/alexa/entities.py @@ -0,0 +1,676 @@ +"""Alexa entity adapters.""" +from typing import List + +from homeassistant.components import ( + alarm_control_panel, + alert, + automation, + binary_sensor, + cover, + fan, + group, + image_processing, + input_boolean, + light, + lock, + media_player, + scene, + script, + sensor, + switch, +) +from homeassistant.components.climate import const as climate +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_SUPPORTED_FEATURES, + ATTR_UNIT_OF_MEASUREMENT, + CLOUD_NEVER_EXPOSED_ENTITIES, + CONF_NAME, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) +from homeassistant.core import callback +from homeassistant.util.decorator import Registry + +from .capabilities import ( + Alexa, + AlexaBrightnessController, + AlexaChannelController, + AlexaColorController, + AlexaColorTemperatureController, + AlexaContactSensor, + AlexaDoorbellEventSource, + AlexaEndpointHealth, + AlexaEventDetectionSensor, + AlexaInputController, + AlexaLockController, + AlexaModeController, + AlexaMotionSensor, + AlexaPercentageController, + AlexaPlaybackController, + AlexaPlaybackStateReporter, + AlexaPowerController, + AlexaPowerLevelController, + AlexaRangeController, + AlexaSceneController, + AlexaSecurityPanelController, + AlexaSeekController, + AlexaSpeaker, + AlexaStepSpeaker, + AlexaTemperatureSensor, + AlexaThermostatController, + AlexaToggleController, +) +from .const import CONF_DESCRIPTION, CONF_DISPLAY_CATEGORIES + +ENTITY_ADAPTERS = Registry() + +TRANSLATION_TABLE = dict.fromkeys(map(ord, r"}{\/|\"()[]+~!><*%"), None) + + +class DisplayCategory: + """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 non-mobile computer, such as a desktop computer. + COMPUTER = "COMPUTER" + + # Indicates an endpoint that detects and reports contact. + CONTACT_SENSOR = "CONTACT_SENSOR" + + # Indicates a door. + DOOR = "DOOR" + + # Indicates a doorbell. + DOORBELL = "DOORBELL" + + # Indicates a window covering on the outside of a structure. + EXTERIOR_BLIND = "EXTERIOR_BLIND" + + # Indicates a fan. + FAN = "FAN" + + # Indicates a game console, such as Microsoft Xbox or Nintendo Switch + GAME_CONSOLE = "GAME_CONSOLE" + + # Indicates a garage door. Garage doors must implement the ModeController interface to open and close the door. + GARAGE_DOOR = "GARAGE_DOOR" + + # Indicates a window covering on the inside of a structure. + INTERIOR_BLIND = "INTERIOR_BLIND" + + # Indicates a laptop or other mobile computer. + LAPTOP = "LAPTOP" + + # Indicates light sources or fixtures. + LIGHT = "LIGHT" + + # Indicates a microwave oven. + MICROWAVE = "MICROWAVE" + + # Indicates a mobile phone. + MOBILE_PHONE = "MOBILE_PHONE" + + # Indicates an endpoint that detects and reports motion. + MOTION_SENSOR = "MOTION_SENSOR" + + # Indicates a network-connected music system. + MUSIC_SYSTEM = "MUSIC_SYSTEM" + + # An endpoint that cannot be described in on of the other categories. + OTHER = "OTHER" + + # Indicates a network router. + NETWORK_HARDWARE = "NETWORK_HARDWARE" + + # Indicates an oven cooking appliance. + OVEN = "OVEN" + + # Indicates a non-mobile phone, such as landline or an IP phone. + PHONE = "PHONE" + + # 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 a projector screen. + SCREEN = "SCREEN" + + # Indicates a security panel. + SECURITY_PANEL = "SECURITY_PANEL" + + # 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 a streaming device such as Apple TV, Chromecast, or Roku. + STREAMING_DEVICE = "STREAMING_DEVICE" + + # Indicates in-wall switches wired to the electrical system. Can control a + # variety of devices. + SWITCH = "SWITCH" + + # Indicates a tablet computer. + TABLET = "TABLET" + + # 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. + TV = "TV" + + # Indicates a network-connected wearable device, such as an Apple Watch, Fitbit, or Samsung Gear. + WEARABLE = "WEARABLE" + + +class AlexaEntity: + """An adaptation of an entity, expressed in Alexa's terms. + + The API handlers should manipulate entities only through this interface. + """ + + def __init__(self, hass, config, entity): + """Initialize Alexa Entity.""" + self.hass = hass + self.config = config + self.entity = entity + self.entity_conf = config.entity_config.get(entity.entity_id, {}) + + @property + def entity_id(self): + """Return the Entity ID.""" + return self.entity.entity_id + + def friendly_name(self): + """Return the Alexa API friendly name.""" + return self.entity_conf.get(CONF_NAME, self.entity.name).translate( + TRANSLATION_TABLE + ) + + def description(self): + """Return the Alexa API description.""" + description = self.entity_conf.get(CONF_DESCRIPTION) or self.entity_id + return f"{description} via Home Assistant".translate(TRANSLATION_TABLE) + + def alexa_id(self): + """Return the Alexa API entity id.""" + return self.entity.entity_id.replace(".", "#").translate(TRANSLATION_TABLE) + + 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 + + def serialize_properties(self): + """Yield each supported property in API format.""" + for interface in self.interfaces(): + for prop in interface.serialize_properties(): + yield prop + + def serialize_discovery(self): + """Serialize the entity for discovery.""" + return { + "displayCategories": self.display_categories(), + "cookie": {}, + "endpointId": self.alexa_id(), + "friendlyName": self.friendly_name(), + "description": self.description(), + "manufacturerName": "Home Assistant", + "capabilities": [i.serialize_discovery() for i in self.interfaces()], + } + + +@callback +def async_get_entities(hass, config) -> List[AlexaEntity]: + """Return all entities that are supported by Alexa.""" + entities = [] + for state in hass.states.async_all(): + if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + continue + + if state.domain not in ENTITY_ADAPTERS: + continue + + alexa_entity = ENTITY_ADAPTERS[state.domain](hass, config, state) + + if not list(alexa_entity.interfaces()): + continue + + entities.append(alexa_entity) + + return entities + + +@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 the display categories for this entity.""" + return [DisplayCategory.OTHER] + + def interfaces(self): + """Yield the supported interfaces.""" + return [ + AlexaPowerController(self.entity), + AlexaEndpointHealth(self.hass, self.entity), + Alexa(self.hass), + ] + + +@ENTITY_ADAPTERS.register(switch.DOMAIN) +class SwitchCapabilities(AlexaEntity): + """Class to represent Switch capabilities.""" + + def default_display_categories(self): + """Return the display categories for this entity.""" + device_class = self.entity.attributes.get(ATTR_DEVICE_CLASS) + if device_class == switch.DEVICE_CLASS_OUTLET: + return [DisplayCategory.SMARTPLUG] + + return [DisplayCategory.SWITCH] + + def interfaces(self): + """Yield the supported interfaces.""" + return [ + AlexaPowerController(self.entity), + AlexaEndpointHealth(self.hass, self.entity), + Alexa(self.hass), + ] + + +@ENTITY_ADAPTERS.register(climate.DOMAIN) +class ClimateCapabilities(AlexaEntity): + """Class to represent Climate capabilities.""" + + def default_display_categories(self): + """Return the display categories for this entity.""" + return [DisplayCategory.THERMOSTAT] + + def interfaces(self): + """Yield the supported interfaces.""" + # If we support two modes, one being off, we allow turning on too. + if climate.HVAC_MODE_OFF in self.entity.attributes.get( + climate.ATTR_HVAC_MODES, [] + ): + yield AlexaPowerController(self.entity) + + yield AlexaThermostatController(self.hass, self.entity) + yield AlexaTemperatureSensor(self.hass, self.entity) + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.hass) + + +@ENTITY_ADAPTERS.register(cover.DOMAIN) +class CoverCapabilities(AlexaEntity): + """Class to represent Cover capabilities.""" + + def default_display_categories(self): + """Return the display categories for this entity.""" + device_class = self.entity.attributes.get(ATTR_DEVICE_CLASS) + if device_class == cover.DEVICE_CLASS_GARAGE: + return [DisplayCategory.GARAGE_DOOR] + if device_class == cover.DEVICE_CLASS_DOOR: + return [DisplayCategory.DOOR] + if device_class in ( + cover.DEVICE_CLASS_BLIND, + cover.DEVICE_CLASS_SHADE, + cover.DEVICE_CLASS_CURTAIN, + ): + return [DisplayCategory.INTERIOR_BLIND] + if device_class in ( + cover.DEVICE_CLASS_WINDOW, + cover.DEVICE_CLASS_AWNING, + cover.DEVICE_CLASS_SHUTTER, + ): + return [DisplayCategory.EXTERIOR_BLIND] + + return [DisplayCategory.OTHER] + + def interfaces(self): + """Yield the supported interfaces.""" + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported & cover.SUPPORT_SET_POSITION: + yield AlexaRangeController( + self.entity, instance=f"{cover.DOMAIN}.{cover.ATTR_POSITION}" + ) + elif supported & (cover.SUPPORT_CLOSE | cover.SUPPORT_OPEN): + yield AlexaModeController( + self.entity, instance=f"{cover.DOMAIN}.{cover.ATTR_POSITION}" + ) + if supported & cover.SUPPORT_SET_TILT_POSITION: + yield AlexaRangeController( + self.entity, instance=f"{cover.DOMAIN}.{cover.ATTR_TILT_POSITION}" + ) + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.hass) + + +@ENTITY_ADAPTERS.register(light.DOMAIN) +class LightCapabilities(AlexaEntity): + """Class to represent Light capabilities.""" + + def default_display_categories(self): + """Return the display categories for this entity.""" + return [DisplayCategory.LIGHT] + + def interfaces(self): + """Yield the supported interfaces.""" + 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) + + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.hass) + + +@ENTITY_ADAPTERS.register(fan.DOMAIN) +class FanCapabilities(AlexaEntity): + """Class to represent Fan capabilities.""" + + def default_display_categories(self): + """Return the display categories for this entity.""" + return [DisplayCategory.FAN] + + def interfaces(self): + """Yield the supported interfaces.""" + yield AlexaPowerController(self.entity) + + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported & fan.SUPPORT_SET_SPEED: + yield AlexaPercentageController(self.entity) + yield AlexaPowerLevelController(self.entity) + yield AlexaRangeController( + self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_SPEED}" + ) + if supported & fan.SUPPORT_OSCILLATE: + yield AlexaToggleController( + self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}" + ) + if supported & fan.SUPPORT_DIRECTION: + yield AlexaModeController( + self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_DIRECTION}" + ) + + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.hass) + + +@ENTITY_ADAPTERS.register(lock.DOMAIN) +class LockCapabilities(AlexaEntity): + """Class to represent Lock capabilities.""" + + def default_display_categories(self): + """Return the display categories for this entity.""" + return [DisplayCategory.SMARTLOCK] + + def interfaces(self): + """Yield the supported interfaces.""" + return [ + AlexaLockController(self.entity), + AlexaEndpointHealth(self.hass, self.entity), + Alexa(self.hass), + ] + + +@ENTITY_ADAPTERS.register(media_player.const.DOMAIN) +class MediaPlayerCapabilities(AlexaEntity): + """Class to represent MediaPlayer capabilities.""" + + def default_display_categories(self): + """Return the display categories for this entity.""" + device_class = self.entity.attributes.get(ATTR_DEVICE_CLASS) + if device_class == media_player.DEVICE_CLASS_SPEAKER: + return [DisplayCategory.SPEAKER] + + return [DisplayCategory.TV] + + def interfaces(self): + """Yield the supported interfaces.""" + yield AlexaPowerController(self.entity) + + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported & media_player.const.SUPPORT_VOLUME_SET: + yield AlexaSpeaker(self.entity) + + step_volume_features = ( + media_player.const.SUPPORT_VOLUME_MUTE + | media_player.const.SUPPORT_VOLUME_STEP + ) + if supported & step_volume_features: + yield AlexaStepSpeaker(self.entity) + + playback_features = ( + media_player.const.SUPPORT_PLAY + | media_player.const.SUPPORT_PAUSE + | media_player.const.SUPPORT_STOP + | media_player.const.SUPPORT_NEXT_TRACK + | media_player.const.SUPPORT_PREVIOUS_TRACK + ) + if supported & playback_features: + yield AlexaPlaybackController(self.entity) + yield AlexaPlaybackStateReporter(self.entity) + + if supported & media_player.const.SUPPORT_SEEK: + yield AlexaSeekController(self.entity) + + if supported & media_player.SUPPORT_SELECT_SOURCE: + yield AlexaInputController(self.entity) + + if supported & media_player.const.SUPPORT_PLAY_MEDIA: + yield AlexaChannelController(self.entity) + + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.hass) + + +@ENTITY_ADAPTERS.register(scene.DOMAIN) +class SceneCapabilities(AlexaEntity): + """Class to represent Scene capabilities.""" + + def description(self): + """Return the Alexa API description.""" + description = AlexaEntity.description(self) + if "scene" not in description.casefold(): + return f"{description} (Scene)" + return description + + def default_display_categories(self): + """Return the display categories for this entity.""" + return [DisplayCategory.SCENE_TRIGGER] + + def interfaces(self): + """Yield the supported interfaces.""" + return [ + AlexaSceneController(self.entity, supports_deactivation=False), + Alexa(self.hass), + ] + + +@ENTITY_ADAPTERS.register(script.DOMAIN) +class ScriptCapabilities(AlexaEntity): + """Class to represent Script capabilities.""" + + def default_display_categories(self): + """Return the display categories for this entity.""" + return [DisplayCategory.ACTIVITY_TRIGGER] + + def interfaces(self): + """Yield the supported interfaces.""" + can_cancel = bool(self.entity.attributes.get("can_cancel")) + return [ + AlexaSceneController(self.entity, supports_deactivation=can_cancel), + Alexa(self.hass), + ] + + +@ENTITY_ADAPTERS.register(sensor.DOMAIN) +class SensorCapabilities(AlexaEntity): + """Class to represent Sensor capabilities.""" + + def default_display_categories(self): + """Return the display categories for this entity.""" + # although there are other kinds of sensors, all but temperature + # sensors are currently ignored. + return [DisplayCategory.TEMPERATURE_SENSOR] + + def interfaces(self): + """Yield the supported interfaces.""" + attrs = self.entity.attributes + if attrs.get(ATTR_UNIT_OF_MEASUREMENT) in (TEMP_FAHRENHEIT, TEMP_CELSIUS): + yield AlexaTemperatureSensor(self.hass, self.entity) + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.hass) + + +@ENTITY_ADAPTERS.register(binary_sensor.DOMAIN) +class BinarySensorCapabilities(AlexaEntity): + """Class to represent BinarySensor capabilities.""" + + TYPE_CONTACT = "contact" + TYPE_MOTION = "motion" + TYPE_PRESENCE = "presence" + + def default_display_categories(self): + """Return the display categories for this entity.""" + sensor_type = self.get_type() + if sensor_type is self.TYPE_CONTACT: + return [DisplayCategory.CONTACT_SENSOR] + if sensor_type is self.TYPE_MOTION: + return [DisplayCategory.MOTION_SENSOR] + if sensor_type is self.TYPE_PRESENCE: + return [DisplayCategory.CAMERA] + + def interfaces(self): + """Yield the supported interfaces.""" + sensor_type = self.get_type() + if sensor_type is self.TYPE_CONTACT: + yield AlexaContactSensor(self.hass, self.entity) + elif sensor_type is self.TYPE_MOTION: + yield AlexaMotionSensor(self.hass, self.entity) + elif sensor_type is self.TYPE_PRESENCE: + yield AlexaEventDetectionSensor(self.hass, self.entity) + + # yield additional interfaces based on specified display category in config. + entity_conf = self.config.entity_config.get(self.entity.entity_id, {}) + if CONF_DISPLAY_CATEGORIES in entity_conf: + if entity_conf[CONF_DISPLAY_CATEGORIES] == DisplayCategory.DOORBELL: + yield AlexaDoorbellEventSource(self.entity) + elif entity_conf[CONF_DISPLAY_CATEGORIES] == DisplayCategory.CONTACT_SENSOR: + yield AlexaContactSensor(self.hass, self.entity) + elif entity_conf[CONF_DISPLAY_CATEGORIES] == DisplayCategory.MOTION_SENSOR: + yield AlexaMotionSensor(self.hass, self.entity) + elif entity_conf[CONF_DISPLAY_CATEGORIES] == DisplayCategory.CAMERA: + yield AlexaEventDetectionSensor(self.hass, self.entity) + + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.hass) + + def get_type(self): + """Return the type of binary sensor.""" + attrs = self.entity.attributes + if attrs.get(ATTR_DEVICE_CLASS) in ( + binary_sensor.DEVICE_CLASS_DOOR, + binary_sensor.DEVICE_CLASS_GARAGE_DOOR, + binary_sensor.DEVICE_CLASS_OPENING, + binary_sensor.DEVICE_CLASS_WINDOW, + ): + return self.TYPE_CONTACT + + if attrs.get(ATTR_DEVICE_CLASS) == binary_sensor.DEVICE_CLASS_MOTION: + return self.TYPE_MOTION + + if attrs.get(ATTR_DEVICE_CLASS) == binary_sensor.DEVICE_CLASS_PRESENCE: + return self.TYPE_PRESENCE + + +@ENTITY_ADAPTERS.register(alarm_control_panel.DOMAIN) +class AlarmControlPanelCapabilities(AlexaEntity): + """Class to represent Alarm capabilities.""" + + def default_display_categories(self): + """Return the display categories for this entity.""" + return [DisplayCategory.SECURITY_PANEL] + + def interfaces(self): + """Yield the supported interfaces.""" + if not self.entity.attributes.get("code_arm_required"): + yield AlexaSecurityPanelController(self.hass, self.entity) + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.hass) + + +@ENTITY_ADAPTERS.register(image_processing.DOMAIN) +class ImageProcessingCapabilities(AlexaEntity): + """Class to represent image_processing capabilities.""" + + def default_display_categories(self): + """Return the display categories for this entity.""" + return [DisplayCategory.CAMERA] + + def interfaces(self): + """Yield the supported interfaces.""" + yield AlexaEventDetectionSensor(self.hass, self.entity) + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.hass) diff --git a/homeassistant/components/alexa/errors.py b/homeassistant/components/alexa/errors.py new file mode 100644 index 000000000..29643bacc --- /dev/null +++ b/homeassistant/components/alexa/errors.py @@ -0,0 +1,120 @@ +"""Alexa related errors.""" +from homeassistant.exceptions import HomeAssistantError + +from .const import API_TEMP_UNITS + + +class UnsupportedInterface(HomeAssistantError): + """This entity does not support the requested Smart Home API interface.""" + + +class UnsupportedProperty(HomeAssistantError): + """This entity does not support the requested Smart Home API property.""" + + +class NoTokenAvailable(HomeAssistantError): + """There is no access token available.""" + + +class AlexaError(Exception): + """Base class for errors that can be serialized for the Alexa API. + + A handler can raise subclasses of this to return an error to the request. + """ + + namespace = None + error_type = None + + def __init__(self, error_message, payload=None): + """Initialize an alexa error.""" + Exception.__init__(self) + self.error_message = error_message + self.payload = None + + +class AlexaInvalidEndpointError(AlexaError): + """The endpoint in the request does not exist.""" + + namespace = "Alexa" + error_type = "NO_SUCH_ENDPOINT" + + def __init__(self, endpoint_id): + """Initialize invalid endpoint error.""" + msg = f"The endpoint {endpoint_id} does not exist" + AlexaError.__init__(self, msg) + self.endpoint_id = endpoint_id + + +class AlexaInvalidValueError(AlexaError): + """Class to represent InvalidValue errors.""" + + namespace = "Alexa" + error_type = "INVALID_VALUE" + + +class AlexaUnsupportedThermostatModeError(AlexaError): + """Class to represent UnsupportedThermostatMode errors.""" + + namespace = "Alexa.ThermostatController" + error_type = "UNSUPPORTED_THERMOSTAT_MODE" + + +class AlexaTempRangeError(AlexaError): + """Class to represent TempRange errors.""" + + namespace = "Alexa" + error_type = "TEMPERATURE_VALUE_OUT_OF_RANGE" + + def __init__(self, hass, temp, min_temp, max_temp): + """Initialize TempRange error.""" + unit = hass.config.units.temperature_unit + temp_range = { + "minimumValue": {"value": min_temp, "scale": API_TEMP_UNITS[unit]}, + "maximumValue": {"value": max_temp, "scale": API_TEMP_UNITS[unit]}, + } + payload = {"validRange": temp_range} + msg = f"The requested temperature {temp} is out of range" + + AlexaError.__init__(self, msg, payload) + + +class AlexaBridgeUnreachableError(AlexaError): + """Class to represent BridgeUnreachable errors.""" + + namespace = "Alexa" + error_type = "BRIDGE_UNREACHABLE" + + +class AlexaSecurityPanelUnauthorizedError(AlexaError): + """Class to represent SecurityPanelController Unauthorized errors.""" + + namespace = "Alexa.SecurityPanelController" + error_type = "UNAUTHORIZED" + + +class AlexaSecurityPanelAuthorizationRequired(AlexaError): + """Class to represent SecurityPanelController AuthorizationRequired errors.""" + + namespace = "Alexa.SecurityPanelController" + error_type = "AUTHORIZATION_REQUIRED" + + +class AlexaAlreadyInOperationError(AlexaError): + """Class to represent AlreadyInOperation errors.""" + + namespace = "Alexa" + error_type = "ALREADY_IN_OPERATION" + + +class AlexaInvalidDirectiveError(AlexaError): + """Class to represent InvalidDirective errors.""" + + namespace = "Alexa" + error_type = "INVALID_DIRECTIVE" + + +class AlexaVideoActionNotPermittedForContentError(AlexaError): + """Class to represent action not permitted for content errors.""" + + namespace = "Alexa.Video" + error_type = "ACTION_NOT_PERMITTED_FOR_CONTENT" diff --git a/homeassistant/components/alexa/flash_briefings.py b/homeassistant/components/alexa/flash_briefings.py index 02f47b056..45d31d608 100644 --- a/homeassistant/components/alexa/flash_briefings.py +++ b/homeassistant/components/alexa/flash_briefings.py @@ -1,40 +1,44 @@ -""" -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/ -""" +"""Support for Alexa skill service end point.""" 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 +import homeassistant.util.dt as dt_util 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) + 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}' +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)) + 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' + name = "api:alexa:flash_briefings" def __init__(self, hass, flash_briefings): """Initialize Alexa view.""" @@ -45,13 +49,12 @@ class AlexaFlashBriefingView(http.HomeAssistantView): @callback def get(self, request, briefing_id): """Handle Alexa Flash Briefing request.""" - _LOGGER.debug("Received Alexa flash briefing request for: %s", - briefing_id) + _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 + return b"", 404 briefing = [] @@ -81,14 +84,12 @@ class AlexaFlashBriefingView(http.HomeAssistantView): 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() + 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) + output[ATTR_UPDATE_DATE] = dt_util.utcnow().strftime(DATE_FORMAT) briefing.append(output) diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py new file mode 100644 index 000000000..b5603af74 --- /dev/null +++ b/homeassistant/components/alexa/handlers.py @@ -0,0 +1,1323 @@ +"""Alexa message handlers.""" +import logging +import math + +from homeassistant import core as ha +from homeassistant.components import cover, fan, group, light, media_player +from homeassistant.components.climate import const as climate +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + ATTR_TEMPERATURE, + SERVICE_ALARM_ARM_AWAY, + SERVICE_ALARM_ARM_HOME, + SERVICE_ALARM_ARM_NIGHT, + SERVICE_ALARM_DISARM, + SERVICE_LOCK, + SERVICE_MEDIA_NEXT_TRACK, + SERVICE_MEDIA_PAUSE, + SERVICE_MEDIA_PLAY, + SERVICE_MEDIA_PREVIOUS_TRACK, + SERVICE_MEDIA_STOP, + SERVICE_SET_COVER_POSITION, + SERVICE_SET_COVER_TILT_POSITION, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + SERVICE_UNLOCK, + SERVICE_VOLUME_DOWN, + SERVICE_VOLUME_MUTE, + SERVICE_VOLUME_SET, + SERVICE_VOLUME_UP, + STATE_ALARM_DISARMED, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) +import homeassistant.util.color as color_util +from homeassistant.util.decorator import Registry +import homeassistant.util.dt as dt_util +from homeassistant.util.temperature import convert as convert_temperature + +from .const import ( + API_TEMP_UNITS, + API_THERMOSTAT_MODES, + API_THERMOSTAT_MODES_CUSTOM, + API_THERMOSTAT_PRESETS, + PERCENTAGE_FAN_MAP, + RANGE_FAN_MAP, + SPEED_FAN_MAP, + Cause, + Inputs, +) +from .entities import async_get_entities +from .errors import ( + AlexaInvalidDirectiveError, + AlexaInvalidValueError, + AlexaSecurityPanelAuthorizationRequired, + AlexaSecurityPanelUnauthorizedError, + AlexaTempRangeError, + AlexaUnsupportedThermostatModeError, + AlexaVideoActionNotPermittedForContentError, +) +from .state_report import async_enable_proactive_mode + +_LOGGER = logging.getLogger(__name__) +HANDLERS = Registry() + + +@HANDLERS.register(("Alexa.Discovery", "Discover")) +async def async_api_discovery(hass, config, directive, context): + """Create a API formatted discovery response. + + Async friendly. + """ + discovery_endpoints = [ + alexa_entity.serialize_discovery() + for alexa_entity in async_get_entities(hass, config) + if config.should_expose(alexa_entity.entity_id) + ] + + return directive.response( + name="Discover.Response", + namespace="Alexa.Discovery", + payload={"endpoints": discovery_endpoints}, + ) + + +@HANDLERS.register(("Alexa.Authorization", "AcceptGrant")) +async def async_api_accept_grant(hass, config, directive, context): + """Create a API formatted AcceptGrant response. + + Async friendly. + """ + auth_code = directive.payload["grant"]["code"] + _LOGGER.debug("AcceptGrant code: %s", auth_code) + + if config.supports_auth: + await config.async_accept_grant(auth_code) + + if config.should_report_state: + await async_enable_proactive_mode(hass, config) + + return directive.response( + name="AcceptGrant.Response", namespace="Alexa.Authorization", payload={} + ) + + +@HANDLERS.register(("Alexa.PowerController", "TurnOn")) +async def async_api_turn_on(hass, config, directive, context): + """Process a turn on request.""" + entity = directive.entity + domain = entity.domain + if domain == group.DOMAIN: + domain = ha.DOMAIN + + service = SERVICE_TURN_ON + if domain == media_player.DOMAIN: + supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + power_features = media_player.SUPPORT_TURN_ON | media_player.SUPPORT_TURN_OFF + if not supported & power_features: + service = media_player.SERVICE_MEDIA_PLAY + + await hass.services.async_call( + domain, + service, + {ATTR_ENTITY_ID: entity.entity_id}, + blocking=False, + context=context, + ) + + return directive.response() + + +@HANDLERS.register(("Alexa.PowerController", "TurnOff")) +async def async_api_turn_off(hass, config, directive, context): + """Process a turn off request.""" + entity = directive.entity + domain = entity.domain + if entity.domain == group.DOMAIN: + domain = ha.DOMAIN + + service = SERVICE_TURN_OFF + if domain == media_player.DOMAIN: + supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + power_features = media_player.SUPPORT_TURN_ON | media_player.SUPPORT_TURN_OFF + if not supported & power_features: + service = media_player.SERVICE_MEDIA_STOP + + await hass.services.async_call( + domain, + service, + {ATTR_ENTITY_ID: entity.entity_id}, + blocking=False, + context=context, + ) + + return directive.response() + + +@HANDLERS.register(("Alexa.BrightnessController", "SetBrightness")) +async def async_api_set_brightness(hass, config, directive, context): + """Process a set brightness request.""" + entity = directive.entity + brightness = int(directive.payload["brightness"]) + + await hass.services.async_call( + entity.domain, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity.entity_id, light.ATTR_BRIGHTNESS_PCT: brightness}, + blocking=False, + context=context, + ) + + return directive.response() + + +@HANDLERS.register(("Alexa.BrightnessController", "AdjustBrightness")) +async def async_api_adjust_brightness(hass, config, directive, context): + """Process an adjust brightness request.""" + entity = directive.entity + brightness_delta = int(directive.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) + await hass.services.async_call( + entity.domain, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity.entity_id, light.ATTR_BRIGHTNESS_PCT: brightness}, + blocking=False, + context=context, + ) + + return directive.response() + + +@HANDLERS.register(("Alexa.ColorController", "SetColor")) +async def async_api_set_color(hass, config, directive, context): + """Process a set color request.""" + entity = directive.entity + rgb = color_util.color_hsb_to_RGB( + float(directive.payload["color"]["hue"]), + float(directive.payload["color"]["saturation"]), + float(directive.payload["color"]["brightness"]), + ) + + await hass.services.async_call( + entity.domain, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity.entity_id, light.ATTR_RGB_COLOR: rgb}, + blocking=False, + context=context, + ) + + return directive.response() + + +@HANDLERS.register(("Alexa.ColorTemperatureController", "SetColorTemperature")) +async def async_api_set_color_temperature(hass, config, directive, context): + """Process a set color temperature request.""" + entity = directive.entity + kelvin = int(directive.payload["colorTemperatureInKelvin"]) + + await hass.services.async_call( + entity.domain, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity.entity_id, light.ATTR_KELVIN: kelvin}, + blocking=False, + context=context, + ) + + return directive.response() + + +@HANDLERS.register(("Alexa.ColorTemperatureController", "DecreaseColorTemperature")) +async def async_api_decrease_color_temp(hass, config, directive, context): + """Process a decrease color temperature request.""" + entity = directive.entity + 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) + await hass.services.async_call( + entity.domain, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity.entity_id, light.ATTR_COLOR_TEMP: value}, + blocking=False, + context=context, + ) + + return directive.response() + + +@HANDLERS.register(("Alexa.ColorTemperatureController", "IncreaseColorTemperature")) +async def async_api_increase_color_temp(hass, config, directive, context): + """Process an increase color temperature request.""" + entity = directive.entity + 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) + await hass.services.async_call( + entity.domain, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity.entity_id, light.ATTR_COLOR_TEMP: value}, + blocking=False, + context=context, + ) + + return directive.response() + + +@HANDLERS.register(("Alexa.SceneController", "Activate")) +async def async_api_activate(hass, config, directive, context): + """Process an activate request.""" + entity = directive.entity + domain = entity.domain + + await hass.services.async_call( + domain, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity.entity_id}, + blocking=False, + context=context, + ) + + payload = { + "cause": {"type": Cause.VOICE_INTERACTION}, + "timestamp": f"{dt_util.utcnow().replace(tzinfo=None).isoformat()}Z", + } + + return directive.response( + name="ActivationStarted", namespace="Alexa.SceneController", payload=payload + ) + + +@HANDLERS.register(("Alexa.SceneController", "Deactivate")) +async def async_api_deactivate(hass, config, directive, context): + """Process a deactivate request.""" + entity = directive.entity + domain = entity.domain + + await hass.services.async_call( + domain, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity.entity_id}, + blocking=False, + context=context, + ) + + payload = { + "cause": {"type": Cause.VOICE_INTERACTION}, + "timestamp": f"{dt_util.utcnow().replace(tzinfo=None).isoformat()}Z", + } + + return directive.response( + name="DeactivationStarted", namespace="Alexa.SceneController", payload=payload + ) + + +@HANDLERS.register(("Alexa.PercentageController", "SetPercentage")) +async def async_api_set_percentage(hass, config, directive, context): + """Process a set percentage request.""" + entity = directive.entity + percentage = int(directive.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 + + await hass.services.async_call( + entity.domain, service, data, blocking=False, context=context + ) + + return directive.response() + + +@HANDLERS.register(("Alexa.PercentageController", "AdjustPercentage")) +async def async_api_adjust_percentage(hass, config, directive, context): + """Process an adjust percentage request.""" + entity = directive.entity + percentage_delta = int(directive.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) + current = PERCENTAGE_FAN_MAP.get(speed, 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 + + await hass.services.async_call( + entity.domain, service, data, blocking=False, context=context + ) + + return directive.response() + + +@HANDLERS.register(("Alexa.LockController", "Lock")) +async def async_api_lock(hass, config, directive, context): + """Process a lock request.""" + entity = directive.entity + await hass.services.async_call( + entity.domain, + SERVICE_LOCK, + {ATTR_ENTITY_ID: entity.entity_id}, + blocking=False, + context=context, + ) + + response = directive.response() + response.add_context_property( + {"name": "lockState", "namespace": "Alexa.LockController", "value": "LOCKED"} + ) + return response + + +@HANDLERS.register(("Alexa.LockController", "Unlock")) +async def async_api_unlock(hass, config, directive, context): + """Process an unlock request.""" + entity = directive.entity + await hass.services.async_call( + entity.domain, + SERVICE_UNLOCK, + {ATTR_ENTITY_ID: entity.entity_id}, + blocking=False, + context=context, + ) + + response = directive.response() + response.add_context_property( + {"namespace": "Alexa.LockController", "name": "lockState", "value": "UNLOCKED"} + ) + + return response + + +@HANDLERS.register(("Alexa.Speaker", "SetVolume")) +async def async_api_set_volume(hass, config, directive, context): + """Process a set volume request.""" + volume = round(float(directive.payload["volume"] / 100), 2) + entity = directive.entity + + data = { + ATTR_ENTITY_ID: entity.entity_id, + media_player.const.ATTR_MEDIA_VOLUME_LEVEL: volume, + } + + await hass.services.async_call( + entity.domain, SERVICE_VOLUME_SET, data, blocking=False, context=context + ) + + return directive.response() + + +@HANDLERS.register(("Alexa.InputController", "SelectInput")) +async def async_api_select_input(hass, config, directive, context): + """Process a set input request.""" + media_input = directive.payload["input"] + entity = directive.entity + + # Attempt to map the ALL UPPERCASE payload name to a source. + # Strips trailing 1 to match single input devices. + source_list = entity.attributes.get(media_player.const.ATTR_INPUT_SOURCE_LIST, []) + for source in source_list: + formatted_source = ( + source.lower().replace("-", "").replace("_", "").replace(" ", "") + ) + media_input = media_input.lower().replace(" ", "") + if ( + formatted_source in Inputs.VALID_SOURCE_NAME_MAP.keys() + and formatted_source == media_input + ) or ( + media_input.endswith("1") and formatted_source == media_input.rstrip("1") + ): + media_input = source + break + else: + msg = "failed to map input {} to a media source on {}".format( + media_input, entity.entity_id + ) + raise AlexaInvalidValueError(msg) + + data = { + ATTR_ENTITY_ID: entity.entity_id, + media_player.const.ATTR_INPUT_SOURCE: media_input, + } + + await hass.services.async_call( + entity.domain, + media_player.SERVICE_SELECT_SOURCE, + data, + blocking=False, + context=context, + ) + + return directive.response() + + +@HANDLERS.register(("Alexa.Speaker", "AdjustVolume")) +async def async_api_adjust_volume(hass, config, directive, context): + """Process an adjust volume request.""" + volume_delta = int(directive.payload["volume"]) + + entity = directive.entity + current_level = entity.attributes.get(media_player.const.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.const.ATTR_MEDIA_VOLUME_LEVEL: volume, + } + + await hass.services.async_call( + entity.domain, SERVICE_VOLUME_SET, data, blocking=False, context=context + ) + + return directive.response() + + +@HANDLERS.register(("Alexa.StepSpeaker", "AdjustVolume")) +async def async_api_adjust_volume_step(hass, config, directive, context): + """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. + # This workaround will simply call the volume up/Volume down the amount of steps asked for + # When no steps are called in the request, Alexa sends a default of 10 steps which for most + # purposes is too high. The default is set 1 in this case. + entity = directive.entity + volume_int = int(directive.payload["volumeSteps"]) + is_default = bool(directive.payload["volumeStepsDefault"]) + default_steps = 1 + + if volume_int < 0: + service_volume = SERVICE_VOLUME_DOWN + if is_default: + volume_int = -default_steps + else: + service_volume = SERVICE_VOLUME_UP + if is_default: + volume_int = default_steps + + data = {ATTR_ENTITY_ID: entity.entity_id} + + for _ in range(0, abs(volume_int)): + await hass.services.async_call( + entity.domain, service_volume, data, blocking=False, context=context + ) + + return directive.response() + + +@HANDLERS.register(("Alexa.StepSpeaker", "SetMute")) +@HANDLERS.register(("Alexa.Speaker", "SetMute")) +async def async_api_set_mute(hass, config, directive, context): + """Process a set mute request.""" + mute = bool(directive.payload["mute"]) + entity = directive.entity + data = { + ATTR_ENTITY_ID: entity.entity_id, + media_player.const.ATTR_MEDIA_VOLUME_MUTED: mute, + } + + await hass.services.async_call( + entity.domain, SERVICE_VOLUME_MUTE, data, blocking=False, context=context + ) + + return directive.response() + + +@HANDLERS.register(("Alexa.PlaybackController", "Play")) +async def async_api_play(hass, config, directive, context): + """Process a play request.""" + entity = directive.entity + data = {ATTR_ENTITY_ID: entity.entity_id} + + await hass.services.async_call( + entity.domain, SERVICE_MEDIA_PLAY, data, blocking=False, context=context + ) + + return directive.response() + + +@HANDLERS.register(("Alexa.PlaybackController", "Pause")) +async def async_api_pause(hass, config, directive, context): + """Process a pause request.""" + entity = directive.entity + data = {ATTR_ENTITY_ID: entity.entity_id} + + await hass.services.async_call( + entity.domain, SERVICE_MEDIA_PAUSE, data, blocking=False, context=context + ) + + return directive.response() + + +@HANDLERS.register(("Alexa.PlaybackController", "Stop")) +async def async_api_stop(hass, config, directive, context): + """Process a stop request.""" + entity = directive.entity + data = {ATTR_ENTITY_ID: entity.entity_id} + + await hass.services.async_call( + entity.domain, SERVICE_MEDIA_STOP, data, blocking=False, context=context + ) + + return directive.response() + + +@HANDLERS.register(("Alexa.PlaybackController", "Next")) +async def async_api_next(hass, config, directive, context): + """Process a next request.""" + entity = directive.entity + data = {ATTR_ENTITY_ID: entity.entity_id} + + await hass.services.async_call( + entity.domain, SERVICE_MEDIA_NEXT_TRACK, data, blocking=False, context=context + ) + + return directive.response() + + +@HANDLERS.register(("Alexa.PlaybackController", "Previous")) +async def async_api_previous(hass, config, directive, context): + """Process a previous request.""" + entity = directive.entity + data = {ATTR_ENTITY_ID: entity.entity_id} + + await hass.services.async_call( + entity.domain, + SERVICE_MEDIA_PREVIOUS_TRACK, + data, + blocking=False, + context=context, + ) + + return directive.response() + + +def temperature_from_object(hass, temp_obj, interval=False): + """Get temperature from Temperature object in requested unit.""" + to_unit = hass.config.units.temperature_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")) +async def async_api_set_target_temp(hass, config, directive, context): + """Process a set target temperature request.""" + entity = directive.entity + min_temp = entity.attributes.get(climate.ATTR_MIN_TEMP) + max_temp = entity.attributes.get(climate.ATTR_MAX_TEMP) + unit = hass.config.units.temperature_unit + + data = {ATTR_ENTITY_ID: entity.entity_id} + + payload = directive.payload + response = directive.response() + if "targetSetpoint" in payload: + temp = temperature_from_object(hass, payload["targetSetpoint"]) + if temp < min_temp or temp > max_temp: + raise AlexaTempRangeError(hass, temp, min_temp, max_temp) + data[ATTR_TEMPERATURE] = temp + response.add_context_property( + { + "name": "targetSetpoint", + "namespace": "Alexa.ThermostatController", + "value": {"value": temp, "scale": API_TEMP_UNITS[unit]}, + } + ) + if "lowerSetpoint" in payload: + temp_low = temperature_from_object(hass, payload["lowerSetpoint"]) + if temp_low < min_temp or temp_low > max_temp: + raise AlexaTempRangeError(hass, temp_low, min_temp, max_temp) + data[climate.ATTR_TARGET_TEMP_LOW] = temp_low + response.add_context_property( + { + "name": "lowerSetpoint", + "namespace": "Alexa.ThermostatController", + "value": {"value": temp_low, "scale": API_TEMP_UNITS[unit]}, + } + ) + if "upperSetpoint" in payload: + temp_high = temperature_from_object(hass, payload["upperSetpoint"]) + if temp_high < min_temp or temp_high > max_temp: + raise AlexaTempRangeError(hass, temp_high, min_temp, max_temp) + data[climate.ATTR_TARGET_TEMP_HIGH] = temp_high + response.add_context_property( + { + "name": "upperSetpoint", + "namespace": "Alexa.ThermostatController", + "value": {"value": temp_high, "scale": API_TEMP_UNITS[unit]}, + } + ) + + await hass.services.async_call( + entity.domain, + climate.SERVICE_SET_TEMPERATURE, + data, + blocking=False, + context=context, + ) + + return response + + +@HANDLERS.register(("Alexa.ThermostatController", "AdjustTargetTemperature")) +async def async_api_adjust_target_temp(hass, config, directive, context): + """Process an adjust target temperature request.""" + entity = directive.entity + min_temp = entity.attributes.get(climate.ATTR_MIN_TEMP) + max_temp = entity.attributes.get(climate.ATTR_MAX_TEMP) + unit = hass.config.units.temperature_unit + + temp_delta = temperature_from_object( + hass, directive.payload["targetSetpointDelta"], interval=True + ) + target_temp = float(entity.attributes.get(ATTR_TEMPERATURE)) + temp_delta + + if target_temp < min_temp or target_temp > max_temp: + raise AlexaTempRangeError(hass, target_temp, min_temp, max_temp) + + data = {ATTR_ENTITY_ID: entity.entity_id, ATTR_TEMPERATURE: target_temp} + + response = directive.response() + await hass.services.async_call( + entity.domain, + climate.SERVICE_SET_TEMPERATURE, + data, + blocking=False, + context=context, + ) + response.add_context_property( + { + "name": "targetSetpoint", + "namespace": "Alexa.ThermostatController", + "value": {"value": target_temp, "scale": API_TEMP_UNITS[unit]}, + } + ) + + return response + + +@HANDLERS.register(("Alexa.ThermostatController", "SetThermostatMode")) +async def async_api_set_thermostat_mode(hass, config, directive, context): + """Process a set thermostat mode request.""" + entity = directive.entity + mode = directive.payload["thermostatMode"] + mode = mode if isinstance(mode, str) else mode["value"] + + data = {ATTR_ENTITY_ID: entity.entity_id} + + ha_preset = next((k for k, v in API_THERMOSTAT_PRESETS.items() if v == mode), None) + + if ha_preset: + presets = entity.attributes.get(climate.ATTR_PRESET_MODES, []) + + if ha_preset not in presets: + msg = f"The requested thermostat mode {ha_preset} is not supported" + raise AlexaUnsupportedThermostatModeError(msg) + + service = climate.SERVICE_SET_PRESET_MODE + data[climate.ATTR_PRESET_MODE] = ha_preset + + elif mode == "CUSTOM": + operation_list = entity.attributes.get(climate.ATTR_HVAC_MODES) + custom_mode = directive.payload["thermostatMode"]["customName"] + custom_mode = next( + (k for k, v in API_THERMOSTAT_MODES_CUSTOM.items() if v == custom_mode), + None, + ) + if custom_mode not in operation_list: + msg = ( + f"The requested thermostat mode {mode}: {custom_mode} is not supported" + ) + raise AlexaUnsupportedThermostatModeError(msg) + + service = climate.SERVICE_SET_HVAC_MODE + data[climate.ATTR_HVAC_MODE] = custom_mode + + else: + operation_list = entity.attributes.get(climate.ATTR_HVAC_MODES) + ha_modes = {k: v for k, v in API_THERMOSTAT_MODES.items() if v == mode} + ha_mode = next(iter(set(ha_modes).intersection(operation_list)), None) + if ha_mode not in operation_list: + msg = f"The requested thermostat mode {mode} is not supported" + raise AlexaUnsupportedThermostatModeError(msg) + + service = climate.SERVICE_SET_HVAC_MODE + data[climate.ATTR_HVAC_MODE] = ha_mode + + response = directive.response() + await hass.services.async_call( + climate.DOMAIN, service, data, blocking=False, context=context + ) + response.add_context_property( + { + "name": "thermostatMode", + "namespace": "Alexa.ThermostatController", + "value": mode, + } + ) + + return response + + +@HANDLERS.register(("Alexa", "ReportState")) +async def async_api_reportstate(hass, config, directive, context): + """Process a ReportState request.""" + return directive.response(name="StateReport") + + +@HANDLERS.register(("Alexa.PowerLevelController", "SetPowerLevel")) +async def async_api_set_power_level(hass, config, directive, context): + """Process a SetPowerLevel request.""" + entity = directive.entity + percentage = int(directive.payload["powerLevel"]) + 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" + else: + speed = "high" + + data[fan.ATTR_SPEED] = speed + + await hass.services.async_call( + entity.domain, service, data, blocking=False, context=context + ) + + return directive.response() + + +@HANDLERS.register(("Alexa.PowerLevelController", "AdjustPowerLevel")) +async def async_api_adjust_power_level(hass, config, directive, context): + """Process an AdjustPowerLevel request.""" + entity = directive.entity + percentage_delta = int(directive.payload["powerLevelDelta"]) + 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) + current = PERCENTAGE_FAN_MAP.get(speed, 100) + + # set percentage + percentage = max(0, percentage_delta + current) + speed = "off" + + if percentage <= 33: + speed = "low" + elif percentage <= 66: + speed = "medium" + else: + speed = "high" + + data[fan.ATTR_SPEED] = speed + + await hass.services.async_call( + entity.domain, service, data, blocking=False, context=context + ) + + return directive.response() + + +@HANDLERS.register(("Alexa.SecurityPanelController", "Arm")) +async def async_api_arm(hass, config, directive, context): + """Process a Security Panel Arm request.""" + entity = directive.entity + service = None + arm_state = directive.payload["armState"] + data = {ATTR_ENTITY_ID: entity.entity_id} + + if entity.state != STATE_ALARM_DISARMED: + msg = "You must disarm the system before you can set the requested arm state." + raise AlexaSecurityPanelAuthorizationRequired(msg) + + if arm_state == "ARMED_AWAY": + service = SERVICE_ALARM_ARM_AWAY + if arm_state == "ARMED_STAY": + service = SERVICE_ALARM_ARM_HOME + if arm_state == "ARMED_NIGHT": + service = SERVICE_ALARM_ARM_NIGHT + + await hass.services.async_call( + entity.domain, service, data, blocking=False, context=context + ) + + response = directive.response( + name="Arm.Response", namespace="Alexa.SecurityPanelController" + ) + + response.add_context_property( + { + "name": "armState", + "namespace": "Alexa.SecurityPanelController", + "value": arm_state, + } + ) + + return response + + +@HANDLERS.register(("Alexa.SecurityPanelController", "Disarm")) +async def async_api_disarm(hass, config, directive, context): + """Process a Security Panel Disarm request.""" + entity = directive.entity + data = {ATTR_ENTITY_ID: entity.entity_id} + + payload = directive.payload + if "authorization" in payload: + value = payload["authorization"]["value"] + if payload["authorization"]["type"] == "FOUR_DIGIT_PIN": + data["code"] = value + + if not await hass.services.async_call( + entity.domain, SERVICE_ALARM_DISARM, data, blocking=True, context=context + ): + msg = "Invalid Code" + raise AlexaSecurityPanelUnauthorizedError(msg) + + response = directive.response() + response.add_context_property( + { + "name": "armState", + "namespace": "Alexa.SecurityPanelController", + "value": "DISARMED", + } + ) + + return response + + +@HANDLERS.register(("Alexa.ModeController", "SetMode")) +async def async_api_set_mode(hass, config, directive, context): + """Process a SetMode directive.""" + entity = directive.entity + instance = directive.instance + domain = entity.domain + service = None + data = {ATTR_ENTITY_ID: entity.entity_id} + mode = directive.payload["mode"] + + # Fan Direction + if instance == f"{fan.DOMAIN}.{fan.ATTR_DIRECTION}": + _, direction = mode.split(".") + if direction in (fan.DIRECTION_REVERSE, fan.DIRECTION_FORWARD): + service = fan.SERVICE_SET_DIRECTION + data[fan.ATTR_DIRECTION] = direction + + # Cover Position + elif instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": + _, position = mode.split(".") + + if position == cover.STATE_CLOSED: + service = cover.SERVICE_CLOSE_COVER + elif position == cover.STATE_OPEN: + service = cover.SERVICE_OPEN_COVER + elif position == "custom": + service = cover.SERVICE_STOP_COVER + + else: + msg = "Entity does not support directive" + raise AlexaInvalidDirectiveError(msg) + + await hass.services.async_call( + domain, service, data, blocking=False, context=context + ) + + response = directive.response() + response.add_context_property( + { + "namespace": "Alexa.ModeController", + "instance": instance, + "name": "mode", + "value": mode, + } + ) + + return response + + +@HANDLERS.register(("Alexa.ModeController", "AdjustMode")) +async def async_api_adjust_mode(hass, config, directive, context): + """Process a AdjustMode request. + + Requires capabilityResources supportedModes to be ordered. + Only supportedModes with ordered=True support the adjustMode directive. + """ + + # Currently no supportedModes are configured with ordered=True to support this request. + msg = "Entity does not support directive" + raise AlexaInvalidDirectiveError(msg) + + +@HANDLERS.register(("Alexa.ToggleController", "TurnOn")) +async def async_api_toggle_on(hass, config, directive, context): + """Process a toggle on request.""" + entity = directive.entity + instance = directive.instance + domain = entity.domain + service = None + data = {ATTR_ENTITY_ID: entity.entity_id} + + # Fan Oscillating + if instance == f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}": + service = fan.SERVICE_OSCILLATE + data[fan.ATTR_OSCILLATING] = True + else: + msg = "Entity does not support directive" + raise AlexaInvalidDirectiveError(msg) + + await hass.services.async_call( + domain, service, data, blocking=False, context=context + ) + + response = directive.response() + response.add_context_property( + { + "namespace": "Alexa.ToggleController", + "instance": instance, + "name": "toggleState", + "value": "ON", + } + ) + + return response + + +@HANDLERS.register(("Alexa.ToggleController", "TurnOff")) +async def async_api_toggle_off(hass, config, directive, context): + """Process a toggle off request.""" + entity = directive.entity + instance = directive.instance + domain = entity.domain + service = None + data = {ATTR_ENTITY_ID: entity.entity_id} + + # Fan Oscillating + if instance == f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}": + service = fan.SERVICE_OSCILLATE + data[fan.ATTR_OSCILLATING] = False + else: + msg = "Entity does not support directive" + raise AlexaInvalidDirectiveError(msg) + + await hass.services.async_call( + domain, service, data, blocking=False, context=context + ) + + response = directive.response() + response.add_context_property( + { + "namespace": "Alexa.ToggleController", + "instance": instance, + "name": "toggleState", + "value": "OFF", + } + ) + + return response + + +@HANDLERS.register(("Alexa.RangeController", "SetRangeValue")) +async def async_api_set_range(hass, config, directive, context): + """Process a next request.""" + entity = directive.entity + instance = directive.instance + domain = entity.domain + service = None + data = {ATTR_ENTITY_ID: entity.entity_id} + range_value = int(directive.payload["rangeValue"]) + + # Fan Speed + if instance == f"{fan.DOMAIN}.{fan.ATTR_SPEED}": + service = fan.SERVICE_SET_SPEED + speed = SPEED_FAN_MAP.get(range_value, None) + + if not speed: + msg = "Entity does not support value" + raise AlexaInvalidValueError(msg) + + if speed == fan.SPEED_OFF: + service = fan.SERVICE_TURN_OFF + + data[fan.ATTR_SPEED] = speed + + # Cover Position + elif instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": + if range_value == 0: + service = cover.SERVICE_CLOSE_COVER + elif range_value == 100: + service = cover.SERVICE_OPEN_COVER + else: + service = cover.SERVICE_SET_COVER_POSITION + data[cover.ATTR_POSITION] = range_value + + # Cover Tilt Position + elif instance == f"{cover.DOMAIN}.{cover.ATTR_TILT_POSITION}": + if range_value == 0: + service = cover.SERVICE_CLOSE_COVER_TILT + elif range_value == 100: + service = cover.SERVICE_OPEN_COVER_TILT + else: + service = cover.SERVICE_SET_COVER_TILT_POSITION + data[cover.ATTR_POSITION] = range_value + + else: + msg = "Entity does not support directive" + raise AlexaInvalidDirectiveError(msg) + + await hass.services.async_call( + domain, service, data, blocking=False, context=context + ) + + response = directive.response() + response.add_context_property( + { + "namespace": "Alexa.RangeController", + "instance": instance, + "name": "rangeValue", + "value": range_value, + } + ) + + return response + + +@HANDLERS.register(("Alexa.RangeController", "AdjustRangeValue")) +async def async_api_adjust_range(hass, config, directive, context): + """Process a next request.""" + entity = directive.entity + instance = directive.instance + domain = entity.domain + service = None + data = {ATTR_ENTITY_ID: entity.entity_id} + range_delta = int(directive.payload["rangeValueDelta"]) + response_value = 0 + + # Fan Speed + if instance == f"{fan.DOMAIN}.{fan.ATTR_SPEED}": + service = fan.SERVICE_SET_SPEED + current_range = RANGE_FAN_MAP.get(entity.attributes.get(fan.ATTR_SPEED), 0) + speed = SPEED_FAN_MAP.get( + min(3, max(0, range_delta + current_range)), fan.SPEED_OFF + ) + + if speed == fan.SPEED_OFF: + service = fan.SERVICE_TURN_OFF + + data[fan.ATTR_SPEED] = response_value = speed + + # Cover Position + elif instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": + service = SERVICE_SET_COVER_POSITION + current = entity.attributes.get(cover.ATTR_POSITION) + data[cover.ATTR_POSITION] = response_value = min( + 100, max(0, range_delta + current) + ) + + # Cover Tilt Position + elif instance == f"{cover.DOMAIN}.{cover.ATTR_TILT_POSITION}": + service = SERVICE_SET_COVER_TILT_POSITION + current = entity.attributes.get(cover.ATTR_TILT_POSITION) + data[cover.ATTR_TILT_POSITION] = response_value = min( + 100, max(0, range_delta + current) + ) + + else: + msg = "Entity does not support directive" + raise AlexaInvalidDirectiveError(msg) + + await hass.services.async_call( + domain, service, data, blocking=False, context=context + ) + + response = directive.response() + response.add_context_property( + { + "namespace": "Alexa.RangeController", + "instance": instance, + "name": "rangeValue", + "value": response_value, + } + ) + + return response + + +@HANDLERS.register(("Alexa.ChannelController", "ChangeChannel")) +async def async_api_changechannel(hass, config, directive, context): + """Process a change channel request.""" + channel = "0" + entity = directive.entity + channel_payload = directive.payload["channel"] + metadata_payload = directive.payload["channelMetadata"] + payload_name = "number" + + if "number" in channel_payload: + channel = channel_payload["number"] + payload_name = "number" + elif "callSign" in channel_payload: + channel = channel_payload["callSign"] + payload_name = "callSign" + elif "affiliateCallSign" in channel_payload: + channel = channel_payload["affiliateCallSign"] + payload_name = "affiliateCallSign" + elif "uri" in channel_payload: + channel = channel_payload["uri"] + payload_name = "uri" + elif "name" in metadata_payload: + channel = metadata_payload["name"] + payload_name = "callSign" + + data = { + ATTR_ENTITY_ID: entity.entity_id, + media_player.const.ATTR_MEDIA_CONTENT_ID: channel, + media_player.const.ATTR_MEDIA_CONTENT_TYPE: media_player.const.MEDIA_TYPE_CHANNEL, + } + + await hass.services.async_call( + entity.domain, + media_player.const.SERVICE_PLAY_MEDIA, + data, + blocking=False, + context=context, + ) + + response = directive.response() + + response.add_context_property( + { + "namespace": "Alexa.ChannelController", + "name": "channel", + "value": {payload_name: channel}, + } + ) + + return response + + +@HANDLERS.register(("Alexa.ChannelController", "SkipChannels")) +async def async_api_skipchannel(hass, config, directive, context): + """Process a skipchannel request.""" + channel = int(directive.payload["channelCount"]) + entity = directive.entity + + data = {ATTR_ENTITY_ID: entity.entity_id} + + if channel < 0: + service_media = SERVICE_MEDIA_PREVIOUS_TRACK + else: + service_media = SERVICE_MEDIA_NEXT_TRACK + + for _ in range(0, abs(channel)): + await hass.services.async_call( + entity.domain, service_media, data, blocking=False, context=context + ) + + response = directive.response() + + response.add_context_property( + { + "namespace": "Alexa.ChannelController", + "name": "channel", + "value": {"number": ""}, + } + ) + + return response + + +@HANDLERS.register(("Alexa.SeekController", "AdjustSeekPosition")) +async def async_api_seek(hass, config, directive, context): + """Process a seek request.""" + entity = directive.entity + position_delta = int(directive.payload["deltaPositionMilliseconds"]) + + current_position = entity.attributes.get(media_player.ATTR_MEDIA_POSITION) + if not current_position: + msg = f"{entity} did not return the current media position." + raise AlexaVideoActionNotPermittedForContentError(msg) + + seek_position = int(current_position) + int(position_delta / 1000) + + if seek_position < 0: + seek_position = 0 + + media_duration = entity.attributes.get(media_player.ATTR_MEDIA_DURATION) + if media_duration and 0 < int(media_duration) < seek_position: + seek_position = media_duration + + data = { + ATTR_ENTITY_ID: entity.entity_id, + media_player.ATTR_MEDIA_SEEK_POSITION: seek_position, + } + + await hass.services.async_call( + media_player.DOMAIN, + media_player.SERVICE_MEDIA_SEEK, + data, + blocking=False, + context=context, + ) + + # convert seconds to milliseconds for StateReport. + seek_position = int(seek_position * 1000) + + payload = {"properties": [{"name": "positionMilliseconds", "value": seek_position}]} + return directive.response( + name="StateReport", namespace="Alexa.SeekController", payload=payload + ) diff --git a/homeassistant/components/alexa/intent.py b/homeassistant/components/alexa/intent.py index 8d4520d74..4cb75c65b 100644 --- a/homeassistant/components/alexa/intent.py +++ b/homeassistant/components/alexa/intent.py @@ -1,10 +1,4 @@ -""" -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 +"""Support for Alexa skill service end point.""" import enum import logging @@ -20,27 +14,24 @@ _LOGGER = logging.getLogger(__name__) HANDLERS = Registry() -INTENTS_API_ENDPOINT = '/api/alexa' +INTENTS_API_ENDPOINT = "/api/alexa" class SpeechType(enum.Enum): """The Alexa speech types.""" - plaintext = 'PlainText' - ssml = 'SSML' + plaintext = "PlainText" + ssml = "SSML" -SPEECH_MAPPINGS = { - 'plain': SpeechType.plaintext, - 'ssml': SpeechType.ssml, -} +SPEECH_MAPPINGS = {"plain": SpeechType.plaintext, "ssml": SpeechType.ssml} class CardType(enum.Enum): """The Alexa card types.""" - simple = 'Simple' - link_account = 'LinkAccount' + simple = "Simple" + link_account = "LinkAccount" @callback @@ -57,52 +48,56 @@ class AlexaIntentsView(http.HomeAssistantView): """Handle Alexa requests.""" url = INTENTS_API_ENDPOINT - name = 'api:alexa' + name = "api:alexa" - @asyncio.coroutine - def post(self, request): + async def post(self, request): """Handle Alexa.""" - hass = request.app['hass'] - message = yield from request.json() + hass = request.app["hass"] + message = await 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) + response = await 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))) + 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.")) + 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.")) + 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.")) + 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_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): +async def async_handle_message(hass, message): """Handle an Alexa intent. Raises: @@ -112,28 +107,26 @@ def async_handle_message(hass, message): - intent.IntentError """ - req = message.get('request') - req_type = req['type'] + req = message.get("request") + req_type = req["type"] handler = HANDLERS.get(req_type) if not handler: - raise UnknownRequest('Received unknown request {}'.format(req_type)) + raise UnknownRequest(f"Received unknown request {req_type}") - return (yield from handler(hass, message)) + return await handler(hass, message) -@HANDLERS.register('SessionEndedRequest') -@asyncio.coroutine -def async_handle_session_end(hass, message): +@HANDLERS.register("SessionEndedRequest") +async 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): +@HANDLERS.register("IntentRequest") +@HANDLERS.register("LaunchRequest") +async def async_handle_intent(hass, message): """Handle an intent request. Raises: @@ -142,33 +135,37 @@ def async_handle_intent(hass, message): - intent.IntentError """ - req = message.get('request') - alexa_intent_info = req.get('intent') + 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') + if req["type"] == "LaunchRequest": + intent_name = ( + message.get("session", {}).get("application", {}).get("applicationId") + ) else: - intent_name = alexa_intent_info['name'] + 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()}) + intent_response = await 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']) + alexa_speech, intent_response.speech[intent_speech]["speech"] + ) break - if 'simple' in intent_response.card: + if "simple" in intent_response.card: alexa_response.add_card( - CardType.simple, intent_response.card['simple']['title'], - intent_response.card['simple']['content']) + CardType.simple, + intent_response.card["simple"]["title"], + intent_response.card["simple"]["content"], + ) return alexa_response.as_dict() @@ -178,23 +175,23 @@ def resolve_slot_synonyms(key, request): # 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'] + resolved_value = request["value"] - if ('resolutions' in request and - 'resolutionsPerAuthority' in request['resolutions'] and - len(request['resolutions']['resolutionsPerAuthority']) >= 1): + 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: + 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']]) + 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 @@ -202,9 +199,9 @@ def resolve_slot_synonyms(key, request): resolved_value = possible_values[0] else: _LOGGER.debug( - 'Found multiple synonym resolutions for slot value: {%s: %s}', + "Found multiple synonym resolutions for slot value: {%s: %s}", key, - request['value'] + request["value"], ) return resolved_value @@ -225,12 +222,12 @@ class AlexaResponse: # 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(): + for key, value in intent_info.get("slots", {}).items(): # Only include slots with values - if 'value' not in value: + if "value" not in value: continue - _key = key.replace('.', '_') + _key = key.replace(".", "_") self.variables[_key] = resolve_slot_synonyms(key, value) @@ -238,9 +235,7 @@ class AlexaResponse: """Add a card to the response.""" assert self.card is None - card = { - "type": card_type.value - } + card = {"type": card_type.value} if card_type == CardType.link_account: self.card = card @@ -254,43 +249,36 @@ class AlexaResponse: """Add speech to the response.""" assert self.speech is None - key = 'ssml' if speech_type == SpeechType.ssml else 'text' + key = "ssml" if speech_type == SpeechType.ssml else "text" - self.speech = { - 'type': speech_type.value, - key: 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' + key = "ssml" if speech_type == SpeechType.ssml else "text" self.reprompt = { - 'type': speech_type.value, - key: text.async_render(self.variables) + "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 - } + response = {"shouldEndSession": self.should_end_session} if self.card is not None: - response['card'] = self.card + response["card"] = self.card if self.speech is not None: - response['outputSpeech'] = self.speech + response["outputSpeech"] = self.speech if self.reprompt is not None: - response['reprompt'] = { - 'outputSpeech': self.reprompt - } + response["reprompt"] = {"outputSpeech": self.reprompt} return { - 'version': '1.0', - 'sessionAttributes': self.session_attributes, - 'response': response, + "version": "1.0", + "sessionAttributes": self.session_attributes, + "response": response, } diff --git a/homeassistant/components/alexa/manifest.json b/homeassistant/components/alexa/manifest.json new file mode 100644 index 000000000..ad0f1c33d --- /dev/null +++ b/homeassistant/components/alexa/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "alexa", + "name": "Alexa", + "documentation": "https://www.home-assistant.io/integrations/alexa", + "requirements": [], + "dependencies": ["http"], + "codeowners": [ + "@home-assistant/cloud", + "@ochlocracy" + ] +} diff --git a/homeassistant/components/alexa/messages.py b/homeassistant/components/alexa/messages.py new file mode 100644 index 000000000..cb78f269f --- /dev/null +++ b/homeassistant/components/alexa/messages.py @@ -0,0 +1,195 @@ +"""Alexa models.""" +import logging +from uuid import uuid4 + +from .const import ( + API_CONTEXT, + API_DIRECTIVE, + API_ENDPOINT, + API_EVENT, + API_HEADER, + API_PAYLOAD, + API_SCOPE, +) +from .entities import ENTITY_ADAPTERS +from .errors import AlexaInvalidEndpointError + +_LOGGER = logging.getLogger(__name__) + + +class AlexaDirective: + """An incoming Alexa directive.""" + + def __init__(self, request): + """Initialize a directive.""" + self._directive = request[API_DIRECTIVE] + self.namespace = self._directive[API_HEADER]["namespace"] + self.name = self._directive[API_HEADER]["name"] + self.payload = self._directive[API_PAYLOAD] + self.has_endpoint = API_ENDPOINT in self._directive + + self.entity = self.entity_id = self.endpoint = self.instance = None + + def load_entity(self, hass, config): + """Set attributes related to the entity for this request. + + Sets these attributes when self.has_endpoint is True: + + - entity + - entity_id + - endpoint + - instance (when header includes instance property) + + Behavior when self.has_endpoint is False is undefined. + + Will raise AlexaInvalidEndpointError if the endpoint in the request is + malformed or nonexistant. + """ + _endpoint_id = self._directive[API_ENDPOINT]["endpointId"] + self.entity_id = _endpoint_id.replace("#", ".") + + self.entity = hass.states.get(self.entity_id) + if not self.entity or not config.should_expose(self.entity_id): + raise AlexaInvalidEndpointError(_endpoint_id) + + self.endpoint = ENTITY_ADAPTERS[self.entity.domain](hass, config, self.entity) + if "instance" in self._directive[API_HEADER]: + self.instance = self._directive[API_HEADER]["instance"] + + def response(self, name="Response", namespace="Alexa", payload=None): + """Create an API formatted response. + + Async friendly. + """ + response = AlexaResponse(name, namespace, payload) + + token = self._directive[API_HEADER].get("correlationToken") + if token: + response.set_correlation_token(token) + + if self.has_endpoint: + response.set_endpoint(self._directive[API_ENDPOINT].copy()) + + return response + + def error( + self, + 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", + self._directive[API_HEADER]["namespace"], + self._directive[API_HEADER]["name"], + error_type, + error_message, + ) + + return self.response(name="ErrorResponse", namespace=namespace, payload=payload) + + +class AlexaResponse: + """Class to hold a response.""" + + def __init__(self, name, namespace, payload=None): + """Initialize the response.""" + payload = payload or {} + self._response = { + API_EVENT: { + API_HEADER: { + "namespace": namespace, + "name": name, + "messageId": str(uuid4()), + "payloadVersion": "3", + }, + API_PAYLOAD: payload, + } + } + + @property + def name(self): + """Return the name of this response.""" + return self._response[API_EVENT][API_HEADER]["name"] + + @property + def namespace(self): + """Return the namespace of this response.""" + return self._response[API_EVENT][API_HEADER]["namespace"] + + def set_correlation_token(self, token): + """Set the correlationToken. + + This should normally mirror the value from a request, and is set by + AlexaDirective.response() usually. + """ + self._response[API_EVENT][API_HEADER]["correlationToken"] = token + + def set_endpoint_full(self, bearer_token, endpoint_id, cookie=None): + """Set the endpoint dictionary. + + This is used to send proactive messages to Alexa. + """ + self._response[API_EVENT][API_ENDPOINT] = { + API_SCOPE: {"type": "BearerToken", "token": bearer_token} + } + + if endpoint_id is not None: + self._response[API_EVENT][API_ENDPOINT]["endpointId"] = endpoint_id + + if cookie is not None: + self._response[API_EVENT][API_ENDPOINT]["cookie"] = cookie + + def set_endpoint(self, endpoint): + """Set the endpoint. + + This should normally mirror the value from a request, and is set by + AlexaDirective.response() usually. + """ + self._response[API_EVENT][API_ENDPOINT] = endpoint + + def _properties(self): + context = self._response.setdefault(API_CONTEXT, {}) + return context.setdefault("properties", []) + + def add_context_property(self, prop): + """Add a property to the response context. + + The Alexa response includes a list of properties which provides + feedback on how states have changed. For example if a user asks, + "Alexa, set thermostat to 20 degrees", the API expects a response with + the new value of the property, and Alexa will respond to the user + "Thermostat set to 20 degrees". + + async_handle_message() will call .merge_context_properties() for every + request automatically, however often handlers will call services to + change state but the effects of those changes are applied + asynchronously. Thus, handlers should call this method to confirm + changes before returning. + """ + self._properties().append(prop) + + def merge_context_properties(self, endpoint): + """Add all properties from given endpoint if not already set. + + Handlers should be using .add_context_property(). + """ + properties = self._properties() + already_set = {(p["namespace"], p["name"]) for p in properties} + + for prop in endpoint.serialize_properties(): + if (prop["namespace"], prop["name"]) not in already_set: + self.add_context_property(prop) + + def serialize(self): + """Return response as a JSON-able data structure.""" + return self._response diff --git a/homeassistant/components/alexa/resources.py b/homeassistant/components/alexa/resources.py new file mode 100644 index 000000000..061005252 --- /dev/null +++ b/homeassistant/components/alexa/resources.py @@ -0,0 +1,387 @@ +"""Alexa Resources and Assets.""" + + +class AlexaGlobalCatalog: + """The Global Alexa catalog. + + https://developer.amazon.com/docs/device-apis/resources-and-assets.html#global-alexa-catalog + + You can use the global Alexa catalog for pre-defined names of devices, settings, values, and units. + This catalog is localized into all the languages that Alexa supports. + + You can reference the following catalog of pre-defined friendly names. + Each item in the following list is an asset identifier followed by its supported friendly names. + The first friendly name for each identifier is the one displayed in the Alexa mobile app. + """ + + # Air Purifier, Air Cleaner,Clean Air Machine + DEVICE_NAME_AIR_PURIFIER = "Alexa.DeviceName.AirPurifier" + + # Fan, Blower + DEVICE_NAME_FAN = "Alexa.DeviceName.Fan" + + # Router, Internet Router, Network Router, Wifi Router, Net Router + DEVICE_NAME_ROUTER = "Alexa.DeviceName.Router" + + # Shade, Blind, Curtain, Roller, Shutter, Drape, Awning, Window shade, Interior blind + DEVICE_NAME_SHADE = "Alexa.DeviceName.Shade" + + # Shower + DEVICE_NAME_SHOWER = "Alexa.DeviceName.Shower" + + # Space Heater, Portable Heater + DEVICE_NAME_SPACE_HEATER = "Alexa.DeviceName.SpaceHeater" + + # Washer, Washing Machine + DEVICE_NAME_WASHER = "Alexa.DeviceName.Washer" + + # 2.4G Guest Wi-Fi, 2.4G Guest Network, Guest Network 2.4G, 2G Guest Wifi + SETTING_2G_GUEST_WIFI = "Alexa.Setting.2GGuestWiFi" + + # 5G Guest Wi-Fi, 5G Guest Network, Guest Network 5G, 5G Guest Wifi + SETTING_5G_GUEST_WIFI = "Alexa.Setting.5GGuestWiFi" + + # Auto, Automatic, Automatic Mode, Auto Mode + SETTING_AUTO = "Alexa.Setting.Auto" + + # Direction + SETTING_DIRECTION = "Alexa.Setting.Direction" + + # Dry Cycle, Dry Preset, Dry Setting, Dryer Cycle, Dryer Preset, Dryer Setting + SETTING_DRY_CYCLE = "Alexa.Setting.DryCycle" + + # Fan Speed, Airflow speed, Wind Speed, Air speed, Air velocity + SETTING_FAN_SPEED = "Alexa.Setting.FanSpeed" + + # Guest Wi-fi, Guest Network, Guest Net + SETTING_GUEST_WIFI = "Alexa.Setting.GuestWiFi" + + # Heat + SETTING_HEAT = "Alexa.Setting.Heat" + + # Mode + SETTING_MODE = "Alexa.Setting.Mode" + + # Night, Night Mode + SETTING_NIGHT = "Alexa.Setting.Night" + + # Opening, Height, Lift, Width + SETTING_OPENING = "Alexa.Setting.Opening" + + # Oscillate, Swivel, Oscillation, Spin, Back and forth + SETTING_OSCILLATE = "Alexa.Setting.Oscillate" + + # Preset, Setting + SETTING_PRESET = "Alexa.Setting.Preset" + + # Quiet, Quiet Mode, Noiseless, Silent + SETTING_QUIET = "Alexa.Setting.Quiet" + + # Temperature, Temp + SETTING_TEMPERATURE = "Alexa.Setting.Temperature" + + # Wash Cycle, Wash Preset, Wash setting + SETTING_WASH_CYCLE = "Alexa.Setting.WashCycle" + + # Water Temperature, Water Temp, Water Heat + SETTING_WATER_TEMPERATURE = "Alexa.Setting.WaterTemperature" + + # Handheld Shower, Shower Wand, Hand Shower + SHOWER_HAND_HELD = "Alexa.Shower.HandHeld" + + # Rain Head, Overhead shower, Rain Shower, Rain Spout, Rain Faucet + SHOWER_RAIN_HEAD = "Alexa.Shower.RainHead" + + # Degrees, Degree + UNIT_ANGLE_DEGREES = "Alexa.Unit.Angle.Degrees" + + # Radians, Radian + UNIT_ANGLE_RADIANS = "Alexa.Unit.Angle.Radians" + + # Feet, Foot + UNIT_DISTANCE_FEET = "Alexa.Unit.Distance.Feet" + + # Inches, Inch + UNIT_DISTANCE_INCHES = "Alexa.Unit.Distance.Inches" + + # Kilometers + UNIT_DISTANCE_KILOMETERS = "Alexa.Unit.Distance.Kilometers" + + # Meters, Meter, m + UNIT_DISTANCE_METERS = "Alexa.Unit.Distance.Meters" + + # Miles, Mile + UNIT_DISTANCE_MILES = "Alexa.Unit.Distance.Miles" + + # Yards, Yard + UNIT_DISTANCE_YARDS = "Alexa.Unit.Distance.Yards" + + # Grams, Gram, g + UNIT_MASS_GRAMS = "Alexa.Unit.Mass.Grams" + + # Kilograms, Kilogram, kg + UNIT_MASS_KILOGRAMS = "Alexa.Unit.Mass.Kilograms" + + # Percent + UNIT_PERCENT = "Alexa.Unit.Percent" + + # Celsius, Degrees Celsius, Degrees, C, Centigrade, Degrees Centigrade + UNIT_TEMPERATURE_CELSIUS = "Alexa.Unit.Temperature.Celsius" + + # Degrees, Degree + UNIT_TEMPERATURE_DEGREES = "Alexa.Unit.Temperature.Degrees" + + # Fahrenheit, Degrees Fahrenheit, Degrees F, Degrees, F + UNIT_TEMPERATURE_FAHRENHEIT = "Alexa.Unit.Temperature.Fahrenheit" + + # Kelvin, Degrees Kelvin, Degrees K, Degrees, K + UNIT_TEMPERATURE_KELVIN = "Alexa.Unit.Temperature.Kelvin" + + # Cubic Feet, Cubic Foot + UNIT_VOLUME_CUBIC_FEET = "Alexa.Unit.Volume.CubicFeet" + + # Cubic Meters, Cubic Meter, Meters Cubed + UNIT_VOLUME_CUBIC_METERS = "Alexa.Unit.Volume.CubicMeters" + + # Gallons, Gallon + UNIT_VOLUME_GALLONS = "Alexa.Unit.Volume.Gallons" + + # Liters, Liter, L + UNIT_VOLUME_LITERS = "Alexa.Unit.Volume.Liters" + + # Pints, Pint + UNIT_VOLUME_PINTS = "Alexa.Unit.Volume.Pints" + + # Quarts, Quart + UNIT_VOLUME_QUARTS = "Alexa.Unit.Volume.Quarts" + + # Ounces, Ounce, oz + UNIT_WEIGHT_OUNCES = "Alexa.Unit.Weight.Ounces" + + # Pounds, Pound, lbs + UNIT_WEIGHT_POUNDS = "Alexa.Unit.Weight.Pounds" + + # Close + VALUE_CLOSE = "Alexa.Value.Close" + + # Delicates, Delicate + VALUE_DELICATE = "Alexa.Value.Delicate" + + # High + VALUE_HIGH = "Alexa.Value.High" + + # Low + VALUE_LOW = "Alexa.Value.Low" + + # Maximum, Max + VALUE_MAXIMUM = "Alexa.Value.Maximum" + + # Medium, Mid + VALUE_MEDIUM = "Alexa.Value.Medium" + + # Minimum, Min + VALUE_MINIMUM = "Alexa.Value.Minimum" + + # Open + VALUE_OPEN = "Alexa.Value.Open" + + # Quick Wash, Fast Wash, Wash Quickly, Speed Wash + VALUE_QUICK_WASH = "Alexa.Value.QuickWash" + + +class AlexaCapabilityResource: + """Base class for Alexa capabilityResources, ModeResources, and presetResources objects. + + https://developer.amazon.com/docs/device-apis/resources-and-assets.html#capability-resources + """ + + def __init__(self, labels): + """Initialize an Alexa resource.""" + self._resource_labels = [] + for label in labels: + self._resource_labels.append(label) + + def serialize_capability_resources(self): + """Return capabilityResources object serialized for an API response.""" + return self.serialize_labels(self._resource_labels) + + @staticmethod + def serialize_configuration(): + """Return ModeResources, PresetResources friendlyNames serialized for an API response.""" + return [] + + @staticmethod + def serialize_labels(resources): + """Return resource label objects for friendlyNames serialized for an API response.""" + labels = [] + for label in resources: + if label in AlexaGlobalCatalog.__dict__.values(): + label = {"@type": "asset", "value": {"assetId": label}} + else: + label = {"@type": "text", "value": {"text": label, "locale": "en-US"}} + + labels.append(label) + + return {"friendlyNames": labels} + + +class AlexaModeResource(AlexaCapabilityResource): + """Implements Alexa ModeResources. + + https://developer.amazon.com/docs/device-apis/resources-and-assets.html#capability-resources + """ + + def __init__(self, labels, ordered=False): + """Initialize an Alexa modeResource.""" + super().__init__(labels) + self._supported_modes = [] + self._mode_ordered = ordered + + def add_mode(self, value, labels): + """Add mode to the supportedModes object.""" + self._supported_modes.append({"value": value, "labels": labels}) + + def serialize_configuration(self): + """Return configuration for ModeResources friendlyNames serialized for an API response.""" + mode_resources = [] + for mode in self._supported_modes: + result = { + "value": mode["value"], + "modeResources": self.serialize_labels(mode["labels"]), + } + mode_resources.append(result) + + return {"ordered": self._mode_ordered, "supportedModes": mode_resources} + + +class AlexaPresetResource(AlexaCapabilityResource): + """Implements Alexa PresetResources. + + Use presetResources with RangeController to provide a set of friendlyNames for each RangeController preset. + + https://developer.amazon.com/docs/device-apis/resources-and-assets.html#presetresources + """ + + def __init__(self, labels, min_value, max_value, precision, unit=None): + """Initialize an Alexa presetResource.""" + super().__init__(labels) + self._presets = [] + self._minimum_value = int(min_value) + self._maximum_value = int(max_value) + self._precision = int(precision) + self._unit_of_measure = None + if unit in AlexaGlobalCatalog.__dict__.values(): + self._unit_of_measure = unit + + def add_preset(self, value, labels): + """Add preset to configuration presets array.""" + self._presets.append({"value": value, "labels": labels}) + + def serialize_configuration(self): + """Return configuration for PresetResources friendlyNames serialized for an API response.""" + configuration = { + "supportedRange": { + "minimumValue": self._minimum_value, + "maximumValue": self._maximum_value, + "precision": self._precision, + } + } + + if self._unit_of_measure: + configuration["unitOfMeasure"] = self._unit_of_measure + + if self._presets: + preset_resources = [] + for preset in self._presets: + preset_resources.append( + { + "rangeValue": preset["value"], + "presetResources": self.serialize_labels(preset["labels"]), + } + ) + configuration["presets"] = preset_resources + + return configuration + + +class AlexaSemantics: + """Class for Alexa Semantics Object. + + You can optionally enable additional utterances by using semantics. When you use semantics, + you manually map the phrases "open", "close", "raise", and "lower" to directives. + + Semantics is supported for the following interfaces only: ModeController, RangeController, and ToggleController. + + https://developer.amazon.com/docs/device-apis/alexa-discovery.html#semantics-object + """ + + MAPPINGS_ACTION = "actionMappings" + MAPPINGS_STATE = "stateMappings" + + ACTIONS_TO_DIRECTIVE = "ActionsToDirective" + STATES_TO_VALUE = "StatesToValue" + STATES_TO_RANGE = "StatesToRange" + + ACTION_CLOSE = "Alexa.Actions.Close" + ACTION_LOWER = "Alexa.Actions.Lower" + ACTION_OPEN = "Alexa.Actions.Open" + ACTION_RAISE = "Alexa.Actions.Raise" + + STATES_OPEN = "Alexa.States.Open" + STATES_CLOSED = "Alexa.States.Closed" + + DIRECTIVE_RANGE_SET_VALUE = "SetRangeValue" + DIRECTIVE_RANGE_ADJUST_VALUE = "AdjustRangeValue" + DIRECTIVE_TOGGLE_TURN_ON = "TurnOn" + DIRECTIVE_TOGGLE_TURN_OFF = "TurnOff" + DIRECTIVE_MODE_SET_MODE = "SetMode" + DIRECTIVE_MODE_ADJUST_MODE = "AdjustMode" + + def __init__(self): + """Initialize an Alexa modeResource.""" + self._action_mappings = [] + self._state_mappings = [] + + def _add_action_mapping(self, semantics): + """Add action mapping between actions and interface directives.""" + self._action_mappings.append(semantics) + + def _add_state_mapping(self, semantics): + """Add state mapping between states and interface directives.""" + self._state_mappings.append(semantics) + + def add_states_to_value(self, states, value): + """Add StatesToValue stateMappings.""" + self._add_state_mapping( + {"@type": self.STATES_TO_VALUE, "states": states, "value": value} + ) + + def add_states_to_range(self, states, min_value, max_value): + """Add StatesToRange stateMappings.""" + self._add_state_mapping( + { + "@type": self.STATES_TO_RANGE, + "states": states, + "range": {"minimumValue": min_value, "maximumValue": max_value}, + } + ) + + def add_action_to_directive(self, actions, directive, payload): + """Add ActionsToDirective actionMappings.""" + self._add_action_mapping( + { + "@type": self.ACTIONS_TO_DIRECTIVE, + "actions": actions, + "directive": {"name": directive, "payload": payload}, + } + ) + + def serialize_semantics(self): + """Return semantics object serialized for an API response.""" + semantics = {} + if self._action_mappings: + semantics[self.MAPPINGS_ACTION] = self._action_mappings + if self._state_mappings: + semantics[self.MAPPINGS_STATE] = self._state_mappings + + return semantics diff --git a/virtualization/vagrant/config/.placeholder b/homeassistant/components/alexa/services.yaml similarity index 100% rename from virtualization/vagrant/config/.placeholder rename to homeassistant/components/alexa/services.yaml diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index eab725c46..9b0955f8f 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -1,1531 +1,68 @@ """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, - ATTR_UNIT_OF_MEASUREMENT, 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, - STATE_LOCKED, STATE_UNLOCKED, STATE_ON) -from .const import CONF_FILTER, CONF_ENTITY_CONFIG +from .const import API_DIRECTIVE, API_HEADER +from .errors import AlexaBridgeUnreachableError, AlexaError +from .handlers import HANDLERS +from .messages import AlexaDirective _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() -EVENT_ALEXA_SMART_HOME = 'alexa_smart_home' +EVENT_ALEXA_SMART_HOME = "alexa_smart_home" -class _DisplayCategory: - """Possible display categories for Discovery response. +async def async_handle_message(hass, config, request, context=None, enabled=True): + """Handle incoming API messages. - https://developer.amazon.com/docs/device-apis/alexa-discovery.html#display-categories + If enabled is False, the response to all messagess will be a + BRIDGE_UNREACHABLE error. This can be used if the API has been disabled in + configuration. """ - - # 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. - 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: - """An adaptation of an entity, expressed in Alexa's terms. - - The API handlers should manipulate entities only through this interface. - """ - - def __init__(self, hass, config, entity): - self.hass = hass - 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: - 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'] - # pylint: disable=assignment-from-no-return - prop_value = self.get_property(prop_name) - if prop_value is not None: - yield { - 'name': prop_name, - 'namespace': self.name(), - 'value': prop_value, - } - - -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' - if 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 __init__(self, hass, entity): - _AlexaInterface.__init__(self, entity) - self.hass = hass - - 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.get(ATTR_UNIT_OF_MEASUREMENT) - temp = self.entity.state - if self.entity.domain == climate.DOMAIN: - unit = self.hass.config.units.temperature_unit - temp = self.entity.attributes.get( - climate.ATTR_CURRENT_TEMPERATURE) - return { - 'value': float(temp), - 'scale': API_TEMP_UNITS[unit], - } - - -class _AlexaThermostatController(_AlexaInterface): - def __init__(self, hass, entity): - _AlexaInterface.__init__(self, entity) - self.hass = hass - - 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.hass.config.units.temperature_unit - if name == 'targetSetpoint': - temp = self.entity.attributes.get(climate.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) - else: - raise _UnsupportedProperty(name) - - if temp is None: - return None - - 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.hass, self.entity) - yield _AlexaTemperatureSensor(self.hass, 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(ATTR_UNIT_OF_MEASUREMENT) in ( - TEMP_FAHRENHEIT, - TEMP_CELSIUS, - ): - yield _AlexaTemperatureSensor(self.hass, self.entity) - - -class _Cause: - """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) - - -async def async_handle_message(hass, config, request, context=None): - """Handle incoming API messages.""" - assert request[API_DIRECTIVE][API_HEADER]['payloadVersion'] == '3' + assert request[API_DIRECTIVE][API_HEADER]["payloadVersion"] == "3" if context is None: context = ha.Context() - # Read head data - request = request[API_DIRECTIVE] - namespace = request[API_HEADER]['namespace'] - name = request[API_HEADER]['name'] + directive = AlexaDirective(request) - # Do we support this API request? - funct_ref = HANDLERS.get((namespace, name)) - if funct_ref: - response = await funct_ref(hass, config, request, context) - else: - _LOGGER.warning( - "Unsupported API request %s/%s", namespace, name) - response = api_error(request) - - request_info = { - 'namespace': namespace, - 'name': name, - } - - if API_ENDPOINT in request and 'endpointId' in request[API_ENDPOINT]: - request_info['entity_id'] = \ - request[API_ENDPOINT]['endpointId'].replace('#', '.') - - response_header = response[API_EVENT][API_HEADER] - - hass.bus.async_fire(EVENT_ALEXA_SMART_HOME, { - 'request': request_info, - 'response': { - 'namespace': response_header['namespace'], - 'name': response_header['name'], - } - }, context=context) - - return response - - -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')) -async def async_api_discovery(hass, config, request, context): - """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](hass, 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.""" - async def async_api_entity_wrapper(hass, config, request, context): - """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 await funct(hass, config, request, context, entity) - - return async_api_entity_wrapper - - -@HANDLERS.register(('Alexa.PowerController', 'TurnOn')) -@extract_entity -async def async_api_turn_on(hass, config, request, context, 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 - - await hass.services.async_call(domain, service, { - ATTR_ENTITY_ID: entity.entity_id - }, blocking=False, context=context) - - return api_message(request) - - -@HANDLERS.register(('Alexa.PowerController', 'TurnOff')) -@extract_entity -async def async_api_turn_off(hass, config, request, context, 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 - - await hass.services.async_call(domain, service, { - ATTR_ENTITY_ID: entity.entity_id - }, blocking=False, context=context) - - return api_message(request) - - -@HANDLERS.register(('Alexa.BrightnessController', 'SetBrightness')) -@extract_entity -async def async_api_set_brightness(hass, config, request, context, entity): - """Process a set brightness request.""" - brightness = int(request[API_PAYLOAD]['brightness']) - - await hass.services.async_call(entity.domain, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: entity.entity_id, - light.ATTR_BRIGHTNESS_PCT: brightness, - }, blocking=False, context=context) - - return api_message(request) - - -@HANDLERS.register(('Alexa.BrightnessController', 'AdjustBrightness')) -@extract_entity -async def async_api_adjust_brightness(hass, config, request, context, 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) - await hass.services.async_call(entity.domain, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: entity.entity_id, - light.ATTR_BRIGHTNESS_PCT: brightness, - }, blocking=False, context=context) - - return api_message(request) - - -@HANDLERS.register(('Alexa.ColorController', 'SetColor')) -@extract_entity -async def async_api_set_color(hass, config, request, context, 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']) - ) - - await hass.services.async_call(entity.domain, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: entity.entity_id, - light.ATTR_RGB_COLOR: rgb, - }, blocking=False, context=context) - - return api_message(request) - - -@HANDLERS.register(('Alexa.ColorTemperatureController', 'SetColorTemperature')) -@extract_entity -async def async_api_set_color_temperature(hass, config, request, context, - entity): - """Process a set color temperature request.""" - kelvin = int(request[API_PAYLOAD]['colorTemperatureInKelvin']) - - await hass.services.async_call(entity.domain, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: entity.entity_id, - light.ATTR_KELVIN: kelvin, - }, blocking=False, context=context) - - return api_message(request) - - -@HANDLERS.register( - ('Alexa.ColorTemperatureController', 'DecreaseColorTemperature')) -@extract_entity -async def async_api_decrease_color_temp(hass, config, request, context, - 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) - await hass.services.async_call(entity.domain, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: entity.entity_id, - light.ATTR_COLOR_TEMP: value, - }, blocking=False, context=context) - - return api_message(request) - - -@HANDLERS.register( - ('Alexa.ColorTemperatureController', 'IncreaseColorTemperature')) -@extract_entity -async def async_api_increase_color_temp(hass, config, request, context, - 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) - await hass.services.async_call(entity.domain, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: entity.entity_id, - light.ATTR_COLOR_TEMP: value, - }, blocking=False, context=context) - - return api_message(request) - - -@HANDLERS.register(('Alexa.SceneController', 'Activate')) -@extract_entity -async def async_api_activate(hass, config, request, context, entity): - """Process an activate request.""" - domain = entity.domain - - await hass.services.async_call(domain, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: entity.entity_id - }, blocking=False, context=context) - - 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 -async def async_api_deactivate(hass, config, request, context, entity): - """Process a deactivate request.""" - domain = entity.domain - - await hass.services.async_call(domain, SERVICE_TURN_OFF, { - ATTR_ENTITY_ID: entity.entity_id - }, blocking=False, context=context) - - 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 -async def async_api_set_percentage(hass, config, request, context, 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 - - await hass.services.async_call( - entity.domain, service, data, blocking=False, context=context) - - return api_message(request) - - -@HANDLERS.register(('Alexa.PercentageController', 'AdjustPercentage')) -@extract_entity -async def async_api_adjust_percentage(hass, config, request, context, 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) - - await hass.services.async_call( - entity.domain, service, data, blocking=False, context=context) - - return api_message(request) - - -@HANDLERS.register(('Alexa.LockController', 'Lock')) -@extract_entity -async def async_api_lock(hass, config, request, context, entity): - """Process a lock request.""" - await hass.services.async_call(entity.domain, SERVICE_LOCK, { - ATTR_ENTITY_ID: entity.entity_id - }, blocking=False, context=context) - - # 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 -async def async_api_unlock(hass, config, request, context, entity): - """Process an unlock request.""" - await hass.services.async_call(entity.domain, SERVICE_UNLOCK, { - ATTR_ENTITY_ID: entity.entity_id - }, blocking=False, context=context) - - return api_message(request) - - -@HANDLERS.register(('Alexa.Speaker', 'SetVolume')) -@extract_entity -async def async_api_set_volume(hass, config, request, context, 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, - } - - await hass.services.async_call( - entity.domain, SERVICE_VOLUME_SET, - data, blocking=False, context=context) - - return api_message(request) - - -@HANDLERS.register(('Alexa.InputController', 'SelectInput')) -@extract_entity -async def async_api_select_input(hass, config, request, context, 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, - } - - await hass.services.async_call( - entity.domain, media_player.SERVICE_SELECT_SOURCE, - data, blocking=False, context=context) - - return api_message(request) - - -@HANDLERS.register(('Alexa.Speaker', 'AdjustVolume')) -@extract_entity -async def async_api_adjust_volume(hass, config, request, context, 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, - } - - await hass.services.async_call( - entity.domain, media_player.SERVICE_VOLUME_SET, - data, blocking=False, context=context) - - return api_message(request) - - -@HANDLERS.register(('Alexa.StepSpeaker', 'AdjustVolume')) -@extract_entity -async def async_api_adjust_volume_step(hass, config, request, context, 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: - await hass.services.async_call( - entity.domain, media_player.SERVICE_VOLUME_UP, - data, blocking=False, context=context) - elif volume_step < 0: - await hass.services.async_call( - entity.domain, media_player.SERVICE_VOLUME_DOWN, - data, blocking=False, context=context) - - return api_message(request) - - -@HANDLERS.register(('Alexa.StepSpeaker', 'SetMute')) -@HANDLERS.register(('Alexa.Speaker', 'SetMute')) -@extract_entity -async def async_api_set_mute(hass, config, request, context, 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, - } - - await hass.services.async_call( - entity.domain, media_player.SERVICE_VOLUME_MUTE, - data, blocking=False, context=context) - - return api_message(request) - - -@HANDLERS.register(('Alexa.PlaybackController', 'Play')) -@extract_entity -async def async_api_play(hass, config, request, context, entity): - """Process a play request.""" - data = { - ATTR_ENTITY_ID: entity.entity_id - } - - await hass.services.async_call( - entity.domain, SERVICE_MEDIA_PLAY, - data, blocking=False, context=context) - - return api_message(request) - - -@HANDLERS.register(('Alexa.PlaybackController', 'Pause')) -@extract_entity -async def async_api_pause(hass, config, request, context, entity): - """Process a pause request.""" - data = { - ATTR_ENTITY_ID: entity.entity_id - } - - await hass.services.async_call( - entity.domain, SERVICE_MEDIA_PAUSE, - data, blocking=False, context=context) - - return api_message(request) - - -@HANDLERS.register(('Alexa.PlaybackController', 'Stop')) -@extract_entity -async def async_api_stop(hass, config, request, context, entity): - """Process a stop request.""" - data = { - ATTR_ENTITY_ID: entity.entity_id - } - - await hass.services.async_call( - entity.domain, SERVICE_MEDIA_STOP, - data, blocking=False, context=context) - - return api_message(request) - - -@HANDLERS.register(('Alexa.PlaybackController', 'Next')) -@extract_entity -async def async_api_next(hass, config, request, context, entity): - """Process a next request.""" - data = { - ATTR_ENTITY_ID: entity.entity_id - } - - await hass.services.async_call( - entity.domain, SERVICE_MEDIA_NEXT_TRACK, - data, blocking=False, context=context) - - return api_message(request) - - -@HANDLERS.register(('Alexa.PlaybackController', 'Previous')) -@extract_entity -async def async_api_previous(hass, config, request, context, entity): - """Process a previous request.""" - data = { - ATTR_ENTITY_ID: entity.entity_id - } - - await hass.services.async_call( - entity.domain, SERVICE_MEDIA_PREVIOUS_TRACK, - data, blocking=False, context=context) - - return api_message(request) - - -def api_error_temp_range(hass, request, temp, min_temp, max_temp): - """Create temperature value out of range API error response. - - Async friendly. - """ - unit = hass.config.units.temperature_unit - 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(hass, temp_obj, interval=False): - """Get temperature from Temperature object in requested unit.""" - to_unit = hass.config.units.temperature_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, context, entity): - """Process a set target temperature request.""" - 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(hass, payload['targetSetpoint']) - if temp < min_temp or temp > max_temp: - return api_error_temp_range( - hass, request, temp, min_temp, max_temp) - data[ATTR_TEMPERATURE] = temp - if 'lowerSetpoint' in payload: - temp_low = temperature_from_object(hass, payload['lowerSetpoint']) - if temp_low < min_temp or temp_low > max_temp: - return api_error_temp_range( - hass, request, temp_low, min_temp, max_temp) - data[climate.ATTR_TARGET_TEMP_LOW] = temp_low - if 'upperSetpoint' in payload: - temp_high = temperature_from_object(hass, payload['upperSetpoint']) - if temp_high < min_temp or temp_high > max_temp: - return api_error_temp_range( - hass, request, temp_high, min_temp, max_temp) - data[climate.ATTR_TARGET_TEMP_HIGH] = temp_high - - await hass.services.async_call( - entity.domain, climate.SERVICE_SET_TEMPERATURE, data, blocking=False, - context=context) - - return api_message(request) - - -@HANDLERS.register(('Alexa.ThermostatController', 'AdjustTargetTemperature')) -@extract_entity -async def async_api_adjust_target_temp(hass, config, request, context, entity): - """Process an adjust target temperature request.""" - min_temp = entity.attributes.get(climate.ATTR_MIN_TEMP) - max_temp = entity.attributes.get(climate.ATTR_MAX_TEMP) - - temp_delta = temperature_from_object( - hass, request[API_PAYLOAD]['targetSetpointDelta'], 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( - hass, request, target_temp, min_temp, max_temp) - - 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, - context=context) - - return api_message(request) - - -@HANDLERS.register(('Alexa.ThermostatController', 'SetThermostatMode')) -@extract_entity -async def async_api_set_thermostat_mode(hass, config, request, context, - 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) - 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 + if not enabled: + raise AlexaBridgeUnreachableError( + "Alexa API not enabled in Home Assistant configuration" + ) + + if directive.has_endpoint: + directive.load_entity(hass, config) + + funct_ref = HANDLERS.get((directive.namespace, directive.name)) + if funct_ref: + response = await funct_ref(hass, config, directive, context) + if directive.has_endpoint: + response.merge_context_properties(directive.endpoint) + else: + _LOGGER.warning( + "Unsupported API request %s/%s", directive.namespace, directive.name + ) + response = directive.error() + except AlexaError as err: + response = directive.error( + error_type=err.error_type, error_message=err.error_message ) - data = { - ATTR_ENTITY_ID: entity.entity_id, - climate.ATTR_OPERATION_MODE: ha_mode, - } + request_info = {"namespace": directive.namespace, "name": directive.name} - await hass.services.async_call( - entity.domain, climate.SERVICE_SET_OPERATION_MODE, data, - blocking=False, context=context) + if directive.has_endpoint: + request_info["entity_id"] = directive.entity_id - return api_message(request) - - -@HANDLERS.register(('Alexa', 'ReportState')) -@extract_entity -async def async_api_reportstate(hass, config, request, context, entity): - """Process a ReportState request.""" - alexa_entity = ENTITY_ADAPTERS[entity.domain](hass, config, entity) - properties = [] - for interface in alexa_entity.interfaces(): - properties.extend(interface.serialize_properties()) - - return api_message( - request, - name='StateReport', - context={'properties': properties} + hass.bus.async_fire( + EVENT_ALEXA_SMART_HOME, + { + "request": request_info, + "response": {"namespace": response.namespace, "name": response.name}, + }, + context=context, ) + + return response.serialize() diff --git a/homeassistant/components/alexa/smart_home_http.py b/homeassistant/components/alexa/smart_home_http.py new file mode 100644 index 000000000..08d33ffa0 --- /dev/null +++ b/homeassistant/components/alexa/smart_home_http.py @@ -0,0 +1,117 @@ +"""Alexa HTTP interface.""" +import logging + +from homeassistant import core +from homeassistant.components.http.view import HomeAssistantView + +from .auth import Auth +from .config import AbstractConfig +from .const import ( + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + CONF_ENDPOINT, + CONF_ENTITY_CONFIG, + CONF_FILTER, +) +from .smart_home import async_handle_message +from .state_report import async_enable_proactive_mode + +_LOGGER = logging.getLogger(__name__) +SMART_HOME_HTTP_ENDPOINT = "/api/alexa/smart_home" + + +class AlexaConfig(AbstractConfig): + """Alexa config.""" + + def __init__(self, hass, config): + """Initialize Alexa config.""" + super().__init__(hass) + self._config = config + + if config.get(CONF_CLIENT_ID) and config.get(CONF_CLIENT_SECRET): + self._auth = Auth(hass, config[CONF_CLIENT_ID], config[CONF_CLIENT_SECRET]) + else: + self._auth = None + + @property + def supports_auth(self): + """Return if config supports auth.""" + return self._auth is not None + + @property + def should_report_state(self): + """Return if we should proactively report states.""" + return self._auth is not None + + @property + def endpoint(self): + """Endpoint for report state.""" + return self._config.get(CONF_ENDPOINT) + + @property + def entity_config(self): + """Return entity config.""" + return self._config.get(CONF_ENTITY_CONFIG) or {} + + def should_expose(self, entity_id): + """If an entity should be exposed.""" + return self._config[CONF_FILTER](entity_id) + + @core.callback + def async_invalidate_access_token(self): + """Invalidate access token.""" + self._auth.async_invalidate_access_token() + + async def async_get_access_token(self): + """Get an access token.""" + return await self._auth.async_get_access_token() + + async def async_accept_grant(self, code): + """Accept a grant.""" + return await self._auth.async_do_auth(code) + + +async 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 = AlexaConfig(hass, config) + hass.http.register_view(SmartHomeView(smart_home_config)) + + if smart_home_config.should_report_state: + await async_enable_proactive_mode(hass, smart_home_config) + + +class SmartHomeView(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 + + async 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"] + user = request["hass_user"] + message = await request.json() + + _LOGGER.debug("Received Alexa Smart Home request: %s", message) + + response = await async_handle_message( + hass, self.smart_home_config, message, context=core.Context(user_id=user.id) + ) + _LOGGER.debug("Sending Alexa Smart Home response: %s", response) + return b"" if response is None else self.json(response) diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py new file mode 100644 index 000000000..44e1b7f4f --- /dev/null +++ b/homeassistant/components/alexa/state_report.py @@ -0,0 +1,250 @@ +"""Alexa state report code.""" +import asyncio +import json +import logging + +import aiohttp +import async_timeout + +from homeassistant.const import MATCH_ALL, STATE_ON +import homeassistant.util.dt as dt_util + +from .const import API_CHANGE, Cause +from .entities import ENTITY_ADAPTERS +from .messages import AlexaResponse + +_LOGGER = logging.getLogger(__name__) +DEFAULT_TIMEOUT = 10 + + +async def async_enable_proactive_mode(hass, smart_home_config): + """Enable the proactive mode. + + Proactive mode makes this component report state changes to Alexa. + """ + # Validate we can get access token. + await smart_home_config.async_get_access_token() + + async def async_entity_state_listener(changed_entity, old_state, new_state): + if not new_state: + return + + if new_state.domain not in ENTITY_ADAPTERS: + return + + if not smart_home_config.should_expose(changed_entity): + _LOGGER.debug("Not exposing %s because filtered by config", changed_entity) + return + + alexa_changed_entity = ENTITY_ADAPTERS[new_state.domain]( + hass, smart_home_config, new_state + ) + + for interface in alexa_changed_entity.interfaces(): + if interface.properties_proactively_reported(): + await async_send_changereport_message( + hass, smart_home_config, alexa_changed_entity + ) + return + if ( + interface.name() == "Alexa.DoorbellEventSource" + and new_state.state == STATE_ON + ): + await async_send_doorbell_event_message( + hass, smart_home_config, alexa_changed_entity + ) + return + + return hass.helpers.event.async_track_state_change( + MATCH_ALL, async_entity_state_listener + ) + + +async def async_send_changereport_message( + hass, config, alexa_entity, *, invalidate_access_token=True +): + """Send a ChangeReport message for an Alexa entity. + + https://developer.amazon.com/docs/smarthome/state-reporting-for-a-smart-home-skill.html#report-state-with-changereport-events + """ + token = await config.async_get_access_token() + + headers = {"Authorization": f"Bearer {token}"} + + endpoint = alexa_entity.alexa_id() + + # this sends all the properties of the Alexa Entity, whether they have + # changed or not. this should be improved, and properties that have not + # changed should be moved to the 'context' object + properties = list(alexa_entity.serialize_properties()) + + payload = { + API_CHANGE: {"cause": {"type": Cause.APP_INTERACTION}, "properties": properties} + } + + message = AlexaResponse(name="ChangeReport", namespace="Alexa", payload=payload) + message.set_endpoint_full(token, endpoint) + + message_serialized = message.serialize() + session = hass.helpers.aiohttp_client.async_get_clientsession() + + try: + with async_timeout.timeout(DEFAULT_TIMEOUT): + response = await session.post( + config.endpoint, + headers=headers, + json=message_serialized, + allow_redirects=True, + ) + + except (asyncio.TimeoutError, aiohttp.ClientError): + _LOGGER.error("Timeout sending report to Alexa.") + return + + response_text = await response.text() + + _LOGGER.debug("Sent: %s", json.dumps(message_serialized)) + _LOGGER.debug("Received (%s): %s", response.status, response_text) + + if response.status == 202: + return + + response_json = json.loads(response_text) + + if ( + response_json["payload"]["code"] == "INVALID_ACCESS_TOKEN_EXCEPTION" + and not invalidate_access_token + ): + config.async_invalidate_access_token() + return await async_send_changereport_message( + hass, config, alexa_entity, invalidate_access_token=False + ) + + _LOGGER.error( + "Error when sending ChangeReport to Alexa: %s: %s", + response_json["payload"]["code"], + response_json["payload"]["description"], + ) + + +async def async_send_add_or_update_message(hass, config, entity_ids): + """Send an AddOrUpdateReport message for entities. + + https://developer.amazon.com/docs/device-apis/alexa-discovery.html#add-or-update-report + """ + token = await config.async_get_access_token() + + headers = {"Authorization": f"Bearer {token}"} + + endpoints = [] + + for entity_id in entity_ids: + domain = entity_id.split(".", 1)[0] + + if domain not in ENTITY_ADAPTERS: + continue + + alexa_entity = ENTITY_ADAPTERS[domain](hass, config, hass.states.get(entity_id)) + endpoints.append(alexa_entity.serialize_discovery()) + + payload = {"endpoints": endpoints, "scope": {"type": "BearerToken", "token": token}} + + message = AlexaResponse( + name="AddOrUpdateReport", namespace="Alexa.Discovery", payload=payload + ) + + message_serialized = message.serialize() + session = hass.helpers.aiohttp_client.async_get_clientsession() + + return await session.post( + config.endpoint, headers=headers, json=message_serialized, allow_redirects=True + ) + + +async def async_send_delete_message(hass, config, entity_ids): + """Send an DeleteReport message for entities. + + https://developer.amazon.com/docs/device-apis/alexa-discovery.html#deletereport-event + """ + token = await config.async_get_access_token() + + headers = {"Authorization": f"Bearer {token}"} + + endpoints = [] + + for entity_id in entity_ids: + domain = entity_id.split(".", 1)[0] + + if domain not in ENTITY_ADAPTERS: + continue + + alexa_entity = ENTITY_ADAPTERS[domain](hass, config, hass.states.get(entity_id)) + endpoints.append({"endpointId": alexa_entity.alexa_id()}) + + payload = {"endpoints": endpoints, "scope": {"type": "BearerToken", "token": token}} + + message = AlexaResponse( + name="DeleteReport", namespace="Alexa.Discovery", payload=payload + ) + + message_serialized = message.serialize() + session = hass.helpers.aiohttp_client.async_get_clientsession() + + return await session.post( + config.endpoint, headers=headers, json=message_serialized, allow_redirects=True + ) + + +async def async_send_doorbell_event_message(hass, config, alexa_entity): + """Send a DoorbellPress event message for an Alexa entity. + + https://developer.amazon.com/docs/smarthome/send-events-to-the-alexa-event-gateway.html + """ + token = await config.async_get_access_token() + + headers = {"Authorization": f"Bearer {token}"} + + endpoint = alexa_entity.alexa_id() + + message = AlexaResponse( + name="DoorbellPress", + namespace="Alexa.DoorbellEventSource", + payload={ + "cause": {"type": Cause.PHYSICAL_INTERACTION}, + "timestamp": f"{dt_util.utcnow().replace(tzinfo=None).isoformat()}Z", + }, + ) + + message.set_endpoint_full(token, endpoint) + + message_serialized = message.serialize() + session = hass.helpers.aiohttp_client.async_get_clientsession() + + try: + with async_timeout.timeout(DEFAULT_TIMEOUT): + response = await session.post( + config.endpoint, + headers=headers, + json=message_serialized, + allow_redirects=True, + ) + + except (asyncio.TimeoutError, aiohttp.ClientError): + _LOGGER.error("Timeout sending report to Alexa.") + return + + response_text = await response.text() + + _LOGGER.debug("Sent: %s", json.dumps(message_serialized)) + _LOGGER.debug("Received (%s): %s", response.status, response_text) + + if response.status == 202: + return + + response_json = json.loads(response_text) + + _LOGGER.error( + "Error when sending DoorbellPress event to Alexa: %s: %s", + response_json["payload"]["code"], + response_json["payload"]["description"], + ) diff --git a/homeassistant/components/almond/.translations/bg.json b/homeassistant/components/almond/.translations/bg.json new file mode 100644 index 000000000..3327e34e7 --- /dev/null +++ b/homeassistant/components/almond/.translations/bg.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_setup": "\u041c\u043e\u0436\u0435\u0442\u0435 \u0434\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u0442\u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u0438\u043d Almond \u0430\u043a\u0430\u0443\u043d\u0442.", + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u0441 Almond \u0441\u044a\u0440\u0432\u044a\u0440\u0430.", + "missing_configuration": "\u041c\u043e\u043b\u044f, \u043f\u0440\u043e\u0432\u0435\u0440\u0435\u0442\u0435 \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u0442\u0430 \u043a\u0430\u043a \u0434\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u0435 Almond." + }, + "step": { + "pick_implementation": { + "title": "\u0418\u0437\u0431\u043e\u0440 \u043d\u0430 \u043c\u0435\u0442\u043e\u0434 \u0437\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u043a\u0430\u0446\u0438\u044f" + } + }, + "title": "Almond" + } +} \ No newline at end of file diff --git a/homeassistant/components/almond/.translations/ca.json b/homeassistant/components/almond/.translations/ca.json new file mode 100644 index 000000000..c626e2795 --- /dev/null +++ b/homeassistant/components/almond/.translations/ca.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_setup": "Nom\u00e9s pots configurar un \u00fanic compte amb Almond.", + "cannot_connect": "No es pot connectar amb el servidor d'Almond.", + "missing_configuration": "Consulta la documentaci\u00f3 sobre com configurar Almond." + }, + "step": { + "pick_implementation": { + "title": "Selecci\u00f3 del m\u00e8tode d'autenticaci\u00f3" + } + }, + "title": "Almond" + } +} \ No newline at end of file diff --git a/homeassistant/components/almond/.translations/de.json b/homeassistant/components/almond/.translations/de.json new file mode 100644 index 000000000..1495cabf9 --- /dev/null +++ b/homeassistant/components/almond/.translations/de.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_setup": "Sie k\u00f6nnen nur ein Almond-Konto konfigurieren.", + "cannot_connect": "Verbindung zum Almond-Server nicht m\u00f6glich.", + "missing_configuration": "Bitte \u00fcberpr\u00fcfen Sie die Dokumentation zur Einrichtung von Almond." + }, + "step": { + "pick_implementation": { + "title": "W\u00e4hle die Authentifizierungsmethode" + } + }, + "title": "Almond" + } +} \ No newline at end of file diff --git a/homeassistant/components/almond/.translations/en.json b/homeassistant/components/almond/.translations/en.json new file mode 100644 index 000000000..3b7b5b9aa --- /dev/null +++ b/homeassistant/components/almond/.translations/en.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_setup": "You can only configure one Almond account.", + "cannot_connect": "Unable to connect to the Almond server.", + "missing_configuration": "Please check the documentation on how to set up Almond." + }, + "step": { + "pick_implementation": { + "title": "Pick Authentication Method" + } + }, + "title": "Almond" + } +} \ No newline at end of file diff --git a/homeassistant/components/almond/.translations/es.json b/homeassistant/components/almond/.translations/es.json new file mode 100644 index 000000000..26eacb834 --- /dev/null +++ b/homeassistant/components/almond/.translations/es.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_setup": "S\u00f3lo puede configurar una cuenta de Almond.", + "cannot_connect": "No se puede conectar al servidor Almond.", + "missing_configuration": "Consulte la documentaci\u00f3n sobre c\u00f3mo configurar Almond." + }, + "step": { + "pick_implementation": { + "title": "Seleccione el m\u00e9todo de autenticaci\u00f3n" + } + }, + "title": "Almond" + } +} \ No newline at end of file diff --git a/homeassistant/components/almond/.translations/fr.json b/homeassistant/components/almond/.translations/fr.json new file mode 100644 index 000000000..9ae881d33 --- /dev/null +++ b/homeassistant/components/almond/.translations/fr.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_setup": "Vous ne pouvez configurer qu'un seul compte Almond", + "cannot_connect": "Impossible de se connecter au serveur Almond", + "missing_configuration": "Veuillez consulter la documentation pour savoir comment configurer Almond." + }, + "step": { + "pick_implementation": { + "title": "S\u00e9lectionner une m\u00e9thode d'authentification" + } + }, + "title": "Almond" + } +} \ No newline at end of file diff --git a/homeassistant/components/almond/.translations/it.json b/homeassistant/components/almond/.translations/it.json new file mode 100644 index 000000000..9d529e5e5 --- /dev/null +++ b/homeassistant/components/almond/.translations/it.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_setup": "\u00c8 possibile configurare un solo account Almond.", + "cannot_connect": "Impossibile connettersi al server Almond.", + "missing_configuration": "Si prega di controllare la documentazione su come impostare Almond." + }, + "step": { + "pick_implementation": { + "title": "Seleziona metodo di autenticazione" + } + }, + "title": "Almond" + } +} \ No newline at end of file diff --git a/homeassistant/components/almond/.translations/ko.json b/homeassistant/components/almond/.translations/ko.json new file mode 100644 index 000000000..2a2346aaf --- /dev/null +++ b/homeassistant/components/almond/.translations/ko.json @@ -0,0 +1,10 @@ +{ + "config": { + "abort": { + "already_setup": "\ud558\ub098\uc758 Almond \uacc4\uc815\ub9cc \uad6c\uc131 \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.", + "cannot_connect": "Almond \uc11c\ubc84\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.", + "missing_configuration": "Almond \uc124\uc815 \ubc29\ubc95\uc5d0 \ub300\ud55c \uc124\uba85\uc11c\ub97c \ud655\uc778\ud574\uc8fc\uc138\uc694." + }, + "title": "Almond" + } +} \ No newline at end of file diff --git a/homeassistant/components/almond/.translations/lb.json b/homeassistant/components/almond/.translations/lb.json new file mode 100644 index 000000000..ca836267d --- /dev/null +++ b/homeassistant/components/almond/.translations/lb.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_setup": "Dir k\u00ebnnt n\u00ebmmen een eenzegen Almond Kont konfigur\u00e9ieren.", + "cannot_connect": "Kann sech net mam Almond Server verbannen.", + "missing_configuration": "Kuckt w.e.g. Dokumentatioun iwwert d'ariichten vun Almond." + }, + "step": { + "pick_implementation": { + "title": "Wielt Authentifikatiouns Method aus" + } + }, + "title": "Almond" + } +} \ No newline at end of file diff --git a/homeassistant/components/almond/.translations/nl.json b/homeassistant/components/almond/.translations/nl.json new file mode 100644 index 000000000..d77fe69f7 --- /dev/null +++ b/homeassistant/components/almond/.translations/nl.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_setup": "U kunt slechts \u00e9\u00e9n Almond-account configureren.", + "cannot_connect": "Kan geen verbinding maken met de Almond-server.", + "missing_configuration": "Raadpleeg de documentatie over het instellen van Almond." + }, + "step": { + "pick_implementation": { + "title": "Kies de authenticatie methode" + } + }, + "title": "Almond" + } +} \ No newline at end of file diff --git a/homeassistant/components/almond/.translations/nn.json b/homeassistant/components/almond/.translations/nn.json new file mode 100644 index 000000000..a25f5dc15 --- /dev/null +++ b/homeassistant/components/almond/.translations/nn.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Almond" + } +} \ No newline at end of file diff --git a/homeassistant/components/almond/.translations/no.json b/homeassistant/components/almond/.translations/no.json new file mode 100644 index 000000000..0272a120f --- /dev/null +++ b/homeassistant/components/almond/.translations/no.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_setup": "Du kan bare konfigurere en Almond konto.", + "cannot_connect": "Kan ikke koble til Almond-serveren.", + "missing_configuration": "Vennligst sjekk dokumentasjonen om hvordan du setter opp Almond." + }, + "step": { + "pick_implementation": { + "title": "Velg autentiseringsmetode" + } + }, + "title": "Almond" + } +} \ No newline at end of file diff --git a/homeassistant/components/almond/.translations/pl.json b/homeassistant/components/almond/.translations/pl.json new file mode 100644 index 000000000..56aa629e0 --- /dev/null +++ b/homeassistant/components/almond/.translations/pl.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_setup": "Mo\u017cesz skonfigurowa\u0107 tylko jedno konto Almond.", + "cannot_connect": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z serwerem Almond.", + "missing_configuration": "Prosz\u0119 zapozna\u0107 si\u0119 z dokumentacj\u0105 konfiguracji Almond." + }, + "step": { + "pick_implementation": { + "title": "Wybierz metod\u0119 uwierzytelniania" + } + }, + "title": "Almond" + } +} \ No newline at end of file diff --git a/homeassistant/components/almond/.translations/pt-BR.json b/homeassistant/components/almond/.translations/pt-BR.json new file mode 100644 index 000000000..94dfbefb8 --- /dev/null +++ b/homeassistant/components/almond/.translations/pt-BR.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "pick_implementation": { + "title": "Escolha o m\u00e9todo de autentica\u00e7\u00e3o" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/almond/.translations/pt.json b/homeassistant/components/almond/.translations/pt.json new file mode 100644 index 000000000..720400e72 --- /dev/null +++ b/homeassistant/components/almond/.translations/pt.json @@ -0,0 +1,10 @@ +{ + "config": { + "step": { + "pick_implementation": { + "title": "Escolha o m\u00e9todo de autentica\u00e7\u00e3o" + } + }, + "title": "" + } +} \ No newline at end of file diff --git a/homeassistant/components/almond/.translations/ru.json b/homeassistant/components/almond/.translations/ru.json new file mode 100644 index 000000000..39dc41a39 --- /dev/null +++ b/homeassistant/components/almond/.translations/ru.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_setup": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.", + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0441\u0435\u0440\u0432\u0435\u0440\u0443 Almond.", + "missing_configuration": "\u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 \u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438 \u043f\u043e \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0435 Almond." + }, + "step": { + "pick_implementation": { + "title": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043c\u0435\u0442\u043e\u0434 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438" + } + }, + "title": "Almond" + } +} \ No newline at end of file diff --git a/homeassistant/components/almond/.translations/sl.json b/homeassistant/components/almond/.translations/sl.json new file mode 100644 index 000000000..086190590 --- /dev/null +++ b/homeassistant/components/almond/.translations/sl.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_setup": "Konfigurirate lahko samo en ra\u010dun Almond.", + "cannot_connect": "Ni mogo\u010de vzpostaviti povezave s stre\u017enikom Almond.", + "missing_configuration": "Prosimo, preverite dokumentacijo o tem, kako nastaviti Almond." + }, + "step": { + "pick_implementation": { + "title": "Izberite na\u010din preverjanja pristnosti" + } + }, + "title": "Almond" + } +} \ No newline at end of file diff --git a/homeassistant/components/almond/.translations/zh-Hant.json b/homeassistant/components/almond/.translations/zh-Hant.json new file mode 100644 index 000000000..4db6e0c93 --- /dev/null +++ b/homeassistant/components/almond/.translations/zh-Hant.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_setup": "\u50c5\u80fd\u8a2d\u5b9a\u4e00\u7d44 Almond \u5e33\u865f\u3002", + "cannot_connect": "\u7121\u6cd5\u9023\u7dda\u81f3 Almond \u4f3a\u670d\u5668\u3002", + "missing_configuration": "\u8acb\u53c3\u8003\u76f8\u95dc\u6587\u4ef6\u4ee5\u4e86\u89e3\u5982\u4f55\u8a2d\u5b9a Almond\u3002" + }, + "step": { + "pick_implementation": { + "title": "\u9078\u64c7\u9a57\u8b49\u6a21\u5f0f" + } + }, + "title": "Almond" + } +} \ No newline at end of file diff --git a/homeassistant/components/almond/__init__.py b/homeassistant/components/almond/__init__.py new file mode 100644 index 000000000..8877107b9 --- /dev/null +++ b/homeassistant/components/almond/__init__.py @@ -0,0 +1,308 @@ +"""Support for Almond.""" +import asyncio +from datetime import timedelta +import logging +import time +from typing import Optional + +from aiohttp import ClientError, ClientSession +import async_timeout +from pyalmond import AbstractAlmondWebAuth, AlmondLocalAuth, WebAlmondAPI +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.auth.const import GROUP_ID_ADMIN +from homeassistant.components import conversation +from homeassistant.const import CONF_HOST, CONF_TYPE, EVENT_HOMEASSISTANT_START +from homeassistant.core import Context, CoreState, HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import ( + aiohttp_client, + config_entry_oauth2_flow, + config_validation as cv, + event, + intent, + network, + storage, +) + +from . import config_flow +from .const import DOMAIN, TYPE_LOCAL, TYPE_OAUTH2 + +CONF_CLIENT_ID = "client_id" +CONF_CLIENT_SECRET = "client_secret" + +STORAGE_VERSION = 1 +STORAGE_KEY = DOMAIN + +ALMOND_SETUP_DELAY = 30 + +DEFAULT_OAUTH2_HOST = "https://almond.stanford.edu" +DEFAULT_LOCAL_HOST = "http://localhost:3000" + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Any( + vol.Schema( + { + vol.Required(CONF_TYPE): TYPE_OAUTH2, + vol.Required(CONF_CLIENT_ID): cv.string, + vol.Required(CONF_CLIENT_SECRET): cv.string, + vol.Optional(CONF_HOST, default=DEFAULT_OAUTH2_HOST): cv.url, + } + ), + vol.Schema( + {vol.Required(CONF_TYPE): TYPE_LOCAL, vol.Required(CONF_HOST): cv.url} + ), + ) + }, + extra=vol.ALLOW_EXTRA, +) +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass, config): + """Set up the Almond component.""" + hass.data[DOMAIN] = {} + + if DOMAIN not in config: + return True + + conf = config[DOMAIN] + + host = conf[CONF_HOST] + + if conf[CONF_TYPE] == TYPE_OAUTH2: + config_flow.AlmondFlowHandler.async_register_implementation( + hass, + config_entry_oauth2_flow.LocalOAuth2Implementation( + hass, + DOMAIN, + conf[CONF_CLIENT_ID], + conf[CONF_CLIENT_SECRET], + f"{host}/me/api/oauth2/authorize", + f"{host}/me/api/oauth2/token", + ), + ) + return True + + if not hass.config_entries.async_entries(DOMAIN): + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={"type": TYPE_LOCAL, "host": conf[CONF_HOST]}, + ) + ) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: config_entries.ConfigEntry): + """Set up Almond config entry.""" + websession = aiohttp_client.async_get_clientsession(hass) + + if entry.data["type"] == TYPE_LOCAL: + auth = AlmondLocalAuth(entry.data["host"], websession) + else: + # OAuth2 + implementation = await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) + oauth_session = config_entry_oauth2_flow.OAuth2Session( + hass, entry, implementation + ) + auth = AlmondOAuth(entry.data["host"], websession, oauth_session) + + api = WebAlmondAPI(auth) + agent = AlmondAgent(hass, api, entry) + + # Hass.io does its own configuration. + if not entry.data.get("is_hassio"): + # If we're not starting or local, set up Almond right away + if hass.state != CoreState.not_running or entry.data["type"] == TYPE_LOCAL: + await _configure_almond_for_ha(hass, entry, api) + + else: + # OAuth2 implementations can potentially rely on the HA Cloud url. + # This url is not be available until 30 seconds after boot. + + async def configure_almond(_now): + try: + await _configure_almond_for_ha(hass, entry, api) + except ConfigEntryNotReady: + _LOGGER.warning( + "Unable to configure Almond to connect to Home Assistant" + ) + + async def almond_hass_start(_event): + event.async_call_later(hass, ALMOND_SETUP_DELAY, configure_almond) + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, almond_hass_start) + + conversation.async_set_agent(hass, agent) + return True + + +async def _configure_almond_for_ha( + hass: HomeAssistant, entry: config_entries.ConfigEntry, api: WebAlmondAPI +): + """Configure Almond to connect to HA.""" + + if entry.data["type"] == TYPE_OAUTH2: + # If we're connecting over OAuth2, we will only set up connection + # with Home Assistant if we're remotely accessible. + hass_url = network.async_get_external_url(hass) + else: + hass_url = hass.config.api.base_url + + # If hass_url is None, we're not going to configure Almond to connect to HA. + if hass_url is None: + return + + _LOGGER.debug("Configuring Almond to connect to Home Assistant at %s", hass_url) + store = storage.Store(hass, STORAGE_VERSION, STORAGE_KEY) + data = await store.async_load() + + if data is None: + data = {} + + user = None + if "almond_user" in data: + user = await hass.auth.async_get_user(data["almond_user"]) + + if user is None: + user = await hass.auth.async_create_system_user("Almond", [GROUP_ID_ADMIN]) + data["almond_user"] = user.id + await store.async_save(data) + + refresh_token = await hass.auth.async_create_refresh_token( + user, + # Almond will be fine as long as we restart once every 5 years + access_token_expiration=timedelta(days=365 * 5), + ) + + # Create long lived access token + access_token = hass.auth.async_create_access_token(refresh_token) + + # Store token in Almond + try: + with async_timeout.timeout(30): + await api.async_create_device( + { + "kind": "io.home-assistant", + "hassUrl": hass_url, + "accessToken": access_token, + "refreshToken": "", + # 5 years from now in ms. + "accessTokenExpires": (time.time() + 60 * 60 * 24 * 365 * 5) * 1000, + } + ) + except (asyncio.TimeoutError, ClientError) as err: + if isinstance(err, asyncio.TimeoutError): + msg = "Request timeout" + else: + msg = err + _LOGGER.warning("Unable to configure Almond: %s", msg) + await hass.auth.async_remove_refresh_token(refresh_token) + raise ConfigEntryNotReady + + # Clear all other refresh tokens + for token in list(user.refresh_tokens.values()): + if token.id != refresh_token.id: + await hass.auth.async_remove_refresh_token(token) + + +async def async_unload_entry(hass, entry): + """Unload Almond.""" + conversation.async_set_agent(hass, None) + return True + + +class AlmondOAuth(AbstractAlmondWebAuth): + """Almond Authentication using OAuth2.""" + + def __init__( + self, + host: str, + websession: ClientSession, + oauth_session: config_entry_oauth2_flow.OAuth2Session, + ): + """Initialize Almond auth.""" + super().__init__(host, websession) + self._oauth_session = oauth_session + + async def async_get_access_token(self): + """Return a valid access token.""" + if not self._oauth_session.valid_token: + await self._oauth_session.async_ensure_token_valid() + + return self._oauth_session.token["access_token"] + + +class AlmondAgent(conversation.AbstractConversationAgent): + """Almond conversation agent.""" + + def __init__( + self, hass: HomeAssistant, api: WebAlmondAPI, entry: config_entries.ConfigEntry + ): + """Initialize the agent.""" + self.hass = hass + self.api = api + self.entry = entry + + @property + def attribution(self): + """Return the attribution.""" + return {"name": "Powered by Almond", "url": "https://almond.stanford.edu/"} + + async def async_get_onboarding(self): + """Get onboard url if not onboarded.""" + if self.entry.data.get("onboarded"): + return None + + host = self.entry.data["host"] + if self.entry.data.get("is_hassio"): + host = "/core_almond" + return { + "text": "Would you like to opt-in to share your anonymized commands with Stanford to improve Almond's responses?", + "url": f"{host}/conversation", + } + + async def async_set_onboarding(self, shown): + """Set onboarding status.""" + self.hass.config_entries.async_update_entry( + self.entry, data={**self.entry.data, "onboarded": shown} + ) + + return True + + async def async_process( + self, text: str, context: Context, conversation_id: Optional[str] = None + ) -> intent.IntentResponse: + """Process a sentence.""" + response = await self.api.async_converse_text(text, conversation_id) + + first_choice = True + buffer = "" + for message in response["messages"]: + if message["type"] == "text": + buffer += "\n" + message["text"] + elif message["type"] == "picture": + buffer += "\n Picture: " + message["url"] + elif message["type"] == "rdl": + buffer += ( + "\n Link: " + + message["rdl"]["displayTitle"] + + " " + + message["rdl"]["webCallback"] + ) + elif message["type"] == "choice": + if first_choice: + first_choice = False + else: + buffer += "," + buffer += f" {message['title']}" + + intent_result = intent.IntentResponse() + intent_result.async_set_speech(buffer.strip()) + return intent_result diff --git a/homeassistant/components/almond/config_flow.py b/homeassistant/components/almond/config_flow.py new file mode 100644 index 000000000..42f9318a0 --- /dev/null +++ b/homeassistant/components/almond/config_flow.py @@ -0,0 +1,125 @@ +"""Config flow to connect with Home Assistant.""" +import asyncio +import logging + +from aiohttp import ClientError +import async_timeout +from pyalmond import AlmondLocalAuth, WebAlmondAPI +import voluptuous as vol +from yarl import URL + +from homeassistant import config_entries, core, data_entry_flow +from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow + +from .const import DOMAIN, TYPE_LOCAL, TYPE_OAUTH2 + + +async def async_verify_local_connection(hass: core.HomeAssistant, host: str): + """Verify that a local connection works.""" + websession = aiohttp_client.async_get_clientsession(hass) + api = WebAlmondAPI(AlmondLocalAuth(host, websession)) + + try: + with async_timeout.timeout(10): + await api.async_list_apps() + + return True + except (asyncio.TimeoutError, ClientError): + return False + + +@config_entries.HANDLERS.register(DOMAIN) +class AlmondFlowHandler(config_entry_oauth2_flow.AbstractOAuth2FlowHandler): + """Implementation of the Almond OAuth2 config flow.""" + + DOMAIN = DOMAIN + + host = None + hassio_discovery = None + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) + + @property + def extra_authorize_data(self) -> dict: + """Extra data that needs to be appended to the authorize url.""" + return {"scope": "profile user-read user-read-results user-exec-command"} + + async def async_step_user(self, user_input=None): + """Handle a flow start.""" + # Only allow 1 instance. + if self._async_current_entries(): + return self.async_abort(reason="already_setup") + + return await super().async_step_user(user_input) + + async def async_step_auth(self, user_input=None): + """Handle authorize step.""" + result = await super().async_step_auth(user_input) + + if result["type"] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP: + self.host = str(URL(result["url"]).with_path("me")) + + return result + + async def async_oauth_create_entry(self, data: dict) -> dict: + """Create an entry for the flow. + + Ok to override if you want to fetch extra info or even add another step. + """ + # pylint: disable=invalid-name + self.CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + data["type"] = TYPE_OAUTH2 + data["host"] = self.host + return self.async_create_entry(title=self.flow_impl.name, data=data) + + async def async_step_import(self, user_input: dict = None) -> dict: + """Import data.""" + # Only allow 1 instance. + if self._async_current_entries(): + return self.async_abort(reason="already_setup") + + if not await async_verify_local_connection(self.hass, user_input["host"]): + self.logger.warning( + "Aborting import of Almond because we're unable to connect" + ) + return self.async_abort(reason="cannot_connect") + + # pylint: disable=invalid-name + self.CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + return self.async_create_entry( + title="Configuration.yaml", + data={"type": TYPE_LOCAL, "host": user_input["host"]}, + ) + + async def async_step_hassio(self, user_input=None): + """Receive a Hass.io discovery.""" + if self._async_current_entries(): + return self.async_abort(reason="already_setup") + + self.hassio_discovery = user_input + + return await self.async_step_hassio_confirm() + + async def async_step_hassio_confirm(self, user_input=None): + """Confirm a Hass.io discovery.""" + data = self.hassio_discovery + + if user_input is not None: + return self.async_create_entry( + title=data["addon"], + data={ + "is_hassio": True, + "type": TYPE_LOCAL, + "host": f"http://{data['host']}:{data['port']}", + }, + ) + + return self.async_show_form( + step_id="hassio_confirm", + description_placeholders={"addon": data["addon"]}, + data_schema=vol.Schema({}), + ) diff --git a/homeassistant/components/almond/const.py b/homeassistant/components/almond/const.py new file mode 100644 index 000000000..34dca28e9 --- /dev/null +++ b/homeassistant/components/almond/const.py @@ -0,0 +1,4 @@ +"""Constants for the Almond integration.""" +DOMAIN = "almond" +TYPE_OAUTH2 = "oauth2" +TYPE_LOCAL = "local" diff --git a/homeassistant/components/almond/manifest.json b/homeassistant/components/almond/manifest.json new file mode 100644 index 000000000..44404b504 --- /dev/null +++ b/homeassistant/components/almond/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "almond", + "name": "Almond", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/almond", + "dependencies": ["http", "conversation"], + "codeowners": ["@gcampax", "@balloob"], + "requirements": ["pyalmond==0.0.2"] +} diff --git a/homeassistant/components/almond/strings.json b/homeassistant/components/almond/strings.json new file mode 100644 index 000000000..872367eb8 --- /dev/null +++ b/homeassistant/components/almond/strings.json @@ -0,0 +1,15 @@ +{ + "config": { + "step": { + "pick_implementation": { + "title": "Pick Authentication Method" + } + }, + "abort": { + "already_setup": "You can only configure one Almond account.", + "cannot_connect": "Unable to connect to the Almond server.", + "missing_configuration": "Please check the documentation on how to set up Almond." + }, + "title": "Almond" + } +} diff --git a/homeassistant/components/alpha_vantage/__init__.py b/homeassistant/components/alpha_vantage/__init__.py new file mode 100644 index 000000000..b8da9c190 --- /dev/null +++ b/homeassistant/components/alpha_vantage/__init__.py @@ -0,0 +1 @@ +"""The Alpha Vantage component.""" diff --git a/homeassistant/components/alpha_vantage/manifest.json b/homeassistant/components/alpha_vantage/manifest.json new file mode 100644 index 000000000..99498991b --- /dev/null +++ b/homeassistant/components/alpha_vantage/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "alpha_vantage", + "name": "Alpha Vantage", + "documentation": "https://www.home-assistant.io/integrations/alpha_vantage", + "requirements": [ + "alpha_vantage==2.1.2" + ], + "dependencies": [], + "codeowners": [ + "@fabaff" + ] +} diff --git a/homeassistant/components/alpha_vantage/sensor.py b/homeassistant/components/alpha_vantage/sensor.py new file mode 100644 index 000000000..7d871c286 --- /dev/null +++ b/homeassistant/components/alpha_vantage/sensor.py @@ -0,0 +1,219 @@ +"""Stock market information from Alpha Vantage.""" +from datetime import timedelta +import logging + +from alpha_vantage.foreignexchange import ForeignExchange +from alpha_vantage.timeseries import TimeSeries +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ATTR_ATTRIBUTION, CONF_API_KEY, CONF_CURRENCY, CONF_NAME +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +ATTR_CLOSE = "close" +ATTR_HIGH = "high" +ATTR_LOW = "low" + +ATTRIBUTION = "Stock market information provided by Alpha Vantage" + +CONF_FOREIGN_EXCHANGE = "foreign_exchange" +CONF_FROM = "from" +CONF_SYMBOL = "symbol" +CONF_SYMBOLS = "symbols" +CONF_TO = "to" + +ICONS = { + "BTC": "mdi:currency-btc", + "EUR": "mdi:currency-eur", + "GBP": "mdi:currency-gbp", + "INR": "mdi:currency-inr", + "RUB": "mdi:currency-rub", + "TRY": "mdi:currency-try", + "USD": "mdi:currency-usd", +} + +SCAN_INTERVAL = timedelta(minutes=5) + +SYMBOL_SCHEMA = vol.Schema( + { + vol.Required(CONF_SYMBOL): cv.string, + vol.Optional(CONF_CURRENCY): cv.string, + vol.Optional(CONF_NAME): cv.string, + } +) + +CURRENCY_SCHEMA = vol.Schema( + { + vol.Required(CONF_FROM): cv.string, + vol.Required(CONF_TO): cv.string, + vol.Optional(CONF_NAME): cv.string, + } +) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_API_KEY): cv.string, + vol.Optional(CONF_FOREIGN_EXCHANGE): vol.All(cv.ensure_list, [CURRENCY_SCHEMA]), + vol.Optional(CONF_SYMBOLS): vol.All(cv.ensure_list, [SYMBOL_SCHEMA]), + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Alpha Vantage sensor.""" + api_key = config.get(CONF_API_KEY) + symbols = config.get(CONF_SYMBOLS, []) + conversions = config.get(CONF_FOREIGN_EXCHANGE, []) + + if not symbols and not conversions: + msg = "No symbols or currencies configured." + hass.components.persistent_notification.create(msg, "Sensor alpha_vantage") + _LOGGER.warning(msg) + return + + timeseries = TimeSeries(key=api_key) + + dev = [] + for symbol in symbols: + try: + _LOGGER.debug("Configuring timeseries for symbols: %s", symbol[CONF_SYMBOL]) + timeseries.get_intraday(symbol[CONF_SYMBOL]) + except ValueError: + _LOGGER.error("API Key is not valid or symbol '%s' not known", symbol) + dev.append(AlphaVantageSensor(timeseries, symbol)) + + forex = ForeignExchange(key=api_key) + for conversion in conversions: + from_cur = conversion.get(CONF_FROM) + to_cur = conversion.get(CONF_TO) + try: + _LOGGER.debug("Configuring forex %s - %s", from_cur, to_cur) + forex.get_currency_exchange_rate(from_currency=from_cur, to_currency=to_cur) + except ValueError as error: + _LOGGER.error( + "API Key is not valid or currencies '%s'/'%s' not known", + from_cur, + to_cur, + ) + _LOGGER.debug(str(error)) + dev.append(AlphaVantageForeignExchange(forex, conversion)) + + add_entities(dev, True) + _LOGGER.debug("Setup completed") + + +class AlphaVantageSensor(Entity): + """Representation of a Alpha Vantage sensor.""" + + def __init__(self, timeseries, symbol): + """Initialize the sensor.""" + self._symbol = symbol[CONF_SYMBOL] + self._name = symbol.get(CONF_NAME, self._symbol) + self._timeseries = timeseries + self.values = None + self._unit_of_measurement = symbol.get(CONF_CURRENCY, self._symbol) + self._icon = ICONS.get(symbol.get(CONF_CURRENCY, "USD")) + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._unit_of_measurement + + @property + def state(self): + """Return the state of the sensor.""" + return self.values["1. open"] + + @property + def device_state_attributes(self): + """Return the state attributes.""" + if self.values is not None: + return { + ATTR_ATTRIBUTION: ATTRIBUTION, + ATTR_CLOSE: self.values["4. close"], + ATTR_HIGH: self.values["2. high"], + ATTR_LOW: self.values["3. low"], + } + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return self._icon + + def update(self): + """Get the latest data and updates the states.""" + _LOGGER.debug("Requesting new data for symbol %s", self._symbol) + all_values, _ = self._timeseries.get_intraday(self._symbol) + self.values = next(iter(all_values.values())) + _LOGGER.debug("Received new values for symbol %s", self._symbol) + + +class AlphaVantageForeignExchange(Entity): + """Sensor for foreign exchange rates.""" + + def __init__(self, foreign_exchange, config): + """Initialize the sensor.""" + self._foreign_exchange = foreign_exchange + self._from_currency = config.get(CONF_FROM) + self._to_currency = config.get(CONF_TO) + if CONF_NAME in config: + self._name = config.get(CONF_NAME) + else: + self._name = f"{self._to_currency}/{self._from_currency}" + self._unit_of_measurement = self._to_currency + self._icon = ICONS.get(self._from_currency, "USD") + self.values = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._unit_of_measurement + + @property + def state(self): + """Return the state of the sensor.""" + return round(float(self.values["5. Exchange Rate"]), 4) + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return self._icon + + @property + def device_state_attributes(self): + """Return the state attributes.""" + if self.values is not None: + return { + ATTR_ATTRIBUTION: ATTRIBUTION, + CONF_FROM: self._from_currency, + CONF_TO: self._to_currency, + } + + def update(self): + """Get the latest data and updates the states.""" + _LOGGER.debug( + "Requesting new data for forex %s - %s", + self._from_currency, + self._to_currency, + ) + self.values, _ = self._foreign_exchange.get_currency_exchange_rate( + from_currency=self._from_currency, to_currency=self._to_currency + ) + _LOGGER.debug( + "Received new data for forex %s - %s", + self._from_currency, + self._to_currency, + ) diff --git a/homeassistant/components/amazon_polly/__init__.py b/homeassistant/components/amazon_polly/__init__.py new file mode 100644 index 000000000..0fab4af43 --- /dev/null +++ b/homeassistant/components/amazon_polly/__init__.py @@ -0,0 +1 @@ +"""Support for Amazon Polly integration.""" diff --git a/homeassistant/components/amazon_polly/manifest.json b/homeassistant/components/amazon_polly/manifest.json new file mode 100644 index 000000000..c07aad079 --- /dev/null +++ b/homeassistant/components/amazon_polly/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "amazon_polly", + "name": "Amazon polly", + "documentation": "https://www.home-assistant.io/integrations/amazon_polly", + "requirements": [ + "boto3==1.9.252" + ], + "dependencies": [], + "codeowners": [ + "@robbiet480" + ] +} \ No newline at end of file diff --git a/homeassistant/components/amazon_polly/tts.py b/homeassistant/components/amazon_polly/tts.py new file mode 100644 index 000000000..bcb4a24e9 --- /dev/null +++ b/homeassistant/components/amazon_polly/tts.py @@ -0,0 +1,243 @@ +"""Support for the Amazon Polly text to speech service.""" +import logging + +import boto3 +import voluptuous as vol + +from homeassistant.components.tts import PLATFORM_SCHEMA, Provider +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +CONF_REGION = "region_name" +CONF_ACCESS_KEY_ID = "aws_access_key_id" +CONF_SECRET_ACCESS_KEY = "aws_secret_access_key" +CONF_PROFILE_NAME = "profile_name" +ATTR_CREDENTIALS = "credentials" + +DEFAULT_REGION = "us-east-1" +SUPPORTED_REGIONS = [ + "us-east-1", + "us-east-2", + "us-west-1", + "us-west-2", + "ca-central-1", + "eu-west-1", + "eu-central-1", + "eu-west-2", + "eu-west-3", + "ap-southeast-1", + "ap-southeast-2", + "ap-northeast-2", + "ap-northeast-1", + "ap-south-1", + "sa-east-1", +] + +CONF_ENGINE = "engine" +CONF_VOICE = "voice" +CONF_OUTPUT_FORMAT = "output_format" +CONF_SAMPLE_RATE = "sample_rate" +CONF_TEXT_TYPE = "text_type" + +SUPPORTED_VOICES = [ + "Zhiyu", # Chinese + "Mads", + "Naja", # Danish + "Ruben", + "Lotte", # Dutch + "Russell", + "Nicole", # English Australian + "Brian", + "Amy", + "Emma", # English + "Aditi", + "Raveena", # English, Indian + "Joey", + "Justin", + "Matthew", + "Ivy", + "Joanna", + "Kendra", + "Kimberly", + "Salli", # English + "Geraint", # English Welsh + "Mathieu", + "Celine", + "Lea", # French + "Chantal", # French Canadian + "Hans", + "Marlene", + "Vicki", # German + "Aditi", # Hindi + "Karl", + "Dora", # Icelandic + "Giorgio", + "Carla", + "Bianca", # Italian + "Takumi", + "Mizuki", # Japanese + "Seoyeon", # Korean + "Liv", # Norwegian + "Jacek", + "Jan", + "Ewa", + "Maja", # Polish + "Ricardo", + "Vitoria", # Portuguese, Brazilian + "Cristiano", + "Ines", # Portuguese, European + "Carmen", # Romanian + "Maxim", + "Tatyana", # Russian + "Enrique", + "Conchita", + "Lucia", # Spanish European + "Mia", # Spanish Mexican + "Miguel", + "Penelope", # Spanish US + "Astrid", # Swedish + "Filiz", # Turkish + "Gwyneth", # Welsh +] + +SUPPORTED_OUTPUT_FORMATS = ["mp3", "ogg_vorbis", "pcm"] + +SUPPORTED_ENGINES = ["neural", "standard"] + +SUPPORTED_SAMPLE_RATES = ["8000", "16000", "22050", "24000"] + +SUPPORTED_SAMPLE_RATES_MAP = { + "mp3": ["8000", "16000", "22050", "24000"], + "ogg_vorbis": ["8000", "16000", "22050"], + "pcm": ["8000", "16000"], +} + +SUPPORTED_TEXT_TYPES = ["text", "ssml"] + +CONTENT_TYPE_EXTENSIONS = {"audio/mpeg": "mp3", "audio/ogg": "ogg", "audio/pcm": "pcm"} + +DEFAULT_ENGINE = "standard" +DEFAULT_VOICE = "Joanna" +DEFAULT_OUTPUT_FORMAT = "mp3" +DEFAULT_TEXT_TYPE = "text" + +DEFAULT_SAMPLE_RATES = {"mp3": "22050", "ogg_vorbis": "22050", "pcm": "16000"} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_REGION, default=DEFAULT_REGION): vol.In(SUPPORTED_REGIONS), + vol.Inclusive(CONF_ACCESS_KEY_ID, ATTR_CREDENTIALS): cv.string, + vol.Inclusive(CONF_SECRET_ACCESS_KEY, ATTR_CREDENTIALS): cv.string, + vol.Exclusive(CONF_PROFILE_NAME, ATTR_CREDENTIALS): cv.string, + vol.Optional(CONF_VOICE, default=DEFAULT_VOICE): vol.In(SUPPORTED_VOICES), + vol.Optional(CONF_ENGINE, default=DEFAULT_ENGINE): vol.In(SUPPORTED_ENGINES), + vol.Optional(CONF_OUTPUT_FORMAT, default=DEFAULT_OUTPUT_FORMAT): vol.In( + SUPPORTED_OUTPUT_FORMATS + ), + vol.Optional(CONF_SAMPLE_RATE): vol.All( + cv.string, vol.In(SUPPORTED_SAMPLE_RATES) + ), + vol.Optional(CONF_TEXT_TYPE, default=DEFAULT_TEXT_TYPE): vol.In( + SUPPORTED_TEXT_TYPES + ), + } +) + + +def get_engine(hass, config, discovery_info=None): + """Set up Amazon Polly speech component.""" + output_format = config.get(CONF_OUTPUT_FORMAT) + sample_rate = config.get(CONF_SAMPLE_RATE, DEFAULT_SAMPLE_RATES[output_format]) + if sample_rate not in SUPPORTED_SAMPLE_RATES_MAP.get(output_format): + _LOGGER.error( + "%s is not a valid sample rate for %s", sample_rate, output_format + ) + return None + + config[CONF_SAMPLE_RATE] = sample_rate + + profile = config.get(CONF_PROFILE_NAME) + + if profile is not None: + boto3.setup_default_session(profile_name=profile) + + aws_config = { + CONF_REGION: config.get(CONF_REGION), + CONF_ACCESS_KEY_ID: config.get(CONF_ACCESS_KEY_ID), + CONF_SECRET_ACCESS_KEY: config.get(CONF_SECRET_ACCESS_KEY), + } + + del config[CONF_REGION] + del config[CONF_ACCESS_KEY_ID] + del config[CONF_SECRET_ACCESS_KEY] + + polly_client = boto3.client("polly", **aws_config) + + supported_languages = [] + + all_voices = {} + + all_voices_req = polly_client.describe_voices() + + for voice in all_voices_req.get("Voices"): + all_voices[voice.get("Id")] = voice + if voice.get("LanguageCode") not in supported_languages: + supported_languages.append(voice.get("LanguageCode")) + + return AmazonPollyProvider(polly_client, config, supported_languages, all_voices) + + +class AmazonPollyProvider(Provider): + """Amazon Polly speech api provider.""" + + def __init__(self, polly_client, config, supported_languages, all_voices): + """Initialize Amazon Polly provider for TTS.""" + self.client = polly_client + self.config = config + self.supported_langs = supported_languages + self.all_voices = all_voices + self.default_voice = self.config.get(CONF_VOICE) + self.name = "Amazon Polly" + + @property + def supported_languages(self): + """Return a list of supported languages.""" + return self.supported_langs + + @property + def default_language(self): + """Return the default language.""" + return self.all_voices.get(self.default_voice).get("LanguageCode") + + @property + def default_options(self): + """Return dict include default options.""" + return {CONF_VOICE: self.default_voice} + + @property + def supported_options(self): + """Return a list of supported options.""" + return [CONF_VOICE] + + def get_tts_audio(self, message, language=None, options=None): + """Request TTS file from Polly.""" + voice_id = options.get(CONF_VOICE, self.default_voice) + voice_in_dict = self.all_voices.get(voice_id) + if language != voice_in_dict.get("LanguageCode"): + _LOGGER.error("%s does not support the %s language", voice_id, language) + return None, None + + resp = self.client.synthesize_speech( + Engine=self.config[CONF_ENGINE], + OutputFormat=self.config[CONF_OUTPUT_FORMAT], + SampleRate=self.config[CONF_SAMPLE_RATE], + Text=message, + TextType=self.config[CONF_TEXT_TYPE], + VoiceId=voice_id, + ) + + return ( + CONTENT_TYPE_EXTENSIONS[resp.get("ContentType")], + resp.get("AudioStream").read(), + ) diff --git a/homeassistant/components/ambiclimate/.translations/bg.json b/homeassistant/components/ambiclimate/.translations/bg.json new file mode 100644 index 000000000..4795267cd --- /dev/null +++ b/homeassistant/components/ambiclimate/.translations/bg.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "access_token": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430 \u043f\u0440\u0438 \u0433\u0435\u043d\u0435\u0440\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u043a\u043e\u0434 \u0437\u0430 \u0434\u043e\u0441\u0442\u044a\u043f.", + "already_setup": "\u041f\u0440\u043e\u0444\u0438\u043b\u044a\u0442 \u043d\u0430 Ambiclimate \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d.", + "no_config": "\u0422\u0440\u044f\u0431\u0432\u0430 \u0434\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u0442\u0435 Ambiclimate, \u043f\u0440\u0435\u0434\u0438 \u0434\u0430 \u043c\u043e\u0436\u0435\u0442\u0435 \u0434\u0430 \u0433\u043e \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u043a\u0438\u0440\u0430\u0442\u0435. [\u041c\u043e\u043b\u044f, \u043f\u0440\u043e\u0447\u0435\u0442\u0435\u0442\u0435 \u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u0438\u0442\u0435](https://www.home-assistant.io/components/ambiclimate/)." + }, + "create_entry": { + "default": "\u0423\u0441\u043f\u0435\u0448\u043d\u043e \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u043a\u0438\u0440\u0430\u043d\u0435 \u0441 Ambiclimate." + }, + "error": { + "follow_link": "\u041c\u043e\u043b\u044f, \u043f\u043e\u0441\u043b\u0435\u0434\u0432\u0430\u0439\u0442\u0435 \u0432\u0440\u044a\u0437\u043a\u0430\u0442\u0430 \u0438 \u0441\u0435 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u043a\u0438\u0440\u0430\u0439\u0442\u0435, \u043f\u0440\u0435\u0434\u0438 \u0434\u0430 \u043d\u0430\u0442\u0438\u0441\u043d\u0435\u0442\u0435 \u0418\u0437\u043f\u0440\u0430\u0449\u0430\u043d\u0435", + "no_token": "\u041b\u0438\u043f\u0441\u0432\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u043a\u0438\u0440\u0430\u043d\u0435 \u0441 Ambiclimate" + }, + "step": { + "auth": { + "description": "\u041c\u043e\u043b\u044f, \u043f\u043e\u0441\u043b\u0435\u0434\u0432\u0430\u0439\u0442\u0435 \u0442\u043e\u0437\u0438 [link]({authorization_url}) \u0438 \u0420\u0430\u0437\u0440\u0435\u0448\u0435\u0442\u0435 \u0434\u043e\u0441\u0442\u044a\u043f\u0430 \u0434\u043e \u043f\u0440\u043e\u0444\u0438\u043b\u0430 \u0441\u0438 \u0432 Ambiclimate, \u0441\u043b\u0435\u0434 \u0442\u043e\u0432\u0430 \u0441\u0435 \u0432\u044a\u0440\u043d\u0435\u0442\u0435 \u0438 \u043d\u0430\u0442\u0438\u0441\u043d\u0435\u0442\u0435 \u0418\u0437\u043f\u0440\u0430\u0449\u0430\u043d\u0435 \u043f\u043e-\u0434\u043e\u043b\u0443. \n (\u0423\u0432\u0435\u0440\u0435\u0442\u0435 \u0441\u0435, \u0447\u0435 \u043f\u043e\u0441\u043e\u0447\u0435\u043d\u0438\u044f\u0442 url \u0437\u0430 \u043e\u0431\u0440\u0430\u0442\u043d\u0430 \u043f\u043e\u0432\u0438\u043a\u0432\u0430\u043d\u0435 \u0435 {cb_url})", + "title": "\u0410\u0443\u0442\u0435\u043d\u0442\u0438\u043a\u0438\u0440\u0430\u043d\u0435 \u0441 Ambiclimate" + } + }, + "title": "Ambiclimate" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/.translations/ca.json b/homeassistant/components/ambiclimate/.translations/ca.json new file mode 100644 index 000000000..f446bf739 --- /dev/null +++ b/homeassistant/components/ambiclimate/.translations/ca.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "access_token": "S'ha produ\u00eft un error desconegut al generat un testimoni d'acc\u00e9s.", + "already_setup": "El compte d\u2019Ambi Climate est\u00e0 configurat.", + "no_config": "Necessites configurar Ambiclimate abans de poder autenticar-t'hi. Llegeix les [instruccions](https://www.home-assistant.io/components/ambiclimate/)." + }, + "create_entry": { + "default": "Autenticaci\u00f3 exitosa amb Ambi Climate." + }, + "error": { + "follow_link": "V\u00e9s a l'enlla\u00e7 i autentica't abans de pr\u00e9mer Envia", + "no_token": "No autenticat amb Ambi Climate" + }, + "step": { + "auth": { + "description": "V\u00e9s a l'[enlla\u00e7]({authorization_url}) i Permet l'acc\u00e9s al teu compte de Ambiclimate, despr\u00e9s torna i prem Envia (a sota).\n(Assegura't que l'enlla\u00e7 de retorn \u00e9s el seg\u00fcent {cb_url})", + "title": "Autenticaci\u00f3 amb Ambi Climate" + } + }, + "title": "Ambi Climate" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/.translations/cs.json b/homeassistant/components/ambiclimate/.translations/cs.json new file mode 100644 index 000000000..d34169edf --- /dev/null +++ b/homeassistant/components/ambiclimate/.translations/cs.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "follow_link": "N\u00e1sledujte odkaz a prove\u010fte ov\u011b\u0159en\u00ed p\u0159ed stisknut\u00edm tla\u010d\u00edtka Odeslat.", + "no_token": "Nen\u00ed ov\u011b\u0159en s Ambiclimate" + }, + "step": { + "auth": { + "description": "N\u00e1sledujte tento [odkaz]({authorization_url}) a Povolit p\u0159\u00edstup k va\u0161emu \u00fa\u010dtu Ambiclimate, pot\u00e9 se vra\u0165te a stiskn\u011bte Odeslat n\u00ed\u017ee. \n (Ujist\u011bte se, \u017ee zadan\u00e1 adresa URL zp\u011btn\u00e9ho vol\u00e1n\u00ed je {cb_url} )", + "title": "Ov\u011b\u0159it Ambiclimate" + } + }, + "title": "Ambiclimate" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/.translations/da.json b/homeassistant/components/ambiclimate/.translations/da.json new file mode 100644 index 000000000..b57a0e157 --- /dev/null +++ b/homeassistant/components/ambiclimate/.translations/da.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "access_token": "Ukendt fejl ved generering af et adgangstoken.", + "already_setup": "Ambiclimate kontoen er konfigureret.", + "no_config": "Du skal konfigurere Ambiclimate f\u00f8r du kan godkende med det. [L\u00e6s venligst vejledningen](https://www.home-assistant.io/components/ambiclimate/)." + }, + "create_entry": { + "default": "Godkendt med Ambiclimate" + }, + "error": { + "follow_link": "F\u00f8lg linket og godkend f\u00f8r du trykker p\u00e5 send", + "no_token": "Ikke godkendt med Ambiclimate" + }, + "step": { + "auth": { + "description": "F\u00f8lg dette [link]({authorization_url}) og Tillad adgang til din Ambiclimate-konto, vend s\u00e5 tilbage og tryk p\u00e5 Indsend nedenfor.\n(Kontroll\u00e9r den angivne callback url er {cb_url})", + "title": "Godkend Ambiclimate" + } + }, + "title": "Ambiclimate" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/.translations/de.json b/homeassistant/components/ambiclimate/.translations/de.json new file mode 100644 index 000000000..68d714cfc --- /dev/null +++ b/homeassistant/components/ambiclimate/.translations/de.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "access_token": "Unbekannter Fehler beim Generieren eines Zugriffstokens.", + "already_setup": "Das Ambiclimate Konto ist konfiguriert.", + "no_config": "Ambiclimate muss konfiguriert sein, bevor die Authentifizierund durchgef\u00fchrt werden kann. [Bitte lies die Anleitung] (https://www.home-assistant.io/components/ambiclimate/)." + }, + "create_entry": { + "default": "Erfolgreiche Authentifizierung mit Ambiclimate" + }, + "error": { + "follow_link": "Bitte folge dem Link und authentifizieren dich, bevor du auf Senden klickst", + "no_token": "Nicht authentifiziert mit Ambiclimate" + }, + "step": { + "auth": { + "description": "Bitte folge diesem [link] ({authorization_url}) und Erlaube Zugriff auf dein Ambiclimate-Konto, komme dann zur\u00fcck und dr\u00fccke Senden darunter.\n (Pr\u00fcfe, dass die Callback-URL {cb_url} ist.)", + "title": "Ambiclimate authentifizieren" + } + }, + "title": "Ambiclimate" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/.translations/en.json b/homeassistant/components/ambiclimate/.translations/en.json new file mode 100644 index 000000000..da1e173b4 --- /dev/null +++ b/homeassistant/components/ambiclimate/.translations/en.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "access_token": "Unknown error generating an access token.", + "already_setup": "The Ambiclimate account is configured.", + "no_config": "You need to configure Ambiclimate before being able to authenticate with it. [Please read the instructions](https://www.home-assistant.io/components/ambiclimate/)." + }, + "create_entry": { + "default": "Successfully authenticated with Ambiclimate" + }, + "error": { + "follow_link": "Please follow the link and authenticate before pressing Submit", + "no_token": "Not authenticated with Ambiclimate" + }, + "step": { + "auth": { + "description": "Please follow this [link]({authorization_url}) and Allow access to your Ambiclimate account, then come back and press Submit below.\n(Make sure the specified callback url is {cb_url})", + "title": "Authenticate Ambiclimate" + } + }, + "title": "Ambiclimate" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/.translations/es-419.json b/homeassistant/components/ambiclimate/.translations/es-419.json new file mode 100644 index 000000000..607454f44 --- /dev/null +++ b/homeassistant/components/ambiclimate/.translations/es-419.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "access_token": "Error desconocido al generar un token de acceso.", + "already_setup": "La cuenta de Ambiclimate est\u00e1 configurada.", + "no_config": "Es necesario configurar Ambiclimate antes de poder autenticarse con \u00e9l. Por favor, lea las instrucciones](https://www.home-assistant.io/components/ambiclimate/)." + }, + "create_entry": { + "default": "Autenticaci\u00f3n exitosa con Ambiclimate" + }, + "error": { + "follow_link": "Por favor, siga el enlace y autent\u00edquese antes de presionar Enviar", + "no_token": "No autenticado con Ambiclimate" + }, + "step": { + "auth": { + "description": "Por favor, siga este [link]('authorization_url') y Permitir acceso a su cuenta de Ambiclimate, luego vuelva y presione Enviar a continuaci\u00f3n.\n(Aseg\u00farese de que la url de devoluci\u00f3n de llamada especificada es {cb_url})", + "title": "Autenticaci\u00f3n de Ambiclimate" + } + }, + "title": "Ambiclimate" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/.translations/es.json b/homeassistant/components/ambiclimate/.translations/es.json new file mode 100644 index 000000000..6447926f6 --- /dev/null +++ b/homeassistant/components/ambiclimate/.translations/es.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "access_token": "Error desconocido al generar un token de acceso.", + "already_setup": "La cuenta de Ambiclimate est\u00e1 configurada.", + "no_config": "Es necesario configurar Ambiclimate antes de poder autenticarse con \u00e9l. [Por favor, lee las instrucciones](https://www.home-assistant.io/components/ambiclimate/)." + }, + "create_entry": { + "default": "Autenticado correctamente con Ambiclimate" + }, + "error": { + "follow_link": "Accede al enlace e identif\u00edcate antes de pulsar Enviar.", + "no_token": "No autenticado con Ambiclimate" + }, + "step": { + "auth": { + "description": "Accede al siguiente [enlace]({authorization_url}) y permite el acceso a tu cuenta de Ambiclimate, despu\u00e9s vuelve y pulsa en enviar a continuaci\u00f3n.\n(Aseg\u00farate que la url de devoluci\u00f3n de llamada es {cb_url})", + "title": "Autenticaci\u00f3n de Ambiclimate" + } + }, + "title": "Ambiclimate" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/.translations/fr.json b/homeassistant/components/ambiclimate/.translations/fr.json new file mode 100644 index 000000000..6d09fd6ee --- /dev/null +++ b/homeassistant/components/ambiclimate/.translations/fr.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "access_token": "Erreur inconnue lors de la g\u00e9n\u00e9ration d'un jeton d'acc\u00e8s.", + "already_setup": "Le compte Ambiclimate est configur\u00e9.", + "no_config": "Vous devez configurer Ambiclimate avant de pouvoir vous authentifier aupr\u00e8s de celui-ci. [Veuillez lire les instructions] (https://www.home-assistant.io/components/ambiclimate/)." + }, + "create_entry": { + "default": "Authentifi\u00e9 avec succ\u00e8s avec Ambiclimate" + }, + "error": { + "follow_link": "Veuillez suivre le lien et vous authentifier avant d'appuyer sur Soumettre.", + "no_token": "Non authentifi\u00e9 avec Ambiclimate" + }, + "step": { + "auth": { + "description": "Suivez ce [lien] ( {authorization_url} ) et Autorisez l'acc\u00e8s \u00e0 votre compte Ambiclimate, puis revenez et appuyez sur Envoyer ci-dessous. \n (Assurez-vous que l'URL de rappel sp\u00e9cifi\u00e9 est {cb_url} )", + "title": "Authentifier Ambiclimate" + } + }, + "title": "Ambiclimate" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/.translations/it.json b/homeassistant/components/ambiclimate/.translations/it.json new file mode 100644 index 000000000..a13874b36 --- /dev/null +++ b/homeassistant/components/ambiclimate/.translations/it.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "access_token": "Errore sconosciuto durante la generazione di un token di accesso.", + "already_setup": "L'account Ambiclimate \u00e8 configurato.", + "no_config": "\u00c8 necessario configurare Ambiclimate prima di poter eseguire l'autenticazione con esso. [Leggere le istruzioni] (https://www.home-assistant.io/components/ambiclimate/)." + }, + "create_entry": { + "default": "Autenticato con successo con Ambiclimate" + }, + "error": { + "follow_link": "Si prega di seguire il link e di autenticarsi prima di premere Invia", + "no_token": "Non autenticato con Ambiclimate" + }, + "step": { + "auth": { + "description": "Segui questo [link]({authorization_url}) e Consenti accesso al tuo account Ambiclimate, quindi torna indietro e premi Invia qui sotto. \n (Assicurati che l'URL di richiamata specificato sia {cb_url})", + "title": "Autenticare Ambiclimate" + } + }, + "title": "Ambiclimate" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/.translations/ko.json b/homeassistant/components/ambiclimate/.translations/ko.json new file mode 100644 index 000000000..3b21726bc --- /dev/null +++ b/homeassistant/components/ambiclimate/.translations/ko.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "access_token": "\uc561\uc138\uc2a4 \ud1a0\ud070 \uc0dd\uc131\uc5d0 \uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4.", + "already_setup": "Ambi Climate \uacc4\uc815\uc774 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", + "no_config": "Ambiclimate \ub97c \uc778\uc99d\ud558\ub824\uba74 \uba3c\uc800 Ambiclimate \ub97c \uad6c\uc131\ud574\uc57c \ud569\ub2c8\ub2e4. [\uc548\ub0b4](https://www.home-assistant.io/components/ambiclimate/) \ub97c \uc77d\uc5b4\ubcf4\uc138\uc694." + }, + "create_entry": { + "default": "Ambi Climate \ub85c \uc131\uacf5\uc801\uc73c\ub85c \uc778\uc99d\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + }, + "error": { + "follow_link": "Submit \ubc84\ud2bc\uc744 \ub204\ub974\uae30 \uc804\uc5d0 \ub9c1\ud06c\ub97c \ub530\ub77c \uc778\uc99d\uc744 \ubc1b\uc544\uc8fc\uc138\uc694", + "no_token": "Ambi Climate \ub85c \uc778\uc99d\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4" + }, + "step": { + "auth": { + "description": "[\ub9c1\ud06c]({authorization_url}) \ub97c \ud074\ub9ad\ud558\uc5ec Ambi Climate \uacc4\uc815\uc5d0 \ub300\ud574 \ud5c8\uc6a9 \ud55c \ub2e4\uc74c, \ub2e4\uc2dc \ub3cc\uc544\uc640\uc11c \ud558\ub2e8\uc758 Submit \ubc84\ud2bc\uc744 \ub20c\ub7ec\uc8fc\uc138\uc694. \n(\ucf5c\ubc31 url \uc744 {cb_url} \ub85c \uad6c\uc131\ud588\ub294\uc9c0 \ud655\uc778\ud574\uc8fc\uc138\uc694)", + "title": "Ambi Climate \uc778\uc99d" + } + }, + "title": "Ambi Climate" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/.translations/lb.json b/homeassistant/components/ambiclimate/.translations/lb.json new file mode 100644 index 000000000..88be279ae --- /dev/null +++ b/homeassistant/components/ambiclimate/.translations/lb.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "access_token": "Onbekannte Feeler beim gener\u00e9ieren vum Acc\u00e8s Jeton.", + "already_setup": "Den Ambiclimate Kont ass konfigur\u00e9iert.", + "no_config": "Dir musst Ambiclimate konfigur\u00e9ieren, ier Dir d\u00ebs Authentifiz\u00e9ierung k\u00ebnnt benotzen.[Liest w.e.g. d'Instruktioune](https://www.home-assistant.io/components/ambiclimate/)." + }, + "create_entry": { + "default": "Erfollegr\u00e4ich mat Ambiclimate authentifiz\u00e9iert." + }, + "error": { + "follow_link": "Follegt w.e.g. dem Link an authentifiz\u00e9iert de Kont ier dir op ofsch\u00e9cken dr\u00e9ckt.", + "no_token": "Net mat Ambiclimate authentifiz\u00e9iert" + }, + "step": { + "auth": { + "description": "Follegt d\u00ebsem [Link]({authorization_url}) an erlaabtt den Acc\u00e8s zu \u00e4rem Ambiclimate Kont , a kommt dann zer\u00e9ck heihin an dr\u00e9ck op ofsch\u00e9cken hei \u00ebnnen.\n(Stellt s\u00e9cher dass den Type vun Callback {cb_url} ass.)", + "title": "Ambiclimate authentifiz\u00e9ieren" + } + }, + "title": "Ambiclimate" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/.translations/nl.json b/homeassistant/components/ambiclimate/.translations/nl.json new file mode 100644 index 000000000..ca4d0b912 --- /dev/null +++ b/homeassistant/components/ambiclimate/.translations/nl.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "access_token": "Onbekende fout bij het genereren van een toegangstoken.", + "already_setup": "Het Ambiclimate-account is geconfigureerd.", + "no_config": "U moet Ambiclimate configureren voordat u zich ermee kunt authenticeren. (Lees de instructies) (https://www.home-assistant.io/components/ambiclimate/)." + }, + "create_entry": { + "default": "Succesvol geverifieerd met Ambiclimate" + }, + "error": { + "follow_link": "Gelieve de link te volgen en te verifi\u00ebren voordat u op Verzenden drukt.", + "no_token": "Niet geverifieerd met Ambiclimate" + }, + "step": { + "auth": { + "description": "Volg deze [link] ( {authorization_url} ) en Toestaan toegang tot uw Ambiclimate-account, kom dan terug en druk hieronder op Verzenden . \n (Zorg ervoor dat de opgegeven callback-URL {cb_url} )", + "title": "Authenticatie Ambiclimate" + } + }, + "title": "Ambiclimate" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/.translations/nn.json b/homeassistant/components/ambiclimate/.translations/nn.json new file mode 100644 index 000000000..ce8a3ed9d --- /dev/null +++ b/homeassistant/components/ambiclimate/.translations/nn.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Ambiclimate" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/.translations/no.json b/homeassistant/components/ambiclimate/.translations/no.json new file mode 100644 index 000000000..e84de4ffc --- /dev/null +++ b/homeassistant/components/ambiclimate/.translations/no.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "access_token": "Ukjent feil ved oppretting av tilgangstoken.", + "already_setup": "Ambiclimate-kontoen er konfigurert.", + "no_config": "Du m\u00e5 konfigurere Ambiclimate f\u00f8r du kan autentisere med den. [Vennligst les instruksjonene](https://www.home-assistant.io/components/ambiclimate/)." + }, + "create_entry": { + "default": "Vellykket autentisering med Ambiclimate" + }, + "error": { + "follow_link": "Vennligst f\u00f8lg lenken og godkjenn f\u00f8r du trykker p\u00e5 Send", + "no_token": "Ikke autentisert med Ambiclimate" + }, + "step": { + "auth": { + "description": "Vennligst f\u00f8lg denne [linken]({authorization_url}) og Tillat tilgang til din Ambiclimate konto, kom deretter tilbake og trykk Send nedenfor.\n(Kontroller at den angitte URL-adressen for tilbakeringing er {cb_url})", + "title": "Autensiere Ambiclimate" + } + }, + "title": "Ambiclimate" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/.translations/pl.json b/homeassistant/components/ambiclimate/.translations/pl.json new file mode 100644 index 000000000..18f5d043d --- /dev/null +++ b/homeassistant/components/ambiclimate/.translations/pl.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "access_token": "Nieznany b\u0142\u0105d podczas generowania tokena dost\u0119pu.", + "already_setup": "Konto Ambiclimate jest skonfigurowane.", + "no_config": "Musisz skonfigurowa\u0107 Ambiclimate, aby m\u00f3c si\u0119 z nim uwierzytelni\u0107. Zapoznaj si\u0119 z [instrukcj\u0105](https://www.home-assistant.io/components/ambiclimate/)." + }, + "create_entry": { + "default": "Pomy\u015blnie uwierzytelniono z Ambiclimate" + }, + "error": { + "follow_link": "Prosz\u0119 klikn\u0105\u0107 link i uwierzytelni\u0107 przed naci\u015bni\u0119ciem przycisku Prze\u015blij", + "no_token": "Nieuwierzytelniony z Ambiclimate" + }, + "step": { + "auth": { + "description": "Kliknij poni\u017cszy [link]({authorization_url}) i Zezw\u00f3l na dost\u0119p do konta Ambiclimate, a nast\u0119pnie wr\u00f3\u0107 i naci\u015bnij Prze\u015blij poni\u017cej. \n(Upewnij si\u0119, \u017ce podany adres URL to {cb_url})", + "title": "Uwierzytelnienie Ambiclimate" + } + }, + "title": "Ambiclimate" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/.translations/pt-BR.json b/homeassistant/components/ambiclimate/.translations/pt-BR.json new file mode 100644 index 000000000..4de4190d0 --- /dev/null +++ b/homeassistant/components/ambiclimate/.translations/pt-BR.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "access_token": "Erro desconhecido ao gerar um token de acesso.", + "already_setup": "A conta Ambiclimate est\u00e1 configurada.", + "no_config": "Voc\u00ea precisa configurar o Ambiclimate antes de poder autenticar com ele. [Por favor, leia as instru\u00e7\u00f5es] (https://www.home-assistant.io/components/ambiclimate/)." + }, + "create_entry": { + "default": "Autenticado com sucesso no Ambiclimate" + }, + "error": { + "follow_link": "Por favor, siga o link e autentique-se antes de pressionar Enviar", + "no_token": "N\u00e3o autenticado com o Ambiclimate" + }, + "step": { + "auth": { + "description": "Por favor, siga este [link]({authorization_url}) e Permitir acesso \u00e0 sua conta Ambiclimate, em seguida, volte e pressione Enviar abaixo. \n (Verifique se a URL de retorno de chamada especificada \u00e9 {cb_url})", + "title": "Autenticar Ambiclimate" + } + }, + "title": "Ambiclimate" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/.translations/ru.json b/homeassistant/components/ambiclimate/.translations/ru.json new file mode 100644 index 000000000..2a99430e4 --- /dev/null +++ b/homeassistant/components/ambiclimate/.translations/ru.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "access_token": "\u041f\u0440\u0438 \u0441\u043e\u0437\u0434\u0430\u043d\u0438\u0438 \u0442\u043e\u043a\u0435\u043d\u0430 \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u043f\u0440\u043e\u0438\u0437\u043e\u0448\u043b\u0430 \u043e\u0448\u0438\u0431\u043a\u0430.", + "already_setup": "\u0423\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430.", + "no_config": "\u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0443 Ambiclimate \u043f\u0435\u0440\u0435\u0434 \u043f\u0440\u043e\u0445\u043e\u0436\u0434\u0435\u043d\u0438\u0435\u043c \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 [\u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438](https://www.home-assistant.io/components/ambiclimate/)." + }, + "create_entry": { + "default": "\u0410\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u043f\u0440\u043e\u0439\u0434\u0435\u043d\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e." + }, + "error": { + "follow_link": "\u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u0435\u0440\u0435\u0439\u0434\u0438\u0442\u0435 \u043f\u043e \u0441\u0441\u044b\u043b\u043a\u0435 \u0438 \u043f\u0440\u043e\u0439\u0434\u0438\u0442\u0435 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044e, \u043f\u0440\u0435\u0436\u0434\u0435 \u0447\u0435\u043c \u043d\u0430\u0436\u0430\u0442\u044c \"\u041f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u044c\".", + "no_token": "\u0410\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u043d\u0435 \u043f\u0440\u043e\u0439\u0434\u0435\u043d\u0430." + }, + "step": { + "auth": { + "description": "\u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u0435\u0440\u0435\u0439\u0434\u0438\u0442\u0435 \u043f\u043e [\u0441\u0441\u044b\u043b\u043a\u0435]({authorization_url}) \u0438 \u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u0435 \u0434\u043e\u0441\u0442\u0443\u043f \u043a \u0412\u0430\u0448\u0435\u0439 \u0443\u0447\u0451\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 Ambi Climate, \u0437\u0430\u0442\u0435\u043c \u0432\u0435\u0440\u043d\u0438\u0442\u0435\u0441\u044c \u0441\u044e\u0434\u0430 \u0438 \u043d\u0430\u0436\u043c\u0438\u0442\u0435 \u041f\u041e\u0414\u0422\u0412\u0415\u0420\u0414\u0418\u0422\u042c. \n(\u0423\u0431\u0435\u0434\u0438\u0442\u0435\u0441\u044c, \u0447\u0442\u043e \u0443\u043a\u0430\u0437\u0430\u043d\u043d\u044b\u0439 URL \u043e\u0431\u0440\u0430\u0442\u043d\u043e\u0433\u043e \u0432\u044b\u0437\u043e\u0432\u0430 \u0441\u043e\u043e\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0443\u0435\u0442 {cb_url})", + "title": "Ambi Climate" + } + }, + "title": "Ambi Climate" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/.translations/sl.json b/homeassistant/components/ambiclimate/.translations/sl.json new file mode 100644 index 000000000..c5d84f030 --- /dev/null +++ b/homeassistant/components/ambiclimate/.translations/sl.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "access_token": "Neznana napaka pri ustvarjanju \u017eetona za dostop.", + "already_setup": "Ra\u010dun Ambiclimate je konfiguriran.", + "no_config": "Ambiclimate morate konfigurirati, preden lahko z njo preverjate pristnost. [Preberite navodila] (https://www.home-assistant.io/components/ambiclimate/)." + }, + "create_entry": { + "default": "Uspe\u0161no overjeno z funkcijo Ambiclimate" + }, + "error": { + "follow_link": "Preden pritisnete Po\u0161lji, sledite povezavi in preverite pristnost", + "no_token": "Ni overjeno z Ambiclimate" + }, + "step": { + "auth": { + "description": "Sledite temu povezavi ( {authorization_url} in Dovoli dostopu do svojega ra\u010duna Ambiclimate, nato se vrnite in pritisnite Po\u0161lji spodaj. \n (Poskrbite, da je dolo\u010den url za povratni klic {cb_url} )", + "title": "Overi Ambiclimate" + } + }, + "title": "Ambiclimate" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/.translations/sv.json b/homeassistant/components/ambiclimate/.translations/sv.json new file mode 100644 index 000000000..f52bb6697 --- /dev/null +++ b/homeassistant/components/ambiclimate/.translations/sv.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "access_token": "Ok\u00e4nt fel vid generering av \u00e5tkomsttoken.", + "already_setup": "Ambiclientkontot \u00e4r konfigurerat", + "no_config": "Du m\u00e5ste konfigurera Ambiclimate innan du kan autentisera med den. [V\u00e4nligen l\u00e4s instruktionerna] (https://www.home-assistant.io/components/ambiclimate/)." + }, + "create_entry": { + "default": "Lyckad autentisering med Ambiclimate" + }, + "error": { + "follow_link": "V\u00e4nligen f\u00f6lj l\u00e4nken och autentisera dig innan du trycker p\u00e5 Skicka", + "no_token": "Inte autentiserad med Ambiclimate" + }, + "step": { + "auth": { + "description": "V\u00e4nligen f\u00f6lj denna [l\u00e4nk] ({authorization_url}) och till\u00e5ta till g\u00e5ng till ditt Ambiclimate konto, kom sedan tillbaka och tryck p\u00e5 Skicka nedan.\n(Kontrollera att den angivna callback url \u00e4r {cb_url})", + "title": "Autentisera Ambiclimate" + } + }, + "title": "Ambiclimate" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/.translations/zh-Hant.json b/homeassistant/components/ambiclimate/.translations/zh-Hant.json new file mode 100644 index 000000000..1539429d0 --- /dev/null +++ b/homeassistant/components/ambiclimate/.translations/zh-Hant.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "access_token": "\u7522\u751f\u5b58\u53d6\u8a8d\u8b49\u78bc\u672a\u77e5\u932f\u8aa4\u3002", + "already_setup": "Ambiclimate \u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "no_config": "\u5fc5\u9808\u5148\u8a2d\u5b9a Ambiclimate \u65b9\u80fd\u9032\u884c\u8a8d\u8b49\u3002[\u8acb\u53c3\u95b1\u6559\u5b78\u6307\u5f15]\uff08https://www.home-assistant.io/components/ambiclimate/\uff09\u3002" + }, + "create_entry": { + "default": "\u5df2\u6210\u529f\u8a8d\u8b49 Ambiclimate \u8a2d\u5099\u3002" + }, + "error": { + "follow_link": "\u8acb\u65bc\u50b3\u9001\u524d\uff0c\u5148\u4f7f\u7528\u9023\u7d50\u4e26\u9032\u884c\u8a8d\u8b49\u3002", + "no_token": "Ambiclimate \u672a\u6388\u6b0a" + }, + "step": { + "auth": { + "description": "\u8acb\u4f7f\u7528\u6b64[\u9023\u7d50]\uff08{authorization_url}\uff09\u4e26\u9ede\u9078\u5141\u8a31\u4ee5\u5b58\u53d6 Ambiclimate \u5e33\u865f\uff0c\u7136\u5f8c\u8fd4\u56de\u6b64\u9801\u9762\u4e26\u9ede\u9078\u4e0b\u65b9\u7684\u50b3\u9001\u3002\n\uff08\u78ba\u5b9a Callback url \u70ba {cb_url}\uff09", + "title": "\u8a8d\u8b49 Ambiclimate" + } + }, + "title": "Ambiclimate" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/__init__.py b/homeassistant/components/ambiclimate/__init__.py new file mode 100644 index 000000000..e15f6dea2 --- /dev/null +++ b/homeassistant/components/ambiclimate/__init__.py @@ -0,0 +1,46 @@ +"""Support for Ambiclimate devices.""" +import logging + +import voluptuous as vol + +from homeassistant.helpers import config_validation as cv + +from . import config_flow +from .const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_CLIENT_ID): cv.string, + vol.Required(CONF_CLIENT_SECRET): cv.string, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, config): + """Set up Ambiclimate components.""" + if DOMAIN not in config: + return True + + conf = config[DOMAIN] + + config_flow.register_flow_implementation( + hass, conf[CONF_CLIENT_ID], conf[CONF_CLIENT_SECRET] + ) + + return True + + +async def async_setup_entry(hass, entry): + """Set up Ambiclimate from a config entry.""" + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, "climate") + ) + + return True diff --git a/homeassistant/components/ambiclimate/climate.py b/homeassistant/components/ambiclimate/climate.py new file mode 100644 index 000000000..a8ed16690 --- /dev/null +++ b/homeassistant/components/ambiclimate/climate.py @@ -0,0 +1,240 @@ +"""Support for Ambiclimate ac.""" +import asyncio +import logging + +import ambiclimate +import voluptuous as vol + +from homeassistant.components.climate import ClimateDevice +from homeassistant.components.climate.const import ( + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.const import ATTR_NAME, ATTR_TEMPERATURE, TEMP_CELSIUS +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import ( + ATTR_VALUE, + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + DOMAIN, + SERVICE_COMFORT_FEEDBACK, + SERVICE_COMFORT_MODE, + SERVICE_TEMPERATURE_MODE, + STORAGE_KEY, + STORAGE_VERSION, +) + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE + +SEND_COMFORT_FEEDBACK_SCHEMA = vol.Schema( + {vol.Required(ATTR_NAME): cv.string, vol.Required(ATTR_VALUE): cv.string} +) + +SET_COMFORT_MODE_SCHEMA = vol.Schema({vol.Required(ATTR_NAME): cv.string}) + +SET_TEMPERATURE_MODE_SCHEMA = vol.Schema( + {vol.Required(ATTR_NAME): cv.string, vol.Required(ATTR_VALUE): cv.string} +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the Ambicliamte device.""" + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up the Ambicliamte device from config entry.""" + config = entry.data + websession = async_get_clientsession(hass) + store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + token_info = await store.async_load() + + oauth = ambiclimate.AmbiclimateOAuth( + config[CONF_CLIENT_ID], + config[CONF_CLIENT_SECRET], + config["callback_url"], + websession, + ) + + try: + token_info = await oauth.refresh_access_token(token_info) + except ambiclimate.AmbiclimateOauthError: + token_info = None + + if not token_info: + _LOGGER.error("Failed to refresh access token") + return + + await store.async_save(token_info) + + data_connection = ambiclimate.AmbiclimateConnection( + oauth, token_info=token_info, websession=websession + ) + + if not await data_connection.find_devices(): + _LOGGER.error("No devices found") + return + + tasks = [] + for heater in data_connection.get_devices(): + tasks.append(heater.update_device_info()) + await asyncio.wait(tasks) + + devs = [] + for heater in data_connection.get_devices(): + devs.append(AmbiclimateEntity(heater, store)) + + async_add_entities(devs, True) + + async def send_comfort_feedback(service): + """Send comfort feedback.""" + device_name = service.data[ATTR_NAME] + device = data_connection.find_device_by_room_name(device_name) + if device: + await device.set_comfort_feedback(service.data[ATTR_VALUE]) + + hass.services.async_register( + DOMAIN, + SERVICE_COMFORT_FEEDBACK, + send_comfort_feedback, + schema=SEND_COMFORT_FEEDBACK_SCHEMA, + ) + + async def set_comfort_mode(service): + """Set comfort mode.""" + device_name = service.data[ATTR_NAME] + device = data_connection.find_device_by_room_name(device_name) + if device: + await device.set_comfort_mode() + + hass.services.async_register( + DOMAIN, SERVICE_COMFORT_MODE, set_comfort_mode, schema=SET_COMFORT_MODE_SCHEMA + ) + + async def set_temperature_mode(service): + """Set temperature mode.""" + device_name = service.data[ATTR_NAME] + device = data_connection.find_device_by_room_name(device_name) + if device: + await device.set_temperature_mode(service.data[ATTR_VALUE]) + + hass.services.async_register( + DOMAIN, + SERVICE_TEMPERATURE_MODE, + set_temperature_mode, + schema=SET_TEMPERATURE_MODE_SCHEMA, + ) + + +class AmbiclimateEntity(ClimateDevice): + """Representation of a Ambiclimate Thermostat device.""" + + def __init__(self, heater, store): + """Initialize the thermostat.""" + self._heater = heater + self._store = store + self._data = {} + + @property + def unique_id(self): + """Return a unique ID.""" + return self._heater.device_id + + @property + def name(self): + """Return the name of the entity.""" + return self._heater.name + + @property + def device_info(self): + """Return the device info.""" + return { + "identifiers": {(DOMAIN, self.unique_id)}, + "name": self.name, + "manufacturer": "Ambiclimate", + } + + @property + def temperature_unit(self): + """Return the unit of measurement which this thermostat uses.""" + return TEMP_CELSIUS + + @property + def target_temperature(self): + """Return the target temperature.""" + return self._data.get("target_temperature") + + @property + def target_temperature_step(self): + """Return the supported step of target temperature.""" + return 1 + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._data.get("temperature") + + @property + def current_humidity(self): + """Return the current humidity.""" + return self._data.get("humidity") + + @property + def min_temp(self): + """Return the minimum temperature.""" + return self._heater.get_min_temp() + + @property + def max_temp(self): + """Return the maximum temperature.""" + return self._heater.get_max_temp() + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS + + @property + def hvac_modes(self): + """Return the list of available hvac operation modes.""" + return [HVAC_MODE_HEAT, HVAC_MODE_OFF] + + @property + def hvac_mode(self): + """Return current operation.""" + if self._data.get("power", "").lower() == "on": + return HVAC_MODE_HEAT + + return HVAC_MODE_OFF + + async def async_set_temperature(self, **kwargs): + """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return + await self._heater.set_target_temperature(temperature) + + async def async_set_hvac_mode(self, hvac_mode): + """Set new target hvac mode.""" + if hvac_mode == HVAC_MODE_HEAT: + await self._heater.turn_on() + return + if hvac_mode == HVAC_MODE_OFF: + await self._heater.turn_off() + + async def async_update(self): + """Retrieve latest state.""" + try: + token_info = await self._heater.control.refresh_access_token() + except ambiclimate.AmbiclimateOauthError: + _LOGGER.error("Failed to refresh access token") + return + + if token_info: + await self._store.async_save(token_info) + + self._data = await self._heater.update_device() diff --git a/homeassistant/components/ambiclimate/config_flow.py b/homeassistant/components/ambiclimate/config_flow.py new file mode 100644 index 000000000..4996a458a --- /dev/null +++ b/homeassistant/components/ambiclimate/config_flow.py @@ -0,0 +1,159 @@ +"""Config flow for Ambiclimate.""" +import logging + +import ambiclimate + +from homeassistant import config_entries +from homeassistant.components.http import HomeAssistantView +from homeassistant.core import callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import ( + AUTH_CALLBACK_NAME, + AUTH_CALLBACK_PATH, + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + DOMAIN, + STORAGE_KEY, + STORAGE_VERSION, +) + +DATA_AMBICLIMATE_IMPL = "ambiclimate_flow_implementation" + +_LOGGER = logging.getLogger(__name__) + + +@callback +def register_flow_implementation(hass, client_id, client_secret): + """Register a ambiclimate implementation. + + client_id: Client id. + client_secret: Client secret. + """ + hass.data.setdefault(DATA_AMBICLIMATE_IMPL, {}) + + hass.data[DATA_AMBICLIMATE_IMPL] = { + CONF_CLIENT_ID: client_id, + CONF_CLIENT_SECRET: client_secret, + } + + +@config_entries.HANDLERS.register("ambiclimate") +class AmbiclimateFlowHandler(config_entries.ConfigFlow): + """Handle a config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Initialize flow.""" + self._registered_view = False + self._oauth = None + + async def async_step_user(self, user_input=None): + """Handle external yaml configuration.""" + if self.hass.config_entries.async_entries(DOMAIN): + return self.async_abort(reason="already_setup") + + config = self.hass.data.get(DATA_AMBICLIMATE_IMPL, {}) + + if not config: + _LOGGER.debug("No config") + return self.async_abort(reason="no_config") + + return await self.async_step_auth() + + async def async_step_auth(self, user_input=None): + """Handle a flow start.""" + if self.hass.config_entries.async_entries(DOMAIN): + return self.async_abort(reason="already_setup") + + errors = {} + + if user_input is not None: + errors["base"] = "follow_link" + + if not self._registered_view: + self._generate_view() + + return self.async_show_form( + step_id="auth", + description_placeholders={ + "authorization_url": await self._get_authorize_url(), + "cb_url": self._cb_url(), + }, + errors=errors, + ) + + async def async_step_code(self, code=None): + """Received code for authentication.""" + if self.hass.config_entries.async_entries(DOMAIN): + return self.async_abort(reason="already_setup") + + token_info = await self._get_token_info(code) + + if token_info is None: + return self.async_abort(reason="access_token") + + config = self.hass.data[DATA_AMBICLIMATE_IMPL].copy() + config["callback_url"] = self._cb_url() + + return self.async_create_entry(title="Ambiclimate", data=config) + + async def _get_token_info(self, code): + oauth = self._generate_oauth() + try: + token_info = await oauth.get_access_token(code) + except ambiclimate.AmbiclimateOauthError: + _LOGGER.error("Failed to get access token", exc_info=True) + return None + + store = self.hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + await store.async_save(token_info) + + return token_info + + def _generate_view(self): + self.hass.http.register_view(AmbiclimateAuthCallbackView()) + self._registered_view = True + + def _generate_oauth(self): + config = self.hass.data[DATA_AMBICLIMATE_IMPL] + clientsession = async_get_clientsession(self.hass) + callback_url = self._cb_url() + + oauth = ambiclimate.AmbiclimateOAuth( + config.get(CONF_CLIENT_ID), + config.get(CONF_CLIENT_SECRET), + callback_url, + clientsession, + ) + return oauth + + def _cb_url(self): + return f"{self.hass.config.api.base_url}{AUTH_CALLBACK_PATH}" + + async def _get_authorize_url(self): + oauth = self._generate_oauth() + return oauth.get_authorize_url() + + +class AmbiclimateAuthCallbackView(HomeAssistantView): + """Ambiclimate Authorization Callback View.""" + + requires_auth = False + url = AUTH_CALLBACK_PATH + name = AUTH_CALLBACK_NAME + + async def get(self, request): + """Receive authorization token.""" + code = request.query.get("code") + if code is None: + return "No code" + hass = request.app["hass"] + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": "code"}, data=code + ) + ) + return "OK!" diff --git a/homeassistant/components/ambiclimate/const.py b/homeassistant/components/ambiclimate/const.py new file mode 100644 index 000000000..833fef303 --- /dev/null +++ b/homeassistant/components/ambiclimate/const.py @@ -0,0 +1,14 @@ +"""Constants used by the Ambiclimate component.""" + +ATTR_VALUE = "value" +CONF_CLIENT_ID = "client_id" +CONF_CLIENT_SECRET = "client_secret" +DOMAIN = "ambiclimate" +SERVICE_COMFORT_FEEDBACK = "send_comfort_feedback" +SERVICE_COMFORT_MODE = "set_comfort_mode" +SERVICE_TEMPERATURE_MODE = "set_temperature_mode" +STORAGE_KEY = "ambiclimate_auth" +STORAGE_VERSION = 1 + +AUTH_CALLBACK_NAME = "api:ambiclimate" +AUTH_CALLBACK_PATH = "/api/ambiclimate" diff --git a/homeassistant/components/ambiclimate/manifest.json b/homeassistant/components/ambiclimate/manifest.json new file mode 100644 index 000000000..151b761df --- /dev/null +++ b/homeassistant/components/ambiclimate/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "ambiclimate", + "name": "Ambiclimate", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/ambiclimate", + "requirements": ["ambiclimate==0.2.1"], + "dependencies": ["http"], + "codeowners": ["@danielhiversen"] +} diff --git a/homeassistant/components/ambiclimate/services.yaml b/homeassistant/components/ambiclimate/services.yaml new file mode 100644 index 000000000..19f47c6c3 --- /dev/null +++ b/homeassistant/components/ambiclimate/services.yaml @@ -0,0 +1,36 @@ +# Describes the format for available services for ambiclimate + +set_comfort_mode: + description: > + Enable comfort mode on your AC + fields: + Name: + description: > + String with device name. + example: Bedroom + +send_comfort_feedback: + description: > + Send feedback for comfort mode + fields: + Name: + description: > + String with device name. + example: Bedroom + Value: + description: > + Send any of the following comfort values: too_hot, too_warm, bit_warm, comfortable, bit_cold, too_cold, freezing + example: bit_warm + +set_temperature_mode: + description: > + Enable temperature mode on your AC + fields: + Name: + description: > + String with device name. + example: Bedroom + Value: + description: > + Target value in celsius + example: 22 diff --git a/homeassistant/components/ambiclimate/strings.json b/homeassistant/components/ambiclimate/strings.json new file mode 100644 index 000000000..78386077a --- /dev/null +++ b/homeassistant/components/ambiclimate/strings.json @@ -0,0 +1,23 @@ +{ + "config": { + "title": "Ambiclimate", + "step": { + "auth": { + "title": "Authenticate Ambiclimate", + "description": "Please follow this [link]({authorization_url}) and Allow access to your Ambiclimate account, then come back and press Submit below.\n(Make sure the specified callback url is {cb_url})" + } + }, + "create_entry": { + "default": "Successfully authenticated with Ambiclimate" + }, + "error": { + "no_token": "Not authenticated with Ambiclimate", + "follow_link": "Please follow the link and authenticate before pressing Submit" + }, + "abort": { + "already_setup": "The Ambiclimate account is configured.", + "no_config": "You need to configure Ambiclimate before being able to authenticate with it. [Please read the instructions](https://www.home-assistant.io/components/ambiclimate/).", + "access_token": "Unknown error generating an access token." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambient_station/.translations/bg.json b/homeassistant/components/ambient_station/.translations/bg.json new file mode 100644 index 000000000..2099038f0 --- /dev/null +++ b/homeassistant/components/ambient_station/.translations/bg.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Application \u0438/\u0438\u043b\u0438 API \u043a\u043b\u044e\u0447\u044a\u0442 \u0432\u0435\u0447\u0435 \u0441\u0430 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0430\u043d\u0438", + "invalid_key": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u0435\u043d API \u043a\u043b\u044e\u0447 \u0438/\u0438\u043b\u0438 Application \u043a\u043b\u044e\u0447", + "no_devices": "\u041d\u0435 \u0441\u0430 \u043d\u0430\u043c\u0435\u0440\u0435\u043d\u0438 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0432 \u043f\u0440\u043e\u0444\u0438\u043b\u0430" + }, + "step": { + "user": { + "data": { + "api_key": "API \u043a\u043b\u044e\u0447", + "app_key": "Application \u043a\u043b\u044e\u0447" + }, + "title": "\u041f\u043e\u043f\u044a\u043b\u043d\u0435\u0442\u0435 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f\u0442\u0430 \u0441\u0438" + } + }, + "title": "\u0410\u0442\u043c\u043e\u0441\u0444\u0435\u0440\u043d\u0430 PWS" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambient_station/.translations/ca.json b/homeassistant/components/ambient_station/.translations/ca.json new file mode 100644 index 000000000..d3c451f3e --- /dev/null +++ b/homeassistant/components/ambient_station/.translations/ca.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Clau d'aplicaci\u00f3 i/o clau API ja registrada", + "invalid_key": "Clau API i/o clau d'aplicaci\u00f3 inv\u00e0lida/es", + "no_devices": "No s'ha trobat cap dispositiu al compte" + }, + "step": { + "user": { + "data": { + "api_key": "Clau API", + "app_key": "Clau d'aplicaci\u00f3" + }, + "title": "Introdueix la teva informaci\u00f3" + } + }, + "title": "Ambient PWS" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambient_station/.translations/da.json b/homeassistant/components/ambient_station/.translations/da.json new file mode 100644 index 000000000..ac3d86a99 --- /dev/null +++ b/homeassistant/components/ambient_station/.translations/da.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Applikationsn\u00f8gle og/eller API n\u00f8gle er allerede registreret", + "invalid_key": "Ugyldig API n\u00f8gle og/eller applikationsn\u00f8gle", + "no_devices": "Ingen enheder fundet i konto" + }, + "step": { + "user": { + "data": { + "api_key": "API n\u00f8gle", + "app_key": "Applikationsn\u00f8gle" + }, + "title": "Udfyld dine oplysninger" + } + }, + "title": "Ambient PWS" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambient_station/.translations/de.json b/homeassistant/components/ambient_station/.translations/de.json new file mode 100644 index 000000000..1431efbf1 --- /dev/null +++ b/homeassistant/components/ambient_station/.translations/de.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Anwendungsschl\u00fcssel und / oder API-Schl\u00fcssel bereits registriert", + "invalid_key": "Ung\u00fcltiger API Key und / oder Anwendungsschl\u00fcssel", + "no_devices": "Keine Ger\u00e4te im Konto gefunden" + }, + "step": { + "user": { + "data": { + "api_key": "API Key", + "app_key": "Anwendungsschl\u00fcssel" + }, + "title": "Gib deine Informationen ein" + } + }, + "title": "Ambient PWS" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambient_station/.translations/en.json b/homeassistant/components/ambient_station/.translations/en.json new file mode 100644 index 000000000..5bd643da5 --- /dev/null +++ b/homeassistant/components/ambient_station/.translations/en.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Application Key and/or API Key already registered", + "invalid_key": "Invalid API Key and/or Application Key", + "no_devices": "No devices found in account" + }, + "step": { + "user": { + "data": { + "api_key": "API Key", + "app_key": "Application Key" + }, + "title": "Fill in your information" + } + }, + "title": "Ambient PWS" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambient_station/.translations/es-419.json b/homeassistant/components/ambient_station/.translations/es-419.json new file mode 100644 index 000000000..268a6ba00 --- /dev/null +++ b/homeassistant/components/ambient_station/.translations/es-419.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Clave de aplicaci\u00f3n y/o clave de API ya registrada", + "invalid_key": "Clave de API y/o clave de aplicaci\u00f3n no v\u00e1lida", + "no_devices": "No se han encontrado dispositivos en la cuenta." + }, + "step": { + "user": { + "data": { + "api_key": "Clave API", + "app_key": "Clave de aplicaci\u00f3n" + }, + "title": "Completa tu informaci\u00f3n" + } + }, + "title": "Ambient PWS" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambient_station/.translations/es.json b/homeassistant/components/ambient_station/.translations/es.json new file mode 100644 index 000000000..d4222f1d2 --- /dev/null +++ b/homeassistant/components/ambient_station/.translations/es.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "La clave API y/o la clave de aplicaci\u00f3n ya est\u00e1 registrada", + "invalid_key": "Clave API y/o clave de aplicaci\u00f3n no v\u00e1lida", + "no_devices": "No se han encontrado dispositivos en la cuenta" + }, + "step": { + "user": { + "data": { + "api_key": "Clave API", + "app_key": "Clave de aplicaci\u00f3n" + }, + "title": "Completa tu informaci\u00f3n" + } + }, + "title": "Ambiente PWS" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambient_station/.translations/fr.json b/homeassistant/components/ambient_station/.translations/fr.json new file mode 100644 index 000000000..b28cb374e --- /dev/null +++ b/homeassistant/components/ambient_station/.translations/fr.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Cl\u00e9 d'application et / ou cl\u00e9 API d\u00e9j\u00e0 enregistr\u00e9e", + "invalid_key": "Cl\u00e9 d'API et / ou cl\u00e9 d'application non valide", + "no_devices": "Aucun appareil trouv\u00e9 dans le compte" + }, + "step": { + "user": { + "data": { + "api_key": "Cl\u00e9 d'API", + "app_key": "Cl\u00e9 d'application" + }, + "title": "Veuillez saisir vos informations" + } + }, + "title": "Ambient PWS" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambient_station/.translations/he.json b/homeassistant/components/ambient_station/.translations/he.json new file mode 100644 index 000000000..f5afbca71 --- /dev/null +++ b/homeassistant/components/ambient_station/.translations/he.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "no_devices": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05d4\u05ea\u05e7\u05df \u05d1\u05d7\u05e9\u05d1\u05d5\u05df" + }, + "step": { + "user": { + "data": { + "api_key": "\u05de\u05e4\u05ea\u05d7 API" + }, + "title": "\u05de\u05dc\u05d0 \u05d0\u05ea \u05d4\u05e4\u05e8\u05d8\u05d9\u05dd \u05e9\u05dc\u05da" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambient_station/.translations/hu.json b/homeassistant/components/ambient_station/.translations/hu.json new file mode 100644 index 000000000..222b512c3 --- /dev/null +++ b/homeassistant/components/ambient_station/.translations/hu.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Alkalmaz\u00e1s kulcsot \u00e9s/vagy az API kulcsot m\u00e1r regisztr\u00e1lt\u00e1k", + "invalid_key": "\u00c9rv\u00e9nytelen API kulcs \u00e9s / vagy alkalmaz\u00e1skulcs", + "no_devices": "Nincs a fi\u00f3kodban tal\u00e1lhat\u00f3 eszk\u00f6z" + }, + "step": { + "user": { + "data": { + "api_key": "API kulcs", + "app_key": "Alkalmaz\u00e1skulcs" + }, + "title": "T\u00f6ltsd ki az adataid" + } + }, + "title": "Ambient PWS" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambient_station/.translations/it.json b/homeassistant/components/ambient_station/.translations/it.json new file mode 100644 index 000000000..b468ba367 --- /dev/null +++ b/homeassistant/components/ambient_station/.translations/it.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "API Key e/o Application Key gi\u00e0 registrata", + "invalid_key": "API Key e/o Application Key non valida", + "no_devices": "Nessun dispositivo trovato nell'account" + }, + "step": { + "user": { + "data": { + "api_key": "API Key", + "app_key": "Application Key" + }, + "title": "Inserisci i tuoi dati" + } + }, + "title": "PWS ambientale" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambient_station/.translations/ko.json b/homeassistant/components/ambient_station/.translations/ko.json new file mode 100644 index 000000000..541b8699d --- /dev/null +++ b/homeassistant/components/ambient_station/.translations/ko.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Application \ud0a4 \ud639\uc740 API \ud0a4\uac00 \uc774\ubbf8 \ub4f1\ub85d\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "invalid_key": "Application \ud0a4 \ud639\uc740 API \ud0a4\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "no_devices": "\uacc4\uc815\uc5d0 \uae30\uae30\uac00 \uc874\uc7ac\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "api_key": "API \ud0a4", + "app_key": "Application \ud0a4" + }, + "title": "\uc0ac\uc6a9\uc790 \uc815\ubcf4 \uc785\ub825" + } + }, + "title": "Ambient PWS" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambient_station/.translations/lb.json b/homeassistant/components/ambient_station/.translations/lb.json new file mode 100644 index 000000000..0f0d60d44 --- /dev/null +++ b/homeassistant/components/ambient_station/.translations/lb.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Applikatioun's Schl\u00ebssel an/oder API Schl\u00ebssel ass scho registr\u00e9iert", + "invalid_key": "Ong\u00ebltegen API Schl\u00ebssel an/oder Applikatioun's Schl\u00ebssel", + "no_devices": "Keng Apparater am Kont fonnt" + }, + "step": { + "user": { + "data": { + "api_key": "API Schl\u00ebssel", + "app_key": "Applikatioun's Schl\u00ebssel" + }, + "title": "F\u00ebllt \u00e4r Informatiounen aus" + } + }, + "title": "Ambient PWS" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambient_station/.translations/nl.json b/homeassistant/components/ambient_station/.translations/nl.json new file mode 100644 index 000000000..a070128ee --- /dev/null +++ b/homeassistant/components/ambient_station/.translations/nl.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Applicatiesleutel en/of API-sleutel al geregistreerd", + "invalid_key": "Ongeldige API-sleutel en/of applicatiesleutel", + "no_devices": "Geen apparaten gevonden in account" + }, + "step": { + "user": { + "data": { + "api_key": "API-sleutel", + "app_key": "Applicatiesleutel" + }, + "title": "Vul uw gegevens in" + } + }, + "title": "Ambient PWS" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambient_station/.translations/nn.json b/homeassistant/components/ambient_station/.translations/nn.json new file mode 100644 index 000000000..0f878b363 --- /dev/null +++ b/homeassistant/components/ambient_station/.translations/nn.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Ambient PWS" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambient_station/.translations/no.json b/homeassistant/components/ambient_station/.translations/no.json new file mode 100644 index 000000000..0b9d37771 --- /dev/null +++ b/homeassistant/components/ambient_station/.translations/no.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Programn\u00f8kkel og/eller API-n\u00f8kkel er allerede registrert", + "invalid_key": "Ugyldig API-n\u00f8kkel og/eller programn\u00f8kkel", + "no_devices": "Ingen enheter funnet i kontoen" + }, + "step": { + "user": { + "data": { + "api_key": "API-n\u00f8kkel", + "app_key": "Applikasjonsn\u00f8kkel" + }, + "title": "Fyll ut informasjonen din" + } + }, + "title": "Ambient PWS" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambient_station/.translations/pl.json b/homeassistant/components/ambient_station/.translations/pl.json new file mode 100644 index 000000000..6ebd0848a --- /dev/null +++ b/homeassistant/components/ambient_station/.translations/pl.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Klucz aplikacji i/lub klucz API ju\u017c jest zarejestrowany", + "invalid_key": "Nieprawid\u0142owy klucz API i/lub klucz aplikacji", + "no_devices": "Nie znaleziono urz\u0105dze\u0144 na koncie" + }, + "step": { + "user": { + "data": { + "api_key": "Klucz API", + "app_key": "Klucz aplikacji" + }, + "title": "Wprowad\u017a dane" + } + }, + "title": "Ambient PWS" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambient_station/.translations/pt-BR.json b/homeassistant/components/ambient_station/.translations/pt-BR.json new file mode 100644 index 000000000..61f5cea5e --- /dev/null +++ b/homeassistant/components/ambient_station/.translations/pt-BR.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Chave de aplicativo e / ou chave de API j\u00e1 registrada", + "invalid_key": "Chave de API e / ou chave de aplicativo inv\u00e1lidas", + "no_devices": "Nenhum dispositivo encontrado na conta" + }, + "step": { + "user": { + "data": { + "api_key": "Chave API", + "app_key": "Chave de aplicativo" + }, + "title": "Preencha suas informa\u00e7\u00f5es" + } + }, + "title": "Ambiente PWS" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambient_station/.translations/pt.json b/homeassistant/components/ambient_station/.translations/pt.json new file mode 100644 index 000000000..92746b29f --- /dev/null +++ b/homeassistant/components/ambient_station/.translations/pt.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Chave de aplica\u00e7\u00e3o e/ou chave de API j\u00e1 registradas.", + "invalid_key": "Chave de API e/ou chave de aplica\u00e7\u00e3o inv\u00e1lidas", + "no_devices": "Nenhum dispositivo encontrado na conta" + }, + "step": { + "user": { + "data": { + "api_key": "Chave de API", + "app_key": "Chave de aplica\u00e7\u00e3o" + }, + "title": "Preencha as suas informa\u00e7\u00f5es" + } + }, + "title": "Ambient PWS" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambient_station/.translations/ru.json b/homeassistant/components/ambient_station/.translations/ru.json new file mode 100644 index 000000000..438b1cf87 --- /dev/null +++ b/homeassistant/components/ambient_station/.translations/ru.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "\u041a\u043b\u044e\u0447 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0438/\u0438\u043b\u0438 \u043a\u043b\u044e\u0447 API \u0443\u0436\u0435 \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d.", + "invalid_key": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043a\u043b\u044e\u0447 API \u0438/\u0438\u043b\u0438 \u043a\u043b\u044e\u0447 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f.", + "no_devices": "\u0412 \u0443\u0447\u0451\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b." + }, + "step": { + "user": { + "data": { + "api_key": "\u041a\u043b\u044e\u0447 API", + "app_key": "\u041a\u043b\u044e\u0447 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f" + }, + "title": "Ambient PWS" + } + }, + "title": "Ambient PWS" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambient_station/.translations/sl.json b/homeassistant/components/ambient_station/.translations/sl.json new file mode 100644 index 000000000..906a6b404 --- /dev/null +++ b/homeassistant/components/ambient_station/.translations/sl.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Aplikacijski klju\u010d in / ali klju\u010d API je \u017ee registriran", + "invalid_key": "Neveljaven klju\u010d API in / ali klju\u010d aplikacije", + "no_devices": "V ra\u010dunu ni najdene nobene naprave" + }, + "step": { + "user": { + "data": { + "api_key": "API Klju\u010d", + "app_key": "Klju\u010d aplikacije" + }, + "title": "Izpolnite svoje podatke" + } + }, + "title": "Ambient PWS" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambient_station/.translations/sv.json b/homeassistant/components/ambient_station/.translations/sv.json new file mode 100644 index 000000000..c429d4395 --- /dev/null +++ b/homeassistant/components/ambient_station/.translations/sv.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Applikationsnyckel och/eller API-nyckel \u00e4r redan registrerade", + "invalid_key": "Ogiltigt API-nyckel och/eller applikationsnyckel", + "no_devices": "Inga enheter hittades i kontot" + }, + "step": { + "user": { + "data": { + "api_key": "API-nyckel", + "app_key": "Applikationsnyckel" + }, + "title": "Fyll i dina uppgifter" + } + }, + "title": "Ambient Weather PWS (Personal Weather Station)" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambient_station/.translations/th.json b/homeassistant/components/ambient_station/.translations/th.json new file mode 100644 index 000000000..a6115413e --- /dev/null +++ b/homeassistant/components/ambient_station/.translations/th.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "no_devices": "\u0e44\u0e21\u0e48\u0e1e\u0e1a\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e43\u0e14\u0e46 \u0e43\u0e19\u0e1a\u0e31\u0e0d\u0e0a\u0e35\u0e40\u0e25\u0e22" + }, + "step": { + "user": { + "data": { + "api_key": "\u0e04\u0e35\u0e22\u0e4c API", + "app_key": "\u0e23\u0e2b\u0e31\u0e2a\u0e41\u0e2d\u0e1b\u0e1e\u0e25\u0e34\u0e40\u0e04\u0e0a\u0e31\u0e19" + }, + "title": "\u0e01\u0e23\u0e2d\u0e01\u0e02\u0e49\u0e2d\u0e21\u0e39\u0e25\u0e02\u0e2d\u0e07\u0e04\u0e38\u0e13" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambient_station/.translations/zh-Hans.json b/homeassistant/components/ambient_station/.translations/zh-Hans.json new file mode 100644 index 000000000..866c06316 --- /dev/null +++ b/homeassistant/components/ambient_station/.translations/zh-Hans.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Application Key \u548c/\u6216 API Key \u5df2\u6ce8\u518c", + "invalid_key": "\u65e0\u6548\u7684 API \u5bc6\u94a5\u548c/\u6216 Application Key", + "no_devices": "\u6ca1\u6709\u5728\u5e10\u6237\u4e2d\u627e\u5230\u8bbe\u5907" + }, + "step": { + "user": { + "data": { + "api_key": "API Key", + "app_key": "Application Key" + }, + "title": "\u586b\u5199\u60a8\u7684\u4fe1\u606f" + } + }, + "title": "Ambient PWS" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambient_station/.translations/zh-Hant.json b/homeassistant/components/ambient_station/.translations/zh-Hant.json new file mode 100644 index 000000000..6c7c88a80 --- /dev/null +++ b/homeassistant/components/ambient_station/.translations/zh-Hant.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "API \u5bc6\u9470\u53ca/\u6216\u61c9\u7528\u5bc6\u9470\u5df2\u8a3b\u518a", + "invalid_key": "API \u5bc6\u9470\u53ca/\u6216\u61c9\u7528\u5bc6\u9470\u7121\u6548", + "no_devices": "\u5e33\u865f\u4e2d\u627e\u4e0d\u5230\u4efb\u4f55\u8a2d\u5099" + }, + "step": { + "user": { + "data": { + "api_key": "API \u5bc6\u9470", + "app_key": "\u61c9\u7528\u5bc6\u9470" + }, + "title": "\u586b\u5beb\u8cc7\u8a0a" + } + }, + "title": "\u74b0\u5883 PWS" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py new file mode 100644 index 000000000..58389dd18 --- /dev/null +++ b/homeassistant/components/ambient_station/__init__.py @@ -0,0 +1,527 @@ +"""Support for Ambient Weather Station Service.""" +import asyncio +import logging + +from aioambient import Client +from aioambient.errors import WebsocketError +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import ( + ATTR_LOCATION, + ATTR_NAME, + CONF_API_KEY, + EVENT_HOMEASSISTANT_STOP, +) +from homeassistant.core import callback +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_call_later + +from .config_flow import configured_instances +from .const import ( + ATTR_LAST_DATA, + CONF_APP_KEY, + DATA_CLIENT, + DOMAIN, + TOPIC_UPDATE, + TYPE_BINARY_SENSOR, + TYPE_SENSOR, +) + +_LOGGER = logging.getLogger(__name__) + +DATA_CONFIG = "config" + +DEFAULT_SOCKET_MIN_RETRY = 15 +DEFAULT_WATCHDOG_SECONDS = 5 * 60 + +TYPE_24HOURRAININ = "24hourrainin" +TYPE_BAROMABSIN = "baromabsin" +TYPE_BAROMRELIN = "baromrelin" +TYPE_BATT1 = "batt1" +TYPE_BATT10 = "batt10" +TYPE_BATT2 = "batt2" +TYPE_BATT3 = "batt3" +TYPE_BATT4 = "batt4" +TYPE_BATT5 = "batt5" +TYPE_BATT6 = "batt6" +TYPE_BATT7 = "batt7" +TYPE_BATT8 = "batt8" +TYPE_BATT9 = "batt9" +TYPE_BATTOUT = "battout" +TYPE_CO2 = "co2" +TYPE_DAILYRAININ = "dailyrainin" +TYPE_DEWPOINT = "dewPoint" +TYPE_EVENTRAININ = "eventrainin" +TYPE_FEELSLIKE = "feelsLike" +TYPE_HOURLYRAININ = "hourlyrainin" +TYPE_HUMIDITY = "humidity" +TYPE_HUMIDITY1 = "humidity1" +TYPE_HUMIDITY10 = "humidity10" +TYPE_HUMIDITY2 = "humidity2" +TYPE_HUMIDITY3 = "humidity3" +TYPE_HUMIDITY4 = "humidity4" +TYPE_HUMIDITY5 = "humidity5" +TYPE_HUMIDITY6 = "humidity6" +TYPE_HUMIDITY7 = "humidity7" +TYPE_HUMIDITY8 = "humidity8" +TYPE_HUMIDITY9 = "humidity9" +TYPE_HUMIDITYIN = "humidityin" +TYPE_LASTRAIN = "lastRain" +TYPE_MAXDAILYGUST = "maxdailygust" +TYPE_MONTHLYRAININ = "monthlyrainin" +TYPE_RELAY1 = "relay1" +TYPE_RELAY10 = "relay10" +TYPE_RELAY2 = "relay2" +TYPE_RELAY3 = "relay3" +TYPE_RELAY4 = "relay4" +TYPE_RELAY5 = "relay5" +TYPE_RELAY6 = "relay6" +TYPE_RELAY7 = "relay7" +TYPE_RELAY8 = "relay8" +TYPE_RELAY9 = "relay9" +TYPE_SOILHUM1 = "soilhum1" +TYPE_SOILHUM10 = "soilhum10" +TYPE_SOILHUM2 = "soilhum2" +TYPE_SOILHUM3 = "soilhum3" +TYPE_SOILHUM4 = "soilhum4" +TYPE_SOILHUM5 = "soilhum5" +TYPE_SOILHUM6 = "soilhum6" +TYPE_SOILHUM7 = "soilhum7" +TYPE_SOILHUM8 = "soilhum8" +TYPE_SOILHUM9 = "soilhum9" +TYPE_SOILTEMP1F = "soiltemp1f" +TYPE_SOILTEMP10F = "soiltemp10f" +TYPE_SOILTEMP2F = "soiltemp2f" +TYPE_SOILTEMP3F = "soiltemp3f" +TYPE_SOILTEMP4F = "soiltemp4f" +TYPE_SOILTEMP5F = "soiltemp5f" +TYPE_SOILTEMP6F = "soiltemp6f" +TYPE_SOILTEMP7F = "soiltemp7f" +TYPE_SOILTEMP8F = "soiltemp8f" +TYPE_SOILTEMP9F = "soiltemp9f" +TYPE_SOLARRADIATION = "solarradiation" +TYPE_SOLARRADIATION_LX = "solarradiation_lx" +TYPE_TEMP10F = "temp10f" +TYPE_TEMP1F = "temp1f" +TYPE_TEMP2F = "temp2f" +TYPE_TEMP3F = "temp3f" +TYPE_TEMP4F = "temp4f" +TYPE_TEMP5F = "temp5f" +TYPE_TEMP6F = "temp6f" +TYPE_TEMP7F = "temp7f" +TYPE_TEMP8F = "temp8f" +TYPE_TEMP9F = "temp9f" +TYPE_TEMPF = "tempf" +TYPE_TEMPINF = "tempinf" +TYPE_TOTALRAININ = "totalrainin" +TYPE_UV = "uv" +TYPE_WEEKLYRAININ = "weeklyrainin" +TYPE_WINDDIR = "winddir" +TYPE_WINDDIR_AVG10M = "winddir_avg10m" +TYPE_WINDDIR_AVG2M = "winddir_avg2m" +TYPE_WINDGUSTDIR = "windgustdir" +TYPE_WINDGUSTMPH = "windgustmph" +TYPE_WINDSPDMPH_AVG10M = "windspdmph_avg10m" +TYPE_WINDSPDMPH_AVG2M = "windspdmph_avg2m" +TYPE_WINDSPEEDMPH = "windspeedmph" +TYPE_YEARLYRAININ = "yearlyrainin" +SENSOR_TYPES = { + TYPE_24HOURRAININ: ("24 Hr Rain", "in", TYPE_SENSOR, None), + TYPE_BAROMABSIN: ("Abs Pressure", "inHg", TYPE_SENSOR, "pressure"), + TYPE_BAROMRELIN: ("Rel Pressure", "inHg", TYPE_SENSOR, "pressure"), + TYPE_BATT10: ("Battery 10", None, TYPE_BINARY_SENSOR, "battery"), + TYPE_BATT1: ("Battery 1", None, TYPE_BINARY_SENSOR, "battery"), + TYPE_BATT2: ("Battery 2", None, TYPE_BINARY_SENSOR, "battery"), + TYPE_BATT3: ("Battery 3", None, TYPE_BINARY_SENSOR, "battery"), + TYPE_BATT4: ("Battery 4", None, TYPE_BINARY_SENSOR, "battery"), + TYPE_BATT5: ("Battery 5", None, TYPE_BINARY_SENSOR, "battery"), + TYPE_BATT6: ("Battery 6", None, TYPE_BINARY_SENSOR, "battery"), + TYPE_BATT7: ("Battery 7", None, TYPE_BINARY_SENSOR, "battery"), + TYPE_BATT8: ("Battery 8", None, TYPE_BINARY_SENSOR, "battery"), + TYPE_BATT9: ("Battery 9", None, TYPE_BINARY_SENSOR, "battery"), + TYPE_BATTOUT: ("Battery", None, TYPE_BINARY_SENSOR, "battery"), + TYPE_CO2: ("co2", "ppm", TYPE_SENSOR, None), + TYPE_DAILYRAININ: ("Daily Rain", "in", TYPE_SENSOR, None), + TYPE_DEWPOINT: ("Dew Point", "°F", TYPE_SENSOR, "temperature"), + TYPE_EVENTRAININ: ("Event Rain", "in", TYPE_SENSOR, None), + TYPE_FEELSLIKE: ("Feels Like", "°F", TYPE_SENSOR, "temperature"), + TYPE_HOURLYRAININ: ("Hourly Rain Rate", "in/hr", TYPE_SENSOR, None), + TYPE_HUMIDITY10: ("Humidity 10", "%", TYPE_SENSOR, "humidity"), + TYPE_HUMIDITY1: ("Humidity 1", "%", TYPE_SENSOR, "humidity"), + TYPE_HUMIDITY2: ("Humidity 2", "%", TYPE_SENSOR, "humidity"), + TYPE_HUMIDITY3: ("Humidity 3", "%", TYPE_SENSOR, "humidity"), + TYPE_HUMIDITY4: ("Humidity 4", "%", TYPE_SENSOR, "humidity"), + TYPE_HUMIDITY5: ("Humidity 5", "%", TYPE_SENSOR, "humidity"), + TYPE_HUMIDITY6: ("Humidity 6", "%", TYPE_SENSOR, "humidity"), + TYPE_HUMIDITY7: ("Humidity 7", "%", TYPE_SENSOR, "humidity"), + TYPE_HUMIDITY8: ("Humidity 8", "%", TYPE_SENSOR, "humidity"), + TYPE_HUMIDITY9: ("Humidity 9", "%", TYPE_SENSOR, "humidity"), + TYPE_HUMIDITY: ("Humidity", "%", TYPE_SENSOR, "humidity"), + TYPE_HUMIDITYIN: ("Humidity In", "%", TYPE_SENSOR, "humidity"), + TYPE_LASTRAIN: ("Last Rain", None, TYPE_SENSOR, "timestamp"), + TYPE_MAXDAILYGUST: ("Max Gust", "mph", TYPE_SENSOR, None), + TYPE_MONTHLYRAININ: ("Monthly Rain", "in", TYPE_SENSOR, None), + TYPE_RELAY10: ("Relay 10", None, TYPE_BINARY_SENSOR, "connectivity"), + TYPE_RELAY1: ("Relay 1", None, TYPE_BINARY_SENSOR, "connectivity"), + TYPE_RELAY2: ("Relay 2", None, TYPE_BINARY_SENSOR, "connectivity"), + TYPE_RELAY3: ("Relay 3", None, TYPE_BINARY_SENSOR, "connectivity"), + TYPE_RELAY4: ("Relay 4", None, TYPE_BINARY_SENSOR, "connectivity"), + TYPE_RELAY5: ("Relay 5", None, TYPE_BINARY_SENSOR, "connectivity"), + TYPE_RELAY6: ("Relay 6", None, TYPE_BINARY_SENSOR, "connectivity"), + TYPE_RELAY7: ("Relay 7", None, TYPE_BINARY_SENSOR, "connectivity"), + TYPE_RELAY8: ("Relay 8", None, TYPE_BINARY_SENSOR, "connectivity"), + TYPE_RELAY9: ("Relay 9", None, TYPE_BINARY_SENSOR, "connectivity"), + TYPE_SOILHUM10: ("Soil Humidity 10", "%", TYPE_SENSOR, "humidity"), + TYPE_SOILHUM1: ("Soil Humidity 1", "%", TYPE_SENSOR, "humidity"), + TYPE_SOILHUM2: ("Soil Humidity 2", "%", TYPE_SENSOR, "humidity"), + TYPE_SOILHUM3: ("Soil Humidity 3", "%", TYPE_SENSOR, "humidity"), + TYPE_SOILHUM4: ("Soil Humidity 4", "%", TYPE_SENSOR, "humidity"), + TYPE_SOILHUM5: ("Soil Humidity 5", "%", TYPE_SENSOR, "humidity"), + TYPE_SOILHUM6: ("Soil Humidity 6", "%", TYPE_SENSOR, "humidity"), + TYPE_SOILHUM7: ("Soil Humidity 7", "%", TYPE_SENSOR, "humidity"), + TYPE_SOILHUM8: ("Soil Humidity 8", "%", TYPE_SENSOR, "humidity"), + TYPE_SOILHUM9: ("Soil Humidity 9", "%", TYPE_SENSOR, "humidity"), + TYPE_SOILTEMP10F: ("Soil Temp 10", "°F", TYPE_SENSOR, "temperature"), + TYPE_SOILTEMP1F: ("Soil Temp 1", "°F", TYPE_SENSOR, "temperature"), + TYPE_SOILTEMP2F: ("Soil Temp 2", "°F", TYPE_SENSOR, "temperature"), + TYPE_SOILTEMP3F: ("Soil Temp 3", "°F", TYPE_SENSOR, "temperature"), + TYPE_SOILTEMP4F: ("Soil Temp 4", "°F", TYPE_SENSOR, "temperature"), + TYPE_SOILTEMP5F: ("Soil Temp 5", "°F", TYPE_SENSOR, "temperature"), + TYPE_SOILTEMP6F: ("Soil Temp 6", "°F", TYPE_SENSOR, "temperature"), + TYPE_SOILTEMP7F: ("Soil Temp 7", "°F", TYPE_SENSOR, "temperature"), + TYPE_SOILTEMP8F: ("Soil Temp 8", "°F", TYPE_SENSOR, "temperature"), + TYPE_SOILTEMP9F: ("Soil Temp 9", "°F", TYPE_SENSOR, "temperature"), + TYPE_SOLARRADIATION: ("Solar Rad", "W/m^2", TYPE_SENSOR, None), + TYPE_SOLARRADIATION_LX: ("Solar Rad (lx)", "lx", TYPE_SENSOR, "illuminance"), + TYPE_TEMP10F: ("Temp 10", "°F", TYPE_SENSOR, "temperature"), + TYPE_TEMP1F: ("Temp 1", "°F", TYPE_SENSOR, "temperature"), + TYPE_TEMP2F: ("Temp 2", "°F", TYPE_SENSOR, "temperature"), + TYPE_TEMP3F: ("Temp 3", "°F", TYPE_SENSOR, "temperature"), + TYPE_TEMP4F: ("Temp 4", "°F", TYPE_SENSOR, "temperature"), + TYPE_TEMP5F: ("Temp 5", "°F", TYPE_SENSOR, "temperature"), + TYPE_TEMP6F: ("Temp 6", "°F", TYPE_SENSOR, "temperature"), + TYPE_TEMP7F: ("Temp 7", "°F", TYPE_SENSOR, "temperature"), + TYPE_TEMP8F: ("Temp 8", "°F", TYPE_SENSOR, "temperature"), + TYPE_TEMP9F: ("Temp 9", "°F", TYPE_SENSOR, "temperature"), + TYPE_TEMPF: ("Temp", "°F", TYPE_SENSOR, "temperature"), + TYPE_TEMPINF: ("Inside Temp", "°F", TYPE_SENSOR, "temperature"), + TYPE_TOTALRAININ: ("Lifetime Rain", "in", TYPE_SENSOR, None), + TYPE_UV: ("uv", "Index", TYPE_SENSOR, None), + TYPE_WEEKLYRAININ: ("Weekly Rain", "in", TYPE_SENSOR, None), + TYPE_WINDDIR: ("Wind Dir", "°", TYPE_SENSOR, None), + TYPE_WINDDIR_AVG10M: ("Wind Dir Avg 10m", "°", TYPE_SENSOR, None), + TYPE_WINDDIR_AVG2M: ("Wind Dir Avg 2m", "mph", TYPE_SENSOR, None), + TYPE_WINDGUSTDIR: ("Gust Dir", "°", TYPE_SENSOR, None), + TYPE_WINDGUSTMPH: ("Wind Gust", "mph", TYPE_SENSOR, None), + TYPE_WINDSPDMPH_AVG10M: ("Wind Avg 10m", "mph", TYPE_SENSOR, None), + TYPE_WINDSPDMPH_AVG2M: ("Wind Avg 2m", "mph", TYPE_SENSOR, None), + TYPE_WINDSPEEDMPH: ("Wind Speed", "mph", TYPE_SENSOR, None), + TYPE_YEARLYRAININ: ("Yearly Rain", "in", TYPE_SENSOR, None), +} + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_APP_KEY): cv.string, + vol.Required(CONF_API_KEY): cv.string, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, config): + """Set up the Ambient PWS component.""" + hass.data[DOMAIN] = {} + hass.data[DOMAIN][DATA_CLIENT] = {} + + if DOMAIN not in config: + return True + + conf = config[DOMAIN] + + # Store config for use during entry setup: + hass.data[DOMAIN][DATA_CONFIG] = conf + + if conf[CONF_APP_KEY] in configured_instances(hass): + return True + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_API_KEY: conf[CONF_API_KEY], CONF_APP_KEY: conf[CONF_APP_KEY]}, + ) + ) + + return True + + +async def async_setup_entry(hass, config_entry): + """Set up the Ambient PWS as config entry.""" + session = aiohttp_client.async_get_clientsession(hass) + + try: + ambient = AmbientStation( + hass, + config_entry, + Client( + config_entry.data[CONF_API_KEY], + config_entry.data[CONF_APP_KEY], + session, + ), + ) + hass.loop.create_task(ambient.ws_connect()) + hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] = ambient + except WebsocketError as err: + _LOGGER.error("Config entry failed: %s", err) + raise ConfigEntryNotReady + + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, ambient.client.websocket.disconnect() + ) + + return True + + +async def async_unload_entry(hass, config_entry): + """Unload an Ambient PWS config entry.""" + ambient = hass.data[DOMAIN][DATA_CLIENT].pop(config_entry.entry_id) + hass.async_create_task(ambient.ws_disconnect()) + + tasks = [ + hass.config_entries.async_forward_entry_unload(config_entry, component) + for component in ("binary_sensor", "sensor") + ] + + await asyncio.gather(*tasks) + + return True + + +async def async_migrate_entry(hass, config_entry): + """Migrate old entry.""" + version = config_entry.version + + _LOGGER.debug("Migrating from version %s", version) + + # 1 -> 2: Unique ID format changed, so delete and re-import: + if version == 1: + dev_reg = await hass.helpers.device_registry.async_get_registry() + dev_reg.async_clear_config_entry(config_entry) + + en_reg = await hass.helpers.entity_registry.async_get_registry() + en_reg.async_clear_config_entry(config_entry) + + version = config_entry.version = 2 + hass.config_entries.async_update_entry(config_entry) + + _LOGGER.info("Migration to version %s successful", version) + + return True + + +class AmbientStation: + """Define a class to handle the Ambient websocket.""" + + def __init__(self, hass, config_entry, client): + """Initialize.""" + self._config_entry = config_entry + self._entry_setup_complete = False + self._hass = hass + self._watchdog_listener = None + self._ws_reconnect_delay = DEFAULT_SOCKET_MIN_RETRY + self.client = client + self.monitored_conditions = [] + self.stations = {} + + async def _attempt_connect(self): + """Attempt to connect to the socket (retrying later on fail).""" + try: + await self.client.websocket.connect() + except WebsocketError as err: + _LOGGER.error("Error with the websocket connection: %s", err) + self._ws_reconnect_delay = min(2 * self._ws_reconnect_delay, 480) + async_call_later(self._hass, self._ws_reconnect_delay, self.ws_connect) + + async def ws_connect(self): + """Register handlers and connect to the websocket.""" + + async def _ws_reconnect(event_time): + """Forcibly disconnect from and reconnect to the websocket.""" + _LOGGER.debug("Watchdog expired; forcing socket reconnection") + await self.client.websocket.disconnect() + await self._attempt_connect() + + def on_connect(): + """Define a handler to fire when the websocket is connected.""" + _LOGGER.info("Connected to websocket") + _LOGGER.debug("Watchdog starting") + if self._watchdog_listener is not None: + self._watchdog_listener() + self._watchdog_listener = async_call_later( + self._hass, DEFAULT_WATCHDOG_SECONDS, _ws_reconnect + ) + + def on_data(data): + """Define a handler to fire when the data is received.""" + mac_address = data["macAddress"] + if data != self.stations[mac_address][ATTR_LAST_DATA]: + _LOGGER.debug("New data received: %s", data) + self.stations[mac_address][ATTR_LAST_DATA] = data + async_dispatcher_send(self._hass, TOPIC_UPDATE) + + _LOGGER.debug("Resetting watchdog") + self._watchdog_listener() + self._watchdog_listener = async_call_later( + self._hass, DEFAULT_WATCHDOG_SECONDS, _ws_reconnect + ) + + def on_disconnect(): + """Define a handler to fire when the websocket is disconnected.""" + _LOGGER.info("Disconnected from websocket") + + def on_subscribed(data): + """Define a handler to fire when the subscription is set.""" + for station in data["devices"]: + if station["macAddress"] in self.stations: + continue + + _LOGGER.debug("New station subscription: %s", data) + + self.monitored_conditions = [ + k for k in station["lastData"] if k in SENSOR_TYPES + ] + + # If the user is monitoring brightness (in W/m^2), + # make sure we also add a calculated sensor for the + # same data measured in lx: + if TYPE_SOLARRADIATION in self.monitored_conditions: + self.monitored_conditions.append(TYPE_SOLARRADIATION_LX) + + self.stations[station["macAddress"]] = { + ATTR_LAST_DATA: station["lastData"], + ATTR_LOCATION: station.get("info", {}).get("location"), + ATTR_NAME: station.get("info", {}).get( + "name", station["macAddress"] + ), + } + + # If the websocket disconnects and reconnects, the on_subscribed + # handler will get called again; in that case, we don't want to + # attempt forward setup of the config entry (because it will have + # already been done): + if not self._entry_setup_complete: + for component in ("binary_sensor", "sensor"): + self._hass.async_create_task( + self._hass.config_entries.async_forward_entry_setup( + self._config_entry, component + ) + ) + self._entry_setup_complete = True + + self._ws_reconnect_delay = DEFAULT_SOCKET_MIN_RETRY + + self.client.websocket.on_connect(on_connect) + self.client.websocket.on_data(on_data) + self.client.websocket.on_disconnect(on_disconnect) + self.client.websocket.on_subscribed(on_subscribed) + + await self._attempt_connect() + + async def ws_disconnect(self): + """Disconnect from the websocket.""" + await self.client.websocket.disconnect() + + +class AmbientWeatherEntity(Entity): + """Define a base Ambient PWS entity.""" + + def __init__( + self, ambient, mac_address, station_name, sensor_type, sensor_name, device_class + ): + """Initialize the sensor.""" + self._ambient = ambient + self._device_class = device_class + self._async_unsub_dispatcher_connect = None + self._mac_address = mac_address + self._sensor_name = sensor_name + self._sensor_type = sensor_type + self._state = None + self._station_name = station_name + + @property + def available(self): + """Return True if entity is available.""" + # Since the solarradiation_lx sensor is created only if the + # user shows a solarradiation sensor, ensure that the + # solarradiation_lx sensor shows as available if the solarradiation + # sensor is available: + if self._sensor_type == TYPE_SOLARRADIATION_LX: + return ( + self._ambient.stations[self._mac_address][ATTR_LAST_DATA].get( + TYPE_SOLARRADIATION + ) + is not None + ) + return ( + self._ambient.stations[self._mac_address][ATTR_LAST_DATA].get( + self._sensor_type + ) + is not None + ) + + @property + def device_class(self): + """Return the device class.""" + return self._device_class + + @property + def device_info(self): + """Return device registry information for this entity.""" + return { + "identifiers": {(DOMAIN, self._mac_address)}, + "name": self._station_name, + "manufacturer": "Ambient Weather", + } + + @property + def name(self): + """Return the name of the sensor.""" + return f"{self._station_name}_{self._sensor_name}" + + @property + def should_poll(self): + """Disable polling.""" + return False + + @property + def unique_id(self): + """Return a unique, unchanging string that represents this sensor.""" + return f"{self._mac_address}_{self._sensor_type}" + + async def async_added_to_hass(self): + """Register callbacks.""" + + @callback + def update(): + """Update the state.""" + self.async_schedule_update_ha_state(True) + + self._async_unsub_dispatcher_connect = async_dispatcher_connect( + self.hass, TOPIC_UPDATE, update + ) + + async def async_will_remove_from_hass(self): + """Disconnect dispatcher listener when removed.""" + if self._async_unsub_dispatcher_connect: + self._async_unsub_dispatcher_connect() diff --git a/homeassistant/components/ambient_station/binary_sensor.py b/homeassistant/components/ambient_station/binary_sensor.py new file mode 100644 index 000000000..3f02eb9f1 --- /dev/null +++ b/homeassistant/components/ambient_station/binary_sensor.py @@ -0,0 +1,82 @@ +"""Support for Ambient Weather Station binary sensors.""" +import logging + +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.const import ATTR_NAME + +from . import ( + SENSOR_TYPES, + TYPE_BATT1, + TYPE_BATT2, + TYPE_BATT3, + TYPE_BATT4, + TYPE_BATT5, + TYPE_BATT6, + TYPE_BATT7, + TYPE_BATT8, + TYPE_BATT9, + TYPE_BATT10, + TYPE_BATTOUT, + AmbientWeatherEntity, +) +from .const import ATTR_LAST_DATA, DATA_CLIENT, DOMAIN, TYPE_BINARY_SENSOR + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up Ambient PWS binary sensors based on the old way.""" + pass + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up Ambient PWS binary sensors based on a config entry.""" + ambient = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id] + + binary_sensor_list = [] + for mac_address, station in ambient.stations.items(): + for condition in ambient.monitored_conditions: + name, _, kind, device_class = SENSOR_TYPES[condition] + if kind == TYPE_BINARY_SENSOR: + binary_sensor_list.append( + AmbientWeatherBinarySensor( + ambient, + mac_address, + station[ATTR_NAME], + condition, + name, + device_class, + ) + ) + + async_add_entities(binary_sensor_list, True) + + +class AmbientWeatherBinarySensor(AmbientWeatherEntity, BinarySensorDevice): + """Define an Ambient binary sensor.""" + + @property + def is_on(self): + """Return the status of the sensor.""" + if self._sensor_type in ( + TYPE_BATT1, + TYPE_BATT10, + TYPE_BATT2, + TYPE_BATT3, + TYPE_BATT4, + TYPE_BATT5, + TYPE_BATT6, + TYPE_BATT7, + TYPE_BATT8, + TYPE_BATT9, + TYPE_BATTOUT, + ): + return self._state == 0 + + return self._state == 1 + + async def async_update(self): + """Fetch new state data for the entity.""" + self._state = self._ambient.stations[self._mac_address][ATTR_LAST_DATA].get( + self._sensor_type + ) diff --git a/homeassistant/components/ambient_station/config_flow.py b/homeassistant/components/ambient_station/config_flow.py new file mode 100644 index 000000000..c20b43598 --- /dev/null +++ b/homeassistant/components/ambient_station/config_flow.py @@ -0,0 +1,68 @@ +"""Config flow to configure the Ambient PWS component.""" +from aioambient import Client +from aioambient.errors import AmbientError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_API_KEY +from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client + +from .const import CONF_APP_KEY, DOMAIN + + +@callback +def configured_instances(hass): + """Return a set of configured Ambient PWS instances.""" + return set( + entry.data[CONF_APP_KEY] for entry in hass.config_entries.async_entries(DOMAIN) + ) + + +@config_entries.HANDLERS.register(DOMAIN) +class AmbientStationFlowHandler(config_entries.ConfigFlow): + """Handle an Ambient PWS config flow.""" + + VERSION = 2 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH + + async def _show_form(self, errors=None): + """Show the form to the user.""" + data_schema = vol.Schema( + {vol.Required(CONF_API_KEY): str, vol.Required(CONF_APP_KEY): str} + ) + + return self.async_show_form( + step_id="user", data_schema=data_schema, errors=errors if errors else {} + ) + + async def async_step_import(self, import_config): + """Import a config entry from configuration.yaml.""" + return await self.async_step_user(import_config) + + async def async_step_user(self, user_input=None): + """Handle the start of the config flow.""" + + if not user_input: + return await self._show_form() + + if user_input[CONF_APP_KEY] in configured_instances(self.hass): + return await self._show_form({CONF_APP_KEY: "identifier_exists"}) + + session = aiohttp_client.async_get_clientsession(self.hass) + client = Client(user_input[CONF_API_KEY], user_input[CONF_APP_KEY], session) + + try: + devices = await client.api.get_devices() + except AmbientError: + return await self._show_form({"base": "invalid_key"}) + + if not devices: + return await self._show_form({"base": "no_devices"}) + + # The Application Key (which identifies each config entry) is too long + # to show nicely in the UI, so we take the first 12 characters (similar + # to how GitHub does it): + return self.async_create_entry( + title=user_input[CONF_APP_KEY][:12], data=user_input + ) diff --git a/homeassistant/components/ambient_station/const.py b/homeassistant/components/ambient_station/const.py new file mode 100644 index 000000000..b2df34f2f --- /dev/null +++ b/homeassistant/components/ambient_station/const.py @@ -0,0 +1,13 @@ +"""Define constants for the Ambient PWS component.""" +DOMAIN = "ambient_station" + +ATTR_LAST_DATA = "last_data" + +CONF_APP_KEY = "app_key" + +DATA_CLIENT = "data_client" + +TOPIC_UPDATE = "update" + +TYPE_BINARY_SENSOR = "binary_sensor" +TYPE_SENSOR = "sensor" diff --git a/homeassistant/components/ambient_station/manifest.json b/homeassistant/components/ambient_station/manifest.json new file mode 100644 index 000000000..1e6c06f26 --- /dev/null +++ b/homeassistant/components/ambient_station/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "ambient_station", + "name": "Ambient station", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/ambient_station", + "requirements": [ + "aioambient==1.0.2" + ], + "dependencies": [], + "codeowners": [ + "@bachya" + ] +} diff --git a/homeassistant/components/ambient_station/sensor.py b/homeassistant/components/ambient_station/sensor.py new file mode 100644 index 000000000..56425221e --- /dev/null +++ b/homeassistant/components/ambient_station/sensor.py @@ -0,0 +1,89 @@ +"""Support for Ambient Weather Station sensors.""" +import logging + +from homeassistant.const import ATTR_NAME + +from . import ( + SENSOR_TYPES, + TYPE_SOLARRADIATION, + TYPE_SOLARRADIATION_LX, + AmbientWeatherEntity, +) +from .const import ATTR_LAST_DATA, DATA_CLIENT, DOMAIN, TYPE_SENSOR + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up Ambient PWS sensors based on existing config.""" + pass + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up Ambient PWS sensors based on a config entry.""" + ambient = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id] + + sensor_list = [] + for mac_address, station in ambient.stations.items(): + for condition in ambient.monitored_conditions: + name, unit, kind, device_class = SENSOR_TYPES[condition] + if kind == TYPE_SENSOR: + sensor_list.append( + AmbientWeatherSensor( + ambient, + mac_address, + station[ATTR_NAME], + condition, + name, + device_class, + unit, + ) + ) + + async_add_entities(sensor_list, True) + + +class AmbientWeatherSensor(AmbientWeatherEntity): + """Define an Ambient sensor.""" + + def __init__( + self, + ambient, + mac_address, + station_name, + sensor_type, + sensor_name, + device_class, + unit, + ): + """Initialize the sensor.""" + super().__init__( + ambient, mac_address, station_name, sensor_type, sensor_name, device_class + ) + + self._unit = unit + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit + + async def async_update(self): + """Fetch new state data for the sensor.""" + if self._sensor_type == TYPE_SOLARRADIATION_LX: + # If the user requests the solarradiation_lx sensor, use the + # value of the solarradiation sensor and apply a very accurate + # approximation of converting sunlight W/m^2 to lx: + w_m2_brightness_val = self._ambient.stations[self._mac_address][ + ATTR_LAST_DATA + ].get(TYPE_SOLARRADIATION) + self._state = round(float(w_m2_brightness_val) / 0.0079) + else: + self._state = self._ambient.stations[self._mac_address][ATTR_LAST_DATA].get( + self._sensor_type + ) diff --git a/homeassistant/components/ambient_station/strings.json b/homeassistant/components/ambient_station/strings.json new file mode 100644 index 000000000..657b3477b --- /dev/null +++ b/homeassistant/components/ambient_station/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "title": "Ambient PWS", + "step": { + "user": { + "title": "Fill in your information", + "data": { + "api_key": "API Key", + "app_key": "Application Key" + } + } + }, + "error": { + "identifier_exists": "Application Key and/or API Key already registered", + "invalid_key": "Invalid API Key and/or Application Key", + "no_devices": "No devices found in account" + } + } +} diff --git a/homeassistant/components/amcrest.py b/homeassistant/components/amcrest.py deleted file mode 100644 index bcd0c38c3..000000000 --- a/homeassistant/components/amcrest.py +++ /dev/null @@ -1,178 +0,0 @@ -""" -This component provides basic support for Amcrest IP cameras. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/amcrest/ -""" -import logging -from datetime import timedelta - -import aiohttp -import voluptuous as vol -from requests.exceptions import HTTPError, ConnectTimeout -from requests.exceptions import ConnectionError as ConnectError - -from homeassistant.const import ( - CONF_NAME, CONF_HOST, CONF_PORT, CONF_USERNAME, CONF_PASSWORD, - CONF_SENSORS, CONF_SWITCHES, CONF_SCAN_INTERVAL, HTTP_BASIC_AUTHENTICATION) -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv - -REQUIREMENTS = ['amcrest==1.2.3'] -DEPENDENCIES = ['ffmpeg'] - -_LOGGER = logging.getLogger(__name__) - -CONF_AUTHENTICATION = 'authentication' -CONF_RESOLUTION = 'resolution' -CONF_STREAM_SOURCE = 'stream_source' -CONF_FFMPEG_ARGUMENTS = 'ffmpeg_arguments' - -DEFAULT_NAME = 'Amcrest Camera' -DEFAULT_PORT = 80 -DEFAULT_RESOLUTION = 'high' -DEFAULT_STREAM_SOURCE = 'snapshot' -TIMEOUT = 10 - -DATA_AMCREST = 'amcrest' -DOMAIN = 'amcrest' - -NOTIFICATION_ID = 'amcrest_notification' -NOTIFICATION_TITLE = 'Amcrest Camera Setup' - -RESOLUTION_LIST = { - 'high': 0, - 'low': 1, -} - -SCAN_INTERVAL = timedelta(seconds=10) - -AUTHENTICATION_LIST = { - 'basic': 'basic' -} - -STREAM_SOURCE_LIST = { - 'mjpeg': 0, - 'snapshot': 1, - 'rtsp': 2, -} - -# Sensor types are defined like: Name, units, icon -SENSORS = { - 'motion_detector': ['Motion Detected', None, 'mdi:run'], - 'sdcard': ['SD Used', '%', 'mdi:sd'], - '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, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_AUTHENTICATION, default=HTTP_BASIC_AUTHENTICATION): - vol.All(vol.In(AUTHENTICATION_LIST)), - vol.Optional(CONF_RESOLUTION, default=DEFAULT_RESOLUTION): - vol.All(vol.In(RESOLUTION_LIST)), - vol.Optional(CONF_STREAM_SOURCE, default=DEFAULT_STREAM_SOURCE): - vol.All(vol.In(STREAM_SOURCE_LIST)), - vol.Optional(CONF_FFMPEG_ARGUMENTS): cv.string, - vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL): - cv.time_period, - vol.Optional(CONF_SENSORS): - vol.All(cv.ensure_list, [vol.In(SENSORS)]), - vol.Optional(CONF_SWITCHES): - vol.All(cv.ensure_list, [vol.In(SWITCHES)]), - })]) -}, extra=vol.ALLOW_EXTRA) - - -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: - 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 (ConnectError, ConnectTimeout, HTTPError) as ex: - _LOGGER.error("Unable to connect to Amcrest camera: %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) - 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) - password = device.get(CONF_PASSWORD) - - # currently aiohttp only works with basic authentication - # only valid for mjpeg streaming - if username is not None and password is not None: - if device.get(CONF_AUTHENTICATION) == HTTP_BASIC_AUTHENTICATION: - authentication = aiohttp.BasicAuth(username, password) - else: - authentication = None - - hass.data[DATA_AMCREST][name] = AmcrestDevice( - camera, name, authentication, ffmpeg_arguments, stream_source, - resolution) - - discovery.load_platform( - hass, 'camera', DOMAIN, { - CONF_NAME: name, - }, config) - - if sensors: - discovery.load_platform( - hass, 'sensor', DOMAIN, { - 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: - """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/amcrest/__init__.py b/homeassistant/components/amcrest/__init__.py new file mode 100644 index 000000000..3420f42f9 --- /dev/null +++ b/homeassistant/components/amcrest/__init__.py @@ -0,0 +1,325 @@ +"""Support for Amcrest IP cameras.""" +from datetime import timedelta +import logging +import threading + +import aiohttp +from amcrest import AmcrestError, Http, LoginError +import voluptuous as vol + +from homeassistant.auth.permissions.const import POLICY_CONTROL +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR +from homeassistant.components.camera import DOMAIN as CAMERA +from homeassistant.components.sensor import DOMAIN as SENSOR +from homeassistant.components.switch import DOMAIN as SWITCH +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_AUTHENTICATION, + CONF_BINARY_SENSORS, + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_SCAN_INTERVAL, + CONF_SENSORS, + CONF_SWITCHES, + CONF_USERNAME, + ENTITY_MATCH_ALL, + HTTP_BASIC_AUTHENTICATION, +) +from homeassistant.exceptions import Unauthorized, UnknownUser +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send, dispatcher_send +from homeassistant.helpers.event import track_time_interval +from homeassistant.helpers.service import async_extract_entity_ids + +from .binary_sensor import BINARY_SENSOR_MOTION_DETECTED, BINARY_SENSORS +from .camera import CAMERA_SERVICES, STREAM_SOURCE_LIST +from .const import CAMERAS, DATA_AMCREST, DEVICES, DOMAIN, SERVICE_UPDATE +from .helpers import service_signal +from .sensor import SENSOR_MOTION_DETECTOR, SENSORS +from .switch import SWITCHES + +_LOGGER = logging.getLogger(__name__) + +CONF_RESOLUTION = "resolution" +CONF_STREAM_SOURCE = "stream_source" +CONF_FFMPEG_ARGUMENTS = "ffmpeg_arguments" +CONF_CONTROL_LIGHT = "control_light" + +DEFAULT_NAME = "Amcrest Camera" +DEFAULT_PORT = 80 +DEFAULT_RESOLUTION = "high" +DEFAULT_ARGUMENTS = "-pred 1" +MAX_ERRORS = 5 +RECHECK_INTERVAL = timedelta(minutes=1) + +NOTIFICATION_ID = "amcrest_notification" +NOTIFICATION_TITLE = "Amcrest Camera Setup" + +RESOLUTION_LIST = {"high": 0, "low": 1} + +SCAN_INTERVAL = timedelta(seconds=10) + +AUTHENTICATION_LIST = {"basic": "basic"} + + +def _deprecated_sensor_values(sensors): + if SENSOR_MOTION_DETECTOR in sensors: + _LOGGER.warning( + "The '%s' option value '%s' is deprecated, " + "please remove it from your configuration and use " + "the '%s' option with value '%s' instead", + CONF_SENSORS, + SENSOR_MOTION_DETECTOR, + CONF_BINARY_SENSORS, + BINARY_SENSOR_MOTION_DETECTED, + ) + return sensors + + +def _deprecated_switches(config): + if CONF_SWITCHES in config: + _LOGGER.warning( + "The '%s' option (with value %s) is deprecated, " + "please remove it from your configuration and use " + "services and attributes instead", + CONF_SWITCHES, + config[CONF_SWITCHES], + ) + return config + + +def _has_unique_names(devices): + names = [device[CONF_NAME] for device in devices] + vol.Schema(vol.Unique())(names) + return devices + + +AMCREST_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional( + CONF_AUTHENTICATION, default=HTTP_BASIC_AUTHENTICATION + ): vol.All(vol.In(AUTHENTICATION_LIST)), + vol.Optional(CONF_RESOLUTION, default=DEFAULT_RESOLUTION): vol.All( + vol.In(RESOLUTION_LIST) + ), + vol.Optional(CONF_STREAM_SOURCE, default=STREAM_SOURCE_LIST[0]): vol.All( + vol.In(STREAM_SOURCE_LIST) + ), + vol.Optional(CONF_FFMPEG_ARGUMENTS, default=DEFAULT_ARGUMENTS): cv.string, + vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL): cv.time_period, + vol.Optional(CONF_BINARY_SENSORS): vol.All( + cv.ensure_list, [vol.In(BINARY_SENSORS)] + ), + vol.Optional(CONF_SENSORS): vol.All( + cv.ensure_list, [vol.In(SENSORS)], _deprecated_sensor_values + ), + vol.Optional(CONF_SWITCHES): vol.All(cv.ensure_list, [vol.In(SWITCHES)]), + vol.Optional(CONF_CONTROL_LIGHT, default=True): cv.boolean, + } + ), + _deprecated_switches, +) + +CONFIG_SCHEMA = vol.Schema( + {DOMAIN: vol.All(cv.ensure_list, [AMCREST_SCHEMA], _has_unique_names)}, + extra=vol.ALLOW_EXTRA, +) + + +# pylint: disable=too-many-ancestors +class AmcrestChecker(Http): + """amcrest.Http wrapper for catching errors.""" + + def __init__(self, hass, name, host, port, user, password): + """Initialize.""" + self._hass = hass + self._wrap_name = name + self._wrap_errors = 0 + self._wrap_lock = threading.Lock() + self._unsub_recheck = None + super().__init__( + host, port, user, password, retries_connection=1, timeout_protocol=3.05 + ) + + @property + def available(self): + """Return if camera's API is responding.""" + return self._wrap_errors <= MAX_ERRORS + + def command(self, cmd, retries=None, timeout_cmd=None, stream=False): + """amcrest.Http.command wrapper to catch errors.""" + try: + ret = super().command(cmd, retries, timeout_cmd, stream) + except AmcrestError: + with self._wrap_lock: + was_online = self.available + self._wrap_errors += 1 + _LOGGER.debug("%s camera errs: %i", self._wrap_name, self._wrap_errors) + offline = not self.available + if offline and was_online: + _LOGGER.error("%s camera offline: Too many errors", self._wrap_name) + with self._token_lock: + self._token = None + dispatcher_send( + self._hass, service_signal(SERVICE_UPDATE, self._wrap_name) + ) + self._unsub_recheck = track_time_interval( + self._hass, self._wrap_test_online, RECHECK_INTERVAL + ) + raise + with self._wrap_lock: + was_offline = not self.available + self._wrap_errors = 0 + if was_offline: + self._unsub_recheck() + self._unsub_recheck = None + _LOGGER.error("%s camera back online", self._wrap_name) + dispatcher_send(self._hass, service_signal(SERVICE_UPDATE, self._wrap_name)) + return ret + + def _wrap_test_online(self, now): + """Test if camera is back online.""" + try: + self.current_time + except AmcrestError: + pass + + +def setup(hass, config): + """Set up the Amcrest IP Camera component.""" + hass.data.setdefault(DATA_AMCREST, {DEVICES: {}, CAMERAS: []}) + + for device in config[DOMAIN]: + name = device[CONF_NAME] + username = device[CONF_USERNAME] + password = device[CONF_PASSWORD] + + try: + api = AmcrestChecker( + hass, name, device[CONF_HOST], device[CONF_PORT], username, password + ) + + except LoginError as ex: + _LOGGER.error("Login error for %s camera: %s", name, ex) + continue + + ffmpeg_arguments = device[CONF_FFMPEG_ARGUMENTS] + resolution = RESOLUTION_LIST[device[CONF_RESOLUTION]] + binary_sensors = device.get(CONF_BINARY_SENSORS) + sensors = device.get(CONF_SENSORS) + switches = device.get(CONF_SWITCHES) + stream_source = device[CONF_STREAM_SOURCE] + control_light = device.get(CONF_CONTROL_LIGHT) + + # currently aiohttp only works with basic authentication + # only valid for mjpeg streaming + if device[CONF_AUTHENTICATION] == HTTP_BASIC_AUTHENTICATION: + authentication = aiohttp.BasicAuth(username, password) + else: + authentication = None + + hass.data[DATA_AMCREST][DEVICES][name] = AmcrestDevice( + api, + authentication, + ffmpeg_arguments, + stream_source, + resolution, + control_light, + ) + + discovery.load_platform(hass, CAMERA, DOMAIN, {CONF_NAME: name}, config) + + if binary_sensors: + discovery.load_platform( + hass, + BINARY_SENSOR, + DOMAIN, + {CONF_NAME: name, CONF_BINARY_SENSORS: binary_sensors}, + config, + ) + + if sensors: + discovery.load_platform( + hass, SENSOR, DOMAIN, {CONF_NAME: name, CONF_SENSORS: sensors}, config + ) + + if switches: + discovery.load_platform( + hass, SWITCH, DOMAIN, {CONF_NAME: name, CONF_SWITCHES: switches}, config + ) + + if not hass.data[DATA_AMCREST][DEVICES]: + return False + + def have_permission(user, entity_id): + return not user or user.permissions.check_entity(entity_id, POLICY_CONTROL) + + async def async_extract_from_service(call): + if call.context.user_id: + user = await hass.auth.async_get_user(call.context.user_id) + if user is None: + raise UnknownUser(context=call.context) + else: + user = None + + if call.data.get(ATTR_ENTITY_ID) == ENTITY_MATCH_ALL: + # Return all entity_ids user has permission to control. + return [ + entity_id + for entity_id in hass.data[DATA_AMCREST][CAMERAS] + if have_permission(user, entity_id) + ] + + call_ids = await async_extract_entity_ids(hass, call) + entity_ids = [] + for entity_id in hass.data[DATA_AMCREST][CAMERAS]: + if entity_id not in call_ids: + continue + if not have_permission(user, entity_id): + raise Unauthorized( + context=call.context, entity_id=entity_id, permission=POLICY_CONTROL + ) + entity_ids.append(entity_id) + return entity_ids + + async def async_service_handler(call): + args = [] + for arg in CAMERA_SERVICES[call.service][2]: + args.append(call.data[arg]) + for entity_id in await async_extract_from_service(call): + async_dispatcher_send(hass, service_signal(call.service, entity_id), *args) + + for service, params in CAMERA_SERVICES.items(): + hass.services.async_register(DOMAIN, service, async_service_handler, params[0]) + + return True + + +class AmcrestDevice: + """Representation of a base Amcrest discovery device.""" + + def __init__( + self, + api, + authentication, + ffmpeg_arguments, + stream_source, + resolution, + control_light, + ): + """Initialize the entity.""" + self.api = api + self.authentication = authentication + self.ffmpeg_arguments = ffmpeg_arguments + self.stream_source = stream_source + self.resolution = resolution + self.control_light = control_light diff --git a/homeassistant/components/amcrest/binary_sensor.py b/homeassistant/components/amcrest/binary_sensor.py new file mode 100644 index 000000000..ac16f0664 --- /dev/null +++ b/homeassistant/components/amcrest/binary_sensor.py @@ -0,0 +1,119 @@ +"""Suppoort for Amcrest IP camera binary sensors.""" +from datetime import timedelta +import logging + +from amcrest import AmcrestError + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_CONNECTIVITY, + DEVICE_CLASS_MOTION, + BinarySensorDevice, +) +from homeassistant.const import CONF_BINARY_SENSORS, CONF_NAME +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import ( + BINARY_SENSOR_SCAN_INTERVAL_SECS, + DATA_AMCREST, + DEVICES, + SERVICE_UPDATE, +) +from .helpers import log_update_error, service_signal + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=BINARY_SENSOR_SCAN_INTERVAL_SECS) + +BINARY_SENSOR_MOTION_DETECTED = "motion_detected" +BINARY_SENSOR_ONLINE = "online" +# Binary sensor types are defined like: Name, device class +BINARY_SENSORS = { + BINARY_SENSOR_MOTION_DETECTED: ("Motion Detected", DEVICE_CLASS_MOTION), + BINARY_SENSOR_ONLINE: ("Online", DEVICE_CLASS_CONNECTIVITY), +} + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up a binary sensor for an Amcrest IP Camera.""" + if discovery_info is None: + return + + name = discovery_info[CONF_NAME] + device = hass.data[DATA_AMCREST][DEVICES][name] + async_add_entities( + [ + AmcrestBinarySensor(name, device, sensor_type) + for sensor_type in discovery_info[CONF_BINARY_SENSORS] + ], + True, + ) + + +class AmcrestBinarySensor(BinarySensorDevice): + """Binary sensor for Amcrest camera.""" + + def __init__(self, name, device, sensor_type): + """Initialize entity.""" + self._name = "{} {}".format(name, BINARY_SENSORS[sensor_type][0]) + self._signal_name = name + self._api = device.api + self._sensor_type = sensor_type + self._state = None + self._device_class = BINARY_SENSORS[sensor_type][1] + self._unsub_dispatcher = None + + @property + def should_poll(self): + """Return True if entity has to be polled for state.""" + return self._sensor_type != BINARY_SENSOR_ONLINE + + @property + def name(self): + """Return entity name.""" + return self._name + + @property + def is_on(self): + """Return if entity is on.""" + return self._state + + @property + def device_class(self): + """Return device class.""" + return self._device_class + + @property + def available(self): + """Return True if entity is available.""" + return self._sensor_type == BINARY_SENSOR_ONLINE or self._api.available + + def update(self): + """Update entity.""" + if not self.available: + return + _LOGGER.debug("Updating %s binary sensor", self._name) + + try: + if self._sensor_type == BINARY_SENSOR_MOTION_DETECTED: + self._state = self._api.is_motion_detected + + elif self._sensor_type == BINARY_SENSOR_ONLINE: + self._state = self._api.available + except AmcrestError as error: + log_update_error(_LOGGER, "update", self.name, "binary sensor", error) + + async def async_on_demand_update(self): + """Update state.""" + self.async_schedule_update_ha_state(True) + + async def async_added_to_hass(self): + """Subscribe to update signal.""" + self._unsub_dispatcher = async_dispatcher_connect( + self.hass, + service_signal(SERVICE_UPDATE, self._signal_name), + self.async_on_demand_update, + ) + + async def async_will_remove_from_hass(self): + """Disconnect from update signal.""" + self._unsub_dispatcher() diff --git a/homeassistant/components/amcrest/camera.py b/homeassistant/components/amcrest/camera.py new file mode 100644 index 000000000..e9e1e2b5f --- /dev/null +++ b/homeassistant/components/amcrest/camera.py @@ -0,0 +1,515 @@ +"""Support for Amcrest IP cameras.""" +import asyncio +from datetime import timedelta +import logging + +from amcrest import AmcrestError +from haffmpeg.camera import CameraMjpeg +from urllib3.exceptions import HTTPError +import voluptuous as vol + +from homeassistant.components.camera import ( + CAMERA_SERVICE_SCHEMA, + SUPPORT_ON_OFF, + SUPPORT_STREAM, + Camera, +) +from homeassistant.components.ffmpeg import DATA_FFMPEG +from homeassistant.const import CONF_NAME, STATE_OFF, STATE_ON +from homeassistant.helpers.aiohttp_client import ( + async_aiohttp_proxy_stream, + async_aiohttp_proxy_web, + async_get_clientsession, +) +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import ( + CAMERA_WEB_SESSION_TIMEOUT, + CAMERAS, + DATA_AMCREST, + DEVICES, + SERVICE_UPDATE, +) +from .helpers import log_update_error, service_signal + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=15) + +STREAM_SOURCE_LIST = ["snapshot", "mjpeg", "rtsp"] + +_SRV_EN_REC = "enable_recording" +_SRV_DS_REC = "disable_recording" +_SRV_EN_AUD = "enable_audio" +_SRV_DS_AUD = "disable_audio" +_SRV_EN_MOT_REC = "enable_motion_recording" +_SRV_DS_MOT_REC = "disable_motion_recording" +_SRV_GOTO = "goto_preset" +_SRV_CBW = "set_color_bw" +_SRV_TOUR_ON = "start_tour" +_SRV_TOUR_OFF = "stop_tour" + +_ATTR_PRESET = "preset" +_ATTR_COLOR_BW = "color_bw" + +_CBW_COLOR = "color" +_CBW_AUTO = "auto" +_CBW_BW = "bw" +_CBW = [_CBW_COLOR, _CBW_AUTO, _CBW_BW] + +_SRV_GOTO_SCHEMA = CAMERA_SERVICE_SCHEMA.extend( + {vol.Required(_ATTR_PRESET): vol.All(vol.Coerce(int), vol.Range(min=1))} +) +_SRV_CBW_SCHEMA = CAMERA_SERVICE_SCHEMA.extend( + {vol.Required(_ATTR_COLOR_BW): vol.In(_CBW)} +) + +CAMERA_SERVICES = { + _SRV_EN_REC: (CAMERA_SERVICE_SCHEMA, "async_enable_recording", ()), + _SRV_DS_REC: (CAMERA_SERVICE_SCHEMA, "async_disable_recording", ()), + _SRV_EN_AUD: (CAMERA_SERVICE_SCHEMA, "async_enable_audio", ()), + _SRV_DS_AUD: (CAMERA_SERVICE_SCHEMA, "async_disable_audio", ()), + _SRV_EN_MOT_REC: (CAMERA_SERVICE_SCHEMA, "async_enable_motion_recording", ()), + _SRV_DS_MOT_REC: (CAMERA_SERVICE_SCHEMA, "async_disable_motion_recording", ()), + _SRV_GOTO: (_SRV_GOTO_SCHEMA, "async_goto_preset", (_ATTR_PRESET,)), + _SRV_CBW: (_SRV_CBW_SCHEMA, "async_set_color_bw", (_ATTR_COLOR_BW,)), + _SRV_TOUR_ON: (CAMERA_SERVICE_SCHEMA, "async_start_tour", ()), + _SRV_TOUR_OFF: (CAMERA_SERVICE_SCHEMA, "async_stop_tour", ()), +} + +_BOOL_TO_STATE = {True: STATE_ON, False: STATE_OFF} + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up an Amcrest IP Camera.""" + if discovery_info is None: + return + + name = discovery_info[CONF_NAME] + device = hass.data[DATA_AMCREST][DEVICES][name] + async_add_entities([AmcrestCam(name, device, hass.data[DATA_FFMPEG])], True) + + +class AmcrestCam(Camera): + """An implementation of an Amcrest IP camera.""" + + def __init__(self, name, device, ffmpeg): + """Initialize an Amcrest camera.""" + super().__init__() + self._name = name + self._api = device.api + self._ffmpeg = ffmpeg + self._ffmpeg_arguments = device.ffmpeg_arguments + self._stream_source = device.stream_source + self._resolution = device.resolution + self._token = self._auth = device.authentication + self._control_light = device.control_light + self._is_recording = False + self._motion_detection_enabled = None + self._brand = None + self._model = None + self._audio_enabled = None + self._motion_recording_enabled = None + self._color_bw = None + self._rtsp_url = None + self._snapshot_lock = asyncio.Lock() + self._unsub_dispatcher = [] + self._update_succeeded = False + + async def async_camera_image(self): + """Return a still image response from the camera.""" + available = self.available + if not available or not self.is_on: + _LOGGER.warning( + "Attempt to take snaphot when %s camera is %s", + self.name, + "offline" if not available else "off", + ) + return None + async with self._snapshot_lock: + try: + # Send the request to snap a picture and return raw jpg data + response = await self.hass.async_add_executor_job(self._api.snapshot) + return response.data + except (AmcrestError, HTTPError) as error: + log_update_error(_LOGGER, "get image from", self.name, "camera", error) + return None + + async def handle_async_mjpeg_stream(self, request): + """Return an MJPEG stream.""" + # The snapshot implementation is handled by the parent class + if self._stream_source == "snapshot": + return await super().handle_async_mjpeg_stream(request) + + if not self.available: + _LOGGER.warning( + "Attempt to stream %s when %s camera is offline", + self._stream_source, + self.name, + ) + return None + + if self._stream_source == "mjpeg": + # stream an MJPEG image stream directly from the camera + websession = async_get_clientsession(self.hass) + streaming_url = self._api.mjpeg_url(typeno=self._resolution) + stream_coro = websession.get( + streaming_url, auth=self._token, timeout=CAMERA_WEB_SESSION_TIMEOUT + ) + + return await async_aiohttp_proxy_web(self.hass, request, stream_coro) + + # streaming via ffmpeg + + streaming_url = self._rtsp_url + stream = CameraMjpeg(self._ffmpeg.binary, loop=self.hass.loop) + await stream.open_camera(streaming_url, extra_cmd=self._ffmpeg_arguments) + + try: + stream_reader = await stream.get_reader() + return await async_aiohttp_proxy_stream( + self.hass, + request, + stream_reader, + self._ffmpeg.ffmpeg_stream_content_type, + ) + finally: + await stream.close() + + # Entity property overrides + + @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 True + + @property + def name(self): + """Return the name of this camera.""" + return self._name + + @property + def device_state_attributes(self): + """Return the Amcrest-specific camera state attributes.""" + attr = {} + if self._audio_enabled is not None: + attr["audio"] = _BOOL_TO_STATE.get(self._audio_enabled) + if self._motion_recording_enabled is not None: + attr["motion_recording"] = _BOOL_TO_STATE.get( + self._motion_recording_enabled + ) + if self._color_bw is not None: + attr[_ATTR_COLOR_BW] = self._color_bw + return attr + + @property + def available(self): + """Return True if entity is available.""" + return self._api.available + + @property + def supported_features(self): + """Return supported features.""" + return SUPPORT_ON_OFF | SUPPORT_STREAM + + # Camera property overrides + + @property + def is_recording(self): + """Return true if the device is recording.""" + return self._is_recording + + @property + def brand(self): + """Return the camera brand.""" + return self._brand + + @property + def motion_detection_enabled(self): + """Return the camera motion detection status.""" + return self._motion_detection_enabled + + @property + def model(self): + """Return the camera model.""" + return self._model + + async def stream_source(self): + """Return the source of the stream.""" + return self._rtsp_url + + @property + def is_on(self): + """Return true if on.""" + return self.is_streaming + + # Other Entity method overrides + + async def async_on_demand_update(self): + """Update state.""" + self.async_schedule_update_ha_state(True) + + async def async_added_to_hass(self): + """Subscribe to signals and add camera to list.""" + for service, params in CAMERA_SERVICES.items(): + self._unsub_dispatcher.append( + async_dispatcher_connect( + self.hass, + service_signal(service, self.entity_id), + getattr(self, params[1]), + ) + ) + self._unsub_dispatcher.append( + async_dispatcher_connect( + self.hass, + service_signal(SERVICE_UPDATE, self._name), + self.async_on_demand_update, + ) + ) + self.hass.data[DATA_AMCREST][CAMERAS].append(self.entity_id) + + async def async_will_remove_from_hass(self): + """Remove camera from list and disconnect from signals.""" + self.hass.data[DATA_AMCREST][CAMERAS].remove(self.entity_id) + for unsub_dispatcher in self._unsub_dispatcher: + unsub_dispatcher() + + def update(self): + """Update entity status.""" + if not self.available or self._update_succeeded: + if not self.available: + self._update_succeeded = False + return + _LOGGER.debug("Updating %s camera", self.name) + try: + if self._brand is None: + resp = self._api.vendor_information.strip() + if resp.startswith("vendor="): + self._brand = resp.split("=")[-1] + else: + self._brand = "unknown" + if self._model is None: + resp = self._api.device_type.strip() + if resp.startswith("type="): + self._model = resp.split("=")[-1] + else: + self._model = "unknown" + self.is_streaming = self._api.video_enabled + self._is_recording = self._api.record_mode == "Manual" + self._motion_detection_enabled = self._api.is_motion_detector_on() + self._audio_enabled = self._api.audio_enabled + self._motion_recording_enabled = self._api.is_record_on_motion_detection() + self._color_bw = _CBW[self._api.day_night_color] + self._rtsp_url = self._api.rtsp_url(typeno=self._resolution) + except AmcrestError as error: + log_update_error(_LOGGER, "get", self.name, "camera attributes", error) + self._update_succeeded = False + else: + self._update_succeeded = True + + # Other Camera method overrides + + def turn_off(self): + """Turn off camera.""" + self._enable_video_stream(False) + + def turn_on(self): + """Turn on camera.""" + self._enable_video_stream(True) + + def enable_motion_detection(self): + """Enable motion detection in the camera.""" + self._enable_motion_detection(True) + + def disable_motion_detection(self): + """Disable motion detection in camera.""" + self._enable_motion_detection(False) + + # Additional Amcrest Camera service methods + + async def async_enable_recording(self): + """Call the job and enable recording.""" + await self.hass.async_add_executor_job(self._enable_recording, True) + + async def async_disable_recording(self): + """Call the job and disable recording.""" + await self.hass.async_add_executor_job(self._enable_recording, False) + + async def async_enable_audio(self): + """Call the job and enable audio.""" + await self.hass.async_add_executor_job(self._enable_audio, True) + + async def async_disable_audio(self): + """Call the job and disable audio.""" + await self.hass.async_add_executor_job(self._enable_audio, False) + + async def async_enable_motion_recording(self): + """Call the job and enable motion recording.""" + await self.hass.async_add_executor_job(self._enable_motion_recording, True) + + async def async_disable_motion_recording(self): + """Call the job and disable motion recording.""" + await self.hass.async_add_executor_job(self._enable_motion_recording, False) + + async def async_goto_preset(self, preset): + """Call the job and move camera to preset position.""" + await self.hass.async_add_executor_job(self._goto_preset, preset) + + async def async_set_color_bw(self, color_bw): + """Call the job and set camera color mode.""" + await self.hass.async_add_executor_job(self._set_color_bw, color_bw) + + async def async_start_tour(self): + """Call the job and start camera tour.""" + await self.hass.async_add_executor_job(self._start_tour, True) + + async def async_stop_tour(self): + """Call the job and stop camera tour.""" + await self.hass.async_add_executor_job(self._start_tour, False) + + # Methods to send commands to Amcrest camera and handle errors + + def _enable_video_stream(self, enable): + """Enable or disable camera video stream.""" + # Given the way the camera's state is determined by + # is_streaming and is_recording, we can't leave + # recording on if video stream is being turned off. + if self.is_recording and not enable: + self._enable_recording(False) + try: + self._api.video_enabled = enable + except AmcrestError as error: + log_update_error( + _LOGGER, + "enable" if enable else "disable", + self.name, + "camera video stream", + error, + ) + else: + self.is_streaming = enable + self.schedule_update_ha_state() + if self._control_light: + self._enable_light(self._audio_enabled or self.is_streaming) + + def _enable_recording(self, enable): + """Turn recording on or off.""" + # Given the way the camera's state is determined by + # is_streaming and is_recording, we can't leave + # video stream off if recording is being turned on. + if not self.is_streaming and enable: + self._enable_video_stream(True) + rec_mode = {"Automatic": 0, "Manual": 1} + try: + self._api.record_mode = rec_mode["Manual" if enable else "Automatic"] + except AmcrestError as error: + log_update_error( + _LOGGER, + "enable" if enable else "disable", + self.name, + "camera recording", + error, + ) + else: + self._is_recording = enable + self.schedule_update_ha_state() + + def _enable_motion_detection(self, enable): + """Enable or disable motion detection.""" + try: + self._api.motion_detection = str(enable).lower() + except AmcrestError as error: + log_update_error( + _LOGGER, + "enable" if enable else "disable", + self.name, + "camera motion detection", + error, + ) + else: + self._motion_detection_enabled = enable + self.schedule_update_ha_state() + + def _enable_audio(self, enable): + """Enable or disable audio stream.""" + try: + self._api.audio_enabled = enable + except AmcrestError as error: + log_update_error( + _LOGGER, + "enable" if enable else "disable", + self.name, + "camera audio stream", + error, + ) + else: + self._audio_enabled = enable + self.schedule_update_ha_state() + if self._control_light: + self._enable_light(self._audio_enabled or self.is_streaming) + + def _enable_light(self, enable): + """Enable or disable indicator light.""" + try: + self._api.command( + "configManager.cgi?action=setConfig&LightGlobal[0].Enable={}".format( + str(enable).lower() + ) + ) + except AmcrestError as error: + log_update_error( + _LOGGER, + "enable" if enable else "disable", + self.name, + "indicator light", + error, + ) + + def _enable_motion_recording(self, enable): + """Enable or disable motion recording.""" + try: + self._api.motion_recording = str(enable).lower() + except AmcrestError as error: + log_update_error( + _LOGGER, + "enable" if enable else "disable", + self.name, + "camera motion recording", + error, + ) + else: + self._motion_recording_enabled = enable + self.schedule_update_ha_state() + + def _goto_preset(self, preset): + """Move camera position and zoom to preset.""" + try: + self._api.go_to_preset(action="start", preset_point_number=preset) + except AmcrestError as error: + log_update_error( + _LOGGER, "move", self.name, f"camera to preset {preset}", error + ) + + def _set_color_bw(self, cbw): + """Set camera color mode.""" + try: + self._api.day_night_color = _CBW.index(cbw) + except AmcrestError as error: + log_update_error( + _LOGGER, "set", self.name, f"camera color mode to {cbw}", error + ) + else: + self._color_bw = cbw + self.schedule_update_ha_state() + + def _start_tour(self, start): + """Start camera tour.""" + try: + self._api.tour(start=start) + except AmcrestError as error: + log_update_error( + _LOGGER, "start" if start else "stop", self.name, "camera tour", error + ) diff --git a/homeassistant/components/amcrest/const.py b/homeassistant/components/amcrest/const.py new file mode 100644 index 000000000..98d613634 --- /dev/null +++ b/homeassistant/components/amcrest/const.py @@ -0,0 +1,11 @@ +"""Constants for amcrest component.""" +DOMAIN = "amcrest" +DATA_AMCREST = DOMAIN +CAMERAS = "cameras" +DEVICES = "devices" + +BINARY_SENSOR_SCAN_INTERVAL_SECS = 5 +CAMERA_WEB_SESSION_TIMEOUT = 10 +SENSOR_SCAN_INTERVAL_SECS = 10 + +SERVICE_UPDATE = "update" diff --git a/homeassistant/components/amcrest/helpers.py b/homeassistant/components/amcrest/helpers.py new file mode 100644 index 000000000..a40d6ace5 --- /dev/null +++ b/homeassistant/components/amcrest/helpers.py @@ -0,0 +1,21 @@ +"""Helpers for amcrest component.""" +from .const import DOMAIN + + +def service_signal(service, ident=None): + """Encode service and identifier into signal.""" + signal = f"{DOMAIN}_{service}" + if ident: + signal += "_{}".format(ident.replace(".", "_")) + return signal + + +def log_update_error(logger, action, name, entity_type, error): + """Log an update error.""" + logger.error( + "Could not %s %s %s due to error: %s", + action, + name, + entity_type, + error.__class__.__name__, + ) diff --git a/homeassistant/components/amcrest/manifest.json b/homeassistant/components/amcrest/manifest.json new file mode 100644 index 000000000..4453687b8 --- /dev/null +++ b/homeassistant/components/amcrest/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "amcrest", + "name": "Amcrest", + "documentation": "https://www.home-assistant.io/integrations/amcrest", + "requirements": [ + "amcrest==1.5.3" + ], + "dependencies": [ + "ffmpeg" + ], + "codeowners": [] +} diff --git a/homeassistant/components/amcrest/sensor.py b/homeassistant/components/amcrest/sensor.py new file mode 100644 index 000000000..b53f05273 --- /dev/null +++ b/homeassistant/components/amcrest/sensor.py @@ -0,0 +1,135 @@ +"""Suppoort for Amcrest IP camera sensors.""" +from datetime import timedelta +import logging + +from amcrest import AmcrestError + +from homeassistant.const import CONF_NAME, CONF_SENSORS +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from .const import DATA_AMCREST, DEVICES, SENSOR_SCAN_INTERVAL_SECS, SERVICE_UPDATE +from .helpers import log_update_error, service_signal + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=SENSOR_SCAN_INTERVAL_SECS) + +SENSOR_MOTION_DETECTOR = "motion_detector" +SENSOR_PTZ_PRESET = "ptz_preset" +SENSOR_SDCARD = "sdcard" +# Sensor types are defined like: Name, units, icon +SENSORS = { + SENSOR_MOTION_DETECTOR: ["Motion Detected", None, "mdi:run"], + SENSOR_PTZ_PRESET: ["PTZ Preset", None, "mdi:camera-iris"], + SENSOR_SDCARD: ["SD Used", "%", "mdi:sd"], +} + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up a sensor for an Amcrest IP Camera.""" + if discovery_info is None: + return + + name = discovery_info[CONF_NAME] + device = hass.data[DATA_AMCREST][DEVICES][name] + async_add_entities( + [ + AmcrestSensor(name, device, sensor_type) + for sensor_type in discovery_info[CONF_SENSORS] + ], + True, + ) + + +class AmcrestSensor(Entity): + """A sensor implementation for Amcrest IP camera.""" + + def __init__(self, name, device, sensor_type): + """Initialize a sensor for Amcrest camera.""" + self._name = "{} {}".format(name, SENSORS[sensor_type][0]) + self._signal_name = name + self._api = device.api + self._sensor_type = sensor_type + self._state = None + self._attrs = {} + self._unit_of_measurement = SENSORS[sensor_type][1] + self._icon = SENSORS[sensor_type][2] + self._unsub_dispatcher = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._attrs + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return self._icon + + @property + def unit_of_measurement(self): + """Return the units of measurement.""" + return self._unit_of_measurement + + @property + def available(self): + """Return True if entity is available.""" + return self._api.available + + def update(self): + """Get the latest data and updates the state.""" + if not self.available: + return + _LOGGER.debug("Updating %s sensor", self._name) + + try: + if self._sensor_type == SENSOR_MOTION_DETECTOR: + self._state = self._api.is_motion_detected + self._attrs["Record Mode"] = self._api.record_mode + + elif self._sensor_type == SENSOR_PTZ_PRESET: + self._state = self._api.ptz_presets_count + + elif self._sensor_type == SENSOR_SDCARD: + storage = self._api.storage_all + try: + self._attrs["Total"] = "{:.2f} {}".format(*storage["total"]) + except ValueError: + self._attrs["Total"] = "{} {}".format(*storage["total"]) + try: + self._attrs["Used"] = "{:.2f} {}".format(*storage["used"]) + except ValueError: + self._attrs["Used"] = "{} {}".format(*storage["used"]) + try: + self._state = "{:.2f}".format(storage["used_percent"]) + except ValueError: + self._state = storage["used_percent"] + except AmcrestError as error: + log_update_error(_LOGGER, "update", self.name, "sensor", error) + + async def async_on_demand_update(self): + """Update state.""" + self.async_schedule_update_ha_state(True) + + async def async_added_to_hass(self): + """Subscribe to update signal.""" + self._unsub_dispatcher = async_dispatcher_connect( + self.hass, + service_signal(SERVICE_UPDATE, self._signal_name), + self.async_on_demand_update, + ) + + async def async_will_remove_from_hass(self): + """Disconnect from update signal.""" + self._unsub_dispatcher() diff --git a/homeassistant/components/amcrest/services.yaml b/homeassistant/components/amcrest/services.yaml new file mode 100644 index 000000000..d6e7a02a4 --- /dev/null +++ b/homeassistant/components/amcrest/services.yaml @@ -0,0 +1,75 @@ +enable_recording: + description: Enable continuous recording to camera storage. + fields: + entity_id: + description: "Name(s) of the cameras, or 'all' for all cameras." + example: 'camera.house_front' + +disable_recording: + description: Disable continuous recording to camera storage. + fields: + entity_id: + description: "Name(s) of the cameras, or 'all' for all cameras." + example: 'camera.house_front' + +enable_audio: + description: Enable audio stream. + fields: + entity_id: + description: "Name(s) of the cameras, or 'all' for all cameras." + example: 'camera.house_front' + +disable_audio: + description: Disable audio stream. + fields: + entity_id: + description: "Name(s) of the cameras, or 'all' for all cameras." + example: 'camera.house_front' + +enable_motion_recording: + description: Enable recording a clip to camera storage when motion is detected. + fields: + entity_id: + description: "Name(s) of the cameras, or 'all' for all cameras." + example: 'camera.house_front' + +disable_motion_recording: + description: Disable recording a clip to camera storage when motion is detected. + fields: + entity_id: + description: "Name(s) of the cameras, or 'all' for all cameras." + example: 'camera.house_front' + +goto_preset: + description: Move camera to PTZ preset. + fields: + entity_id: + description: "Name(s) of the cameras, or 'all' for all cameras." + example: 'camera.house_front' + preset: + description: Preset number, starting from 1. + example: 1 + +set_color_bw: + description: Set camera color mode. + fields: + entity_id: + description: "Name(s) of the cameras, or 'all' for all cameras." + example: 'camera.house_front' + color_bw: + description: Color mode, one of 'auto', 'color' or 'bw'. + example: auto + +start_tour: + description: Start camera's PTZ tour function. + fields: + entity_id: + description: "Name(s) of the cameras, or 'all' for all cameras." + example: 'camera.house_front' + +stop_tour: + description: Stop camera's PTZ tour function. + fields: + entity_id: + description: "Name(s) of the cameras, or 'all' for all cameras." + example: 'camera.house_front' diff --git a/homeassistant/components/amcrest/switch.py b/homeassistant/components/amcrest/switch.py new file mode 100644 index 000000000..0c3390c16 --- /dev/null +++ b/homeassistant/components/amcrest/switch.py @@ -0,0 +1,126 @@ +"""Support for toggling Amcrest IP camera settings.""" +import logging + +from amcrest import AmcrestError + +from homeassistant.const import CONF_NAME, CONF_SWITCHES +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import ToggleEntity + +from .const import DATA_AMCREST, DEVICES, SERVICE_UPDATE +from .helpers import log_update_error, service_signal + +_LOGGER = logging.getLogger(__name__) + +MOTION_DETECTION = "motion_detection" +MOTION_RECORDING = "motion_recording" +# Switch types are defined like: Name, icon +SWITCHES = { + MOTION_DETECTION: ["Motion Detection", "mdi:run-fast"], + MOTION_RECORDING: ["Motion Recording", "mdi:record-rec"], +} + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the IP Amcrest camera switch platform.""" + if discovery_info is None: + return + + name = discovery_info[CONF_NAME] + device = hass.data[DATA_AMCREST][DEVICES][name] + async_add_entities( + [ + AmcrestSwitch(name, device, setting) + for setting in discovery_info[CONF_SWITCHES] + ], + True, + ) + + +class AmcrestSwitch(ToggleEntity): + """Representation of an Amcrest IP camera switch.""" + + def __init__(self, name, device, setting): + """Initialize the Amcrest switch.""" + self._name = "{} {}".format(name, SWITCHES[setting][0]) + self._signal_name = name + self._api = device.api + self._setting = setting + self._state = False + self._icon = SWITCHES[setting][1] + self._unsub_dispatcher = None + + @property + def name(self): + """Return the name of the switch if any.""" + return self._name + + @property + def is_on(self): + """Return true if switch is on.""" + return self._state + + def turn_on(self, **kwargs): + """Turn setting on.""" + if not self.available: + return + try: + if self._setting == MOTION_DETECTION: + self._api.motion_detection = "true" + elif self._setting == MOTION_RECORDING: + self._api.motion_recording = "true" + except AmcrestError as error: + log_update_error(_LOGGER, "turn on", self.name, "switch", error) + + def turn_off(self, **kwargs): + """Turn setting off.""" + if not self.available: + return + try: + if self._setting == MOTION_DETECTION: + self._api.motion_detection = "false" + elif self._setting == MOTION_RECORDING: + self._api.motion_recording = "false" + except AmcrestError as error: + log_update_error(_LOGGER, "turn off", self.name, "switch", error) + + @property + def available(self): + """Return True if entity is available.""" + return self._api.available + + def update(self): + """Update setting state.""" + if not self.available: + return + _LOGGER.debug("Updating %s switch", self._name) + + try: + if self._setting == MOTION_DETECTION: + detection = self._api.is_motion_detector_on() + elif self._setting == MOTION_RECORDING: + detection = self._api.is_record_on_motion_detection() + self._state = detection + except AmcrestError as error: + log_update_error(_LOGGER, "update", self.name, "switch", error) + + @property + def icon(self): + """Return the icon for the switch.""" + return self._icon + + async def async_on_demand_update(self): + """Update state.""" + self.async_schedule_update_ha_state(True) + + async def async_added_to_hass(self): + """Subscribe to update signal.""" + self._unsub_dispatcher = async_dispatcher_connect( + self.hass, + service_signal(SERVICE_UPDATE, self._signal_name), + self.async_on_demand_update, + ) + + async def async_will_remove_from_hass(self): + """Disconnect from update signal.""" + self._unsub_dispatcher() diff --git a/homeassistant/components/ampio/__init__.py b/homeassistant/components/ampio/__init__.py new file mode 100644 index 000000000..5f7bb4a44 --- /dev/null +++ b/homeassistant/components/ampio/__init__.py @@ -0,0 +1 @@ +"""The Ampio component.""" diff --git a/homeassistant/components/ampio/air_quality.py b/homeassistant/components/ampio/air_quality.py new file mode 100644 index 000000000..c925909a9 --- /dev/null +++ b/homeassistant/components/ampio/air_quality.py @@ -0,0 +1,92 @@ +"""Support for Ampio Air Quality data.""" +from datetime import timedelta +import logging + +from asmog import AmpioSmog +import voluptuous as vol + +from homeassistant.components.air_quality import PLATFORM_SCHEMA, AirQualityEntity +from homeassistant.const import CONF_NAME +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv +from homeassistant.util import Throttle + +_LOGGER = logging.getLogger(__name__) + +ATTRIBUTION = "Data provided by Ampio" +CONF_STATION_ID = "station_id" +SCAN_INTERVAL = timedelta(minutes=10) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + {vol.Required(CONF_STATION_ID): cv.string, vol.Optional(CONF_NAME): cv.string} +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the Ampio Smog air quality platform.""" + + name = config.get(CONF_NAME) + station_id = config[CONF_STATION_ID] + + session = async_get_clientsession(hass) + api = AmpioSmogMapData(AmpioSmog(station_id, hass.loop, session)) + + await api.async_update() + + if not api.api.data: + _LOGGER.error("Station %s is not available", station_id) + return + + async_add_entities([AmpioSmogQuality(api, station_id, name)], True) + + +class AmpioSmogQuality(AirQualityEntity): + """Implementation of an Ampio Smog air quality entity.""" + + def __init__(self, api, station_id, name): + """Initialize the air quality entity.""" + self._ampio = api + self._station_id = station_id + self._name = name or api.api.name + + @property + def name(self): + """Return the name of the air quality entity.""" + return self._name + + @property + def unique_id(self): + """Return unique_name.""" + return f"ampio_smog_{self._station_id}" + + @property + def particulate_matter_2_5(self): + """Return the particulate matter 2.5 level.""" + return self._ampio.api.pm2_5 + + @property + def particulate_matter_10(self): + """Return the particulate matter 10 level.""" + return self._ampio.api.pm10 + + @property + def attribution(self): + """Return the attribution.""" + return ATTRIBUTION + + async def async_update(self): + """Get the latest data from the AmpioMap API.""" + await self._ampio.async_update() + + +class AmpioSmogMapData: + """Get the latest data and update the states.""" + + def __init__(self, api): + """Initialize the data object.""" + self.api = api + + @Throttle(SCAN_INTERVAL) + async def async_update(self): + """Get the latest data from AmpioMap.""" + await self.api.get_data() diff --git a/homeassistant/components/ampio/manifest.json b/homeassistant/components/ampio/manifest.json new file mode 100644 index 000000000..6bf79e27f --- /dev/null +++ b/homeassistant/components/ampio/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "ampio", + "name": "Ampio", + "documentation": "https://www.home-assistant.io/integrations/ampio", + "requirements": [ + "asmog==0.0.6" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/android_ip_webcam.py b/homeassistant/components/android_ip_webcam.py deleted file mode 100644 index 5da117e74..000000000 --- a/homeassistant/components/android_ip_webcam.py +++ /dev/null @@ -1,293 +0,0 @@ -""" -Support for IP Webcam, an Android app that acts as a full-featured webcam. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/android_ip_webcam/ -""" -import asyncio -import logging -from datetime import timedelta - -import voluptuous as vol - -from homeassistant.core import callback -from homeassistant.const import ( - CONF_NAME, CONF_HOST, CONF_PORT, CONF_USERNAME, CONF_PASSWORD, - CONF_SENSORS, CONF_SWITCHES, CONF_TIMEOUT, CONF_SCAN_INTERVAL, - CONF_PLATFORM) -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import ( - async_dispatcher_send, async_dispatcher_connect) -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.event import async_track_point_in_utc_time -from homeassistant.util.dt import utcnow -from homeassistant.components.camera.mjpeg import ( - CONF_MJPEG_URL, CONF_STILL_IMAGE_URL) - -REQUIREMENTS = ['pydroid-ipcam==0.8'] - -_LOGGER = logging.getLogger(__name__) - -ATTR_AUD_CONNS = 'Audio Connections' -ATTR_HOST = 'host' -ATTR_VID_CONNS = 'Video Connections' - -CONF_MOTION_SENSOR = 'motion_sensor' - -DATA_IP_WEBCAM = 'android_ip_webcam' -DEFAULT_NAME = 'IP Webcam' -DEFAULT_PORT = 8080 -DEFAULT_TIMEOUT = 10 -DOMAIN = 'android_ip_webcam' - -SCAN_INTERVAL = timedelta(seconds=10) -SIGNAL_UPDATE_DATA = 'android_ip_webcam_update' - -KEY_MAP = { - 'audio_connections': 'Audio Connections', - 'adet_limit': 'Audio Trigger Limit', - 'antibanding': 'Anti-banding', - 'audio_only': 'Audio Only', - 'battery_level': 'Battery Level', - 'battery_temp': 'Battery Temperature', - 'battery_voltage': 'Battery Voltage', - 'coloreffect': 'Color Effect', - 'exposure': 'Exposure Level', - 'exposure_lock': 'Exposure Lock', - 'ffc': 'Front-facing Camera', - 'flashmode': 'Flash Mode', - 'focus': 'Focus', - 'focus_homing': 'Focus Homing', - 'focus_region': 'Focus Region', - 'focusmode': 'Focus Mode', - 'gps_active': 'GPS Active', - 'idle': 'Idle', - 'ip_address': 'IPv4 Address', - 'ipv6_address': 'IPv6 Address', - 'ivideon_streaming': 'Ivideon Streaming', - 'light': 'Light Level', - 'mirror_flip': 'Mirror Flip', - 'motion': 'Motion', - 'motion_active': 'Motion Active', - 'motion_detect': 'Motion Detection', - 'motion_event': 'Motion Event', - 'motion_limit': 'Motion Limit', - 'night_vision': 'Night Vision', - 'night_vision_average': 'Night Vision Average', - 'night_vision_gain': 'Night Vision Gain', - 'orientation': 'Orientation', - 'overlay': 'Overlay', - 'photo_size': 'Photo Size', - 'pressure': 'Pressure', - 'proximity': 'Proximity', - 'quality': 'Quality', - 'scenemode': 'Scene Mode', - 'sound': 'Sound', - 'sound_event': 'Sound Event', - 'sound_timeout': 'Sound Timeout', - 'torch': 'Torch', - 'video_connections': 'Video Connections', - 'video_chunk_len': 'Video Chunk Length', - 'video_recording': 'Video Recording', - 'video_size': 'Video Size', - 'whitebalance': 'White Balance', - 'whitebalance_lock': 'White Balance Lock', - 'zoom': 'Zoom' -} - -ICON_MAP = { - 'audio_connections': 'mdi:speaker', - 'battery_level': 'mdi:battery', - 'battery_temp': 'mdi:thermometer', - 'battery_voltage': 'mdi:battery-charging-100', - 'exposure_lock': 'mdi:camera', - 'ffc': 'mdi:camera-front-variant', - 'focus': 'mdi:image-filter-center-focus', - 'gps_active': 'mdi:crosshairs-gps', - 'light': 'mdi:flashlight', - 'motion': 'mdi:run', - 'night_vision': 'mdi:weather-night', - 'overlay': 'mdi:monitor', - 'pressure': 'mdi:gauge', - 'proximity': 'mdi:map-marker-radius', - 'quality': 'mdi:quality-high', - 'sound': 'mdi:speaker', - 'sound_event': 'mdi:speaker', - 'sound_timeout': 'mdi:speaker', - 'torch': 'mdi:white-balance-sunny', - 'video_chunk_len': 'mdi:video', - 'video_connections': 'mdi:eye', - 'video_recording': 'mdi:record-rec', - 'whitebalance_lock': 'mdi:white-balance-auto' -} - -SWITCHES = ['exposure_lock', 'ffc', 'focus', 'gps_active', 'night_vision', - 'overlay', 'torch', 'whitebalance_lock', 'video_recording'] - -SENSORS = ['audio_connections', 'battery_level', 'battery_temp', - 'battery_voltage', 'light', 'motion', 'pressure', 'proximity', - 'sound', 'video_connections'] - -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.All(cv.ensure_list, [vol.Schema({ - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, - vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL): - cv.time_period, - vol.Inclusive(CONF_USERNAME, 'authentication'): cv.string, - vol.Inclusive(CONF_PASSWORD, 'authentication'): cv.string, - vol.Optional(CONF_SWITCHES): - vol.All(cv.ensure_list, [vol.In(SWITCHES)]), - vol.Optional(CONF_SENSORS): - vol.All(cv.ensure_list, [vol.In(SENSORS)]), - vol.Optional(CONF_MOTION_SENSOR): cv.boolean, - })]) -}, extra=vol.ALLOW_EXTRA) - - -@asyncio.coroutine -def async_setup(hass, config): - """Set up the IP Webcam component.""" - from pydroid_ipcam import PyDroidIPCam - - webcams = hass.data[DATA_IP_WEBCAM] = {} - websession = async_get_clientsession(hass) - - @asyncio.coroutine - def async_setup_ipcamera(cam_config): - """Set up an IP camera.""" - host = cam_config[CONF_HOST] - username = cam_config.get(CONF_USERNAME) - password = cam_config.get(CONF_PASSWORD) - name = cam_config[CONF_NAME] - interval = cam_config[CONF_SCAN_INTERVAL] - switches = cam_config.get(CONF_SWITCHES) - sensors = cam_config.get(CONF_SENSORS) - motion = cam_config.get(CONF_MOTION_SENSOR) - - # Init ip webcam - cam = PyDroidIPCam( - hass.loop, websession, host, cam_config[CONF_PORT], - username=username, password=password, - timeout=cam_config[CONF_TIMEOUT] - ) - - if switches is None: - switches = [setting for setting in cam.enabled_settings - if setting in SWITCHES] - - if sensors is None: - sensors = [sensor for sensor in cam.enabled_sensors - if sensor in SENSORS] - sensors.extend(['audio_connections', 'video_connections']) - - if motion is None: - motion = 'motion_active' in cam.enabled_sensors - - @asyncio.coroutine - def async_update_data(now): - """Update data from IP camera in SCAN_INTERVAL.""" - yield from cam.update() - async_dispatcher_send(hass, SIGNAL_UPDATE_DATA, host) - - async_track_point_in_utc_time( - hass, async_update_data, utcnow() + interval) - - yield from async_update_data(None) - - # Load platforms - webcams[host] = cam - - mjpeg_camera = { - CONF_PLATFORM: 'mjpeg', - CONF_MJPEG_URL: cam.mjpeg_url, - CONF_STILL_IMAGE_URL: cam.image_url, - CONF_NAME: name, - } - if username and password: - mjpeg_camera.update({ - CONF_USERNAME: username, - CONF_PASSWORD: password - }) - - hass.async_create_task(discovery.async_load_platform( - hass, 'camera', 'mjpeg', mjpeg_camera, config)) - - if sensors: - hass.async_create_task(discovery.async_load_platform( - hass, 'sensor', DOMAIN, { - CONF_NAME: name, - CONF_HOST: host, - CONF_SENSORS: sensors, - }, config)) - - if switches: - hass.async_create_task(discovery.async_load_platform( - hass, 'switch', DOMAIN, { - CONF_NAME: name, - CONF_HOST: host, - CONF_SWITCHES: switches, - }, config)) - - if motion: - hass.async_create_task(discovery.async_load_platform( - hass, 'binary_sensor', DOMAIN, { - CONF_HOST: host, - CONF_NAME: name, - }, config)) - - tasks = [async_setup_ipcamera(conf) for conf in config[DOMAIN]] - if tasks: - yield from asyncio.wait(tasks, loop=hass.loop) - - return True - - -class AndroidIPCamEntity(Entity): - """The Android device running IP Webcam.""" - - def __init__(self, host, ipcam): - """Initialize the data object.""" - self._host = host - self._ipcam = ipcam - - @asyncio.coroutine - def async_added_to_hass(self): - """Register update dispatcher.""" - @callback - def async_ipcam_update(host): - """Update callback.""" - if self._host != host: - return - self.async_schedule_update_ha_state(True) - - async_dispatcher_connect( - self.hass, SIGNAL_UPDATE_DATA, async_ipcam_update) - - @property - def should_poll(self): - """Return True if entity has to be polled for state.""" - return False - - @property - def available(self): - """Return True if entity is available.""" - return self._ipcam.available - - @property - def device_state_attributes(self): - """Return the state attributes.""" - state_attr = {ATTR_HOST: self._host} - if self._ipcam.status_data is None: - return state_attr - - state_attr[ATTR_VID_CONNS] = \ - self._ipcam.status_data.get('video_connections') - state_attr[ATTR_AUD_CONNS] = \ - self._ipcam.status_data.get('audio_connections') - - return state_attr diff --git a/homeassistant/components/android_ip_webcam/__init__.py b/homeassistant/components/android_ip_webcam/__init__.py new file mode 100644 index 000000000..1f9df527c --- /dev/null +++ b/homeassistant/components/android_ip_webcam/__init__.py @@ -0,0 +1,334 @@ +"""Support for Android IP Webcam.""" +import asyncio +from datetime import timedelta +import logging + +from pydroid_ipcam import PyDroidIPCam +import voluptuous as vol + +from homeassistant.components.mjpeg.camera import CONF_MJPEG_URL, CONF_STILL_IMAGE_URL +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PLATFORM, + CONF_PORT, + CONF_SCAN_INTERVAL, + CONF_SENSORS, + CONF_SWITCHES, + CONF_TIMEOUT, + CONF_USERNAME, +) +from homeassistant.core import callback +from homeassistant.helpers import discovery +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.util.dt import utcnow + +_LOGGER = logging.getLogger(__name__) + +ATTR_AUD_CONNS = "Audio Connections" +ATTR_HOST = "host" +ATTR_VID_CONNS = "Video Connections" + +CONF_MOTION_SENSOR = "motion_sensor" + +DATA_IP_WEBCAM = "android_ip_webcam" +DEFAULT_NAME = "IP Webcam" +DEFAULT_PORT = 8080 +DEFAULT_TIMEOUT = 10 +DOMAIN = "android_ip_webcam" + +SCAN_INTERVAL = timedelta(seconds=10) +SIGNAL_UPDATE_DATA = "android_ip_webcam_update" + +KEY_MAP = { + "audio_connections": "Audio Connections", + "adet_limit": "Audio Trigger Limit", + "antibanding": "Anti-banding", + "audio_only": "Audio Only", + "battery_level": "Battery Level", + "battery_temp": "Battery Temperature", + "battery_voltage": "Battery Voltage", + "coloreffect": "Color Effect", + "exposure": "Exposure Level", + "exposure_lock": "Exposure Lock", + "ffc": "Front-facing Camera", + "flashmode": "Flash Mode", + "focus": "Focus", + "focus_homing": "Focus Homing", + "focus_region": "Focus Region", + "focusmode": "Focus Mode", + "gps_active": "GPS Active", + "idle": "Idle", + "ip_address": "IPv4 Address", + "ipv6_address": "IPv6 Address", + "ivideon_streaming": "Ivideon Streaming", + "light": "Light Level", + "mirror_flip": "Mirror Flip", + "motion": "Motion", + "motion_active": "Motion Active", + "motion_detect": "Motion Detection", + "motion_event": "Motion Event", + "motion_limit": "Motion Limit", + "night_vision": "Night Vision", + "night_vision_average": "Night Vision Average", + "night_vision_gain": "Night Vision Gain", + "orientation": "Orientation", + "overlay": "Overlay", + "photo_size": "Photo Size", + "pressure": "Pressure", + "proximity": "Proximity", + "quality": "Quality", + "scenemode": "Scene Mode", + "sound": "Sound", + "sound_event": "Sound Event", + "sound_timeout": "Sound Timeout", + "torch": "Torch", + "video_connections": "Video Connections", + "video_chunk_len": "Video Chunk Length", + "video_recording": "Video Recording", + "video_size": "Video Size", + "whitebalance": "White Balance", + "whitebalance_lock": "White Balance Lock", + "zoom": "Zoom", +} + +ICON_MAP = { + "audio_connections": "mdi:speaker", + "battery_level": "mdi:battery", + "battery_temp": "mdi:thermometer", + "battery_voltage": "mdi:battery-charging-100", + "exposure_lock": "mdi:camera", + "ffc": "mdi:camera-front-variant", + "focus": "mdi:image-filter-center-focus", + "gps_active": "mdi:crosshairs-gps", + "light": "mdi:flashlight", + "motion": "mdi:run", + "night_vision": "mdi:weather-night", + "overlay": "mdi:monitor", + "pressure": "mdi:gauge", + "proximity": "mdi:map-marker-radius", + "quality": "mdi:quality-high", + "sound": "mdi:speaker", + "sound_event": "mdi:speaker", + "sound_timeout": "mdi:speaker", + "torch": "mdi:white-balance-sunny", + "video_chunk_len": "mdi:video", + "video_connections": "mdi:eye", + "video_recording": "mdi:record-rec", + "whitebalance_lock": "mdi:white-balance-auto", +} + +SWITCHES = [ + "exposure_lock", + "ffc", + "focus", + "gps_active", + "motion_detect", + "night_vision", + "overlay", + "torch", + "whitebalance_lock", + "video_recording", +] + +SENSORS = [ + "audio_connections", + "battery_level", + "battery_temp", + "battery_voltage", + "light", + "motion", + "pressure", + "proximity", + "sound", + "video_connections", +] + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.All( + cv.ensure_list, + [ + vol.Schema( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional( + CONF_TIMEOUT, default=DEFAULT_TIMEOUT + ): cv.positive_int, + vol.Optional( + CONF_SCAN_INTERVAL, default=SCAN_INTERVAL + ): cv.time_period, + vol.Inclusive(CONF_USERNAME, "authentication"): cv.string, + vol.Inclusive(CONF_PASSWORD, "authentication"): cv.string, + vol.Optional(CONF_SWITCHES): vol.All( + cv.ensure_list, [vol.In(SWITCHES)] + ), + vol.Optional(CONF_SENSORS): vol.All( + cv.ensure_list, [vol.In(SENSORS)] + ), + vol.Optional(CONF_MOTION_SENSOR): cv.boolean, + } + ) + ], + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, config): + """Set up the IP Webcam component.""" + + webcams = hass.data[DATA_IP_WEBCAM] = {} + websession = async_get_clientsession(hass) + + async def async_setup_ipcamera(cam_config): + """Set up an IP camera.""" + host = cam_config[CONF_HOST] + username = cam_config.get(CONF_USERNAME) + password = cam_config.get(CONF_PASSWORD) + name = cam_config[CONF_NAME] + interval = cam_config[CONF_SCAN_INTERVAL] + switches = cam_config.get(CONF_SWITCHES) + sensors = cam_config.get(CONF_SENSORS) + motion = cam_config.get(CONF_MOTION_SENSOR) + + # Init ip webcam + cam = PyDroidIPCam( + hass.loop, + websession, + host, + cam_config[CONF_PORT], + username=username, + password=password, + timeout=cam_config[CONF_TIMEOUT], + ) + + if switches is None: + switches = [ + setting for setting in cam.enabled_settings if setting in SWITCHES + ] + + if sensors is None: + sensors = [sensor for sensor in cam.enabled_sensors if sensor in SENSORS] + sensors.extend(["audio_connections", "video_connections"]) + + if motion is None: + motion = "motion_active" in cam.enabled_sensors + + async def async_update_data(now): + """Update data from IP camera in SCAN_INTERVAL.""" + await cam.update() + async_dispatcher_send(hass, SIGNAL_UPDATE_DATA, host) + + async_track_point_in_utc_time(hass, async_update_data, utcnow() + interval) + + await async_update_data(None) + + # Load platforms + webcams[host] = cam + + mjpeg_camera = { + CONF_PLATFORM: "mjpeg", + CONF_MJPEG_URL: cam.mjpeg_url, + CONF_STILL_IMAGE_URL: cam.image_url, + CONF_NAME: name, + } + if username and password: + mjpeg_camera.update({CONF_USERNAME: username, CONF_PASSWORD: password}) + + hass.async_create_task( + discovery.async_load_platform(hass, "camera", "mjpeg", mjpeg_camera, config) + ) + + if sensors: + hass.async_create_task( + discovery.async_load_platform( + hass, + "sensor", + DOMAIN, + {CONF_NAME: name, CONF_HOST: host, CONF_SENSORS: sensors}, + config, + ) + ) + + if switches: + hass.async_create_task( + discovery.async_load_platform( + hass, + "switch", + DOMAIN, + {CONF_NAME: name, CONF_HOST: host, CONF_SWITCHES: switches}, + config, + ) + ) + + if motion: + hass.async_create_task( + discovery.async_load_platform( + hass, + "binary_sensor", + DOMAIN, + {CONF_HOST: host, CONF_NAME: name}, + config, + ) + ) + + tasks = [async_setup_ipcamera(conf) for conf in config[DOMAIN]] + if tasks: + await asyncio.wait(tasks) + + return True + + +class AndroidIPCamEntity(Entity): + """The Android device running IP Webcam.""" + + def __init__(self, host, ipcam): + """Initialize the data object.""" + self._host = host + self._ipcam = ipcam + + async def async_added_to_hass(self): + """Register update dispatcher.""" + + @callback + def async_ipcam_update(host): + """Update callback.""" + if self._host != host: + return + self.async_schedule_update_ha_state(True) + + async_dispatcher_connect(self.hass, SIGNAL_UPDATE_DATA, async_ipcam_update) + + @property + def should_poll(self): + """Return True if entity has to be polled for state.""" + return False + + @property + def available(self): + """Return True if entity is available.""" + return self._ipcam.available + + @property + def device_state_attributes(self): + """Return the state attributes.""" + state_attr = {ATTR_HOST: self._host} + if self._ipcam.status_data is None: + return state_attr + + state_attr[ATTR_VID_CONNS] = self._ipcam.status_data.get("video_connections") + state_attr[ATTR_AUD_CONNS] = self._ipcam.status_data.get("audio_connections") + + return state_attr diff --git a/homeassistant/components/android_ip_webcam/binary_sensor.py b/homeassistant/components/android_ip_webcam/binary_sensor.py new file mode 100644 index 000000000..0e9cca46a --- /dev/null +++ b/homeassistant/components/android_ip_webcam/binary_sensor.py @@ -0,0 +1,50 @@ +"""Support for Android IP Webcam binary sensors.""" +from homeassistant.components.binary_sensor import BinarySensorDevice + +from . import CONF_HOST, CONF_NAME, DATA_IP_WEBCAM, KEY_MAP, AndroidIPCamEntity + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the IP Webcam binary sensors.""" + if discovery_info is None: + return + + host = discovery_info[CONF_HOST] + name = discovery_info[CONF_NAME] + ipcam = hass.data[DATA_IP_WEBCAM][host] + + async_add_entities([IPWebcamBinarySensor(name, host, ipcam, "motion_active")], True) + + +class IPWebcamBinarySensor(AndroidIPCamEntity, BinarySensorDevice): + """Representation of an IP Webcam binary sensor.""" + + def __init__(self, name, host, ipcam, sensor): + """Initialize the binary sensor.""" + super().__init__(host, ipcam) + + self._sensor = sensor + self._mapped_name = KEY_MAP.get(self._sensor, self._sensor) + self._name = f"{name} {self._mapped_name}" + self._state = None + self._unit = None + + @property + def name(self): + """Return the name of the binary sensor, if any.""" + return self._name + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self._state + + async def async_update(self): + """Retrieve latest state.""" + state, _ = self._ipcam.export_sensor(self._sensor) + self._state = state == 1.0 + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return "motion" diff --git a/homeassistant/components/android_ip_webcam/manifest.json b/homeassistant/components/android_ip_webcam/manifest.json new file mode 100644 index 000000000..c9602d757 --- /dev/null +++ b/homeassistant/components/android_ip_webcam/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "android_ip_webcam", + "name": "Android ip webcam", + "documentation": "https://www.home-assistant.io/integrations/android_ip_webcam", + "requirements": [ + "pydroid-ipcam==0.8" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/android_ip_webcam/sensor.py b/homeassistant/components/android_ip_webcam/sensor.py new file mode 100644 index 000000000..05c1fe16c --- /dev/null +++ b/homeassistant/components/android_ip_webcam/sensor.py @@ -0,0 +1,76 @@ +"""Support for Android IP Webcam sensors.""" +from homeassistant.helpers.icon import icon_for_battery_level + +from . import ( + CONF_HOST, + CONF_NAME, + CONF_SENSORS, + DATA_IP_WEBCAM, + ICON_MAP, + KEY_MAP, + AndroidIPCamEntity, +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the IP Webcam Sensor.""" + if discovery_info is None: + return + + host = discovery_info[CONF_HOST] + name = discovery_info[CONF_NAME] + sensors = discovery_info[CONF_SENSORS] + ipcam = hass.data[DATA_IP_WEBCAM][host] + + all_sensors = [] + + for sensor in sensors: + all_sensors.append(IPWebcamSensor(name, host, ipcam, sensor)) + + async_add_entities(all_sensors, True) + + +class IPWebcamSensor(AndroidIPCamEntity): + """Representation of a IP Webcam sensor.""" + + def __init__(self, name, host, ipcam, sensor): + """Initialize the sensor.""" + super().__init__(host, ipcam) + + self._sensor = sensor + self._mapped_name = KEY_MAP.get(self._sensor, self._sensor) + self._name = f"{name} {self._mapped_name}" + self._state = None + self._unit = None + + @property + def name(self): + """Return the name of the sensor, if any.""" + return self._name + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self._unit + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + async def async_update(self): + """Retrieve latest state.""" + if self._sensor in ("audio_connections", "video_connections"): + if not self._ipcam.status_data: + return + self._state = self._ipcam.status_data.get(self._sensor) + self._unit = "Connections" + else: + self._state, self._unit = self._ipcam.export_sensor(self._sensor) + + @property + def icon(self): + """Return the icon for the sensor.""" + if self._sensor == "battery_level" and self._state is not None: + return icon_for_battery_level(int(self._state)) + return ICON_MAP.get(self._sensor, "mdi:eye") diff --git a/homeassistant/components/android_ip_webcam/switch.py b/homeassistant/components/android_ip_webcam/switch.py new file mode 100644 index 000000000..2d5f2412d --- /dev/null +++ b/homeassistant/components/android_ip_webcam/switch.py @@ -0,0 +1,88 @@ +"""Support for Android IP Webcam settings.""" +from homeassistant.components.switch import SwitchDevice + +from . import ( + CONF_HOST, + CONF_NAME, + CONF_SWITCHES, + DATA_IP_WEBCAM, + ICON_MAP, + KEY_MAP, + AndroidIPCamEntity, +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the IP Webcam switch platform.""" + if discovery_info is None: + return + + host = discovery_info[CONF_HOST] + name = discovery_info[CONF_NAME] + switches = discovery_info[CONF_SWITCHES] + ipcam = hass.data[DATA_IP_WEBCAM][host] + + all_switches = [] + + for setting in switches: + all_switches.append(IPWebcamSettingsSwitch(name, host, ipcam, setting)) + + async_add_entities(all_switches, True) + + +class IPWebcamSettingsSwitch(AndroidIPCamEntity, SwitchDevice): + """An abstract class for an IP Webcam setting.""" + + def __init__(self, name, host, ipcam, setting): + """Initialize the settings switch.""" + super().__init__(host, ipcam) + + self._setting = setting + self._mapped_name = KEY_MAP.get(self._setting, self._setting) + self._name = f"{name} {self._mapped_name}" + self._state = False + + @property + def name(self): + """Return the name of the node.""" + return self._name + + async def async_update(self): + """Get the updated status of the switch.""" + self._state = bool(self._ipcam.current_settings.get(self._setting)) + + @property + def is_on(self): + """Return the boolean response if the node is on.""" + return self._state + + async def async_turn_on(self, **kwargs): + """Turn device on.""" + if self._setting == "torch": + await self._ipcam.torch(activate=True) + elif self._setting == "focus": + await self._ipcam.focus(activate=True) + elif self._setting == "video_recording": + await self._ipcam.record(record=True) + else: + await self._ipcam.change_setting(self._setting, True) + self._state = True + self.async_schedule_update_ha_state() + + async def async_turn_off(self, **kwargs): + """Turn device off.""" + if self._setting == "torch": + await self._ipcam.torch(activate=False) + elif self._setting == "focus": + await self._ipcam.focus(activate=False) + elif self._setting == "video_recording": + await self._ipcam.record(record=False) + else: + await self._ipcam.change_setting(self._setting, False) + self._state = False + self.async_schedule_update_ha_state() + + @property + def icon(self): + """Return the icon for the switch.""" + return ICON_MAP.get(self._setting, "mdi:flash") diff --git a/homeassistant/components/androidtv/__init__.py b/homeassistant/components/androidtv/__init__.py new file mode 100644 index 000000000..14832aef3 --- /dev/null +++ b/homeassistant/components/androidtv/__init__.py @@ -0,0 +1 @@ +"""Support for functionality to interact with Android TV/Fire TV devices.""" diff --git a/homeassistant/components/androidtv/manifest.json b/homeassistant/components/androidtv/manifest.json new file mode 100644 index 000000000..39e5bfb2c --- /dev/null +++ b/homeassistant/components/androidtv/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "androidtv", + "name": "Androidtv", + "documentation": "https://www.home-assistant.io/integrations/androidtv", + "requirements": [ + "adb-shell==0.1.0", + "androidtv==0.0.36", + "pure-python-adb==0.2.2.dev0" + ], + "dependencies": [], + "codeowners": ["@JeffLIrion"] +} diff --git a/homeassistant/components/androidtv/media_player.py b/homeassistant/components/androidtv/media_player.py new file mode 100644 index 000000000..15acd594b --- /dev/null +++ b/homeassistant/components/androidtv/media_player.py @@ -0,0 +1,599 @@ +"""Support for functionality to interact with Android TV / Fire TV devices.""" +import functools +import logging +import os + +from adb_shell.auth.keygen import keygen +from adb_shell.exceptions import ( + InvalidChecksumError, + InvalidCommandError, + InvalidResponseError, + TcpTimeoutException, +) +from androidtv import ha_state_detection_rules_validator, setup +from androidtv.constants import APPS, KEYS +import voluptuous as vol + +from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerDevice +from homeassistant.components.media_player.const import ( + SUPPORT_NEXT_TRACK, + SUPPORT_PAUSE, + SUPPORT_PLAY, + SUPPORT_PREVIOUS_TRACK, + SUPPORT_SELECT_SOURCE, + SUPPORT_STOP, + SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, + SUPPORT_VOLUME_MUTE, + SUPPORT_VOLUME_STEP, +) +from homeassistant.const import ( + ATTR_COMMAND, + ATTR_ENTITY_ID, + CONF_DEVICE_CLASS, + CONF_HOST, + CONF_NAME, + CONF_PORT, + STATE_IDLE, + STATE_OFF, + STATE_PAUSED, + STATE_PLAYING, + STATE_STANDBY, +) +from homeassistant.exceptions import PlatformNotReady +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.storage import STORAGE_DIR + +ANDROIDTV_DOMAIN = "androidtv" + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_ANDROIDTV = ( + SUPPORT_PAUSE + | SUPPORT_PLAY + | SUPPORT_TURN_ON + | SUPPORT_TURN_OFF + | SUPPORT_PREVIOUS_TRACK + | SUPPORT_NEXT_TRACK + | SUPPORT_SELECT_SOURCE + | SUPPORT_STOP + | SUPPORT_VOLUME_MUTE + | SUPPORT_VOLUME_STEP +) + +SUPPORT_FIRETV = ( + SUPPORT_PAUSE + | SUPPORT_PLAY + | SUPPORT_TURN_ON + | SUPPORT_TURN_OFF + | SUPPORT_PREVIOUS_TRACK + | SUPPORT_NEXT_TRACK + | SUPPORT_SELECT_SOURCE + | SUPPORT_STOP +) + +CONF_ADBKEY = "adbkey" +CONF_ADB_SERVER_IP = "adb_server_ip" +CONF_ADB_SERVER_PORT = "adb_server_port" +CONF_APPS = "apps" +CONF_GET_SOURCES = "get_sources" +CONF_STATE_DETECTION_RULES = "state_detection_rules" +CONF_TURN_ON_COMMAND = "turn_on_command" +CONF_TURN_OFF_COMMAND = "turn_off_command" + +DEFAULT_NAME = "Android TV" +DEFAULT_PORT = 5555 +DEFAULT_ADB_SERVER_PORT = 5037 +DEFAULT_GET_SOURCES = True +DEFAULT_DEVICE_CLASS = "auto" + +DEVICE_ANDROIDTV = "androidtv" +DEVICE_FIRETV = "firetv" +DEVICE_CLASSES = [DEFAULT_DEVICE_CLASS, DEVICE_ANDROIDTV, DEVICE_FIRETV] + +SERVICE_ADB_COMMAND = "adb_command" + +SERVICE_ADB_COMMAND_SCHEMA = vol.Schema( + {vol.Required(ATTR_ENTITY_ID): cv.entity_ids, vol.Required(ATTR_COMMAND): cv.string} +) + + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_DEVICE_CLASS, default=DEFAULT_DEVICE_CLASS): vol.In( + DEVICE_CLASSES + ), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_ADBKEY): cv.isfile, + vol.Optional(CONF_ADB_SERVER_IP): cv.string, + vol.Optional(CONF_ADB_SERVER_PORT, default=DEFAULT_ADB_SERVER_PORT): cv.port, + vol.Optional(CONF_GET_SOURCES, default=DEFAULT_GET_SOURCES): cv.boolean, + vol.Optional(CONF_APPS, default=dict()): vol.Schema({cv.string: cv.string}), + vol.Optional(CONF_TURN_ON_COMMAND): cv.string, + vol.Optional(CONF_TURN_OFF_COMMAND): cv.string, + vol.Optional(CONF_STATE_DETECTION_RULES, default={}): vol.Schema( + {cv.string: ha_state_detection_rules_validator(vol.Invalid)} + ), + } +) + +# Translate from `AndroidTV` / `FireTV` reported state to HA state. +ANDROIDTV_STATES = { + "off": STATE_OFF, + "idle": STATE_IDLE, + "standby": STATE_STANDBY, + "playing": STATE_PLAYING, + "paused": STATE_PAUSED, +} + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Android TV / Fire TV platform.""" + hass.data.setdefault(ANDROIDTV_DOMAIN, {}) + + host = f"{config[CONF_HOST]}:{config[CONF_PORT]}" + + if CONF_ADB_SERVER_IP not in config: + # Use "adb_shell" (Python ADB implementation) + if CONF_ADBKEY not in config: + # Generate ADB key files (if they don't exist) + adbkey = hass.config.path(STORAGE_DIR, "androidtv_adbkey") + if not os.path.isfile(adbkey): + keygen(adbkey) + + adb_log = f"using Python ADB implementation with adbkey='{adbkey}'" + + aftv = setup( + config[CONF_HOST], + config[CONF_PORT], + adbkey, + device_class=config[CONF_DEVICE_CLASS], + state_detection_rules=config[CONF_STATE_DETECTION_RULES], + auth_timeout_s=10.0, + ) + + else: + adb_log = ( + f"using Python ADB implementation with adbkey='{config[CONF_ADBKEY]}'" + ) + + aftv = setup( + config[CONF_HOST], + config[CONF_PORT], + config[CONF_ADBKEY], + device_class=config[CONF_DEVICE_CLASS], + state_detection_rules=config[CONF_STATE_DETECTION_RULES], + auth_timeout_s=10.0, + ) + + else: + # Use "pure-python-adb" (communicate with ADB server) + adb_log = f"using ADB server at {config[CONF_ADB_SERVER_IP]}:{config[CONF_ADB_SERVER_PORT]}" + + aftv = setup( + config[CONF_HOST], + config[CONF_PORT], + adb_server_ip=config[CONF_ADB_SERVER_IP], + adb_server_port=config[CONF_ADB_SERVER_PORT], + device_class=config[CONF_DEVICE_CLASS], + state_detection_rules=config[CONF_STATE_DETECTION_RULES], + ) + + if not aftv.available: + # Determine the name that will be used for the device in the log + if CONF_NAME in config: + device_name = config[CONF_NAME] + elif config[CONF_DEVICE_CLASS] == DEVICE_ANDROIDTV: + device_name = "Android TV device" + elif config[CONF_DEVICE_CLASS] == DEVICE_FIRETV: + device_name = "Fire TV device" + else: + device_name = "Android TV / Fire TV device" + + _LOGGER.warning("Could not connect to %s at %s %s", device_name, host, adb_log) + raise PlatformNotReady + + if host in hass.data[ANDROIDTV_DOMAIN]: + _LOGGER.warning("Platform already setup on %s, skipping", host) + else: + if aftv.DEVICE_CLASS == DEVICE_ANDROIDTV: + device = AndroidTVDevice( + aftv, + config[CONF_NAME], + config[CONF_APPS], + config[CONF_GET_SOURCES], + config.get(CONF_TURN_ON_COMMAND), + config.get(CONF_TURN_OFF_COMMAND), + ) + device_name = config[CONF_NAME] if CONF_NAME in config else "Android TV" + else: + device = FireTVDevice( + aftv, + config[CONF_NAME], + config[CONF_APPS], + config[CONF_GET_SOURCES], + config.get(CONF_TURN_ON_COMMAND), + config.get(CONF_TURN_OFF_COMMAND), + ) + device_name = config[CONF_NAME] if CONF_NAME in config else "Fire TV" + + add_entities([device]) + _LOGGER.debug("Setup %s at %s %s", device_name, host, adb_log) + hass.data[ANDROIDTV_DOMAIN][host] = device + + if hass.services.has_service(ANDROIDTV_DOMAIN, SERVICE_ADB_COMMAND): + return + + def service_adb_command(service): + """Dispatch service calls to target entities.""" + cmd = service.data.get(ATTR_COMMAND) + entity_id = service.data.get(ATTR_ENTITY_ID) + target_devices = [ + dev + for dev in hass.data[ANDROIDTV_DOMAIN].values() + if dev.entity_id in entity_id + ] + + for target_device in target_devices: + output = target_device.adb_command(cmd) + + # log the output, if there is any + if output: + _LOGGER.info( + "Output of command '%s' from '%s': %s", + cmd, + target_device.entity_id, + output, + ) + + hass.services.register( + ANDROIDTV_DOMAIN, + SERVICE_ADB_COMMAND, + service_adb_command, + schema=SERVICE_ADB_COMMAND_SCHEMA, + ) + + +def adb_decorator(override_available=False): + """Wrap ADB methods and catch exceptions. + + Allows for overriding the available status of the ADB connection via the + `override_available` parameter. + """ + + def _adb_decorator(func): + """Wrap the provided ADB method and catch exceptions.""" + + @functools.wraps(func) + def _adb_exception_catcher(self, *args, **kwargs): + """Call an ADB-related method and catch exceptions.""" + if not self.available and not override_available: + return None + + try: + return func(self, *args, **kwargs) + except self.exceptions as err: + _LOGGER.error( + "Failed to execute an ADB command. ADB connection re-" + "establishing attempt in the next update. Error: %s", + err, + ) + self.aftv.adb_close() + self._available = False # pylint: disable=protected-access + return None + + return _adb_exception_catcher + + return _adb_decorator + + +class ADBDevice(MediaPlayerDevice): + """Representation of an Android TV or Fire TV device.""" + + def __init__( + self, aftv, name, apps, get_sources, turn_on_command, turn_off_command + ): + """Initialize the Android TV / Fire TV device.""" + self.aftv = aftv + self._name = name + self._app_id_to_name = APPS.copy() + self._app_id_to_name.update(apps) + self._app_name_to_id = { + value: key for key, value in self._app_id_to_name.items() + } + self._get_sources = get_sources + self._keys = KEYS + + self._device_properties = self.aftv.device_properties + self._unique_id = self._device_properties.get("serialno") + + self.turn_on_command = turn_on_command + self.turn_off_command = turn_off_command + + # ADB exceptions to catch + if not self.aftv.adb_server_ip: + # Using "adb_shell" (Python ADB implementation) + self.exceptions = ( + AttributeError, + BrokenPipeError, + TypeError, + ValueError, + InvalidChecksumError, + InvalidCommandError, + InvalidResponseError, + TcpTimeoutException, + ) + else: + # Using "pure-python-adb" (communicate with ADB server) + self.exceptions = (ConnectionResetError, RuntimeError) + + # Property attributes + self._adb_response = None + self._available = True + self._current_app = None + self._sources = None + self._state = None + + @property + def app_id(self): + """Return the current app.""" + return self._current_app + + @property + def app_name(self): + """Return the friendly name of the current app.""" + return self._app_id_to_name.get(self._current_app, self._current_app) + + @property + def available(self): + """Return whether or not the ADB connection is valid.""" + return self._available + + @property + def device_state_attributes(self): + """Provide the last ADB command's response as an attribute.""" + return {"adb_response": self._adb_response} + + @property + def name(self): + """Return the device name.""" + return self._name + + @property + def should_poll(self): + """Device should be polled.""" + return True + + @property + def source(self): + """Return the current app.""" + return self._app_id_to_name.get(self._current_app, self._current_app) + + @property + def source_list(self): + """Return a list of running apps.""" + return self._sources + + @property + def state(self): + """Return the state of the player.""" + return self._state + + @property + def unique_id(self): + """Return the device unique id.""" + return self._unique_id + + @adb_decorator() + def media_play(self): + """Send play command.""" + self.aftv.media_play() + + @adb_decorator() + def media_pause(self): + """Send pause command.""" + self.aftv.media_pause() + + @adb_decorator() + def media_play_pause(self): + """Send play/pause command.""" + self.aftv.media_play_pause() + + @adb_decorator() + def turn_on(self): + """Turn on the device.""" + if self.turn_on_command: + self.aftv.adb_shell(self.turn_on_command) + else: + self.aftv.turn_on() + + @adb_decorator() + def turn_off(self): + """Turn off the device.""" + if self.turn_off_command: + self.aftv.adb_shell(self.turn_off_command) + else: + self.aftv.turn_off() + + @adb_decorator() + def media_previous_track(self): + """Send previous track command (results in rewind).""" + self.aftv.media_previous_track() + + @adb_decorator() + def media_next_track(self): + """Send next track command (results in fast-forward).""" + self.aftv.media_next_track() + + @adb_decorator() + def select_source(self, source): + """Select input source. + + If the source starts with a '!', then it will close the app instead of + opening it. + """ + if isinstance(source, str): + if not source.startswith("!"): + self.aftv.launch_app(self._app_name_to_id.get(source, source)) + else: + source_ = source[1:].lstrip() + self.aftv.stop_app(self._app_name_to_id.get(source_, source_)) + + @adb_decorator() + def adb_command(self, cmd): + """Send an ADB command to an Android TV / Fire TV device.""" + key = self._keys.get(cmd) + if key: + self.aftv.adb_shell(f"input keyevent {key}") + self._adb_response = None + self.schedule_update_ha_state() + return + + if cmd == "GET_PROPERTIES": + self._adb_response = str(self.aftv.get_properties_dict()) + self.schedule_update_ha_state() + return self._adb_response + + response = self.aftv.adb_shell(cmd) + if isinstance(response, str) and response.strip(): + self._adb_response = response.strip() + else: + self._adb_response = None + + self.schedule_update_ha_state() + return self._adb_response + + +class AndroidTVDevice(ADBDevice): + """Representation of an Android TV device.""" + + def __init__( + self, aftv, name, apps, get_sources, turn_on_command, turn_off_command + ): + """Initialize the Android TV device.""" + super().__init__( + aftv, name, apps, get_sources, turn_on_command, turn_off_command + ) + + self._is_volume_muted = None + self._volume_level = None + + @adb_decorator(override_available=True) + def update(self): + """Update the device state and, if necessary, re-connect.""" + # Check if device is disconnected. + if not self._available: + # Try to connect + self._available = self.aftv.adb_connect(always_log_errors=False) + + # To be safe, wait until the next update to run ADB commands if + # using the Python ADB implementation. + if not self.aftv.adb_server_ip: + return + + # If the ADB connection is not intact, don't update. + if not self._available: + return + + # Get the updated state and attributes. + ( + state, + self._current_app, + running_apps, + _, + self._is_volume_muted, + self._volume_level, + ) = self.aftv.update(self._get_sources) + + self._state = ANDROIDTV_STATES.get(state) + if self._state is None: + self._available = False + + if running_apps: + self._sources = [ + self._app_id_to_name.get(app_id, app_id) for app_id in running_apps + ] + else: + self._sources = None + + @property + def is_volume_muted(self): + """Boolean if volume is currently muted.""" + return self._is_volume_muted + + @property + def supported_features(self): + """Flag media player features that are supported.""" + return SUPPORT_ANDROIDTV + + @property + def volume_level(self): + """Return the volume level.""" + return self._volume_level + + @adb_decorator() + def media_stop(self): + """Send stop command.""" + self.aftv.media_stop() + + @adb_decorator() + def mute_volume(self, mute): + """Mute the volume.""" + self.aftv.mute_volume() + + @adb_decorator() + def volume_down(self): + """Send volume down command.""" + self._volume_level = self.aftv.volume_down(self._volume_level) + + @adb_decorator() + def volume_up(self): + """Send volume up command.""" + self._volume_level = self.aftv.volume_up(self._volume_level) + + +class FireTVDevice(ADBDevice): + """Representation of a Fire TV device.""" + + @adb_decorator(override_available=True) + def update(self): + """Update the device state and, if necessary, re-connect.""" + # Check if device is disconnected. + if not self._available: + # Try to connect + self._available = self.aftv.adb_connect(always_log_errors=False) + + # To be safe, wait until the next update to run ADB commands if + # using the Python ADB implementation. + if not self.aftv.adb_server_ip: + return + + # If the ADB connection is not intact, don't update. + if not self._available: + return + + # Get the `state`, `current_app`, and `running_apps`. + state, self._current_app, running_apps = self.aftv.update(self._get_sources) + + self._state = ANDROIDTV_STATES.get(state) + if self._state is None: + self._available = False + + if running_apps: + self._sources = [ + self._app_id_to_name.get(app_id, app_id) for app_id in running_apps + ] + else: + self._sources = None + + @property + def supported_features(self): + """Flag media player features that are supported.""" + return SUPPORT_FIRETV + + @adb_decorator() + def media_stop(self): + """Send stop (back) command.""" + self.aftv.back() diff --git a/homeassistant/components/androidtv/services.yaml b/homeassistant/components/androidtv/services.yaml new file mode 100644 index 000000000..78ff0a828 --- /dev/null +++ b/homeassistant/components/androidtv/services.yaml @@ -0,0 +1,11 @@ +# Describes the format for available Android TV and Fire TV services + +adb_command: + description: Send an ADB command to an Android TV / Fire TV device. + fields: + entity_id: + description: Name(s) of Android TV / Fire TV entities. + example: 'media_player.android_tv_living_room' + command: + description: Either a key command or an ADB shell command. + example: 'HOME' diff --git a/homeassistant/components/anel_pwrctrl/__init__.py b/homeassistant/components/anel_pwrctrl/__init__.py new file mode 100644 index 000000000..bd06aa87b --- /dev/null +++ b/homeassistant/components/anel_pwrctrl/__init__.py @@ -0,0 +1 @@ +"""The anel_pwrctrl component.""" diff --git a/homeassistant/components/anel_pwrctrl/manifest.json b/homeassistant/components/anel_pwrctrl/manifest.json new file mode 100644 index 000000000..d4055a406 --- /dev/null +++ b/homeassistant/components/anel_pwrctrl/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "anel_pwrctrl", + "name": "Anel pwrctrl", + "documentation": "https://www.home-assistant.io/integrations/anel_pwrctrl", + "requirements": [ + "anel_pwrctrl-homeassistant==0.0.1.dev2" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/anel_pwrctrl/switch.py b/homeassistant/components/anel_pwrctrl/switch.py new file mode 100644 index 000000000..3c181d7d0 --- /dev/null +++ b/homeassistant/components/anel_pwrctrl/switch.py @@ -0,0 +1,115 @@ +"""Support for ANEL PwrCtrl switches.""" +from datetime import timedelta +import logging +import socket + +from anel_pwrctrl import DeviceMaster +import voluptuous as vol + +from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +import homeassistant.helpers.config_validation as cv +from homeassistant.util import Throttle + +_LOGGER = logging.getLogger(__name__) + +CONF_PORT_RECV = "port_recv" +CONF_PORT_SEND = "port_send" + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=5) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_PORT_RECV): cv.port, + vol.Required(CONF_PORT_SEND): cv.port, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_HOST): cv.string, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up PwrCtrl devices/switches.""" + host = config.get(CONF_HOST, None) + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + port_recv = config.get(CONF_PORT_RECV) + port_send = config.get(CONF_PORT_SEND) + + try: + master = DeviceMaster( + username=username, + password=password, + read_port=port_send, + write_port=port_recv, + ) + master.query(ip_addr=host) + except socket.error as ex: + _LOGGER.error("Unable to discover PwrCtrl device: %s", str(ex)) + return False + + devices = [] + for device in master.devices.values(): + parent_device = PwrCtrlDevice(device) + devices.extend( + PwrCtrlSwitch(switch, parent_device) for switch in device.switches.values() + ) + + add_entities(devices) + + +class PwrCtrlSwitch(SwitchDevice): + """Representation of a PwrCtrl switch.""" + + def __init__(self, port, parent_device): + """Initialize the PwrCtrl switch.""" + self._port = port + self._parent_device = parent_device + + @property + def should_poll(self): + """Return the polling state.""" + return True + + @property + def unique_id(self): + """Return the unique ID of the device.""" + return "{device}-{switch_idx}".format( + device=self._port.device.host, switch_idx=self._port.get_index() + ) + + @property + def name(self): + """Return the name of the device.""" + return self._port.label + + @property + def is_on(self): + """Return true if the device is on.""" + return self._port.get_state() + + def update(self): + """Trigger update for all switches on the parent device.""" + self._parent_device.update() + + def turn_on(self, **kwargs): + """Turn the switch on.""" + self._port.on() + + def turn_off(self, **kwargs): + """Turn the switch off.""" + self._port.off() + + +class PwrCtrlDevice: + """Device representation for per device throttling.""" + + def __init__(self, device): + """Initialize the PwrCtrl device.""" + self._device = device + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Update the device and all its switches.""" + self._device.update() diff --git a/homeassistant/components/anthemav/__init__.py b/homeassistant/components/anthemav/__init__.py new file mode 100644 index 000000000..56b06e865 --- /dev/null +++ b/homeassistant/components/anthemav/__init__.py @@ -0,0 +1 @@ +"""The anthemav component.""" diff --git a/homeassistant/components/anthemav/manifest.json b/homeassistant/components/anthemav/manifest.json new file mode 100644 index 000000000..7c648d37b --- /dev/null +++ b/homeassistant/components/anthemav/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "anthemav", + "name": "Anthemav", + "documentation": "https://www.home-assistant.io/integrations/anthemav", + "requirements": [ + "anthemav==1.1.10" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/anthemav/media_player.py b/homeassistant/components/anthemav/media_player.py new file mode 100644 index 000000000..f7b385d80 --- /dev/null +++ b/homeassistant/components/anthemav/media_player.py @@ -0,0 +1,177 @@ +"""Support for Anthem Network Receivers and Processors.""" +import logging + +import anthemav +import voluptuous as vol + +from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerDevice +from homeassistant.components.media_player.const import ( + SUPPORT_SELECT_SOURCE, + SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, + SUPPORT_VOLUME_MUTE, + SUPPORT_VOLUME_SET, +) +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PORT, + EVENT_HOMEASSISTANT_STOP, + STATE_OFF, + STATE_ON, +) +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "anthemav" + +DEFAULT_PORT = 14999 + +SUPPORT_ANTHEMAV = ( + SUPPORT_VOLUME_SET + | SUPPORT_VOLUME_MUTE + | SUPPORT_TURN_ON + | SUPPORT_TURN_OFF + | SUPPORT_SELECT_SOURCE +) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + } +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up our socket to the AVR.""" + + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) + name = config.get(CONF_NAME) + device = None + + _LOGGER.info("Provisioning Anthem AVR device at %s:%d", host, port) + + def async_anthemav_update_callback(message): + """Receive notification from transport that new data exists.""" + _LOGGER.info("Received update callback from AVR: %s", message) + hass.async_create_task(device.async_update_ha_state()) + + avr = await anthemav.Connection.create( + host=host, port=port, update_callback=async_anthemav_update_callback + ) + + device = AnthemAVR(avr, name) + + _LOGGER.debug("dump_devicedata: %s", device.dump_avrdata) + _LOGGER.debug("dump_conndata: %s", avr.dump_conndata) + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, device.avr.close) + async_add_entities([device]) + + +class AnthemAVR(MediaPlayerDevice): + """Entity reading values from Anthem AVR protocol.""" + + def __init__(self, avr, name): + """Initialize entity with transport.""" + super().__init__() + self.avr = avr + self._name = name + + def _lookup(self, propname, dval=None): + return getattr(self.avr.protocol, propname, dval) + + @property + def supported_features(self): + """Flag media player features that are supported.""" + return SUPPORT_ANTHEMAV + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def name(self): + """Return name of device.""" + return self._name or self._lookup("model") + + @property + def state(self): + """Return state of power on/off.""" + pwrstate = self._lookup("power") + + if pwrstate is True: + return STATE_ON + if pwrstate is False: + return STATE_OFF + return None + + @property + def is_volume_muted(self): + """Return boolean reflecting mute state on device.""" + return self._lookup("mute", False) + + @property + def volume_level(self): + """Return volume level from 0 to 1.""" + return self._lookup("volume_as_percentage", 0.0) + + @property + def media_title(self): + """Return current input name (closest we have to media title).""" + return self._lookup("input_name", "No Source") + + @property + def app_name(self): + """Return details about current video and audio stream.""" + return ( + self._lookup("video_input_resolution_text", "") + + " " + + self._lookup("audio_input_name", "") + ) + + @property + def source(self): + """Return currently selected input.""" + return self._lookup("input_name", "Unknown") + + @property + def source_list(self): + """Return all active, configured inputs.""" + return self._lookup("input_list", ["Unknown"]) + + async def async_select_source(self, source): + """Change AVR to the designated source (by name).""" + self._update_avr("input_name", source) + + async def async_turn_off(self): + """Turn AVR power off.""" + self._update_avr("power", False) + + async def async_turn_on(self): + """Turn AVR power on.""" + self._update_avr("power", True) + + async def async_set_volume_level(self, volume): + """Set AVR volume (0 to 1).""" + self._update_avr("volume_as_percentage", volume) + + async def async_mute_volume(self, mute): + """Engage AVR mute.""" + self._update_avr("mute", mute) + + def _update_avr(self, propname, value): + """Update a property in the AVR.""" + _LOGGER.info("Sending command to AVR: set %s to %s", propname, str(value)) + setattr(self.avr.protocol, propname, value) + + @property + def dump_avrdata(self): + """Return state of avr object for debugging forensics.""" + attrs = vars(self) + return "dump_avrdata: " + ", ".join("%s: %s" % item for item in attrs.items()) diff --git a/homeassistant/components/apache_kafka/__init__.py b/homeassistant/components/apache_kafka/__init__.py new file mode 100644 index 000000000..7bd23630b --- /dev/null +++ b/homeassistant/components/apache_kafka/__init__.py @@ -0,0 +1,117 @@ +"""Support for Apache Kafka.""" +from datetime import datetime +import json +import logging + +from aiokafka import AIOKafkaProducer +import voluptuous as vol + +from homeassistant.const import ( + CONF_IP_ADDRESS, + CONF_PORT, + EVENT_HOMEASSISTANT_STOP, + EVENT_STATE_CHANGED, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entityfilter import FILTER_SCHEMA + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "apache_kafka" + +CONF_FILTER = "filter" +CONF_TOPIC = "topic" + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_IP_ADDRESS): cv.string, + vol.Required(CONF_PORT): cv.port, + vol.Required(CONF_TOPIC): cv.string, + vol.Optional(CONF_FILTER, default={}): FILTER_SCHEMA, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, config): + """Activate the Apache Kafka integration.""" + conf = config[DOMAIN] + + kafka = hass.data[DOMAIN] = KafkaManager( + hass, + conf[CONF_IP_ADDRESS], + conf[CONF_PORT], + conf[CONF_TOPIC], + conf[CONF_FILTER], + ) + + hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, kafka.shutdown()) + + await kafka.start() + + return True + + +class DateTimeJSONEncoder(json.JSONEncoder): + """Encode python objects. + + Additionally add encoding for datetime objects as isoformat. + """ + + def default(self, o): # pylint: disable=method-hidden + """Implement encoding logic.""" + if isinstance(o, datetime): + return o.isoformat() + return super().default(o) + + +class KafkaManager: + """Define a manager to buffer events to Kafka.""" + + def __init__(self, hass, ip_address, port, topic, entities_filter): + """Initialize.""" + self._encoder = DateTimeJSONEncoder() + self._entities_filter = entities_filter + self._hass = hass + self._producer = AIOKafkaProducer( + loop=hass.loop, + bootstrap_servers=f"{ip_address}:{port}", + compression_type="gzip", + ) + self._topic = topic + + def _encode_event(self, event): + """Translate events into a binary JSON payload.""" + state = event.data.get("new_state") + if ( + state is None + or state.state in (STATE_UNKNOWN, "", STATE_UNAVAILABLE) + or not self._entities_filter(state.entity_id) + ): + return + + return json.dumps(obj=state.as_dict(), default=self._encoder.encode).encode( + "utf-8" + ) + + async def start(self): + """Start the Kafka manager.""" + self._hass.bus.async_listen(EVENT_STATE_CHANGED, self.write) + await self._producer.start() + + async def shutdown(self): + """Shut the manager down.""" + await self._producer.stop() + + async def write(self, event): + """Write a binary payload to Kafka.""" + payload = self._encode_event(event) + + if payload: + await self._producer.send_and_wait(self._topic, payload) diff --git a/homeassistant/components/apache_kafka/manifest.json b/homeassistant/components/apache_kafka/manifest.json new file mode 100644 index 000000000..fb2bd6435 --- /dev/null +++ b/homeassistant/components/apache_kafka/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "apache_kafka", + "name": "Apache Kafka", + "documentation": "https://www.home-assistant.io/integrations/apache_kafka", + "requirements": [ + "aiokafka==0.5.1" + ], + "dependencies": [], + "codeowners": [ + "@bachya" + ] +} diff --git a/homeassistant/components/apcupsd.py b/homeassistant/components/apcupsd.py deleted file mode 100644 index 8808cee79..000000000 --- a/homeassistant/components/apcupsd.py +++ /dev/null @@ -1,90 +0,0 @@ -""" -Support for status output of APCUPSd via its Network Information Server (NIS). - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/apcupsd/ -""" -import logging -from datetime import timedelta - -import voluptuous as vol - -from homeassistant.const import (CONF_HOST, CONF_PORT) -import homeassistant.helpers.config_validation as cv -from homeassistant.util import Throttle - -REQUIREMENTS = ['apcaccess==0.0.13'] - -_LOGGER = logging.getLogger(__name__) - -CONF_TYPE = 'type' - -DATA = None -DEFAULT_HOST = 'localhost' -DEFAULT_PORT = 3551 -DOMAIN = 'apcupsd' - -KEY_STATUS = 'STATUS' - -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) - -VALUE_ONLINE = 'ONLINE' - -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - }), -}, extra=vol.ALLOW_EXTRA) - - -def setup(hass, config): - """Use config values to set up a function enabling status retrieval.""" - global DATA - conf = config[DOMAIN] - host = conf.get(CONF_HOST) - port = conf.get(CONF_PORT) - - DATA = APCUPSdData(host, port) - - # It doesn't really matter why we're not able to get the status, just that - # we can't. - # pylint: disable=broad-except - try: - DATA.update(no_throttle=True) - except Exception: - _LOGGER.exception("Failure while testing APCUPSd status retrieval.") - return False - return True - - -class APCUPSdData: - """Stores the data retrieved from APCUPSd. - - For each entity to use, acts as the single point responsible for fetching - updates from the server. - """ - - def __init__(self, host, port): - """Initialize the data object.""" - from apcaccess import status - self._host = host - self._port = port - self._status = None - self._get = status.get - self._parse = status.parse - - @property - def status(self): - """Get latest update if throttle allows. Return status.""" - self.update() - return self._status - - def _get_status(self): - """Get the status from APCUPSd and parse it into a dict.""" - return self._parse(self._get(host=self._host, port=self._port)) - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self, **kwargs): - """Fetch the latest status from APCUPSd.""" - self._status = self._get_status() diff --git a/homeassistant/components/apcupsd/__init__.py b/homeassistant/components/apcupsd/__init__.py new file mode 100644 index 000000000..71f25f043 --- /dev/null +++ b/homeassistant/components/apcupsd/__init__.py @@ -0,0 +1,88 @@ +"""Support for APCUPSd via its Network Information Server (NIS).""" +from datetime import timedelta +import logging + +from apcaccess import status +import voluptuous as vol + +from homeassistant.const import CONF_HOST, CONF_PORT +import homeassistant.helpers.config_validation as cv +from homeassistant.util import Throttle + +_LOGGER = logging.getLogger(__name__) + +CONF_TYPE = "type" + +DATA = None +DEFAULT_HOST = "localhost" +DEFAULT_PORT = 3551 +DOMAIN = "apcupsd" + +KEY_STATUS = "STATUS" + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) + +VALUE_ONLINE = "ONLINE" + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +def setup(hass, config): + """Use config values to set up a function enabling status retrieval.""" + global DATA + conf = config[DOMAIN] + host = conf.get(CONF_HOST) + port = conf.get(CONF_PORT) + + DATA = APCUPSdData(host, port) + + # It doesn't really matter why we're not able to get the status, just that + # we can't. + try: + DATA.update(no_throttle=True) + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Failure while testing APCUPSd status retrieval.") + return False + return True + + +class APCUPSdData: + """Stores the data retrieved from APCUPSd. + + For each entity to use, acts as the single point responsible for fetching + updates from the server. + """ + + def __init__(self, host, port): + """Initialize the data object.""" + + self._host = host + self._port = port + self._status = None + self._get = status.get + self._parse = status.parse + + @property + def status(self): + """Get latest update if throttle allows. Return status.""" + self.update() + return self._status + + def _get_status(self): + """Get the status from APCUPSd and parse it into a dict.""" + return self._parse(self._get(host=self._host, port=self._port)) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self, **kwargs): + """Fetch the latest status from APCUPSd.""" + self._status = self._get_status() diff --git a/homeassistant/components/apcupsd/binary_sensor.py b/homeassistant/components/apcupsd/binary_sensor.py new file mode 100644 index 000000000..29825fd69 --- /dev/null +++ b/homeassistant/components/apcupsd/binary_sensor.py @@ -0,0 +1,41 @@ +"""Support for tracking the online status of a UPS.""" +import voluptuous as vol + +from homeassistant.components import apcupsd +from homeassistant.components.binary_sensor import PLATFORM_SCHEMA, BinarySensorDevice +from homeassistant.const import CONF_NAME +import homeassistant.helpers.config_validation as cv + +DEFAULT_NAME = "UPS Online Status" +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + {vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string} +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up an APCUPSd Online Status binary sensor.""" + add_entities([OnlineStatus(config, apcupsd.DATA)], True) + + +class OnlineStatus(BinarySensorDevice): + """Representation of an UPS online status.""" + + def __init__(self, config, data): + """Initialize the APCUPSd binary device.""" + self._config = config + self._data = data + self._state = None + + @property + def name(self): + """Return the name of the UPS online status sensor.""" + return self._config.get(CONF_NAME) + + @property + def is_on(self): + """Return true if the UPS is online, else false.""" + return self._state == apcupsd.VALUE_ONLINE + + def update(self): + """Get the status report from APCUPSd and set this entity's state.""" + self._state = self._data.status[apcupsd.KEY_STATUS] diff --git a/homeassistant/components/apcupsd/manifest.json b/homeassistant/components/apcupsd/manifest.json new file mode 100644 index 000000000..08cac5452 --- /dev/null +++ b/homeassistant/components/apcupsd/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "apcupsd", + "name": "Apcupsd", + "documentation": "https://www.home-assistant.io/integrations/apcupsd", + "requirements": [ + "apcaccess==0.0.13" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/apcupsd/sensor.py b/homeassistant/components/apcupsd/sensor.py new file mode 100644 index 000000000..255eb1624 --- /dev/null +++ b/homeassistant/components/apcupsd/sensor.py @@ -0,0 +1,188 @@ +"""Support for APCUPSd sensors.""" +import logging + +from apcaccess.status import ALL_UNITS +import voluptuous as vol + +from homeassistant.components import apcupsd +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_RESOURCES, POWER_WATT, TEMP_CELSIUS +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +SENSOR_PREFIX = "UPS " +SENSOR_TYPES = { + "alarmdel": ["Alarm Delay", "", "mdi:alarm"], + "ambtemp": ["Ambient Temperature", "", "mdi:thermometer"], + "apc": ["Status Data", "", "mdi:information-outline"], + "apcmodel": ["Model", "", "mdi:information-outline"], + "badbatts": ["Bad Batteries", "", "mdi:information-outline"], + "battdate": ["Battery Replaced", "", "mdi:calendar-clock"], + "battstat": ["Battery Status", "", "mdi:information-outline"], + "battv": ["Battery Voltage", "V", "mdi:flash"], + "bcharge": ["Battery", "%", "mdi:battery"], + "cable": ["Cable Type", "", "mdi:ethernet-cable"], + "cumonbatt": ["Total Time on Battery", "", "mdi:timer"], + "date": ["Status Date", "", "mdi:calendar-clock"], + "dipsw": ["Dip Switch Settings", "", "mdi:information-outline"], + "dlowbatt": ["Low Battery Signal", "", "mdi:clock-alert"], + "driver": ["Driver", "", "mdi:information-outline"], + "dshutd": ["Shutdown Delay", "", "mdi:timer"], + "dwake": ["Wake Delay", "", "mdi:timer"], + "endapc": ["Date and Time", "", "mdi:calendar-clock"], + "extbatts": ["External Batteries", "", "mdi:information-outline"], + "firmware": ["Firmware Version", "", "mdi:information-outline"], + "hitrans": ["Transfer High", "V", "mdi:flash"], + "hostname": ["Hostname", "", "mdi:information-outline"], + "humidity": ["Ambient Humidity", "%", "mdi:water-percent"], + "itemp": ["Internal Temperature", TEMP_CELSIUS, "mdi:thermometer"], + "lastxfer": ["Last Transfer", "", "mdi:transfer"], + "linefail": ["Input Voltage Status", "", "mdi:information-outline"], + "linefreq": ["Line Frequency", "Hz", "mdi:information-outline"], + "linev": ["Input Voltage", "V", "mdi:flash"], + "loadpct": ["Load", "%", "mdi:gauge"], + "loadapnt": ["Load Apparent Power", "%", "mdi:gauge"], + "lotrans": ["Transfer Low", "V", "mdi:flash"], + "mandate": ["Manufacture Date", "", "mdi:calendar"], + "masterupd": ["Master Update", "", "mdi:information-outline"], + "maxlinev": ["Input Voltage High", "V", "mdi:flash"], + "maxtime": ["Battery Timeout", "", "mdi:timer-off"], + "mbattchg": ["Battery Shutdown", "%", "mdi:battery-alert"], + "minlinev": ["Input Voltage Low", "V", "mdi:flash"], + "mintimel": ["Shutdown Time", "", "mdi:timer"], + "model": ["Model", "", "mdi:information-outline"], + "nombattv": ["Battery Nominal Voltage", "V", "mdi:flash"], + "nominv": ["Nominal Input Voltage", "V", "mdi:flash"], + "nomoutv": ["Nominal Output Voltage", "V", "mdi:flash"], + "nompower": ["Nominal Output Power", POWER_WATT, "mdi:flash"], + "nomapnt": ["Nominal Apparent Power", "VA", "mdi:flash"], + "numxfers": ["Transfer Count", "", "mdi:counter"], + "outcurnt": ["Output Current", "A", "mdi:flash"], + "outputv": ["Output Voltage", "V", "mdi:flash"], + "reg1": ["Register 1 Fault", "", "mdi:information-outline"], + "reg2": ["Register 2 Fault", "", "mdi:information-outline"], + "reg3": ["Register 3 Fault", "", "mdi:information-outline"], + "retpct": ["Restore Requirement", "%", "mdi:battery-alert"], + "selftest": ["Last Self Test", "", "mdi:calendar-clock"], + "sense": ["Sensitivity", "", "mdi:information-outline"], + "serialno": ["Serial Number", "", "mdi:information-outline"], + "starttime": ["Startup Time", "", "mdi:calendar-clock"], + "statflag": ["Status Flag", "", "mdi:information-outline"], + "status": ["Status", "", "mdi:information-outline"], + "stesti": ["Self Test Interval", "", "mdi:information-outline"], + "timeleft": ["Time Left", "", "mdi:clock-alert"], + "tonbatt": ["Time on Battery", "", "mdi:timer"], + "upsmode": ["Mode", "", "mdi:information-outline"], + "upsname": ["Name", "", "mdi:information-outline"], + "version": ["Daemon Info", "", "mdi:information-outline"], + "xoffbat": ["Transfer from Battery", "", "mdi:transfer"], + "xoffbatt": ["Transfer from Battery", "", "mdi:transfer"], + "xonbatt": ["Transfer to Battery", "", "mdi:transfer"], +} + +SPECIFIC_UNITS = {"ITEMP": TEMP_CELSIUS} +INFERRED_UNITS = { + " Minutes": "min", + " Seconds": "sec", + " Percent": "%", + " Volts": "V", + " Ampere": "A", + " Volt-Ampere": "VA", + " Watts": POWER_WATT, + " Hz": "Hz", + " C": TEMP_CELSIUS, + " Percent Load Capacity": "%", +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_RESOURCES, default=[]): vol.All( + cv.ensure_list, [vol.In(SENSOR_TYPES)] + ) + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the APCUPSd sensors.""" + entities = [] + + for resource in config[CONF_RESOURCES]: + sensor_type = resource.lower() + + if sensor_type not in SENSOR_TYPES: + SENSOR_TYPES[sensor_type] = [ + sensor_type.title(), + "", + "mdi:information-outline", + ] + + if sensor_type.upper() not in apcupsd.DATA.status: + _LOGGER.warning( + "Sensor type: %s does not appear in the APCUPSd status output", + sensor_type, + ) + + entities.append(APCUPSdSensor(apcupsd.DATA, sensor_type)) + + add_entities(entities, True) + + +def infer_unit(value): + """If the value ends with any of the units from ALL_UNITS. + + Split the unit off the end of the value and return the value, unit tuple + pair. Else return the original value and None as the unit. + """ + + for unit in ALL_UNITS: + if value.endswith(unit): + return value[: -len(unit)], INFERRED_UNITS.get(unit, unit.strip()) + return value, None + + +class APCUPSdSensor(Entity): + """Representation of a sensor entity for APCUPSd status values.""" + + def __init__(self, data, sensor_type): + """Initialize the sensor.""" + self._data = data + self.type = sensor_type + self._name = SENSOR_PREFIX + SENSOR_TYPES[sensor_type][0] + self._unit = SENSOR_TYPES[sensor_type][1] + self._inferred_unit = None + self._state = None + + @property + def name(self): + """Return the name of the UPS sensor.""" + return self._name + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return SENSOR_TYPES[self.type][2] + + @property + def state(self): + """Return true if the UPS is online, else False.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + if not self._unit: + return self._inferred_unit + return self._unit + + def update(self): + """Get the latest status and use it to update our sensor state.""" + if self.type.upper() not in self._data.status: + self._state = None + self._inferred_unit = None + else: + self._state, self._inferred_unit = infer_unit( + self._data.status[self.type.upper()] + ) diff --git a/homeassistant/components/api.py b/homeassistant/components/api.py deleted file mode 100644 index 0fbb4de39..000000000 --- a/homeassistant/components/api.py +++ /dev/null @@ -1,377 +0,0 @@ -""" -Rest API for Home Assistant. - -For more details about the RESTful API, please refer to the documentation at -https://developers.home-assistant.io/docs/en/external_api_rest.html -""" -import asyncio -import json -import logging - -from aiohttp import web -import async_timeout - -from homeassistant.bootstrap import DATA_LOGGING -from homeassistant.components.http import HomeAssistantView -from homeassistant.const import ( - EVENT_HOMEASSISTANT_STOP, EVENT_TIME_CHANGED, HTTP_BAD_REQUEST, - HTTP_CREATED, HTTP_NOT_FOUND, MATCH_ALL, URL_API, URL_API_COMPONENTS, - URL_API_CONFIG, URL_API_DISCOVERY_INFO, URL_API_ERROR_LOG, URL_API_EVENTS, - URL_API_SERVICES, URL_API_STATES, URL_API_STATES_ENTITY, URL_API_STREAM, - URL_API_TEMPLATE, __version__) -import homeassistant.core as ha -from homeassistant.exceptions import TemplateError -from homeassistant.helpers import template -from homeassistant.helpers.service import async_get_all_descriptions -from homeassistant.helpers.state import AsyncTrackStates -from homeassistant.helpers.json import JSONEncoder - -_LOGGER = logging.getLogger(__name__) - -ATTR_BASE_URL = 'base_url' -ATTR_LOCATION_NAME = 'location_name' -ATTR_REQUIRES_API_PASSWORD = 'requires_api_password' -ATTR_VERSION = 'version' - -DOMAIN = 'api' -DEPENDENCIES = ['http'] - -STREAM_PING_PAYLOAD = 'ping' -STREAM_PING_INTERVAL = 50 # seconds - - -def setup(hass, config): - """Register the API with the HTTP interface.""" - hass.http.register_view(APIStatusView) - hass.http.register_view(APIEventStream) - hass.http.register_view(APIConfigView) - hass.http.register_view(APIDiscoveryView) - hass.http.register_view(APIStatesView) - hass.http.register_view(APIEntityStateView) - hass.http.register_view(APIEventListenersView) - hass.http.register_view(APIEventView) - hass.http.register_view(APIServicesView) - hass.http.register_view(APIDomainServicesView) - hass.http.register_view(APIComponentsView) - hass.http.register_view(APITemplateView) - - if DATA_LOGGING in hass.data: - hass.http.register_view(APIErrorLog) - - return True - - -class APIStatusView(HomeAssistantView): - """View to handle Status requests.""" - - url = URL_API - name = 'api:status' - - @ha.callback - def get(self, request): - """Retrieve if API is running.""" - return self.json_message("API running.") - - -class APIEventStream(HomeAssistantView): - """View to handle EventStream requests.""" - - url = URL_API_STREAM - name = 'api:stream' - - async def get(self, request): - """Provide a streaming interface for the event bus.""" - hass = request.app['hass'] - stop_obj = object() - to_write = asyncio.Queue(loop=hass.loop) - - restrict = request.query.get('restrict') - if restrict: - restrict = restrict.split(',') + [EVENT_HOMEASSISTANT_STOP] - - async def forward_events(event): - """Forward events to the open request.""" - if event.event_type == EVENT_TIME_CHANGED: - return - - if restrict and event.event_type not in restrict: - return - - _LOGGER.debug("STREAM %s FORWARDING %s", id(stop_obj), event) - - if event.event_type == EVENT_HOMEASSISTANT_STOP: - data = stop_obj - else: - data = json.dumps(event, cls=JSONEncoder) - - await to_write.put(data) - - response = web.StreamResponse() - response.content_type = 'text/event-stream' - await response.prepare(request) - - unsub_stream = hass.bus.async_listen(MATCH_ALL, forward_events) - - try: - _LOGGER.debug("STREAM %s ATTACHED", id(stop_obj)) - - # Fire off one message so browsers fire open event right away - await to_write.put(STREAM_PING_PAYLOAD) - - while True: - try: - with async_timeout.timeout(STREAM_PING_INTERVAL, - loop=hass.loop): - payload = await to_write.get() - - if payload is stop_obj: - break - - msg = "data: {}\n\n".format(payload) - _LOGGER.debug( - "STREAM %s WRITING %s", id(stop_obj), msg.strip()) - await response.write(msg.encode('UTF-8')) - except asyncio.TimeoutError: - await to_write.put(STREAM_PING_PAYLOAD) - - except asyncio.CancelledError: - _LOGGER.debug("STREAM %s ABORT", id(stop_obj)) - - finally: - _LOGGER.debug("STREAM %s RESPONSE CLOSED", id(stop_obj)) - unsub_stream() - - -class APIConfigView(HomeAssistantView): - """View to handle Configuration requests.""" - - url = URL_API_CONFIG - name = 'api:config' - - @ha.callback - def get(self, request): - """Get current configuration.""" - return self.json(request.app['hass'].config.as_dict()) - - -class APIDiscoveryView(HomeAssistantView): - """View to provide Discovery information.""" - - requires_auth = False - url = URL_API_DISCOVERY_INFO - name = 'api:discovery' - - @ha.callback - def get(self, request): - """Get discovery information.""" - hass = request.app['hass'] - needs_auth = hass.config.api.api_password is not None - return self.json({ - ATTR_BASE_URL: hass.config.api.base_url, - ATTR_LOCATION_NAME: hass.config.location_name, - ATTR_REQUIRES_API_PASSWORD: needs_auth, - ATTR_VERSION: __version__, - }) - - -class APIStatesView(HomeAssistantView): - """View to handle States requests.""" - - url = URL_API_STATES - name = "api:states" - - @ha.callback - def get(self, request): - """Get current states.""" - return self.json(request.app['hass'].states.async_all()) - - -class APIEntityStateView(HomeAssistantView): - """View to handle EntityState requests.""" - - url = '/api/states/{entity_id}' - name = 'api:entity-state' - - @ha.callback - def get(self, request, entity_id): - """Retrieve state of entity.""" - state = request.app['hass'].states.get(entity_id) - if state: - return self.json(state) - return self.json_message("Entity not found.", HTTP_NOT_FOUND) - - async def post(self, request, entity_id): - """Update state of entity.""" - hass = request.app['hass'] - try: - data = await request.json() - except ValueError: - return self.json_message( - "Invalid JSON specified.", HTTP_BAD_REQUEST) - - new_state = data.get('state') - - if new_state is None: - return self.json_message("No state specified.", HTTP_BAD_REQUEST) - - attributes = data.get('attributes') - force_update = data.get('force_update', False) - - is_new_state = hass.states.get(entity_id) is None - - # Write state - hass.states.async_set(entity_id, new_state, attributes, force_update, - self.context(request)) - - # Read the state back for our response - status_code = HTTP_CREATED if is_new_state else 200 - resp = self.json(hass.states.get(entity_id), status_code) - - resp.headers.add('Location', URL_API_STATES_ENTITY.format(entity_id)) - - return resp - - @ha.callback - def delete(self, request, entity_id): - """Remove entity.""" - if request.app['hass'].states.async_remove(entity_id): - return self.json_message("Entity removed.") - return self.json_message("Entity not found.", HTTP_NOT_FOUND) - - -class APIEventListenersView(HomeAssistantView): - """View to handle EventListeners requests.""" - - url = URL_API_EVENTS - name = 'api:event-listeners' - - @ha.callback - def get(self, request): - """Get event listeners.""" - return self.json(async_events_json(request.app['hass'])) - - -class APIEventView(HomeAssistantView): - """View to handle Event requests.""" - - url = '/api/events/{event_type}' - name = 'api:event' - - async def post(self, request, event_type): - """Fire events.""" - 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", HTTP_BAD_REQUEST) - - # Special case handling for event STATE_CHANGED - # We will try to convert state dicts back to State objects - if event_type == ha.EVENT_STATE_CHANGED and event_data: - for key in ('old_state', 'new_state'): - state = ha.State.from_dict(event_data.get(key)) - - if state: - event_data[key] = state - - request.app['hass'].bus.async_fire( - event_type, event_data, ha.EventOrigin.remote, - self.context(request)) - - return self.json_message("Event {} fired.".format(event_type)) - - -class APIServicesView(HomeAssistantView): - """View to handle Services requests.""" - - url = URL_API_SERVICES - name = 'api:services' - - async def get(self, request): - """Get registered services.""" - services = await async_services_json(request.app['hass']) - return self.json(services) - - -class APIDomainServicesView(HomeAssistantView): - """View to handle DomainServices requests.""" - - url = '/api/services/{domain}/{service}' - name = 'api:domain-services' - - async def post(self, request, domain, service): - """Call a service. - - Returns a list of changed states. - """ - hass = request.app['hass'] - 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: - await hass.services.async_call( - domain, service, data, True, self.context(request)) - - return self.json(changed_states) - - -class APIComponentsView(HomeAssistantView): - """View to handle Components requests.""" - - url = URL_API_COMPONENTS - name = 'api:components' - - @ha.callback - def get(self, request): - """Get current loaded components.""" - return self.json(request.app['hass'].config.components) - - -class APITemplateView(HomeAssistantView): - """View to handle Template requests.""" - - url = URL_API_TEMPLATE - name = 'api:template' - - async def post(self, request): - """Render a template.""" - try: - data = await request.json() - tpl = template.Template(data['template'], request.app['hass']) - return tpl.async_render(data.get('variables')) - except (ValueError, TemplateError) as ex: - return self.json_message( - "Error rendering template: {}".format(ex), HTTP_BAD_REQUEST) - - -class APIErrorLog(HomeAssistantView): - """View to fetch the API error log.""" - - url = URL_API_ERROR_LOG - name = 'api:error_log' - - async def get(self, request): - """Retrieve API error log.""" - return web.FileResponse(request.app['hass'].data[DATA_LOGGING]) - - -async def async_services_json(hass): - """Generate services data to JSONify.""" - descriptions = await async_get_all_descriptions(hass) - return [{'domain': key, 'services': value} - for key, value in descriptions.items()] - - -def async_events_json(hass): - """Generate event data to JSONify.""" - return [{'event': key, 'listener_count': value} - for key, value in hass.bus.async_listeners().items()] diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py new file mode 100644 index 000000000..fc2f01d41 --- /dev/null +++ b/homeassistant/components/api/__init__.py @@ -0,0 +1,419 @@ +"""Rest API for Home Assistant.""" +import asyncio +import json +import logging + +from aiohttp import web +from aiohttp.web_exceptions import HTTPBadRequest +import async_timeout +import voluptuous as vol + +from homeassistant.auth.permissions.const import POLICY_READ +from homeassistant.bootstrap import DATA_LOGGING +from homeassistant.components.http import HomeAssistantView +from homeassistant.const import ( + EVENT_HOMEASSISTANT_STOP, + EVENT_TIME_CHANGED, + HTTP_BAD_REQUEST, + HTTP_CREATED, + HTTP_NOT_FOUND, + MATCH_ALL, + URL_API, + URL_API_COMPONENTS, + URL_API_CONFIG, + URL_API_DISCOVERY_INFO, + URL_API_ERROR_LOG, + URL_API_EVENTS, + URL_API_SERVICES, + URL_API_STATES, + URL_API_STATES_ENTITY, + URL_API_STREAM, + URL_API_TEMPLATE, + __version__, +) +import homeassistant.core as ha +from homeassistant.exceptions import ServiceNotFound, TemplateError, Unauthorized +from homeassistant.helpers import template +from homeassistant.helpers.json import JSONEncoder +from homeassistant.helpers.service import async_get_all_descriptions +from homeassistant.helpers.state import AsyncTrackStates + +_LOGGER = logging.getLogger(__name__) + +ATTR_BASE_URL = "base_url" +ATTR_LOCATION_NAME = "location_name" +ATTR_REQUIRES_API_PASSWORD = "requires_api_password" +ATTR_VERSION = "version" + +DOMAIN = "api" +STREAM_PING_PAYLOAD = "ping" +STREAM_PING_INTERVAL = 50 # seconds + + +def setup(hass, config): + """Register the API with the HTTP interface.""" + hass.http.register_view(APIStatusView) + hass.http.register_view(APIEventStream) + hass.http.register_view(APIConfigView) + hass.http.register_view(APIDiscoveryView) + hass.http.register_view(APIStatesView) + hass.http.register_view(APIEntityStateView) + hass.http.register_view(APIEventListenersView) + hass.http.register_view(APIEventView) + hass.http.register_view(APIServicesView) + hass.http.register_view(APIDomainServicesView) + hass.http.register_view(APIComponentsView) + hass.http.register_view(APITemplateView) + + if DATA_LOGGING in hass.data: + hass.http.register_view(APIErrorLog) + + return True + + +class APIStatusView(HomeAssistantView): + """View to handle Status requests.""" + + url = URL_API + name = "api:status" + + @ha.callback + def get(self, request): + """Retrieve if API is running.""" + return self.json_message("API running.") + + +class APIEventStream(HomeAssistantView): + """View to handle EventStream requests.""" + + url = URL_API_STREAM + name = "api:stream" + + async def get(self, request): + """Provide a streaming interface for the event bus.""" + if not request["hass_user"].is_admin: + raise Unauthorized() + hass = request.app["hass"] + stop_obj = object() + to_write = asyncio.Queue() + + restrict = request.query.get("restrict") + if restrict: + restrict = restrict.split(",") + [EVENT_HOMEASSISTANT_STOP] + + async def forward_events(event): + """Forward events to the open request.""" + if event.event_type == EVENT_TIME_CHANGED: + return + + if restrict and event.event_type not in restrict: + return + + _LOGGER.debug("STREAM %s FORWARDING %s", id(stop_obj), event) + + if event.event_type == EVENT_HOMEASSISTANT_STOP: + data = stop_obj + else: + data = json.dumps(event, cls=JSONEncoder) + + await to_write.put(data) + + response = web.StreamResponse() + response.content_type = "text/event-stream" + await response.prepare(request) + + unsub_stream = hass.bus.async_listen(MATCH_ALL, forward_events) + + try: + _LOGGER.debug("STREAM %s ATTACHED", id(stop_obj)) + + # Fire off one message so browsers fire open event right away + await to_write.put(STREAM_PING_PAYLOAD) + + while True: + try: + with async_timeout.timeout(STREAM_PING_INTERVAL): + payload = await to_write.get() + + if payload is stop_obj: + break + + msg = f"data: {payload}\n\n" + _LOGGER.debug("STREAM %s WRITING %s", id(stop_obj), msg.strip()) + await response.write(msg.encode("UTF-8")) + except asyncio.TimeoutError: + await to_write.put(STREAM_PING_PAYLOAD) + + except asyncio.CancelledError: + _LOGGER.debug("STREAM %s ABORT", id(stop_obj)) + + finally: + _LOGGER.debug("STREAM %s RESPONSE CLOSED", id(stop_obj)) + unsub_stream() + + return response + + +class APIConfigView(HomeAssistantView): + """View to handle Configuration requests.""" + + url = URL_API_CONFIG + name = "api:config" + + @ha.callback + def get(self, request): + """Get current configuration.""" + return self.json(request.app["hass"].config.as_dict()) + + +class APIDiscoveryView(HomeAssistantView): + """View to provide Discovery information.""" + + requires_auth = False + url = URL_API_DISCOVERY_INFO + name = "api:discovery" + + @ha.callback + def get(self, request): + """Get discovery information.""" + hass = request.app["hass"] + return self.json( + { + ATTR_BASE_URL: hass.config.api.base_url, + ATTR_LOCATION_NAME: hass.config.location_name, + # always needs authentication + ATTR_REQUIRES_API_PASSWORD: True, + ATTR_VERSION: __version__, + } + ) + + +class APIStatesView(HomeAssistantView): + """View to handle States requests.""" + + url = URL_API_STATES + name = "api:states" + + @ha.callback + def get(self, request): + """Get current states.""" + user = request["hass_user"] + entity_perm = user.permissions.check_entity + states = [ + state + for state in request.app["hass"].states.async_all() + if entity_perm(state.entity_id, "read") + ] + return self.json(states) + + +class APIEntityStateView(HomeAssistantView): + """View to handle EntityState requests.""" + + url = "/api/states/{entity_id}" + name = "api:entity-state" + + @ha.callback + def get(self, request, entity_id): + """Retrieve state of entity.""" + user = request["hass_user"] + if not user.permissions.check_entity(entity_id, POLICY_READ): + raise Unauthorized(entity_id=entity_id) + + state = request.app["hass"].states.get(entity_id) + if state: + return self.json(state) + return self.json_message("Entity not found.", HTTP_NOT_FOUND) + + async def post(self, request, entity_id): + """Update state of entity.""" + if not request["hass_user"].is_admin: + raise Unauthorized(entity_id=entity_id) + hass = request.app["hass"] + try: + data = await request.json() + except ValueError: + return self.json_message("Invalid JSON specified.", HTTP_BAD_REQUEST) + + new_state = data.get("state") + + if new_state is None: + return self.json_message("No state specified.", HTTP_BAD_REQUEST) + + attributes = data.get("attributes") + force_update = data.get("force_update", False) + + is_new_state = hass.states.get(entity_id) is None + + # Write state + hass.states.async_set( + entity_id, new_state, attributes, force_update, self.context(request) + ) + + # Read the state back for our response + status_code = HTTP_CREATED if is_new_state else 200 + resp = self.json(hass.states.get(entity_id), status_code) + + resp.headers.add("Location", URL_API_STATES_ENTITY.format(entity_id)) + + return resp + + @ha.callback + def delete(self, request, entity_id): + """Remove entity.""" + if not request["hass_user"].is_admin: + raise Unauthorized(entity_id=entity_id) + if request.app["hass"].states.async_remove(entity_id): + return self.json_message("Entity removed.") + return self.json_message("Entity not found.", HTTP_NOT_FOUND) + + +class APIEventListenersView(HomeAssistantView): + """View to handle EventListeners requests.""" + + url = URL_API_EVENTS + name = "api:event-listeners" + + @ha.callback + def get(self, request): + """Get event listeners.""" + return self.json(async_events_json(request.app["hass"])) + + +class APIEventView(HomeAssistantView): + """View to handle Event requests.""" + + url = "/api/events/{event_type}" + name = "api:event" + + async def post(self, request, event_type): + """Fire events.""" + if not request["hass_user"].is_admin: + raise Unauthorized() + 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", HTTP_BAD_REQUEST + ) + + # Special case handling for event STATE_CHANGED + # We will try to convert state dicts back to State objects + if event_type == ha.EVENT_STATE_CHANGED and event_data: + for key in ("old_state", "new_state"): + state = ha.State.from_dict(event_data.get(key)) + + if state: + event_data[key] = state + + request.app["hass"].bus.async_fire( + event_type, event_data, ha.EventOrigin.remote, self.context(request) + ) + + return self.json_message(f"Event {event_type} fired.") + + +class APIServicesView(HomeAssistantView): + """View to handle Services requests.""" + + url = URL_API_SERVICES + name = "api:services" + + async def get(self, request): + """Get registered services.""" + services = await async_services_json(request.app["hass"]) + return self.json(services) + + +class APIDomainServicesView(HomeAssistantView): + """View to handle DomainServices requests.""" + + url = "/api/services/{domain}/{service}" + name = "api:domain-services" + + async def post(self, request, domain, service): + """Call a service. + + Returns a list of changed states. + """ + hass = request.app["hass"] + 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: + try: + await hass.services.async_call( + domain, service, data, True, self.context(request) + ) + except (vol.Invalid, ServiceNotFound): + raise HTTPBadRequest() + + return self.json(changed_states) + + +class APIComponentsView(HomeAssistantView): + """View to handle Components requests.""" + + url = URL_API_COMPONENTS + name = "api:components" + + @ha.callback + def get(self, request): + """Get current loaded components.""" + return self.json(request.app["hass"].config.components) + + +class APITemplateView(HomeAssistantView): + """View to handle Template requests.""" + + url = URL_API_TEMPLATE + name = "api:template" + + async def post(self, request): + """Render a template.""" + if not request["hass_user"].is_admin: + raise Unauthorized() + try: + data = await request.json() + tpl = template.Template(data["template"], request.app["hass"]) + return tpl.async_render(data.get("variables")) + except (ValueError, TemplateError) as ex: + return self.json_message( + f"Error rendering template: {ex}", HTTP_BAD_REQUEST + ) + + +class APIErrorLog(HomeAssistantView): + """View to fetch the API error log.""" + + url = URL_API_ERROR_LOG + name = "api:error_log" + + async def get(self, request): + """Retrieve API error log.""" + if not request["hass_user"].is_admin: + raise Unauthorized() + 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 descriptions.items()] + + +def async_events_json(hass): + """Generate event data to JSONify.""" + return [ + {"event": key, "listener_count": value} + for key, value in hass.bus.async_listeners().items() + ] diff --git a/homeassistant/components/api/manifest.json b/homeassistant/components/api/manifest.json new file mode 100644 index 000000000..830fc0444 --- /dev/null +++ b/homeassistant/components/api/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "api", + "name": "Home Assistant API", + "documentation": "https://www.home-assistant.io/integrations/api", + "requirements": [], + "dependencies": [ + "http" + ], + "codeowners": [ + "@home-assistant/core" + ] +} diff --git a/homeassistant/components/api/services.yaml b/homeassistant/components/api/services.yaml new file mode 100644 index 000000000..e69de29bb diff --git a/homeassistant/components/apns/__init__.py b/homeassistant/components/apns/__init__.py new file mode 100644 index 000000000..9332b0d1e --- /dev/null +++ b/homeassistant/components/apns/__init__.py @@ -0,0 +1 @@ +"""The apns component.""" diff --git a/homeassistant/components/apns/const.py b/homeassistant/components/apns/const.py new file mode 100644 index 000000000..a8dc1204a --- /dev/null +++ b/homeassistant/components/apns/const.py @@ -0,0 +1,2 @@ +"""Constants for the apns component.""" +DOMAIN = "apns" diff --git a/homeassistant/components/apns/manifest.json b/homeassistant/components/apns/manifest.json new file mode 100644 index 000000000..3c38238f7 --- /dev/null +++ b/homeassistant/components/apns/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "apns", + "name": "Apns", + "documentation": "https://www.home-assistant.io/integrations/apns", + "requirements": ["apns2==0.3.0"], + "dependencies": [], + "after_dependencies": ["device_tracker"], + "codeowners": [] +} diff --git a/homeassistant/components/apns/notify.py b/homeassistant/components/apns/notify.py new file mode 100644 index 000000000..990598508 --- /dev/null +++ b/homeassistant/components/apns/notify.py @@ -0,0 +1,264 @@ +"""APNS Notification platform.""" +import logging + +from apns2.client import APNsClient +from apns2.errors import Unregistered +from apns2.payload import Payload +import voluptuous as vol + +from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN +from homeassistant.components.notify import ( + ATTR_DATA, + ATTR_TARGET, + PLATFORM_SCHEMA, + BaseNotificationService, +) +from homeassistant.config import load_yaml_config_file +from homeassistant.const import ATTR_NAME, CONF_NAME, CONF_PLATFORM +from homeassistant.helpers import template as template_helper +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import track_state_change + +from .const import DOMAIN + +APNS_DEVICES = "apns.yaml" +CONF_CERTFILE = "cert_file" +CONF_TOPIC = "topic" +CONF_SANDBOX = "sandbox" + +ATTR_PUSH_ID = "push_id" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_PLATFORM): "apns", + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_CERTFILE): cv.isfile, + vol.Required(CONF_TOPIC): cv.string, + vol.Optional(CONF_SANDBOX, default=False): cv.boolean, + } +) + +REGISTER_SERVICE_SCHEMA = vol.Schema( + {vol.Required(ATTR_PUSH_ID): cv.string, vol.Optional(ATTR_NAME): cv.string} +) + + +def get_service(hass, config, discovery_info=None): + """Return push service.""" + name = config.get(CONF_NAME) + cert_file = config.get(CONF_CERTFILE) + topic = config.get(CONF_TOPIC) + sandbox = config.get(CONF_SANDBOX) + + service = ApnsNotificationService(hass, name, topic, sandbox, cert_file) + hass.services.register( + DOMAIN, f"apns_{name}", service.register, schema=REGISTER_SERVICE_SCHEMA + ) + return service + + +class ApnsDevice: + """ + The APNS Device class. + + Stores information about a device that is registered for push + notifications. + """ + + def __init__(self, push_id, name, tracking_device_id=None, disabled=False): + """Initialize APNS Device.""" + self.device_push_id = push_id + self.device_name = name + self.tracking_id = tracking_device_id + self.device_disabled = disabled + + @property + def push_id(self): + """Return the APNS id for the device.""" + return self.device_push_id + + @property + def name(self): + """Return the friendly name for the device.""" + return self.device_name + + @property + def tracking_device_id(self): + """ + Return the device Id. + + The id of a device that is tracked by the device + tracking component. + """ + return self.tracking_id + + @property + def full_tracking_device_id(self): + """ + Return the fully qualified device id. + + The full id of a device that is tracked by the device + tracking component. + """ + return f"{DEVICE_TRACKER_DOMAIN}.{self.tracking_id}" + + @property + def disabled(self): + """Return the state of the service.""" + return self.device_disabled + + def disable(self): + """Disable the device from receiving notifications.""" + self.device_disabled = True + + def __eq__(self, other): + """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 comparison.""" + return not self.__eq__(other) + + +def _write_device(out, device): + """Write a single device to file.""" + attributes = [] + if device.name is not None: + attributes.append(f"name: {device.name}") + if device.tracking_device_id is not None: + attributes.append(f"tracking_device_id: {device.tracking_device_id}") + if device.disabled: + attributes.append("disabled: True") + + out.write(device.push_id) + out.write(": {") + if attributes: + separator = ", " + out.write(separator.join(attributes)) + + out.write("}\n") + + +class ApnsNotificationService(BaseNotificationService): + """Implement the notification service for the APNS service.""" + + def __init__(self, hass, app_name, topic, sandbox, cert_file): + """Initialize APNS application.""" + self.hass = hass + self.app_name = app_name + self.sandbox = sandbox + self.certificate = cert_file + self.yaml_path = hass.config.path(app_name + "_" + APNS_DEVICES) + self.devices = {} + self.device_states = {} + self.topic = topic + + try: + self.devices = { + str(key): ApnsDevice( + str(key), + value.get("name"), + value.get("tracking_device_id"), + value.get("disabled", False), + ) + for (key, value) in load_yaml_config_file(self.yaml_path).items() + } + except FileNotFoundError: + pass + + tracking_ids = [ + device.full_tracking_device_id + for (key, device) in self.devices.items() + if device.tracking_device_id is not None + ] + track_state_change(hass, tracking_ids, self.device_state_changed_listener) + + def device_state_changed_listener(self, entity_id, from_s, to_s): + """ + Listen for sate change. + + Track device state change if a device has a tracking id specified. + """ + self.device_states[entity_id] = str(to_s.state) + + def write_devices(self): + """Write all known devices to file.""" + with open(self.yaml_path, "w+") as out: + for _, device in self.devices.items(): + _write_device(out, device) + + def register(self, call): + """Register a device to receive push messages.""" + push_id = call.data.get(ATTR_PUSH_ID) + + device_name = call.data.get(ATTR_NAME) + current_device = self.devices.get(push_id) + current_tracking_id = ( + None if current_device is None else current_device.tracking_device_id + ) + + device = ApnsDevice(push_id, device_name, current_tracking_id) + + if current_device is None: + self.devices[push_id] = device + with open(self.yaml_path, "a") as out: + _write_device(out, device) + return True + + if device != current_device: + self.devices[push_id] = device + self.write_devices() + + return True + + def send_message(self, message=None, **kwargs): + """Send push message to registered devices.""" + + apns = APNsClient( + self.certificate, use_sandbox=self.sandbox, use_alternative_port=False + ) + + device_state = kwargs.get(ATTR_TARGET) + message_data = kwargs.get(ATTR_DATA) + + if message_data is None: + message_data = {} + + if isinstance(message, str): + rendered_message = message + elif isinstance(message, template_helper.Template): + rendered_message = message.render() + else: + rendered_message = "" + + payload = Payload( + alert=rendered_message, + badge=message_data.get("badge"), + sound=message_data.get("sound"), + category=message_data.get("category"), + custom=message_data.get("custom", {}), + content_available=message_data.get("content_available", False), + ) + + device_update = False + + for push_id, device in self.devices.items(): + if not device.disabled: + state = None + if device.tracking_device_id is not None: + state = self.device_states.get(device.full_tracking_device_id) + + if device_state is None or state == str(device_state): + try: + apns.send_notification(push_id, payload, topic=self.topic) + except Unregistered: + logging.error("Device %s has unregistered", push_id) + device_update = True + device.disable() + + if device_update: + self.write_devices() + + return True diff --git a/homeassistant/components/apns/services.yaml b/homeassistant/components/apns/services.yaml new file mode 100644 index 000000000..e69de29bb diff --git a/homeassistant/components/apple_tv.py b/homeassistant/components/apple_tv.py deleted file mode 100644 index 21ff0e328..000000000 --- a/homeassistant/components/apple_tv.py +++ /dev/null @@ -1,261 +0,0 @@ -""" -Support for Apple TV. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/apple_tv/ -""" -import asyncio -import logging -from typing import Sequence, TypeVar, Union - -import voluptuous as vol - -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.10'] - -_LOGGER = logging.getLogger(__name__) - -DOMAIN = 'apple_tv' - -SERVICE_SCAN = 'apple_tv_scan' -SERVICE_AUTHENTICATE = 'apple_tv_authenticate' - -ATTR_ATV = 'atv' -ATTR_POWER = 'power' - -CONF_LOGIN_ID = 'login_id' -CONF_START_OFF = 'start_off' -CONF_CREDENTIALS = 'credentials' - -DEFAULT_NAME = 'Apple TV' - -DATA_APPLE_TV = 'data_apple_tv' -DATA_ENTITIES = 'data_apple_tv_entities' - -KEY_CONFIG = 'apple_tv_configuring' - -NOTIFICATION_AUTH_ID = 'apple_tv_auth_notification' -NOTIFICATION_AUTH_TITLE = 'Apple TV Authentication' -NOTIFICATION_SCAN_ID = 'apple_tv_scan_notification' -NOTIFICATION_SCAN_TITLE = 'Apple TV Scan' - -T = TypeVar('T') # pylint: disable=invalid-name - - -# 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(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_START_OFF, default=False): cv.boolean, - })]) -}, extra=vol.ALLOW_EXTRA) - -# Currently no attributes but it might change later -APPLE_TV_SCAN_SCHEMA = vol.Schema({}) - -APPLE_TV_AUTHENTICATE_SCHEMA = vol.Schema({ - ATTR_ENTITY_ID: cv.entity_ids, -}) - - -def request_configuration(hass, config, atv, credentials): - """Request configuration steps from the user.""" - configurator = hass.components.configurator - - @asyncio.coroutine - def configuration_callback(callback_data): - """Handle the submitted configuration.""" - from pyatv import exceptions - pin = callback_data.get('pin') - - try: - yield from atv.airplay.finish_authentication(pin) - hass.components.persistent_notification.async_create( - 'Authentication succeeded!

Add the following ' - 'to credentials: in your apple_tv configuration:

' - '{0}'.format(credentials), - title=NOTIFICATION_AUTH_TITLE, - notification_id=NOTIFICATION_AUTH_ID) - except exceptions.DeviceAuthenticationError as ex: - hass.components.persistent_notification.async_create( - 'Authentication failed! Did you enter correct PIN?

' - 'Details: {0}'.format(ex), - title=NOTIFICATION_AUTH_TITLE, - notification_id=NOTIFICATION_AUTH_ID) - - hass.async_add_job(configurator.request_done, instance) - - instance = configurator.request_config( - 'Apple TV Authentication', configuration_callback, - description='Please enter PIN code shown on screen.', - submit_caption='Confirm', - fields=[{'id': 'pin', 'name': 'PIN Code', 'type': 'password'}] - ) - - -@asyncio.coroutine -def scan_for_apple_tvs(hass): - """Scan for devices and present a notification of the ones found.""" - import pyatv - atvs = yield from pyatv.scan_for_apple_tvs(hass.loop, timeout=3) - - devices = [] - for atv in atvs: - login_id = atv.login_id - if login_id is None: - login_id = 'Home Sharing disabled' - devices.append('Name: {0}
Host: {1}
Login ID: {2}'.format( - atv.name, atv.address, login_id)) - - if not devices: - devices = ['No device(s) found'] - - hass.components.persistent_notification.async_create( - 'The following devices were found:

' + - '

'.join(devices), - title=NOTIFICATION_SCAN_TITLE, - notification_id=NOTIFICATION_SCAN_ID) - - -@asyncio.coroutine -def async_setup(hass, config): - """Set up the Apple TV component.""" - if DATA_APPLE_TV not in hass.data: - hass.data[DATA_APPLE_TV] = {} - - @asyncio.coroutine - def async_service_handler(service): - """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] - else: - devices = hass.data[DATA_ENTITIES] - - for device in devices: - if service.service != SERVICE_AUTHENTICATE: - continue - - atv = device.atv - 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): - """Set up an Apple TV that was auto discovered.""" - yield from _setup_atv(hass, { - CONF_NAME: info['name'], - CONF_HOST: info['host'], - CONF_LOGIN_ID: info['properties']['hG'], - CONF_START_OFF: False - }) - - discovery.async_listen(hass, SERVICE_APPLE_TV, atv_discovered) - - tasks = [_setup_atv(hass, conf) for conf in config.get(DOMAIN, [])] - if tasks: - yield from asyncio.wait(tasks, loop=hass.loop) - - hass.services.async_register( - DOMAIN, SERVICE_SCAN, async_service_handler, - schema=APPLE_TV_SCAN_SCHEMA) - - hass.services.async_register( - DOMAIN, SERVICE_AUTHENTICATE, async_service_handler, - schema=APPLE_TV_AUTHENTICATE_SCHEMA) - - return True - - -@asyncio.coroutine -def _setup_atv(hass, atv_config): - """Set up an Apple TV.""" - import pyatv - name = atv_config.get(CONF_NAME) - host = atv_config.get(CONF_HOST) - login_id = atv_config.get(CONF_LOGIN_ID) - start_off = atv_config.get(CONF_START_OFF) - credentials = atv_config.get(CONF_CREDENTIALS) - - if host in hass.data[DATA_APPLE_TV]: - return - - details = pyatv.AppleTVDevice(name, host, login_id) - session = async_get_clientsession(hass) - atv = pyatv.connect_to_apple_tv(details, hass.loop, session=session) - if credentials: - yield from atv.airplay.load_credentials(credentials) - - power = AppleTVPowerManager(hass, atv, start_off) - hass.data[DATA_APPLE_TV][host] = { - ATTR_ATV: atv, - ATTR_POWER: power - } - - hass.async_create_task(discovery.async_load_platform( - hass, 'media_player', DOMAIN, atv_config)) - - hass.async_create_task(discovery.async_load_platform( - hass, 'remote', DOMAIN, atv_config)) - - -class AppleTVPowerManager: - """Manager for global power management of an Apple TV. - - An instance is used per device to share the same power state between - several platforms. - """ - - def __init__(self, hass, atv, is_off): - """Initialize power manager.""" - self.hass = hass - self.atv = atv - self.listeners = [] - self._is_on = not is_off - - def init(self): - """Initialize power management.""" - if self._is_on: - self.atv.push_updater.start() - - @property - def turned_on(self): - """Return true if device is on or off.""" - return self._is_on - - def set_power_on(self, value): - """Change if a device is on or off.""" - if value != self._is_on: - self._is_on = value - if not self._is_on: - self.atv.push_updater.stop() - else: - self.atv.push_updater.start() - - for listener in self.listeners: - self.hass.async_add_job(listener.async_update_ha_state()) diff --git a/homeassistant/components/apple_tv/__init__.py b/homeassistant/components/apple_tv/__init__.py new file mode 100644 index 000000000..e11b246fd --- /dev/null +++ b/homeassistant/components/apple_tv/__init__.py @@ -0,0 +1,274 @@ +"""Support for Apple TV.""" +import asyncio +import logging +from typing import Sequence, TypeVar, Union + +from pyatv import AppleTVDevice, connect_to_apple_tv, scan_for_apple_tvs +from pyatv.exceptions import DeviceAuthenticationError +import voluptuous as vol + +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 + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "apple_tv" + +SERVICE_SCAN = "apple_tv_scan" +SERVICE_AUTHENTICATE = "apple_tv_authenticate" + +ATTR_ATV = "atv" +ATTR_POWER = "power" + +CONF_LOGIN_ID = "login_id" +CONF_START_OFF = "start_off" +CONF_CREDENTIALS = "credentials" + +DEFAULT_NAME = "Apple TV" + +DATA_APPLE_TV = "data_apple_tv" +DATA_ENTITIES = "data_apple_tv_entities" + +KEY_CONFIG = "apple_tv_configuring" + +NOTIFICATION_AUTH_ID = "apple_tv_auth_notification" +NOTIFICATION_AUTH_TITLE = "Apple TV Authentication" +NOTIFICATION_SCAN_ID = "apple_tv_scan_notification" +NOTIFICATION_SCAN_TITLE = "Apple TV Scan" + +T = TypeVar("T") # pylint: disable=invalid-name + + +# 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( + 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_START_OFF, default=False): cv.boolean, + } + ) + ], + ) + }, + extra=vol.ALLOW_EXTRA, +) + +# Currently no attributes but it might change later +APPLE_TV_SCAN_SCHEMA = vol.Schema({}) + +APPLE_TV_AUTHENTICATE_SCHEMA = vol.Schema({ATTR_ENTITY_ID: cv.entity_ids}) + + +def request_configuration(hass, config, atv, credentials): + """Request configuration steps from the user.""" + configurator = hass.components.configurator + + async def configuration_callback(callback_data): + """Handle the submitted configuration.""" + + pin = callback_data.get("pin") + + try: + await atv.airplay.finish_authentication(pin) + hass.components.persistent_notification.async_create( + "Authentication succeeded!

Add the following " + "to credentials: in your apple_tv configuration:

" + "{0}".format(credentials), + title=NOTIFICATION_AUTH_TITLE, + notification_id=NOTIFICATION_AUTH_ID, + ) + except DeviceAuthenticationError as ex: + hass.components.persistent_notification.async_create( + "Authentication failed! Did you enter correct PIN?

" + "Details: {0}".format(ex), + title=NOTIFICATION_AUTH_TITLE, + notification_id=NOTIFICATION_AUTH_ID, + ) + + hass.async_add_job(configurator.request_done, instance) + + instance = configurator.request_config( + "Apple TV Authentication", + configuration_callback, + description="Please enter PIN code shown on screen.", + submit_caption="Confirm", + fields=[{"id": "pin", "name": "PIN Code", "type": "password"}], + ) + + +async def scan_apple_tvs(hass): + """Scan for devices and present a notification of the ones found.""" + + atvs = await scan_for_apple_tvs(hass.loop, timeout=3) + + devices = [] + for atv in atvs: + login_id = atv.login_id + if login_id is None: + login_id = "Home Sharing disabled" + devices.append( + "Name: {0}
Host: {1}
Login ID: {2}".format( + atv.name, atv.address, login_id + ) + ) + + if not devices: + devices = ["No device(s) found"] + + hass.components.persistent_notification.async_create( + "The following devices were found:

" + "

".join(devices), + title=NOTIFICATION_SCAN_TITLE, + notification_id=NOTIFICATION_SCAN_ID, + ) + + +async def async_setup(hass, config): + """Set up the Apple TV component.""" + if DATA_APPLE_TV not in hass.data: + hass.data[DATA_APPLE_TV] = {} + + async def async_service_handler(service): + """Handle service calls.""" + entity_ids = service.data.get(ATTR_ENTITY_ID) + + if service.service == SERVICE_SCAN: + hass.async_add_job(scan_apple_tvs, hass) + return + + if entity_ids: + devices = [ + device + for device in hass.data[DATA_ENTITIES] + if device.entity_id in entity_ids + ] + else: + devices = hass.data[DATA_ENTITIES] + + for device in devices: + if service.service != SERVICE_AUTHENTICATE: + continue + + atv = device.atv + credentials = await atv.airplay.generate_credentials() + await atv.airplay.load_credentials(credentials) + _LOGGER.debug("Generated new credentials: %s", credentials) + await atv.airplay.start_authentication() + hass.async_add_job(request_configuration, hass, config, atv, credentials) + + async def atv_discovered(service, info): + """Set up an Apple TV that was auto discovered.""" + await _setup_atv( + hass, + config, + { + CONF_NAME: info["name"], + CONF_HOST: info["host"], + CONF_LOGIN_ID: info["properties"]["hG"], + CONF_START_OFF: False, + }, + ) + + discovery.async_listen(hass, SERVICE_APPLE_TV, atv_discovered) + + tasks = [_setup_atv(hass, config, conf) for conf in config.get(DOMAIN, [])] + if tasks: + await asyncio.wait(tasks) + + hass.services.async_register( + DOMAIN, SERVICE_SCAN, async_service_handler, schema=APPLE_TV_SCAN_SCHEMA + ) + + hass.services.async_register( + DOMAIN, + SERVICE_AUTHENTICATE, + async_service_handler, + schema=APPLE_TV_AUTHENTICATE_SCHEMA, + ) + + return True + + +async def _setup_atv(hass, hass_config, atv_config): + """Set up an Apple TV.""" + + name = atv_config.get(CONF_NAME) + host = atv_config.get(CONF_HOST) + login_id = atv_config.get(CONF_LOGIN_ID) + start_off = atv_config.get(CONF_START_OFF) + credentials = atv_config.get(CONF_CREDENTIALS) + + if host in hass.data[DATA_APPLE_TV]: + return + + details = AppleTVDevice(name, host, login_id) + session = async_get_clientsession(hass) + atv = connect_to_apple_tv(details, hass.loop, session=session) + if credentials: + await atv.airplay.load_credentials(credentials) + + power = AppleTVPowerManager(hass, atv, start_off) + hass.data[DATA_APPLE_TV][host] = {ATTR_ATV: atv, ATTR_POWER: power} + + hass.async_create_task( + discovery.async_load_platform( + hass, "media_player", DOMAIN, atv_config, hass_config + ) + ) + + hass.async_create_task( + discovery.async_load_platform(hass, "remote", DOMAIN, atv_config, hass_config) + ) + + +class AppleTVPowerManager: + """Manager for global power management of an Apple TV. + + An instance is used per device to share the same power state between + several platforms. + """ + + def __init__(self, hass, atv, is_off): + """Initialize power manager.""" + self.hass = hass + self.atv = atv + self.listeners = [] + self._is_on = not is_off + + def init(self): + """Initialize power management.""" + if self._is_on: + self.atv.push_updater.start() + + @property + def turned_on(self): + """Return true if device is on or off.""" + return self._is_on + + def set_power_on(self, value): + """Change if a device is on or off.""" + if value != self._is_on: + self._is_on = value + if not self._is_on: + self.atv.push_updater.stop() + else: + self.atv.push_updater.start() + + for listener in self.listeners: + self.hass.async_create_task(listener.async_update_ha_state()) diff --git a/homeassistant/components/apple_tv/manifest.json b/homeassistant/components/apple_tv/manifest.json new file mode 100644 index 000000000..f8fd2e0ef --- /dev/null +++ b/homeassistant/components/apple_tv/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "apple_tv", + "name": "Apple tv", + "documentation": "https://www.home-assistant.io/integrations/apple_tv", + "requirements": [ + "pyatv==0.3.13" + ], + "dependencies": ["configurator"], + "codeowners": [] +} diff --git a/homeassistant/components/apple_tv/media_player.py b/homeassistant/components/apple_tv/media_player.py new file mode 100644 index 000000000..c816be522 --- /dev/null +++ b/homeassistant/components/apple_tv/media_player.py @@ -0,0 +1,290 @@ +"""Support for Apple TV media player.""" +import logging + +import pyatv.const as atv_const + +from homeassistant.components.media_player import MediaPlayerDevice +from homeassistant.components.media_player.const import ( + MEDIA_TYPE_MUSIC, + MEDIA_TYPE_TVSHOW, + MEDIA_TYPE_VIDEO, + SUPPORT_NEXT_TRACK, + SUPPORT_PAUSE, + SUPPORT_PLAY, + SUPPORT_PLAY_MEDIA, + SUPPORT_PREVIOUS_TRACK, + SUPPORT_SEEK, + SUPPORT_STOP, + SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, +) +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + EVENT_HOMEASSISTANT_STOP, + STATE_IDLE, + STATE_OFF, + STATE_PAUSED, + STATE_PLAYING, + STATE_STANDBY, +) +from homeassistant.core import callback +import homeassistant.util.dt as dt_util + +from . import ATTR_ATV, ATTR_POWER, DATA_APPLE_TV, DATA_ENTITIES + +_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 +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the Apple TV platform.""" + if not discovery_info: + return + + # Manage entity cache for service handler + if DATA_ENTITIES not in hass.data: + hass.data[DATA_ENTITIES] = [] + + name = discovery_info[CONF_NAME] + host = discovery_info[CONF_HOST] + atv = hass.data[DATA_APPLE_TV][host][ATTR_ATV] + power = hass.data[DATA_APPLE_TV][host][ATTR_POWER] + entity = AppleTvDevice(atv, name, power) + + @callback + def on_hass_stop(event): + """Stop push updates when hass stops.""" + atv.push_updater.stop() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop) + + if entity not in hass.data[DATA_ENTITIES]: + hass.data[DATA_ENTITIES].append(entity) + + async_add_entities([entity]) + + +class AppleTvDevice(MediaPlayerDevice): + """Representation of an Apple TV device.""" + + def __init__(self, atv, name, power): + """Initialize the Apple TV device.""" + self.atv = atv + self._name = name + self._playing = None + self._power = power + self._power.listeners.append(self) + self.atv.push_updater.listener = self + + async def async_added_to_hass(self): + """Handle when an entity is about to be added to Home Assistant.""" + self._power.init() + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def unique_id(self): + """Return a unique ID.""" + return self.atv.metadata.device_id + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def state(self): + """Return the state of the device.""" + if not self._power.turned_on: + return STATE_OFF + + if self._playing: + + state = self._playing.play_state + if state in ( + atv_const.PLAY_STATE_IDLE, + atv_const.PLAY_STATE_NO_MEDIA, + atv_const.PLAY_STATE_LOADING, + ): + return STATE_IDLE + if state == atv_const.PLAY_STATE_PLAYING: + return STATE_PLAYING + if state in ( + atv_const.PLAY_STATE_PAUSED, + atv_const.PLAY_STATE_FAST_FORWARD, + atv_const.PLAY_STATE_FAST_BACKWARD, + atv_const.PLAY_STATE_STOPPED, + ): + # Catch fast forward/backward here so "play" is default action + return STATE_PAUSED + return STATE_STANDBY # Bad or unknown state? + + @callback + def playstatus_update(self, updater, playing): + """Print what is currently playing when it changes.""" + self._playing = playing + self.async_schedule_update_ha_state() + + @callback + def playstatus_error(self, updater, exception): + """Inform about an error and restart push updates.""" + _LOGGER.warning("A %s error occurred: %s", exception.__class__, exception) + + # This will wait 10 seconds before restarting push updates. If the + # connection continues to fail, it will flood the log (every 10 + # seconds) until it succeeds. A better approach should probably be + # implemented here later. + updater.start(initial_delay=10) + self._playing = None + self.async_schedule_update_ha_state() + + @property + def media_content_type(self): + """Content type of current playing media.""" + if self._playing: + + media_type = self._playing.media_type + if media_type == atv_const.MEDIA_TYPE_VIDEO: + return MEDIA_TYPE_VIDEO + if media_type == atv_const.MEDIA_TYPE_MUSIC: + return MEDIA_TYPE_MUSIC + if media_type == atv_const.MEDIA_TYPE_TV: + return MEDIA_TYPE_TVSHOW + + @property + def media_duration(self): + """Duration of current playing media in seconds.""" + if self._playing: + return self._playing.total_time + + @property + def media_position(self): + """Position of current playing media in seconds.""" + if self._playing: + return self._playing.position + + @property + def media_position_updated_at(self): + """Last valid time of media position.""" + state = self.state + if state in (STATE_PLAYING, STATE_PAUSED): + return dt_util.utcnow() + + async def async_play_media(self, media_type, media_id, **kwargs): + """Send the play_media command to the media player.""" + await self.atv.airplay.play_url(media_id) + + @property + def media_image_hash(self): + """Hash value for media image.""" + state = self.state + if self._playing and state not in [STATE_OFF, STATE_IDLE]: + return self._playing.hash + + async def async_get_media_image(self): + """Fetch media image of current playing image.""" + state = self.state + if self._playing and state not in [STATE_OFF, STATE_IDLE]: + return (await self.atv.metadata.artwork()), "image/png" + + return None, None + + @property + def media_title(self): + """Title of current playing media.""" + if self._playing: + if self.state == STATE_IDLE: + return "Nothing playing" + title = self._playing.title + return title if title else "No title" + + return f"Establishing a connection to {self._name}..." + + @property + def supported_features(self): + """Flag media player features that are supported.""" + return SUPPORT_APPLE_TV + + async def async_turn_on(self): + """Turn the media player on.""" + self._power.set_power_on(True) + + async def async_turn_off(self): + """Turn the media player off.""" + self._playing = None + self._power.set_power_on(False) + + def async_media_play_pause(self): + """Pause media on media player. + + This method must be run in the event loop and returns a coroutine. + """ + if self._playing: + state = self.state + if state == STATE_PAUSED: + return self.atv.remote_control.play() + if state == STATE_PLAYING: + return self.atv.remote_control.pause() + + def async_media_play(self): + """Play media. + + This method must be run in the event loop and returns a coroutine. + """ + if self._playing: + return self.atv.remote_control.play() + + def async_media_stop(self): + """Stop the media player. + + This method must be run in the event loop and returns a coroutine. + """ + if self._playing: + return self.atv.remote_control.stop() + + def async_media_pause(self): + """Pause the media player. + + This method must be run in the event loop and returns a coroutine. + """ + if self._playing: + return self.atv.remote_control.pause() + + def async_media_next_track(self): + """Send next track command. + + This method must be run in the event loop and returns a coroutine. + """ + if self._playing: + return self.atv.remote_control.next() + + def async_media_previous_track(self): + """Send previous track command. + + This method must be run in the event loop and returns a coroutine. + """ + if self._playing: + return self.atv.remote_control.previous() + + def async_media_seek(self, position): + """Send seek command. + + This method must be run in the event loop and returns a coroutine. + """ + if self._playing: + return self.atv.remote_control.set_position(position) diff --git a/homeassistant/components/apple_tv/remote.py b/homeassistant/components/apple_tv/remote.py new file mode 100644 index 000000000..1229b756e --- /dev/null +++ b/homeassistant/components/apple_tv/remote.py @@ -0,0 +1,77 @@ +"""Remote control support for Apple TV.""" +from homeassistant.components import remote +from homeassistant.const import CONF_HOST, CONF_NAME + +from . import ATTR_ATV, ATTR_POWER, DATA_APPLE_TV + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the Apple TV remote platform.""" + if not discovery_info: + return + + name = discovery_info[CONF_NAME] + host = discovery_info[CONF_HOST] + atv = hass.data[DATA_APPLE_TV][host][ATTR_ATV] + power = hass.data[DATA_APPLE_TV][host][ATTR_POWER] + async_add_entities([AppleTVRemote(atv, power, name)]) + + +class AppleTVRemote(remote.RemoteDevice): + """Device that sends commands to an Apple TV.""" + + def __init__(self, atv, power, name): + """Initialize device.""" + self._atv = atv + self._name = name + self._power = power + self._power.listeners.append(self) + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def unique_id(self): + """Return a unique ID.""" + return self._atv.metadata.device_id + + @property + def is_on(self): + """Return true if device is on.""" + return self._power.turned_on + + @property + def should_poll(self): + """No polling needed for Apple TV.""" + return False + + async def async_turn_on(self, **kwargs): + """Turn the device on. + + This method is a coroutine. + """ + self._power.set_power_on(True) + + async def async_turn_off(self, **kwargs): + """Turn the device off. + + This method is a coroutine. + """ + self._power.set_power_on(False) + + def async_send_command(self, command, **kwargs): + """Send a command to one device. + + This method must be run in the event loop and returns a coroutine. + """ + # Send commands in specified order but schedule only one coroutine + async def _send_commands(): + for single_command in command: + if not hasattr(self._atv.remote_control, single_command): + continue + + await getattr(self._atv.remote_control, single_command)() + + return _send_commands() diff --git a/homeassistant/components/apple_tv/services.yaml b/homeassistant/components/apple_tv/services.yaml new file mode 100644 index 000000000..01e26a563 --- /dev/null +++ b/homeassistant/components/apple_tv/services.yaml @@ -0,0 +1,5 @@ +apple_tv_authenticate: + description: Start AirPlay device authentication. + fields: + entity_id: {description: Name(s) of entities to authenticate with., example: media_player.apple_tv} +apple_tv_scan: {description: Scan for Apple TV devices.} diff --git a/homeassistant/components/apprise/__init__.py b/homeassistant/components/apprise/__init__.py new file mode 100644 index 000000000..6ffdaf690 --- /dev/null +++ b/homeassistant/components/apprise/__init__.py @@ -0,0 +1 @@ +"""The apprise component.""" diff --git a/homeassistant/components/apprise/manifest.json b/homeassistant/components/apprise/manifest.json new file mode 100644 index 000000000..09d840e79 --- /dev/null +++ b/homeassistant/components/apprise/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "apprise", + "name": "Apprise", + "documentation": "https://www.home-assistant.io/components/apprise", + "requirements": [ + "apprise==0.8.2" + ], + "dependencies": [], + "codeowners": [ + "@caronc" + ] +} diff --git a/homeassistant/components/apprise/notify.py b/homeassistant/components/apprise/notify.py new file mode 100644 index 000000000..0c8c5b26e --- /dev/null +++ b/homeassistant/components/apprise/notify.py @@ -0,0 +1,71 @@ +"""Apprise platform for notify component.""" +import logging + +import apprise +import voluptuous as vol + +from homeassistant.components.notify import ( + ATTR_TARGET, + ATTR_TITLE, + ATTR_TITLE_DEFAULT, + PLATFORM_SCHEMA, + BaseNotificationService, +) +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +CONF_FILE = "config" +CONF_URL = "url" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_URL): vol.All(cv.ensure_list, [str]), + vol.Optional(CONF_FILE): cv.string, + } +) + + +def get_service(hass, config, discovery_info=None): + """Get the Apprise notification service.""" + + # Create our object + a_obj = apprise.Apprise() + + if config.get(CONF_FILE): + # Sourced from a Configuration File + a_config = apprise.AppriseConfig() + if not a_config.add(config[CONF_FILE]): + _LOGGER.error("Invalid Apprise config url provided") + return None + + if not a_obj.add(a_config): + _LOGGER.error("Invalid Apprise config url provided") + return None + + if config.get(CONF_URL): + # Ordered list of URLs + if not a_obj.add(config[CONF_URL]): + _LOGGER.error("Invalid Apprise URL(s) supplied") + return None + + return AppriseNotificationService(a_obj) + + +class AppriseNotificationService(BaseNotificationService): + """Implement the notification service for Apprise.""" + + def __init__(self, a_obj): + """Initialize the service.""" + self.apprise = a_obj + + def send_message(self, message="", **kwargs): + """Send a message to a specified target. + + If no target/tags are specified, then services are notified as is + However, if any tags are specified, then they will be applied + to the notification causing filtering (if set up that way). + """ + targets = kwargs.get(ATTR_TARGET) + title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) + self.apprise.notify(body=message, title=title, tag=targets) diff --git a/homeassistant/components/aprs/__init__.py b/homeassistant/components/aprs/__init__.py new file mode 100644 index 000000000..20a023166 --- /dev/null +++ b/homeassistant/components/aprs/__init__.py @@ -0,0 +1 @@ +"""The APRS component.""" diff --git a/homeassistant/components/aprs/device_tracker.py b/homeassistant/components/aprs/device_tracker.py new file mode 100644 index 000000000..6258b470e --- /dev/null +++ b/homeassistant/components/aprs/device_tracker.py @@ -0,0 +1,183 @@ +"""Support for APRS device tracking.""" + +import logging +import threading + +import aprslib +from aprslib import ConnectionError as AprsConnectionError, LoginError +import geopy.distance +import voluptuous as vol + +from homeassistant.components.device_tracker import PLATFORM_SCHEMA +from homeassistant.const import ( + ATTR_GPS_ACCURACY, + ATTR_LATITUDE, + ATTR_LONGITUDE, + CONF_HOST, + CONF_PASSWORD, + CONF_TIMEOUT, + CONF_USERNAME, + EVENT_HOMEASSISTANT_STOP, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.util import slugify + +DOMAIN = "aprs" + +_LOGGER = logging.getLogger(__name__) + +ATTR_ALTITUDE = "altitude" +ATTR_COURSE = "course" +ATTR_COMMENT = "comment" +ATTR_FROM = "from" +ATTR_FORMAT = "format" +ATTR_POS_AMBIGUITY = "posambiguity" +ATTR_SPEED = "speed" + +CONF_CALLSIGNS = "callsigns" + +DEFAULT_HOST = "rotate.aprs2.net" +DEFAULT_PASSWORD = "-1" +DEFAULT_TIMEOUT = 30.0 + +FILTER_PORT = 14580 + +MSG_FORMATS = ["compressed", "uncompressed", "mic-e"] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_CALLSIGNS): cv.ensure_list, + vol.Required(CONF_USERNAME): cv.string, + vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, + vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): vol.Coerce(float), + } +) + + +def make_filter(callsigns: list) -> str: + """Make a server-side filter from a list of callsigns.""" + return " ".join("b/{0}".format(cs.upper()) for cs in callsigns) + + +def gps_accuracy(gps, posambiguity: int) -> int: + """Calculate the GPS accuracy based on APRS posambiguity.""" + + pos_a_map = {0: 0, 1: 1 / 600, 2: 1 / 60, 3: 1 / 6, 4: 1} + if posambiguity in pos_a_map: + degrees = pos_a_map[posambiguity] + + gps2 = (gps[0], gps[1] + degrees) + dist_m = geopy.distance.distance(gps, gps2).m + + accuracy = round(dist_m) + else: + message = f"APRS position ambiguity must be 0-4, not '{posambiguity}'." + raise ValueError(message) + + return accuracy + + +def setup_scanner(hass, config, see, discovery_info=None): + """Set up the APRS tracker.""" + callsigns = config.get(CONF_CALLSIGNS) + server_filter = make_filter(callsigns) + + callsign = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + host = config.get(CONF_HOST) + timeout = config.get(CONF_TIMEOUT) + aprs_listener = AprsListenerThread(callsign, password, host, server_filter, see) + + def aprs_disconnect(event): + """Stop the APRS connection.""" + aprs_listener.stop() + + aprs_listener.start() + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, aprs_disconnect) + + if not aprs_listener.start_event.wait(timeout): + _LOGGER.error("Timeout waiting for APRS to connect.") + return + + if not aprs_listener.start_success: + _LOGGER.error(aprs_listener.start_message) + return + + _LOGGER.debug(aprs_listener.start_message) + return True + + +class AprsListenerThread(threading.Thread): + """APRS message listener.""" + + def __init__( + self, callsign: str, password: str, host: str, server_filter: str, see + ): + """Initialize the class.""" + super().__init__() + + self.callsign = callsign + self.host = host + self.start_event = threading.Event() + self.see = see + self.server_filter = server_filter + self.start_message = "" + self.start_success = False + + self.ais = aprslib.IS( + self.callsign, passwd=password, host=self.host, port=FILTER_PORT + ) + + def start_complete(self, success: bool, message: str): + """Complete startup process.""" + self.start_message = message + self.start_success = success + self.start_event.set() + + def run(self): + """Connect to APRS and listen for data.""" + self.ais.set_filter(self.server_filter) + + try: + _LOGGER.info( + "Opening connection to %s with callsign %s.", self.host, self.callsign + ) + self.ais.connect() + self.start_complete( + True, f"Connected to {self.host} with callsign {self.callsign}." + ) + self.ais.consumer(callback=self.rx_msg, immortal=True) + except (AprsConnectionError, LoginError) as err: + self.start_complete(False, str(err)) + except OSError: + _LOGGER.info( + "Closing connection to %s with callsign %s.", self.host, self.callsign + ) + + def stop(self): + """Close the connection to the APRS network.""" + self.ais.close() + + def rx_msg(self, msg: dict): + """Receive message and process if position.""" + _LOGGER.debug("APRS message received: %s", str(msg)) + if msg[ATTR_FORMAT] in MSG_FORMATS: + dev_id = slugify(msg[ATTR_FROM]) + lat = msg[ATTR_LATITUDE] + lon = msg[ATTR_LONGITUDE] + + attrs = {} + if ATTR_POS_AMBIGUITY in msg: + pos_amb = msg[ATTR_POS_AMBIGUITY] + try: + attrs[ATTR_GPS_ACCURACY] = gps_accuracy((lat, lon), pos_amb) + except ValueError: + _LOGGER.warning( + "APRS message contained invalid posambiguity: %s", str(pos_amb) + ) + for attr in [ATTR_ALTITUDE, ATTR_COMMENT, ATTR_COURSE, ATTR_SPEED]: + if attr in msg: + attrs[attr] = msg[attr] + + self.see(dev_id=dev_id, gps=(lat, lon), attributes=attrs) diff --git a/homeassistant/components/aprs/manifest.json b/homeassistant/components/aprs/manifest.json new file mode 100644 index 000000000..c7615817b --- /dev/null +++ b/homeassistant/components/aprs/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "aprs", + "name": "APRS", + "documentation": "https://www.home-assistant.io/integrations/aprs", + "dependencies": [], + "codeowners": ["@PhilRW"], + "requirements": [ + "aprslib==0.6.46", + "geopy==1.19.0" + ] +} diff --git a/homeassistant/components/aqualogic/__init__.py b/homeassistant/components/aqualogic/__init__.py new file mode 100644 index 000000000..9f6939663 --- /dev/null +++ b/homeassistant/components/aqualogic/__init__.py @@ -0,0 +1,90 @@ +"""Support for AquaLogic devices.""" +from datetime import timedelta +import logging +import threading +import time + +from aqualogic.core import AquaLogic +import voluptuous as vol + +from homeassistant.const import ( + CONF_HOST, + CONF_PORT, + EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP, +) +from homeassistant.helpers import config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "aqualogic" +UPDATE_TOPIC = DOMAIN + "_update" +CONF_UNIT = "unit" +RECONNECT_INTERVAL = timedelta(seconds=10) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + {vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PORT): cv.port} + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +def setup(hass, config): + """Set up AquaLogic platform.""" + host = config[DOMAIN][CONF_HOST] + port = config[DOMAIN][CONF_PORT] + processor = AquaLogicProcessor(hass, host, port) + hass.data[DOMAIN] = processor + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, processor.start_listen) + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, processor.shutdown) + _LOGGER.debug("AquaLogicProcessor %s:%i initialized", host, port) + return True + + +class AquaLogicProcessor(threading.Thread): + """AquaLogic event processor thread.""" + + def __init__(self, hass, host, port): + """Initialize the data object.""" + super().__init__(daemon=True) + self._hass = hass + self._host = host + self._port = port + self._shutdown = False + self._panel = None + + def start_listen(self, event): + """Start event-processing thread.""" + _LOGGER.debug("Event processing thread started") + self.start() + + def shutdown(self, event): + """Signal shutdown of processing event.""" + _LOGGER.debug("Event processing signaled exit") + self._shutdown = True + + def data_changed(self, panel): + """Aqualogic data changed callback.""" + self._hass.helpers.dispatcher.dispatcher_send(UPDATE_TOPIC) + + def run(self): + """Event thread.""" + + while True: + self._panel = AquaLogic() + self._panel.connect(self._host, self._port) + self._panel.process(self.data_changed) + + if self._shutdown: + return + + _LOGGER.error("Connection to %s:%d lost", self._host, self._port) + time.sleep(RECONNECT_INTERVAL.seconds) + + @property + def panel(self): + """Retrieve the AquaLogic object.""" + return self._panel diff --git a/homeassistant/components/aqualogic/manifest.json b/homeassistant/components/aqualogic/manifest.json new file mode 100644 index 000000000..2e5dded4a --- /dev/null +++ b/homeassistant/components/aqualogic/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "aqualogic", + "name": "Aqualogic", + "documentation": "https://www.home-assistant.io/integrations/aqualogic", + "requirements": [ + "aqualogic==1.0" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/aqualogic/sensor.py b/homeassistant/components/aqualogic/sensor.py new file mode 100644 index 000000000..1cc06fc44 --- /dev/null +++ b/homeassistant/components/aqualogic/sensor.py @@ -0,0 +1,107 @@ +"""Support for AquaLogic sensors.""" +import logging + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_MONITORED_CONDITIONS, TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +from . import DOMAIN, UPDATE_TOPIC + +_LOGGER = logging.getLogger(__name__) + +TEMP_UNITS = [TEMP_CELSIUS, TEMP_FAHRENHEIT] +PERCENT_UNITS = ["%", "%"] +SALT_UNITS = ["g/L", "PPM"] +WATT_UNITS = ["W", "W"] +NO_UNITS = [None, None] + +# sensor_type [ description, unit, icon ] +# sensor_type corresponds to property names in aqualogic.core.AquaLogic +SENSOR_TYPES = { + "air_temp": ["Air Temperature", TEMP_UNITS, "mdi:thermometer"], + "pool_temp": ["Pool Temperature", TEMP_UNITS, "mdi:oil-temperature"], + "spa_temp": ["Spa Temperature", TEMP_UNITS, "mdi:oil-temperature"], + "pool_chlorinator": ["Pool Chlorinator", PERCENT_UNITS, "mdi:gauge"], + "spa_chlorinator": ["Spa Chlorinator", PERCENT_UNITS, "mdi:gauge"], + "salt_level": ["Salt Level", SALT_UNITS, "mdi:gauge"], + "pump_speed": ["Pump Speed", PERCENT_UNITS, "mdi:speedometer"], + "pump_power": ["Pump Power", WATT_UNITS, "mdi:gauge"], + "status": ["Status", NO_UNITS, "mdi:alert"], +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): vol.All( + cv.ensure_list, [vol.In(SENSOR_TYPES)] + ) + } +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the sensor platform.""" + sensors = [] + + processor = hass.data[DOMAIN] + for sensor_type in config.get(CONF_MONITORED_CONDITIONS): + sensors.append(AquaLogicSensor(processor, sensor_type)) + + async_add_entities(sensors) + + +class AquaLogicSensor(Entity): + """Sensor implementation for the AquaLogic component.""" + + def __init__(self, processor, sensor_type): + """Initialize sensor.""" + self._processor = processor + self._type = sensor_type + self._state = None + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def name(self): + """Return the name of the sensor.""" + return "AquaLogic {}".format(SENSOR_TYPES[self._type][0]) + + @property + def unit_of_measurement(self): + """Return the unit of measurement the value is expressed in.""" + panel = self._processor.panel + if panel is None: + return None + if panel.is_metric: + return SENSOR_TYPES[self._type][1][0] + return SENSOR_TYPES[self._type][1][1] + + @property + def should_poll(self): + """Return the polling state.""" + return False + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return SENSOR_TYPES[self._type][2] + + async 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 callback.""" + panel = self._processor.panel + if panel is not None: + self._state = getattr(panel, self._type) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/aqualogic/switch.py b/homeassistant/components/aqualogic/switch.py new file mode 100644 index 000000000..74f1a9d9f --- /dev/null +++ b/homeassistant/components/aqualogic/switch.py @@ -0,0 +1,112 @@ +"""Support for AquaLogic switches.""" +import logging + +from aqualogic.core import States +import voluptuous as vol + +from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice +from homeassistant.const import CONF_MONITORED_CONDITIONS +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv + +from . import DOMAIN, UPDATE_TOPIC + +_LOGGER = logging.getLogger(__name__) + +SWITCH_TYPES = { + "lights": "Lights", + "filter": "Filter", + "filter_low_speed": "Filter Low Speed", + "aux_1": "Aux 1", + "aux_2": "Aux 2", + "aux_3": "Aux 3", + "aux_4": "Aux 4", + "aux_5": "Aux 5", + "aux_6": "Aux 6", + "aux_7": "Aux 7", +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SWITCH_TYPES)): vol.All( + cv.ensure_list, [vol.In(SWITCH_TYPES)] + ) + } +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the switch platform.""" + switches = [] + + processor = hass.data[DOMAIN] + for switch_type in config.get(CONF_MONITORED_CONDITIONS): + switches.append(AquaLogicSwitch(processor, switch_type)) + + async_add_entities(switches) + + +class AquaLogicSwitch(SwitchDevice): + """Switch implementation for the AquaLogic component.""" + + def __init__(self, processor, switch_type): + """Initialize switch.""" + + self._processor = processor + self._type = switch_type + self._state_name = { + "lights": States.LIGHTS, + "filter": States.FILTER, + "filter_low_speed": States.FILTER_LOW_SPEED, + "aux_1": States.AUX_1, + "aux_2": States.AUX_2, + "aux_3": States.AUX_3, + "aux_4": States.AUX_4, + "aux_5": States.AUX_5, + "aux_6": States.AUX_6, + "aux_7": States.AUX_7, + }[switch_type] + + @property + def name(self): + """Return the name of the switch.""" + return "AquaLogic {}".format(SWITCH_TYPES[self._type]) + + @property + def should_poll(self): + """Return the polling state.""" + return False + + @property + def is_on(self): + """Return true if device is on.""" + panel = self._processor.panel + if panel is None: + return False + state = panel.get_state(self._state_name) + return state + + def turn_on(self, **kwargs): + """Turn the device on.""" + panel = self._processor.panel + if panel is None: + return + panel.set_state(self._state_name, True) + + def turn_off(self, **kwargs): + """Turn the device off.""" + panel = self._processor.panel + if panel is None: + return + panel.set_state(self._state_name, False) + + async 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 callback.""" + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/aquostv/__init__.py b/homeassistant/components/aquostv/__init__.py new file mode 100644 index 000000000..a7f39037f --- /dev/null +++ b/homeassistant/components/aquostv/__init__.py @@ -0,0 +1 @@ +"""The aquostv component.""" diff --git a/homeassistant/components/aquostv/manifest.json b/homeassistant/components/aquostv/manifest.json new file mode 100644 index 000000000..d4c491cb7 --- /dev/null +++ b/homeassistant/components/aquostv/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "aquostv", + "name": "Aquostv", + "documentation": "https://www.home-assistant.io/integrations/aquostv", + "requirements": [ + "sharp_aquos_rc==0.3.2" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/aquostv/media_player.py b/homeassistant/components/aquostv/media_player.py new file mode 100644 index 000000000..f71f41dc2 --- /dev/null +++ b/homeassistant/components/aquostv/media_player.py @@ -0,0 +1,263 @@ +"""Support for interface with an Aquos TV.""" +import logging + +import sharp_aquos_rc +import voluptuous as vol + +from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerDevice +from homeassistant.components.media_player.const import ( + SUPPORT_NEXT_TRACK, + SUPPORT_PAUSE, + SUPPORT_PLAY, + SUPPORT_PREVIOUS_TRACK, + SUPPORT_SELECT_SOURCE, + SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, + SUPPORT_VOLUME_MUTE, + SUPPORT_VOLUME_SET, + SUPPORT_VOLUME_STEP, +) +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_TIMEOUT, + CONF_USERNAME, + STATE_OFF, + STATE_ON, +) +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = "Sharp Aquos TV" +DEFAULT_PORT = 10002 +DEFAULT_USERNAME = "admin" +DEFAULT_PASSWORD = "password" +DEFAULT_TIMEOUT = 0.5 +DEFAULT_RETRIES = 2 + +SUPPORT_SHARPTV = ( + SUPPORT_TURN_OFF + | SUPPORT_NEXT_TRACK + | SUPPORT_PAUSE + | SUPPORT_PREVIOUS_TRACK + | SUPPORT_SELECT_SOURCE + | SUPPORT_VOLUME_MUTE + | SUPPORT_VOLUME_STEP + | SUPPORT_VOLUME_SET + | SUPPORT_PLAY +) + +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, + vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, + vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.string, + vol.Optional("retries", default=DEFAULT_RETRIES): cv.string, + vol.Optional("power_on_enabled", default=False): cv.boolean, + } +) + +SOURCES = { + 0: "TV / Antenna", + 1: "HDMI_IN_1", + 2: "HDMI_IN_2", + 3: "HDMI_IN_3", + 4: "HDMI_IN_4", + 5: "COMPONENT IN", + 6: "VIDEO_IN_1", + 7: "VIDEO_IN_2", + 8: "PC_IN", +} + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Sharp Aquos TV platform.""" + + name = config.get(CONF_NAME) + port = config.get(CONF_PORT) + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + power_on_enabled = config.get("power_on_enabled") + + if discovery_info: + _LOGGER.debug("%s", discovery_info) + vals = discovery_info.split(":") + if len(vals) > 1: + port = vals[1] + + host = vals[0] + remote = sharp_aquos_rc.TV(host, port, username, password, timeout=20) + add_entities([SharpAquosTVDevice(name, remote, power_on_enabled)]) + return True + + host = config.get(CONF_HOST) + remote = sharp_aquos_rc.TV(host, port, username, password, 15, 1) + + add_entities([SharpAquosTVDevice(name, remote, power_on_enabled)]) + return True + + +def _retry(func): + """Handle query retries.""" + + def wrapper(obj, *args, **kwargs): + """Wrap all query functions.""" + update_retries = 5 + while update_retries > 0: + try: + func(obj, *args, **kwargs) + break + except (OSError, TypeError, ValueError): + update_retries -= 1 + if update_retries == 0: + obj.set_state(STATE_OFF) + + return wrapper + + +class SharpAquosTVDevice(MediaPlayerDevice): + """Representation of a Aquos TV.""" + + def __init__(self, name, remote, power_on_enabled=False): + """Initialize the aquos device.""" + global SUPPORT_SHARPTV + self._power_on_enabled = power_on_enabled + if self._power_on_enabled: + SUPPORT_SHARPTV = SUPPORT_SHARPTV | SUPPORT_TURN_ON + # Save a reference to the imported class + self._name = name + # Assume that the TV is not muted + self._muted = False + self._state = None + self._remote = remote + self._volume = 0 + self._source = None + self._source_list = list(SOURCES.values()) + + def set_state(self, state): + """Set TV state.""" + self._state = state + + @_retry + def update(self): + """Retrieve the latest data.""" + if self._remote.power() == 1: + self._state = STATE_ON + else: + self._state = STATE_OFF + # Set TV to be able to remotely power on + if self._power_on_enabled: + self._remote.power_on_command_settings(2) + else: + self._remote.power_on_command_settings(0) + # Get mute state + if self._remote.mute() == 2: + self._muted = False + else: + self._muted = True + # Get source + self._source = SOURCES.get(self._remote.input()) + # Get volume + self._volume = self._remote.volume() / 60 + + @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(self): + """Return the current source.""" + return self._source + + @property + def source_list(self): + """Return the source list.""" + return self._source_list + + @property + def volume_level(self): + """Volume level of the media player (0..1).""" + return self._volume + + @property + def is_volume_muted(self): + """Boolean if volume is currently muted.""" + return self._muted + + @property + def supported_features(self): + """Flag media player features that are supported.""" + return SUPPORT_SHARPTV + + @_retry + def turn_off(self): + """Turn off tvplayer.""" + self._remote.power(0) + + @_retry + def volume_up(self): + """Volume up the media player.""" + self._remote.volume(int(self._volume * 60) + 2) + + @_retry + def volume_down(self): + """Volume down media player.""" + self._remote.volume(int(self._volume * 60) - 2) + + @_retry + def set_volume_level(self, volume): + """Set Volume media player.""" + self._remote.volume(int(volume * 60)) + + @_retry + def mute_volume(self, mute): + """Send mute command.""" + self._remote.mute(0) + + @_retry + def turn_on(self): + """Turn the media player on.""" + self._remote.power(1) + + @_retry + def media_play_pause(self): + """Simulate play pause media player.""" + self._remote.remote_button(40) + + @_retry + def media_play(self): + """Send play command.""" + self._remote.remote_button(16) + + @_retry + def media_pause(self): + """Send pause command.""" + self._remote.remote_button(16) + + @_retry + def media_next_track(self): + """Send next track command.""" + self._remote.remote_button(21) + + @_retry + def media_previous_track(self): + """Send the previous track command.""" + self._remote.remote_button(19) + + def select_source(self, source): + """Set the input source.""" + for key, value in SOURCES.items(): + if source == value: + self._remote.input(key) diff --git a/homeassistant/components/arcam_fmj/.translations/bg.json b/homeassistant/components/arcam_fmj/.translations/bg.json new file mode 100644 index 000000000..b0ad4660d --- /dev/null +++ b/homeassistant/components/arcam_fmj/.translations/bg.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Arcam FMJ" + } +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/.translations/ca.json b/homeassistant/components/arcam_fmj/.translations/ca.json new file mode 100644 index 000000000..b0ad4660d --- /dev/null +++ b/homeassistant/components/arcam_fmj/.translations/ca.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Arcam FMJ" + } +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/.translations/da.json b/homeassistant/components/arcam_fmj/.translations/da.json new file mode 100644 index 000000000..b0ad4660d --- /dev/null +++ b/homeassistant/components/arcam_fmj/.translations/da.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Arcam FMJ" + } +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/.translations/de.json b/homeassistant/components/arcam_fmj/.translations/de.json new file mode 100644 index 000000000..b0ad4660d --- /dev/null +++ b/homeassistant/components/arcam_fmj/.translations/de.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Arcam FMJ" + } +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/.translations/en.json b/homeassistant/components/arcam_fmj/.translations/en.json new file mode 100644 index 000000000..b0ad4660d --- /dev/null +++ b/homeassistant/components/arcam_fmj/.translations/en.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Arcam FMJ" + } +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/.translations/es-419.json b/homeassistant/components/arcam_fmj/.translations/es-419.json new file mode 100644 index 000000000..b0ad4660d --- /dev/null +++ b/homeassistant/components/arcam_fmj/.translations/es-419.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Arcam FMJ" + } +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/.translations/es.json b/homeassistant/components/arcam_fmj/.translations/es.json new file mode 100644 index 000000000..b0ad4660d --- /dev/null +++ b/homeassistant/components/arcam_fmj/.translations/es.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Arcam FMJ" + } +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/.translations/fr.json b/homeassistant/components/arcam_fmj/.translations/fr.json new file mode 100644 index 000000000..b0ad4660d --- /dev/null +++ b/homeassistant/components/arcam_fmj/.translations/fr.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Arcam FMJ" + } +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/.translations/it.json b/homeassistant/components/arcam_fmj/.translations/it.json new file mode 100644 index 000000000..b0ad4660d --- /dev/null +++ b/homeassistant/components/arcam_fmj/.translations/it.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Arcam FMJ" + } +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/.translations/ko.json b/homeassistant/components/arcam_fmj/.translations/ko.json new file mode 100644 index 000000000..b0ad4660d --- /dev/null +++ b/homeassistant/components/arcam_fmj/.translations/ko.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Arcam FMJ" + } +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/.translations/lb.json b/homeassistant/components/arcam_fmj/.translations/lb.json new file mode 100644 index 000000000..b0ad4660d --- /dev/null +++ b/homeassistant/components/arcam_fmj/.translations/lb.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Arcam FMJ" + } +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/.translations/nl.json b/homeassistant/components/arcam_fmj/.translations/nl.json new file mode 100644 index 000000000..b0ad4660d --- /dev/null +++ b/homeassistant/components/arcam_fmj/.translations/nl.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Arcam FMJ" + } +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/.translations/nn.json b/homeassistant/components/arcam_fmj/.translations/nn.json new file mode 100644 index 000000000..b0ad4660d --- /dev/null +++ b/homeassistant/components/arcam_fmj/.translations/nn.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Arcam FMJ" + } +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/.translations/no.json b/homeassistant/components/arcam_fmj/.translations/no.json new file mode 100644 index 000000000..b0ad4660d --- /dev/null +++ b/homeassistant/components/arcam_fmj/.translations/no.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Arcam FMJ" + } +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/.translations/pl.json b/homeassistant/components/arcam_fmj/.translations/pl.json new file mode 100644 index 000000000..b0ad4660d --- /dev/null +++ b/homeassistant/components/arcam_fmj/.translations/pl.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Arcam FMJ" + } +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/.translations/pt-BR.json b/homeassistant/components/arcam_fmj/.translations/pt-BR.json new file mode 100644 index 000000000..b0ad4660d --- /dev/null +++ b/homeassistant/components/arcam_fmj/.translations/pt-BR.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Arcam FMJ" + } +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/.translations/ru.json b/homeassistant/components/arcam_fmj/.translations/ru.json new file mode 100644 index 000000000..b0ad4660d --- /dev/null +++ b/homeassistant/components/arcam_fmj/.translations/ru.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Arcam FMJ" + } +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/.translations/sl.json b/homeassistant/components/arcam_fmj/.translations/sl.json new file mode 100644 index 000000000..b0ad4660d --- /dev/null +++ b/homeassistant/components/arcam_fmj/.translations/sl.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Arcam FMJ" + } +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/.translations/zh-Hant.json b/homeassistant/components/arcam_fmj/.translations/zh-Hant.json new file mode 100644 index 000000000..b0ad4660d --- /dev/null +++ b/homeassistant/components/arcam_fmj/.translations/zh-Hant.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Arcam FMJ" + } +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/__init__.py b/homeassistant/components/arcam_fmj/__init__.py new file mode 100644 index 000000000..d81841475 --- /dev/null +++ b/homeassistant/components/arcam_fmj/__init__.py @@ -0,0 +1,164 @@ +"""Arcam component.""" +import asyncio +import logging + +from arcam.fmj import ConnectionFailed +from arcam.fmj.client import Client +import async_timeout +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PORT, + CONF_SCAN_INTERVAL, + CONF_ZONE, + EVENT_HOMEASSISTANT_STOP, + SERVICE_TURN_ON, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType, HomeAssistantType + +from .const import ( + DEFAULT_NAME, + DEFAULT_PORT, + DEFAULT_SCAN_INTERVAL, + DOMAIN, + DOMAIN_DATA_CONFIG, + DOMAIN_DATA_ENTRIES, + SIGNAL_CLIENT_DATA, + SIGNAL_CLIENT_STARTED, + SIGNAL_CLIENT_STOPPED, +) + +_LOGGER = logging.getLogger(__name__) + + +def _optional_zone(value): + if value: + return ZONE_SCHEMA(value) + return ZONE_SCHEMA({}) + + +def _zone_name_validator(config): + for zone, zone_config in config[CONF_ZONE].items(): + if CONF_NAME not in zone_config: + zone_config[CONF_NAME] = "{} ({}:{}) - {}".format( + DEFAULT_NAME, config[CONF_HOST], config[CONF_PORT], zone + ) + return config + + +ZONE_SCHEMA = vol.Schema( + { + vol.Optional(CONF_NAME): cv.string, + vol.Optional(SERVICE_TURN_ON): cv.SERVICE_SCHEMA, + } +) + +DEVICE_SCHEMA = vol.Schema( + vol.All( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.positive_int, + vol.Optional(CONF_ZONE, default={1: _optional_zone(None)}): { + vol.In([1, 2]): _optional_zone + }, + vol.Optional( + CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL + ): cv.positive_int, + }, + _zone_name_validator, + ) +) + +CONFIG_SCHEMA = vol.Schema( + {DOMAIN: vol.All(cv.ensure_list, [DEVICE_SCHEMA])}, extra=vol.ALLOW_EXTRA +) + + +async def async_setup(hass: HomeAssistantType, config: ConfigType): + """Set up the component.""" + hass.data[DOMAIN_DATA_ENTRIES] = {} + hass.data[DOMAIN_DATA_CONFIG] = {} + + for device in config[DOMAIN]: + hass.data[DOMAIN_DATA_CONFIG][(device[CONF_HOST], device[CONF_PORT])] = device + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_HOST: device[CONF_HOST], CONF_PORT: device[CONF_PORT]}, + ) + ) + + return True + + +async def async_setup_entry(hass: HomeAssistantType, entry: config_entries.ConfigEntry): + """Set up an access point from a config entry.""" + client = Client(entry.data[CONF_HOST], entry.data[CONF_PORT]) + + config = hass.data[DOMAIN_DATA_CONFIG].get( + (entry.data[CONF_HOST], entry.data[CONF_PORT]), + DEVICE_SCHEMA( + {CONF_HOST: entry.data[CONF_HOST], CONF_PORT: entry.data[CONF_PORT]} + ), + ) + + hass.data[DOMAIN_DATA_ENTRIES][entry.entry_id] = { + "client": client, + "config": config, + } + + asyncio.ensure_future(_run_client(hass, client, config[CONF_SCAN_INTERVAL])) + + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, "media_player") + ) + + return True + + +async def _run_client(hass, client, interval): + task = asyncio.Task.current_task() + run = True + + async def _stop(_): + nonlocal run + run = False + task.cancel() + await task + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop) + + def _listen(_): + hass.helpers.dispatcher.async_dispatcher_send(SIGNAL_CLIENT_DATA, client.host) + + while run: + try: + with async_timeout.timeout(interval): + await client.start() + + _LOGGER.debug("Client connected %s", client.host) + hass.helpers.dispatcher.async_dispatcher_send( + SIGNAL_CLIENT_STARTED, client.host + ) + + try: + with client.listen(_listen): + await client.process() + finally: + await client.stop() + + _LOGGER.debug("Client disconnected %s", client.host) + hass.helpers.dispatcher.async_dispatcher_send( + SIGNAL_CLIENT_STOPPED, client.host + ) + + except ConnectionFailed: + await asyncio.sleep(interval) + except asyncio.TimeoutError: + continue diff --git a/homeassistant/components/arcam_fmj/config_flow.py b/homeassistant/components/arcam_fmj/config_flow.py new file mode 100644 index 000000000..a92a2ec52 --- /dev/null +++ b/homeassistant/components/arcam_fmj/config_flow.py @@ -0,0 +1,27 @@ +"""Config flow to configure the Arcam FMJ component.""" +from operator import itemgetter + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_PORT + +from .const import DOMAIN + +_GETKEY = itemgetter(CONF_HOST, CONF_PORT) + + +@config_entries.HANDLERS.register(DOMAIN) +class ArcamFmjFlowHandler(config_entries.ConfigFlow): + """Handle a SimpliSafe config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + async def async_step_import(self, import_config): + """Import a config entry from configuration.yaml.""" + entries = self.hass.config_entries.async_entries(DOMAIN) + import_key = _GETKEY(import_config) + for entry in entries: + if _GETKEY(entry.data) == import_key: + return self.async_abort(reason="already_setup") + + return self.async_create_entry(title="Arcam FMJ", data=import_config) diff --git a/homeassistant/components/arcam_fmj/const.py b/homeassistant/components/arcam_fmj/const.py new file mode 100644 index 000000000..dc5a576ac --- /dev/null +++ b/homeassistant/components/arcam_fmj/const.py @@ -0,0 +1,13 @@ +"""Constants used for arcam.""" +DOMAIN = "arcam_fmj" + +SIGNAL_CLIENT_STARTED = "arcam.client_started" +SIGNAL_CLIENT_STOPPED = "arcam.client_stopped" +SIGNAL_CLIENT_DATA = "arcam.client_data" + +DEFAULT_PORT = 50000 +DEFAULT_NAME = "Arcam FMJ" +DEFAULT_SCAN_INTERVAL = 5 + +DOMAIN_DATA_ENTRIES = f"{DOMAIN}.entries" +DOMAIN_DATA_CONFIG = f"{DOMAIN}.config" diff --git a/homeassistant/components/arcam_fmj/manifest.json b/homeassistant/components/arcam_fmj/manifest.json new file mode 100644 index 000000000..288b8fb38 --- /dev/null +++ b/homeassistant/components/arcam_fmj/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "arcam_fmj", + "name": "Arcam FMJ Receiver control", + "config_flow": false, + "documentation": "https://www.home-assistant.io/integrations/arcam_fmj", + "requirements": [ + "arcam-fmj==0.4.3" + ], + "dependencies": [], + "codeowners": [ + "@elupus" + ] +} diff --git a/homeassistant/components/arcam_fmj/media_player.py b/homeassistant/components/arcam_fmj/media_player.py new file mode 100644 index 000000000..8a54c7456 --- /dev/null +++ b/homeassistant/components/arcam_fmj/media_player.py @@ -0,0 +1,325 @@ +"""Arcam media player.""" +import logging +from typing import Optional + +from arcam.fmj import DecodeMode2CH, DecodeModeMCH, IncomingAudioFormat, SourceCodes +from arcam.fmj.state import State + +from homeassistant import config_entries +from homeassistant.components.media_player import MediaPlayerDevice +from homeassistant.components.media_player.const import ( + MEDIA_TYPE_MUSIC, + SUPPORT_SELECT_SOUND_MODE, + SUPPORT_SELECT_SOURCE, + SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, + SUPPORT_VOLUME_MUTE, + SUPPORT_VOLUME_SET, + SUPPORT_VOLUME_STEP, +) +from homeassistant.const import ( + CONF_NAME, + CONF_ZONE, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, +) +from homeassistant.core import callback +from homeassistant.helpers.service import async_call_from_config +from homeassistant.helpers.typing import ConfigType, HomeAssistantType + +from .const import ( + DOMAIN, + DOMAIN_DATA_ENTRIES, + SIGNAL_CLIENT_DATA, + SIGNAL_CLIENT_STARTED, + SIGNAL_CLIENT_STOPPED, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistantType, + config_entry: config_entries.ConfigEntry, + async_add_entities, +): + """Set up the configuration entry.""" + data = hass.data[DOMAIN_DATA_ENTRIES][config_entry.entry_id] + client = data["client"] + config = data["config"] + + async_add_entities( + [ + ArcamFmj( + State(client, zone), + zone_config[CONF_NAME], + zone_config.get(SERVICE_TURN_ON), + ) + for zone, zone_config in config[CONF_ZONE].items() + ] + ) + + return True + + +class ArcamFmj(MediaPlayerDevice): + """Representation of a media device.""" + + def __init__(self, state: State, name: str, turn_on: Optional[ConfigType]): + """Initialize device.""" + self._state = state + self._name = name + self._turn_on = turn_on + self._support = ( + SUPPORT_SELECT_SOURCE + | SUPPORT_VOLUME_SET + | SUPPORT_VOLUME_MUTE + | SUPPORT_VOLUME_STEP + | SUPPORT_TURN_OFF + ) + if state.zn == 1: + self._support |= SUPPORT_SELECT_SOUND_MODE + + def _get_2ch(self): + """Return if source is 2 channel or not.""" + audio_format, _ = self._state.get_incoming_audio_format() + return bool( + audio_format + in (IncomingAudioFormat.PCM, IncomingAudioFormat.ANALOGUE_DIRECT, None) + ) + + @property + def device_info(self): + """Return a device description for device registry.""" + return { + "identifiers": {(DOMAIN, self._state.client.host, self._state.client.port)}, + "model": "FMJ", + "manufacturer": "Arcam", + } + + @property + def should_poll(self) -> bool: + """No need to poll.""" + return False + + @property + def name(self): + """Return the name of the controlled device.""" + return self._name + + @property + def state(self): + """Return the state of the device.""" + if self._state.get_power(): + return STATE_ON + return STATE_OFF + + @property + def supported_features(self): + """Flag media player features that are supported.""" + support = self._support + if self._state.get_power() is not None or self._turn_on: + support |= SUPPORT_TURN_ON + return support + + async def async_added_to_hass(self): + """Once registered, add listener for events.""" + await self._state.start() + + @callback + def _data(host): + if host == self._state.client.host: + self.async_schedule_update_ha_state() + + @callback + def _started(host): + if host == self._state.client.host: + self.async_schedule_update_ha_state(force_refresh=True) + + @callback + def _stopped(host): + if host == self._state.client.host: + self.async_schedule_update_ha_state(force_refresh=True) + + self.hass.helpers.dispatcher.async_dispatcher_connect(SIGNAL_CLIENT_DATA, _data) + + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_CLIENT_STARTED, _started + ) + + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_CLIENT_STOPPED, _stopped + ) + + async def async_update(self): + """Force update of state.""" + _LOGGER.debug("Update state %s", self.name) + await self._state.update() + + async def async_mute_volume(self, mute): + """Send mute command.""" + await self._state.set_mute(mute) + self.async_schedule_update_ha_state() + + async def async_select_source(self, source): + """Select a specific source.""" + try: + value = SourceCodes[source] + except KeyError: + _LOGGER.error("Unsupported source %s", source) + return + + await self._state.set_source(value) + self.async_schedule_update_ha_state() + + async def async_select_sound_mode(self, sound_mode): + """Select a specific source.""" + try: + if self._get_2ch(): + await self._state.set_decode_mode_2ch(DecodeMode2CH[sound_mode]) + else: + await self._state.set_decode_mode_mch(DecodeModeMCH[sound_mode]) + except KeyError: + _LOGGER.error("Unsupported sound_mode %s", sound_mode) + return + + self.async_schedule_update_ha_state() + + async def async_set_volume_level(self, volume): + """Set volume level, range 0..1.""" + await self._state.set_volume(round(volume * 99.0)) + self.async_schedule_update_ha_state() + + async def async_volume_up(self): + """Turn volume up for media player.""" + await self._state.inc_volume() + self.async_schedule_update_ha_state() + + async def async_volume_down(self): + """Turn volume up for media player.""" + await self._state.dec_volume() + self.async_schedule_update_ha_state() + + async def async_turn_on(self): + """Turn the media player on.""" + if self._state.get_power() is not None: + _LOGGER.debug("Turning on device using connection") + await self._state.set_power(True) + elif self._turn_on: + _LOGGER.debug("Turning on device using service call") + await async_call_from_config( + self.hass, + self._turn_on, + variables=None, + blocking=True, + validate_config=False, + ) + else: + _LOGGER.error("Unable to turn on") + + async def async_turn_off(self): + """Turn the media player off.""" + await self._state.set_power(False) + + @property + def source(self): + """Return the current input source.""" + value = self._state.get_source() + if value is None: + return None + return value.name + + @property + def source_list(self): + """List of available input sources.""" + return [x.name for x in self._state.get_source_list()] + + @property + def sound_mode(self): + """Name of the current sound mode.""" + if self._state.zn != 1: + return None + + if self._get_2ch(): + value = self._state.get_decode_mode_2ch() + else: + value = self._state.get_decode_mode_mch() + if value: + return value.name + return None + + @property + def sound_mode_list(self): + """List of available sound modes.""" + if self._state.zn != 1: + return None + + if self._get_2ch(): + return [x.name for x in DecodeMode2CH] + return [x.name for x in DecodeModeMCH] + + @property + def is_volume_muted(self): + """Boolean if volume is currently muted.""" + value = self._state.get_mute() + if value is None: + return None + return value + + @property + def volume_level(self): + """Volume level of device.""" + value = self._state.get_volume() + if value is None: + return None + return value / 99.0 + + @property + def media_content_type(self): + """Content type of current playing media.""" + source = self._state.get_source() + if source == SourceCodes.DAB: + value = MEDIA_TYPE_MUSIC + elif source == SourceCodes.FM: + value = MEDIA_TYPE_MUSIC + else: + value = None + return value + + @property + def media_channel(self): + """Channel currently playing.""" + source = self._state.get_source() + if source == SourceCodes.DAB: + value = self._state.get_dab_station() + elif source == SourceCodes.FM: + value = self._state.get_rds_information() + else: + value = None + return value + + @property + def media_artist(self): + """Artist of current playing media, music track only.""" + source = self._state.get_source() + if source == SourceCodes.DAB: + value = self._state.get_dls_pdt() + else: + value = None + return value + + @property + def media_title(self): + """Title of current playing media.""" + source = self._state.get_source() + if source is None: + return None + + channel = self.media_channel + + if channel: + value = f"{source.name} - {channel}" + else: + value = source.name + return value diff --git a/homeassistant/components/arcam_fmj/strings.json b/homeassistant/components/arcam_fmj/strings.json new file mode 100644 index 000000000..b0006dbb5 --- /dev/null +++ b/homeassistant/components/arcam_fmj/strings.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Arcam FMJ" + } +} diff --git a/homeassistant/components/arduino.py b/homeassistant/components/arduino.py deleted file mode 100644 index 785f8c57f..000000000 --- a/homeassistant/components/arduino.py +++ /dev/null @@ -1,120 +0,0 @@ -""" -Support for Arduino boards running with the Firmata firmware. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/arduino/ -""" -import logging - -import voluptuous as vol - -from homeassistant.const import ( - EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) -from homeassistant.const import CONF_PORT -import homeassistant.helpers.config_validation as cv - -REQUIREMENTS = ['PyMata==2.14'] - -_LOGGER = logging.getLogger(__name__) - -BOARD = None - -DOMAIN = 'arduino' - -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - vol.Required(CONF_PORT): cv.string, - }), -}, extra=vol.ALLOW_EXTRA) - - -def setup(hass, config): - """Set up the Arduino component.""" - import serial - - port = config[DOMAIN][CONF_PORT] - - global BOARD - try: - BOARD = ArduinoBoard(port) - except (serial.serialutil.SerialException, FileNotFoundError): - _LOGGER.error("Your port %s is not accessible", port) - return False - - try: - if BOARD.get_firmata()[1] <= 2: - _LOGGER.error("The StandardFirmata sketch should be 2.2 or newer") - return False - except IndexError: - _LOGGER.warning("The version of the StandardFirmata sketch was not" - "detected. This may lead to side effects") - - def stop_arduino(event): - """Stop the Arduino service.""" - BOARD.disconnect() - - def start_arduino(event): - """Start the Arduino service.""" - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_arduino) - - hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_arduino) - - return True - - -class ArduinoBoard: - """Representation of an Arduino board.""" - - def __init__(self, port): - """Initialize the board.""" - from PyMata.pymata import PyMata - self._port = port - self._board = PyMata(self._port, verbose=False) - - def set_mode(self, pin, direction, mode): - """Set the mode and the direction of a given pin.""" - if mode == 'analog' and direction == 'in': - self._board.set_pin_mode( - pin, self._board.INPUT, self._board.ANALOG) - elif mode == 'analog' and direction == 'out': - self._board.set_pin_mode( - pin, self._board.OUTPUT, self._board.ANALOG) - elif mode == 'digital' and direction == 'in': - self._board.set_pin_mode( - pin, self._board.INPUT, self._board.DIGITAL) - elif mode == 'digital' and direction == 'out': - self._board.set_pin_mode( - pin, self._board.OUTPUT, self._board.DIGITAL) - elif mode == 'pwm': - self._board.set_pin_mode( - pin, self._board.OUTPUT, self._board.PWM) - - def get_analog_inputs(self): - """Get the values from the pins.""" - self._board.capability_query() - return self._board.get_analog_response_table() - - def set_digital_out_high(self, pin): - """Set a given digital pin to high.""" - self._board.digital_write(pin, 1) - - def set_digital_out_low(self, pin): - """Set a given digital pin to low.""" - self._board.digital_write(pin, 0) - - def get_digital_in(self, pin): - """Get the value from a given digital pin.""" - self._board.digital_read(pin) - - def get_analog_in(self, pin): - """Get the value from a given analog pin.""" - self._board.analog_read(pin) - - def get_firmata(self): - """Return the version of the Firmata firmware.""" - return self._board.get_firmata_version() - - def disconnect(self): - """Disconnect the board and close the serial connection.""" - self._board.reset() - self._board.close() diff --git a/homeassistant/components/arduino/__init__.py b/homeassistant/components/arduino/__init__.py new file mode 100644 index 000000000..61b03a316 --- /dev/null +++ b/homeassistant/components/arduino/__init__.py @@ -0,0 +1,111 @@ +"""Support for Arduino boards running with the Firmata firmware.""" +import logging + +from PyMata.pymata import PyMata +import serial +import voluptuous as vol + +from homeassistant.const import ( + CONF_PORT, + EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP, +) +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +BOARD = None + +DOMAIN = "arduino" + +CONFIG_SCHEMA = vol.Schema( + {DOMAIN: vol.Schema({vol.Required(CONF_PORT): cv.string})}, extra=vol.ALLOW_EXTRA +) + + +def setup(hass, config): + """Set up the Arduino component.""" + + port = config[DOMAIN][CONF_PORT] + + global BOARD + try: + BOARD = ArduinoBoard(port) + except (serial.serialutil.SerialException, FileNotFoundError): + _LOGGER.error("Your port %s is not accessible", port) + return False + + try: + if BOARD.get_firmata()[1] <= 2: + _LOGGER.error("The StandardFirmata sketch should be 2.2 or newer") + return False + except IndexError: + _LOGGER.warning( + "The version of the StandardFirmata sketch was not" + "detected. This may lead to side effects" + ) + + def stop_arduino(event): + """Stop the Arduino service.""" + BOARD.disconnect() + + def start_arduino(event): + """Start the Arduino service.""" + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_arduino) + + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_arduino) + + return True + + +class ArduinoBoard: + """Representation of an Arduino board.""" + + def __init__(self, port): + """Initialize the board.""" + + self._port = port + self._board = PyMata(self._port, verbose=False) + + def set_mode(self, pin, direction, mode): + """Set the mode and the direction of a given pin.""" + if mode == "analog" and direction == "in": + self._board.set_pin_mode(pin, self._board.INPUT, self._board.ANALOG) + elif mode == "analog" and direction == "out": + self._board.set_pin_mode(pin, self._board.OUTPUT, self._board.ANALOG) + elif mode == "digital" and direction == "in": + self._board.set_pin_mode(pin, self._board.INPUT, self._board.DIGITAL) + elif mode == "digital" and direction == "out": + self._board.set_pin_mode(pin, self._board.OUTPUT, self._board.DIGITAL) + elif mode == "pwm": + self._board.set_pin_mode(pin, self._board.OUTPUT, self._board.PWM) + + def get_analog_inputs(self): + """Get the values from the pins.""" + self._board.capability_query() + return self._board.get_analog_response_table() + + def set_digital_out_high(self, pin): + """Set a given digital pin to high.""" + self._board.digital_write(pin, 1) + + def set_digital_out_low(self, pin): + """Set a given digital pin to low.""" + self._board.digital_write(pin, 0) + + def get_digital_in(self, pin): + """Get the value from a given digital pin.""" + self._board.digital_read(pin) + + def get_analog_in(self, pin): + """Get the value from a given analog pin.""" + self._board.analog_read(pin) + + def get_firmata(self): + """Return the version of the Firmata firmware.""" + return self._board.get_firmata_version() + + def disconnect(self): + """Disconnect the board and close the serial connection.""" + self._board.reset() + self._board.close() diff --git a/homeassistant/components/arduino/manifest.json b/homeassistant/components/arduino/manifest.json new file mode 100644 index 000000000..a29f65700 --- /dev/null +++ b/homeassistant/components/arduino/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "arduino", + "name": "Arduino", + "documentation": "https://www.home-assistant.io/integrations/arduino", + "requirements": [ + "PyMata==2.20" + ], + "dependencies": [], + "codeowners": [ + "@fabaff" + ] +} diff --git a/homeassistant/components/arduino/sensor.py b/homeassistant/components/arduino/sensor.py new file mode 100644 index 000000000..c58634755 --- /dev/null +++ b/homeassistant/components/arduino/sensor.py @@ -0,0 +1,63 @@ +"""Support for getting information from Arduino pins.""" +import logging + +import voluptuous as vol + +from homeassistant.components import arduino +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_NAME +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +CONF_PINS = "pins" +CONF_TYPE = "analog" + +PIN_SCHEMA = vol.Schema({vol.Required(CONF_NAME): cv.string}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + {vol.Required(CONF_PINS): vol.Schema({cv.positive_int: PIN_SCHEMA})} +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Arduino platform.""" + if arduino.BOARD is None: + _LOGGER.error("A connection has not been made to the Arduino board") + return False + + pins = config.get(CONF_PINS) + + sensors = [] + for pinnum, pin in pins.items(): + sensors.append(ArduinoSensor(pin.get(CONF_NAME), pinnum, CONF_TYPE)) + add_entities(sensors) + + +class ArduinoSensor(Entity): + """Representation of an Arduino Sensor.""" + + def __init__(self, name, pin, pin_type): + """Initialize the sensor.""" + self._pin = pin + self._name = name + self.pin_type = pin_type + self.direction = "in" + self._value = None + + arduino.BOARD.set_mode(self._pin, self.direction, self.pin_type) + + @property + def state(self): + """Return the state of the sensor.""" + return self._value + + @property + def name(self): + """Get the name of the sensor.""" + return self._name + + def update(self): + """Get the latest value from the pin.""" + self._value = arduino.BOARD.get_analog_inputs()[self._pin][1] diff --git a/homeassistant/components/arduino/switch.py b/homeassistant/components/arduino/switch.py new file mode 100644 index 000000000..5b5b161a2 --- /dev/null +++ b/homeassistant/components/arduino/switch.py @@ -0,0 +1,86 @@ +"""Support for switching Arduino pins on and off.""" +import logging + +import voluptuous as vol + +from homeassistant.components import arduino +from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice +from homeassistant.const import CONF_NAME +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +CONF_PINS = "pins" +CONF_TYPE = "digital" +CONF_NEGATE = "negate" +CONF_INITIAL = "initial" + +PIN_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_INITIAL, default=False): cv.boolean, + vol.Optional(CONF_NEGATE, default=False): cv.boolean, + } +) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + {vol.Required(CONF_PINS, default={}): vol.Schema({cv.positive_int: PIN_SCHEMA})} +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Arduino platform.""" + # Verify that Arduino board is present + if arduino.BOARD is None: + _LOGGER.error("A connection has not been made to the Arduino board") + return False + + pins = config.get(CONF_PINS) + + switches = [] + for pinnum, pin in pins.items(): + switches.append(ArduinoSwitch(pinnum, pin)) + add_entities(switches) + + +class ArduinoSwitch(SwitchDevice): + """Representation of an Arduino switch.""" + + def __init__(self, pin, options): + """Initialize the Pin.""" + self._pin = pin + self._name = options.get(CONF_NAME) + self.pin_type = CONF_TYPE + self.direction = "out" + + self._state = options.get(CONF_INITIAL) + + if options.get(CONF_NEGATE): + self.turn_on_handler = arduino.BOARD.set_digital_out_low + self.turn_off_handler = arduino.BOARD.set_digital_out_high + else: + self.turn_on_handler = arduino.BOARD.set_digital_out_high + self.turn_off_handler = arduino.BOARD.set_digital_out_low + + arduino.BOARD.set_mode(self._pin, self.direction, self.pin_type) + (self.turn_on_handler if self._state else self.turn_off_handler)(pin) + + @property + def name(self): + """Get the name of the pin.""" + return self._name + + @property + def is_on(self): + """Return true if pin is high/on.""" + return self._state + + def turn_on(self, **kwargs): + """Turn the pin to high/on.""" + self._state = True + self.turn_on_handler(self._pin) + + def turn_off(self, **kwargs): + """Turn the pin to low/off.""" + self._state = False + self.turn_off_handler(self._pin) diff --git a/homeassistant/components/arest/__init__.py b/homeassistant/components/arest/__init__.py new file mode 100644 index 000000000..37a104c08 --- /dev/null +++ b/homeassistant/components/arest/__init__.py @@ -0,0 +1 @@ +"""The arest component.""" diff --git a/homeassistant/components/arest/binary_sensor.py b/homeassistant/components/arest/binary_sensor.py new file mode 100644 index 000000000..caabe3333 --- /dev/null +++ b/homeassistant/components/arest/binary_sensor.py @@ -0,0 +1,116 @@ +"""Support for an exposed aREST RESTful API of a device.""" +from datetime import timedelta +import logging + +import requests +import voluptuous as vol + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASSES_SCHEMA, + PLATFORM_SCHEMA, + BinarySensorDevice, +) +from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME, CONF_PIN, CONF_RESOURCE +import homeassistant.helpers.config_validation as cv +from homeassistant.util import Throttle + +_LOGGER = logging.getLogger(__name__) + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_RESOURCE): cv.url, + vol.Optional(CONF_NAME): cv.string, + vol.Required(CONF_PIN): cv.string, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the aREST binary sensor.""" + resource = config.get(CONF_RESOURCE) + pin = config.get(CONF_PIN) + device_class = config.get(CONF_DEVICE_CLASS) + + try: + response = requests.get(resource, timeout=10).json() + except requests.exceptions.MissingSchema: + _LOGGER.error( + "Missing resource or schema in configuration. " "Add http:// to your URL" + ) + return False + except requests.exceptions.ConnectionError: + _LOGGER.error("No route to device at %s", resource) + return False + + arest = ArestData(resource, pin) + + add_entities( + [ + ArestBinarySensor( + arest, + resource, + config.get(CONF_NAME, response[CONF_NAME]), + device_class, + pin, + ) + ], + True, + ) + + +class ArestBinarySensor(BinarySensorDevice): + """Implement an aREST binary sensor for a pin.""" + + def __init__(self, arest, resource, name, device_class, pin): + """Initialize the aREST device.""" + self.arest = arest + self._resource = resource + self._name = name + self._device_class = device_class + self._pin = pin + + if self._pin is not None: + request = requests.get(f"{self._resource}/mode/{self._pin}/i", timeout=10) + if request.status_code != 200: + _LOGGER.error("Can't set mode of %s", self._resource) + + @property + def name(self): + """Return the name of the binary sensor.""" + return self._name + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return bool(self.arest.data.get("state")) + + @property + def device_class(self): + """Return the class of this sensor.""" + return self._device_class + + def update(self): + """Get the latest data from aREST API.""" + self.arest.update() + + +class ArestData: + """Class for handling the data retrieval for pins.""" + + def __init__(self, resource, pin): + """Initialize the aREST data object.""" + self._resource = resource + self._pin = pin + self.data = {} + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data from aREST device.""" + try: + response = requests.get(f"{self._resource}/digital/{self._pin}", timeout=10) + self.data = {"state": response.json()["return_value"]} + except requests.exceptions.ConnectionError: + _LOGGER.error("No route to device '%s'", self._resource) diff --git a/homeassistant/components/arest/manifest.json b/homeassistant/components/arest/manifest.json new file mode 100644 index 000000000..ee6b915e6 --- /dev/null +++ b/homeassistant/components/arest/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "arest", + "name": "Arest", + "documentation": "https://www.home-assistant.io/integrations/arest", + "requirements": [], + "dependencies": [], + "codeowners": [ + "@fabaff" + ] +} diff --git a/homeassistant/components/arest/sensor.py b/homeassistant/components/arest/sensor.py new file mode 100644 index 000000000..270a3cda2 --- /dev/null +++ b/homeassistant/components/arest/sensor.py @@ -0,0 +1,219 @@ +"""Support for an exposed aREST RESTful API of a device.""" +from datetime import timedelta +import logging + +import requests +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_MONITORED_VARIABLES, + CONF_NAME, + CONF_RESOURCE, + CONF_UNIT_OF_MEASUREMENT, + CONF_VALUE_TEMPLATE, +) +from homeassistant.exceptions import TemplateError +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +_LOGGER = logging.getLogger(__name__) + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) + +CONF_FUNCTIONS = "functions" +CONF_PINS = "pins" + +DEFAULT_NAME = "aREST sensor" + +PIN_VARIABLE_SCHEMA = vol.Schema( + { + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + } +) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_RESOURCE): cv.url, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PINS, default={}): vol.Schema( + {cv.string: PIN_VARIABLE_SCHEMA} + ), + vol.Optional(CONF_MONITORED_VARIABLES, default={}): vol.Schema( + {cv.string: PIN_VARIABLE_SCHEMA} + ), + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the aREST sensor.""" + resource = config.get(CONF_RESOURCE) + var_conf = config.get(CONF_MONITORED_VARIABLES) + pins = config.get(CONF_PINS) + + try: + response = requests.get(resource, timeout=10).json() + except requests.exceptions.MissingSchema: + _LOGGER.error( + "Missing resource or schema in configuration. " "Add http:// to your URL" + ) + return False + except requests.exceptions.ConnectionError: + _LOGGER.error("No route to device at %s", resource) + return False + + arest = ArestData(resource) + + def make_renderer(value_template): + """Create a renderer based on variable_template value.""" + if value_template is None: + return lambda value: value + + value_template.hass = hass + + def _render(value): + try: + return value_template.async_render({"value": value}) + except TemplateError: + _LOGGER.exception("Error parsing value") + return value + + return _render + + dev = [] + + if var_conf is not None: + for variable, var_data in var_conf.items(): + if variable not in response["variables"]: + _LOGGER.error("Variable: %s does not exist", variable) + continue + + renderer = make_renderer(var_data.get(CONF_VALUE_TEMPLATE)) + dev.append( + ArestSensor( + arest, + resource, + config.get(CONF_NAME, response[CONF_NAME]), + var_data.get(CONF_NAME, variable), + variable=variable, + unit_of_measurement=var_data.get(CONF_UNIT_OF_MEASUREMENT), + renderer=renderer, + ) + ) + + if pins is not None: + for pinnum, pin in pins.items(): + renderer = make_renderer(pin.get(CONF_VALUE_TEMPLATE)) + dev.append( + ArestSensor( + ArestData(resource, pinnum), + resource, + config.get(CONF_NAME, response[CONF_NAME]), + pin.get(CONF_NAME), + pin=pinnum, + unit_of_measurement=pin.get(CONF_UNIT_OF_MEASUREMENT), + renderer=renderer, + ) + ) + + add_entities(dev, True) + + +class ArestSensor(Entity): + """Implementation of an aREST sensor for exposed variables.""" + + def __init__( + self, + arest, + resource, + location, + name, + variable=None, + pin=None, + unit_of_measurement=None, + renderer=None, + ): + """Initialize the sensor.""" + self.arest = arest + self._resource = resource + self._name = "{} {}".format(location.title(), name.title()) + self._variable = variable + self._pin = pin + self._state = None + self._unit_of_measurement = unit_of_measurement + self._renderer = renderer + + if self._pin is not None: + request = requests.get(f"{self._resource}/mode/{self._pin}/i", timeout=10) + if request.status_code != 200: + _LOGGER.error("Can't set mode of %s", self._resource) + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self._unit_of_measurement + + @property + def state(self): + """Return the state of the sensor.""" + values = self.arest.data + + if "error" in values: + return values["error"] + + value = self._renderer(values.get("value", values.get(self._variable, None))) + return value + + def update(self): + """Get the latest data from aREST API.""" + self.arest.update() + + @property + def available(self): + """Could the device be accessed during the last update call.""" + return self.arest.available + + +class ArestData: + """The Class for handling the data retrieval for variables.""" + + def __init__(self, resource, pin=None): + """Initialize the data object.""" + self._resource = resource + self._pin = pin + self.data = {} + self.available = True + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data from aREST device.""" + try: + if self._pin is None: + response = requests.get(self._resource, timeout=10) + self.data = response.json()["variables"] + else: + try: + if str(self._pin[0]) == "A": + response = requests.get( + "{}/analog/{}".format(self._resource, self._pin[1:]), + timeout=10, + ) + self.data = {"value": response.json()["return_value"]} + except TypeError: + response = requests.get( + f"{self._resource}/digital/{self._pin}", timeout=10 + ) + self.data = {"value": response.json()["return_value"]} + self.available = True + except requests.exceptions.ConnectionError: + _LOGGER.error("No route to device %s", self._resource) + self.available = False diff --git a/homeassistant/components/arest/switch.py b/homeassistant/components/arest/switch.py new file mode 100644 index 000000000..b3db6684c --- /dev/null +++ b/homeassistant/components/arest/switch.py @@ -0,0 +1,210 @@ +"""Support for an exposed aREST RESTful API of a device.""" + +import logging + +import requests +import voluptuous as vol + +from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice +from homeassistant.const import CONF_NAME, CONF_RESOURCE +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +CONF_FUNCTIONS = "functions" +CONF_PINS = "pins" +CONF_INVERT = "invert" + +DEFAULT_NAME = "aREST switch" + +PIN_FUNCTION_SCHEMA = vol.Schema( + { + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_INVERT, default=False): cv.boolean, + } +) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_RESOURCE): cv.url, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PINS, default={}): vol.Schema( + {cv.string: PIN_FUNCTION_SCHEMA} + ), + vol.Optional(CONF_FUNCTIONS, default={}): vol.Schema( + {cv.string: PIN_FUNCTION_SCHEMA} + ), + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the aREST switches.""" + resource = config.get(CONF_RESOURCE) + + try: + response = requests.get(resource, timeout=10) + except requests.exceptions.MissingSchema: + _LOGGER.error( + "Missing resource or schema in configuration. " "Add http:// to your URL" + ) + return False + except requests.exceptions.ConnectionError: + _LOGGER.error("No route to device at %s", resource) + return False + + dev = [] + pins = config.get(CONF_PINS) + for pinnum, pin in pins.items(): + dev.append( + ArestSwitchPin( + resource, + config.get(CONF_NAME, response.json()[CONF_NAME]), + pin.get(CONF_NAME), + pinnum, + pin.get(CONF_INVERT), + ) + ) + + functions = config.get(CONF_FUNCTIONS) + for funcname, func in functions.items(): + dev.append( + ArestSwitchFunction( + resource, + config.get(CONF_NAME, response.json()[CONF_NAME]), + func.get(CONF_NAME), + funcname, + ) + ) + + add_entities(dev) + + +class ArestSwitchBase(SwitchDevice): + """Representation of an aREST switch.""" + + def __init__(self, resource, location, name): + """Initialize the switch.""" + self._resource = resource + self._name = "{} {}".format(location.title(), name.title()) + self._state = None + self._available = True + + @property + def name(self): + """Return the name of the switch.""" + return self._name + + @property + def is_on(self): + """Return true if device is on.""" + return self._state + + @property + def available(self): + """Could the device be accessed during the last update call.""" + return self._available + + +class ArestSwitchFunction(ArestSwitchBase): + """Representation of an aREST switch.""" + + def __init__(self, resource, location, name, func): + """Initialize the switch.""" + super().__init__(resource, location, name) + self._func = func + + request = requests.get(f"{self._resource}/{self._func}", timeout=10) + + if request.status_code != 200: + _LOGGER.error("Can't find function") + return + + try: + request.json()["return_value"] + except KeyError: + _LOGGER.error("No return_value received") + except ValueError: + _LOGGER.error("Response invalid") + + def turn_on(self, **kwargs): + """Turn the device on.""" + request = requests.get( + f"{self._resource}/{self._func}", timeout=10, params={"params": "1"} + ) + + if request.status_code == 200: + self._state = True + else: + _LOGGER.error("Can't turn on function %s at %s", self._func, self._resource) + + def turn_off(self, **kwargs): + """Turn the device off.""" + request = requests.get( + f"{self._resource}/{self._func}", timeout=10, params={"params": "0"} + ) + + if request.status_code == 200: + self._state = False + else: + _LOGGER.error( + "Can't turn off function %s at %s", self._func, self._resource + ) + + def update(self): + """Get the latest data from aREST API and update the state.""" + try: + request = requests.get(f"{self._resource}/{self._func}", timeout=10) + self._state = request.json()["return_value"] != 0 + self._available = True + except requests.exceptions.ConnectionError: + _LOGGER.warning("No route to device %s", self._resource) + self._available = False + + +class ArestSwitchPin(ArestSwitchBase): + """Representation of an aREST switch. Based on digital I/O.""" + + def __init__(self, resource, location, name, pin, invert): + """Initialize the switch.""" + super().__init__(resource, location, name) + self._pin = pin + self.invert = invert + + request = requests.get(f"{self._resource}/mode/{self._pin}/o", timeout=10) + if request.status_code != 200: + _LOGGER.error("Can't set mode") + self._available = False + + def turn_on(self, **kwargs): + """Turn the device on.""" + turn_on_payload = int(not self.invert) + request = requests.get( + f"{self._resource}/digital/{self._pin}/{turn_on_payload}", timeout=10 + ) + if request.status_code == 200: + self._state = True + else: + _LOGGER.error("Can't turn on pin %s at %s", self._pin, self._resource) + + def turn_off(self, **kwargs): + """Turn the device off.""" + turn_off_payload = int(self.invert) + request = requests.get( + f"{self._resource}/digital/{self._pin}/{turn_off_payload}", timeout=10 + ) + if request.status_code == 200: + self._state = False + else: + _LOGGER.error("Can't turn off pin %s at %s", self._pin, self._resource) + + def update(self): + """Get the latest data from aREST API and update the state.""" + try: + request = requests.get(f"{self._resource}/digital/{self._pin}", timeout=10) + status_value = int(self.invert) + self._state = request.json()["return_value"] != status_value + self._available = True + except requests.exceptions.ConnectionError: + _LOGGER.warning("No route to device %s", self._resource) + self._available = False diff --git a/homeassistant/components/arlo.py b/homeassistant/components/arlo.py deleted file mode 100644 index 015e1e0d1..000000000 --- a/homeassistant/components/arlo.py +++ /dev/null @@ -1,94 +0,0 @@ -""" -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/ -""" -import logging -from datetime import timedelta - -import voluptuous as vol -from requests.exceptions import HTTPError, ConnectTimeout - -from homeassistant.helpers import config_validation as cv -from homeassistant.const import ( - CONF_USERNAME, CONF_PASSWORD, CONF_SCAN_INTERVAL) -from homeassistant.helpers.event import track_time_interval -from homeassistant.helpers.dispatcher import dispatcher_send - -REQUIREMENTS = ['pyarlo==0.2.0'] - -_LOGGER = logging.getLogger(__name__) - -CONF_ATTRIBUTION = "Data provided by arlo.netgear.com" - -DATA_ARLO = 'data_arlo' -DEFAULT_BRAND = 'Netgear Arlo' -DOMAIN = 'arlo' - -NOTIFICATION_ID = 'arlo_notification' -NOTIFICATION_TITLE = 'Arlo Component Setup' - -SCAN_INTERVAL = timedelta(seconds=60) - -SIGNAL_UPDATE_ARLO = "arlo_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 an Arlo component.""" - conf = config[DOMAIN] - username = conf.get(CONF_USERNAME) - password = conf.get(CONF_PASSWORD) - scan_interval = conf.get(CONF_SCAN_INTERVAL) - - try: - from pyarlo import PyArlo - - arlo = PyArlo(username, password, preload=False) - if not arlo.is_connected: - return False - - # assign refresh period to base station thread - arlo_base_station = next(( - station for station in arlo.base_stations), None) - - if arlo_base_station is not None: - arlo_base_station.refresh_rate = scan_interval.total_seconds() - elif not arlo.cameras: - _LOGGER.error("No Arlo camera or base station available.") - return False - - hass.data[DATA_ARLO] = arlo - - except (ConnectTimeout, HTTPError) as 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.' - ''.format(ex), - title=NOTIFICATION_TITLE, - notification_id=NOTIFICATION_ID) - return False - - def hub_refresh(event_time): - """Call ArloHub to refresh information.""" - _LOGGER.info("Updating Arlo Hub component") - hass.data[DATA_ARLO].update(update_cameras=True, - update_base_station=True) - dispatcher_send(hass, SIGNAL_UPDATE_ARLO) - - # register service - hass.services.register(DOMAIN, 'update', hub_refresh) - - # register scan interval for ArloHub - track_time_interval(hass, hub_refresh, scan_interval) - return True diff --git a/homeassistant/components/arlo/__init__.py b/homeassistant/components/arlo/__init__.py new file mode 100644 index 000000000..df24bdd1a --- /dev/null +++ b/homeassistant/components/arlo/__init__.py @@ -0,0 +1,89 @@ +"""Support for Netgear Arlo IP cameras.""" +from datetime import timedelta +import logging + +from pyarlo import PyArlo +from requests.exceptions import ConnectTimeout, HTTPError +import voluptuous as vol + +from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.dispatcher import dispatcher_send +from homeassistant.helpers.event import track_time_interval + +_LOGGER = logging.getLogger(__name__) + +ATTRIBUTION = "Data provided by arlo.netgear.com" + +DATA_ARLO = "data_arlo" +DEFAULT_BRAND = "Netgear Arlo" +DOMAIN = "arlo" + +NOTIFICATION_ID = "arlo_notification" +NOTIFICATION_TITLE = "Arlo Component Setup" + +SCAN_INTERVAL = timedelta(seconds=60) + +SIGNAL_UPDATE_ARLO = "arlo_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 an Arlo component.""" + conf = config[DOMAIN] + username = conf.get(CONF_USERNAME) + password = conf.get(CONF_PASSWORD) + scan_interval = conf.get(CONF_SCAN_INTERVAL) + + try: + + arlo = PyArlo(username, password, preload=False) + if not arlo.is_connected: + return False + + # assign refresh period to base station thread + arlo_base_station = next((station for station in arlo.base_stations), None) + + if arlo_base_station is not None: + arlo_base_station.refresh_rate = scan_interval.total_seconds() + elif not arlo.cameras: + _LOGGER.error("No Arlo camera or base station available.") + return False + + hass.data[DATA_ARLO] = arlo + + except (ConnectTimeout, HTTPError) as 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." + "".format(ex), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID, + ) + return False + + def hub_refresh(event_time): + """Call ArloHub to refresh information.""" + _LOGGER.debug("Updating Arlo Hub component") + hass.data[DATA_ARLO].update(update_cameras=True, update_base_station=True) + dispatcher_send(hass, SIGNAL_UPDATE_ARLO) + + # register service + hass.services.register(DOMAIN, "update", hub_refresh) + + # register scan interval for ArloHub + track_time_interval(hass, hub_refresh, scan_interval) + return True diff --git a/homeassistant/components/arlo/alarm_control_panel.py b/homeassistant/components/arlo/alarm_control_panel.py new file mode 100644 index 000000000..838f319ab --- /dev/null +++ b/homeassistant/components/arlo/alarm_control_panel.py @@ -0,0 +1,154 @@ +"""Support for Arlo Alarm Control Panels.""" +import logging + +import voluptuous as vol + +from homeassistant.components.alarm_control_panel import ( + PLATFORM_SCHEMA, + AlarmControlPanel, +) +from homeassistant.components.alarm_control_panel.const import ( + SUPPORT_ALARM_ARM_AWAY, + SUPPORT_ALARM_ARM_HOME, + SUPPORT_ALARM_ARM_NIGHT, +) +from homeassistant.const import ( + ATTR_ATTRIBUTION, + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_DISARMED, +) +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from . import ATTRIBUTION, DATA_ARLO, SIGNAL_UPDATE_ARLO + +_LOGGER = logging.getLogger(__name__) + +ARMED = "armed" + +CONF_HOME_MODE_NAME = "home_mode_name" +CONF_AWAY_MODE_NAME = "away_mode_name" +CONF_NIGHT_MODE_NAME = "night_mode_name" + +DISARMED = "disarmed" + +ICON = "mdi:security" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_HOME_MODE_NAME, default=ARMED): cv.string, + vol.Optional(CONF_AWAY_MODE_NAME, default=ARMED): cv.string, + vol.Optional(CONF_NIGHT_MODE_NAME, default=ARMED): cv.string, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Arlo Alarm Control Panels.""" + arlo = hass.data[DATA_ARLO] + + if not arlo.base_stations: + return + + home_mode_name = config.get(CONF_HOME_MODE_NAME) + away_mode_name = config.get(CONF_AWAY_MODE_NAME) + night_mode_name = config.get(CONF_NIGHT_MODE_NAME) + base_stations = [] + for base_station in arlo.base_stations: + base_stations.append( + ArloBaseStation( + base_station, home_mode_name, away_mode_name, night_mode_name + ) + ) + add_entities(base_stations, True) + + +class ArloBaseStation(AlarmControlPanel): + """Representation of an Arlo Alarm Control Panel.""" + + def __init__(self, data, home_mode_name, away_mode_name, night_mode_name): + """Initialize the alarm control panel.""" + self._base_station = data + self._home_mode_name = home_mode_name + self._away_mode_name = away_mode_name + self._night_mode_name = night_mode_name + self._state = None + + @property + def icon(self): + """Return icon.""" + return ICON + + async def async_added_to_hass(self): + """Register callbacks.""" + async_dispatcher_connect(self.hass, SIGNAL_UPDATE_ARLO, self._update_callback) + + @callback + def _update_callback(self): + """Call update method.""" + self.async_schedule_update_ha_state(True) + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @property + def supported_features(self) -> int: + """Return the list of supported features.""" + return SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY | SUPPORT_ALARM_ARM_NIGHT + + def update(self): + """Update the state of the device.""" + _LOGGER.debug("Updating Arlo Alarm Control Panel %s", self.name) + mode = self._base_station.mode + if mode: + self._state = self._get_state_from_mode(mode) + else: + self._state = None + + async def async_alarm_disarm(self, code=None): + """Send disarm command.""" + self._base_station.mode = DISARMED + + async def async_alarm_arm_away(self, code=None): + """Send arm away command. Uses custom mode.""" + self._base_station.mode = self._away_mode_name + + async def async_alarm_arm_home(self, code=None): + """Send arm home command. Uses custom mode.""" + self._base_station.mode = self._home_mode_name + + async def async_alarm_arm_night(self, code=None): + """Send arm night command. Uses custom mode.""" + self._base_station.mode = self._night_mode_name + + @property + def name(self): + """Return the name of the base station.""" + return self._base_station.name + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + ATTR_ATTRIBUTION: ATTRIBUTION, + "device_id": self._base_station.device_id, + } + + def _get_state_from_mode(self, mode): + """Convert Arlo mode to Home Assistant state.""" + if mode == ARMED: + return STATE_ALARM_ARMED_AWAY + if mode == DISARMED: + return STATE_ALARM_DISARMED + if mode == self._home_mode_name: + return STATE_ALARM_ARMED_HOME + if mode == self._away_mode_name: + return STATE_ALARM_ARMED_AWAY + if mode == self._night_mode_name: + return STATE_ALARM_ARMED_NIGHT + return mode diff --git a/homeassistant/components/arlo/camera.py b/homeassistant/components/arlo/camera.py new file mode 100644 index 000000000..958c38376 --- /dev/null +++ b/homeassistant/components/arlo/camera.py @@ -0,0 +1,166 @@ +"""Support for Netgear Arlo IP cameras.""" +import logging + +from haffmpeg.camera import CameraMjpeg +import voluptuous as vol + +from homeassistant.components.camera import PLATFORM_SCHEMA, Camera +from homeassistant.components.ffmpeg import DATA_FFMPEG +from homeassistant.const import ATTR_BATTERY_LEVEL +from homeassistant.core import callback +from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from . import DATA_ARLO, DEFAULT_BRAND, SIGNAL_UPDATE_ARLO + +_LOGGER = logging.getLogger(__name__) + +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" +DEFAULT_ARGUMENTS = "-pred 1" + +POWERSAVE_MODE_MAPPING = {1: "best_battery_life", 2: "optimized", 3: "best_video"} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + {vol.Optional(CONF_FFMPEG_ARGUMENTS, default=DEFAULT_ARGUMENTS): cv.string} +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up an Arlo IP Camera.""" + arlo = hass.data[DATA_ARLO] + + cameras = [] + for camera in arlo.cameras: + cameras.append(ArloCam(hass, camera, config)) + + add_entities(cameras) + + +class ArloCam(Camera): + """An implementation of a Netgear Arlo IP camera.""" + + def __init__(self, hass, camera, device_info): + """Initialize an Arlo camera.""" + super().__init__() + self._camera = camera + self._name = self._camera.name + self._motion_status = False + self._ffmpeg = hass.data[DATA_FFMPEG] + self._ffmpeg_arguments = device_info.get(CONF_FFMPEG_ARGUMENTS) + self._last_refresh = None + self.attrs = {} + + def camera_image(self): + """Return a still image response from the camera.""" + return self._camera.last_image_from_cache + + async def async_added_to_hass(self): + """Register callbacks.""" + async_dispatcher_connect(self.hass, SIGNAL_UPDATE_ARLO, self._update_callback) + + @callback + def _update_callback(self): + """Call update method.""" + self.async_schedule_update_ha_state() + + async def handle_async_mjpeg_stream(self, request): + """Generate an HTTP MJPEG stream from the camera.""" + + video = self._camera.last_video + if not video: + error_msg = "Video not found for {0}. Is it older than {1} days?".format( + self.name, self._camera.min_days_vdo_cache + ) + _LOGGER.error(error_msg) + return + + stream = CameraMjpeg(self._ffmpeg.binary, loop=self.hass.loop) + await stream.open_camera(video.video_url, extra_cmd=self._ffmpeg_arguments) + + try: + stream_reader = await stream.get_reader() + return await async_aiohttp_proxy_stream( + self.hass, + request, + stream_reader, + self._ffmpeg.ffmpeg_stream_content_type, + ) + finally: + await stream.close() + + @property + 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): + """Return the camera model.""" + return self._camera.model_id + + @property + def brand(self): + """Return the camera brand.""" + return DEFAULT_BRAND + + @property + def motion_detection_enabled(self): + """Return the camera motion detection status.""" + return self._motion_status + + def set_base_station_mode(self, mode): + """Set the mode in the base station.""" + # Get the list of base stations identified by library + base_stations = self.hass.data[DATA_ARLO].base_stations + + # 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 + if base_stations: + primary_base_station = base_stations[0] + primary_base_station.mode = mode + + def enable_motion_detection(self): + """Enable the Motion detection in base station (Arm).""" + self._motion_status = True + self.set_base_station_mode(ARLO_MODE_ARMED) + + 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) diff --git a/homeassistant/components/arlo/manifest.json b/homeassistant/components/arlo/manifest.json new file mode 100644 index 000000000..8779e051d --- /dev/null +++ b/homeassistant/components/arlo/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "arlo", + "name": "Arlo", + "documentation": "https://www.home-assistant.io/integrations/arlo", + "requirements": [ + "pyarlo==0.2.3" + ], + "dependencies": [ + "ffmpeg" + ], + "codeowners": [] +} diff --git a/homeassistant/components/arlo/sensor.py b/homeassistant/components/arlo/sensor.py new file mode 100644 index 000000000..aadd5a48d --- /dev/null +++ b/homeassistant/components/arlo/sensor.py @@ -0,0 +1,191 @@ +"""Sensor support for Netgear Arlo IP cameras.""" +import logging + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + ATTR_ATTRIBUTION, + CONF_MONITORED_CONDITIONS, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_TEMPERATURE, + TEMP_CELSIUS, +) +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.icon import icon_for_battery_level + +from . import ATTRIBUTION, DATA_ARLO, DEFAULT_BRAND, SIGNAL_UPDATE_ARLO + +_LOGGER = logging.getLogger(__name__) + +# sensor_type [ description, unit, icon ] +SENSOR_TYPES = { + "last_capture": ["Last", None, "run-fast"], + "total_cameras": ["Arlo Cameras", None, "video"], + "captured_today": ["Captured Today", None, "file-video"], + "battery_level": ["Battery Level", "%", "battery-50"], + "signal_strength": ["Signal Strength", None, "signal"], + "temperature": ["Temperature", TEMP_CELSIUS, "thermometer"], + "humidity": ["Humidity", "%", "water-percent"], + "air_quality": ["Air Quality", "ppm", "biohazard"], +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): vol.All( + cv.ensure_list, [vol.In(SENSOR_TYPES)] + ) + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up an Arlo IP sensor.""" + arlo = hass.data.get(DATA_ARLO) + if not arlo: + return + + sensors = [] + for sensor_type in config.get(CONF_MONITORED_CONDITIONS): + if sensor_type == "total_cameras": + sensors.append(ArloSensor(SENSOR_TYPES[sensor_type][0], arlo, sensor_type)) + else: + for camera in arlo.cameras: + if sensor_type in ("temperature", "humidity", "air_quality"): + continue + + name = "{0} {1}".format(SENSOR_TYPES[sensor_type][0], camera.name) + sensors.append(ArloSensor(name, camera, sensor_type)) + + for base_station in arlo.base_stations: + if ( + sensor_type in ("temperature", "humidity", "air_quality") + and base_station.model_id == "ABC1000" + ): + name = "{0} {1}".format( + SENSOR_TYPES[sensor_type][0], base_station.name + ) + sensors.append(ArloSensor(name, base_station, sensor_type)) + + add_entities(sensors, True) + + +class ArloSensor(Entity): + """An implementation of a Netgear Arlo IP sensor.""" + + def __init__(self, name, device, sensor_type): + """Initialize an Arlo sensor.""" + _LOGGER.debug("ArloSensor created for %s", name) + self._name = name + self._data = device + self._sensor_type = sensor_type + self._state = None + self._icon = "mdi:{}".format(SENSOR_TYPES.get(self._sensor_type)[2]) + + @property + def name(self): + """Return the name of this camera.""" + return self._name + + async def async_added_to_hass(self): + """Register callbacks.""" + async_dispatcher_connect(self.hass, SIGNAL_UPDATE_ARLO, self._update_callback) + + @callback + def _update_callback(self): + """Call update method.""" + self.async_schedule_update_ha_state(True) + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + if self._sensor_type == "battery_level" and self._state is not None: + return icon_for_battery_level( + battery_level=int(self._state), charging=False + ) + return self._icon + + @property + def unit_of_measurement(self): + """Return the units of measurement.""" + return SENSOR_TYPES.get(self._sensor_type)[1] + + @property + def device_class(self): + """Return the device class of the sensor.""" + if self._sensor_type == "temperature": + return DEVICE_CLASS_TEMPERATURE + if self._sensor_type == "humidity": + return DEVICE_CLASS_HUMIDITY + return None + + def update(self): + """Get the latest data and updates the state.""" + _LOGGER.debug("Updating Arlo sensor %s", self.name) + if self._sensor_type == "total_cameras": + self._state = len(self._data.cameras) + + elif self._sensor_type == "captured_today": + self._state = len(self._data.captured_today) + + elif self._sensor_type == "last_capture": + try: + video = self._data.last_video + self._state = video.created_at_pretty("%m-%d-%Y %H:%M:%S") + except (AttributeError, IndexError): + error_msg = "Video not found for {0}. Older than {1} days?".format( + self.name, self._data.min_days_vdo_cache + ) + _LOGGER.debug(error_msg) + self._state = None + + elif self._sensor_type == "battery_level": + try: + self._state = self._data.battery_level + except TypeError: + self._state = None + + elif self._sensor_type == "signal_strength": + try: + self._state = self._data.signal_strength + except TypeError: + self._state = None + + elif self._sensor_type == "temperature": + try: + self._state = self._data.ambient_temperature + except TypeError: + self._state = None + + elif self._sensor_type == "humidity": + try: + self._state = self._data.ambient_humidity + except TypeError: + self._state = None + + elif self._sensor_type == "air_quality": + try: + self._state = self._data.ambient_air_quality + except TypeError: + self._state = None + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + attrs = {} + + attrs[ATTR_ATTRIBUTION] = ATTRIBUTION + attrs["brand"] = DEFAULT_BRAND + + if self._sensor_type != "total_cameras": + attrs["model"] = self._data.model_id + + return attrs diff --git a/homeassistant/components/arlo/services.yaml b/homeassistant/components/arlo/services.yaml new file mode 100644 index 000000000..773bee443 --- /dev/null +++ b/homeassistant/components/arlo/services.yaml @@ -0,0 +1,4 @@ +# Describes the format for available arlo services + +update: + description: Update the state for all cameras and the base station. \ No newline at end of file diff --git a/homeassistant/components/aruba/__init__.py b/homeassistant/components/aruba/__init__.py new file mode 100644 index 000000000..cd52f7310 --- /dev/null +++ b/homeassistant/components/aruba/__init__.py @@ -0,0 +1 @@ +"""The aruba component.""" diff --git a/homeassistant/components/aruba/device_tracker.py b/homeassistant/components/aruba/device_tracker.py new file mode 100644 index 000000000..485c731ff --- /dev/null +++ b/homeassistant/components/aruba/device_tracker.py @@ -0,0 +1,135 @@ +"""Support for Aruba Access Points.""" +import logging +import re + +import pexpect +import voluptuous as vol + +from homeassistant.components.device_tracker import ( + DOMAIN, + PLATFORM_SCHEMA, + DeviceScanner, +) +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +_DEVICES_REGEX = re.compile( + 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+" +) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, + } +) + + +def get_scanner(hass, config): + """Validate the configuration and return a Aruba scanner.""" + scanner = ArubaDeviceScanner(config[DOMAIN]) + + return scanner if scanner.success_init else None + + +class ArubaDeviceScanner(DeviceScanner): + """This class queries a Aruba Access Point for connected devices.""" + + def __init__(self, config): + """Initialize the scanner.""" + self.host = config[CONF_HOST] + self.username = config[CONF_USERNAME] + self.password = config[CONF_PASSWORD] + + self.last_results = {} + + # Test the router is accessible. + data = self.get_aruba_data() + self.success_init = data is not None + + 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] + + 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: + return None + for client in self.last_results: + if client["mac"] == device: + return client["name"] + return None + + def _update_info(self): + """Ensure the information from the Aruba Access Point is up to date. + + Return boolean if scanning successful. + """ + if not self.success_init: + return False + + data = self.get_aruba_data() + if not data: + return False + + self.last_results = data.values() + return True + + def get_aruba_data(self): + """Retrieve data from Aruba Access Point and return parsed result.""" + + connect = "ssh {}@{}" + ssh = pexpect.spawn(connect.format(self.username, self.host)) + query = ssh.expect( + [ + "password:", + pexpect.TIMEOUT, + pexpect.EOF, + "continue connecting (yes/no)?", + "Host key verification failed.", + "Connection refused", + "Connection timed out", + ], + timeout=120, + ) + if query == 1: + _LOGGER.error("Timeout") + return + if query == 2: + _LOGGER.error("Unexpected response from router") + return + if query == 3: + ssh.sendline("yes") + ssh.expect("password:") + elif query == 4: + _LOGGER.error("Host key changed") + return + elif query == 5: + _LOGGER.error("Connection refused by server") + return + elif query == 6: + _LOGGER.error("Connection timed out") + return + ssh.sendline(self.password) + ssh.expect("#") + ssh.sendline("show clients") + ssh.expect("#") + devices_result = ssh.before.split(b"\r\n") + ssh.sendline("exit") + + devices = {} + for device in devices_result: + match = _DEVICES_REGEX.search(device.decode("utf-8")) + if match: + devices[match.group("ip")] = { + "ip": match.group("ip"), + "mac": match.group("mac").upper(), + "name": match.group("name"), + } + return devices diff --git a/homeassistant/components/aruba/manifest.json b/homeassistant/components/aruba/manifest.json new file mode 100644 index 000000000..ccc4f1190 --- /dev/null +++ b/homeassistant/components/aruba/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "aruba", + "name": "Aruba", + "documentation": "https://www.home-assistant.io/integrations/aruba", + "requirements": [ + "pexpect==4.6.0" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/arwn/__init__.py b/homeassistant/components/arwn/__init__.py new file mode 100644 index 000000000..726f3dba5 --- /dev/null +++ b/homeassistant/components/arwn/__init__.py @@ -0,0 +1 @@ +"""The arwn component.""" diff --git a/homeassistant/components/arwn/manifest.json b/homeassistant/components/arwn/manifest.json new file mode 100644 index 000000000..d84202cba --- /dev/null +++ b/homeassistant/components/arwn/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "arwn", + "name": "Arwn", + "documentation": "https://www.home-assistant.io/integrations/arwn", + "requirements": [], + "dependencies": [ + "mqtt" + ], + "codeowners": [] +} diff --git a/homeassistant/components/arwn/sensor.py b/homeassistant/components/arwn/sensor.py new file mode 100644 index 000000000..685e5d90f --- /dev/null +++ b/homeassistant/components/arwn/sensor.py @@ -0,0 +1,152 @@ +"""Support for collecting data from the ARWN project.""" +import json +import logging + +from homeassistant.components import mqtt +from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.core import callback +from homeassistant.helpers.entity import Entity +from homeassistant.util import slugify + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "arwn" + +DATA_ARWN = "arwn" +TOPIC = "arwn/#" + + +def discover_sensors(topic, payload): + """Given a topic, dynamically create the right sensor type. + + Async friendly. + """ + parts = topic.split("/") + unit = payload.get("units", "") + domain = parts[1] + if domain == "temperature": + name = parts[2] + if unit == "F": + unit = TEMP_FAHRENHEIT + else: + unit = TEMP_CELSIUS + return ArwnSensor(name, "temp", unit) + if domain == "moisture": + name = parts[2] + " Moisture" + return ArwnSensor(name, "moisture", unit, "mdi:water-percent") + if domain == "rain": + if len(parts) >= 3 and parts[2] == "today": + return ArwnSensor( + "Rain Since Midnight", "since_midnight", "in", "mdi:water" + ) + if domain == "barometer": + return ArwnSensor("Barometer", "pressure", unit, "mdi:thermometer-lines") + if domain == "wind": + return ( + ArwnSensor("Wind Speed", "speed", unit, "mdi:speedometer"), + ArwnSensor("Wind Gust", "gust", unit, "mdi:speedometer"), + ArwnSensor("Wind Direction", "direction", "°", "mdi:compass"), + ) + + +def _slug(name): + return "sensor.arwn_{}".format(slugify(name)) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the ARWN platform.""" + + @callback + def async_sensor_event_received(msg): + """Process events as sensors. + + When a new event on our topic (arwn/#) is received we map it + into a known kind of sensor based on topic name. If we've + never seen this before, we keep this sensor around in a global + cache. If we have seen it before, we update the values of the + existing sensor. Either way, we push an ha state update at the + end for the new event we've seen. + + This lets us dynamically incorporate sensors without any + configuration on our side. + """ + event = json.loads(msg.payload) + sensors = discover_sensors(msg.topic, event) + if not sensors: + return + + store = hass.data.get(DATA_ARWN) + if store is None: + store = hass.data[DATA_ARWN] = {} + + if isinstance(sensors, ArwnSensor): + sensors = (sensors,) + + if "timestamp" in event: + del event["timestamp"] + + for sensor in sensors: + if sensor.name not in store: + sensor.hass = hass + sensor.set_event(event) + store[sensor.name] = sensor + _LOGGER.debug( + "Registering new sensor %(name)s => %(event)s", + dict(name=sensor.name, event=event), + ) + async_add_entities((sensor,), True) + else: + store[sensor.name].set_event(event) + + await mqtt.async_subscribe(hass, TOPIC, async_sensor_event_received, 0) + return True + + +class ArwnSensor(Entity): + """Representation of an ARWN sensor.""" + + def __init__(self, name, state_key, units, icon=None): + """Initialize the sensor.""" + self.hass = None + self.entity_id = _slug(name) + self._name = name + self._state_key = state_key + self.event = {} + self._unit_of_measurement = units + self._icon = icon + + def set_event(self, event): + """Update the sensor with the most recent event.""" + self.event = {} + self.event.update(event) + self.async_schedule_update_ha_state() + + @property + def state(self): + """Return the state of the device.""" + return self.event.get(self._state_key, None) + + @property + def name(self): + """Get the name of the sensor.""" + return self._name + + @property + def state_attributes(self): + """Return all the state attributes.""" + return self.event + + @property + def unit_of_measurement(self): + """Return the unit of measurement the state is expressed in.""" + return self._unit_of_measurement + + @property + def should_poll(self): + """Return the polling state.""" + return False + + @property + def icon(self): + """Return the icon of device based on its type.""" + return self._icon diff --git a/homeassistant/components/asterisk_cdr/__init__.py b/homeassistant/components/asterisk_cdr/__init__.py new file mode 100644 index 000000000..d681a392c --- /dev/null +++ b/homeassistant/components/asterisk_cdr/__init__.py @@ -0,0 +1 @@ +"""The asterisk_cdr component.""" diff --git a/homeassistant/components/asterisk_cdr/mailbox.py b/homeassistant/components/asterisk_cdr/mailbox.py new file mode 100644 index 000000000..0bae6ebf3 --- /dev/null +++ b/homeassistant/components/asterisk_cdr/mailbox.py @@ -0,0 +1,62 @@ +"""Support for the Asterisk CDR interface.""" +import datetime +import hashlib +import logging + +from homeassistant.components.asterisk_mbox import ( + DOMAIN as ASTERISK_DOMAIN, + SIGNAL_CDR_UPDATE, +) +from homeassistant.components.mailbox import Mailbox +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +_LOGGER = logging.getLogger(__name__) + +MAILBOX_NAME = "asterisk_cdr" + + +async def async_get_handler(hass, config, discovery_info=None): + """Set up the Asterix CDR platform.""" + return AsteriskCDR(hass, MAILBOX_NAME) + + +class AsteriskCDR(Mailbox): + """Asterisk VM Call Data Record mailbox.""" + + def __init__(self, hass, name): + """Initialize Asterisk CDR.""" + super().__init__(hass, name) + self.cdr = [] + async_dispatcher_connect(self.hass, SIGNAL_CDR_UPDATE, self._update_callback) + + @callback + def _update_callback(self, msg): + """Update the message count in HA, if needed.""" + self._build_message() + self.async_update() + + def _build_message(self): + """Build message structure.""" + cdr = [] + for entry in self.hass.data[ASTERISK_DOMAIN].cdr: + timestamp = datetime.datetime.strptime( + entry["time"], "%Y-%m-%d %H:%M:%S" + ).timestamp() + info = { + "origtime": timestamp, + "callerid": entry["callerid"], + "duration": entry["duration"], + } + sha = hashlib.sha256(str(entry).encode("utf-8")).hexdigest() + msg = "Destination: {}\nApplication: {}\n Context: {}".format( + entry["dest"], entry["application"], entry["context"] + ) + cdr.append({"info": info, "sha": sha, "text": msg}) + self.cdr = cdr + + async def async_get_messages(self): + """Return a list of the current messages.""" + if not self.cdr: + self._build_message() + return self.cdr diff --git a/homeassistant/components/asterisk_cdr/manifest.json b/homeassistant/components/asterisk_cdr/manifest.json new file mode 100644 index 000000000..56018ba77 --- /dev/null +++ b/homeassistant/components/asterisk_cdr/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "asterisk_cdr", + "name": "Asterisk cdr", + "documentation": "https://www.home-assistant.io/integrations/asterisk_cdr", + "requirements": [], + "dependencies": [ + "asterisk_mbox" + ], + "codeowners": [] +} diff --git a/homeassistant/components/asterisk_mbox.py b/homeassistant/components/asterisk_mbox.py deleted file mode 100644 index 0d6d811db..000000000 --- a/homeassistant/components/asterisk_mbox.py +++ /dev/null @@ -1,81 +0,0 @@ -""" -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 - -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT -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, async_dispatcher_send) - -REQUIREMENTS = ['asterisk_mbox==0.5.0'] - -_LOGGER = logging.getLogger(__name__) - -DOMAIN = 'asterisk_mbox' - -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_PASSWORD): cv.string, - vol.Required(CONF_PORT): int, - }), -}, extra=vol.ALLOW_EXTRA) - - -def setup(hass, config): - """Set up for the Asterisk Voicemail box.""" - conf = config.get(DOMAIN) - - host = conf.get(CONF_HOST) - port = conf.get(CONF_PORT) - password = conf.get(CONF_PASSWORD) - - hass.data[DOMAIN] = AsteriskData(hass, host, port, password) - - discovery.load_platform(hass, 'mailbox', DOMAIN, {}, config) - - return True - - -class AsteriskData: - """Store Asterisk mailbox data.""" - - def __init__(self, hass, host, port, password): - """Init the Asterisk data object.""" - from asterisk_mbox import Client as asteriskClient - - self.hass = hass - self.client = asteriskClient(host, port, password, self.handle_data) - self.messages = [] - - async_dispatcher_connect( - self.hass, SIGNAL_MESSAGE_REQUEST, self._request_messages) - - @callback - def handle_data(self, command, msg): - """Handle changes to the mailbox.""" - from asterisk_mbox.commands import CMD_MESSAGE_LIST - - if command == CMD_MESSAGE_LIST: - _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.debug("Requesting message list") - self.client.messages() diff --git a/homeassistant/components/asterisk_mbox/__init__.py b/homeassistant/components/asterisk_mbox/__init__.py new file mode 100644 index 000000000..1ecba9f4c --- /dev/null +++ b/homeassistant/components/asterisk_mbox/__init__.py @@ -0,0 +1,123 @@ +"""Support for Asterisk Voicemail interface.""" +import logging + +from asterisk_mbox import Client as asteriskClient +from asterisk_mbox.commands import ( + CMD_MESSAGE_CDR, + CMD_MESSAGE_CDR_AVAILABLE, + CMD_MESSAGE_LIST, +) +import voluptuous as vol + +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT +from homeassistant.core import callback +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send, dispatcher_connect + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "asterisk_mbox" + +SIGNAL_DISCOVER_PLATFORM = "asterisk_mbox.discover_platform" +SIGNAL_MESSAGE_REQUEST = "asterisk_mbox.message_request" +SIGNAL_MESSAGE_UPDATE = "asterisk_mbox.message_updated" +SIGNAL_CDR_UPDATE = "asterisk_mbox.message_updated" +SIGNAL_CDR_REQUEST = "asterisk_mbox.message_request" + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_PORT): cv.port, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +def setup(hass, config): + """Set up for the Asterisk Voicemail box.""" + conf = config.get(DOMAIN) + + host = conf.get(CONF_HOST) + port = conf.get(CONF_PORT) + password = conf.get(CONF_PASSWORD) + + hass.data[DOMAIN] = AsteriskData(hass, host, port, password, config) + + return True + + +class AsteriskData: + """Store Asterisk mailbox data.""" + + def __init__(self, hass, host, port, password, config): + """Init the Asterisk data object.""" + + self.hass = hass + self.config = config + self.messages = None + self.cdr = None + + dispatcher_connect(self.hass, SIGNAL_MESSAGE_REQUEST, self._request_messages) + dispatcher_connect(self.hass, SIGNAL_CDR_REQUEST, self._request_cdr) + dispatcher_connect(self.hass, SIGNAL_DISCOVER_PLATFORM, self._discover_platform) + # Only connect after signal connection to ensure we don't miss any + self.client = asteriskClient(host, port, password, self.handle_data) + + @callback + def _discover_platform(self, component): + _LOGGER.debug("Adding mailbox %s", component) + self.hass.async_create_task( + discovery.async_load_platform( + self.hass, "mailbox", component, {}, self.config + ) + ) + + @callback + def handle_data(self, command, msg): + """Handle changes to the mailbox.""" + + if command == CMD_MESSAGE_LIST: + _LOGGER.debug("AsteriskVM sent updated message list: Len %d", len(msg)) + old_messages = self.messages + self.messages = sorted( + msg, key=lambda item: item["info"]["origtime"], reverse=True + ) + if not isinstance(old_messages, list): + async_dispatcher_send(self.hass, SIGNAL_DISCOVER_PLATFORM, DOMAIN) + async_dispatcher_send(self.hass, SIGNAL_MESSAGE_UPDATE, self.messages) + elif command == CMD_MESSAGE_CDR: + _LOGGER.debug( + "AsteriskVM sent updated CDR list: Len %d", len(msg.get("entries", [])) + ) + self.cdr = msg["entries"] + async_dispatcher_send(self.hass, SIGNAL_CDR_UPDATE, self.cdr) + elif command == CMD_MESSAGE_CDR_AVAILABLE: + if not isinstance(self.cdr, list): + _LOGGER.debug("AsteriskVM adding CDR platform") + self.cdr = [] + async_dispatcher_send( + self.hass, SIGNAL_DISCOVER_PLATFORM, "asterisk_cdr" + ) + async_dispatcher_send(self.hass, SIGNAL_CDR_REQUEST) + else: + _LOGGER.debug( + "AsteriskVM sent unknown message '%d' len: %d", command, len(msg) + ) + + @callback + def _request_messages(self): + """Handle changes to the mailbox.""" + _LOGGER.debug("Requesting message list") + self.client.messages() + + @callback + def _request_cdr(self): + """Handle changes to the CDR.""" + _LOGGER.debug("Requesting CDR list") + self.client.get_cdr() diff --git a/homeassistant/components/asterisk_mbox/mailbox.py b/homeassistant/components/asterisk_mbox/mailbox.py new file mode 100644 index 000000000..3cd6fe059 --- /dev/null +++ b/homeassistant/components/asterisk_mbox/mailbox.py @@ -0,0 +1,71 @@ +"""Support for the Asterisk Voicemail interface.""" +import logging + +from asterisk_mbox import ServerError + +from homeassistant.components.mailbox import CONTENT_TYPE_MPEG, Mailbox, StreamError +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from . import DOMAIN as ASTERISK_DOMAIN + +_LOGGER = logging.getLogger(__name__) + +SIGNAL_MESSAGE_REQUEST = "asterisk_mbox.message_request" +SIGNAL_MESSAGE_UPDATE = "asterisk_mbox.message_updated" + + +async def async_get_handler(hass, config, discovery_info=None): + """Set up the Asterix VM platform.""" + return AsteriskMailbox(hass, ASTERISK_DOMAIN) + + +class AsteriskMailbox(Mailbox): + """Asterisk VM Sensor.""" + + def __init__(self, hass, name): + """Initialize Asterisk mailbox.""" + super().__init__(hass, name) + async_dispatcher_connect( + self.hass, SIGNAL_MESSAGE_UPDATE, self._update_callback + ) + + @callback + def _update_callback(self, msg): + """Update the message count in HA, if needed.""" + self.async_update() + + @property + def media_type(self): + """Return the supported media type.""" + return CONTENT_TYPE_MPEG + + @property + def can_delete(self): + """Return if messages can be deleted.""" + return True + + @property + def has_media(self): + """Return if messages have attached media files.""" + return True + + async def async_get_media(self, msgid): + """Return the media blob for the msgid.""" + + client = self.hass.data[ASTERISK_DOMAIN].client + try: + return client.mp3(msgid, sync=True) + except ServerError as err: + raise StreamError(err) + + async def async_get_messages(self): + """Return a list of the current messages.""" + return self.hass.data[ASTERISK_DOMAIN].messages + + def async_delete(self, msgid): + """Delete the specified messages.""" + client = self.hass.data[ASTERISK_DOMAIN].client + _LOGGER.info("Deleting: %s", msgid) + client.delete(msgid) + return True diff --git a/homeassistant/components/asterisk_mbox/manifest.json b/homeassistant/components/asterisk_mbox/manifest.json new file mode 100644 index 000000000..cf793328d --- /dev/null +++ b/homeassistant/components/asterisk_mbox/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "asterisk_mbox", + "name": "Asterisk mbox", + "documentation": "https://www.home-assistant.io/integrations/asterisk_mbox", + "requirements": [ + "asterisk_mbox==0.5.0" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/asuswrt/__init__.py b/homeassistant/components/asuswrt/__init__.py new file mode 100644 index 000000000..64d2d7c7a --- /dev/null +++ b/homeassistant/components/asuswrt/__init__.py @@ -0,0 +1,87 @@ +"""Support for ASUSWRT devices.""" +import logging + +from aioasuswrt.asuswrt import AsusWrt +import voluptuous as vol + +from homeassistant.const import ( + CONF_HOST, + CONF_MODE, + CONF_PASSWORD, + CONF_PORT, + CONF_PROTOCOL, + CONF_USERNAME, +) +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.discovery import async_load_platform + +_LOGGER = logging.getLogger(__name__) + +CONF_PUB_KEY = "pub_key" +CONF_REQUIRE_IP = "require_ip" +CONF_SENSORS = "sensors" +CONF_SSH_KEY = "ssh_key" + +DOMAIN = "asuswrt" +DATA_ASUSWRT = DOMAIN +DEFAULT_SSH_PORT = 22 + +SECRET_GROUP = "Password or SSH Key" +SENSOR_TYPES = ["upload_speed", "download_speed", "download", "upload"] + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + 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_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, + vol.Optional(CONF_SENSORS): vol.All( + cv.ensure_list, [vol.In(SENSOR_TYPES)] + ), + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, config): + """Set up the asuswrt component.""" + + conf = config[DOMAIN] + + api = AsusWrt( + conf[CONF_HOST], + conf.get(CONF_PORT), + conf.get(CONF_PROTOCOL) == "telnet", + conf[CONF_USERNAME], + conf.get(CONF_PASSWORD, ""), + conf.get("ssh_key", conf.get("pub_key", "")), + conf.get(CONF_MODE), + conf.get(CONF_REQUIRE_IP), + ) + + await api.connection.async_connect() + if not api.is_connected: + _LOGGER.error("Unable to setup asuswrt component") + return False + + hass.data[DATA_ASUSWRT] = api + + hass.async_create_task( + async_load_platform( + hass, "sensor", DOMAIN, config[DOMAIN].get(CONF_SENSORS), config + ) + ) + hass.async_create_task( + async_load_platform(hass, "device_tracker", DOMAIN, {}, config) + ) + + return True diff --git a/homeassistant/components/asuswrt/device_tracker.py b/homeassistant/components/asuswrt/device_tracker.py new file mode 100644 index 000000000..5e3297da8 --- /dev/null +++ b/homeassistant/components/asuswrt/device_tracker.py @@ -0,0 +1,52 @@ +"""Support for ASUSWRT routers.""" +import logging + +from homeassistant.components.device_tracker import DeviceScanner + +from . import DATA_ASUSWRT + +_LOGGER = logging.getLogger(__name__) + + +async def async_get_scanner(hass, config): + """Validate the configuration and return an ASUS-WRT scanner.""" + scanner = AsusWrtDeviceScanner(hass.data[DATA_ASUSWRT]) + await scanner.async_connect() + return scanner if scanner.success_init else None + + +class AsusWrtDeviceScanner(DeviceScanner): + """This class queries a router running ASUSWRT firmware.""" + + # Eighth attribute needed for mode (AP mode vs router mode) + def __init__(self, api): + """Initialize the scanner.""" + self.last_results = {} + self.success_init = False + self.connection = api + + async def async_connect(self): + """Initialize connection to the router.""" + # Test the router is accessible. + data = await self.connection.async_get_connected_devices() + self.success_init = data is not None + + async def async_scan_devices(self): + """Scan for new devices and return a list with found device IDs.""" + await self.async_update_info() + return list(self.last_results.keys()) + + async def async_get_device_name(self, device): + """Return the name of the given device or None if we don't know.""" + if device not in self.last_results: + return None + return self.last_results[device].name + + async def async_update_info(self): + """Ensure the information from the ASUSWRT router is up to date. + + Return boolean if scanning successful. + """ + _LOGGER.debug("Checking Devices") + + self.last_results = await self.connection.async_get_connected_devices() diff --git a/homeassistant/components/asuswrt/manifest.json b/homeassistant/components/asuswrt/manifest.json new file mode 100644 index 000000000..3d8cebce0 --- /dev/null +++ b/homeassistant/components/asuswrt/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "asuswrt", + "name": "Asuswrt", + "documentation": "https://www.home-assistant.io/integrations/asuswrt", + "requirements": [ + "aioasuswrt==1.1.22" + ], + "dependencies": [], + "codeowners": [ + "@kennedyshead" + ] +} diff --git a/homeassistant/components/asuswrt/sensor.py b/homeassistant/components/asuswrt/sensor.py new file mode 100644 index 000000000..b5ce8539f --- /dev/null +++ b/homeassistant/components/asuswrt/sensor.py @@ -0,0 +1,129 @@ +"""Asuswrt status sensors.""" +import logging + +from homeassistant.helpers.entity import Entity + +from . import DATA_ASUSWRT + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the asuswrt sensors.""" + if discovery_info is None: + return + + api = hass.data[DATA_ASUSWRT] + + devices = [] + + if "download" in discovery_info: + devices.append(AsuswrtTotalRXSensor(api)) + if "upload" in discovery_info: + devices.append(AsuswrtTotalTXSensor(api)) + if "download_speed" in discovery_info: + devices.append(AsuswrtRXSensor(api)) + if "upload_speed" in discovery_info: + devices.append(AsuswrtTXSensor(api)) + + add_entities(devices) + + +class AsuswrtSensor(Entity): + """Representation of a asuswrt sensor.""" + + _name = "generic" + + def __init__(self, api): + """Initialize the sensor.""" + self._api = api + self._state = None + self._rates = None + self._speed = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + async def async_update(self): + """Fetch status from asuswrt.""" + self._rates = await self._api.async_get_bytes_total() + self._speed = await self._api.async_get_current_transfer_rates() + + +class AsuswrtRXSensor(AsuswrtSensor): + """Representation of a asuswrt download speed sensor.""" + + _name = "Asuswrt Download Speed" + _unit = "Mbit/s" + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit + + async def async_update(self): + """Fetch new state data for the sensor.""" + await super().async_update() + if self._speed: + self._state = round(self._speed[0] / 125000, 2) + + +class AsuswrtTXSensor(AsuswrtSensor): + """Representation of a asuswrt upload speed sensor.""" + + _name = "Asuswrt Upload Speed" + _unit = "Mbit/s" + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit + + async def async_update(self): + """Fetch new state data for the sensor.""" + await super().async_update() + if self._speed: + self._state = round(self._speed[1] / 125000, 2) + + +class AsuswrtTotalRXSensor(AsuswrtSensor): + """Representation of a asuswrt total download sensor.""" + + _name = "Asuswrt Download" + _unit = "Gigabyte" + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit + + async def async_update(self): + """Fetch new state data for the sensor.""" + await super().async_update() + if self._rates: + self._state = round(self._rates[0] / 1000000000, 1) + + +class AsuswrtTotalTXSensor(AsuswrtSensor): + """Representation of a asuswrt total upload sensor.""" + + _name = "Asuswrt Upload" + _unit = "Gigabyte" + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit + + async def async_update(self): + """Fetch new state data for the sensor.""" + await super().async_update() + if self._rates: + self._state = round(self._rates[1] / 1000000000, 1) diff --git a/homeassistant/components/aten_pe/__init__.py b/homeassistant/components/aten_pe/__init__.py new file mode 100644 index 000000000..2a0fb277a --- /dev/null +++ b/homeassistant/components/aten_pe/__init__.py @@ -0,0 +1 @@ +"""The ATEN PE component.""" diff --git a/homeassistant/components/aten_pe/manifest.json b/homeassistant/components/aten_pe/manifest.json new file mode 100644 index 000000000..4f6416dd7 --- /dev/null +++ b/homeassistant/components/aten_pe/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "aten_pe", + "name": "ATEN eco PDUs", + "documentation": "https://www.home-assistant.io/integrations/aten_pe", + "requirements": [ + "atenpdu==0.3.0" + ], + "dependencies": [], + "codeowners": [ + "@mtdcr" + ] +} diff --git a/homeassistant/components/aten_pe/switch.py b/homeassistant/components/aten_pe/switch.py new file mode 100644 index 000000000..2ec6ec4b8 --- /dev/null +++ b/homeassistant/components/aten_pe/switch.py @@ -0,0 +1,122 @@ +"""The ATEN PE switch component.""" + +import logging + +from atenpdu import AtenPE, AtenPEError +import voluptuous as vol + +from homeassistant.components.switch import ( + DEVICE_CLASS_OUTLET, + PLATFORM_SCHEMA, + SwitchDevice, +) +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_USERNAME +from homeassistant.exceptions import PlatformNotReady +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +CONF_AUTH_KEY = "auth_key" +CONF_COMMUNITY = "community" +CONF_PRIV_KEY = "priv_key" +DEFAULT_COMMUNITY = "private" +DEFAULT_PORT = "161" +DEFAULT_USERNAME = "administrator" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_COMMUNITY, default=DEFAULT_COMMUNITY): cv.string, + vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, + vol.Optional(CONF_AUTH_KEY): cv.string, + vol.Optional(CONF_PRIV_KEY): cv.string, + } +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the ATEN PE switch.""" + node = config[CONF_HOST] + serv = config[CONF_PORT] + + dev = AtenPE( + node=node, + serv=serv, + community=config[CONF_COMMUNITY], + username=config[CONF_USERNAME], + authkey=config.get(CONF_AUTH_KEY), + privkey=config.get(CONF_PRIV_KEY), + ) + + try: + await hass.async_add_executor_job(dev.initialize) + mac = await dev.deviceMAC() + outlets = dev.outlets() + except AtenPEError as exc: + _LOGGER.error("Failed to initialize %s:%s: %s", node, serv, str(exc)) + raise PlatformNotReady + + switches = [] + async for outlet in outlets: + switches.append(AtenSwitch(dev, mac, outlet.id, outlet.name)) + + async_add_entities(switches) + + +class AtenSwitch(SwitchDevice): + """Represents an ATEN PE switch.""" + + def __init__(self, device, mac, outlet, name): + """Initialize an ATEN PE switch.""" + self._device = device + self._mac = mac + self._outlet = outlet + self._name = name or f"Outlet {outlet}" + self._enabled = False + self._outlet_power = 0.0 + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return f"{self._mac}-{self._outlet}" + + @property + def name(self) -> str: + """Return the name of the entity.""" + return self._name + + @property + def device_class(self) -> str: + """Return the class of this device, from component DEVICE_CLASSES.""" + return DEVICE_CLASS_OUTLET + + @property + def is_on(self) -> bool: + """Return True if entity is on.""" + return self._enabled + + @property + def current_power_w(self) -> float: + """Return the current power usage in W.""" + return self._outlet_power + + async def async_turn_on(self, **kwargs): + """Turn the switch on.""" + await self._device.setOutletStatus(self._outlet, "on") + self._enabled = True + + async def async_turn_off(self, **kwargs): + """Turn the switch off.""" + await self._device.setOutletStatus(self._outlet, "off") + self._enabled = False + + async def async_update(self): + """Process update from entity.""" + status = await self._device.displayOutletStatus(self._outlet) + if status == "on": + self._enabled = True + self._outlet_power = await self._device.outletPower(self._outlet) + elif status == "off": + self._enabled = False + self._outlet_power = 0.0 diff --git a/homeassistant/components/atome/__init__.py b/homeassistant/components/atome/__init__.py new file mode 100644 index 000000000..6f524606a --- /dev/null +++ b/homeassistant/components/atome/__init__.py @@ -0,0 +1 @@ +"""Support for Atome devices connected to a Linky Energy Meter.""" diff --git a/homeassistant/components/atome/manifest.json b/homeassistant/components/atome/manifest.json new file mode 100644 index 000000000..55739cad8 --- /dev/null +++ b/homeassistant/components/atome/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "atome", + "name": "Atome", + "documentation": "https://www.home-assistant.io/integrations/atome", + "dependencies": [], + "codeowners": ["@baqs"], + "requirements": ["pyatome==0.1.1"] +} diff --git a/homeassistant/components/atome/sensor.py b/homeassistant/components/atome/sensor.py new file mode 100644 index 000000000..f9dd6b2dd --- /dev/null +++ b/homeassistant/components/atome/sensor.py @@ -0,0 +1,278 @@ +"""Linky Atome.""" +from datetime import timedelta +import logging + +from pyatome.client import AtomeClient, PyAtomeError +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_NAME, + CONF_PASSWORD, + CONF_USERNAME, + DEVICE_CLASS_POWER, + ENERGY_KILO_WATT_HOUR, + POWER_WATT, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = "atome" + +LIVE_SCAN_INTERVAL = timedelta(seconds=30) +DAILY_SCAN_INTERVAL = timedelta(seconds=150) +WEEKLY_SCAN_INTERVAL = timedelta(hours=1) +MONTHLY_SCAN_INTERVAL = timedelta(hours=1) +YEARLY_SCAN_INTERVAL = timedelta(days=1) + +LIVE_NAME = "Atome Live Power" +DAILY_NAME = "Atome Daily" +WEEKLY_NAME = "Atome Weekly" +MONTHLY_NAME = "Atome Monthly" +YEARLY_NAME = "Atome Yearly" + +LIVE_TYPE = "live" +DAILY_TYPE = "day" +WEEKLY_TYPE = "week" +MONTHLY_TYPE = "month" +YEARLY_TYPE = "year" + +ICON = "mdi:flash" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Atome sensor.""" + username = config[CONF_USERNAME] + password = config[CONF_PASSWORD] + + try: + atome_client = AtomeClient(username, password) + atome_client.login() + except PyAtomeError as exp: + _LOGGER.error(exp) + return + + data = AtomeData(atome_client) + + sensors = [] + sensors.append(AtomeSensor(data, LIVE_NAME, LIVE_TYPE)) + sensors.append(AtomeSensor(data, DAILY_NAME, DAILY_TYPE)) + sensors.append(AtomeSensor(data, WEEKLY_NAME, WEEKLY_TYPE)) + sensors.append(AtomeSensor(data, MONTHLY_NAME, MONTHLY_TYPE)) + sensors.append(AtomeSensor(data, YEARLY_NAME, YEARLY_TYPE)) + + add_entities(sensors, True) + + +class AtomeData: + """Stores data retrieved from Neurio sensor.""" + + def __init__(self, client: AtomeClient): + """Initialize the data.""" + self.atome_client = client + self._live_power = None + self._subscribed_power = None + self._is_connected = None + self._day_usage = None + self._day_price = None + self._week_usage = None + self._week_price = None + self._month_usage = None + self._month_price = None + self._year_usage = None + self._year_price = None + + @property + def live_power(self): + """Return latest active power value.""" + return self._live_power + + @property + def subscribed_power(self): + """Return latest active power value.""" + return self._subscribed_power + + @property + def is_connected(self): + """Return latest active power value.""" + return self._is_connected + + @Throttle(LIVE_SCAN_INTERVAL) + def update_live_usage(self): + """Return current power value.""" + try: + values = self.atome_client.get_live() + self._live_power = values["last"] + self._subscribed_power = values["subscribed"] + self._is_connected = values["isConnected"] + _LOGGER.debug( + "Updating Atome live data. Got: %d, isConnected: %s, subscribed: %d", + self._live_power, + self._is_connected, + self._subscribed_power, + ) + + except KeyError as error: + _LOGGER.error("Missing last value in values: %s: %s", values, error) + + @property + def day_usage(self): + """Return latest daily usage value.""" + return self._day_usage + + @property + def day_price(self): + """Return latest daily usage value.""" + return self._day_price + + @Throttle(DAILY_SCAN_INTERVAL) + def update_day_usage(self): + """Return current daily power usage.""" + try: + values = self.atome_client.get_consumption(DAILY_TYPE) + self._day_usage = values["total"] / 1000 + self._day_price = values["price"] + _LOGGER.debug("Updating Atome daily data. Got: %d.", self._day_usage) + + except KeyError as error: + _LOGGER.error("Missing last value in values: %s: %s", values, error) + + @property + def week_usage(self): + """Return latest weekly usage value.""" + return self._week_usage + + @property + def week_price(self): + """Return latest weekly usage value.""" + return self._week_price + + @Throttle(WEEKLY_SCAN_INTERVAL) + def update_week_usage(self): + """Return current weekly power usage.""" + try: + values = self.atome_client.get_consumption(WEEKLY_TYPE) + self._week_usage = values["total"] / 1000 + self._week_price = values["price"] + _LOGGER.debug("Updating Atome weekly data. Got: %d.", self._week_usage) + + except KeyError as error: + _LOGGER.error("Missing last value in values: %s: %s", values, error) + + @property + def month_usage(self): + """Return latest monthly usage value.""" + return self._month_usage + + @property + def month_price(self): + """Return latest monthly usage value.""" + return self._month_price + + @Throttle(MONTHLY_SCAN_INTERVAL) + def update_month_usage(self): + """Return current monthly power usage.""" + try: + values = self.atome_client.get_consumption(MONTHLY_TYPE) + self._month_usage = values["total"] / 1000 + self._month_price = values["price"] + _LOGGER.debug("Updating Atome monthly data. Got: %d.", self._month_usage) + + except KeyError as error: + _LOGGER.error("Missing last value in values: %s: %s", values, error) + + @property + def year_usage(self): + """Return latest yearly usage value.""" + return self._year_usage + + @property + def year_price(self): + """Return latest yearly usage value.""" + return self._year_price + + @Throttle(YEARLY_SCAN_INTERVAL) + def update_year_usage(self): + """Return current yearly power usage.""" + try: + values = self.atome_client.get_consumption(YEARLY_TYPE) + self._year_usage = values["total"] / 1000 + self._year_price = values["price"] + _LOGGER.debug("Updating Atome yearly data. Got: %d.", self._year_usage) + + except KeyError as error: + _LOGGER.error("Missing last value in values: %s: %s", values, error) + + +class AtomeSensor(Entity): + """Representation of a sensor entity for Atome.""" + + def __init__(self, data, name, sensor_type): + """Initialize the sensor.""" + self._name = name + self._data = data + self._state = None + self._attributes = {} + + self._sensor_type = sensor_type + + if sensor_type == LIVE_TYPE: + self._unit_of_measurement = POWER_WATT + else: + self._unit_of_measurement = ENERGY_KILO_WATT_HOUR + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._attributes + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit_of_measurement + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return ICON + + @property + def device_class(self): + """Return the device class.""" + return DEVICE_CLASS_POWER + + def update(self): + """Update device state.""" + update_function = getattr(self._data, f"update_{self._sensor_type}_usage") + update_function() + + if self._sensor_type == LIVE_TYPE: + self._state = self._data.live_power + self._attributes["subscribed_power"] = self._data.subscribed_power + self._attributes["is_connected"] = self._data.is_connected + else: + self._state = getattr(self._data, f"{self._sensor_type}_usage") + self._attributes["price"] = getattr( + self._data, f"{self._sensor_type}_price" + ) diff --git a/homeassistant/components/august.py b/homeassistant/components/august.py deleted file mode 100644 index 5f268a95f..000000000 --- a/homeassistant/components/august.py +++ /dev/null @@ -1,257 +0,0 @@ -""" -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.6.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 - if state == AuthenticationState.BAD_PASSWORD: - return False - if 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/august/__init__.py b/homeassistant/components/august/__init__.py new file mode 100644 index 000000000..468e6e429 --- /dev/null +++ b/homeassistant/components/august/__init__.py @@ -0,0 +1,364 @@ +"""Support for August devices.""" +from datetime import timedelta +import logging + +from august.api import Api +from august.authenticator import AuthenticationState, Authenticator, ValidationResult +from requests import RequestException, Session +import voluptuous as vol + +from homeassistant.const import ( + CONF_PASSWORD, + CONF_TIMEOUT, + CONF_USERNAME, + EVENT_HOMEASSISTANT_STOP, +) +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv +from homeassistant.util import Throttle + +_LOGGER = logging.getLogger(__name__) + +_CONFIGURING = {} + +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.""" + + 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.""" + + 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(hass, api, authentication.access_token) + + for component in AUGUST_COMPONENTS: + discovery.load_platform(hass, component, DOMAIN, {}, config) + + return True + if state == AuthenticationState.BAD_PASSWORD: + _LOGGER.error("Invalid password provided") + return False + if state == AuthenticationState.REQUIRES_VALIDATION: + request_configuration(hass, config, api, authenticator) + return True + + return False + + +def setup(hass, config): + """Set up the August component.""" + + conf = config[DOMAIN] + api_http_session = None + try: + api_http_session = Session() + except RequestException as ex: + _LOGGER.warning("Creating HTTP session failed with: %s", str(ex)) + + api = Api(timeout=conf.get(CONF_TIMEOUT), http_session=api_http_session) + + 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), + ) + + def close_http_session(event): + """Close API sessions used to connect to August.""" + _LOGGER.debug("Closing August HTTP sessions") + if api_http_session: + try: + api_http_session.close() + except RequestException: + pass + + _LOGGER.debug("August HTTP session closed.") + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, close_http_session) + _LOGGER.debug("Registered for HASS stop event") + + return setup_august(hass, config, api, authenticator) + + +class AugustData: + """August data object.""" + + def __init__(self, hass, api, access_token): + """Init August data object.""" + self._hass = hass + 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._door_state_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.""" + _LOGGER.debug("Getting device 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.""" + _LOGGER.debug("Start retrieving device activities") + for house_id in self.house_ids: + _LOGGER.debug("Updating device activity for house id %s", house_id) + + 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 + ] + _LOGGER.debug("Completed retrieving device activities") + + 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 = {} + + _LOGGER.debug("Start retrieving doorbell details") + for doorbell in self._doorbells: + _LOGGER.debug("Updating doorbell status for %s", doorbell.device_name) + try: + detail_by_id[doorbell.device_id] = self._api.get_doorbell_detail( + self._access_token, doorbell.device_id + ) + except RequestException as ex: + _LOGGER.error( + "Request error trying to retrieve doorbell" " status for %s. %s", + doorbell.device_name, + ex, + ) + detail_by_id[doorbell.device_id] = None + except Exception: + detail_by_id[doorbell.device_id] = None + raise + + _LOGGER.debug("Completed retrieving doorbell details") + self._doorbell_detail_by_id = detail_by_id + + def get_lock_status(self, lock_id): + """Return status if the door is locked or unlocked. + + This is status for the lock itself. + """ + 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) + + def get_door_state(self, lock_id): + """Return status if the door is open or closed. + + This is the status from the door sensor. + """ + self._update_doors() + return self._door_state_by_id.get(lock_id) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def _update_doors(self): + state_by_id = {} + + _LOGGER.debug("Start retrieving door status") + for lock in self._locks: + _LOGGER.debug("Updating door status for %s", lock.device_name) + + try: + state_by_id[lock.device_id] = self._api.get_lock_door_status( + self._access_token, lock.device_id + ) + except RequestException as ex: + _LOGGER.error( + "Request error trying to retrieve door" " status for %s. %s", + lock.device_name, + ex, + ) + state_by_id[lock.device_id] = None + except Exception: + state_by_id[lock.device_id] = None + raise + + _LOGGER.debug("Completed retrieving door status") + self._door_state_by_id = state_by_id + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def _update_locks(self): + status_by_id = {} + detail_by_id = {} + + _LOGGER.debug("Start retrieving locks status") + for lock in self._locks: + _LOGGER.debug("Updating lock status for %s", lock.device_name) + try: + status_by_id[lock.device_id] = self._api.get_lock_status( + self._access_token, lock.device_id + ) + except RequestException as ex: + _LOGGER.error( + "Request error trying to retrieve door" " status for %s. %s", + lock.device_name, + ex, + ) + status_by_id[lock.device_id] = None + except Exception: + status_by_id[lock.device_id] = None + raise + + try: + detail_by_id[lock.device_id] = self._api.get_lock_detail( + self._access_token, lock.device_id + ) + except RequestException as ex: + _LOGGER.error( + "Request error trying to retrieve door" " details for %s. %s", + lock.device_name, + ex, + ) + detail_by_id[lock.device_id] = None + except Exception: + detail_by_id[lock.device_id] = None + raise + + _LOGGER.debug("Completed retrieving locks status") + 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/august/binary_sensor.py b/homeassistant/components/august/binary_sensor.py new file mode 100644 index 000000000..14d03189c --- /dev/null +++ b/homeassistant/components/august/binary_sensor.py @@ -0,0 +1,193 @@ +"""Support for August binary sensors.""" +from datetime import datetime, timedelta +import logging + +from august.activity import ActivityType +from august.lock import LockDoorStatus + +from homeassistant.components.binary_sensor import BinarySensorDevice + +from . import DATA_AUGUST + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=5) + + +def _retrieve_door_state(data, lock): + """Get the latest state of the DoorSense sensor.""" + return data.get_door_state(lock.device_id) + + +def _retrieve_online_state(data, doorbell): + """Get the latest state of the sensor.""" + detail = data.get_doorbell_detail(doorbell.device_id) + if detail is None: + return None + + return detail.is_online + + +def _retrieve_motion_state(data, doorbell): + + return _activity_time_based_state( + data, doorbell, [ActivityType.DOORBELL_MOTION, ActivityType.DOORBELL_DING] + ) + + +def _retrieve_ding_state(data, doorbell): + + 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_DOOR = {"door_open": ["Open", "door", _retrieve_door_state]} + +SENSOR_TYPES_DOORBELL = { + "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_entities, discovery_info=None): + """Set up the August binary sensors.""" + data = hass.data[DATA_AUGUST] + devices = [] + + for door in data.locks: + for sensor_type in SENSOR_TYPES_DOOR: + state_provider = SENSOR_TYPES_DOOR[sensor_type][2] + if state_provider(data, door) is LockDoorStatus.UNKNOWN: + _LOGGER.debug( + "Not adding sensor class %s for lock %s ", + SENSOR_TYPES_DOOR[sensor_type][1], + door.device_name, + ) + continue + + _LOGGER.debug( + "Adding sensor class %s for %s", + SENSOR_TYPES_DOOR[sensor_type][1], + door.device_name, + ) + devices.append(AugustDoorBinarySensor(data, sensor_type, door)) + + for doorbell in data.doorbells: + for sensor_type in SENSOR_TYPES_DOORBELL: + _LOGGER.debug( + "Adding doorbell sensor class %s for %s", + SENSOR_TYPES_DOORBELL[sensor_type][1], + doorbell.device_name, + ) + devices.append(AugustDoorbellBinarySensor(data, sensor_type, doorbell)) + + add_entities(devices, True) + + +class AugustDoorBinarySensor(BinarySensorDevice): + """Representation of an August Door binary sensor.""" + + def __init__(self, data, sensor_type, door): + """Initialize the sensor.""" + self._data = data + self._sensor_type = sensor_type + self._door = door + self._state = None + self._available = False + + @property + def available(self): + """Return the availability of this sensor.""" + return self._available + + @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_DOOR[self._sensor_type][1] + + @property + def name(self): + """Return the name of the binary sensor.""" + return "{} {}".format( + self._door.device_name, SENSOR_TYPES_DOOR[self._sensor_type][0] + ) + + def update(self): + """Get the latest state of the sensor.""" + state_provider = SENSOR_TYPES_DOOR[self._sensor_type][2] + self._state = state_provider(self._data, self._door) + self._available = self._state is not None + + self._state = self._state == LockDoorStatus.OPEN + + @property + def unique_id(self) -> str: + """Get the unique of the door open binary sensor.""" + return "{:s}_{:s}".format( + self._door.device_id, SENSOR_TYPES_DOOR[self._sensor_type][0].lower() + ) + + +class AugustDoorbellBinarySensor(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 + self._available = False + + @property + def available(self): + """Return the availability of this sensor.""" + return self._available + + @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_DOORBELL[self._sensor_type][1] + + @property + def name(self): + """Return the name of the binary sensor.""" + return "{} {}".format( + self._doorbell.device_name, SENSOR_TYPES_DOORBELL[self._sensor_type][0] + ) + + def update(self): + """Get the latest state of the sensor.""" + state_provider = SENSOR_TYPES_DOORBELL[self._sensor_type][2] + self._state = state_provider(self._data, self._doorbell) + self._available = self._doorbell.is_online + + @property + def unique_id(self) -> str: + """Get the unique id of the doorbell sensor.""" + return "{:s}_{:s}".format( + self._doorbell.device_id, + SENSOR_TYPES_DOORBELL[self._sensor_type][0].lower(), + ) diff --git a/homeassistant/components/august/camera.py b/homeassistant/components/august/camera.py new file mode 100644 index 000000000..2492eb754 --- /dev/null +++ b/homeassistant/components/august/camera.py @@ -0,0 +1,76 @@ +"""Support for August camera.""" +from datetime import timedelta + +import requests + +from homeassistant.components.camera import Camera + +from . import DATA_AUGUST, DEFAULT_TIMEOUT + +SCAN_INTERVAL = timedelta(seconds=5) + + +def setup_platform(hass, config, add_entities, 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_entities(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 + + @property + def unique_id(self) -> str: + """Get the unique id of the camera.""" + return f"{self._doorbell.device_id:s}_camera" diff --git a/homeassistant/components/august/lock.py b/homeassistant/components/august/lock.py new file mode 100644 index 000000000..a541be670 --- /dev/null +++ b/homeassistant/components/august/lock.py @@ -0,0 +1,96 @@ +"""Support for August lock.""" +from datetime import timedelta +import logging + +from august.activity import ActivityType +from august.lock import LockStatus + +from homeassistant.components.lock import LockDevice +from homeassistant.const import ATTR_BATTERY_LEVEL + +from . import DATA_AUGUST + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=5) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up August locks.""" + data = hass.data[DATA_AUGUST] + devices = [] + + for lock in data.locks: + _LOGGER.debug("Adding lock for %s", lock.device_name) + devices.append(AugustLock(data, lock)) + + add_entities(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 + self._available = False + + 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._available = self._lock_status is not None + + self._lock_detail = self._data.get_lock_detail(self._lock.device_id) + + 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 available(self): + """Return the availability of this sensor.""" + return self._available + + @property + def is_locked(self): + """Return true if device is on.""" + + 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.""" + if self._lock_detail is None: + return None + + return {ATTR_BATTERY_LEVEL: self._lock_detail.battery_level} + + @property + def unique_id(self) -> str: + """Get the unique id of the lock.""" + return f"{self._lock.device_id:s}_lock" diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json new file mode 100644 index 000000000..ebaa56473 --- /dev/null +++ b/homeassistant/components/august/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "august", + "name": "August", + "documentation": "https://www.home-assistant.io/integrations/august", + "requirements": [ + "py-august==0.7.0" + ], + "dependencies": ["configurator"], + "codeowners": [] +} diff --git a/homeassistant/components/aurora/__init__.py b/homeassistant/components/aurora/__init__.py new file mode 100644 index 000000000..2b3caa068 --- /dev/null +++ b/homeassistant/components/aurora/__init__.py @@ -0,0 +1 @@ +"""The aurora component.""" diff --git a/homeassistant/components/aurora/binary_sensor.py b/homeassistant/components/aurora/binary_sensor.py new file mode 100644 index 000000000..d76884d28 --- /dev/null +++ b/homeassistant/components/aurora/binary_sensor.py @@ -0,0 +1,143 @@ +"""Support for aurora forecast data sensor.""" +from datetime import timedelta +import logging + +from aiohttp.hdrs import USER_AGENT +import requests +import voluptuous as vol + +from homeassistant.components.binary_sensor import PLATFORM_SCHEMA, BinarySensorDevice +from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME +import homeassistant.helpers.config_validation as cv +from homeassistant.util import Throttle + +_LOGGER = logging.getLogger(__name__) + +ATTRIBUTION = "Data provided by the National Oceanic and Atmospheric " "Administration" +CONF_THRESHOLD = "forecast_threshold" + +DEFAULT_DEVICE_CLASS = "visible" +DEFAULT_NAME = "Aurora Visibility" +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, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the aurora sensor.""" + if None in (hass.config.latitude, hass.config.longitude): + _LOGGER.error("Lat. or long. not set in Home Assistant config") + return False + + name = config.get(CONF_NAME) + threshold = config.get(CONF_THRESHOLD) + + try: + aurora_data = AuroraData(hass.config.latitude, hass.config.longitude, threshold) + aurora_data.update() + except requests.exceptions.HTTPError as error: + _LOGGER.error("Connection to aurora forecast service failed: %s", error) + return False + + add_entities([AuroraSensor(aurora_data, name)], True) + + +class AuroraSensor(BinarySensorDevice): + """Implementation of an aurora sensor.""" + + def __init__(self, aurora_data, name): + """Initialize the sensor.""" + self.aurora_data = aurora_data + self._name = name + + @property + def name(self): + """Return the name of the sensor.""" + return f"{self._name}" + + @property + def is_on(self): + """Return true if aurora is visible.""" + return self.aurora_data.is_visible if self.aurora_data else False + + @property + def device_class(self): + """Return the class of this device.""" + return DEFAULT_DEVICE_CLASS + + @property + def device_state_attributes(self): + """Return the state attributes.""" + attrs = {} + + if self.aurora_data: + attrs["visibility_level"] = self.aurora_data.visibility_level + attrs["message"] = self.aurora_data.is_visible_text + attrs[ATTR_ATTRIBUTION] = ATTRIBUTION + return attrs + + def update(self): + """Get the latest data from Aurora API and updates the states.""" + self.aurora_data.update() + + +class AuroraData: + """Get aurora forecast.""" + + def __init__(self, latitude, longitude, threshold): + """Initialize the data object.""" + self.latitude = latitude + self.longitude = longitude + self.number_of_latitude_intervals = 513 + self.number_of_longitude_intervals = 1024 + self.headers = {USER_AGENT: HA_USER_AGENT} + self.threshold = int(threshold) + self.is_visible = None + self.is_visible_text = None + self.visibility_level = None + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data from the Aurora service.""" + try: + self.visibility_level = self.get_aurora_forecast() + if int(self.visibility_level) > self.threshold: + self.is_visible = True + self.is_visible_text = "visible!" + else: + self.is_visible = False + self.is_visible_text = "nothing's out" + + except requests.exceptions.HTTPError as error: + _LOGGER.error("Connection to aurora forecast service failed: %s", error) + return False + + def get_aurora_forecast(self): + """Get forecast data and parse for given long/lat.""" + 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 + converted_latitude = round( + (self.latitude / 180) * self.number_of_latitude_intervals + ) + converted_longitude = round( + (self.longitude / 360) * self.number_of_longitude_intervals + ) + + return forecast_table[converted_latitude][converted_longitude] diff --git a/homeassistant/components/aurora/manifest.json b/homeassistant/components/aurora/manifest.json new file mode 100644 index 000000000..204327043 --- /dev/null +++ b/homeassistant/components/aurora/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "aurora", + "name": "Aurora", + "documentation": "https://www.home-assistant.io/integrations/aurora", + "requirements": [], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/aurora_abb_powerone/__init__.py b/homeassistant/components/aurora_abb_powerone/__init__.py new file mode 100644 index 000000000..087172d1b --- /dev/null +++ b/homeassistant/components/aurora_abb_powerone/__init__.py @@ -0,0 +1 @@ +"""The Aurora ABB Powerone PV inverter sensor integration.""" diff --git a/homeassistant/components/aurora_abb_powerone/manifest.json b/homeassistant/components/aurora_abb_powerone/manifest.json new file mode 100644 index 000000000..f49421ea9 --- /dev/null +++ b/homeassistant/components/aurora_abb_powerone/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "aurora_abb_powerone", + "name": "Aurora ABB Solar PV", + "documentation": "https://www.home-assistant.io/integrations/aurora_abb_powerone/", + "dependencies": [], + "codeowners": [ + "@davet2001" + ], + "requirements": ["aurorapy==0.2.6"] +} diff --git a/homeassistant/components/aurora_abb_powerone/sensor.py b/homeassistant/components/aurora_abb_powerone/sensor.py new file mode 100644 index 000000000..a2645e5d7 --- /dev/null +++ b/homeassistant/components/aurora_abb_powerone/sensor.py @@ -0,0 +1,104 @@ +"""Support for Aurora ABB PowerOne Solar Photvoltaic (PV) inverter.""" + +import logging + +from aurorapy.client import AuroraError, AuroraSerialClient +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_ADDRESS, + CONF_DEVICE, + CONF_NAME, + DEVICE_CLASS_POWER, + POWER_WATT, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_ADDRESS = 2 +DEFAULT_NAME = "Solar PV" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_DEVICE): cv.string, + vol.Optional(CONF_ADDRESS, default=DEFAULT_ADDRESS): cv.positive_int, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Aurora ABB PowerOne device.""" + devices = [] + comport = config[CONF_DEVICE] + address = config[CONF_ADDRESS] + name = config[CONF_NAME] + + _LOGGER.debug("Intitialising com port=%s address=%s", comport, address) + client = AuroraSerialClient(address, comport, parity="N", timeout=1) + + devices.append(AuroraABBSolarPVMonitorSensor(client, name, "Power")) + add_entities(devices, True) + + +class AuroraABBSolarPVMonitorSensor(Entity): + """Representation of a Sensor.""" + + def __init__(self, client, name, typename): + """Initialize the sensor.""" + self._name = f"{name} {typename}" + self.client = client + self._state = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return POWER_WATT + + @property + def device_class(self): + """Return the device class.""" + return DEVICE_CLASS_POWER + + def update(self): + """Fetch new state data for the sensor. + + This is the only method that should fetch new data for Home Assistant. + """ + try: + self.client.connect() + # read ADC channel 3 (grid power output) + power_watts = self.client.measure(3, True) + self._state = round(power_watts, 1) + # _LOGGER.debug("Got reading %fW" % self._state) + except AuroraError as error: + # aurorapy does not have different exceptions (yet) for dealing + # with timeout vs other comms errors. + # This means the (normal) situation of no response during darkness + # raises an exception. + # aurorapy (gitlab) pull request merged 29/5/2019. When >0.2.6 is + # released, this could be modified to : + # except AuroraTimeoutError as e: + # Workaround: look at the text of the exception + if "No response after" in str(error): + _LOGGER.debug("No response from inverter (could be dark)") + else: + # print("Exception!!: {}".format(str(e))) + raise error + self._state = None + finally: + if self.client.serline.isOpen(): + self.client.close() diff --git a/homeassistant/components/auth/.translations/bg.json b/homeassistant/components/auth/.translations/bg.json new file mode 100644 index 000000000..d07e20a85 --- /dev/null +++ b/homeassistant/components/auth/.translations/bg.json @@ -0,0 +1,35 @@ +{ + "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "\u041d\u044f\u043c\u0430 \u043d\u0430\u043b\u0438\u0447\u043d\u0438 \u0443\u0441\u043b\u0443\u0433\u0438 \u0437\u0430 \u0443\u0432\u0435\u0434\u043e\u043c\u044f\u0432\u0430\u043d\u0435." + }, + "error": { + "invalid_code": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u0435\u043d \u043a\u043e\u0434, \u043c\u043e\u043b\u044f \u043e\u043f\u0438\u0442\u0430\u0439\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e." + }, + "step": { + "init": { + "description": "\u041c\u043e\u043b\u044f, \u0438\u0437\u0431\u0435\u0440\u0435\u0442\u0435 \u0435\u0434\u043d\u0430 \u043e\u0442 \u0443\u0441\u043b\u0443\u0433\u0438\u0442\u0435 \u0437\u0430 \u0443\u0432\u0435\u0434\u043e\u043c\u044f\u0432\u0430\u043d\u0435:", + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0432\u0430\u043d\u0435 \u043d\u0430 \u0435\u0434\u043d\u043e\u043a\u0440\u0430\u0442\u043d\u0430 \u043f\u0430\u0440\u043e\u043b\u0430, \u0434\u043e\u0441\u0442\u0430\u0432\u0435\u043d\u0430 \u0447\u0440\u0435\u0437 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442 \u0437\u0430 \u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u0435" + }, + "setup": { + "description": "\u0415\u0434\u043d\u043e\u043a\u0440\u0430\u0442\u043d\u0430 \u043f\u0430\u0440\u043e\u043b\u0430 \u0435 \u0438\u0437\u043f\u0440\u0430\u0442\u0435\u043d\u0430 \u0447\u0440\u0435\u0437 **notify.{notify_service}**. \u041c\u043e\u043b\u044f, \u0432\u044a\u0432\u0435\u0434\u0435\u0442\u0435 \u044f \u043f\u043e-\u0434\u043e\u043b\u0443:", + "title": "\u041f\u0440\u043e\u0432\u0435\u0440\u043a\u0430 \u043d\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430\u0442\u0430" + } + }, + "title": "\u0423\u0432\u0435\u0434\u043e\u043c\u044f\u0432\u0430\u043d\u0435 \u043d\u0430 \u0435\u0434\u043d\u043e\u043a\u0440\u0430\u0442\u043d\u0430 \u043f\u0430\u0440\u043e\u043b\u0430" + }, + "totp": { + "error": { + "invalid_code": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u0435\u043d \u043a\u043e\u0434, \u043c\u043e\u043b\u044f \u043e\u043f\u0438\u0442\u0430\u0439\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e. \u0410\u043a\u043e \u043f\u043e\u043b\u0443\u0447\u0430\u0432\u0430\u0442\u0435 \u0442\u0430\u0437\u0438 \u0433\u0440\u0435\u0448\u043a\u0430 \u043f\u043e\u0441\u0442\u043e\u044f\u043d\u043d\u043e, \u043c\u043e\u043b\u044f, \u0443\u0432\u0435\u0440\u0435\u0442\u0435 \u0441\u0435, \u0447\u0435 \u0447\u0430\u0441\u043e\u0432\u043d\u0438\u043a\u044a\u0442 \u043d\u0430 Home Assistant \u0435 \u0441\u0432\u0435\u0440\u0435\u043d." + }, + "step": { + "init": { + "description": "\u0417\u0430 \u0434\u0430 \u0430\u043a\u0442\u0438\u0432\u0438\u0440\u0430\u0442\u0435 \u0434\u0432\u0443\u0444\u0430\u043a\u0442\u043e\u0440\u043d\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u043a\u0430\u0446\u0438\u044f \u043f\u043e\u0441\u0440\u0435\u0434\u0441\u0442\u0432\u043e\u043c \u0432\u0440\u0435\u043c\u0435\u0432\u043e-\u0431\u0430\u0437\u0438\u0440\u0430\u043d\u0438 \u0435\u0434\u043d\u043e\u043a\u0440\u0430\u0442\u043d\u0438 \u043f\u0430\u0440\u043e\u043b\u0438, \u0441\u043a\u0430\u043d\u0438\u0440\u0430\u0439\u0442\u0435 QR \u043a\u043e\u0434\u0430 \u0441 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u043a\u0430\u0442\u043e\u0440\u0430. \u0410\u043a\u043e \u043d\u044f\u043c\u0430\u0442\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435, \u0412\u0438 \u043f\u0440\u0435\u043f\u043e\u0440\u044a\u0447\u0432\u0430\u043c\u0435 \u0438\u043b\u0438 [Google Authenticator](https://support.google.com/accounts/answer/1066447) \u0438\u043b\u0438 [Authy](https://authy.com/).\n\n{qr_code}\n\n\u0421\u043b\u0435\u0434 \u0441\u043a\u0430\u043d\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u043a\u043e\u0434\u0430, \u0432\u044a\u0432\u0435\u0434\u0435\u0442\u0435 6-\u0442\u0435 \u0446\u0438\u0444\u0440\u0438 \u043e\u0442 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0442\u043e \u0437\u0430 \u0434\u0430 \u043f\u043e\u0442\u0432\u044a\u0440\u0434\u0438\u0442\u0435 \u0430\u043a\u0442\u0438\u0432\u0438\u0440\u0430\u043d\u0435\u0442\u043e. \u0410\u043a\u043e \u0438\u043c\u0430\u0442\u0435 \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u0438 \u043f\u0440\u0438 \u0441\u043a\u0430\u043d\u0438\u0440\u0430\u043d\u0435\u0442\u043e \u043d\u0430 QR \u043a\u043e\u0434\u0430, \u043d\u0430\u043f\u0440\u0430\u0432\u0435\u0442\u0435 \u0440\u044a\u0447\u043d\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0441 \u043a\u043e\u0434 **`{code}`**.", + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043d\u0430 \u0434\u0432\u0443\u0444\u0430\u043a\u0442\u043e\u0440\u043d\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u043a\u0430\u0446\u0438\u044f \u0447\u0440\u0435\u0437 TOTP" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/auth/.translations/ca.json b/homeassistant/components/auth/.translations/ca.json index 1b3b25dbc..e5ece421a 100644 --- a/homeassistant/components/auth/.translations/ca.json +++ b/homeassistant/components/auth/.translations/ca.json @@ -1,13 +1,32 @@ { "mfa_setup": { - "totp": { + "notify": { + "abort": { + "no_available_service": "No hi ha serveis de notificaci\u00f3 disponibles." + }, "error": { - "invalid_code": "Codi no v\u00e0lid, si us plau torni a provar-ho. Si obteniu aquest error repetidament, assegureu-vos que la data i hora de Home Assistant sigui correcta i precisa." + "invalid_code": "Codi inv\u00e0lid, si us plau torna a provar-ho." }, "step": { "init": { - "description": "Per activar la verificaci\u00f3 en dos passos mitjan\u00e7ant contrasenyes d'un sol \u00fas basades en temps, escanegeu el codi QR amb la vostre aplicaci\u00f3 de verificaci\u00f3. Si no en teniu cap, us recomanem [Google Authenticator](https://support.google.com/accounts/answer/1066447) o b\u00e9 [Authy](https://authy.com/). \n\n {qr_code} \n \nDespr\u00e9s d'escanejar el codi QR, introdu\u00efu el codi de sis d\u00edgits proporcionat per l'aplicaci\u00f3. Si teniu problemes per escanejar el codi QR, feu una configuraci\u00f3 manual amb el codi **`{code}`**.", - "title": "Configureu la verificaci\u00f3 en dos passos utilitzant TOTP" + "description": "Selecciona un dels serveis de notificaci\u00f3:", + "title": "Configuraci\u00f3 d'una contrasenya d'un sol \u00fas a trav\u00e9s del component de notificacions" + }, + "setup": { + "description": "S'ha enviat una contrasenya d'un sol \u00fas mitjan\u00e7ant **notify.{notify_service}**. Introdueix-la a continuaci\u00f3:", + "title": "Verificaci\u00f3 de la configuraci\u00f3" + } + }, + "title": "Contrasenya d'un sol \u00fas del servei de notificacions" + }, + "totp": { + "error": { + "invalid_code": "Codi inv\u00e0lid, si us plau torna a provar-ho. Si obtens aquest error repetidament, assegura't que la data i hora de Home Assistant siguin correctes i acurades." + }, + "step": { + "init": { + "description": "Per activar la verificaci\u00f3 en dos passos mitjan\u00e7ant contrasenyes d'un sol \u00fas basades en temps, escaneja el codi QR amb la teva aplicaci\u00f3 de verificaci\u00f3. Si no en tens cap, et recomanem [Google Authenticator](https://support.google.com/accounts/answer/1066447) o b\u00e9 [Authy](https://authy.com/). \n\n {qr_code} \n \nDespr\u00e9s d'escanejar el codi QR, introdueix el codi de sis d\u00edgits proporcionat per l'aplicaci\u00f3. Si tens problemes per escanejar el codi QR, fes una configuraci\u00f3 manual amb el codi **`{code}`**.", + "title": "Configura la verificaci\u00f3 en dos passos utilitzant TOTP" } }, "title": "TOTP" diff --git a/homeassistant/components/auth/.translations/cs.json b/homeassistant/components/auth/.translations/cs.json new file mode 100644 index 000000000..da234c3dd --- /dev/null +++ b/homeassistant/components/auth/.translations/cs.json @@ -0,0 +1,34 @@ +{ + "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "\u017d\u00e1dn\u00e9 oznamovac\u00ed slu\u017eby nejsou k dispozici." + }, + "error": { + "invalid_code": "Neplatn\u00fd k\u00f3d, zkuste to znovu." + }, + "step": { + "init": { + "description": "Vyberte pros\u00edm jednu z oznamovac\u00edch slu\u017eeb:", + "title": "Nastavte jednor\u00e1zov\u00e9 heslo dodan\u00e9 komponentou notify" + }, + "setup": { + "description": "Jednor\u00e1zov\u00e9 heslo bylo odesl\u00e1no prost\u0159ednictv\u00edm **notify.{notify_service}**. Zadejte jej n\u00ed\u017ee:", + "title": "Ov\u011b\u0159en\u00ed nastaven\u00ed" + } + } + }, + "totp": { + "error": { + "invalid_code": "Neplatn\u00fd k\u00f3d, zkuste to znovu. Pokud se tato chyba opakuje, ujist\u011bte se, \u017ee hodiny syst\u00e9mu Home Assistant jsou spr\u00e1vn\u011b nastaveny." + }, + "step": { + "init": { + "description": "Chcete-li aktivovat dvoufaktorovou autentizaci pomoc\u00ed jednor\u00e1zov\u00fdch hesel zalo\u017een\u00fdch na \u010dase, na\u010dt\u011bte k\u00f3d QR pomoc\u00ed va\u0161\u00ed autentiza\u010dn\u00ed aplikace. Pokud ji nem\u00e1te, doporu\u010dujeme bu\u010f [Google Authenticator](https://support.google.com/accounts/answer/1066447) nebo [Authy](https://authy.com/). \n\n {qr_code} \n \n Po skenov\u00e1n\u00ed k\u00f3du zadejte \u0161estcifern\u00fd k\u00f3d z aplikace a ov\u011b\u0159te nastaven\u00ed. Pokud m\u00e1te probl\u00e9my se skenov\u00e1n\u00edm k\u00f3du QR, prove\u010fte ru\u010dn\u00ed nastaven\u00ed s k\u00f3dem **`{code}`**.", + "title": "Nastavte dvoufaktorovou autentizaci pomoc\u00ed TOTP" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/auth/.translations/da.json b/homeassistant/components/auth/.translations/da.json new file mode 100644 index 000000000..f461f376d --- /dev/null +++ b/homeassistant/components/auth/.translations/da.json @@ -0,0 +1,35 @@ +{ + "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "Ingen underretningstjenester til r\u00e5dighed." + }, + "error": { + "invalid_code": "Ugyldig kode, pr\u00f8v venligst igen." + }, + "step": { + "init": { + "description": "V\u00e6lg venligst en af meddelelsestjenesterne:", + "title": "Ops\u00e6t engangsadgangskode, der er leveret af besked komponenten" + }, + "setup": { + "description": "En engangsadgangskode er blevet sendt via **notify.{notify_service}**. Indtast den venligst nedenunder:", + "title": "Bekr\u00e6ft ops\u00e6tningen" + } + }, + "title": "Advis\u00e9r engangskodeord" + }, + "totp": { + "error": { + "invalid_code": "Ugyldig kode, pr\u00f8v venligst igen. Hvis du konsekvent f\u00e5r denne fejl skal du s\u00f8rge for at uret p\u00e5 dit Home Assistant system er g\u00e5r n\u00f8jagtigt." + }, + "step": { + "init": { + "description": "Hvis du vil aktivere tofaktorautentificering ved hj\u00e6lp af tidsbaserede engangskoder skal du scanne QR-koden med din autentificeringsapp. Hvis du ikke har en anbefaler vi enten [Google Authenticator] (https://support.google.com/accounts/answer/1066447) eller [Authy] (https://authy.com/). \n\n {qr_code} \n \nN\u00e5r du har scannet koden skal du indtaste den sekscifrede kode fra din app for at bekr\u00e6fte ops\u00e6tningen. Hvis du har problemer med at scanne QR-koden skal du lave en manuel ops\u00e6tning med kode **`{code}`**.", + "title": "Konfigurer to-faktors godkendelse ved hj\u00e6lp af TOTP" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/auth/.translations/de.json b/homeassistant/components/auth/.translations/de.json index 67f948e83..06da3cde1 100644 --- a/homeassistant/components/auth/.translations/de.json +++ b/homeassistant/components/auth/.translations/de.json @@ -1,8 +1,27 @@ { "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "Keine Benachrichtigungsdienste verf\u00fcgbar." + }, + "error": { + "invalid_code": "Ung\u00fcltiger Code, bitte versuche es erneut." + }, + "step": { + "init": { + "description": "Bitte w\u00e4hlen Sie einen der Benachrichtigungsdienste:", + "title": "Einmal Passwort f\u00fcr Notify einrichten" + }, + "setup": { + "description": "Ein Einmal-Passwort wurde per **notify.{notify_service}** gesendet. Bitte geben Sie es unten ein:", + "title": "\u00dcberpr\u00fcfe das Setup" + } + }, + "title": "Benachrichtig f\u00fcr One-Time Password" + }, "totp": { "error": { - "invalid_code": "Ung\u00fcltiger Code, bitte versuche es erneut. Wenn Sie diesen Fehler regelm\u00e4\u00dfig erhalten, stelle sicher, dass die Uhr deines Home Assistant-Systems korrekt ist." + "invalid_code": "Ung\u00fcltiger Code, bitte versuche es erneut. Wenn du diesen Fehler regelm\u00e4\u00dfig erhalten, stelle sicher, dass die Uhr deines Home Assistant-Systems korrekt ist." }, "step": { "init": { diff --git a/homeassistant/components/auth/.translations/en.json b/homeassistant/components/auth/.translations/en.json index a0fd20e9d..66c0e92d9 100644 --- a/homeassistant/components/auth/.translations/en.json +++ b/homeassistant/components/auth/.translations/en.json @@ -1,5 +1,24 @@ { "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "No notification services available." + }, + "error": { + "invalid_code": "Invalid code, please try again." + }, + "step": { + "init": { + "description": "Please select one of the notification services:", + "title": "Set up one-time password delivered by notify component" + }, + "setup": { + "description": "A one-time password has been sent via **notify.{notify_service}**. Please enter it below:", + "title": "Verify setup" + } + }, + "title": "Notify One-Time Password" + }, "totp": { "error": { "invalid_code": "Invalid code, please try again. If you get this error consistently, please make sure the clock of your Home Assistant system is accurate." diff --git a/homeassistant/components/auth/.translations/es-419.json b/homeassistant/components/auth/.translations/es-419.json index 6caa9d499..4ac970689 100644 --- a/homeassistant/components/auth/.translations/es-419.json +++ b/homeassistant/components/auth/.translations/es-419.json @@ -1,8 +1,31 @@ { "mfa_setup": { - "totp": { + "notify": { + "abort": { + "no_available_service": "No hay servicios de notificaci\u00f3n disponibles." + }, + "error": { + "invalid_code": "C\u00f3digo inv\u00e1lido, por favor int\u00e9ntelo de nuevo." + }, "step": { "init": { + "description": "Por favor seleccione uno de los servicios de notificaci\u00f3n:", + "title": "Configure la contrase\u00f1a de un solo uso entregada por el componente de notificaci\u00f3n" + }, + "setup": { + "description": "Se ha enviado una contrase\u00f1a \u00fanica a trav\u00e9s de **notify.{notify_service}**. Por favor ingr\u00e9selo a continuaci\u00f3n:", + "title": "Verificar la configuracion" + } + }, + "title": "Notificar contrase\u00f1a de un solo uso" + }, + "totp": { + "error": { + "invalid_code": "C\u00f3digo no v\u00e1lido, por favor vuelva a intentarlo. Si recibe este error constantemente, aseg\u00farese de que el reloj de su sistema Home Assistant sea exacto." + }, + "step": { + "init": { + "description": "Para activar la autenticaci\u00f3n de dos factores utilizando contrase\u00f1as de un solo uso basadas en el tiempo, escanee el c\u00f3digo QR con su aplicaci\u00f3n de autenticaci\u00f3n. Si no tiene uno, le recomendamos [Autenticador de Google] (https://support.google.com/accounts/answer/1066447) o [Authy] (https://authy.com/). \n\n {qr_code} \n \n Despu\u00e9s de escanear el c\u00f3digo, ingrese el c\u00f3digo de seis d\u00edgitos de su aplicaci\u00f3n para verificar la configuraci\u00f3n. Si tiene problemas para escanear el c\u00f3digo QR, realice una configuraci\u00f3n manual con el c\u00f3digo ** ` {code} ` **.", "title": "Configurar la autenticaci\u00f3n de dos factores mediante TOTP" } }, diff --git a/homeassistant/components/auth/.translations/es.json b/homeassistant/components/auth/.translations/es.json new file mode 100644 index 000000000..5603c14fe --- /dev/null +++ b/homeassistant/components/auth/.translations/es.json @@ -0,0 +1,35 @@ +{ + "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "No hay servicios de notificaci\u00f3n disponibles." + }, + "error": { + "invalid_code": "C\u00f3digo inv\u00e1lido, por favor int\u00e9ntelo de nuevo." + }, + "step": { + "init": { + "description": "Seleccione uno de los servicios de notificaci\u00f3n:", + "title": "Configure una contrase\u00f1a de un solo uso entregada por el componente de notificaci\u00f3n" + }, + "setup": { + "description": "Se ha enviado una contrase\u00f1a de un solo uso a trav\u00e9s de ** notify. {notify_service} **. Por favor introd\u00facela a continuaci\u00f3n:", + "title": "Verificar la configuraci\u00f3n" + } + }, + "title": "Notificar la contrase\u00f1a de un solo uso" + }, + "totp": { + "error": { + "invalid_code": "C\u00f3digo inv\u00e1lido, int\u00e9ntalo de nuevo. Si recibes este error de forma consistente, aseg\u00farate de que el reloj de tu sistema Home Assistant es correcto." + }, + "step": { + "init": { + "description": "Para activar la autenticaci\u00f3n de dos factores utilizando contrase\u00f1as de un solo uso basadas en el tiempo, escanea el c\u00f3digo QR con tu aplicaci\u00f3n de autenticaci\u00f3n. Si no tienes una, te recomendamos el [Autenticador de Google](https://support.google.com/accounts/answer/1066447) o [Authy](https://authy.com/). \n\n {qr_code} \n \nDespu\u00e9s de escanear el c\u00f3digo, introduce el c\u00f3digo de seis d\u00edgitos de tu aplicaci\u00f3n para verificar la configuraci\u00f3n. Si tienes problemas para escanear el c\u00f3digo QR, realiza una configuraci\u00f3n manual con el c\u00f3digo **`{code}`**.", + "title": "Configure la autenticaci\u00f3n de dos factores utilizando TOTP" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/auth/.translations/et.json b/homeassistant/components/auth/.translations/et.json new file mode 100644 index 000000000..290f4ee12 --- /dev/null +++ b/homeassistant/components/auth/.translations/et.json @@ -0,0 +1,7 @@ +{ + "mfa_setup": { + "totp": { + "title": "" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/auth/.translations/fr.json b/homeassistant/components/auth/.translations/fr.json index b8d10dc89..cf0a18884 100644 --- a/homeassistant/components/auth/.translations/fr.json +++ b/homeassistant/components/auth/.translations/fr.json @@ -1,5 +1,24 @@ { "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "Aucun service de notification disponible." + }, + "error": { + "invalid_code": "Code invalide. Veuillez essayer \u00e0 nouveau." + }, + "step": { + "init": { + "description": "Veuillez s\u00e9lectionner l'un des services de notification:", + "title": "Configurer un mot de passe \u00e0 usage unique d\u00e9livr\u00e9 par le composant notify" + }, + "setup": { + "description": "Un mot de passe unique a \u00e9t\u00e9 envoy\u00e9 par **notify.{notify_service}**. Veuillez le saisir ci-dessous :", + "title": "V\u00e9rifier la configuration" + } + }, + "title": "Notifier un mot de passe unique" + }, "totp": { "error": { "invalid_code": "Code invalide. Veuillez essayez \u00e0 nouveau. Si cette erreur persiste, assurez-vous que l'horloge de votre syst\u00e8me Home Assistant est correcte." diff --git a/homeassistant/components/auth/.translations/he.json b/homeassistant/components/auth/.translations/he.json new file mode 100644 index 000000000..bc1826d4d --- /dev/null +++ b/homeassistant/components/auth/.translations/he.json @@ -0,0 +1,35 @@ +{ + "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "\u05d0\u05d9\u05df \u05e9\u05d9\u05e8\u05d5\u05ea\u05d9 notify \u05d6\u05de\u05d9\u05e0\u05d9\u05dd." + }, + "error": { + "invalid_code": "\u05e7\u05d5\u05d3 \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9, \u05d0\u05e0\u05d0 \u05e0\u05e1\u05d4 \u05e9\u05d5\u05d1." + }, + "step": { + "init": { + "description": "\u05d1\u05d7\u05e8 \u05d0\u05ea \u05d0\u05d7\u05d3 \u05de\u05e9\u05e8\u05d5\u05ea\u05d9 notify", + "title": "\u05d4\u05d2\u05d3\u05e8 \u05e1\u05d9\u05e1\u05de\u05d4 \u05d7\u05d3 \u05e4\u05e2\u05de\u05d9\u05ea \u05d4\u05e0\u05e9\u05dc\u05d7\u05ea \u05e2\u05dc \u05d9\u05d3\u05d9 \u05e8\u05db\u05d9\u05d1 notify" + }, + "setup": { + "description": "\u05e1\u05d9\u05e1\u05de\u05d4 \u05d7\u05d3 \u05e4\u05e2\u05de\u05d9\u05ea \u05e0\u05e9\u05dc\u05d7\u05d4 \u05e2\u05dc \u05d9\u05d3\u05d9 **{notify_service}**. \u05d4\u05d6\u05df \u05d0\u05d5\u05ea\u05d4 \u05dc\u05de\u05d8\u05d4:", + "title": "\u05d0\u05d9\u05de\u05d5\u05ea \u05d4\u05d4\u05ea\u05e7\u05e0\u05d4" + } + }, + "title": "\u05dc\u05d4\u05d5\u05d3\u05d9\u05e2 \u200b\u200b\u05e2\u05dc \u05e1\u05d9\u05e1\u05de\u05d4 \u05d7\u05d3 \u05e4\u05e2\u05de\u05d9\u05ea" + }, + "totp": { + "error": { + "invalid_code": "\u05e7\u05d5\u05d3 \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9, \u05d0\u05e0\u05d0 \u05e0\u05e1\u05d4 \u05e9\u05d5\u05d1. \u05d0\u05dd \u05d0\u05ea\u05d4 \u05de\u05e7\u05d1\u05dc \u05d0\u05ea \u05d4\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d4\u05d6\u05d5 \u05d1\u05d0\u05d5\u05e4\u05df \u05e2\u05e7\u05d1\u05d9, \u05d5\u05d3\u05d0 \u05e9\u05d4\u05e9\u05e2\u05d5\u05df \u05e9\u05dc \u05de\u05e2\u05e8\u05db\u05ea \u05d4 - Home Assistant \u05e9\u05dc\u05da \u05de\u05d3\u05d5\u05d9\u05e7." + }, + "step": { + "init": { + "description": "\u05db\u05d3\u05d9 \u05dc\u05d4\u05e4\u05e2\u05d9\u05dc \u05d0\u05d9\u05de\u05d5\u05ea \u05d3\u05d5 \u05e9\u05dc\u05d1\u05d9 \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea \u05e1\u05d9\u05e1\u05de\u05d0\u05d5\u05ea \u05d7\u05d3 \u05e4\u05e2\u05de\u05d9\u05d5\u05ea \u05de\u05d1\u05d5\u05e1\u05e1\u05d5\u05ea \u05d6\u05de\u05df, \u05e1\u05e8\u05d5\u05e7 \u05d0\u05ea \u05e7\u05d5\u05d3 QR \u05e2\u05dd \u05d9\u05d9\u05e9\u05d5\u05dd \u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05e9\u05dc\u05da. \u05d0\u05dd \u05d0\u05d9\u05df \u05dc\u05da \u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d6\u05d4, \u05d0\u05e0\u05d5 \u05de\u05de\u05dc\u05d9\u05e6\u05d9\u05dd \u05e2\u05dc [Google Authenticator] (https://support.google.com/accounts/answer/1066447) \u05d0\u05d5 [Authy] (https://authy.com/). \n\n {qr_code} \n \n \u05dc\u05d0\u05d7\u05e8 \u05e1\u05e8\u05d9\u05e7\u05ea \u05d4\u05e7\u05d5\u05d3, \u05d4\u05d6\u05df \u05d0\u05ea \u05d4\u05e7\u05d5\u05d3 \u05d1\u05df \u05e9\u05e9 \u05d4\u05e1\u05e4\u05e8\u05d5\u05ea \u05de\u05d4\u05d0\u05e4\u05dc\u05d9\u05e7\u05e6\u05d9\u05d4 \u05e9\u05dc\u05da \u05db\u05d3\u05d9 \u05dc\u05d0\u05de\u05ea \u05d0\u05ea \u05d4\u05d4\u05d2\u05d3\u05e8\u05d4. \u05d0\u05dd \u05d0\u05ea\u05d4 \u05e0\u05ea\u05e7\u05dc \u05d1\u05d1\u05e2\u05d9\u05d5\u05ea \u05d1\u05e1\u05e8\u05d9\u05e7\u05ea \u05e7\u05d5\u05d3 QR, \u05d1\u05e6\u05e2 \u05d4\u05d2\u05d3\u05e8\u05d4 \u05d9\u05d3\u05e0\u05d9\u05ea \u05e2\u05dd \u05e7\u05d5\u05d3 **`{code}`**.", + "title": "\u05d4\u05d2\u05d3\u05e8 \u05d0\u05d9\u05de\u05d5\u05ea \u05d3\u05d5 \u05e9\u05dc\u05d1\u05d9 \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea TOTP" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/auth/.translations/hu.json b/homeassistant/components/auth/.translations/hu.json index 450009855..5e7b18350 100644 --- a/homeassistant/components/auth/.translations/hu.json +++ b/homeassistant/components/auth/.translations/hu.json @@ -1,5 +1,24 @@ { "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "Nincs el\u00e9rhet\u0151 \u00e9rtes\u00edt\u00e9si szolg\u00e1ltat\u00e1s." + }, + "error": { + "invalid_code": "\u00c9rv\u00e9nytelen k\u00f3d, pr\u00f3b\u00e1ld \u00fajra." + }, + "step": { + "init": { + "description": "K\u00e9rlek, v\u00e1lassz egyet az \u00e9rtes\u00edt\u00e9si szolg\u00e1ltat\u00e1sok k\u00f6z\u00fcl:", + "title": "\u00c1ll\u00edtsa be az \u00e9rtes\u00edt\u00e9si \u00f6sszetev\u0151 \u00e1ltal megadott egyszeri jelsz\u00f3t" + }, + "setup": { + "description": "Az egyszeri jelsz\u00f3 el lett k\u00fcldve a(z) **notify.{notify_service}** szolg\u00e1ltat\u00e1ssal. K\u00e9rlek, add meg al\u00e1bb:", + "title": "Be\u00e1ll\u00edt\u00e1s ellen\u0151rz\u00e9se" + } + }, + "title": "Egyszeri Jelsz\u00f3 \u00c9rtes\u00edt\u00e9s" + }, "totp": { "error": { "invalid_code": "\u00c9rv\u00e9nytelen k\u00f3d, pr\u00f3b\u00e1ld \u00fajra. Ha ez a hiba folyamatosan el\u0151fordul, akkor gy\u0151z\u0151dj meg r\u00f3la, hogy a Home Assistant rendszered \u00f3r\u00e1ja pontosan j\u00e1r." diff --git a/homeassistant/components/auth/.translations/id.json b/homeassistant/components/auth/.translations/id.json new file mode 100644 index 000000000..f6a22386f --- /dev/null +++ b/homeassistant/components/auth/.translations/id.json @@ -0,0 +1,16 @@ +{ + "mfa_setup": { + "totp": { + "error": { + "invalid_code": "Kode salah, coba lagi. Jika Anda mendapatkan kesalahan ini secara konsisten, pastikan jam pada sistem Home Assistant anda akurat." + }, + "step": { + "init": { + "description": "Untuk mengaktifkan otentikasi dua faktor menggunakan password satu kali berbasis waktu, pindai kode QR dengan aplikasi otentikasi Anda. Jika Anda tidak memilikinya, kami menyarankan [Google Authenticator] (https://support.google.com/accounts/answer/1066447) atau [Authy] (https://authy.com/). \n\n {qr_code} \n \n Setelah memindai kode, masukkan kode enam digit dari aplikasi Anda untuk memverifikasi pengaturan. Jika Anda mengalami masalah saat memindai kode QR, lakukan pengaturan manual dengan kode ** ` {code} ` **.", + "title": "Siapkan otentikasi dua faktor menggunakan TOTP" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/auth/.translations/it.json b/homeassistant/components/auth/.translations/it.json index 869c3b438..dbfe4acd6 100644 --- a/homeassistant/components/auth/.translations/it.json +++ b/homeassistant/components/auth/.translations/it.json @@ -1,9 +1,31 @@ { "mfa_setup": { - "totp": { + "notify": { + "abort": { + "no_available_service": "Nessun servizio di notifica disponibile." + }, + "error": { + "invalid_code": "Codice non valido, per favore riprovare." + }, "step": { "init": { - "description": "Per attivare l'autenticazione a due fattori utilizzando password monouso basate sul tempo, eseguire la scansione del codice QR con l'app di autenticazione. Se non ne hai uno, ti consigliamo [Google Authenticator] (https://support.google.com/accounts/answer/1066447) o [Authy] (https://authy.com/). \n\n {qr_code} \n \n Dopo aver scansionato il codice, inserisci il codice a sei cifre dalla tua app per verificare la configurazione. Se riscontri problemi con la scansione del codice QR, esegui una configurazione manuale con codice ** ` {code} ` **.", + "description": "Selezionare uno dei servizi di notifica:", + "title": "Imposta la password monouso fornita dal componente di notifica" + }, + "setup": { + "description": "\u00c8 stata inviata una password monouso tramite **notify.{notify_service}**. Per favore, inseriscila qui sotto:", + "title": "Verifica l'installazione" + } + }, + "title": "Notifica la Password monouso" + }, + "totp": { + "error": { + "invalid_code": "Codice non valido, per favore riprovare. Se riscontri spesso questo errore, assicurati che l'orologio del sistema Home Assistant sia accurato." + }, + "step": { + "init": { + "description": "Per attivare l'autenticazione a due fattori utilizzando le password monouso basate sul tempo, eseguire la scansione del codice QR con l'app di autenticazione. Se non ne hai uno, ti consigliamo [Google Authenticator] (https://support.google.com/accounts/answer/1066447) o [Authy] (https://authy.com/). \n\n {qr_code} \n \nDopo la scansione, inserisci il codice a sei cifre dalla tua app per verificare la configurazione. Se riscontri problemi con la scansione del codice QR, esegui una configurazione manuale con il codice ** ` {code} ` **.", "title": "Imposta l'autenticazione a due fattori usando TOTP" } }, diff --git a/homeassistant/components/auth/.translations/ko.json b/homeassistant/components/auth/.translations/ko.json index 17fb5c56f..be160b185 100644 --- a/homeassistant/components/auth/.translations/ko.json +++ b/homeassistant/components/auth/.translations/ko.json @@ -1,13 +1,32 @@ { "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "\uc0ac\uc6a9 \uac00\ub2a5\ud55c \uc54c\ub9bc \uc11c\ube44\uc2a4\uac00 \uc5c6\uc2b5\ub2c8\ub2e4." + }, + "error": { + "invalid_code": "\uc798\ubabb\ub41c \ucf54\ub4dc\uc785\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694." + }, + "step": { + "init": { + "description": "\uc54c\ub9bc \uc11c\ube44\uc2a4 \uc911 \ud558\ub098\ub97c \uc120\ud0dd\ud574\uc8fc\uc138\uc694:", + "title": "\uc54c\ub9bc \uad6c\uc131\uc694\uc18c\uac00 \uc81c\uacf5\ud558\ub294 \uc77c\ud68c\uc6a9 \ube44\ubc00\ubc88\ud638 \uc124\uc815" + }, + "setup": { + "description": "**notify.{notify_service}** \uc5d0\uc11c \uc77c\ud68c\uc6a9 \ube44\ubc00\ubc88\ud638\ub97c \ubcf4\ub0c8\uc2b5\ub2c8\ub2e4. \uc544\ub798\uc758 \uacf5\ub780\uc5d0 \uc785\ub825\ud574\uc8fc\uc138\uc694:", + "title": "\uc124\uc815 \ud655\uc778" + } + }, + "title": "\uc77c\ud68c\uc6a9 \ube44\ubc00\ubc88\ud638 \uc54c\ub9bc" + }, "totp": { "error": { "invalid_code": "\uc798\ubabb\ub41c \ucf54\ub4dc \uc785\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694. \uc774 \uc624\ub958\uac00 \uc9c0\uc18d\uc801\uc73c\ub85c \ubc1c\uc0dd\ud55c\ub2e4\uba74 Home Assistant \uc758 \uc2dc\uac04\uc124\uc815\uc774 \uc62c\ubc14\ub978\uc9c0 \ud655\uc778\ud574\ubcf4\uc138\uc694." }, "step": { "init": { - "description": "\uc2dc\uac04 \uae30\ubc18\uc758 \uc77c\ud68c\uc6a9 \ube44\ubc00\ubc88\ud638\ub97c \uc0ac\uc6a9\ud558\ub294 2\ub2e8\uacc4 \uc778\uc99d\uc744 \ud558\ub824\uba74 \uc778\uc99d\uc6a9 \uc571\uc744 \uc774\uc6a9\ud574\uc11c QR \ucf54\ub4dc\ub97c \uc2a4\uce94\ud574 \uc8fc\uc138\uc694. \uc778\uc99d\uc6a9 \uc571\uc740 [Google OTP](https://support.google.com/accounts/answer/1066447) \ub610\ub294 [Authy](https://authy.com/) \ub97c \ucd94\ucc9c\ub4dc\ub9bd\ub2c8\ub2e4.\n\n{qr_code}\n\n\uc2a4\uce94 \ud6c4\uc5d0 \uc0dd\uc131\ub41c 6\uc790\ub9ac \ucf54\ub4dc\ub97c \uc785\ub825\ud574\uc11c \uc124\uc815\uc744 \ud655\uc778\ud558\uc138\uc694. QR \ucf54\ub4dc \uc2a4\uce94\uc5d0 \ubb38\uc81c\uac00 \uc788\ub2e4\uba74, **`{code}`** \ucf54\ub4dc\ub85c \uc9c1\uc811 \uc124\uc815\ud574\ubcf4\uc138\uc694.", - "title": "TOTP \ub97c \uc0ac\uc6a9\ud558\uc5ec 2 \ub2e8\uacc4 \uc778\uc99d \uad6c\uc131" + "description": "\uc2dc\uac04 \uae30\ubc18\uc758 \uc77c\ud68c\uc6a9 \ube44\ubc00\ubc88\ud638\ub97c \uc0ac\uc6a9\ud558\ub294 2\ub2e8\uacc4 \uc778\uc99d\uc744 \uad6c\uc131\ud558\ub824\uba74 \uc778\uc99d\uc6a9 \uc571\uc744 \uc774\uc6a9\ud574\uc11c QR \ucf54\ub4dc\ub97c \uc2a4\uce94\ud574\uc8fc\uc138\uc694. \uc778\uc99d\uc6a9 \uc571\uc740 [Google OTP](https://support.google.com/accounts/answer/1066447) \ub610\ub294 [Authy](https://authy.com/) \ub97c \ucd94\ucc9c\ub4dc\ub9bd\ub2c8\ub2e4.\n\n{qr_code}\n\n\uc2a4\uce94 \ud6c4\uc5d0 \uc0dd\uc131\ub41c 6\uc790\ub9ac \ucf54\ub4dc\ub97c \uc785\ub825\ud574\uc11c \uc124\uc815\uc744 \ud655\uc778\ud558\uc138\uc694. QR \ucf54\ub4dc \uc2a4\uce94\uc5d0 \ubb38\uc81c\uac00 \uc788\ub2e4\uba74, **`{code}`** \ucf54\ub4dc\ub85c \uc9c1\uc811 \uc124\uc815\ud574\ubcf4\uc138\uc694.", + "title": "TOTP 2\ub2e8\uacc4 \uc778\uc99d \uad6c\uc131" } }, "title": "TOTP (\uc2dc\uac04 \uae30\ubc18 OTP)" diff --git a/homeassistant/components/auth/.translations/lb.json b/homeassistant/components/auth/.translations/lb.json index f55ae4b97..12ced9304 100644 --- a/homeassistant/components/auth/.translations/lb.json +++ b/homeassistant/components/auth/.translations/lb.json @@ -1,5 +1,24 @@ { "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "Keen Notifikatioun's D\u00e9ngscht disponibel." + }, + "error": { + "invalid_code": "Ong\u00ebltege Code, prob\u00e9iert w.e.g. nach emol." + }, + "step": { + "init": { + "description": "Wielt w.e.g. een Notifikatioun's D\u00e9ngscht aus:", + "title": "Eemolegt Passwuert ariichte wat vun engem Notifikatioun's Komponente versch\u00e9ckt g\u00ebtt" + }, + "setup": { + "description": "Een eemolegt Passwuert ass vun **notify.{notify_service}** gesch\u00e9ckt ginn. Gitt et w.e.g hei \u00ebnnen dr\u00ebnner an:", + "title": "Astellungen iwwerpr\u00e9iwen" + } + }, + "title": "Eemolegt Passwuert Notifikatioun" + }, "totp": { "error": { "invalid_code": "Ong\u00ebltege Login, prob\u00e9iert w.e.g. nach emol. Falls d\u00ebse Feeler Message \u00ebmmer er\u00ebm optr\u00ebtt dann iwwerpr\u00e9ift op d'Z\u00e4it vum Home Assistant System richteg ass." diff --git a/homeassistant/components/auth/.translations/nl.json b/homeassistant/components/auth/.translations/nl.json index 40a873023..9ec800650 100644 --- a/homeassistant/components/auth/.translations/nl.json +++ b/homeassistant/components/auth/.translations/nl.json @@ -1,5 +1,24 @@ { "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "Geen meldingsservices beschikbaar." + }, + "error": { + "invalid_code": "Ongeldige code, probeer opnieuw." + }, + "step": { + "init": { + "description": "Selecteer een van de meldingsdiensten:", + "title": "Stel een \u00e9\u00e9nmalig wachtwoord in dat wordt afgegeven door een meldingscomponent" + }, + "setup": { + "description": "Een \u00e9\u00e9nmalig wachtwoord is verzonden via **notify. {notify_service}**. Voer het hieronder in:", + "title": "Controleer de instellingen" + } + }, + "title": "Eenmalig wachtwoord melden" + }, "totp": { "error": { "invalid_code": "Ongeldige code, probeer het opnieuw. Als u deze fout blijft krijgen, controleer dan of de klok van uw Home Assistant systeem correct is ingesteld." diff --git a/homeassistant/components/auth/.translations/nn.json b/homeassistant/components/auth/.translations/nn.json new file mode 100644 index 000000000..346c1cfe0 --- /dev/null +++ b/homeassistant/components/auth/.translations/nn.json @@ -0,0 +1,16 @@ +{ + "mfa_setup": { + "totp": { + "error": { + "invalid_code": "Ugyldig kode, pr\u00f8v igjen. Dersom du heile tida f\u00e5r denne feilen, m\u00e5 du s\u00f8rge for at klokka p\u00e5 Home Assistant-systemet ditt er n\u00f8yaktig." + }, + "step": { + "init": { + "description": "For \u00e5 aktivere to-faktor-autentisering ved hjelp av tid-baserte eingangspassord, skann QR-koden med autentiseringsappen din. Dersom du ikkje har ein, vil vi r\u00e5de deg til \u00e5 bruke anten [Google Authenticator] (https://support.google.com/accounts/answer/1066447) eller [Authy] (https://authy.com/). \n\n {qr_code} \n \nN\u00e5r du har skanna koda, skriv du inn den sekssifra koda fr\u00e5 appen din for \u00e5 stadfeste oppsettet. Dersom du har problemer med \u00e5 skanne QR-koda, gjer du eit manuelt oppsett med kode ** ` {code} ` **.", + "title": "Konfigurer to-faktor-autentisering ved hjelp av TOTP" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/auth/.translations/no.json b/homeassistant/components/auth/.translations/no.json index 43ec497cf..48b5db8a3 100644 --- a/homeassistant/components/auth/.translations/no.json +++ b/homeassistant/components/auth/.translations/no.json @@ -1,5 +1,24 @@ { "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "Ingen varslingstjenester er tilgjengelig." + }, + "error": { + "invalid_code": "Ugyldig kode, vennligst pr\u00f8v igjen." + }, + "step": { + "init": { + "description": "Vennligst velg en av varslingstjenestene:", + "title": "Sett opp engangspassord levert av varsel komponent" + }, + "setup": { + "description": "Et engangspassord har blitt sendt via **notify.{notify_service}**. Vennligst skriv det inn nedenfor:", + "title": "Bekreft oppsettet" + } + }, + "title": "Varsle engangspassord" + }, "totp": { "error": { "invalid_code": "Ugyldig kode, pr\u00f8v igjen. Hvis du f\u00e5r denne feilen konsekvent, m\u00e5 du s\u00f8rge for at klokken p\u00e5 Home Assistant systemet er riktig." diff --git a/homeassistant/components/auth/.translations/pl.json b/homeassistant/components/auth/.translations/pl.json index 78999c34c..78610a532 100644 --- a/homeassistant/components/auth/.translations/pl.json +++ b/homeassistant/components/auth/.translations/pl.json @@ -1,5 +1,24 @@ { "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "Brak dost\u0119pnych us\u0142ug powiadamiania." + }, + "error": { + "invalid_code": "Nieprawid\u0142owy kod, spr\u00f3buj ponownie." + }, + "step": { + "init": { + "description": "Prosz\u0119 wybra\u0107 jedn\u0105 us\u0142ug\u0119 powiadamiania:", + "title": "Skonfiguruj has\u0142o jednorazowe dostarczone przez komponent powiadomie\u0144" + }, + "setup": { + "description": "Has\u0142o jednorazowe zosta\u0142o wys\u0142ane przez **notify.{notify_service}**. Wprowad\u017a je poni\u017cej:", + "title": "Sprawd\u017a konfiguracj\u0119" + } + }, + "title": "Powiadomienie z has\u0142em jednorazowym" + }, "totp": { "error": { "invalid_code": "Nieprawid\u0142owy kod, spr\u00f3buj ponownie. Je\u015bli b\u0142\u0105d b\u0119dzie si\u0119 powtarza\u0142, upewnij si\u0119, \u017ce czas zegara systemu Home Assistant jest prawid\u0142owy." diff --git a/homeassistant/components/auth/.translations/pt-BR.json b/homeassistant/components/auth/.translations/pt-BR.json new file mode 100644 index 000000000..e08c27a32 --- /dev/null +++ b/homeassistant/components/auth/.translations/pt-BR.json @@ -0,0 +1,35 @@ +{ + "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "Nenhum servi\u00e7o de notifica\u00e7\u00e3o dispon\u00edvel." + }, + "error": { + "invalid_code": "C\u00f3digo inv\u00e1lido, por favor tente novamente." + }, + "step": { + "init": { + "description": "Por favor, selecione um dos servi\u00e7os de notifica\u00e7\u00e3o:", + "title": "Configurar a senha de uso \u00fanico entregue pelo componente de notifica\u00e7\u00e3o" + }, + "setup": { + "description": "A senha de uso \u00fanico foi enviada via ** notify. {notify_service} **. Por favor, insira abaixo:", + "title": "Verificar a configura\u00e7\u00e3o" + } + }, + "title": "Notificar a senha de uso \u00fanico" + }, + "totp": { + "error": { + "invalid_code": "C\u00f3digo inv\u00e1lido, por favor tente novamente. Se voc\u00ea obtiver este erro de forma consistente, certifique-se de que o rel\u00f3gio do sistema Home Assistant esteja correto." + }, + "step": { + "init": { + "description": "Para ativar a autentica\u00e7\u00e3o de dois fatores usando senhas de uso \u00fanico com base em tempo, digitalize o c\u00f3digo QR com seu aplicativo de autentica\u00e7\u00e3o. Se voc\u00ea n\u00e3o tiver um, recomendamos o [Google Authenticator] (https://support.google.com/accounts/answer/1066447) ou [Authy] (https://authy.com/). \n\n {qr_code} \n \n Depois de digitalizar o c\u00f3digo, insira o c\u00f3digo de seis d\u00edgitos do aplicativo para verificar a configura\u00e7\u00e3o. Se voc\u00ea tiver problemas para escanear o c\u00f3digo QR, fa\u00e7a uma configura\u00e7\u00e3o manual com o c\u00f3digo ** ` {code} ` **.", + "title": "Configure a autentica\u00e7\u00e3o de dois fatores usando o TOTP" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/auth/.translations/pt.json b/homeassistant/components/auth/.translations/pt.json index 474dbe488..e25fe3139 100644 --- a/homeassistant/components/auth/.translations/pt.json +++ b/homeassistant/components/auth/.translations/pt.json @@ -1,12 +1,31 @@ { "mfa_setup": { - "totp": { + "notify": { + "abort": { + "no_available_service": "Nenhum servi\u00e7o de notifica\u00e7\u00e3o dispon\u00edvel." + }, "error": { - "invalid_code": "C\u00f3digo inv\u00e1lido, por favor, tente novamente. Se receber este erro constantemente, por favor, certifique-se de que o rel\u00f3gio do sistema que hospeda o Home Assistent \u00e9 preciso." + "invalid_code": "C\u00f3digo inv\u00e1lido, por favor tente novamente." }, "step": { "init": { - "description": "Para ativar a autentica\u00e7\u00e3o com dois fatores utilizando passwords unicas temporais (OTP), ler o c\u00f3digo QR com a sua aplica\u00e7\u00e3o de autentica\u00e7\u00e3o. Se voc\u00ea n\u00e3o tiver uma, recomendamos [Google Authenticator](https://support.google.com/accounts/answer/1066447) ou [Authy](https://authy.com/).\n\n{qr_code}\n\nDepois de ler o c\u00f3digo, introduza o c\u00f3digo de seis d\u00edgitos fornecido pela sua aplica\u00e7\u00e3o para verificar a configura\u00e7\u00e3o. Se tiver problemas a ler o c\u00f3digo QR, fa\u00e7a uma configura\u00e7\u00e3o manual com o c\u00f3digo **`{c\u00f3digo}`**.", + "description": "Por favor, selecione um dos servi\u00e7os de notifica\u00e7\u00e3o:", + "title": "Configurar uma palavra-passe entregue pela componente de notifica\u00e7\u00e3o" + }, + "setup": { + "description": "Foi enviada uma palavra-passe atrav\u00e9s de **notify.{notify_service}**. Por favor, insira-a:", + "title": "Verificar a configura\u00e7\u00e3o" + } + }, + "title": "Notificar palavra-passe de uso \u00fanico" + }, + "totp": { + "error": { + "invalid_code": "C\u00f3digo inv\u00e1lido, por favor, tente novamente. Se receber este erro constantemente, por favor, certifique-se de que o rel\u00f3gio do sistema que hospeda o Home Assistant \u00e9 preciso." + }, + "step": { + "init": { + "description": "Para ativar a autentica\u00e7\u00e3o com dois fatores utilizando palavras-passe de uso \u00fanico (OTP), ler o c\u00f3digo QR com a sua aplica\u00e7\u00e3o de autentica\u00e7\u00e3o. Se n\u00e3o tiver uma, recomendamos [Google Authenticator](https://support.google.com/accounts/answer/1066447) ou [Authy](https://authy.com/).\n\n{qr_code}\n\nDepois de ler o c\u00f3digo, introduza o c\u00f3digo de seis d\u00edgitos fornecido pela sua aplica\u00e7\u00e3o para verificar a configura\u00e7\u00e3o. Se tiver problemas a ler o c\u00f3digo QR, fa\u00e7a uma configura\u00e7\u00e3o manual com o c\u00f3digo **`{code}`**.", "title": "Configurar autentica\u00e7\u00e3o com dois fatores usando TOTP" } }, diff --git a/homeassistant/components/auth/.translations/ro.json b/homeassistant/components/auth/.translations/ro.json new file mode 100644 index 000000000..19f9ec10c --- /dev/null +++ b/homeassistant/components/auth/.translations/ro.json @@ -0,0 +1,34 @@ +{ + "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "Nu sunt disponibile servicii de notificare." + }, + "error": { + "invalid_code": "Cod invalid, va rugam incercati din nou." + }, + "step": { + "init": { + "description": "Selecta\u021bi unul dintre serviciile de notificare:", + "title": "Configura\u021bi o parol\u0103 unic\u0103 livrat\u0103 de o component\u0103 de notificare" + }, + "setup": { + "description": "O parol\u0103 unic\u0103 a fost trimis\u0103 prin **notify.{notify_service}**. Introduce\u021bi parola mai jos:", + "title": "Verifica\u021bi configurarea" + } + }, + "title": "Notifica\u021bi o parol\u0103 unic\u0103" + }, + "totp": { + "error": { + "invalid_code": "Cod invalid, va rugam incercati din nou. Dac\u0103 primi\u021bi aceast\u0103 eroare \u00een mod consecvent, asigura\u021bi-v\u0103 c\u0103 ceasul sistemului dvs. Home Assistant este corect." + }, + "step": { + "init": { + "title": "Configura\u021bi autentificarea cu doi factori utiliz\u00e2nd TOTP" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/auth/.translations/ru.json b/homeassistant/components/auth/.translations/ru.json index a716425f3..5092e0792 100644 --- a/homeassistant/components/auth/.translations/ru.json +++ b/homeassistant/components/auth/.translations/ru.json @@ -1,12 +1,31 @@ { "mfa_setup": { - "totp": { + "notify": { + "abort": { + "no_available_service": "\u041d\u0435\u0442 \u0434\u043e\u0441\u0442\u0443\u043f\u043d\u044b\u0445 \u0441\u043b\u0443\u0436\u0431 \u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u0439." + }, "error": { - "invalid_code": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043a\u043e\u0434. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0441\u043d\u043e\u0432\u0430. \u0415\u0441\u043b\u0438 \u0432\u044b \u043f\u043e\u0441\u0442\u043e\u044f\u043d\u043d\u043e \u043f\u043e\u043b\u0443\u0447\u0430\u0435\u0442\u0435 \u044d\u0442\u0443 \u043e\u0448\u0438\u0431\u043a\u0443, \u043f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u0443\u0431\u0435\u0434\u0438\u0442\u0435\u0441\u044c, \u0447\u0442\u043e \u0447\u0430\u0441\u044b \u0432 \u0432\u0430\u0448\u0435\u0439 \u0441\u0438\u0441\u0442\u0435\u043c\u0435 Home Assistant \u043f\u043e\u043a\u0430\u0437\u044b\u0432\u0430\u044e\u0442 \u043f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u043e\u0435 \u0432\u0440\u0435\u043c\u044f." + "invalid_code": "\u041d\u0435\u043f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u044b\u0439 \u043a\u043e\u0434, \u043f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u0435 \u043f\u043e\u043f\u044b\u0442\u043a\u0443." }, "step": { "init": { - "description": "\u0427\u0442\u043e\u0431\u044b \u0430\u043a\u0442\u0438\u0432\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u0434\u0432\u0443\u0445\u0444\u0430\u043a\u0442\u043e\u0440\u043d\u0443\u044e \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044e \u0441 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043c \u043e\u0434\u043d\u043e\u0440\u0430\u0437\u043e\u0432\u044b\u0445 \u043f\u0430\u0440\u043e\u043b\u0435\u0439, \u043e\u0441\u043d\u043e\u0432\u0430\u043d\u043d\u044b\u0445 \u043d\u0430 \u0432\u0440\u0435\u043c\u0435\u043d\u0438, \u043e\u0442\u0441\u043a\u0430\u043d\u0438\u0440\u0443\u0439\u0442\u0435 QR-\u043a\u043e\u0434 \u0441 \u043f\u043e\u043c\u043e\u0449\u044c\u044e \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0434\u043b\u044f \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438 \u043f\u043e\u0434\u043b\u0438\u043d\u043d\u043e\u0441\u0442\u0438. \u0415\u0441\u043b\u0438 \u0443 \u0432\u0430\u0441 \u0435\u0433\u043e \u043d\u0435\u0442, \u043c\u044b \u0440\u0435\u043a\u043e\u043c\u0435\u043d\u0434\u0443\u0435\u043c \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c \u043b\u0438\u0431\u043e [Google Authenticator](https://support.google.com/accounts/answer/1066447), \u043b\u0438\u0431\u043e [Authy](https://authy.com/). \n\n {qr_code} \n \n\u041f\u043e\u0441\u043b\u0435 \u0441\u043a\u0430\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f QR-\u043a\u043e\u0434\u0430 \u0432\u0432\u0435\u0434\u0438\u0442\u0435 \u0448\u0435\u0441\u0442\u0438\u0437\u043d\u0430\u0447\u043d\u044b\u0439 \u043a\u043e\u0434 \u0438\u0437 \u0432\u0430\u0448\u0435\u0433\u043e \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f, \u0447\u0442\u043e\u0431\u044b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0443. \u0415\u0441\u043b\u0438 \u0443 \u0432\u0430\u0441 \u0435\u0441\u0442\u044c \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u044b \u0441\u043e \u0441\u043a\u0430\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435\u043c QR-\u043a\u043e\u0434\u0430, \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0443 \u0441 \u043f\u043e\u043c\u043e\u0449\u044c\u044e \u043a\u043e\u0434\u0430 **`{code}`**.", + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043e\u0434\u043d\u0443 \u0438\u0437 \u0441\u043b\u0443\u0436\u0431 \u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u0439:", + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0434\u043e\u0441\u0442\u0430\u0432\u043a\u0438 \u043e\u0434\u043d\u043e\u0440\u0430\u0437\u043e\u0432\u044b\u0445 \u043f\u0430\u0440\u043e\u043b\u0435\u0439 \u0447\u0435\u0440\u0435\u0437 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442 \u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u0439" + }, + "setup": { + "description": "\u041e\u0434\u043d\u043e\u0440\u0430\u0437\u043e\u0432\u044b\u0439 \u043f\u0430\u0440\u043e\u043b\u044c \u043e\u0442\u043f\u0440\u0430\u0432\u043b\u0435\u043d \u0447\u0435\u0440\u0435\u0437 **notify.{notify_service}**. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u0432\u0432\u0435\u0434\u0438\u0442\u0435 \u0435\u0433\u043e \u043d\u0438\u0436\u0435:", + "title": "\u041f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0443" + } + }, + "title": "\u0414\u043e\u0441\u0442\u0430\u0432\u043a\u0430 \u043e\u0434\u043d\u043e\u0440\u0430\u0437\u043e\u0432\u044b\u0445 \u043f\u0430\u0440\u043e\u043b\u0435\u0439" + }, + "totp": { + "error": { + "invalid_code": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043a\u043e\u0434. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0441\u043d\u043e\u0432\u0430. \u0415\u0441\u043b\u0438 \u0412\u044b \u043f\u043e\u0441\u0442\u043e\u044f\u043d\u043d\u043e \u043f\u043e\u043b\u0443\u0447\u0430\u0435\u0442\u0435 \u044d\u0442\u0443 \u043e\u0448\u0438\u0431\u043a\u0443, \u043f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u0443\u0431\u0435\u0434\u0438\u0442\u0435\u0441\u044c, \u0447\u0442\u043e \u0447\u0430\u0441\u044b \u0432 \u0412\u0430\u0448\u0435\u0439 \u0441\u0438\u0441\u0442\u0435\u043c\u0435 Home Assistant \u043f\u043e\u043a\u0430\u0437\u044b\u0432\u0430\u044e\u0442 \u043f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u043e\u0435 \u0432\u0440\u0435\u043c\u044f." + }, + "step": { + "init": { + "description": "\u0427\u0442\u043e\u0431\u044b \u0430\u043a\u0442\u0438\u0432\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u0434\u0432\u0443\u0445\u0444\u0430\u043a\u0442\u043e\u0440\u043d\u0443\u044e \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044e \u0441 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043c \u043e\u0434\u043d\u043e\u0440\u0430\u0437\u043e\u0432\u044b\u0445 \u043f\u0430\u0440\u043e\u043b\u0435\u0439, \u043e\u0441\u043d\u043e\u0432\u0430\u043d\u043d\u044b\u0445 \u043d\u0430 \u0432\u0440\u0435\u043c\u0435\u043d\u0438, \u043e\u0442\u0441\u043a\u0430\u043d\u0438\u0440\u0443\u0439\u0442\u0435 QR-\u043a\u043e\u0434 \u0441 \u043f\u043e\u043c\u043e\u0449\u044c\u044e \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0434\u043b\u044f \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438 \u043f\u043e\u0434\u043b\u0438\u043d\u043d\u043e\u0441\u0442\u0438. \u0415\u0441\u043b\u0438 \u0443 \u0412\u0430\u0441 \u0435\u0433\u043e \u043d\u0435\u0442, \u043c\u044b \u0440\u0435\u043a\u043e\u043c\u0435\u043d\u0434\u0443\u0435\u043c \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c \u043b\u0438\u0431\u043e [Google Authenticator](https://support.google.com/accounts/answer/1066447), \u043b\u0438\u0431\u043e [Authy](https://authy.com/). \n\n {qr_code} \n \n\u041f\u043e\u0441\u043b\u0435 \u0441\u043a\u0430\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f QR-\u043a\u043e\u0434\u0430 \u0432\u0432\u0435\u0434\u0438\u0442\u0435 \u0448\u0435\u0441\u0442\u0438\u0437\u043d\u0430\u0447\u043d\u044b\u0439 \u043a\u043e\u0434 \u0438\u0437 \u0412\u0430\u0448\u0435\u0433\u043e \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f, \u0447\u0442\u043e\u0431\u044b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0443. \u0415\u0441\u043b\u0438 \u0443 \u0412\u0430\u0441 \u0435\u0441\u0442\u044c \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u044b \u0441\u043e \u0441\u043a\u0430\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435\u043c QR-\u043a\u043e\u0434\u0430, \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0443 \u0441 \u043f\u043e\u043c\u043e\u0449\u044c\u044e \u043a\u043e\u0434\u0430 **`{code}`**.", "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0434\u0432\u0443\u0445\u0444\u0430\u043a\u0442\u043e\u0440\u043d\u043e\u0439 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438 \u0441 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043c TOTP" } }, diff --git a/homeassistant/components/auth/.translations/sl.json b/homeassistant/components/auth/.translations/sl.json index 45b57a772..f70bb81e7 100644 --- a/homeassistant/components/auth/.translations/sl.json +++ b/homeassistant/components/auth/.translations/sl.json @@ -1,8 +1,27 @@ { "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "Storitve obve\u0161\u010danja niso na voljo." + }, + "error": { + "invalid_code": "Neveljavna koda, poskusite znova." + }, + "step": { + "init": { + "description": "Izberite eno od storitev obve\u0161\u010danja:", + "title": "Nastavite enkratno geslo, ki ga dostavite z obvestilno komponento" + }, + "setup": { + "description": "Enkratno geslo je poslal **notify.{notify_service} **. Prosimo, vnesite ga spodaj:", + "title": "Preverite nastavitev" + } + }, + "title": "Obvesti Enkratno Geslo" + }, "totp": { "error": { - "invalid_code": "Neveljavna koda, prosimo, poskusite znova. \u010ce dobite to sporo\u010dilo ve\u010dkrat, prosimo poskrbite, da bo ura va\u0161ega Home Assistenta to\u010dna." + "invalid_code": "Neveljavna koda, prosimo, poskusite znova. \u010ce dobite to sporo\u010dilo ve\u010dkrat, prosimo poskrbite, da bo ura va\u0161ega Home Assistanta to\u010dna." }, "step": { "init": { diff --git a/homeassistant/components/auth/.translations/sv.json b/homeassistant/components/auth/.translations/sv.json index cf8227c09..9246a88c5 100644 --- a/homeassistant/components/auth/.translations/sv.json +++ b/homeassistant/components/auth/.translations/sv.json @@ -1,5 +1,24 @@ { "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "Inga tillg\u00e4ngliga meddelande tj\u00e4nster." + }, + "error": { + "invalid_code": "Ogiltig kod, var god f\u00f6rs\u00f6k igen." + }, + "step": { + "init": { + "description": "Var god v\u00e4lj en av notifieringstj\u00e4nsterna:", + "title": "Konfigurera ett eng\u00e5ngsl\u00f6senord levererat genom notifieringskomponenten" + }, + "setup": { + "description": "Ett eng\u00e5ngsl\u00f6senord har skickats av **notify.{notify_service}**. V\u00e4nligen ange det nedan:", + "title": "Verifiera inst\u00e4llningen" + } + }, + "title": "Meddela eng\u00e5ngsl\u00f6senord" + }, "totp": { "error": { "invalid_code": "Ogiltig kod, f\u00f6rs\u00f6k igen. Om du flera g\u00e5nger i rad f\u00e5r detta fel, se till att klockan i din Home Assistant \u00e4r korrekt inst\u00e4lld." diff --git a/homeassistant/components/auth/.translations/th.json b/homeassistant/components/auth/.translations/th.json new file mode 100644 index 000000000..735b7e2fa --- /dev/null +++ b/homeassistant/components/auth/.translations/th.json @@ -0,0 +1,11 @@ +{ + "mfa_setup": { + "notify": { + "step": { + "setup": { + "title": "\u0e15\u0e23\u0e27\u0e08\u0e2a\u0e2d\u0e1a\u0e01\u0e32\u0e23\u0e15\u0e34\u0e14\u0e15\u0e31\u0e49\u0e07" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/auth/.translations/uk.json b/homeassistant/components/auth/.translations/uk.json new file mode 100644 index 000000000..f82607507 --- /dev/null +++ b/homeassistant/components/auth/.translations/uk.json @@ -0,0 +1,14 @@ +{ + "mfa_setup": { + "notify": { + "error": { + "invalid_code": "\u041d\u0435\u0432\u0456\u0440\u043d\u0438\u0439 \u043a\u043e\u0434, \u0441\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0449\u0435 \u0440\u0430\u0437." + }, + "step": { + "setup": { + "title": "\u041f\u0435\u0440\u0435\u0432\u0456\u0440\u0442\u0435 \u043d\u0430\u043b\u0430\u0448\u0442\u0443\u0432\u0430\u043d\u043d\u044f" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/auth/.translations/vi.json b/homeassistant/components/auth/.translations/vi.json new file mode 100644 index 000000000..02ac69bb9 --- /dev/null +++ b/homeassistant/components/auth/.translations/vi.json @@ -0,0 +1,16 @@ +{ + "mfa_setup": { + "totp": { + "error": { + "invalid_code": "M\u00e3 kh\u00f4ng h\u1ee3p l\u1ec7, vui l\u00f2ng th\u1eed l\u1ea1i. N\u1ebfu b\u1ea1n g\u1eb7p l\u1ed7i n\u00e0y m\u1ed9t c\u00e1ch nh\u1ea5t qu\u00e1n, vui l\u00f2ng \u0111\u1ea3m b\u1ea3o \u0111\u1ed3ng h\u1ed3 c\u1ee7a h\u1ec7 th\u1ed1ng Home Assistant l\u00e0 ch\u00ednh x\u00e1c." + }, + "step": { + "init": { + "description": "\u0110\u1ec3 k\u00edch ho\u1ea1t x\u00e1c th\u1ef1c hai y\u1ebfu t\u1ed1 b\u1eb1ng m\u1eadt kh\u1ea9u m\u1ed9t l\u1ea7n d\u1ef1a tr\u00ean th\u1eddi gian, h\u00e3y qu\u00e9t m\u00e3 QR b\u1eb1ng \u1ee9ng d\u1ee5ng x\u00e1c th\u1ef1c c\u1ee7a b\u1ea1n. N\u1ebfu b\u1ea1n kh\u00f4ng c\u00f3, ch\u00fang t\u00f4i khuy\u00ean b\u1ea1n n\u00ean d\u00f9ng [Google Authenticator] (https://support.google.com/accounts/answer/1066447) ho\u1eb7c [Authy] (https://authy.com/). \n\n {qr_code} \n \n Sau khi qu\u00e9t m\u00e3, nh\u1eadp m\u00e3 s\u00e1u ch\u1eef s\u1ed1 t\u1eeb \u1ee9ng d\u1ee5ng c\u1ee7a b\u1ea1n \u0111\u1ec3 x\u00e1c minh thi\u1ebft l\u1eadp. N\u1ebfu b\u1ea1n g\u1eb7p v\u1ea5n \u0111\u1ec1 khi qu\u00e9t m\u00e3 QR, h\u00e3y th\u1ef1c hi\u1ec7n c\u00e0i \u0111\u1eb7t th\u1ee7 c\u00f4ng v\u1edbi m\u00e3 ** ` {code} ` **.", + "title": "Thi\u1ebft l\u1eadp x\u00e1c th\u1ef1c hai y\u1ebfu t\u1ed1 b\u1eb1ng TOTP" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/auth/.translations/zh-Hans.json b/homeassistant/components/auth/.translations/zh-Hans.json index c5b397a8e..1cb311f01 100644 --- a/homeassistant/components/auth/.translations/zh-Hans.json +++ b/homeassistant/components/auth/.translations/zh-Hans.json @@ -1,5 +1,24 @@ { "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "\u6ca1\u6709\u53ef\u7528\u7684\u901a\u77e5\u670d\u52a1\u3002" + }, + "error": { + "invalid_code": "\u4ee3\u7801\u65e0\u6548\uff0c\u8bf7\u518d\u8bd5\u4e00\u6b21\u3002" + }, + "step": { + "init": { + "description": "\u8bf7\u4ece\u4e0b\u9762\u9009\u62e9\u4e00\u4e2a\u901a\u77e5\u670d\u52a1\uff1a", + "title": "\u8bbe\u7f6e\u7531\u901a\u77e5\u7ec4\u4ef6\u4f20\u9012\u7684\u4e00\u6b21\u6027\u5bc6\u7801" + }, + "setup": { + "description": "\u4e00\u6b21\u6027\u5bc6\u7801\u5df2\u7531 **notify.{notify_service}** \u53d1\u9001\u3002\u8bf7\u5728\u4e0b\u9762\u8f93\u5165\uff1a", + "title": "\u9a8c\u8bc1\u8bbe\u7f6e" + } + }, + "title": "\u4e00\u6b21\u6027\u5bc6\u7801\u901a\u77e5" + }, "totp": { "error": { "invalid_code": "\u53e3\u4ee4\u65e0\u6548\uff0c\u8bf7\u91cd\u65b0\u8f93\u5165\u3002\u5982\u679c\u9519\u8bef\u53cd\u590d\u51fa\u73b0\uff0c\u8bf7\u786e\u4fdd Home Assistant \u7cfb\u7edf\u7684\u65f6\u95f4\u51c6\u786e\u65e0\u8bef\u3002" diff --git a/homeassistant/components/auth/.translations/zh-Hant.json b/homeassistant/components/auth/.translations/zh-Hant.json index ef41ea872..b7a26f507 100644 --- a/homeassistant/components/auth/.translations/zh-Hant.json +++ b/homeassistant/components/auth/.translations/zh-Hant.json @@ -1,12 +1,31 @@ { "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "\u6c92\u6709\u53ef\u7528\u7684\u901a\u77e5\u670d\u52d9\u3002" + }, + "error": { + "invalid_code": "\u9a57\u8b49\u78bc\u7121\u6548\uff0c\u8acb\u518d\u8a66\u4e00\u6b21\u3002" + }, + "step": { + "init": { + "description": "\u8acb\u9078\u64c7\u4e00\u9805\u901a\u77e5\u670d\u52d9\uff1a", + "title": "\u8a2d\u5b9a\u4e00\u6b21\u6027\u5bc6\u78bc\u50b3\u9001\u7d44\u4ef6" + }, + "setup": { + "description": "\u4e00\u6b21\u6027\u5bc6\u78bc\u5df2\u900f\u904e **notify.{notify_service}** \u50b3\u9001\u3002\u8acb\u65bc\u4e0b\u65b9\u8f38\u5165\uff1a", + "title": "\u9a57\u8b49\u8a2d\u5b9a" + } + }, + "title": "\u901a\u77e5\u4e00\u6b21\u6027\u5bc6\u78bc" + }, "totp": { "error": { "invalid_code": "\u9a57\u8b49\u78bc\u7121\u6548\uff0c\u8acb\u518d\u8a66\u4e00\u6b21\u3002\u5047\u5982\u932f\u8aa4\u6301\u7e8c\u767c\u751f\uff0c\u8acb\u5148\u78ba\u5b9a\u60a8\u7684 Home Assistant \u7cfb\u7d71\u4e0a\u7684\u6642\u9593\u8a2d\u5b9a\u6b63\u78ba\u5f8c\uff0c\u518d\u8a66\u4e00\u6b21\u3002" }, "step": { "init": { - "description": "\u6b32\u555f\u7528\u4e00\u6b21\u6027\u4e14\u5177\u6642\u6548\u6027\u7684\u5bc6\u78bc\u4e4b\u5169\u6b65\u9a5f\u9a57\u8b49\u529f\u80fd\uff0c\u8acb\u4f7f\u7528\u60a8\u7684\u9a57\u8b49 App \u6383\u7784\u4e0b\u65b9\u7684 QR code \u3002\u5018\u82e5\u60a8\u5c1a\u672a\u5b89\u88dd\u4efb\u4f55 App\uff0c\u63a8\u85a6\u60a8\u4f7f\u7528 [Google Authenticator](https://support.google.com/accounts/answer/1066447) \u6216 [Authy](https://authy.com/)\u3002\n\n{qr_code}\n\n\u65bc\u6383\u63cf\u4e4b\u5f8c\uff0c\u8f38\u5165 App \u4e2d\u7684\u516d\u4f4d\u6578\u5b57\u9032\u884c\u8a2d\u5b9a\u9a57\u8b49\u3002\u5047\u5982\u6383\u63cf\u51fa\u73fe\u554f\u984c\uff0c\u8acb\u624b\u52d5\u8f38\u5165\u4ee5\u4e0b\u9a57\u8b49\u78bc **`{code}`**\u3002", + "description": "\u6b32\u555f\u7528\u4e00\u6b21\u6027\u4e14\u5177\u6642\u6548\u6027\u7684\u5bc6\u78bc\u4e4b\u5169\u6b65\u9a5f\u9a57\u8b49\u529f\u80fd\uff0c\u8acb\u4f7f\u7528\u60a8\u7684\u9a57\u8b49 App \u6383\u7784\u4e0b\u65b9\u7684 QR code \u3002\u5018\u82e5\u60a8\u5c1a\u672a\u5b89\u88dd\u4efb\u4f55 App\uff0c\u63a8\u85a6\u60a8\u4f7f\u7528 [Google Authenticator](https://support.google.com/accounts/answer/1066447) \u6216 [Authy](https://authy.com/)\u3002\n\n{qr_code}\n\n\u65bc\u641c\u5c0b\u4e4b\u5f8c\uff0c\u8f38\u5165 App \u4e2d\u7684\u516d\u4f4d\u6578\u5b57\u9032\u884c\u8a2d\u5b9a\u9a57\u8b49\u3002\u5047\u5982\u641c\u5c0b\u51fa\u73fe\u554f\u984c\uff0c\u8acb\u624b\u52d5\u8f38\u5165\u4ee5\u4e0b\u9a57\u8b49\u78bc **`{code}`**\u3002", "title": "\u4f7f\u7528 TOTP \u8a2d\u5b9a\u5169\u6b65\u9a5f\u9a57\u8b49" } }, diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py index bee72d8e4..888ef98a5 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -78,20 +78,16 @@ The result payload likes "result": { "id": "USER_ID", "name": "John Doe", - "is_owner': true, - "credentials": [ - { - "auth_provider_type": "homeassistant", - "auth_provider_id": null - } - ], - "mfa_modules": [ - { - "id": "totp", - "name": "TOTP", - "enabled": true, - } - ] + "is_owner": true, + "credentials": [{ + "auth_provider_type": "homeassistant", + "auth_provider_id": null + }], + "mfa_modules": [{ + "id": "totp", + "name": "TOTP", + "enabled": true + }] } } @@ -105,7 +101,6 @@ Home Assistant. User need to record the token in secure place. "id": 11, "type": "auth/long_lived_access_token", "client_name": "GPS Logger", - "client_icon": null, "lifespan": 365 } @@ -119,88 +114,107 @@ Result will be a long-lived access token: } """ +from datetime import timedelta import logging import uuid -from datetime import timedelta from aiohttp import web import voluptuous as vol -from homeassistant.auth.models import User, Credentials, \ - TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN +from homeassistant.auth.models import ( + TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN, + Credentials, + User, +) from homeassistant.components import websocket_api from homeassistant.components.http import KEY_REAL_IP +from homeassistant.components.http.auth import async_sign_path from homeassistant.components.http.ban import log_invalid_auth from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.view import HomeAssistantView -from homeassistant.core import callback, HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.loader import bind_hass from homeassistant.util import dt as dt_util -from . import indieauth -from . import login_flow -from . import mfa_setup_flow +from . import indieauth, login_flow, mfa_setup_flow -DOMAIN = 'auth' -DEPENDENCIES = ['http'] +DOMAIN = "auth" +WS_TYPE_CURRENT_USER = "auth/current_user" +SCHEMA_WS_CURRENT_USER = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( + {vol.Required("type"): WS_TYPE_CURRENT_USER} +) -WS_TYPE_CURRENT_USER = 'auth/current_user' -SCHEMA_WS_CURRENT_USER = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required('type'): WS_TYPE_CURRENT_USER, -}) +WS_TYPE_LONG_LIVED_ACCESS_TOKEN = "auth/long_lived_access_token" +SCHEMA_WS_LONG_LIVED_ACCESS_TOKEN = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( + { + vol.Required("type"): WS_TYPE_LONG_LIVED_ACCESS_TOKEN, + vol.Required("lifespan"): int, # days + vol.Required("client_name"): str, + vol.Optional("client_icon"): str, + } +) -WS_TYPE_LONG_LIVED_ACCESS_TOKEN = 'auth/long_lived_access_token' -SCHEMA_WS_LONG_LIVED_ACCESS_TOKEN = \ - websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required('type'): WS_TYPE_LONG_LIVED_ACCESS_TOKEN, - vol.Required('lifespan'): int, # days - vol.Required('client_name'): str, - vol.Optional('client_icon'): str, - }) +WS_TYPE_REFRESH_TOKENS = "auth/refresh_tokens" +SCHEMA_WS_REFRESH_TOKENS = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( + {vol.Required("type"): WS_TYPE_REFRESH_TOKENS} +) -WS_TYPE_REFRESH_TOKENS = 'auth/refresh_tokens' -SCHEMA_WS_REFRESH_TOKENS = \ - websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required('type'): WS_TYPE_REFRESH_TOKENS, - }) +WS_TYPE_DELETE_REFRESH_TOKEN = "auth/delete_refresh_token" +SCHEMA_WS_DELETE_REFRESH_TOKEN = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( + { + vol.Required("type"): WS_TYPE_DELETE_REFRESH_TOKEN, + vol.Required("refresh_token_id"): str, + } +) -WS_TYPE_DELETE_REFRESH_TOKEN = 'auth/delete_refresh_token' -SCHEMA_WS_DELETE_REFRESH_TOKEN = \ - websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required('type'): WS_TYPE_DELETE_REFRESH_TOKEN, - vol.Required('refresh_token_id'): str, - }) +WS_TYPE_SIGN_PATH = "auth/sign_path" +SCHEMA_WS_SIGN_PATH = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( + { + vol.Required("type"): WS_TYPE_SIGN_PATH, + vol.Required("path"): str, + vol.Optional("expires", default=30): int, + } +) -RESULT_TYPE_CREDENTIALS = 'credentials' -RESULT_TYPE_USER = 'user' +RESULT_TYPE_CREDENTIALS = "credentials" +RESULT_TYPE_USER = "user" _LOGGER = logging.getLogger(__name__) +@bind_hass +def create_auth_code(hass, client_id: str, user: User) -> str: + """Create an authorization code to fetch tokens.""" + return hass.data[DOMAIN](client_id, user) + + async def async_setup(hass, config): """Component to allow users to login.""" store_result, retrieve_result = _create_auth_code_store() + hass.data[DOMAIN] = store_result + hass.http.register_view(TokenView(retrieve_result)) hass.http.register_view(LinkUserView(retrieve_result)) hass.components.websocket_api.async_register_command( - WS_TYPE_CURRENT_USER, websocket_current_user, - SCHEMA_WS_CURRENT_USER + WS_TYPE_CURRENT_USER, websocket_current_user, SCHEMA_WS_CURRENT_USER ) hass.components.websocket_api.async_register_command( WS_TYPE_LONG_LIVED_ACCESS_TOKEN, websocket_create_long_lived_access_token, - SCHEMA_WS_LONG_LIVED_ACCESS_TOKEN + SCHEMA_WS_LONG_LIVED_ACCESS_TOKEN, ) hass.components.websocket_api.async_register_command( - WS_TYPE_REFRESH_TOKENS, - websocket_refresh_tokens, - SCHEMA_WS_REFRESH_TOKENS + WS_TYPE_REFRESH_TOKENS, websocket_refresh_tokens, SCHEMA_WS_REFRESH_TOKENS ) hass.components.websocket_api.async_register_command( WS_TYPE_DELETE_REFRESH_TOKEN, websocket_delete_refresh_token, - SCHEMA_WS_DELETE_REFRESH_TOKEN + SCHEMA_WS_DELETE_REFRESH_TOKEN, + ) + hass.components.websocket_api.async_register_command( + WS_TYPE_SIGN_PATH, websocket_sign_path, SCHEMA_WS_SIGN_PATH ) await login_flow.async_setup(hass, store_result) @@ -212,8 +226,8 @@ async def async_setup(hass, config): class TokenView(HomeAssistantView): """View to issue or revoke tokens.""" - url = '/auth/token' - name = 'api:auth:token' + url = "/auth/token" + name = "api:auth:token" requires_auth = False cors_allowed = True @@ -224,29 +238,29 @@ class TokenView(HomeAssistantView): @log_invalid_auth async def post(self, request): """Grant a token.""" - hass = request.app['hass'] + hass = request.app["hass"] data = await request.post() - grant_type = data.get('grant_type') + grant_type = data.get("grant_type") # IndieAuth 6.3.5 # The revocation endpoint is the same as the token endpoint. # The revocation request includes an additional parameter, # action=revoke. - if data.get('action') == 'revoke': + if data.get("action") == "revoke": return await self._async_handle_revoke_token(hass, data) - if grant_type == 'authorization_code': + if grant_type == "authorization_code": return await self._async_handle_auth_code( - hass, data, str(request[KEY_REAL_IP])) + hass, data, str(request[KEY_REAL_IP]) + ) - if grant_type == 'refresh_token': + if grant_type == "refresh_token": return await self._async_handle_refresh_token( - hass, data, str(request[KEY_REAL_IP])) + hass, data, str(request[KEY_REAL_IP]) + ) - return self.json({ - 'error': 'unsupported_grant_type', - }, status_code=400) + return self.json({"error": "unsupported_grant_type"}, status_code=400) async def _async_handle_revoke_token(self, hass, data): """Handle revoke token request.""" @@ -254,7 +268,7 @@ class TokenView(HomeAssistantView): # 2.2 The authorization server responds with HTTP status code 200 # if the token has been revoked successfully or if the client # submitted an invalid token. - token = data.get('token') + token = data.get("token") if token is None: return web.Response(status=200) @@ -269,117 +283,112 @@ class TokenView(HomeAssistantView): async def _async_handle_auth_code(self, hass, data, remote_addr): """Handle authorization code request.""" - client_id = data.get('client_id') + client_id = data.get("client_id") if client_id is None or not indieauth.verify_client_id(client_id): - return self.json({ - 'error': 'invalid_request', - 'error_description': 'Invalid client id', - }, status_code=400) + return self.json( + {"error": "invalid_request", "error_description": "Invalid client id"}, + status_code=400, + ) - code = data.get('code') + code = data.get("code") if code is None: - return self.json({ - 'error': 'invalid_request', - 'error_description': 'Invalid code', - }, status_code=400) + return self.json( + {"error": "invalid_request", "error_description": "Invalid code"}, + status_code=400, + ) user = self._retrieve_user(client_id, RESULT_TYPE_USER, code) if user is None or not isinstance(user, User): - return self.json({ - 'error': 'invalid_request', - 'error_description': 'Invalid code', - }, status_code=400) + return self.json( + {"error": "invalid_request", "error_description": "Invalid code"}, + status_code=400, + ) # refresh user user = await hass.auth.async_get_user(user.id) if not user.is_active: - return self.json({ - 'error': 'access_denied', - 'error_description': 'User is not active', - }, status_code=403) + return self.json( + {"error": "access_denied", "error_description": "User is not active"}, + status_code=403, + ) - refresh_token = await hass.auth.async_create_refresh_token(user, - client_id) - access_token = hass.auth.async_create_access_token( - refresh_token, remote_addr) + refresh_token = await hass.auth.async_create_refresh_token(user, client_id) + access_token = hass.auth.async_create_access_token(refresh_token, remote_addr) - return self.json({ - 'access_token': access_token, - 'token_type': 'Bearer', - 'refresh_token': refresh_token.token, - 'expires_in': - int(refresh_token.access_token_expiration.total_seconds()), - }) + return self.json( + { + "access_token": access_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, data, remote_addr): """Handle authorization code request.""" - client_id = data.get('client_id') + client_id = data.get("client_id") if client_id is not None and not indieauth.verify_client_id(client_id): - return self.json({ - 'error': 'invalid_request', - 'error_description': 'Invalid client id', - }, status_code=400) + return self.json( + {"error": "invalid_request", "error_description": "Invalid client id"}, + status_code=400, + ) - token = data.get('refresh_token') + token = data.get("refresh_token") if token is None: - return self.json({ - 'error': 'invalid_request', - }, status_code=400) + return self.json({"error": "invalid_request"}, status_code=400) refresh_token = await hass.auth.async_get_refresh_token_by_token(token) if refresh_token is None: - return self.json({ - 'error': 'invalid_grant', - }, status_code=400) + return self.json({"error": "invalid_grant"}, status_code=400) if refresh_token.client_id != client_id: - return self.json({ - 'error': 'invalid_request', - }, status_code=400) + return self.json({"error": "invalid_request"}, status_code=400) - access_token = hass.auth.async_create_access_token( - refresh_token, remote_addr) + access_token = hass.auth.async_create_access_token(refresh_token, remote_addr) - return self.json({ - 'access_token': access_token, - 'token_type': 'Bearer', - 'expires_in': - int(refresh_token.access_token_expiration.total_seconds()), - }) + return self.json( + { + "access_token": access_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' + 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, - })) + @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'] + hass = request.app["hass"] + user = request["hass_user"] credentials = self._retrieve_credentials( - data['client_id'], RESULT_TYPE_CREDENTIALS, data['code']) + data["client_id"], RESULT_TYPE_CREDENTIALS, data["code"] + ) if credentials is None: - return self.json_message('Invalid code', status_code=400) + return self.json_message("Invalid code", status_code=400) await hass.auth.async_link_user(user, credentials) - return self.json_message('User linked') + return self.json_message("User linked") @callback @@ -395,11 +404,14 @@ def _create_auth_code_store(): elif isinstance(result, Credentials): result_type = RESULT_TYPE_CREDENTIALS else: - raise ValueError('result has to be either User or Credentials') + raise ValueError("result has to be either User or Credentials") code = uuid.uuid4().hex - temp_results[(client_id, result_type, code)] = \ - (dt_util.utcnow(), result_type, result) + temp_results[(client_id, result_type, code)] = ( + dt_util.utcnow(), + result_type, + result, + ) return code @callback @@ -425,92 +437,123 @@ def _create_auth_code_store(): @websocket_api.ws_require_user() -@callback -def websocket_current_user( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg): +@websocket_api.async_response +async def websocket_current_user( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg +): """Return the current user.""" - async def async_get_current_user(user): - """Get current user.""" - enabled_modules = await hass.auth.async_get_enabled_mfa(user) + user = connection.user + enabled_modules = await hass.auth.async_get_enabled_mfa(user) - connection.send_message_outside( - websocket_api.result_message(msg['id'], { - 'id': user.id, - 'name': user.name, - 'is_owner': user.is_owner, - 'credentials': [{'auth_provider_type': c.auth_provider_type, - 'auth_provider_id': c.auth_provider_id} - for c in user.credentials], - 'mfa_modules': [{ - 'id': module.id, - 'name': module.name, - 'enabled': module.id in enabled_modules, - } for module in hass.auth.auth_mfa_modules], - })) - - hass.async_create_task(async_get_current_user(connection.user)) + connection.send_message( + websocket_api.result_message( + msg["id"], + { + "id": user.id, + "name": user.name, + "is_owner": user.is_owner, + "is_admin": user.is_admin, + "credentials": [ + { + "auth_provider_type": c.auth_provider_type, + "auth_provider_id": c.auth_provider_id, + } + for c in user.credentials + ], + "mfa_modules": [ + { + "id": module.id, + "name": module.name, + "enabled": module.id in enabled_modules, + } + for module in hass.auth.auth_mfa_modules + ], + }, + ) + ) @websocket_api.ws_require_user() -@callback -def websocket_create_long_lived_access_token( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg): +@websocket_api.async_response +async def websocket_create_long_lived_access_token( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg +): """Create or a long-lived access token.""" - async def async_create_long_lived_access_token(user): - """Create or a long-lived access token.""" - refresh_token = await hass.auth.async_create_refresh_token( - user, - client_name=msg['client_name'], - client_icon=msg.get('client_icon'), - token_type=TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN, - access_token_expiration=timedelta(days=msg['lifespan'])) + refresh_token = await hass.auth.async_create_refresh_token( + connection.user, + client_name=msg["client_name"], + client_icon=msg.get("client_icon"), + token_type=TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN, + access_token_expiration=timedelta(days=msg["lifespan"]), + ) - access_token = hass.auth.async_create_access_token( - refresh_token) + access_token = hass.auth.async_create_access_token(refresh_token) - connection.send_message_outside( - websocket_api.result_message(msg['id'], access_token)) - - hass.async_create_task( - async_create_long_lived_access_token(connection.user)) + connection.send_message(websocket_api.result_message(msg["id"], access_token)) @websocket_api.ws_require_user() @callback def websocket_refresh_tokens( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg): + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg +): """Return metadata of users refresh tokens.""" - current_id = connection.request.get('refresh_token_id') - connection.to_write.put_nowait(websocket_api.result_message(msg['id'], [{ - 'id': refresh.id, - 'client_id': refresh.client_id, - 'client_name': refresh.client_name, - 'client_icon': refresh.client_icon, - 'type': refresh.token_type, - 'created_at': refresh.created_at, - 'is_current': refresh.id == current_id, - 'last_used_at': refresh.last_used_at, - 'last_used_ip': refresh.last_used_ip, - } for refresh in connection.user.refresh_tokens.values()])) + current_id = connection.refresh_token_id + connection.send_message( + websocket_api.result_message( + msg["id"], + [ + { + "id": refresh.id, + "client_id": refresh.client_id, + "client_name": refresh.client_name, + "client_icon": refresh.client_icon, + "type": refresh.token_type, + "created_at": refresh.created_at, + "is_current": refresh.id == current_id, + "last_used_at": refresh.last_used_at, + "last_used_ip": refresh.last_used_ip, + } + for refresh in connection.user.refresh_tokens.values() + ], + ) + ) + + +@websocket_api.ws_require_user() +@websocket_api.async_response +async def websocket_delete_refresh_token( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg +): + """Handle a delete refresh token request.""" + refresh_token = connection.user.refresh_tokens.get(msg["refresh_token_id"]) + + if refresh_token is None: + return websocket_api.error_message( + msg["id"], "invalid_token_id", "Received invalid token" + ) + + await hass.auth.async_remove_refresh_token(refresh_token) + + connection.send_message(websocket_api.result_message(msg["id"], {})) @websocket_api.ws_require_user() @callback -def websocket_delete_refresh_token( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg): - """Handle a delete refresh token request.""" - async def async_delete_refresh_token(user, refresh_token_id): - """Delete a refresh token.""" - refresh_token = connection.user.refresh_tokens.get(refresh_token_id) - - if refresh_token is None: - return websocket_api.error_message( - msg['id'], 'invalid_token_id', 'Received invalid token') - - await hass.auth.async_remove_refresh_token(refresh_token) - - connection.send_message_outside( - websocket_api.result_message(msg['id'], {})) - - hass.async_create_task( - async_delete_refresh_token(connection.user, msg['refresh_token_id'])) +def websocket_sign_path( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg +): + """Handle a sign path request.""" + connection.send_message( + websocket_api.result_message( + msg["id"], + { + "path": async_sign_path( + hass, + connection.refresh_token_id, + msg["path"], + timedelta(seconds=msg["expires"]), + ) + }, + ) + ) diff --git a/homeassistant/components/auth/indieauth.py b/homeassistant/components/auth/indieauth.py index bcf73258f..c845f230b 100644 --- a/homeassistant/components/auth/indieauth.py +++ b/homeassistant/components/auth/indieauth.py @@ -1,24 +1,15 @@ """Helpers to resolve client ID/secret.""" import asyncio from html.parser import HTMLParser -from ipaddress import ip_address, ip_network -from urllib.parse import urlparse, urljoin +from ipaddress import ip_address +import logging +from urllib.parse import urljoin, urlparse import aiohttp -from aiohttp.client_exceptions import ClientError -# IP addresses of loopback interfaces -ALLOWED_IPS = ( - ip_address('127.0.0.1'), - ip_address('::1'), -) +from homeassistant.util.network import is_local -# RFC1918 - Address allocation for Private Internets -ALLOWED_NETWORKS = ( - ip_network('10.0.0.0/8'), - ip_network('172.16.0.0/12'), - ip_network('192.168.0.0/16'), -) +_LOGGER = logging.getLogger(__name__) async def verify_redirect_uri(hass, client_id, redirect_uri): @@ -32,8 +23,8 @@ async def verify_redirect_uri(hass, client_id, redirect_uri): # Verify redirect url and client url have same scheme and domain. is_valid = ( - client_id_parts.scheme == redirect_parts.scheme and - client_id_parts.netloc == redirect_parts.netloc + client_id_parts.scheme == redirect_parts.scheme + and client_id_parts.netloc == redirect_parts.netloc ) if is_valid: @@ -56,13 +47,13 @@ class LinkTagParser(HTMLParser): def handle_starttag(self, tag, attrs): """Handle finding a start tag.""" - if tag != 'link': + if tag != "link": return attrs = dict(attrs) - if attrs.get('rel') == self.rel: - self.found.append(attrs.get('href')) + if attrs.get("rel") == self.rel: + self.found.append(attrs.get("href")) async def fetch_redirect_uris(hass, url): @@ -77,7 +68,7 @@ async def fetch_redirect_uris(hass, url): We do not implement extracting redirect uris from headers. """ - parser = LinkTagParser('redirect_uri') + parser = LinkTagParser("redirect_uri") chunks = 0 try: async with aiohttp.ClientSession() as session: @@ -89,7 +80,22 @@ async def fetch_redirect_uris(hass, url): if chunks == 10: break - except (asyncio.TimeoutError, ClientError): + except asyncio.TimeoutError: + _LOGGER.error("Timeout while looking up redirect_uri %s", url) + pass + except aiohttp.client_exceptions.ClientSSLError: + _LOGGER.error("SSL error while looking up redirect_uri %s", url) + pass + except aiohttp.client_exceptions.ClientOSError as ex: + _LOGGER.error("OS error while looking up redirect_uri %s: %s", url, ex.strerror) + pass + except aiohttp.client_exceptions.ClientConnectionError: + _LOGGER.error( + ("Low level connection error while looking up " "redirect_uri %s"), url + ) + pass + except aiohttp.client_exceptions.ClientError: + _LOGGER.error("Unknown error while looking up redirect_uri %s", url) pass # Authorization endpoints verifying that a redirect_uri is allowed for use @@ -119,8 +125,8 @@ def _parse_url(url): # If a URL with no path component is ever encountered, # it MUST be treated as if it had the path /. - if parts.path == '': - parts = parts._replace(path='/') + if parts.path == "": + parts = parts._replace(path="/") return parts @@ -134,34 +140,35 @@ def _parse_client_id(client_id): # Client identifier URLs # MUST have either an https or http scheme - if parts.scheme not in ('http', 'https'): + if parts.scheme not in ("http", "https"): raise ValueError() # MUST contain a path component # Handled by url canonicalization. # MUST NOT contain single-dot or double-dot path segments - if any(segment in ('.', '..') for segment in parts.path.split('/')): + if any(segment in (".", "..") for segment in parts.path.split("/")): raise ValueError( - 'Client ID cannot contain single-dot or double-dot path segments') + "Client ID cannot contain single-dot or double-dot path segments" + ) # MUST NOT contain a fragment component - if parts.fragment != '': - raise ValueError('Client ID cannot contain a fragment') + if parts.fragment != "": + raise ValueError("Client ID cannot contain a fragment") # MUST NOT contain a username or password component if parts.username is not None: - raise ValueError('Client ID cannot contain username') + raise ValueError("Client ID cannot contain username") if parts.password is not None: - raise ValueError('Client ID cannot contain password') + raise ValueError("Client ID cannot contain password") # MAY contain a port try: # parts raises ValueError when port cannot be parsed as int parts.port except ValueError: - raise ValueError('Client ID contains invalid port') + raise ValueError("Client ID contains invalid port") # Additionally, hostnames # MUST be domain names or a loopback interface and @@ -177,7 +184,7 @@ def _parse_client_id(client_id): netloc = parts.netloc # Strip the [, ] from ipv6 addresses before parsing - if netloc[0] == '[' and netloc[-1] == ']': + if netloc[0] == "[" and netloc[-1] == "]": netloc = netloc[1:-1] address = ip_address(netloc) @@ -185,9 +192,7 @@ def _parse_client_id(client_id): # Not an ip address pass - if (address is None or - address in ALLOWED_IPS or - any(address in network for network in ALLOWED_NETWORKS)): + if address is None or is_local(address): return parts - raise ValueError('Hostname should be a domain name or local IP address') + raise ValueError("Hostname should be a domain name or local IP address") diff --git a/homeassistant/components/auth/login_flow.py b/homeassistant/components/auth/login_flow.py index 73a739c29..6f8d27510 100644 --- a/homeassistant/components/auth/login_flow.py +++ b/homeassistant/components/auth/login_flow.py @@ -68,69 +68,71 @@ associate with an credential if "type" set to "link_user" in """ from aiohttp import web import voluptuous as vol +import voluptuous_serialize from homeassistant import data_entry_flow from homeassistant.components.http import KEY_REAL_IP -from homeassistant.components.http.ban import process_wrong_login, \ - log_invalid_auth +from homeassistant.components.http.ban import ( + log_invalid_auth, + process_success_login, + process_wrong_login, +) from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.view import HomeAssistantView + from . import indieauth async def async_setup(hass, store_result): """Component to allow users to login.""" hass.http.register_view(AuthProvidersView) - hass.http.register_view(LoginFlowIndexView(hass.auth.login_flow)) - hass.http.register_view( - LoginFlowResourceView(hass.auth.login_flow, store_result)) + hass.http.register_view(LoginFlowIndexView(hass.auth.login_flow, store_result)) + hass.http.register_view(LoginFlowResourceView(hass.auth.login_flow, store_result)) class AuthProvidersView(HomeAssistantView): """View to get available auth providers.""" - url = '/auth/providers' - name = 'api:auth:providers' + url = "/auth/providers" + name = "api:auth:providers" requires_auth = False async def get(self, request): """Get available auth providers.""" - hass = request.app['hass'] - - if not hass.components.onboarding.async_is_onboarded(): + hass = request.app["hass"] + if not hass.components.onboarding.async_is_user_onboarded(): return self.json_message( - message='Onboarding not finished', + message="Onboarding not finished", status_code=400, - message_code='onboarding_required' + message_code="onboarding_required", ) - return self.json([{ - 'name': provider.name, - 'id': provider.id, - 'type': provider.type, - } for provider in hass.auth.auth_providers]) + return self.json( + [ + {"name": provider.name, "id": provider.id, "type": provider.type} + for provider in hass.auth.auth_providers + ] + ) def _prepare_result_json(result): """Convert result to JSON.""" - if result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY: + if result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY: data = result.copy() - data.pop('result') - data.pop('data') + data.pop("result") + data.pop("data") return data - if result['type'] != data_entry_flow.RESULT_TYPE_FORM: + if result["type"] != data_entry_flow.RESULT_TYPE_FORM: return result - import voluptuous_serialize - data = result.copy() - schema = data['data_schema'] + schema = data["data_schema"] if schema is None: - data['data_schema'] = [] + data["data_schema"] = [] else: - data['data_schema'] = voluptuous_serialize.convert(schema) + data["data_schema"] = voluptuous_serialize.convert(schema) return data @@ -138,46 +140,60 @@ def _prepare_result_json(result): class LoginFlowIndexView(HomeAssistantView): """View to create a config flow.""" - url = '/auth/login_flow' - name = 'api:auth:login_flow' + url = "/auth/login_flow" + name = "api:auth:login_flow" requires_auth = False - def __init__(self, flow_mgr): + def __init__(self, flow_mgr, store_result): """Initialize the flow manager index view.""" self._flow_mgr = flow_mgr + self._store_result = store_result async def get(self, request): """Do not allow index of flows in progress.""" return web.Response(status=405) - @RequestDataValidator(vol.Schema({ - vol.Required('client_id'): str, - vol.Required('handler'): vol.Any(str, list), - vol.Required('redirect_uri'): str, - vol.Optional('type', default='authorize'): str, - })) + @RequestDataValidator( + vol.Schema( + { + vol.Required("client_id"): str, + vol.Required("handler"): vol.Any(str, list), + vol.Required("redirect_uri"): str, + vol.Optional("type", default="authorize"): str, + } + ) + ) @log_invalid_auth async def post(self, request, data): """Create a new login flow.""" if not await indieauth.verify_redirect_uri( - request.app['hass'], data['client_id'], data['redirect_uri']): - return self.json_message('invalid client id or redirect uri', 400) + request.app["hass"], data["client_id"], data["redirect_uri"] + ): + return self.json_message("invalid client id or redirect uri", 400) - if isinstance(data['handler'], list): - handler = tuple(data['handler']) + if isinstance(data["handler"], list): + handler = tuple(data["handler"]) else: - handler = data['handler'] + handler = data["handler"] try: result = await self._flow_mgr.async_init( - handler, context={ - 'ip_address': request[KEY_REAL_IP], - 'credential_only': data.get('type') == 'link_user', - }) + handler, + context={ + "ip_address": request[KEY_REAL_IP], + "credential_only": data.get("type") == "link_user", + }, + ) except data_entry_flow.UnknownHandler: - return self.json_message('Invalid handler specified', 404) + return self.json_message("Invalid handler specified", 404) except data_entry_flow.UnknownStep: - return self.json_message('Handler does not support init', 400) + return self.json_message("Handler does not support init", 400) + + if result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY: + await process_success_login(request) + result.pop("data") + result["result"] = self._store_result(data["client_id"], result["result"]) + return self.json(result) return self.json(_prepare_result_json(result)) @@ -185,8 +201,8 @@ class LoginFlowIndexView(HomeAssistantView): class LoginFlowResourceView(HomeAssistantView): """View to interact with the flow manager.""" - url = '/auth/login_flow/{flow_id}' - name = 'api:auth:login_flow:resource' + url = "/auth/login_flow/{flow_id}" + name = "api:auth:login_flow:resource" requires_auth = False def __init__(self, flow_mgr, store_result): @@ -196,43 +212,43 @@ class LoginFlowResourceView(HomeAssistantView): async def get(self, request): """Do not allow getting status of a flow in progress.""" - return self.json_message('Invalid flow specified', 404) + return self.json_message("Invalid flow specified", 404) - @RequestDataValidator(vol.Schema({ - 'client_id': str - }, extra=vol.ALLOW_EXTRA)) + @RequestDataValidator(vol.Schema({"client_id": str}, extra=vol.ALLOW_EXTRA)) @log_invalid_auth async def post(self, request, flow_id, data): """Handle progressing a login flow request.""" - client_id = data.pop('client_id') + client_id = data.pop("client_id") if not indieauth.verify_client_id(client_id): - return self.json_message('Invalid client id', 400) + return self.json_message("Invalid client id", 400) try: # do not allow change ip during login flow for flow in self._flow_mgr.async_progress(): - if (flow['flow_id'] == flow_id and - flow['context']['ip_address'] != - request.get(KEY_REAL_IP)): - return self.json_message('IP address changed', 400) + if flow["flow_id"] == flow_id and flow["context"][ + "ip_address" + ] != request.get(KEY_REAL_IP): + return self.json_message("IP address changed", 400) result = await self._flow_mgr.async_configure(flow_id, data) except data_entry_flow.UnknownFlow: - return self.json_message('Invalid flow specified', 404) + return self.json_message("Invalid flow specified", 404) except vol.Invalid: - return self.json_message('User input malformed', 400) + return self.json_message("User input malformed", 400) - if result['type'] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY: + if result["type"] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY: # @log_invalid_auth does not work here since it returns HTTP 200 # need manually log failed login attempts - if result['errors'] is not None and \ - result['errors'].get('base') == 'invalid_auth': + if result.get("errors") is not None and result["errors"].get("base") in [ + "invalid_auth", + "invalid_code", + ]: await process_wrong_login(request) return self.json(_prepare_result_json(result)) - result.pop('data') - result['result'] = self._store_result(client_id, result['result']) + result.pop("data") + result["result"] = self._store_result(client_id, result["result"]) return self.json(result) @@ -241,6 +257,6 @@ class LoginFlowResourceView(HomeAssistantView): try: self._flow_mgr.async_abort(flow_id) except data_entry_flow.UnknownFlow: - return self.json_message('Invalid flow specified', 404) + return self.json_message("Invalid flow specified", 404) - return self.json_message('Flow aborted') + return self.json_message("Flow aborted") diff --git a/homeassistant/components/auth/manifest.json b/homeassistant/components/auth/manifest.json new file mode 100644 index 000000000..2f3e724b5 --- /dev/null +++ b/homeassistant/components/auth/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "auth", + "name": "Auth", + "documentation": "https://www.home-assistant.io/integrations/auth", + "requirements": [], + "dependencies": ["http"], + "after_dependencies": ["onboarding"], + "codeowners": ["@home-assistant/core"] +} diff --git a/homeassistant/components/auth/mfa_setup_flow.py b/homeassistant/components/auth/mfa_setup_flow.py index 82eb913d8..92926e2e7 100644 --- a/homeassistant/components/auth/mfa_setup_flow.py +++ b/homeassistant/components/auth/mfa_setup_flow.py @@ -2,87 +2,97 @@ import logging import voluptuous as vol +import voluptuous_serialize from homeassistant import data_entry_flow from homeassistant.components import websocket_api -from homeassistant.core import callback, HomeAssistant +from homeassistant.core import HomeAssistant, callback -WS_TYPE_SETUP_MFA = 'auth/setup_mfa' -SCHEMA_WS_SETUP_MFA = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required('type'): WS_TYPE_SETUP_MFA, - vol.Exclusive('mfa_module_id', 'module_or_flow_id'): str, - vol.Exclusive('flow_id', 'module_or_flow_id'): str, - vol.Optional('user_input'): object, -}) +WS_TYPE_SETUP_MFA = "auth/setup_mfa" +SCHEMA_WS_SETUP_MFA = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( + { + vol.Required("type"): WS_TYPE_SETUP_MFA, + vol.Exclusive("mfa_module_id", "module_or_flow_id"): str, + vol.Exclusive("flow_id", "module_or_flow_id"): str, + vol.Optional("user_input"): object, + } +) -WS_TYPE_DEPOSE_MFA = 'auth/depose_mfa' -SCHEMA_WS_DEPOSE_MFA = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required('type'): WS_TYPE_DEPOSE_MFA, - vol.Required('mfa_module_id'): str, -}) +WS_TYPE_DEPOSE_MFA = "auth/depose_mfa" +SCHEMA_WS_DEPOSE_MFA = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( + {vol.Required("type"): WS_TYPE_DEPOSE_MFA, vol.Required("mfa_module_id"): str} +) -DATA_SETUP_FLOW_MGR = 'auth_mfa_setup_flow_manager' +DATA_SETUP_FLOW_MGR = "auth_mfa_setup_flow_manager" _LOGGER = logging.getLogger(__name__) async def async_setup(hass): """Init mfa setup flow manager.""" + async def _async_create_setup_flow(handler, context, data): - """Create a setup flow. hanlder is a mfa module.""" + """Create a setup flow. handler is a mfa module.""" mfa_module = hass.auth.get_auth_mfa_module(handler) if mfa_module is None: - raise ValueError('Mfa module {} is not found'.format(handler)) + raise ValueError(f"Mfa module {handler} is not found") - user_id = data.pop('user_id') + user_id = data.pop("user_id") return await mfa_module.async_setup_flow(user_id) async def _async_finish_setup_flow(flow, flow_result): - _LOGGER.debug('flow_result: %s', flow_result) + _LOGGER.debug("flow_result: %s", flow_result) return flow_result hass.data[DATA_SETUP_FLOW_MGR] = data_entry_flow.FlowManager( - hass, _async_create_setup_flow, _async_finish_setup_flow) + hass, _async_create_setup_flow, _async_finish_setup_flow + ) hass.components.websocket_api.async_register_command( - WS_TYPE_SETUP_MFA, websocket_setup_mfa, SCHEMA_WS_SETUP_MFA) + WS_TYPE_SETUP_MFA, websocket_setup_mfa, SCHEMA_WS_SETUP_MFA + ) hass.components.websocket_api.async_register_command( - WS_TYPE_DEPOSE_MFA, websocket_depose_mfa, SCHEMA_WS_DEPOSE_MFA) + WS_TYPE_DEPOSE_MFA, websocket_depose_mfa, SCHEMA_WS_DEPOSE_MFA + ) @callback @websocket_api.ws_require_user(allow_system_user=False) def websocket_setup_mfa( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg): + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg +): """Return a setup flow for mfa auth module.""" + async def async_setup_flow(msg): """Return a setup flow for mfa auth module.""" flow_manager = hass.data[DATA_SETUP_FLOW_MGR] - flow_id = msg.get('flow_id') + flow_id = msg.get("flow_id") if flow_id is not None: - result = await flow_manager.async_configure( - flow_id, msg.get('user_input')) - connection.send_message_outside( - websocket_api.result_message( - msg['id'], _prepare_result_json(result))) + result = await flow_manager.async_configure(flow_id, msg.get("user_input")) + connection.send_message( + websocket_api.result_message(msg["id"], _prepare_result_json(result)) + ) return - mfa_module_id = msg.get('mfa_module_id') + mfa_module_id = msg.get("mfa_module_id") mfa_module = hass.auth.get_auth_mfa_module(mfa_module_id) if mfa_module is None: - connection.send_message_outside(websocket_api.error_message( - msg['id'], 'no_module', - 'MFA module {} is not found'.format(mfa_module_id))) + connection.send_message( + websocket_api.error_message( + msg["id"], "no_module", f"MFA module {mfa_module_id} is not found" + ) + ) return result = await flow_manager.async_init( - mfa_module_id, data={'user_id': connection.user.id}) + mfa_module_id, data={"user_id": connection.user.id} + ) - connection.send_message_outside( - websocket_api.result_message( - msg['id'], _prepare_result_json(result))) + connection.send_message( + websocket_api.result_message(msg["id"], _prepare_result_json(result)) + ) hass.async_create_task(async_setup_flow(msg)) @@ -90,45 +100,47 @@ def websocket_setup_mfa( @callback @websocket_api.ws_require_user(allow_system_user=False) def websocket_depose_mfa( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg): + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg +): """Remove user from mfa module.""" + async def async_depose(msg): """Remove user from mfa auth module.""" - mfa_module_id = msg['mfa_module_id'] + mfa_module_id = msg["mfa_module_id"] try: await hass.auth.async_disable_user_mfa( - connection.user, msg['mfa_module_id']) + connection.user, msg["mfa_module_id"] + ) except ValueError as err: - connection.send_message_outside(websocket_api.error_message( - msg['id'], 'disable_failed', - 'Cannot disable MFA Module {}: {}'.format( - mfa_module_id, err))) + connection.send_message( + websocket_api.error_message( + msg["id"], + "disable_failed", + f"Cannot disable MFA Module {mfa_module_id}: {err}", + ) + ) return - connection.send_message_outside( - websocket_api.result_message( - msg['id'], 'done')) + connection.send_message(websocket_api.result_message(msg["id"], "done")) hass.async_create_task(async_depose(msg)) def _prepare_result_json(result): """Convert result to JSON.""" - if result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY: + if result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY: data = result.copy() return data - if result['type'] != data_entry_flow.RESULT_TYPE_FORM: + if result["type"] != data_entry_flow.RESULT_TYPE_FORM: return result - import voluptuous_serialize - data = result.copy() - schema = data['data_schema'] + schema = data["data_schema"] if schema is None: - data['data_schema'] = [] + data["data_schema"] = [] else: - data['data_schema'] = voluptuous_serialize.convert(schema) + data["data_schema"] = voluptuous_serialize.convert(schema) return data diff --git a/homeassistant/components/auth/strings.json b/homeassistant/components/auth/strings.json index b0083ab57..57f5ed659 100644 --- a/homeassistant/components/auth/strings.json +++ b/homeassistant/components/auth/strings.json @@ -11,6 +11,25 @@ "error": { "invalid_code": "Invalid code, please try again. If you get this error consistently, please make sure the clock of your Home Assistant system is accurate." } + }, + "notify": { + "title": "Notify One-Time Password", + "step": { + "init": { + "title": "Set up one-time password delivered by notify component", + "description": "Please select one of the notification services:" + }, + "setup": { + "title": "Verify setup", + "description": "A one-time password has been sent via **notify.{notify_service}**. Please enter it below:" + } + }, + "abort": { + "no_available_service": "No notification services available." + }, + "error": { + "invalid_code": "Invalid code, please try again." + } } } } diff --git a/homeassistant/components/automatic/__init__.py b/homeassistant/components/automatic/__init__.py new file mode 100644 index 000000000..8a1cae16f --- /dev/null +++ b/homeassistant/components/automatic/__init__.py @@ -0,0 +1 @@ +"""The automatic component.""" diff --git a/homeassistant/components/automatic/device_tracker.py b/homeassistant/components/automatic/device_tracker.py new file mode 100644 index 000000000..bb4036879 --- /dev/null +++ b/homeassistant/components/automatic/device_tracker.py @@ -0,0 +1,362 @@ +"""Support for the Automatic platform.""" +import asyncio +from datetime import timedelta +import json +import logging +import os + +import aioautomatic +from aiohttp import web +import voluptuous as vol + +from homeassistant.components.device_tracker import ( + 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 +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 + +_LOGGER = logging.getLogger(__name__) + +ATTR_FUEL_LEVEL = "fuel_level" +AUTOMATIC_CONFIG_FILE = ".automatic/session-{}.json" + +CONF_CLIENT_ID = "client_id" +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 +EVENT_AUTOMATIC_UPDATE = "automatic_update" + +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): vol.All(cv.ensure_list, [cv.string]), + } +) + + +def _get_refresh_token_from_file(hass, filename): + """Attempt to load session data from file.""" + path = hass.config.path(filename) + + if not os.path.isfile(path): + return None + + try: + with open(path) as data_file: + data = json.load(data_file) + if data is None: + return None + + return data.get(DATA_REFRESH_TOKEN) + except ValueError: + return None + + +def _write_refresh_token_to_file(hass, filename, refresh_token): + """Attempt to store session data to file.""" + path = hass.config.path(filename) + + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w+") as data_file: + json.dump({DATA_REFRESH_TOKEN: refresh_token}, data_file) + + +@asyncio.coroutine +def async_setup_scanner(hass, config, async_see, discovery_info=None): + """Validate the configuration and return an Automatic scanner.""" + + hass.http.register_view(AutomaticAuthCallbackView()) + + scope = FULL_SCOPE if config.get(CONF_CURRENT_LOCATION) else DEFAULT_SCOPE + + client = aioautomatic.Client( + client_id=config[CONF_CLIENT_ID], + client_secret=config[CONF_SECRET], + client_session=async_get_clientsession(hass), + request_kwargs={"timeout": DEFAULT_TIMEOUT}, + ) + + filename = AUTOMATIC_CONFIG_FILE.format(config[CONF_CLIENT_ID]) + refresh_token = yield from hass.async_add_job( + _get_refresh_token_from_file, hass, filename + ) + + @asyncio.coroutine + def initialize_data(session): + """Initialize the AutomaticData object from the created session.""" + hass.async_add_job( + _write_refresh_token_to_file, hass, filename, session.refresh_token + ) + data = AutomaticData(hass, client, session, config.get(CONF_DEVICES), async_see) + + # Load the initial vehicle data + vehicles = yield from session.get_vehicles() + for vehicle in vehicles: + hass.async_create_task(data.load_vehicle(vehicle)) + + # Create a task instead of adding a tracking job, since this task will + # run until the websocket connection is closed. + hass.loop.create_task(data.ws_connect()) + + if refresh_token is not None: + try: + session = yield from client.create_session_from_refresh_token(refresh_token) + yield from initialize_data(session) + return True + except aioautomatic.exceptions.AutomaticError as err: + _LOGGER.error(str(err)) + + configurator = hass.components.configurator + request_id = configurator.async_request_config( + "Automatic", + description=("Authorization required for Automatic device tracker."), + link_name="Click here to authorize Home Assistant.", + link_url=client.generate_oauth_url(scope), + entity_picture="/static/images/logo_automatic.png", + ) + + @asyncio.coroutine + def initialize_callback(code, state): + """Call after OAuth2 response is returned.""" + try: + session = yield from client.create_session_from_oauth_code(code, state) + yield from initialize_data(session) + configurator.async_request_done(request_id) + except aioautomatic.exceptions.AutomaticError as err: + _LOGGER.error(str(err)) + configurator.async_notify_errors(request_id, str(err)) + return False + + if DATA_CONFIGURING not in hass.data: + hass.data[DATA_CONFIGURING] = {} + + hass.data[DATA_CONFIGURING][client.state] = initialize_callback + return True + + +class AutomaticAuthCallbackView(HomeAssistantView): + """Handle OAuth finish callback requests.""" + + requires_auth = False + url = "/api/automatic/callback" + name = "api:automatic:callback" + + @callback + def get(self, request): # pylint: disable=no-self-use + """Finish OAuth callback request.""" + hass = request.app["hass"] + params = request.query + response = web.HTTPFound("/states") + + if "state" not in params or "code" not in params: + if "error" in params: + _LOGGER.error("Error authorizing Automatic: %s", params["error"]) + 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") + return response + + code = params["code"] + state = params["state"] + initialize_callback = hass.data[DATA_CONFIGURING][state] + hass.async_create_task(initialize_callback(code, state)) + + return response + + +class AutomaticData: + """A class representing an Automatic cloud service connection.""" + + def __init__(self, hass, client, session, devices, async_see): + """Initialize the automatic device scanner.""" + self.hass = hass + self.devices = devices + self.vehicle_info = {} + self.vehicle_seen = {} + self.client = client + self.session = session + self.async_see = async_see + self.ws_reconnect_handle = None + self.ws_close_requested = False + + self.client.on_app_event( + lambda name, event: self.hass.async_create_task( + self.handle_event(name, event) + ) + ) + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.ws_close()) + + @asyncio.coroutine + def handle_event(self, name, event): + """Coroutine to update state for a real time 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") + try: + vehicle = yield from event.get_vehicle() + except aioautomatic.exceptions.AutomaticError as err: + _LOGGER.error(str(err)) + return + yield from self.get_vehicle_info(vehicle) + + 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, + self.vehicle_seen[event.vehicle.id], + ) + return + self.vehicle_seen[event.vehicle.id] = event.created_at + + kwargs = self.vehicle_info[event.vehicle.id] + if kwargs is None: + # Ignored device + return + + # If this is a vehicle status report, update the fuel level + if name == "vehicle:status_report": + fuel_level = event.vehicle.fuel_level_percent + if fuel_level is not None: + kwargs[ATTR_ATTRIBUTES][ATTR_FUEL_LEVEL] = fuel_level + + # Send the device seen notification + if event.location is not None: + kwargs[ATTR_GPS] = (event.location.lat, event.location.lon) + kwargs[ATTR_GPS_ACCURACY] = event.location.accuracy_m + + yield from self.async_see(**kwargs) + + @asyncio.coroutine + def ws_connect(self, now=None): + """Open the websocket connection.""" + + self.ws_close_requested = False + + if self.ws_reconnect_handle is not None: + _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" + ) + return + except aioautomatic.exceptions.AutomaticError as err: + if self.ws_reconnect_handle is None: + # Show log error and retry connection every 5 minutes + _LOGGER.error("Error opening websocket connection: %s", err) + self.ws_reconnect_handle = async_track_time_interval( + self.hass, self.ws_connect, timedelta(minutes=5) + ) + return + + if self.ws_reconnect_handle is not None: + self.ws_reconnect_handle() + self.ws_reconnect_handle = None + + _LOGGER.info("Websocket connected") + + try: + yield from ws_loop_future + except aioautomatic.exceptions.AutomaticError as err: + _LOGGER.error(str(err)) + + _LOGGER.info("Websocket closed") + + # If websocket was close was not requested, attempt to reconnect + if not self.ws_close_requested: + self.hass.loop.create_task(self.ws_connect()) + + @asyncio.coroutine + def ws_close(self): + """Close the websocket connection.""" + self.ws_close_requested = True + if self.ws_reconnect_handle is not None: + self.ws_reconnect_handle() + self.ws_reconnect_handle = None + + yield from self.client.ws_close() + + @asyncio.coroutine + def load_vehicle(self, vehicle): + """Load the vehicle's initial state and update hass.""" + kwargs = yield from self.get_vehicle_info(vehicle) + yield from self.async_see(**kwargs) + + @asyncio.coroutine + def get_vehicle_info(self, vehicle): + """Fetch the latest vehicle info from automatic.""" + + name = vehicle.display_name + if name is None: + name = " ".join( + filter(None, (str(vehicle.year), vehicle.make, vehicle.model)) + ) + + if self.devices is not None and name not in self.devices: + self.vehicle_info[vehicle.id] = None + return + + self.vehicle_info[vehicle.id] = kwargs = { + ATTR_DEV_ID: vehicle.id, + ATTR_HOST_NAME: name, + ATTR_MAC: vehicle.id, + ATTR_ATTRIBUTES: {ATTR_FUEL_LEVEL: vehicle.fuel_level_percent}, + } + self.vehicle_seen[vehicle.id] = vehicle.updated_at or vehicle.created_at + + if vehicle.latest_location is not None: + location = vehicle.latest_location + kwargs[ATTR_GPS] = (location.lat, location.lon) + kwargs[ATTR_GPS_ACCURACY] = location.accuracy_m + return kwargs + + trips = [] + try: + # Get the most recent trip for this vehicle + trips = yield from self.session.get_trips(vehicle=vehicle.id, limit=1) + except aioautomatic.exceptions.AutomaticError as err: + _LOGGER.error(str(err)) + + if trips: + location = trips[0].end_location + kwargs[ATTR_GPS] = (location.lat, location.lon) + kwargs[ATTR_GPS_ACCURACY] = location.accuracy_m + + if trips[0].ended_at >= self.vehicle_seen[vehicle.id]: + self.vehicle_seen[vehicle.id] = trips[0].ended_at + + return kwargs diff --git a/homeassistant/components/automatic/manifest.json b/homeassistant/components/automatic/manifest.json new file mode 100644 index 000000000..63cf0da0f --- /dev/null +++ b/homeassistant/components/automatic/manifest.json @@ -0,0 +1,15 @@ +{ + "domain": "automatic", + "name": "Automatic", + "documentation": "https://www.home-assistant.io/integrations/automatic", + "requirements": [ + "aioautomatic==0.6.5" + ], + "dependencies": [ + "configurator", + "http" + ], + "codeowners": [ + "@armills" + ] +} diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 43fd4cedb..4441b0285 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -1,69 +1,81 @@ -""" -Allow to set up simple automation rules via the config file. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/automation/ -""" +"""Allow to set up simple automation rules via the config file.""" import asyncio from functools import partial import importlib import logging +from typing import Any, Awaitable, Callable 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.const import ( - ATTR_ENTITY_ID, CONF_PLATFORM, STATE_ON, SERVICE_TURN_ON, SERVICE_TURN_OFF, - SERVICE_TOGGLE, SERVICE_RELOAD, EVENT_HOMEASSISTANT_START, CONF_ID) -from homeassistant.components import logbook + ATTR_ENTITY_ID, + ATTR_NAME, + CONF_ID, + CONF_PLATFORM, + EVENT_AUTOMATION_TRIGGERED, + EVENT_HOMEASSISTANT_START, + SERVICE_RELOAD, + SERVICE_TOGGLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_ON, +) +from homeassistant.core import Context, CoreState, HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import extract_domain_configs, script, condition +from homeassistant.helpers import condition, extract_domain_configs, script +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.config_validation import make_entity_service_schema 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.util.dt import utcnow -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.service import async_register_admin_service +from homeassistant.helpers.typing import TemplateVarsType +from homeassistant.loader import bind_hass +from homeassistant.util.dt import parse_datetime, utcnow -DOMAIN = 'automation' -DEPENDENCIES = ['group'] -ENTITY_ID_FORMAT = DOMAIN + '.{}' +# mypy: allow-untyped-calls, allow-untyped-defs +# mypy: no-check-untyped-defs, no-warn-return-any -GROUP_NAME_ALL_AUTOMATIONS = 'all automations' +DOMAIN = "automation" +ENTITY_ID_FORMAT = DOMAIN + ".{}" -CONF_ALIAS = 'alias' -CONF_HIDE_ENTITY = 'hide_entity' +GROUP_NAME_ALL_AUTOMATIONS = "all automations" -CONF_CONDITION = 'condition' -CONF_ACTION = 'action' -CONF_TRIGGER = 'trigger' -CONF_CONDITION_TYPE = 'condition_type' -CONF_INITIAL_STATE = 'initial_state' +CONF_ALIAS = "alias" +CONF_DESCRIPTION = "description" +CONF_HIDE_ENTITY = "hide_entity" -CONDITION_USE_TRIGGER_VALUES = 'use_trigger_values' -CONDITION_TYPE_AND = 'and' -CONDITION_TYPE_OR = 'or' +CONF_CONDITION = "condition" +CONF_ACTION = "action" +CONF_TRIGGER = "trigger" +CONF_CONDITION_TYPE = "condition_type" +CONF_INITIAL_STATE = "initial_state" + +CONDITION_USE_TRIGGER_VALUES = "use_trigger_values" +CONDITION_TYPE_AND = "and" +CONDITION_TYPE_OR = "or" DEFAULT_CONDITION_TYPE = CONDITION_TYPE_AND DEFAULT_HIDE_ENTITY = False DEFAULT_INITIAL_STATE = True -ATTR_LAST_TRIGGERED = 'last_triggered' -ATTR_VARIABLES = 'variables' -SERVICE_TRIGGER = 'trigger' +ATTR_LAST_TRIGGERED = "last_triggered" +ATTR_VARIABLES = "variables" +SERVICE_TRIGGER = "trigger" _LOGGER = logging.getLogger(__name__) +AutomationActionType = Callable[[HomeAssistant, TemplateVarsType], Awaitable[None]] + def _platform_validator(config): - """Validate it is a valid platform.""" + """Validate it is a valid platform.""" try: platform = importlib.import_module( - 'homeassistant.components.automation.{}'.format( - config[CONF_PLATFORM])) + ".{}".format(config[CONF_PLATFORM]), __name__ + ) except ImportError: - raise vol.Invalid('Invalid platform specified') from None + raise vol.Invalid("Invalid platform specified") from None return platform.TRIGGER_SCHEMA(config) @@ -72,35 +84,31 @@ _TRIGGER_SCHEMA = vol.All( cv.ensure_list, [ vol.All( - vol.Schema({ - vol.Required(CONF_PLATFORM): str - }, extra=vol.ALLOW_EXTRA), - _platform_validator - ), - ] + vol.Schema({vol.Required(CONF_PLATFORM): str}, extra=vol.ALLOW_EXTRA), + _platform_validator, + ) + ], ) _CONDITION_SCHEMA = vol.All(cv.ensure_list, [cv.CONDITION_SCHEMA]) -PLATFORM_SCHEMA = vol.Schema({ - # str on purpose - CONF_ID: str, - CONF_ALIAS: cv.string, - vol.Optional(CONF_INITIAL_STATE): cv.boolean, - vol.Optional(CONF_HIDE_ENTITY, default=DEFAULT_HIDE_ENTITY): cv.boolean, - vol.Required(CONF_TRIGGER): _TRIGGER_SCHEMA, - vol.Optional(CONF_CONDITION): _CONDITION_SCHEMA, - vol.Required(CONF_ACTION): cv.SCRIPT_SCHEMA, -}) +PLATFORM_SCHEMA = vol.Schema( + { + # str on purpose + CONF_ID: str, + CONF_ALIAS: cv.string, + vol.Optional(CONF_DESCRIPTION): cv.string, + vol.Optional(CONF_INITIAL_STATE): cv.boolean, + vol.Optional(CONF_HIDE_ENTITY, default=DEFAULT_HIDE_ENTITY): cv.boolean, + vol.Required(CONF_TRIGGER): _TRIGGER_SCHEMA, + vol.Optional(CONF_CONDITION): _CONDITION_SCHEMA, + vol.Required(CONF_ACTION): cv.SCRIPT_SCHEMA, + } +) -SERVICE_SCHEMA = vol.Schema({ - vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, -}) - -TRIGGER_SERVICE_SCHEMA = vol.Schema({ - vol.Required(ATTR_ENTITY_ID): cv.entity_ids, - vol.Optional(ATTR_VARIABLES, default={}): dict, -}) +TRIGGER_SERVICE_SCHEMA = make_entity_service_schema( + {vol.Optional(ATTR_VARIABLES, default={}): dict} +) RELOAD_SERVICE_SCHEMA = vol.Schema({}) @@ -115,89 +123,50 @@ def is_on(hass, entity_id): return hass.states.is_state(entity_id, STATE_ON) -@bind_hass -def turn_on(hass, entity_id=None): - """Turn on specified automation or all.""" - data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} - hass.services.call(DOMAIN, SERVICE_TURN_ON, data) - - -@bind_hass -def turn_off(hass, entity_id=None): - """Turn off specified automation or all.""" - data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} - hass.services.call(DOMAIN, SERVICE_TURN_OFF, data) - - -@bind_hass -def toggle(hass, entity_id=None): - """Toggle specified automation or all.""" - data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} - hass.services.call(DOMAIN, SERVICE_TOGGLE, data) - - -@bind_hass -def trigger(hass, entity_id=None): - """Trigger specified automation or all.""" - data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} - hass.services.call(DOMAIN, SERVICE_TRIGGER, data) - - -@bind_hass -def reload(hass): - """Reload the automation from config.""" - hass.services.call(DOMAIN, SERVICE_RELOAD) - - -@bind_hass -def async_reload(hass): - """Reload the automation from config. - - Returns a coroutine object. - """ - return hass.services.async_call(DOMAIN, SERVICE_RELOAD) - - async def async_setup(hass, config): """Set up the automation.""" - component = EntityComponent(_LOGGER, DOMAIN, hass, - group_name=GROUP_NAME_ALL_AUTOMATIONS) + component = EntityComponent( + _LOGGER, DOMAIN, hass, group_name=GROUP_NAME_ALL_AUTOMATIONS + ) await _async_process_config(hass, config, component) async def trigger_service_handler(service_call): """Handle automation triggers.""" tasks = [] - for entity in component.async_extract_from_service(service_call): - tasks.append(entity.async_trigger( - service_call.data.get(ATTR_VARIABLES), - skip_condition=True, - context=service_call.context)) + for entity in await component.async_extract_from_service(service_call): + tasks.append( + entity.async_trigger( + service_call.data.get(ATTR_VARIABLES), + skip_condition=True, + context=service_call.context, + ) + ) if tasks: - await asyncio.wait(tasks, loop=hass.loop) + await asyncio.wait(tasks) async def turn_onoff_service_handler(service_call): """Handle automation turn on/off service calls.""" tasks = [] - method = 'async_{}'.format(service_call.service) - for entity in component.async_extract_from_service(service_call): + method = f"async_{service_call.service}" + for entity in await component.async_extract_from_service(service_call): tasks.append(getattr(entity, method)()) if tasks: - await asyncio.wait(tasks, loop=hass.loop) + await asyncio.wait(tasks) async def toggle_service_handler(service_call): """Handle automation toggle service calls.""" tasks = [] - for entity in component.async_extract_from_service(service_call): + for entity in await component.async_extract_from_service(service_call): if entity.is_on: tasks.append(entity.async_turn_off()) else: tasks.append(entity.async_turn_on()) if tasks: - await asyncio.wait(tasks, loop=hass.loop) + await asyncio.wait(tasks) async def reload_service_handler(service_call): """Remove all automations and load new ones from config.""" @@ -207,30 +176,48 @@ async def async_setup(hass, config): await _async_process_config(hass, conf, component) hass.services.async_register( - DOMAIN, SERVICE_TRIGGER, trigger_service_handler, - schema=TRIGGER_SERVICE_SCHEMA) + DOMAIN, SERVICE_TRIGGER, trigger_service_handler, schema=TRIGGER_SERVICE_SCHEMA + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_RELOAD, + reload_service_handler, + schema=RELOAD_SERVICE_SCHEMA, + ) hass.services.async_register( - DOMAIN, SERVICE_RELOAD, reload_service_handler, - schema=RELOAD_SERVICE_SCHEMA) - - hass.services.async_register( - DOMAIN, SERVICE_TOGGLE, toggle_service_handler, - schema=SERVICE_SCHEMA) + DOMAIN, + SERVICE_TOGGLE, + toggle_service_handler, + schema=make_entity_service_schema({}), + ) for service in (SERVICE_TURN_ON, SERVICE_TURN_OFF): hass.services.async_register( - DOMAIN, service, turn_onoff_service_handler, - schema=SERVICE_SCHEMA) + DOMAIN, + service, + turn_onoff_service_handler, + schema=make_entity_service_schema({}), + ) return True -class AutomationEntity(ToggleEntity): +class AutomationEntity(ToggleEntity, RestoreEntity): """Entity to show status of entity.""" - def __init__(self, automation_id, name, async_attach_triggers, cond_func, - async_action, hidden, initial_state): + def __init__( + self, + automation_id, + name, + async_attach_triggers, + cond_func, + async_action, + hidden, + initial_state, + ): """Initialize an automation entity.""" self._id = automation_id self._name = name @@ -241,6 +228,7 @@ class AutomationEntity(ToggleEntity): self._last_triggered = None self._hidden = hidden self._initial_state = initial_state + self._is_enabled = False @property def name(self): @@ -255,9 +243,7 @@ class AutomationEntity(ToggleEntity): @property def state_attributes(self): """Return the entity state attributes.""" - return { - ATTR_LAST_TRIGGERED: self._last_triggered - } + return {ATTR_LAST_TRIGGERED: self._last_triggered} @property def hidden(self) -> bool: @@ -267,87 +253,125 @@ class AutomationEntity(ToggleEntity): @property def is_on(self) -> bool: """Return True if entity is on.""" - return self._async_detach_triggers is not None + return self._async_detach_triggers is not None or self._is_enabled async def async_added_to_hass(self) -> None: """Startup with initial state or previous state.""" + await super().async_added_to_hass() + + state = await self.async_get_last_state() + if state: + enable_automation = state.state == STATE_ON + last_triggered = state.attributes.get("last_triggered") + if last_triggered is not None: + self._last_triggered = parse_datetime(last_triggered) + _LOGGER.debug( + "Loaded automation %s with state %s from state " + " storage last state %s", + self.entity_id, + enable_automation, + state, + ) + else: + enable_automation = DEFAULT_INITIAL_STATE + _LOGGER.debug( + "Automation %s not in state storage, state %s from " "default is used.", + self.entity_id, + enable_automation, + ) + if self._initial_state is not None: enable_automation = self._initial_state - _LOGGER.debug("Automation %s initial state %s from config " - "initial_state", self.entity_id, enable_automation) - else: - state = await async_get_last_state(self.hass, self.entity_id) - if state: - enable_automation = state.state == STATE_ON - self._last_triggered = state.attributes.get('last_triggered') - _LOGGER.debug("Automation %s initial state %s from recorder " - "last state %s", self.entity_id, - enable_automation, state) - else: - enable_automation = DEFAULT_INITIAL_STATE - _LOGGER.debug("Automation %s initial state %s from default " - "initial state", self.entity_id, - enable_automation) + _LOGGER.debug( + "Automation %s initial state %s overridden from " + "config initial_state", + self.entity_id, + enable_automation, + ) - if not enable_automation: - return - - # HomeAssistant is starting up - if self.hass.state == CoreState.not_running: - async def async_enable_automation(event): - """Start automation on startup.""" - await self.async_enable() - - self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_START, async_enable_automation) - - # HomeAssistant is running - else: + if enable_automation: await self.async_enable() - async def async_turn_on(self, **kwargs) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on and update the state.""" - if self.is_on: - return - await self.async_enable() - async def async_turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - if not self.is_on: - return + await self.async_disable() - self._async_detach_triggers() - self._async_detach_triggers = None - await self.async_update_ha_state() - - async def async_trigger(self, variables, skip_condition=False, - context=None): + async def async_trigger(self, variables, skip_condition=False, context=None): """Trigger automation. This method is a coroutine. """ - if skip_condition or self._cond_func(variables): - self.async_set_context(context) - await self._async_action(self.entity_id, variables, context) - self._last_triggered = utcnow() - await self.async_update_ha_state() + if not skip_condition and not self._cond_func(variables): + return + + # Create a new context referring to the old context. + parent_id = None if context is None else context.id + trigger_context = Context(parent_id=parent_id) + + self.async_set_context(trigger_context) + self.hass.bus.async_fire( + EVENT_AUTOMATION_TRIGGERED, + {ATTR_NAME: self._name, ATTR_ENTITY_ID: self.entity_id}, + context=trigger_context, + ) + await self._async_action(self.entity_id, variables, trigger_context) + self._last_triggered = utcnow() + await self.async_update_ha_state() async def async_will_remove_from_hass(self): """Remove listeners when removing automation from HASS.""" - await self.async_turn_off() + await super().async_will_remove_from_hass() + await self.async_disable() async def async_enable(self): """Enable this automation entity. This method is a coroutine. """ - if self.is_on: + if self._is_enabled: return - self._async_detach_triggers = await self._async_attach_triggers( - self.async_trigger) - await self.async_update_ha_state() + self._is_enabled = True + + # HomeAssistant is starting up + if self.hass.state != CoreState.not_running: + self._async_detach_triggers = await self._async_attach_triggers( + self.async_trigger + ) + self.async_write_ha_state() + return + + async def async_enable_automation(event): + """Start automation on startup.""" + # Don't do anything if no longer enabled or already attached + if not self._is_enabled or self._async_detach_triggers is not None: + return + + self._async_detach_triggers = await self._async_attach_triggers( + self.async_trigger + ) + + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, async_enable_automation + ) + self.async_write_ha_state() + + async def async_disable(self): + """Disable the automation entity.""" + if not self._is_enabled: + return + + self._is_enabled = False + + if self._async_detach_triggers is not None: + self._async_detach_triggers() + self._async_detach_triggers = None + + self.async_write_ha_state() @property def device_state_attributes(self): @@ -355,9 +379,7 @@ class AutomationEntity(ToggleEntity): if self._id is None: return None - return { - CONF_ID: self._id - } + return {CONF_ID: self._id} async def _async_process_config(hass, config, component): @@ -372,32 +394,40 @@ async def _async_process_config(hass, config, component): for list_no, config_block in enumerate(conf): automation_id = config_block.get(CONF_ID) - name = config_block.get(CONF_ALIAS) or "{} {}".format(config_key, - list_no) + name = config_block.get(CONF_ALIAS) or f"{config_key} {list_no}" hidden = config_block[CONF_HIDE_ENTITY] initial_state = config_block.get(CONF_INITIAL_STATE) - action = _async_get_action(hass, config_block.get(CONF_ACTION, {}), - name) + action = _async_get_action(hass, config_block.get(CONF_ACTION, {}), name) if CONF_CONDITION in config_block: - cond_func = _async_process_if(hass, config, config_block) + cond_func = await _async_process_if(hass, config, config_block) if cond_func is None: continue else: + def cond_func(variables): """Condition will always pass.""" return True async_attach_triggers = partial( - _async_process_trigger, hass, config, - config_block.get(CONF_TRIGGER, []), name + _async_process_trigger, + hass, + config, + config_block.get(CONF_TRIGGER, []), + name, ) entity = AutomationEntity( - automation_id, name, async_attach_triggers, cond_func, action, - hidden, initial_state) + automation_id, + name, + async_attach_triggers, + cond_func, + action, + hidden, + initial_state, + ) entities.append(entity) @@ -411,24 +441,28 @@ def _async_get_action(hass, config, name): async def action(entity_id, variables, context): """Execute an action.""" - _LOGGER.info('Executing %s', name) - logbook.async_log_entry( - hass, name, 'has been triggered', DOMAIN, entity_id) - await script_obj.async_run(variables, context) + _LOGGER.info("Executing %s", name) + + try: + await script_obj.async_run(variables, context) + except Exception as err: # pylint: disable=broad-except + script_obj.async_log_exception( + _LOGGER, f"Error while executing automation {entity_id}", err + ) return action -def _async_process_if(hass, config, p_config): +async def _async_process_if(hass, config, p_config): """Process if checks.""" if_configs = p_config.get(CONF_CONDITION) checks = [] for if_config in if_configs: try: - checks.append(condition.async_from_config(if_config, False)) + checks.append(await condition.async_from_config(hass, if_config, False)) except HomeAssistantError as ex: - _LOGGER.warning('Invalid condition: %s', ex) + _LOGGER.warning("Invalid condition: %s", ex) return None def if_action(variables=None): @@ -444,15 +478,12 @@ async def _async_process_trigger(hass, config, trigger_configs, name, action): This method is a coroutine. """ removes = [] + info = {"name": name} for conf in trigger_configs: - platform = await async_prepare_setup_platform( - hass, config, DOMAIN, conf.get(CONF_PLATFORM)) + platform = importlib.import_module(".{}".format(conf[CONF_PLATFORM]), __name__) - if platform is None: - return None - - remove = await platform.async_trigger(hass, conf, action) + remove = await platform.async_attach_trigger(hass, conf, action, info) if not remove: _LOGGER.error("Error setting up trigger %s", name) diff --git a/homeassistant/components/automation/config.py b/homeassistant/components/automation/config.py new file mode 100644 index 000000000..d11472a21 --- /dev/null +++ b/homeassistant/components/automation/config.py @@ -0,0 +1,88 @@ +"""Config validation helper for the automation integration.""" +import asyncio +import importlib + +import voluptuous as vol + +from homeassistant.components.device_automation.exceptions import ( + InvalidDeviceAutomationConfig, +) +from homeassistant.config import async_log_exception, config_without_domain +from homeassistant.const import CONF_PLATFORM +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import condition, config_per_platform, script +from homeassistant.loader import IntegrationNotFound + +from . import CONF_ACTION, CONF_CONDITION, CONF_TRIGGER, DOMAIN, PLATFORM_SCHEMA + +# mypy: allow-untyped-calls, allow-untyped-defs +# mypy: no-check-untyped-defs, no-warn-return-any + + +async def async_validate_config_item(hass, config, full_config=None): + """Validate config item.""" + config = PLATFORM_SCHEMA(config) + + triggers = [] + for trigger in config[CONF_TRIGGER]: + trigger_platform = importlib.import_module( + "..{}".format(trigger[CONF_PLATFORM]), __name__ + ) + if hasattr(trigger_platform, "async_validate_trigger_config"): + trigger = await trigger_platform.async_validate_trigger_config( + hass, trigger + ) + triggers.append(trigger) + config[CONF_TRIGGER] = triggers + + if CONF_CONDITION in config: + conditions = [] + for cond in config[CONF_CONDITION]: + cond = await condition.async_validate_condition_config(hass, cond) + conditions.append(cond) + config[CONF_CONDITION] = conditions + + actions = [] + for action in config[CONF_ACTION]: + action = await script.async_validate_action_config(hass, action) + actions.append(action) + config[CONF_ACTION] = actions + + return config + + +async def _try_async_validate_config_item(hass, config, full_config=None): + """Validate config item.""" + try: + config = await async_validate_config_item(hass, config, full_config) + except ( + vol.Invalid, + HomeAssistantError, + IntegrationNotFound, + InvalidDeviceAutomationConfig, + ) as ex: + async_log_exception(ex, DOMAIN, full_config or config, hass) + return None + + return config + + +async def async_validate_config(hass, config): + """Validate config.""" + automations = [] + validated_automations = await asyncio.gather( + *( + _try_async_validate_config_item(hass, p_config, config) + for _, p_config in config_per_platform(config, DOMAIN) + ) + ) + for validated_automation in validated_automations: + if validated_automation is not None: + automations.append(validated_automation) + + # Create a copy of the configuration with all config for current + # component removed and add validated config back in. + config = config_without_domain(config, DOMAIN) + config[DOMAIN] = automations + + return config diff --git a/homeassistant/components/automation/device.py b/homeassistant/components/automation/device.py new file mode 100644 index 000000000..b2892d1ab --- /dev/null +++ b/homeassistant/components/automation/device.py @@ -0,0 +1,31 @@ +"""Offer device oriented automation.""" +import voluptuous as vol + +from homeassistant.components.device_automation import ( + TRIGGER_BASE_SCHEMA, + async_get_device_automation_platform, +) +from homeassistant.const import CONF_DOMAIN + +# mypy: allow-untyped-defs, no-check-untyped-defs + +TRIGGER_SCHEMA = TRIGGER_BASE_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA) + + +async def async_validate_trigger_config(hass, config): + """Validate config.""" + platform = await async_get_device_automation_platform( + hass, config[CONF_DOMAIN], "trigger" + ) + if hasattr(platform, "async_validate_trigger_config"): + return await getattr(platform, "async_validate_trigger_config")(hass, config) + + return platform.TRIGGER_SCHEMA(config) + + +async def async_attach_trigger(hass, config, action, automation_info): + """Listen for trigger.""" + platform = await async_get_device_automation_platform( + hass, config[CONF_DOMAIN], "trigger" + ) + return await platform.async_attach_trigger(hass, config, action, automation_info) diff --git a/homeassistant/components/automation/event.py b/homeassistant/components/automation/event.py index e19a85eda..9fc78746a 100644 --- a/homeassistant/components/automation/event.py +++ b/homeassistant/components/automation/event.py @@ -1,37 +1,38 @@ -""" -Offer event listening automation rules. - -For more details about this automation rule, please refer to the documentation -at https://home-assistant.io/docs/automation/trigger/#event-trigger -""" -import asyncio +"""Offer event listening automation rules.""" import logging import voluptuous as vol -from homeassistant.core import callback from homeassistant.const import CONF_PLATFORM +from homeassistant.core import callback from homeassistant.helpers import config_validation as cv -CONF_EVENT_TYPE = 'event_type' -CONF_EVENT_DATA = 'event_data' +# mypy: allow-untyped-defs + +CONF_EVENT_TYPE = "event_type" +CONF_EVENT_DATA = "event_data" _LOGGER = logging.getLogger(__name__) -TRIGGER_SCHEMA = vol.Schema({ - vol.Required(CONF_PLATFORM): 'event', - vol.Required(CONF_EVENT_TYPE): cv.string, - vol.Optional(CONF_EVENT_DATA): dict, -}) +TRIGGER_SCHEMA = vol.Schema( + { + vol.Required(CONF_PLATFORM): "event", + vol.Required(CONF_EVENT_TYPE): cv.string, + vol.Optional(CONF_EVENT_DATA): dict, + } +) -@asyncio.coroutine -def async_trigger(hass, config, action): +async def async_attach_trigger( + hass, config, action, automation_info, *, platform_type="event" +): """Listen for events based on configuration.""" event_type = config.get(CONF_EVENT_TYPE) - event_data_schema = vol.Schema( - config.get(CONF_EVENT_DATA), - extra=vol.ALLOW_EXTRA) if config.get(CONF_EVENT_DATA) else None + 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): @@ -45,11 +46,11 @@ def async_trigger(hass, config, action): # If event data doesn't match requested schema, skip event return - hass.async_run_job(action({ - 'trigger': { - 'platform': 'event', - 'event': event, - }, - }, context=event.context)) + hass.async_run_job( + action( + {"trigger": {"platform": platform_type, "event": event}}, + context=event.context, + ) + ) return hass.bus.async_listen(event_type, handle_event) diff --git a/homeassistant/components/automation/geo_location.py b/homeassistant/components/automation/geo_location.py new file mode 100644 index 000000000..5dc4f3c80 --- /dev/null +++ b/homeassistant/components/automation/geo_location.py @@ -0,0 +1,87 @@ +"""Offer geolocation automation rules.""" +import voluptuous as vol + +from homeassistant.components.geo_location import DOMAIN +from homeassistant.const import ( + CONF_EVENT, + CONF_PLATFORM, + CONF_SOURCE, + CONF_ZONE, + EVENT_STATE_CHANGED, +) +from homeassistant.core import callback +from homeassistant.helpers import condition, config_validation as cv +from homeassistant.helpers.config_validation import entity_domain + +# mypy: allow-untyped-defs, no-check-untyped-defs + +EVENT_ENTER = "enter" +EVENT_LEAVE = "leave" +DEFAULT_EVENT = EVENT_ENTER + +TRIGGER_SCHEMA = vol.Schema( + { + vol.Required(CONF_PLATFORM): "geo_location", + vol.Required(CONF_SOURCE): cv.string, + vol.Required(CONF_ZONE): entity_domain("zone"), + vol.Required(CONF_EVENT, default=DEFAULT_EVENT): vol.Any( + EVENT_ENTER, EVENT_LEAVE + ), + } +) + + +def source_match(state, source): + """Check if the state matches the provided source.""" + return state and state.attributes.get("source") == source + + +async def async_attach_trigger(hass, config, action, automation_info): + """Listen for state changes based on configuration.""" + source = config.get(CONF_SOURCE).lower() + zone_entity_id = config.get(CONF_ZONE) + trigger_event = config.get(CONF_EVENT) + + @callback + def state_change_listener(event): + """Handle specific state changes.""" + # Skip if the event is not a geo_location entity. + if not event.data.get("entity_id").startswith(DOMAIN): + return + # Skip if the event's source does not match the trigger's source. + from_state = event.data.get("old_state") + to_state = event.data.get("new_state") + if not source_match(from_state, source) and not source_match(to_state, source): + return + + zone_state = hass.states.get(zone_entity_id) + from_match = condition.zone(hass, zone_state, from_state) + to_match = condition.zone(hass, zone_state, to_state) + + # pylint: disable=too-many-boolean-expressions + if ( + trigger_event == EVENT_ENTER + and not from_match + and to_match + or trigger_event == EVENT_LEAVE + and from_match + and not to_match + ): + hass.async_run_job( + action( + { + "trigger": { + "platform": "geo_location", + "source": source, + "entity_id": event.data.get("entity_id"), + "from_state": from_state, + "to_state": to_state, + "zone": zone_state, + "event": trigger_event, + } + }, + context=event.context, + ) + ) + + return hass.bus.async_listen(EVENT_STATE_CHANGED, state_change_listener) diff --git a/homeassistant/components/automation/homeassistant.py b/homeassistant/components/automation/homeassistant.py index b55d99f70..743b169c8 100644 --- a/homeassistant/components/automation/homeassistant.py +++ b/homeassistant/components/automation/homeassistant.py @@ -1,55 +1,48 @@ -""" -Offer Home Assistant core automation rules. - -For more details about this automation rule, please refer to the documentation -at https://home-assistant.io/components/automation/#homeassistant-trigger -""" -import asyncio +"""Offer Home Assistant core automation rules.""" import logging import voluptuous as vol -from homeassistant.core import callback, CoreState -from homeassistant.const import ( - CONF_PLATFORM, CONF_EVENT, EVENT_HOMEASSISTANT_STOP) +from homeassistant.const import CONF_EVENT, CONF_PLATFORM, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import CoreState, callback -EVENT_START = 'start' -EVENT_SHUTDOWN = 'shutdown' +# mypy: allow-untyped-defs + +EVENT_START = "start" +EVENT_SHUTDOWN = "shutdown" _LOGGER = logging.getLogger(__name__) -TRIGGER_SCHEMA = vol.Schema({ - vol.Required(CONF_PLATFORM): 'homeassistant', - vol.Required(CONF_EVENT): vol.Any(EVENT_START, EVENT_SHUTDOWN), -}) +TRIGGER_SCHEMA = vol.Schema( + { + vol.Required(CONF_PLATFORM): "homeassistant", + vol.Required(CONF_EVENT): vol.Any(EVENT_START, EVENT_SHUTDOWN), + } +) -@asyncio.coroutine -def async_trigger(hass, config, action): +async def async_attach_trigger(hass, config, action, automation_info): """Listen for events based on configuration.""" event = config.get(CONF_EVENT) if event == EVENT_SHUTDOWN: + @callback def hass_shutdown(event): """Execute when Home Assistant is shutting down.""" - hass.async_run_job(action({ - 'trigger': { - 'platform': 'homeassistant', - 'event': event, - }, - }, context=event.context)) + hass.async_run_job( + action( + {"trigger": {"platform": "homeassistant", "event": event}}, + context=event.context, + ) + ) - return hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, - hass_shutdown) + return hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, hass_shutdown) # Automation are enabled while hass is starting up, fire right away # Check state because a config reload shouldn't trigger it. if hass.state == CoreState.starting: - hass.async_run_job(action({ - 'trigger': { - 'platform': 'homeassistant', - 'event': event, - }, - })) + hass.async_run_job( + action({"trigger": {"platform": "homeassistant", "event": event}}) + ) return lambda: None diff --git a/homeassistant/components/automation/litejet.py b/homeassistant/components/automation/litejet.py index c827fe8f7..466fc941a 100644 --- a/homeassistant/components/automation/litejet.py +++ b/homeassistant/components/automation/litejet.py @@ -1,40 +1,37 @@ -""" -Trigger an automation when a LiteJet switch is released. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/automation.litejet/ -""" -import asyncio +"""Trigger an automation when a LiteJet switch is released.""" import logging import voluptuous as vol -from homeassistant.core import callback from homeassistant.const import CONF_PLATFORM +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv -import homeassistant.util.dt as dt_util from homeassistant.helpers.event import track_point_in_utc_time +import homeassistant.util.dt as dt_util -DEPENDENCIES = ['litejet'] +# mypy: allow-untyped-defs, no-check-untyped-defs _LOGGER = logging.getLogger(__name__) -CONF_NUMBER = 'number' -CONF_HELD_MORE_THAN = 'held_more_than' -CONF_HELD_LESS_THAN = 'held_less_than' +CONF_NUMBER = "number" +CONF_HELD_MORE_THAN = "held_more_than" +CONF_HELD_LESS_THAN = "held_less_than" -TRIGGER_SCHEMA = vol.Schema({ - vol.Required(CONF_PLATFORM): 'litejet', - vol.Required(CONF_NUMBER): cv.positive_int, - vol.Optional(CONF_HELD_MORE_THAN): - vol.All(cv.time_period, cv.positive_timedelta), - vol.Optional(CONF_HELD_LESS_THAN): - vol.All(cv.time_period, cv.positive_timedelta) -}) +TRIGGER_SCHEMA = vol.Schema( + { + vol.Required(CONF_PLATFORM): "litejet", + vol.Required(CONF_NUMBER): cv.positive_int, + vol.Optional(CONF_HELD_MORE_THAN): vol.All( + cv.time_period, cv.positive_timedelta + ), + vol.Optional(CONF_HELD_LESS_THAN): vol.All( + cv.time_period, cv.positive_timedelta + ), + } +) -@asyncio.coroutine -def async_trigger(hass, config, action): +async def async_attach_trigger(hass, config, action, automation_info): """Listen for events based on configuration.""" number = config.get(CONF_NUMBER) held_more_than = config.get(CONF_HELD_MORE_THAN) @@ -45,14 +42,17 @@ def async_trigger(hass, config, action): @callback def call_action(): """Call action with right context.""" - hass.async_run_job(action, { - 'trigger': { - CONF_PLATFORM: 'litejet', - CONF_NUMBER: number, - CONF_HELD_MORE_THAN: held_more_than, - CONF_HELD_LESS_THAN: held_less_than + hass.async_run_job( + action, + { + "trigger": { + CONF_PLATFORM: "litejet", + CONF_NUMBER: number, + CONF_HELD_MORE_THAN: held_more_than, + CONF_HELD_LESS_THAN: held_less_than, + } }, - }) + ) # held_more_than and held_less_than: trigger on released (if in time range) # held_more_than: trigger after pressed with calculation @@ -73,9 +73,8 @@ def async_trigger(hass, config, action): hass.add_job(call_action) if held_more_than is not None and held_less_than is None: cancel_pressed_more_than = track_point_in_utc_time( - hass, - pressed_more_than_satisfied, - dt_util.utcnow() + held_more_than) + hass, pressed_more_than_satisfied, dt_util.utcnow() + held_more_than + ) def released(): """Handle the release of the LiteJet switch's button.""" @@ -90,8 +89,8 @@ def async_trigger(hass, config, action): if held_more_than is None or held_time > held_more_than: hass.add_job(call_action) - hass.data['litejet_system'].on_switch_pressed(number, pressed) - hass.data['litejet_system'].on_switch_released(number, released) + hass.data["litejet_system"].on_switch_pressed(number, pressed) + hass.data["litejet_system"].on_switch_released(number, released) def async_remove(): """Remove all subscriptions used for this trigger.""" diff --git a/homeassistant/components/automation/manifest.json b/homeassistant/components/automation/manifest.json new file mode 100644 index 000000000..79a687769 --- /dev/null +++ b/homeassistant/components/automation/manifest.json @@ -0,0 +1,14 @@ +{ + "domain": "automation", + "name": "Automation", + "documentation": "https://www.home-assistant.io/integrations/automation", + "requirements": [], + "dependencies": [ + "device_automation", + "group", + "webhook" + ], + "codeowners": [ + "@home-assistant/core" + ] +} diff --git a/homeassistant/components/automation/mqtt.py b/homeassistant/components/automation/mqtt.py index 60c33ca9b..fb0073c78 100644 --- a/homeassistant/components/automation/mqtt.py +++ b/homeassistant/components/automation/mqtt.py @@ -1,56 +1,54 @@ -""" -Offer MQTT listening automation rules. - -For more details about this automation rule, please refer to the documentation -at https://home-assistant.io/docs/automation/trigger/#mqtt-trigger -""" -import asyncio +"""Offer MQTT listening automation rules.""" import json import voluptuous as vol -from homeassistant.core import callback from homeassistant.components import mqtt -from homeassistant.const import (CONF_PLATFORM, CONF_PAYLOAD) +from homeassistant.const import CONF_PAYLOAD, CONF_PLATFORM +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv -DEPENDENCIES = ['mqtt'] +# mypy: allow-untyped-defs -CONF_TOPIC = 'topic' +CONF_ENCODING = "encoding" +CONF_TOPIC = "topic" +DEFAULT_ENCODING = "utf-8" -TRIGGER_SCHEMA = vol.Schema({ - vol.Required(CONF_PLATFORM): mqtt.DOMAIN, - vol.Required(CONF_TOPIC): mqtt.valid_subscribe_topic, - vol.Optional(CONF_PAYLOAD): cv.string, -}) +TRIGGER_SCHEMA = vol.Schema( + { + vol.Required(CONF_PLATFORM): mqtt.DOMAIN, + vol.Required(CONF_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_PAYLOAD): cv.string, + vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): cv.string, + } +) -@asyncio.coroutine -def async_trigger(hass, config, action): +async def async_attach_trigger(hass, config, action, automation_info): """Listen for state changes based on configuration.""" - topic = config.get(CONF_TOPIC) + topic = config[CONF_TOPIC] payload = config.get(CONF_PAYLOAD) + encoding = config[CONF_ENCODING] or None @callback - def mqtt_automation_listener(msg_topic, msg_payload, qos): + def mqtt_automation_listener(mqttmsg): """Listen for MQTT messages.""" - if payload is None or payload == msg_payload: + if payload is None or payload == mqttmsg.payload: data = { - 'platform': 'mqtt', - 'topic': msg_topic, - 'payload': msg_payload, - 'qos': qos, + "platform": "mqtt", + "topic": mqttmsg.topic, + "payload": mqttmsg.payload, + "qos": mqttmsg.qos, } try: - data['payload_json'] = json.loads(msg_payload) + data["payload_json"] = json.loads(mqttmsg.payload) except ValueError: pass - hass.async_run_job(action, { - 'trigger': data - }) + hass.async_run_job(action, {"trigger": data}) - remove = yield from mqtt.async_subscribe( - hass, topic, mqtt_automation_listener) + remove = await mqtt.async_subscribe( + hass, topic, mqtt_automation_listener, encoding=encoding + ) return remove diff --git a/homeassistant/components/automation/numeric_state.py b/homeassistant/components/automation/numeric_state.py index f0dcbf0be..e944b6675 100644 --- a/homeassistant/components/automation/numeric_state.py +++ b/homeassistant/components/automation/numeric_state.py @@ -1,44 +1,58 @@ -""" -Offer numeric state listening automation rules. - -For more details about this automation rule, please refer to the documentation -at https://home-assistant.io/docs/automation/trigger/#numeric-state-trigger -""" -import asyncio +"""Offer numeric state listening automation rules.""" import logging import voluptuous as vol -from homeassistant.core import callback +from homeassistant import exceptions from homeassistant.const import ( - CONF_VALUE_TEMPLATE, CONF_PLATFORM, CONF_ENTITY_ID, - CONF_BELOW, CONF_ABOVE, CONF_FOR) -from homeassistant.helpers.event import ( - async_track_state_change, async_track_same_state) -from homeassistant.helpers import condition, config_validation as cv + CONF_ABOVE, + CONF_BELOW, + CONF_ENTITY_ID, + CONF_FOR, + CONF_PLATFORM, + CONF_VALUE_TEMPLATE, +) +from homeassistant.core import CALLBACK_TYPE, callback +from homeassistant.helpers import condition, config_validation as cv, template +from homeassistant.helpers.event import async_track_same_state, async_track_state_change -TRIGGER_SCHEMA = vol.All(vol.Schema({ - vol.Required(CONF_PLATFORM): 'numeric_state', - vol.Required(CONF_ENTITY_ID): cv.entity_ids, - vol.Optional(CONF_BELOW): vol.Coerce(float), - vol.Optional(CONF_ABOVE): vol.Coerce(float), - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_FOR): vol.All(cv.time_period, cv.positive_timedelta), -}), cv.has_at_least_one_key(CONF_BELOW, CONF_ABOVE)) +# mypy: allow-incomplete-defs, allow-untyped-calls, allow-untyped-defs +# mypy: no-check-untyped-defs + +TRIGGER_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(CONF_PLATFORM): "numeric_state", + vol.Required(CONF_ENTITY_ID): cv.entity_ids, + vol.Optional(CONF_BELOW): vol.Coerce(float), + vol.Optional(CONF_ABOVE): vol.Coerce(float), + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_FOR): vol.Any( + vol.All(cv.time_period, cv.positive_timedelta), + cv.template, + cv.template_complex, + ), + } + ), + cv.has_at_least_one_key(CONF_BELOW, CONF_ABOVE), +) _LOGGER = logging.getLogger(__name__) -@asyncio.coroutine -def async_trigger(hass, config, action): +async def async_attach_trigger( + hass, config, action, automation_info, *, platform_type="numeric_state" +) -> CALLBACK_TYPE: """Listen for state changes based on configuration.""" entity_id = config.get(CONF_ENTITY_ID) below = config.get(CONF_BELOW) above = config.get(CONF_ABOVE) time_delta = config.get(CONF_FOR) + template.attach(hass, time_delta) value_template = config.get(CONF_VALUE_TEMPLATE) unsub_track_same = {} entities_triggered = set() + period: dict = {} if value_template is not None: value_template.hass = hass @@ -50,32 +64,40 @@ def async_trigger(hass, config, action): return False variables = { - 'trigger': { - 'platform': 'numeric_state', - 'entity_id': entity, - 'below': below, - 'above': above, + "trigger": { + "platform": "numeric_state", + "entity_id": entity, + "below": below, + "above": above, } } return condition.async_numeric_state( - hass, to_s, below, above, value_template, variables) + 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.""" + @callback def call_action(): """Call action with right context.""" - hass.async_run_job(action({ - 'trigger': { - 'platform': 'numeric_state', - 'entity_id': entity, - 'below': below, - 'above': above, - 'from_state': from_s, - 'to_state': to_s, - } - }, context=to_s.context)) + hass.async_run_job( + action( + { + "trigger": { + "platform": platform_type, + "entity_id": entity, + "below": below, + "above": above, + "from_state": from_s, + "to_state": to_s, + "for": time_delta if not time_delta else period[entity], + } + }, + context=to_s.context, + ) + ) matching = check_numeric_state(entity, from_s, to_s) @@ -85,14 +107,50 @@ def async_trigger(hass, config, action): entities_triggered.add(entity) if time_delta: + variables = { + "trigger": { + "platform": "numeric_state", + "entity_id": entity, + "below": below, + "above": above, + } + } + + try: + if isinstance(time_delta, template.Template): + period[entity] = vol.All(cv.time_period, cv.positive_timedelta)( + time_delta.async_render(variables) + ) + elif isinstance(time_delta, dict): + time_delta_data = {} + time_delta_data.update( + template.render_complex(time_delta, variables) + ) + period[entity] = vol.All(cv.time_period, cv.positive_timedelta)( + time_delta_data + ) + else: + period[entity] = time_delta + except (exceptions.TemplateError, vol.Invalid) as ex: + _LOGGER.error( + "Error rendering '%s' for template: %s", + automation_info["name"], + ex, + ) + entities_triggered.discard(entity) + return + unsub_track_same[entity] = async_track_same_state( - hass, time_delta, call_action, entity_ids=entity_id, - async_check_same_func=check_numeric_state) + hass, + period[entity], + call_action, + entity_ids=entity, + async_check_same_func=check_numeric_state, + ) else: call_action() - unsub = async_track_state_change( - hass, entity_id, state_automation_listener) + unsub = async_track_state_change(hass, entity_id, state_automation_listener) @callback def async_remove(): diff --git a/homeassistant/components/automation/reproduce_state.py b/homeassistant/components/automation/reproduce_state.py new file mode 100644 index 000000000..4cfe519d5 --- /dev/null +++ b/homeassistant/components/automation/reproduce_state.py @@ -0,0 +1,61 @@ +"""Reproduce an Automation state.""" +import asyncio +import logging +from typing import Iterable, Optional + +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, +) +from homeassistant.core import Context, State +from homeassistant.helpers.typing import HomeAssistantType + +from . import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +VALID_STATES = {STATE_ON, STATE_OFF} + + +async def _async_reproduce_state( + hass: HomeAssistantType, state: State, context: Optional[Context] = None +) -> None: + """Reproduce a single state.""" + cur_state = hass.states.get(state.entity_id) + + if cur_state is None: + _LOGGER.warning("Unable to find entity %s", state.entity_id) + return + + if state.state not in VALID_STATES: + _LOGGER.warning( + "Invalid state specified for %s: %s", state.entity_id, state.state + ) + return + + # Return if we are already at the right state. + if cur_state.state == state.state: + return + + service_data = {ATTR_ENTITY_ID: state.entity_id} + + if state.state == STATE_ON: + service = SERVICE_TURN_ON + elif state.state == STATE_OFF: + service = SERVICE_TURN_OFF + + await hass.services.async_call( + DOMAIN, service, service_data, context=context, blocking=True + ) + + +async def async_reproduce_states( + hass: HomeAssistantType, states: Iterable[State], context: Optional[Context] = None +) -> None: + """Reproduce Automation states.""" + await asyncio.gather( + *(_async_reproduce_state(hass, state, context) for state in states) + ) diff --git a/homeassistant/components/automation/state.py b/homeassistant/components/automation/state.py index 263d4158e..fc3fff475 100644 --- a/homeassistant/components/automation/state.py +++ b/homeassistant/components/automation/state.py @@ -1,74 +1,136 @@ -""" -Offer state listening automation rules. +"""Offer state listening automation rules.""" +from datetime import timedelta +import logging +from typing import Dict -For more details about this automation rule, please refer to the documentation -at https://home-assistant.io/docs/automation/trigger/#state-trigger -""" -import asyncio import voluptuous as vol -from homeassistant.core import callback -from homeassistant.const import MATCH_ALL, CONF_PLATFORM, CONF_FOR -from homeassistant.helpers.event import ( - async_track_state_change, async_track_same_state) -import homeassistant.helpers.config_validation as cv +from homeassistant import exceptions +from homeassistant.const import CONF_FOR, CONF_PLATFORM, MATCH_ALL +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers import config_validation as cv, template +from homeassistant.helpers.event import async_track_same_state, async_track_state_change -CONF_ENTITY_ID = 'entity_id' -CONF_FROM = 'from' -CONF_TO = 'to' +# mypy: allow-incomplete-defs, allow-untyped-calls, allow-untyped-defs +# mypy: no-check-untyped-defs -TRIGGER_SCHEMA = vol.All(vol.Schema({ - vol.Required(CONF_PLATFORM): 'state', - vol.Required(CONF_ENTITY_ID): cv.entity_ids, - # These are str on purpose. Want to catch YAML conversions - vol.Optional(CONF_FROM): str, - vol.Optional(CONF_TO): str, - vol.Optional(CONF_FOR): vol.All(cv.time_period, cv.positive_timedelta), -}), cv.key_dependency(CONF_FOR, CONF_TO)) +_LOGGER = logging.getLogger(__name__) + +CONF_ENTITY_ID = "entity_id" +CONF_FROM = "from" +CONF_TO = "to" + +TRIGGER_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(CONF_PLATFORM): "state", + vol.Required(CONF_ENTITY_ID): cv.entity_ids, + # These are str on purpose. Want to catch YAML conversions + vol.Optional(CONF_FROM): vol.Any(str, [str]), + vol.Optional(CONF_TO): vol.Any(str, [str]), + vol.Optional(CONF_FOR): vol.Any( + vol.All(cv.time_period, cv.positive_timedelta), + cv.template, + cv.template_complex, + ), + } + ), + cv.key_dependency(CONF_FOR, CONF_TO), +) -@asyncio.coroutine -def async_trigger(hass, config, action): +async def async_attach_trigger( + hass: HomeAssistant, + config, + action, + automation_info, + *, + platform_type: str = "state", +) -> CALLBACK_TYPE: """Listen for state changes based on configuration.""" entity_id = config.get(CONF_ENTITY_ID) from_state = config.get(CONF_FROM, MATCH_ALL) 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) + template.attach(hass, time_delta) + match_all = from_state == MATCH_ALL and to_state == MATCH_ALL unsub_track_same = {} + period: Dict[str, timedelta] = {} @callback def state_automation_listener(entity, from_s, to_s): """Listen for state changes and calls action.""" + @callback def call_action(): """Call action with right context.""" - hass.async_run_job(action({ - 'trigger': { - 'platform': 'state', - 'entity_id': entity, - 'from_state': from_s, - 'to_state': to_s, - 'for': time_delta, - } - }, context=to_s.context)) + hass.async_run_job( + action( + { + "trigger": { + "platform": platform_type, + "entity_id": entity, + "from_state": from_s, + "to_state": to_s, + "for": time_delta if not time_delta else period[entity], + } + }, + context=to_s.context, + ) + ) # 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.state == to_s.state): + if ( + not match_all + and from_s is not None + and to_s is not None + and from_s.state == to_s.state + ): return if not time_delta: call_action() return + variables = { + "trigger": { + "platform": "state", + "entity_id": entity, + "from_state": from_s, + "to_state": to_s, + } + } + + try: + if isinstance(time_delta, template.Template): + period[entity] = vol.All(cv.time_period, cv.positive_timedelta)( + time_delta.async_render(variables) + ) + elif isinstance(time_delta, dict): + time_delta_data = {} + time_delta_data.update(template.render_complex(time_delta, variables)) + period[entity] = vol.All(cv.time_period, cv.positive_timedelta)( + time_delta_data + ) + else: + period[entity] = time_delta + except (exceptions.TemplateError, vol.Invalid) as ex: + _LOGGER.error( + "Error rendering '%s' for template: %s", automation_info["name"], ex + ) + return + unsub_track_same[entity] = async_track_same_state( - hass, time_delta, call_action, + hass, + period[entity], + call_action, lambda _, _2, to_state: to_state.state == to_s.state, - entity_ids=entity_id) + entity_ids=entity, + ) unsub = async_track_state_change( - hass, entity_id, state_automation_listener, from_state, to_state) + hass, entity_id, state_automation_listener, from_state, to_state + ) @callback def async_remove(): diff --git a/homeassistant/components/automation/sun.py b/homeassistant/components/automation/sun.py index 497b84532..c416742f3 100644 --- a/homeassistant/components/automation/sun.py +++ b/homeassistant/components/automation/sun.py @@ -1,32 +1,33 @@ -""" -Offer sun based automation rules. - -For more details about this automation rule, please refer to the documentation -at https://home-assistant.io/docs/automation/trigger/#sun-trigger -""" -import asyncio +"""Offer sun based automation rules.""" from datetime import timedelta import logging import voluptuous as vol -from homeassistant.core import callback from homeassistant.const import ( - CONF_EVENT, CONF_OFFSET, CONF_PLATFORM, SUN_EVENT_SUNRISE) -from homeassistant.helpers.event import async_track_sunrise, async_track_sunset + CONF_EVENT, + CONF_OFFSET, + CONF_PLATFORM, + SUN_EVENT_SUNRISE, +) +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import async_track_sunrise, async_track_sunset + +# mypy: allow-untyped-defs, no-check-untyped-defs _LOGGER = logging.getLogger(__name__) -TRIGGER_SCHEMA = vol.Schema({ - vol.Required(CONF_PLATFORM): 'sun', - vol.Required(CONF_EVENT): cv.sun_event, - vol.Required(CONF_OFFSET, default=timedelta(0)): cv.time_period, -}) +TRIGGER_SCHEMA = vol.Schema( + { + vol.Required(CONF_PLATFORM): "sun", + vol.Required(CONF_EVENT): cv.sun_event, + vol.Required(CONF_OFFSET, default=timedelta(0)): cv.time_period, + } +) -@asyncio.coroutine -def async_trigger(hass, config, action): +async def async_attach_trigger(hass, config, action, automation_info): """Listen for events based on configuration.""" event = config.get(CONF_EVENT) offset = config.get(CONF_OFFSET) @@ -34,13 +35,9 @@ def async_trigger(hass, config, action): @callback def call_action(): """Call action with right context.""" - hass.async_run_job(action, { - 'trigger': { - 'platform': 'sun', - 'event': event, - 'offset': offset, - }, - }) + hass.async_run_job( + action, {"trigger": {"platform": "sun", "event": event, "offset": offset}} + ) if event == SUN_EVENT_SUNRISE: return async_track_sunrise(hass, call_action, offset) diff --git a/homeassistant/components/automation/template.py b/homeassistant/components/automation/template.py index 67a44f1a3..ee4484410 100644 --- a/homeassistant/components/automation/template.py +++ b/homeassistant/components/automation/template.py @@ -1,44 +1,110 @@ -""" -Offer template automation rules. - -For more details about this automation rule, please refer to the documentation -at https://home-assistant.io/docs/automation/trigger/#template-trigger -""" -import asyncio +"""Offer template automation rules.""" import logging import voluptuous as vol +from homeassistant import exceptions +from homeassistant.const import CONF_FOR, CONF_PLATFORM, CONF_VALUE_TEMPLATE from homeassistant.core import callback -from homeassistant.const import CONF_VALUE_TEMPLATE, CONF_PLATFORM -from homeassistant.helpers.event import async_track_template -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import condition, config_validation as cv, template +from homeassistant.helpers.event import async_track_same_state, async_track_template +# mypy: allow-untyped-defs, no-check-untyped-defs _LOGGER = logging.getLogger(__name__) -TRIGGER_SCHEMA = IF_ACTION_SCHEMA = vol.Schema({ - vol.Required(CONF_PLATFORM): 'template', - vol.Required(CONF_VALUE_TEMPLATE): cv.template, -}) +TRIGGER_SCHEMA = IF_ACTION_SCHEMA = vol.Schema( + { + vol.Required(CONF_PLATFORM): "template", + vol.Required(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_FOR): vol.Any( + vol.All(cv.time_period, cv.positive_timedelta), + cv.template, + cv.template_complex, + ), + } +) -@asyncio.coroutine -def async_trigger(hass, config, action): +async def async_attach_trigger( + hass, config, action, automation_info, *, platform_type="numeric_state" +): """Listen for state changes based on configuration.""" value_template = config.get(CONF_VALUE_TEMPLATE) value_template.hass = hass + time_delta = config.get(CONF_FOR) + template.attach(hass, time_delta) + unsub_track_same = None @callback def template_listener(entity_id, from_s, to_s): """Listen for state changes and calls action.""" - hass.async_run_job(action({ - 'trigger': { - 'platform': 'template', - 'entity_id': entity_id, - 'from_state': from_s, - 'to_state': to_s, - }, - }, context=to_s.context)) + nonlocal unsub_track_same - return async_track_template(hass, value_template, template_listener) + @callback + def call_action(): + """Call action with right context.""" + hass.async_run_job( + action( + { + "trigger": { + "platform": "template", + "entity_id": entity_id, + "from_state": from_s, + "to_state": to_s, + "for": time_delta if not time_delta else period, + } + }, + context=(to_s.context if to_s else None), + ) + ) + + if not time_delta: + call_action() + return + + variables = { + "trigger": { + "platform": platform_type, + "entity_id": entity_id, + "from_state": from_s, + "to_state": to_s, + } + } + + try: + if isinstance(time_delta, template.Template): + period = vol.All(cv.time_period, cv.positive_timedelta)( + time_delta.async_render(variables) + ) + elif isinstance(time_delta, dict): + time_delta_data = {} + time_delta_data.update(template.render_complex(time_delta, variables)) + period = vol.All(cv.time_period, cv.positive_timedelta)(time_delta_data) + else: + period = time_delta + except (exceptions.TemplateError, vol.Invalid) as ex: + _LOGGER.error( + "Error rendering '%s' for template: %s", automation_info["name"], ex + ) + return + + unsub_track_same = async_track_same_state( + hass, + period, + call_action, + lambda _, _2, _3: condition.async_template(hass, value_template), + value_template.extract_entities(), + ) + + unsub = async_track_template(hass, value_template, template_listener) + + @callback + def async_remove(): + """Remove state listeners async.""" + unsub() + if unsub_track_same: + # pylint: disable=not-callable + unsub_track_same() + + return async_remove diff --git a/homeassistant/components/automation/time.py b/homeassistant/components/automation/time.py index a3a8496c3..5f4619529 100644 --- a/homeassistant/components/automation/time.py +++ b/homeassistant/components/automation/time.py @@ -1,54 +1,32 @@ -""" -Offer time listening automation rules. - -For more details about this automation rule, please refer to the documentation -at https://home-assistant.io/docs/automation/trigger/#time-trigger -""" -import asyncio +"""Offer time listening automation rules.""" import logging import voluptuous as vol -from homeassistant.core import callback from homeassistant.const import CONF_AT, CONF_PLATFORM +from homeassistant.core import callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import async_track_time_change -CONF_HOURS = 'hours' -CONF_MINUTES = 'minutes' -CONF_SECONDS = 'seconds' +# mypy: allow-untyped-defs, no-check-untyped-defs _LOGGER = logging.getLogger(__name__) -TRIGGER_SCHEMA = vol.All(vol.Schema({ - vol.Required(CONF_PLATFORM): 'time', - CONF_AT: cv.time, - CONF_HOURS: vol.Any(vol.Coerce(int), vol.Coerce(str)), - CONF_MINUTES: vol.Any(vol.Coerce(int), vol.Coerce(str)), - CONF_SECONDS: vol.Any(vol.Coerce(int), vol.Coerce(str)), -}), cv.has_at_least_one_key(CONF_HOURS, CONF_MINUTES, CONF_SECONDS, CONF_AT)) +TRIGGER_SCHEMA = vol.Schema( + {vol.Required(CONF_PLATFORM): "time", vol.Required(CONF_AT): cv.time} +) -@asyncio.coroutine -def async_trigger(hass, config, action): +async def async_attach_trigger(hass, config, action, automation_info): """Listen for state changes based on configuration.""" - if CONF_AT in config: - at_time = config.get(CONF_AT) - hours, minutes, seconds = at_time.hour, at_time.minute, at_time.second - else: - hours = config.get(CONF_HOURS) - minutes = config.get(CONF_MINUTES) - seconds = config.get(CONF_SECONDS) + at_time = config.get(CONF_AT) + hours, minutes, seconds = at_time.hour, at_time.minute, at_time.second @callback def time_automation_listener(now): """Listen for time changes and calls action.""" - hass.async_run_job(action, { - 'trigger': { - 'platform': 'time', - 'now': now, - }, - }) + hass.async_run_job(action, {"trigger": {"platform": "time", "now": now}}) - return async_track_time_change(hass, time_automation_listener, - hour=hours, minute=minutes, second=seconds) + return async_track_time_change( + hass, time_automation_listener, hour=hours, minute=minutes, second=seconds + ) diff --git a/homeassistant/components/automation/time_pattern.py b/homeassistant/components/automation/time_pattern.py new file mode 100644 index 000000000..65d44f5b1 --- /dev/null +++ b/homeassistant/components/automation/time_pattern.py @@ -0,0 +1,53 @@ +"""Offer time listening automation rules.""" +import logging + +import voluptuous as vol + +from homeassistant.const import CONF_PLATFORM +from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.event import async_track_time_change + +# mypy: allow-untyped-defs, no-check-untyped-defs + +CONF_HOURS = "hours" +CONF_MINUTES = "minutes" +CONF_SECONDS = "seconds" + +_LOGGER = logging.getLogger(__name__) + +TRIGGER_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(CONF_PLATFORM): "time_pattern", + CONF_HOURS: vol.Any(vol.Coerce(int), vol.Coerce(str)), + CONF_MINUTES: vol.Any(vol.Coerce(int), vol.Coerce(str)), + CONF_SECONDS: vol.Any(vol.Coerce(int), vol.Coerce(str)), + } + ), + cv.has_at_least_one_key(CONF_HOURS, CONF_MINUTES, CONF_SECONDS), +) + + +async def async_attach_trigger(hass, config, action, automation_info): + """Listen for state changes based on configuration.""" + hours = config.get(CONF_HOURS) + minutes = config.get(CONF_MINUTES) + seconds = config.get(CONF_SECONDS) + + # If larger units are specified, default the smaller units to zero + if minutes is None and hours is not None: + minutes = 0 + if seconds is None and minutes is not None: + seconds = 0 + + @callback + def time_automation_listener(now): + """Listen for time changes and calls action.""" + hass.async_run_job( + action, {"trigger": {"platform": "time_pattern", "now": now}} + ) + + return async_track_time_change( + hass, time_automation_listener, hour=hours, minute=minutes, second=seconds + ) diff --git a/homeassistant/components/automation/webhook.py b/homeassistant/components/automation/webhook.py new file mode 100644 index 000000000..5d01c6454 --- /dev/null +++ b/homeassistant/components/automation/webhook.py @@ -0,0 +1,53 @@ +"""Offer webhook triggered automation rules.""" +from functools import partial +import logging + +from aiohttp import hdrs +import voluptuous as vol + +from homeassistant.const import CONF_PLATFORM, CONF_WEBHOOK_ID +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv + +from . import DOMAIN as AUTOMATION_DOMAIN + +# mypy: allow-untyped-defs + +DEPENDENCIES = ("webhook",) + +_LOGGER = logging.getLogger(__name__) + +TRIGGER_SCHEMA = vol.Schema( + {vol.Required(CONF_PLATFORM): "webhook", vol.Required(CONF_WEBHOOK_ID): cv.string} +) + + +async def _handle_webhook(action, hass, webhook_id, request): + """Handle incoming webhook.""" + result = {"platform": "webhook", "webhook_id": webhook_id} + + if "json" in request.headers.get(hdrs.CONTENT_TYPE, ""): + result["json"] = await request.json() + else: + result["data"] = await request.post() + + result["query"] = request.query + hass.async_run_job(action, {"trigger": result}) + + +async def async_attach_trigger(hass, config, action, automation_info): + """Trigger based on incoming webhooks.""" + webhook_id = config.get(CONF_WEBHOOK_ID) + hass.components.webhook.async_register( + AUTOMATION_DOMAIN, + automation_info["name"], + webhook_id, + partial(_handle_webhook, action), + ) + + @callback + def unregister(): + """Unregister webhook.""" + hass.components.webhook.async_unregister(webhook_id) + + return unregister diff --git a/homeassistant/components/automation/zone.py b/homeassistant/components/automation/zone.py index f30dfe753..3dba1a4df 100644 --- a/homeassistant/components/automation/zone.py +++ b/homeassistant/components/automation/zone.py @@ -1,34 +1,36 @@ -""" -Offer zone automation rules. - -For more details about this automation rule, please refer to the documentation -at https://home-assistant.io/docs/automation/trigger/#zone-trigger -""" -import asyncio +"""Offer zone automation rules.""" import voluptuous as vol -from homeassistant.core import callback from homeassistant.const import ( - CONF_EVENT, CONF_ENTITY_ID, CONF_ZONE, MATCH_ALL, CONF_PLATFORM) + CONF_ENTITY_ID, + CONF_EVENT, + CONF_PLATFORM, + CONF_ZONE, + MATCH_ALL, +) +from homeassistant.core import callback +from homeassistant.helpers import condition, config_validation as cv, location from homeassistant.helpers.event import async_track_state_change -from homeassistant.helpers import ( - condition, config_validation as cv, location) -EVENT_ENTER = 'enter' -EVENT_LEAVE = 'leave' +# mypy: allow-untyped-defs, no-check-untyped-defs + +EVENT_ENTER = "enter" +EVENT_LEAVE = "leave" DEFAULT_EVENT = EVENT_ENTER -TRIGGER_SCHEMA = vol.Schema({ - vol.Required(CONF_PLATFORM): 'zone', - vol.Required(CONF_ENTITY_ID): cv.entity_ids, - vol.Required(CONF_ZONE): cv.entity_id, - vol.Required(CONF_EVENT, default=DEFAULT_EVENT): - vol.Any(EVENT_ENTER, EVENT_LEAVE), -}) +TRIGGER_SCHEMA = vol.Schema( + { + vol.Required(CONF_PLATFORM): "zone", + vol.Required(CONF_ENTITY_ID): cv.entity_ids, + vol.Required(CONF_ZONE): cv.entity_id, + vol.Required(CONF_EVENT, default=DEFAULT_EVENT): vol.Any( + EVENT_ENTER, EVENT_LEAVE + ), + } +) -@asyncio.coroutine -def async_trigger(hass, config, action): +async def async_attach_trigger(hass, config, action, automation_info): """Listen for state changes based on configuration.""" entity_id = config.get(CONF_ENTITY_ID) zone_entity_id = config.get(CONF_ZONE) @@ -37,8 +39,11 @@ def async_trigger(hass, config, action): @callback def zone_automation_listener(entity, from_s, to_s): """Listen for state changes and calls action.""" - if from_s and not location.has_location(from_s) or \ - not location.has_location(to_s): + if ( + from_s + and not location.has_location(from_s) + or not location.has_location(to_s) + ): return zone_state = hass.states.get(zone_entity_id) @@ -49,18 +54,30 @@ def async_trigger(hass, config, action): to_match = condition.zone(hass, zone_state, to_s) # pylint: disable=too-many-boolean-expressions - if event == EVENT_ENTER and not from_match and to_match or \ - event == EVENT_LEAVE and from_match and not to_match: - hass.async_run_job(action({ - 'trigger': { - 'platform': 'zone', - 'entity_id': entity, - 'from_state': from_s, - 'to_state': to_s, - 'zone': zone_state, - 'event': event, - }, - }, context=to_s.context)) + if ( + event == EVENT_ENTER + and not from_match + and to_match + or event == EVENT_LEAVE + and from_match + and not to_match + ): + hass.async_run_job( + action( + { + "trigger": { + "platform": "zone", + "entity_id": entity, + "from_state": from_s, + "to_state": to_s, + "zone": zone_state, + "event": event, + } + }, + context=to_s.context, + ) + ) - return async_track_state_change(hass, entity_id, zone_automation_listener, - MATCH_ALL, MATCH_ALL) + return async_track_state_change( + hass, entity_id, zone_automation_listener, MATCH_ALL, MATCH_ALL + ) diff --git a/homeassistant/components/avea/__init__.py b/homeassistant/components/avea/__init__.py new file mode 100644 index 000000000..861c4f655 --- /dev/null +++ b/homeassistant/components/avea/__init__.py @@ -0,0 +1 @@ +"""The avea component.""" diff --git a/homeassistant/components/avea/light.py b/homeassistant/components/avea/light.py new file mode 100644 index 000000000..92d66a554 --- /dev/null +++ b/homeassistant/components/avea/light.py @@ -0,0 +1,91 @@ +"""Support for the Elgato Avea lights.""" +import logging + +import avea + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_HS_COLOR, + SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, + Light, +) +from homeassistant.exceptions import PlatformNotReady +import homeassistant.util.color as color_util + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_AVEA = SUPPORT_BRIGHTNESS | SUPPORT_COLOR + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Avea platform.""" + try: + nearby_bulbs = avea.discover_avea_bulbs() + for bulb in nearby_bulbs: + bulb.get_name() + bulb.get_brightness() + except OSError as err: + raise PlatformNotReady from err + + add_entities(AveaLight(bulb) for bulb in nearby_bulbs) + + +class AveaLight(Light): + """Representation of an Avea.""" + + def __init__(self, light): + """Initialize an AveaLight.""" + self._light = light + self._name = light.name + self._state = None + self._brightness = light.brightness + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_AVEA + + @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.""" + if not kwargs: + self._light.set_brightness(4095) + else: + if ATTR_BRIGHTNESS in kwargs: + bright = round((kwargs[ATTR_BRIGHTNESS] / 255) * 4095) + self._light.set_brightness(bright) + if ATTR_HS_COLOR in kwargs: + rgb = color_util.color_hs_to_RGB(*kwargs[ATTR_HS_COLOR]) + self._light.set_rgb(rgb[0], rgb[1], rgb[2]) + + def turn_off(self, **kwargs): + """Instruct the light to turn off.""" + self._light.set_brightness(0) + + def update(self): + """Fetch new state data for this light. + + This is the only method that should fetch new data for Home Assistant. + """ + brightness = self._light.get_brightness() + if brightness is not None: + if brightness == 0: + self._state = False + else: + self._state = True + self._brightness = round(255 * (brightness / 4095)) diff --git a/homeassistant/components/avea/manifest.json b/homeassistant/components/avea/manifest.json new file mode 100644 index 000000000..f6217eeed --- /dev/null +++ b/homeassistant/components/avea/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "avea", + "name": "Elgato Avea", + "documentation": "https://www.home-assistant.io/integrations/avea", + "dependencies": [], + "codeowners": ["@pattyland"], + "requirements": ["avea==1.4"] +} diff --git a/homeassistant/components/avion/__init__.py b/homeassistant/components/avion/__init__.py new file mode 100644 index 000000000..79e882225 --- /dev/null +++ b/homeassistant/components/avion/__init__.py @@ -0,0 +1 @@ +"""The avion component.""" diff --git a/homeassistant/components/avion/light.py b/homeassistant/components/avion/light.py new file mode 100644 index 000000000..0c95b2bf7 --- /dev/null +++ b/homeassistant/components/avion/light.py @@ -0,0 +1,146 @@ +"""Support for Avion dimmers.""" +import importlib +import logging +import time + +import voluptuous as vol + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + PLATFORM_SCHEMA, + SUPPORT_BRIGHTNESS, + Light, +) +from homeassistant.const import ( + CONF_API_KEY, + CONF_DEVICES, + CONF_ID, + CONF_NAME, + CONF_PASSWORD, + CONF_USERNAME, +) +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_AVION_LED = SUPPORT_BRIGHTNESS + +DEVICE_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_KEY): cv.string, + vol.Optional(CONF_ID): cv.positive_int, + vol.Optional(CONF_NAME): cv.string, + } +) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_DEVICES, default={}): {cv.string: DEVICE_SCHEMA}, + vol.Optional(CONF_USERNAME): cv.string, + vol.Optional(CONF_PASSWORD): cv.string, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up an Avion switch.""" + # pylint: disable=no-member + avion = importlib.import_module("avion") + + lights = [] + if CONF_USERNAME in config and CONF_PASSWORD in config: + devices = avion.get_devices(config[CONF_USERNAME], config[CONF_PASSWORD]) + for device in devices: + lights.append(AvionLight(device)) + + for address, device_config in config[CONF_DEVICES].items(): + device = avion.Avion( + mac=address, + passphrase=device_config[CONF_API_KEY], + name=device_config.get(CONF_NAME), + object_id=device_config.get(CONF_ID), + connect=False, + ) + lights.append(AvionLight(device)) + + add_entities(lights) + + +class AvionLight(Light): + """Representation of an Avion light.""" + + def __init__(self, device): + """Initialize the light.""" + self._name = device.name + self._address = device.mac + self._brightness = 255 + self._state = False + self._switch = device + + @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 self._brightness + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_AVION_LED + + @property + def should_poll(self): + """Don't poll.""" + return False + + @property + def assumed_state(self): + """We can't read the actual state, so assume it matches.""" + return True + + def set_state(self, brightness): + """Set the state of this lamp to the provided brightness.""" + # pylint: disable=no-member + avion = importlib.import_module("avion") + + # Bluetooth LE is unreliable, and the connection may drop at any + # time. Make an effort to re-establish the link. + initial = time.monotonic() + while True: + if time.monotonic() - initial >= 10: + return False + try: + self._switch.set_brightness(brightness) + break + except avion.AvionException: + self._switch.connect() + return True + + def turn_on(self, **kwargs): + """Turn the specified or all lights on.""" + brightness = kwargs.get(ATTR_BRIGHTNESS) + + if brightness is not None: + self._brightness = brightness + + self.set_state(self.brightness) + self._state = True + + def turn_off(self, **kwargs): + """Turn the specified or all lights off.""" + self.set_state(0) + self._state = False diff --git a/homeassistant/components/avion/manifest.json b/homeassistant/components/avion/manifest.json new file mode 100644 index 000000000..8bb8b56cb --- /dev/null +++ b/homeassistant/components/avion/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "avion", + "name": "Avion", + "documentation": "https://www.home-assistant.io/integrations/avion", + "requirements": [ + "avion==0.10" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/awair/__init__.py b/homeassistant/components/awair/__init__.py new file mode 100644 index 000000000..c9a08cb40 --- /dev/null +++ b/homeassistant/components/awair/__init__.py @@ -0,0 +1 @@ +"""The awair component.""" diff --git a/homeassistant/components/awair/manifest.json b/homeassistant/components/awair/manifest.json new file mode 100644 index 000000000..f4e632cb7 --- /dev/null +++ b/homeassistant/components/awair/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "awair", + "name": "Awair", + "documentation": "https://www.home-assistant.io/integrations/awair", + "requirements": [ + "python_awair==0.0.4" + ], + "dependencies": [], + "codeowners": [ + "@danielsjf" + ] +} diff --git a/homeassistant/components/awair/sensor.py b/homeassistant/components/awair/sensor.py new file mode 100644 index 000000000..f15e4a80e --- /dev/null +++ b/homeassistant/components/awair/sensor.py @@ -0,0 +1,244 @@ +"""Support for the Awair indoor air quality monitor.""" + +from datetime import timedelta +import logging +import math + +from python_awair import AwairClient +import voluptuous as vol + +from homeassistant.const import ( + CONF_ACCESS_TOKEN, + CONF_DEVICES, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_TEMPERATURE, + TEMP_CELSIUS, +) +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle, dt + +_LOGGER = logging.getLogger(__name__) + +ATTR_SCORE = "score" +ATTR_TIMESTAMP = "timestamp" +ATTR_LAST_API_UPDATE = "last_api_update" +ATTR_COMPONENT = "component" +ATTR_VALUE = "value" +ATTR_SENSORS = "sensors" + +CONF_UUID = "uuid" + +DEVICE_CLASS_PM2_5 = "PM2.5" +DEVICE_CLASS_PM10 = "PM10" +DEVICE_CLASS_CARBON_DIOXIDE = "CO2" +DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS = "VOC" +DEVICE_CLASS_SCORE = "score" + +SENSOR_TYPES = { + "TEMP": { + "device_class": DEVICE_CLASS_TEMPERATURE, + "unit_of_measurement": TEMP_CELSIUS, + "icon": "mdi:thermometer", + }, + "HUMID": { + "device_class": DEVICE_CLASS_HUMIDITY, + "unit_of_measurement": "%", + "icon": "mdi:water-percent", + }, + "CO2": { + "device_class": DEVICE_CLASS_CARBON_DIOXIDE, + "unit_of_measurement": "ppm", + "icon": "mdi:periodic-table-co2", + }, + "VOC": { + "device_class": DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, + "unit_of_measurement": "ppb", + "icon": "mdi:cloud", + }, + # Awair docs don't actually specify the size they measure for 'dust', + # but 2.5 allows the sensor to show up in HomeKit + "DUST": { + "device_class": DEVICE_CLASS_PM2_5, + "unit_of_measurement": "µg/m3", + "icon": "mdi:cloud", + }, + "PM25": { + "device_class": DEVICE_CLASS_PM2_5, + "unit_of_measurement": "µg/m3", + "icon": "mdi:cloud", + }, + "PM10": { + "device_class": DEVICE_CLASS_PM10, + "unit_of_measurement": "µg/m3", + "icon": "mdi:cloud", + }, + "score": { + "device_class": DEVICE_CLASS_SCORE, + "unit_of_measurement": "%", + "icon": "mdi:percent", + }, +} + +AWAIR_QUOTA = 300 + +# This is the minimum time between throttled update calls. +# Don't bother asking us for state more often than that. +SCAN_INTERVAL = timedelta(minutes=5) + +AWAIR_DEVICE_SCHEMA = vol.Schema({vol.Required(CONF_UUID): cv.string}) + +PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_ACCESS_TOKEN): cv.string, + vol.Optional(CONF_DEVICES): vol.All(cv.ensure_list, [AWAIR_DEVICE_SCHEMA]), + } +) + + +# Awair *heavily* throttles calls that get user information, +# and calls that get the list of user-owned devices - they +# allow 30 per DAY. So, we permit a user to provide a static +# list of devices, and they may provide the same set of information +# that the devices() call would return. However, the only thing +# used at this time is the `uuid` value. +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Connect to the Awair API and find devices.""" + + token = config[CONF_ACCESS_TOKEN] + client = AwairClient(token, session=async_get_clientsession(hass)) + + try: + all_devices = [] + devices = config.get(CONF_DEVICES, await client.devices()) + + # Try to throttle dynamically based on quota and number of devices. + throttle_minutes = math.ceil(60 / ((AWAIR_QUOTA / len(devices)) / 24)) + throttle = timedelta(minutes=throttle_minutes) + + for device in devices: + _LOGGER.debug("Found awair device: %s", device) + awair_data = AwairData(client, device[CONF_UUID], throttle) + await awair_data.async_update() + for sensor in SENSOR_TYPES: + if sensor in awair_data.data: + awair_sensor = AwairSensor(awair_data, device, sensor, throttle) + all_devices.append(awair_sensor) + + async_add_entities(all_devices, True) + return + except AwairClient.AuthError: + _LOGGER.error("Awair API access_token invalid") + except AwairClient.RatelimitError: + _LOGGER.error("Awair API ratelimit exceeded.") + except ( + AwairClient.QueryError, + AwairClient.NotFoundError, + AwairClient.GenericError, + ) as error: + _LOGGER.error("Unexpected Awair API error: %s", error) + + raise PlatformNotReady + + +class AwairSensor(Entity): + """Implementation of an Awair device.""" + + def __init__(self, data, device, sensor_type, throttle): + """Initialize the sensor.""" + self._uuid = device[CONF_UUID] + self._device_class = SENSOR_TYPES[sensor_type]["device_class"] + self._name = f"Awair {self._device_class}" + unit = SENSOR_TYPES[sensor_type]["unit_of_measurement"] + self._unit_of_measurement = unit + self._data = data + self._type = sensor_type + self._throttle = throttle + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def device_class(self): + """Return the device class.""" + return self._device_class + + @property + def icon(self): + """Icon to use in the frontend.""" + return SENSOR_TYPES[self._type]["icon"] + + @property + def state(self): + """Return the state of the device.""" + return self._data.data[self._type] + + @property + def device_state_attributes(self): + """Return additional attributes.""" + return self._data.attrs + + # The Awair device should be reporting metrics in quite regularly. + # Based on the raw data from the API, it looks like every ~10 seconds + # is normal. Here we assert that the device is not available if the + # last known API timestamp is more than (3 * throttle) minutes in the + # past. It implies that either hass is somehow unable to query the API + # for new data or that the device is not checking in. Either condition + # fits the definition for 'not available'. We pick (3 * throttle) minutes + # to allow for transient errors to correct themselves. + @property + def available(self): + """Device availability based on the last update timestamp.""" + if ATTR_LAST_API_UPDATE not in self.device_state_attributes: + return False + + last_api_data = self.device_state_attributes[ATTR_LAST_API_UPDATE] + return (dt.utcnow() - last_api_data) < (3 * self._throttle) + + @property + def unique_id(self): + """Return the unique id of this entity.""" + return f"{self._uuid}_{self._type}" + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity.""" + return self._unit_of_measurement + + async def async_update(self): + """Get the latest data.""" + await self._data.async_update() + + +class AwairData: + """Get data from Awair API.""" + + def __init__(self, client, uuid, throttle): + """Initialize the data object.""" + self._client = client + self._uuid = uuid + self.data = {} + self.attrs = {} + self.async_update = Throttle(throttle)(self._async_update) + + async def _async_update(self): + """Get the data from Awair API.""" + resp = await self._client.air_data_latest(self._uuid) + + if not resp: + return + + timestamp = dt.parse_datetime(resp[0][ATTR_TIMESTAMP]) + self.attrs[ATTR_LAST_API_UPDATE] = timestamp + self.data[ATTR_SCORE] = resp[0][ATTR_SCORE] + + # The air_data_latest call only returns one item, so this should + # be safe to only process one entry. + for sensor in resp[0][ATTR_SENSORS]: + self.data[sensor[ATTR_COMPONENT]] = round(sensor[ATTR_VALUE], 1) + + _LOGGER.debug("Got Awair Data for %s: %s", self._uuid, self.data) diff --git a/homeassistant/components/aws/__init__.py b/homeassistant/components/aws/__init__.py new file mode 100644 index 000000000..600874b0d --- /dev/null +++ b/homeassistant/components/aws/__init__.py @@ -0,0 +1,176 @@ +"""Support for Amazon Web Services (AWS).""" +import asyncio +from collections import OrderedDict +import logging + +import aiobotocore +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import ATTR_CREDENTIALS, CONF_NAME, CONF_PROFILE_NAME +from homeassistant.helpers import config_validation as cv, discovery + +# Loading the config flow file will register the flow +from . import config_flow # noqa: F401 +from .const import ( + CONF_ACCESS_KEY_ID, + CONF_CONTEXT, + CONF_CREDENTIAL_NAME, + CONF_CREDENTIALS, + CONF_NOTIFY, + CONF_REGION, + CONF_SECRET_ACCESS_KEY, + CONF_SERVICE, + CONF_VALIDATE, + DATA_CONFIG, + DATA_HASS_CONFIG, + DATA_SESSIONS, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + +AWS_CREDENTIAL_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAME): cv.string, + vol.Inclusive(CONF_ACCESS_KEY_ID, ATTR_CREDENTIALS): cv.string, + vol.Inclusive(CONF_SECRET_ACCESS_KEY, ATTR_CREDENTIALS): cv.string, + vol.Exclusive(CONF_PROFILE_NAME, ATTR_CREDENTIALS): cv.string, + vol.Optional(CONF_VALIDATE, default=True): cv.boolean, + } +) + +DEFAULT_CREDENTIAL = [ + {CONF_NAME: "default", CONF_PROFILE_NAME: "default", CONF_VALIDATE: False} +] + +SUPPORTED_SERVICES = ["lambda", "sns", "sqs"] + +NOTIFY_PLATFORM_SCHEMA = vol.Schema( + { + vol.Optional(CONF_NAME): cv.string, + vol.Required(CONF_SERVICE): vol.All( + cv.string, vol.Lower, vol.In(SUPPORTED_SERVICES) + ), + vol.Required(CONF_REGION): vol.All(cv.string, vol.Lower), + vol.Inclusive(CONF_ACCESS_KEY_ID, ATTR_CREDENTIALS): cv.string, + vol.Inclusive(CONF_SECRET_ACCESS_KEY, ATTR_CREDENTIALS): cv.string, + vol.Exclusive(CONF_PROFILE_NAME, ATTR_CREDENTIALS): cv.string, + vol.Exclusive(CONF_CREDENTIAL_NAME, ATTR_CREDENTIALS): cv.string, + vol.Optional(CONF_CONTEXT): vol.Coerce(dict), + } +) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Optional(CONF_CREDENTIALS, default=DEFAULT_CREDENTIAL): vol.All( + cv.ensure_list, [AWS_CREDENTIAL_SCHEMA] + ), + vol.Optional(CONF_NOTIFY, default=[]): vol.All( + cv.ensure_list, [NOTIFY_PLATFORM_SCHEMA] + ), + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, config): + """Set up AWS component.""" + hass.data[DATA_HASS_CONFIG] = config + + conf = config.get(DOMAIN) + if conf is None: + # create a default conf using default profile + conf = CONFIG_SCHEMA({ATTR_CREDENTIALS: DEFAULT_CREDENTIAL}) + + hass.data[DATA_CONFIG] = conf + hass.data[DATA_SESSIONS] = OrderedDict() + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=conf + ) + ) + + return True + + +async def async_setup_entry(hass, entry): + """Load a config entry. + + Validate and save sessions per aws credential. + """ + config = hass.data.get(DATA_HASS_CONFIG) + conf = hass.data.get(DATA_CONFIG) + + if entry.source == config_entries.SOURCE_IMPORT: + if conf is None: + # user removed config from configuration.yaml, abort setup + hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) + return False + + if conf != entry.data: + # user changed config from configuration.yaml, use conf to setup + hass.config_entries.async_update_entry(entry, data=conf) + + if conf is None: + conf = CONFIG_SCHEMA({DOMAIN: entry.data})[DOMAIN] + + # validate credentials and create sessions + validation = True + tasks = [] + for cred in conf[ATTR_CREDENTIALS]: + tasks.append(_validate_aws_credentials(hass, cred)) + if tasks: + results = await asyncio.gather(*tasks, return_exceptions=True) + for index, result in enumerate(results): + name = conf[ATTR_CREDENTIALS][index][CONF_NAME] + if isinstance(result, Exception): + _LOGGER.error( + "Validating credential [%s] failed: %s", + name, + result, + exc_info=result, + ) + validation = False + else: + hass.data[DATA_SESSIONS][name] = result + + # set up notify platform, no entry support for notify component yet, + # have to use discovery to load platform. + for notify_config in conf[CONF_NOTIFY]: + hass.async_create_task( + discovery.async_load_platform(hass, "notify", DOMAIN, notify_config, config) + ) + + return validation + + +async def _validate_aws_credentials(hass, credential): + """Validate AWS credential config.""" + + aws_config = credential.copy() + del aws_config[CONF_NAME] + del aws_config[CONF_VALIDATE] + + profile = aws_config.get(CONF_PROFILE_NAME) + + if profile is not None: + session = aiobotocore.AioSession(profile=profile) + del aws_config[CONF_PROFILE_NAME] + if CONF_ACCESS_KEY_ID in aws_config: + del aws_config[CONF_ACCESS_KEY_ID] + if CONF_SECRET_ACCESS_KEY in aws_config: + del aws_config[CONF_SECRET_ACCESS_KEY] + else: + session = aiobotocore.AioSession() + + if credential[CONF_VALIDATE]: + async with session.create_client("iam", **aws_config) as client: + await client.get_user() + + return session diff --git a/homeassistant/components/aws/config_flow.py b/homeassistant/components/aws/config_flow.py new file mode 100644 index 000000000..6ac332b25 --- /dev/null +++ b/homeassistant/components/aws/config_flow.py @@ -0,0 +1,20 @@ +"""Config flow for AWS component.""" + +from homeassistant import config_entries + +from .const import DOMAIN + + +@config_entries.HANDLERS.register(DOMAIN) +class AWSFlowHandler(config_entries.ConfigFlow): + """Handle a config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH + + async def async_step_import(self, user_input): + """Import a config entry.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + return self.async_create_entry(title="configuration.yaml", data=user_input) diff --git a/homeassistant/components/aws/const.py b/homeassistant/components/aws/const.py new file mode 100644 index 000000000..499f44135 --- /dev/null +++ b/homeassistant/components/aws/const.py @@ -0,0 +1,17 @@ +"""Constant for AWS component.""" +DOMAIN = "aws" + +DATA_CONFIG = "aws_config" +DATA_HASS_CONFIG = "aws_hass_config" +DATA_SESSIONS = "aws_sessions" + +CONF_ACCESS_KEY_ID = "aws_access_key_id" +CONF_CONTEXT = "context" +CONF_CREDENTIAL_NAME = "credential_name" +CONF_CREDENTIALS = "credentials" +CONF_NOTIFY = "notify" +CONF_PROFILE_NAME = "profile_name" +CONF_REGION = "region_name" +CONF_SECRET_ACCESS_KEY = "aws_secret_access_key" +CONF_SERVICE = "service" +CONF_VALIDATE = "validate" diff --git a/homeassistant/components/aws/manifest.json b/homeassistant/components/aws/manifest.json new file mode 100644 index 000000000..b617eb75e --- /dev/null +++ b/homeassistant/components/aws/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "aws", + "name": "Aws", + "documentation": "https://www.home-assistant.io/integrations/aws", + "requirements": [ + "aiobotocore==0.10.4" + ], + "dependencies": [], + "codeowners": [ + "@awarecan", + "@robbiet480" + ] +} diff --git a/homeassistant/components/aws/notify.py b/homeassistant/components/aws/notify.py new file mode 100644 index 000000000..13fa189a3 --- /dev/null +++ b/homeassistant/components/aws/notify.py @@ -0,0 +1,237 @@ +"""AWS platform for notify component.""" +import asyncio +import base64 +import json +import logging + +import aiobotocore + +from homeassistant.components.notify import ( + ATTR_TARGET, + ATTR_TITLE, + ATTR_TITLE_DEFAULT, + BaseNotificationService, +) +from homeassistant.const import CONF_NAME, CONF_PLATFORM +from homeassistant.helpers.json import JSONEncoder + +from .const import ( + CONF_CONTEXT, + CONF_CREDENTIAL_NAME, + CONF_PROFILE_NAME, + CONF_REGION, + CONF_SERVICE, + DATA_SESSIONS, +) + +_LOGGER = logging.getLogger(__name__) + + +async def get_available_regions(hass, service): + """Get available regions for a service.""" + + session = aiobotocore.get_session() + # get_available_regions is not a coroutine since it does not perform + # network I/O. But it still perform file I/O heavily, so put it into + # an executor thread to unblock event loop + return await hass.async_add_executor_job(session.get_available_regions, service) + + +async def async_get_service(hass, config, discovery_info=None): + """Get the AWS notification service.""" + if discovery_info is None: + _LOGGER.error("Please config aws notify platform in aws component") + return None + + session = None + + conf = discovery_info + + service = conf[CONF_SERVICE] + region_name = conf[CONF_REGION] + + available_regions = await get_available_regions(hass, service) + if region_name not in available_regions: + _LOGGER.error( + "Region %s is not available for %s service, must in %s", + region_name, + service, + available_regions, + ) + return None + + aws_config = conf.copy() + + del aws_config[CONF_SERVICE] + del aws_config[CONF_REGION] + if CONF_PLATFORM in aws_config: + del aws_config[CONF_PLATFORM] + if CONF_NAME in aws_config: + del aws_config[CONF_NAME] + if CONF_CONTEXT in aws_config: + del aws_config[CONF_CONTEXT] + + if not aws_config: + # no platform config, use the first aws component credential instead + if hass.data[DATA_SESSIONS]: + session = next(iter(hass.data[DATA_SESSIONS].values())) + else: + _LOGGER.error("Missing aws credential for %s", config[CONF_NAME]) + return None + + if session is None: + credential_name = aws_config.get(CONF_CREDENTIAL_NAME) + if credential_name is not None: + session = hass.data[DATA_SESSIONS].get(credential_name) + if session is None: + _LOGGER.warning("No available aws session for %s", credential_name) + del aws_config[CONF_CREDENTIAL_NAME] + + if session is None: + profile = aws_config.get(CONF_PROFILE_NAME) + if profile is not None: + session = aiobotocore.AioSession(profile=profile) + del aws_config[CONF_PROFILE_NAME] + else: + session = aiobotocore.AioSession() + + aws_config[CONF_REGION] = region_name + + if service == "lambda": + context_str = json.dumps( + {"custom": conf.get(CONF_CONTEXT, {})}, cls=JSONEncoder + ) + context_b64 = base64.b64encode(context_str.encode("utf-8")) + context = context_b64.decode("utf-8") + return AWSLambda(session, aws_config, context) + + if service == "sns": + return AWSSNS(session, aws_config) + + if service == "sqs": + return AWSSQS(session, aws_config) + + # should not reach here since service was checked in schema + return None + + +class AWSNotify(BaseNotificationService): + """Implement the notification service for the AWS service.""" + + def __init__(self, session, aws_config): + """Initialize the service.""" + self.session = session + self.aws_config = aws_config + + +class AWSLambda(AWSNotify): + """Implement the notification service for the AWS Lambda service.""" + + service = "lambda" + + def __init__(self, session, aws_config, context): + """Initialize the service.""" + super().__init__(session, aws_config) + self.context = context + + async def async_send_message(self, message="", **kwargs): + """Send notification to specified LAMBDA ARN.""" + if not kwargs.get(ATTR_TARGET): + _LOGGER.error("At least one target is required") + return + + cleaned_kwargs = {k: v for k, v in kwargs.items() if v is not None} + payload = {"message": message} + payload.update(cleaned_kwargs) + json_payload = json.dumps(payload) + + async with self.session.create_client( + self.service, **self.aws_config + ) as client: + tasks = [] + for target in kwargs.get(ATTR_TARGET, []): + tasks.append( + client.invoke( + FunctionName=target, + Payload=json_payload, + ClientContext=self.context, + ) + ) + + if tasks: + await asyncio.gather(*tasks) + + +class AWSSNS(AWSNotify): + """Implement the notification service for the AWS SNS service.""" + + service = "sns" + + async def async_send_message(self, message="", **kwargs): + """Send notification to specified SNS ARN.""" + if not kwargs.get(ATTR_TARGET): + _LOGGER.error("At least one target is required") + return + + message_attributes = { + k: {"StringValue": json.dumps(v), "DataType": "String"} + for k, v in kwargs.items() + if v is not None + } + subject = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) + + async with self.session.create_client( + self.service, **self.aws_config + ) as client: + tasks = [] + for target in kwargs.get(ATTR_TARGET, []): + tasks.append( + client.publish( + TargetArn=target, + Message=message, + Subject=subject, + MessageAttributes=message_attributes, + ) + ) + + if tasks: + await asyncio.gather(*tasks) + + +class AWSSQS(AWSNotify): + """Implement the notification service for the AWS SQS service.""" + + service = "sqs" + + async def async_send_message(self, message="", **kwargs): + """Send notification to specified SQS ARN.""" + if not kwargs.get(ATTR_TARGET): + _LOGGER.error("At least one target is required") + return + + cleaned_kwargs = {k: v for k, v in kwargs.items() if v is not None} + message_body = {"message": message} + message_body.update(cleaned_kwargs) + json_body = json.dumps(message_body) + message_attributes = {} + for key, val in cleaned_kwargs.items(): + message_attributes[key] = { + "StringValue": json.dumps(val), + "DataType": "String", + } + + async with self.session.create_client( + self.service, **self.aws_config + ) as client: + tasks = [] + for target in kwargs.get(ATTR_TARGET, []): + tasks.append( + client.send_message( + QueueUrl=target, + MessageBody=json_body, + MessageAttributes=message_attributes, + ) + ) + + if tasks: + await asyncio.gather(*tasks) diff --git a/homeassistant/components/axis.py b/homeassistant/components/axis.py deleted file mode 100644 index 71894364f..000000000 --- a/homeassistant/components/axis.py +++ /dev/null @@ -1,302 +0,0 @@ -""" -Support for Axis devices. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/axis/ -""" -import logging - -import voluptuous as vol - -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 dispatcher_send -from homeassistant.helpers.entity import Entity -from homeassistant.util.json import load_json, save_json - -REQUIREMENTS = ['axis==14'] - -_LOGGER = logging.getLogger(__name__) - -DOMAIN = 'axis' -CONFIG_FILE = 'axis.conf' - -AXIS_DEVICES = {} - -EVENT_TYPES = ['motion', 'vmd3', 'pir', 'sound', - 'daynight', 'tampering', 'input'] - -PLATFORMS = ['camera'] - -AXIS_INCLUDE = EVENT_TYPES + PLATFORMS - -AXIS_DEFAULT_HOST = '192.168.0.90' -AXIS_DEFAULT_USERNAME = 'root' -AXIS_DEFAULT_PASSWORD = 'pass' - -DEVICE_SCHEMA = vol.Schema({ - vol.Required(CONF_INCLUDE): - vol.All(cv.ensure_list, [vol.In(AXIS_INCLUDE)]), - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_HOST, default=AXIS_DEFAULT_HOST): cv.string, - 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, -}) - -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - cv.slug: DEVICE_SCHEMA, - }), -}, extra=vol.ALLOW_EXTRA) - -SERVICE_VAPIX_CALL = 'vapix_call' -SERVICE_VAPIX_CALL_RESPONSE = 'vapix_call_response' -SERVICE_CGI = 'cgi' -SERVICE_ACTION = 'action' -SERVICE_PARAM = 'param' -SERVICE_DEFAULT_CGI = 'param.cgi' -SERVICE_DEFAULT_ACTION = 'update' - -SERVICE_SCHEMA = vol.Schema({ - vol.Required(CONF_NAME): cv.string, - vol.Required(SERVICE_PARAM): cv.string, - vol.Optional(SERVICE_CGI, default=SERVICE_DEFAULT_CGI): cv.string, - vol.Optional(SERVICE_ACTION, default=SERVICE_DEFAULT_ACTION): cv.string, -}) - - -def request_configuration(hass, config, name, host, serialnumber): - """Request configuration steps from the user.""" - configurator = hass.components.configurator - - def configuration_callback(callback_data): - """Call when configuration is submitted.""" - if CONF_INCLUDE not in callback_data: - configurator.notify_errors( - request_id, "Functionality mandatory.") - return False - - callback_data[CONF_INCLUDE] = callback_data[CONF_INCLUDE].split() - callback_data[CONF_HOST] = host - - if CONF_NAME not in callback_data: - callback_data[CONF_NAME] = name - - try: - device_config = DEVICE_SCHEMA(callback_data) - except vol.Invalid: - configurator.notify_errors( - request_id, "Bad input, please check spelling.") - return False - - 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.") - return False - - title = '{} ({})'.format(name, host) - request_id = configurator.request_config( - title, configuration_callback, - description='Functionality: ' + str(AXIS_INCLUDE), - entity_picture="/static/images/logo_axis.png", - link_name='Axis platform documentation', - link_url='https://home-assistant.io/components/axis/', - submit_caption="Confirm", - fields=[ - {'id': CONF_NAME, - 'name': "Device name", - 'type': 'text'}, - {'id': CONF_USERNAME, - 'name': "User name", - 'type': 'text'}, - {'id': CONF_PASSWORD, - 'name': 'Password', - 'type': 'password'}, - {'id': CONF_INCLUDE, - 'name': "Device functionality (space separated list)", - 'type': 'text'}, - {'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'}, - ] - ) - - -def setup(hass, config): - """Set up for Axis devices.""" - def _shutdown(call): - """Stop the event stream on shutdown.""" - for serialnumber, device in AXIS_DEVICES.items(): - _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): - """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 = load_json(hass.config.path(CONFIG_FILE)) - if serialnumber in config_file: - # Device config previously saved to file - try: - 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, device_config): - _LOGGER.error( - "Couldn't set up %s", device_config[CONF_NAME]) - else: - # New device, create configuration request for UI - request_configuration(hass, config, name, host, serialnumber) - else: - # Device already registered, but on a different IP - device = AXIS_DEVICES[serialnumber] - 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 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.vapix.do_request( - call.data[SERVICE_CGI], - call.data[SERVICE_ACTION], - call.data[SERVICE_PARAM]) - hass.bus.fire(SERVICE_VAPIX_CALL_RESPONSE, response) - return True - _LOGGER.info("Couldn't find device %s", call.data[CONF_NAME]) - return False - - # Register service with Home Assistant. - hass.services.register( - DOMAIN, SERVICE_VAPIX_CALL, vapix_service, schema=SERVICE_SCHEMA) - return True - - -def setup_device(hass, config, device_config): - """Set up an Axis device.""" - from axis import AxisDevice - - 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", device_config[CONF_HOST]) - return False - - 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 - - -class AxisDeviceEvent(Entity): - """Representation of a Axis device event.""" - - def __init__(self, event_config): - """Initialize the event.""" - 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): - """Update the sensor's state, if needed.""" - self.schedule_update_ha_state(True) - - @property - def name(self): - """Return the name of the event.""" - return self._name - - @property - def device_class(self): - """Return the class of the event.""" - return self.axis_event.event_class - - @property - def should_poll(self): - """Return the polling state. No polling needed.""" - return False - - @property - def device_state_attributes(self): - """Return the state attributes of the event.""" - attr = {} - - tripped = self.axis_event.is_tripped - attr[ATTR_TRIPPED] = 'True' if tripped else 'False' - - attr[ATTR_LOCATION] = self.location - - return attr diff --git a/homeassistant/components/axis/.translations/bg.json b/homeassistant/components/axis/.translations/bg.json new file mode 100644 index 000000000..c56822ba5 --- /dev/null +++ b/homeassistant/components/axis/.translations/bg.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e", + "bad_config_file": "\u041b\u043e\u0448\u0438 \u0434\u0430\u043d\u043d\u0438 \u043e\u0442 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u043e\u043d\u043d\u0438\u044f \u0444\u0430\u0439\u043b", + "link_local_address": "\u041b\u043e\u043a\u0430\u043b\u043d\u0438 \u0430\u0434\u0440\u0435\u0441\u0438 \u043d\u0435 \u0441\u0435 \u043f\u043e\u0434\u0434\u044a\u0440\u0436\u0430\u0442", + "not_axis_device": "\u041e\u0442\u043a\u0440\u0438\u0442\u043e\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u0435 Axis" + }, + "error": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e", + "already_in_progress": "\u0412 \u043c\u043e\u043c\u0435\u043d\u0442\u0430 \u0442\u0435\u0447\u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e.", + "device_unavailable": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u043d\u0435 \u0435 \u043d\u0430\u043b\u0438\u0447\u043d\u043e", + "faulty_credentials": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u0438 \u0438\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u043e\u043d\u043d\u0438 \u0434\u0430\u043d\u043d\u0438" + }, + "flow_title": "Axis \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e: {name} ({host})", + "step": { + "user": { + "data": { + "host": "\u0410\u0434\u0440\u0435\u0441", + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "port": "\u041f\u043e\u0440\u0442", + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" + }, + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0432\u0430\u043d\u0435 \u043d\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043e\u0442 Axis" + } + }, + "title": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e Axis" + } +} \ No newline at end of file diff --git a/homeassistant/components/axis/.translations/ca.json b/homeassistant/components/axis/.translations/ca.json new file mode 100644 index 000000000..3458dcc45 --- /dev/null +++ b/homeassistant/components/axis/.translations/ca.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat", + "bad_config_file": "Dades incorrectes del fitxer de configuraci\u00f3", + "link_local_address": "L'enlla\u00e7 d'adreces locals no est\u00e0 disponible", + "not_axis_device": "El dispositiu descobert no \u00e9s un dispositiu Axis" + }, + "error": { + "already_configured": "El dispositiu ja est\u00e0 configurat", + "already_in_progress": "El flux de dades pel dispositiu ja est\u00e0 en curs.", + "device_unavailable": "El dispositiu no est\u00e0 disponible", + "faulty_credentials": "Credencials d'usuari incorrectes" + }, + "flow_title": "Dispositiu d'eix: {name} ({host})", + "step": { + "user": { + "data": { + "host": "Amfitri\u00f3", + "password": "Contrasenya", + "port": "Port", + "username": "Nom d'usuari" + }, + "title": "Configuraci\u00f3 de dispositiu Axis" + } + }, + "title": "Dispositiu Axis" + } +} \ No newline at end of file diff --git a/homeassistant/components/axis/.translations/cs.json b/homeassistant/components/axis/.translations/cs.json new file mode 100644 index 000000000..258f301e4 --- /dev/null +++ b/homeassistant/components/axis/.translations/cs.json @@ -0,0 +1,5 @@ +{ + "config": { + "flow_title": "Za\u0159\u00edzen\u00ed Axis: {name} ({host})" + } +} \ No newline at end of file diff --git a/homeassistant/components/axis/.translations/da.json b/homeassistant/components/axis/.translations/da.json new file mode 100644 index 000000000..c169f85f2 --- /dev/null +++ b/homeassistant/components/axis/.translations/da.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Enheden er allerede konfigureret", + "bad_config_file": "Forkerte data fra konfigurationsfilen", + "link_local_address": "Link lokale adresser underst\u00f8ttes ikke", + "not_axis_device": "Fundet enhed ikke en Axis enhed" + }, + "error": { + "already_configured": "Enheden er allerede konfigureret", + "already_in_progress": "Enheds konfiguration er allerede i gang.", + "device_unavailable": "Enheden er ikke tilg\u00e6ngelig", + "faulty_credentials": "Ugyldige legitimationsoplysninger" + }, + "flow_title": "Axis enhed: {name} ({host})", + "step": { + "user": { + "data": { + "host": "V\u00e6rt", + "password": "Adgangskode", + "port": "Port", + "username": "Brugernavn" + }, + "title": "Konfigurer Axis enhed" + } + }, + "title": "Axis enhed" + } +} \ No newline at end of file diff --git a/homeassistant/components/axis/.translations/de.json b/homeassistant/components/axis/.translations/de.json new file mode 100644 index 000000000..05c1853d7 --- /dev/null +++ b/homeassistant/components/axis/.translations/de.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "bad_config_file": "Fehlerhafte Daten aus der Konfigurationsdatei", + "link_local_address": "Link-local Adressen werden nicht unterst\u00fctzt", + "not_axis_device": "Erkanntes Ger\u00e4t ist kein Axis-Ger\u00e4t" + }, + "error": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "already_in_progress": "Der Konfigurationsablauf f\u00fcr das Ger\u00e4t wird bereits ausgef\u00fchrt.", + "device_unavailable": "Ger\u00e4t ist nicht verf\u00fcgbar", + "faulty_credentials": "Ung\u00fcltige Anmeldeinformationen" + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Passwort", + "port": "Port", + "username": "Benutzername" + }, + "title": "Axis Ger\u00e4t einrichten" + } + }, + "title": "Axis Ger\u00e4t" + } +} \ No newline at end of file diff --git a/homeassistant/components/axis/.translations/en.json b/homeassistant/components/axis/.translations/en.json new file mode 100644 index 000000000..c7d84aa8c --- /dev/null +++ b/homeassistant/components/axis/.translations/en.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "bad_config_file": "Bad data from config file", + "link_local_address": "Link local addresses are not supported", + "not_axis_device": "Discovered device not an Axis device" + }, + "error": { + "already_configured": "Device is already configured", + "already_in_progress": "Config flow for device is already in progress.", + "device_unavailable": "Device is not available", + "faulty_credentials": "Bad user credentials" + }, + "flow_title": "Axis device: {name} ({host})", + "step": { + "user": { + "data": { + "host": "Host", + "password": "Password", + "port": "Port", + "username": "Username" + }, + "title": "Set up Axis device" + } + }, + "title": "Axis device" + } +} \ No newline at end of file diff --git a/homeassistant/components/axis/.translations/es-419.json b/homeassistant/components/axis/.translations/es-419.json new file mode 100644 index 000000000..c5404a173 --- /dev/null +++ b/homeassistant/components/axis/.translations/es-419.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado", + "bad_config_file": "Datos err\u00f3neos del archivo de configuraci\u00f3n", + "link_local_address": "Las direcciones locales de enlace no son compatibles", + "not_axis_device": "El dispositivo descubierto no es un dispositivo de Axis" + }, + "error": { + "already_configured": "El dispositivo ya est\u00e1 configurado", + "already_in_progress": "El flujo de configuraci\u00f3n para el dispositivo ya est\u00e1 en progreso.", + "device_unavailable": "El dispositivo no est\u00e1 disponible", + "faulty_credentials": "Credenciales de usuario incorrectas" + }, + "step": { + "user": { + "data": { + "password": "Contrase\u00f1a", + "port": "Puerto", + "username": "Nombre de usuario" + }, + "title": "Configurar dispositivo Axis" + } + }, + "title": "Dispositivo Axis" + } +} \ No newline at end of file diff --git a/homeassistant/components/axis/.translations/es.json b/homeassistant/components/axis/.translations/es.json new file mode 100644 index 000000000..3f7db674f --- /dev/null +++ b/homeassistant/components/axis/.translations/es.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado", + "bad_config_file": "Datos err\u00f3neos en el archivo de configuraci\u00f3n", + "link_local_address": "Las direcciones de enlace locales no son compatibles", + "not_axis_device": "El dispositivo descubierto no es un dispositivo de Axis" + }, + "error": { + "already_configured": "El dispositivo ya est\u00e1 configurado", + "already_in_progress": "El flujo de configuraci\u00f3n del dispositivo ya est\u00e1 en curso.", + "device_unavailable": "El dispositivo no est\u00e1 disponible", + "faulty_credentials": "Credenciales de usuario incorrectas" + }, + "flow_title": "Dispositivo Axis: {name} ({host})", + "step": { + "user": { + "data": { + "host": "Host", + "password": "Contrase\u00f1a", + "port": "Puerto", + "username": "Nombre de usuario" + }, + "title": "Configurar dispositivo Axis" + } + }, + "title": "Dispositivo Axis" + } +} \ No newline at end of file diff --git a/homeassistant/components/axis/.translations/fr.json b/homeassistant/components/axis/.translations/fr.json new file mode 100644 index 000000000..608e12d02 --- /dev/null +++ b/homeassistant/components/axis/.translations/fr.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "bad_config_file": "Mauvaises donn\u00e9es du fichier de configuration", + "link_local_address": "Les adresses locales ne sont pas prises en charge", + "not_axis_device": "L'appareil d\u00e9couvert n'est pas un appareil Axis" + }, + "error": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "Le flux de configuration de l'appareil est d\u00e9j\u00e0 en cours.", + "device_unavailable": "L'appareil n'est pas disponible", + "faulty_credentials": "Mauvaises informations d'identification de l'utilisateur" + }, + "flow_title": "Appareil Axis: {name} ( {host} )", + "step": { + "user": { + "data": { + "host": "H\u00f4te", + "password": "Mot de passe", + "port": "Port", + "username": "Nom d'utilisateur" + }, + "title": "Configurer l'appareil Axis" + } + }, + "title": "Appareil Axis" + } +} \ No newline at end of file diff --git a/homeassistant/components/axis/.translations/hu.json b/homeassistant/components/axis/.translations/hu.json new file mode 100644 index 000000000..41dd3c00d --- /dev/null +++ b/homeassistant/components/axis/.translations/hu.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "already_configured": "Az eszk\u00f6zt m\u00e1r konfigur\u00e1ltuk", + "device_unavailable": "Az eszk\u00f6z nem \u00e9rhet\u0151 el", + "faulty_credentials": "Rossz felhaszn\u00e1l\u00f3i hiteles\u00edt\u0151 adatok" + }, + "step": { + "user": { + "data": { + "host": "Hoszt", + "password": "Jelsz\u00f3", + "port": "Port", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + } + } + }, + "title": "Axis eszk\u00f6z" + } +} \ No newline at end of file diff --git a/homeassistant/components/axis/.translations/it.json b/homeassistant/components/axis/.translations/it.json new file mode 100644 index 000000000..3f303140c --- /dev/null +++ b/homeassistant/components/axis/.translations/it.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "bad_config_file": "Dati errati dal file di configurazione", + "link_local_address": "Gli indirizzi locali di collegamento non sono supportati", + "not_axis_device": "Il dispositivo rilevato non \u00e8 un dispositivo Axis" + }, + "error": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "already_in_progress": "Il flusso di configurazione per il dispositivo \u00e8 gi\u00e0 in corso.", + "device_unavailable": "Il dispositivo non \u00e8 disponibile", + "faulty_credentials": "Credenziali utente non valide" + }, + "flow_title": "Dispositivo Axis: {name} ({host})", + "step": { + "user": { + "data": { + "host": "Host", + "password": "Password", + "port": "Porta", + "username": "Nome utente" + }, + "title": "Impostazione del dispositivo Axis" + } + }, + "title": "Dispositivo Axis" + } +} \ No newline at end of file diff --git a/homeassistant/components/axis/.translations/ko.json b/homeassistant/components/axis/.translations/ko.json new file mode 100644 index 000000000..f02b7cdce --- /dev/null +++ b/homeassistant/components/axis/.translations/ko.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "bad_config_file": "\uad6c\uc131 \ud30c\uc77c\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "link_local_address": "\ub85c\uceec \uc8fc\uc18c \uc5f0\uacb0\uc740 \uc9c0\uc6d0\ub418\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4", + "not_axis_device": "\ubc1c\uacac\ub41c \uae30\uae30\ub294 Axis \uae30\uae30\uac00 \uc544\ub2d9\ub2c8\ub2e4" + }, + "error": { + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "already_in_progress": "\uae30\uae30 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589\uc911\uc785\ub2c8\ub2e4.", + "device_unavailable": "\uae30\uae30\ub97c \uc0ac\uc6a9\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", + "faulty_credentials": "\uc0ac\uc6a9\uc790 \uc774\ub984 \ud639\uc740 \ube44\ubc00\ubc88\ud638\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "flow_title": "Axis \uae30\uae30: {name} ({host})", + "step": { + "user": { + "data": { + "host": "\ud638\uc2a4\ud2b8", + "password": "\ube44\ubc00\ubc88\ud638", + "port": "\ud3ec\ud2b8", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" + }, + "title": "Axis \uae30\uae30 \uc124\uc815" + } + }, + "title": "Axis \uae30\uae30" + } +} \ No newline at end of file diff --git a/homeassistant/components/axis/.translations/lb.json b/homeassistant/components/axis/.translations/lb.json new file mode 100644 index 000000000..24ee0e241 --- /dev/null +++ b/homeassistant/components/axis/.translations/lb.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Apparat ass scho konfigur\u00e9iert", + "bad_config_file": "Feelerhaft Donn\u00e9e\u00eb aus der Konfiguratioun's Datei", + "link_local_address": "Lokal Link Adressen ginn net \u00ebnnerst\u00ebtzt", + "not_axis_device": "Entdeckten Apparat ass keen Axis Apparat" + }, + "error": { + "already_configured": "Apparat ass scho konfigur\u00e9iert", + "already_in_progress": "Konfiguratioun fir d\u00ebsen Apparat ass schonn am gaang.", + "device_unavailable": "Apparat ass net erreechbar", + "faulty_credentials": "Ong\u00eblteg Login Informatioune" + }, + "flow_title": "Axis Apparat: {name} ({host})", + "step": { + "user": { + "data": { + "host": "Apparat", + "password": "Passwuert", + "port": "Port", + "username": "Benotzernumm" + }, + "title": "Axis Apparat ariichten" + } + }, + "title": "Axis Apparat" + } +} \ No newline at end of file diff --git a/homeassistant/components/axis/.translations/nl.json b/homeassistant/components/axis/.translations/nl.json new file mode 100644 index 000000000..10fc8c02d --- /dev/null +++ b/homeassistant/components/axis/.translations/nl.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd", + "bad_config_file": "Slechte gegevens van het configuratiebestand", + "link_local_address": "Link-lokale adressen worden niet ondersteund", + "not_axis_device": "Ontdekte apparaat, is geen Axis-apparaat" + }, + "error": { + "already_configured": "Apparaat is al geconfigureerd", + "already_in_progress": "De configuratiestroom voor het apparaat is al in volle gang.", + "device_unavailable": "Apparaat is niet beschikbaar", + "faulty_credentials": "Ongeldige gebruikersreferenties" + }, + "flow_title": "Axis apparaat: {naam} ({host})", + "step": { + "user": { + "data": { + "host": "Host", + "password": "Wachtwoord", + "port": "Poort", + "username": "Gebruikersnaam" + }, + "title": "Stel het Axis-apparaat in" + } + }, + "title": "Axis-apparaat" + } +} \ No newline at end of file diff --git a/homeassistant/components/axis/.translations/nn.json b/homeassistant/components/axis/.translations/nn.json new file mode 100644 index 000000000..b6296d1ac --- /dev/null +++ b/homeassistant/components/axis/.translations/nn.json @@ -0,0 +1,14 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "Vert", + "password": "Passord", + "port": "Port", + "username": "Brukarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/axis/.translations/no.json b/homeassistant/components/axis/.translations/no.json new file mode 100644 index 000000000..190737e5a --- /dev/null +++ b/homeassistant/components/axis/.translations/no.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert", + "bad_config_file": "D\u00e5rlig data fra konfigurasjonsfilen", + "link_local_address": "Linking av lokale adresser st\u00f8ttes ikke", + "not_axis_device": "Oppdaget enhet ikke en Axis enhet" + }, + "error": { + "already_configured": "Enheten er allerede konfigurert", + "already_in_progress": "Konfigurasjonsflyt for enhet p\u00e5g\u00e5r allerede.", + "device_unavailable": "Enheten er ikke tilgjengelig", + "faulty_credentials": "Ugyldig brukerlegitimasjon" + }, + "flow_title": "Akse-enhet: {Name} ({Host})", + "step": { + "user": { + "data": { + "host": "Vert", + "password": "Passord", + "port": "Port", + "username": "Brukernavn" + }, + "title": "Sett opp Axis enhet" + } + }, + "title": "Axis enhet" + } +} \ No newline at end of file diff --git a/homeassistant/components/axis/.translations/pl.json b/homeassistant/components/axis/.translations/pl.json new file mode 100644 index 000000000..4ca87310f --- /dev/null +++ b/homeassistant/components/axis/.translations/pl.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", + "bad_config_file": "B\u0142\u0119dne dane z pliku konfiguracyjnego", + "link_local_address": "Po\u0142\u0105czenie lokalnego adresu nie jest obs\u0142ugiwane", + "not_axis_device": "Wykryte urz\u0105dzenie nie jest urz\u0105dzeniem Axis" + }, + "error": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", + "already_in_progress": "Konfigurowanie urz\u0105dzenia jest ju\u017c w toku.", + "device_unavailable": "Urz\u0105dzenie jest niedost\u0119pne", + "faulty_credentials": "B\u0142\u0119dne dane uwierzytelniaj\u0105ce" + }, + "flow_title": "Urz\u0105dzenie Axis: {name} ({host})", + "step": { + "user": { + "data": { + "host": "Host", + "password": "Has\u0142o", + "port": "Port", + "username": "Nazwa u\u017cytkownika" + }, + "title": "Konfiguracja urz\u0105dzenia Axis" + } + }, + "title": "Urz\u0105dzenie Axis" + } +} \ No newline at end of file diff --git a/homeassistant/components/axis/.translations/pt-BR.json b/homeassistant/components/axis/.translations/pt-BR.json new file mode 100644 index 000000000..453c8fa36 --- /dev/null +++ b/homeassistant/components/axis/.translations/pt-BR.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "O dispositivo j\u00e1 est\u00e1 configurado", + "bad_config_file": "Dados incorretos do arquivo de configura\u00e7\u00e3o", + "link_local_address": "Link de endere\u00e7os locais n\u00e3o s\u00e3o suportados", + "not_axis_device": "Dispositivo descoberto n\u00e3o \u00e9 um dispositivo Axis" + }, + "error": { + "already_configured": "O dispositivo j\u00e1 est\u00e1 configurado", + "already_in_progress": "O fluxo de configura\u00e7\u00e3o para o dispositivo j\u00e1 est\u00e1 em andamento.", + "device_unavailable": "O dispositivo n\u00e3o est\u00e1 dispon\u00edvel", + "faulty_credentials": "Credenciais do usu\u00e1rio inv\u00e1lidas" + }, + "flow_title": "Eixos do dispositivo: {name} ({host})", + "step": { + "user": { + "data": { + "host": "Host", + "password": "Senha", + "port": "Porta", + "username": "Nome de usu\u00e1rio" + }, + "title": "Configurar o dispositivo Axis" + } + }, + "title": "Dispositivo Axis" + } +} \ No newline at end of file diff --git a/homeassistant/components/axis/.translations/pt.json b/homeassistant/components/axis/.translations/pt.json new file mode 100644 index 000000000..77ce7025f --- /dev/null +++ b/homeassistant/components/axis/.translations/pt.json @@ -0,0 +1,14 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "Servidor", + "password": "Palavra-passe", + "port": "Porta", + "username": "Nome de Utilizador" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/axis/.translations/ru.json b/homeassistant/components/axis/.translations/ru.json new file mode 100644 index 000000000..24990bb0f --- /dev/null +++ b/homeassistant/components/axis/.translations/ru.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.", + "bad_config_file": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 \u0438\u0437 \u0444\u0430\u0439\u043b\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438.", + "link_local_address": "\u0421\u0441\u044b\u043b\u043a\u0430 \u043d\u0430 \u043b\u043e\u043a\u0430\u043b\u044c\u043d\u044b\u0435 \u0430\u0434\u0440\u0435\u0441\u0430 \u043d\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442\u0441\u044f.", + "not_axis_device": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u044f\u0432\u043b\u044f\u0435\u0442\u0441\u044f \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c Axis." + }, + "error": { + "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.", + "already_in_progress": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0443\u0436\u0435 \u043d\u0430\u0447\u0430\u0442\u0430.", + "device_unavailable": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435\u0434\u043e\u0441\u0442\u0443\u043f\u043d\u043e.", + "faulty_credentials": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0435 \u0443\u0447\u0451\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435." + }, + "flow_title": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e Axis {name} ({host})", + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "port": "\u041f\u043e\u0440\u0442", + "username": "\u041b\u043e\u0433\u0438\u043d" + }, + "title": "Axis" + } + }, + "title": "Axis" + } +} \ No newline at end of file diff --git a/homeassistant/components/axis/.translations/sl.json b/homeassistant/components/axis/.translations/sl.json new file mode 100644 index 000000000..5ffa02e19 --- /dev/null +++ b/homeassistant/components/axis/.translations/sl.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Naprava je \u017ee konfigurirana", + "bad_config_file": "Napa\u010dni podatki iz konfiguracijske datoteke", + "link_local_address": "Lokalni naslovi povezave niso podprti", + "not_axis_device": "Odkrita naprava ni naprava Axis" + }, + "error": { + "already_configured": "Naprava je \u017ee konfigurirana", + "already_in_progress": "Konfiguracijski tok za to napravo je \u017ee v teku.", + "device_unavailable": "Naprava ni na voljo", + "faulty_credentials": "Napa\u010dni uporabni\u0161ki podatki" + }, + "flow_title": "OS naprava: {Name} ({Host})", + "step": { + "user": { + "data": { + "host": "Gostitelj", + "password": "Geslo", + "port": "Vrata", + "username": "Uporabni\u0161ko ime" + }, + "title": "Nastavite plo\u0161\u010dek" + } + }, + "title": "Plo\u0161\u010dek" + } +} \ No newline at end of file diff --git a/homeassistant/components/axis/.translations/sv.json b/homeassistant/components/axis/.translations/sv.json new file mode 100644 index 000000000..a38ef2ef7 --- /dev/null +++ b/homeassistant/components/axis/.translations/sv.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten \u00e4r redan konfigurerad", + "bad_config_file": "Felaktig data fr\u00e5n config fil", + "link_local_address": "Link local addresses are not supported", + "not_axis_device": "Uppt\u00e4ckte enhet som inte \u00e4r en Axis enhet" + }, + "error": { + "already_configured": "Enheten \u00e4r redan konfigurerad", + "already_in_progress": "Konfigurations fl\u00f6det f\u00f6r enheten p\u00e5g\u00e5r redan.", + "device_unavailable": "Enheten \u00e4r inte tillg\u00e4nglig", + "faulty_credentials": "Felaktiga anv\u00e4ndaruppgifter" + }, + "step": { + "user": { + "data": { + "host": "V\u00e4rd", + "password": "L\u00f6senord", + "port": "Port", + "username": "Anv\u00e4ndarnamn" + }, + "title": "Konfigurera Axis-enhet" + } + }, + "title": "Axis enhet" + } +} \ No newline at end of file diff --git a/homeassistant/components/axis/.translations/th.json b/homeassistant/components/axis/.translations/th.json new file mode 100644 index 000000000..4226d4ddb --- /dev/null +++ b/homeassistant/components/axis/.translations/th.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "\u0e23\u0e2b\u0e31\u0e2a\u0e1c\u0e48\u0e32\u0e19", + "port": "Port", + "username": "\u0e0a\u0e37\u0e48\u0e2d\u0e1c\u0e39\u0e49\u0e43\u0e0a\u0e49" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/axis/.translations/zh-Hans.json b/homeassistant/components/axis/.translations/zh-Hans.json new file mode 100644 index 000000000..f7f6c8259 --- /dev/null +++ b/homeassistant/components/axis/.translations/zh-Hans.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "\u5bc6\u7801", + "port": "\u7aef\u53e3", + "username": "\u7528\u6237\u540d" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/axis/.translations/zh-Hant.json b/homeassistant/components/axis/.translations/zh-Hant.json new file mode 100644 index 000000000..6c78fc216 --- /dev/null +++ b/homeassistant/components/axis/.translations/zh-Hant.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "\u8a2d\u5099\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "bad_config_file": "\u8a2d\u5b9a\u6a94\u6848\u8cc7\u6599\u7121\u6548", + "link_local_address": "\u4e0d\u652f\u63f4\u9023\u7d50\u672c\u5730\u7aef\u4f4d\u5740", + "not_axis_device": "\u6240\u767c\u73fe\u7684\u8a2d\u5099\u4e26\u975e Axis \u8a2d\u5099" + }, + "error": { + "already_configured": "\u8a2d\u5099\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "already_in_progress": "\u8a2d\u5099\u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d\u3002", + "device_unavailable": "\u8a2d\u5099\u7121\u6cd5\u4f7f\u7528", + "faulty_credentials": "\u4f7f\u7528\u8005\u6191\u8b49\u7121\u6548" + }, + "flow_title": "Axis \u8a2d\u5099\uff1a{name} ({host})", + "step": { + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef", + "password": "\u5bc6\u78bc", + "port": "\u901a\u8a0a\u57e0", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + }, + "title": "\u8a2d\u5b9a Axis \u8a2d\u5099" + } + }, + "title": "Axis \u8a2d\u5099" + } +} \ No newline at end of file diff --git a/homeassistant/components/axis/__init__.py b/homeassistant/components/axis/__init__.py new file mode 100644 index 000000000..bdda82b21 --- /dev/null +++ b/homeassistant/components/axis/__init__.py @@ -0,0 +1,85 @@ +"""Support for Axis devices.""" + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import ( + CONF_DEVICE, + CONF_MAC, + CONF_NAME, + CONF_TRIGGER_TIME, + EVENT_HOMEASSISTANT_STOP, +) +from homeassistant.helpers import config_validation as cv + +from .config_flow import DEVICE_SCHEMA +from .const import CONF_CAMERA, CONF_EVENTS, DEFAULT_TRIGGER_TIME, DOMAIN +from .device import AxisNetworkDevice, get_device + +CONFIG_SCHEMA = vol.Schema( + {DOMAIN: cv.schema_with_slug_keys(DEVICE_SCHEMA)}, extra=vol.ALLOW_EXTRA +) + + +async def async_setup(hass, config): + """Set up for Axis devices.""" + if not hass.config_entries.async_entries(DOMAIN) and DOMAIN in config: + + for device_name, device_config in config[DOMAIN].items(): + + if CONF_NAME not in device_config: + device_config[CONF_NAME] = device_name + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=device_config, + ) + ) + + return True + + +async def async_setup_entry(hass, config_entry): + """Set up the Axis component.""" + if DOMAIN not in hass.data: + hass.data[DOMAIN] = {} + + if not config_entry.options: + await async_populate_options(hass, config_entry) + + device = AxisNetworkDevice(hass, config_entry) + + if not await device.async_setup(): + return False + + hass.data[DOMAIN][device.serial] = device + + await device.async_update_device_registry() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, device.shutdown) + + return True + + +async def async_unload_entry(hass, config_entry): + """Unload Axis device config entry.""" + device = hass.data[DOMAIN].pop(config_entry.data[CONF_MAC]) + return await device.async_reset() + + +async def async_populate_options(hass, config_entry): + """Populate default options for device.""" + device = await get_device(hass, config_entry.data[CONF_DEVICE]) + + supported_formats = device.vapix.params.image_format + camera = bool(supported_formats) + + options = { + CONF_CAMERA: camera, + CONF_EVENTS: True, + CONF_TRIGGER_TIME: DEFAULT_TRIGGER_TIME, + } + + hass.config_entries.async_update_entry(config_entry, options=options) diff --git a/homeassistant/components/axis/axis_base.py b/homeassistant/components/axis/axis_base.py new file mode 100644 index 000000000..f22a169a1 --- /dev/null +++ b/homeassistant/components/axis/axis_base.py @@ -0,0 +1,85 @@ +"""Base classes for Axis entities.""" + +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN as AXIS_DOMAIN + + +class AxisEntityBase(Entity): + """Base common to all Axis entities.""" + + def __init__(self, device): + """Initialize the Axis event.""" + self.device = device + self.unsub_dispatcher = [] + + async def async_added_to_hass(self): + """Subscribe device events.""" + self.unsub_dispatcher.append( + async_dispatcher_connect( + self.hass, self.device.event_reachable, self.update_callback + ) + ) + + async def async_will_remove_from_hass(self) -> None: + """Unsubscribe device events when removed.""" + for unsub_dispatcher in self.unsub_dispatcher: + unsub_dispatcher() + + @property + def available(self): + """Return True if device is available.""" + return self.device.available + + @property + def device_info(self): + """Return a device description for device registry.""" + return {"identifiers": {(AXIS_DOMAIN, self.device.serial)}} + + @callback + def update_callback(self, no_delay=None): + """Update the entities state.""" + self.async_schedule_update_ha_state() + + +class AxisEventBase(AxisEntityBase): + """Base common to all Axis entities from event stream.""" + + def __init__(self, event, device): + """Initialize the Axis event.""" + super().__init__(device) + self.event = event + + async def async_added_to_hass(self) -> None: + """Subscribe sensors events.""" + self.event.register_callback(self.update_callback) + + await super().async_added_to_hass() + + async def async_will_remove_from_hass(self) -> None: + """Disconnect device object when removed.""" + self.event.remove_callback(self.update_callback) + + await super().async_will_remove_from_hass() + + @property + def device_class(self): + """Return the class of the event.""" + return self.event.CLASS + + @property + def name(self): + """Return the name of the event.""" + return f"{self.device.name} {self.event.TYPE} {self.event.id}" + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def unique_id(self): + """Return a unique identifier for this device.""" + return f"{self.device.serial}-{self.event.topic}-{self.event.id}" diff --git a/homeassistant/components/axis/binary_sensor.py b/homeassistant/components/axis/binary_sensor.py new file mode 100644 index 000000000..1d12e0b8d --- /dev/null +++ b/homeassistant/components/axis/binary_sensor.py @@ -0,0 +1,87 @@ +"""Support for Axis binary sensors.""" + +from datetime import timedelta + +from axis.event_stream import CLASS_INPUT, CLASS_OUTPUT + +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.const import CONF_MAC, CONF_TRIGGER_TIME +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.util.dt import utcnow + +from .axis_base import AxisEventBase +from .const import DOMAIN as AXIS_DOMAIN + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up a Axis binary sensor.""" + serial_number = config_entry.data[CONF_MAC] + device = hass.data[AXIS_DOMAIN][serial_number] + + @callback + def async_add_sensor(event_id): + """Add binary sensor from Axis device.""" + event = device.api.event.events[event_id] + + if event.CLASS != CLASS_OUTPUT: + async_add_entities([AxisBinarySensor(event, device)], True) + + device.listeners.append( + async_dispatcher_connect(hass, device.event_new_sensor, async_add_sensor) + ) + + +class AxisBinarySensor(AxisEventBase, BinarySensorDevice): + """Representation of a binary Axis event.""" + + def __init__(self, event, device): + """Initialize the Axis binary sensor.""" + super().__init__(event, device) + self.remove_timer = None + + @callback + def update_callback(self, no_delay=False): + """Update the sensor's state, if needed. + + Parameter no_delay is True when device_event_reachable is sent. + """ + delay = self.device.config_entry.options[CONF_TRIGGER_TIME] + + if self.remove_timer is not None: + self.remove_timer() + self.remove_timer = None + + if self.is_on or delay == 0 or no_delay: + self.async_schedule_update_ha_state() + return + + @callback + def _delay_update(now): + """Timer callback for sensor update.""" + self.async_schedule_update_ha_state() + self.remove_timer = None + + self.remove_timer = async_track_point_in_utc_time( + self.hass, _delay_update, utcnow() + timedelta(seconds=delay) + ) + + @property + def is_on(self): + """Return true if event is active.""" + return self.event.is_tripped + + @property + def name(self): + """Return the name of the event.""" + if ( + self.event.CLASS == CLASS_INPUT + and self.event.id + and self.device.api.vapix.ports[self.event.id].name + ): + return "{} {}".format( + self.device.name, self.device.api.vapix.ports[self.event.id].name + ) + + return super().name diff --git a/homeassistant/components/axis/camera.py b/homeassistant/components/axis/camera.py new file mode 100644 index 000000000..a55e45dd3 --- /dev/null +++ b/homeassistant/components/axis/camera.py @@ -0,0 +1,95 @@ +"""Support for Axis camera streaming.""" + +from homeassistant.components.camera import SUPPORT_STREAM +from homeassistant.components.mjpeg.camera import ( + CONF_MJPEG_URL, + CONF_STILL_IMAGE_URL, + MjpegCamera, + filter_urllib3_logging, +) +from homeassistant.const import ( + CONF_AUTHENTICATION, + CONF_DEVICE, + CONF_HOST, + CONF_MAC, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + HTTP_DIGEST_AUTHENTICATION, +) +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .axis_base import AxisEntityBase +from .const import DOMAIN as AXIS_DOMAIN + +AXIS_IMAGE = "http://{}:{}/axis-cgi/jpg/image.cgi" +AXIS_VIDEO = "http://{}:{}/axis-cgi/mjpg/video.cgi" +AXIS_STREAM = "rtsp://{}:{}@{}/axis-media/media.amp?videocodec=h264" + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Axis camera video stream.""" + filter_urllib3_logging() + + serial_number = config_entry.data[CONF_MAC] + device = hass.data[AXIS_DOMAIN][serial_number] + + config = { + CONF_NAME: config_entry.data[CONF_NAME], + CONF_USERNAME: config_entry.data[CONF_DEVICE][CONF_USERNAME], + CONF_PASSWORD: config_entry.data[CONF_DEVICE][CONF_PASSWORD], + CONF_MJPEG_URL: AXIS_VIDEO.format( + config_entry.data[CONF_DEVICE][CONF_HOST], + config_entry.data[CONF_DEVICE][CONF_PORT], + ), + CONF_STILL_IMAGE_URL: AXIS_IMAGE.format( + config_entry.data[CONF_DEVICE][CONF_HOST], + config_entry.data[CONF_DEVICE][CONF_PORT], + ), + CONF_AUTHENTICATION: HTTP_DIGEST_AUTHENTICATION, + } + async_add_entities([AxisCamera(config, device)]) + + +class AxisCamera(AxisEntityBase, MjpegCamera): + """Representation of a Axis camera.""" + + def __init__(self, config, device): + """Initialize Axis Communications camera component.""" + AxisEntityBase.__init__(self, device) + MjpegCamera.__init__(self, config) + + async def async_added_to_hass(self): + """Subscribe camera events.""" + self.unsub_dispatcher.append( + async_dispatcher_connect( + self.hass, self.device.event_new_address, self._new_address + ) + ) + + await super().async_added_to_hass() + + @property + def supported_features(self): + """Return supported features.""" + return SUPPORT_STREAM + + async def stream_source(self): + """Return the stream source.""" + return AXIS_STREAM.format( + self.device.config_entry.data[CONF_DEVICE][CONF_USERNAME], + self.device.config_entry.data[CONF_DEVICE][CONF_PASSWORD], + self.device.host, + ) + + def _new_address(self): + """Set new device address for video stream.""" + port = self.device.config_entry.data[CONF_DEVICE][CONF_PORT] + self._mjpeg_url = AXIS_VIDEO.format(self.device.host, port) + self._still_image_url = AXIS_IMAGE.format(self.device.host, port) + + @property + def unique_id(self): + """Return a unique identifier for this device.""" + return f"{self.device.serial}-camera" diff --git a/homeassistant/components/axis/config_flow.py b/homeassistant/components/axis/config_flow.py new file mode 100644 index 000000000..5eb4f9dad --- /dev/null +++ b/homeassistant/components/axis/config_flow.py @@ -0,0 +1,239 @@ +"""Config flow to configure Axis devices.""" + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import ( + CONF_DEVICE, + CONF_HOST, + CONF_MAC, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, +) +from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv +from homeassistant.util.json import load_json + +from .const import CONF_MODEL, DOMAIN +from .device import get_device +from .errors import AlreadyConfigured, AuthenticationRequired, CannotConnect + +AXIS_OUI = {"00408C", "ACCC8E", "B8A44F"} + +CONFIG_FILE = "axis.conf" + +EVENT_TYPES = ["motion", "vmd3", "pir", "sound", "daynight", "tampering", "input"] + +PLATFORMS = ["camera"] + +AXIS_INCLUDE = EVENT_TYPES + PLATFORMS + +AXIS_DEFAULT_HOST = "192.168.0.90" +AXIS_DEFAULT_USERNAME = "root" +AXIS_DEFAULT_PASSWORD = "pass" +DEFAULT_PORT = 80 + +DEVICE_SCHEMA = vol.Schema( + { + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_HOST, default=AXIS_DEFAULT_HOST): cv.string, + vol.Optional(CONF_USERNAME, default=AXIS_DEFAULT_USERNAME): cv.string, + vol.Optional(CONF_PASSWORD, default=AXIS_DEFAULT_PASSWORD): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + }, + extra=vol.ALLOW_EXTRA, +) + + +@callback +def configured_devices(hass): + """Return a set of the configured devices.""" + return { + entry.data[CONF_MAC]: entry + for entry in hass.config_entries.async_entries(DOMAIN) + } + + +class AxisFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a Axis config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + + def __init__(self): + """Initialize the Axis config flow.""" + self.device_config = {} + self.model = None + self.name = None + self.serial_number = None + + self.discovery_schema = {} + self.import_schema = {} + + async def async_step_user(self, user_input=None): + """Handle a Axis config flow start. + + Manage device specific parameters. + """ + errors = {} + + if user_input is not None: + try: + self.device_config = { + CONF_HOST: user_input[CONF_HOST], + CONF_PORT: user_input[CONF_PORT], + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + } + device = await get_device(self.hass, self.device_config) + + self.serial_number = device.vapix.params.system_serialnumber + + if self.serial_number in configured_devices(self.hass): + raise AlreadyConfigured + + self.model = device.vapix.params.prodnbr + + return await self._create_entry() + + except AlreadyConfigured: + errors["base"] = "already_configured" + + except AuthenticationRequired: + errors["base"] = "faulty_credentials" + + except CannotConnect: + errors["base"] = "device_unavailable" + + data = ( + self.import_schema + or self.discovery_schema + or { + vol.Required(CONF_HOST): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_PORT, default=DEFAULT_PORT): int, + } + ) + + return self.async_show_form( + step_id="user", + description_placeholders=self.device_config, + data_schema=vol.Schema(data), + errors=errors, + ) + + async def _create_entry(self): + """Create entry for device. + + Generate a name to be used as a prefix for device entities. + """ + if self.name is None: + same_model = [ + entry.data[CONF_NAME] + for entry in self.hass.config_entries.async_entries(DOMAIN) + if entry.data[CONF_MODEL] == self.model + ] + + self.name = f"{self.model}" + for idx in range(len(same_model) + 1): + self.name = f"{self.model} {idx}" + if self.name not in same_model: + break + + data = { + CONF_DEVICE: self.device_config, + CONF_NAME: self.name, + CONF_MAC: self.serial_number, + CONF_MODEL: self.model, + } + + title = f"{self.model} - {self.serial_number}" + return self.async_create_entry(title=title, data=data) + + async def _update_entry(self, entry, host): + """Update existing entry if it is the same device.""" + entry.data[CONF_DEVICE][CONF_HOST] = host + self.hass.config_entries.async_update_entry(entry) + + async def async_step_zeroconf(self, discovery_info): + """Prepare configuration for a discovered Axis device. + + This flow is triggered by the discovery component. + """ + serialnumber = discovery_info["properties"]["macaddress"] + + if serialnumber[:6] not in AXIS_OUI: + return self.async_abort(reason="not_axis_device") + + if discovery_info[CONF_HOST].startswith("169.254"): + return self.async_abort(reason="link_local_address") + + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + self.context["macaddress"] = serialnumber + + if any( + serialnumber == flow["context"]["macaddress"] + for flow in self._async_in_progress() + ): + return self.async_abort(reason="already_in_progress") + + device_entries = configured_devices(self.hass) + + if serialnumber in device_entries: + entry = device_entries[serialnumber] + await self._update_entry(entry, discovery_info[CONF_HOST]) + return self.async_abort(reason="already_configured") + + config_file = await self.hass.async_add_executor_job( + load_json, self.hass.config.path(CONFIG_FILE) + ) + + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + self.context["title_placeholders"] = { + "name": discovery_info["hostname"][:-7], + "host": discovery_info[CONF_HOST], + } + + if serialnumber not in config_file: + self.discovery_schema = { + vol.Required(CONF_HOST, default=discovery_info[CONF_HOST]): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_PORT, default=discovery_info[CONF_PORT]): int, + } + + return await self.async_step_user() + + try: + device_config = DEVICE_SCHEMA(config_file[serialnumber]) + device_config[CONF_HOST] = discovery_info[CONF_HOST] + + if CONF_NAME not in device_config: + device_config[CONF_NAME] = discovery_info["hostname"] + + except vol.Invalid: + return self.async_abort(reason="bad_config_file") + + return await self.async_step_import(device_config) + + async def async_step_import(self, import_config): + """Import a Axis device as a config entry. + + This flow is triggered by `async_setup` for configured devices. + This flow is also triggered by `async_step_discovery`. + + This will execute for any Axis device that contains a complete + configuration. + """ + self.name = import_config[CONF_NAME] + + self.import_schema = { + vol.Required(CONF_HOST, default=import_config[CONF_HOST]): str, + vol.Required(CONF_USERNAME, default=import_config[CONF_USERNAME]): str, + vol.Required(CONF_PASSWORD, default=import_config[CONF_PASSWORD]): str, + vol.Required(CONF_PORT, default=import_config[CONF_PORT]): int, + } + return await self.async_step_user(user_input=import_config) diff --git a/homeassistant/components/axis/const.py b/homeassistant/components/axis/const.py new file mode 100644 index 000000000..7f0fd9c89 --- /dev/null +++ b/homeassistant/components/axis/const.py @@ -0,0 +1,12 @@ +"""Constants for the Axis component.""" +import logging + +LOGGER = logging.getLogger(__package__) + +DOMAIN = "axis" + +CONF_CAMERA = "camera" +CONF_EVENTS = "events" +CONF_MODEL = "model" + +DEFAULT_TRIGGER_TIME = 0 diff --git a/homeassistant/components/axis/device.py b/homeassistant/components/axis/device.py new file mode 100644 index 000000000..b05c5b2fe --- /dev/null +++ b/homeassistant/components/axis/device.py @@ -0,0 +1,238 @@ +"""Axis network device abstraction.""" + +import asyncio + +import async_timeout +import axis +from axis.streammanager import SIGNAL_PLAYING + +from homeassistant.const import ( + CONF_DEVICE, + CONF_HOST, + CONF_MAC, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, +) +from homeassistant.core import callback +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from .const import CONF_CAMERA, CONF_EVENTS, CONF_MODEL, DOMAIN, LOGGER +from .errors import AuthenticationRequired, CannotConnect + + +class AxisNetworkDevice: + """Manages a Axis device.""" + + def __init__(self, hass, config_entry): + """Initialize the device.""" + self.hass = hass + self.config_entry = config_entry + self.available = True + + self.api = None + self.fw_version = None + self.product_type = None + + self.listeners = [] + + @property + def host(self): + """Return the host of this device.""" + return self.config_entry.data[CONF_DEVICE][CONF_HOST] + + @property + def model(self): + """Return the model of this device.""" + return self.config_entry.data[CONF_MODEL] + + @property + def name(self): + """Return the name of this device.""" + return self.config_entry.data[CONF_NAME] + + @property + def serial(self): + """Return the mac of this device.""" + return self.config_entry.data[CONF_MAC] + + async def async_update_device_registry(self): + """Update device registry.""" + device_registry = await self.hass.helpers.device_registry.async_get_registry() + device_registry.async_get_or_create( + config_entry_id=self.config_entry.entry_id, + connections={(CONNECTION_NETWORK_MAC, self.serial)}, + identifiers={(DOMAIN, self.serial)}, + manufacturer="Axis Communications AB", + model=f"{self.model} {self.product_type}", + name=self.name, + sw_version=self.fw_version, + ) + + async def async_setup(self): + """Set up the device.""" + try: + self.api = await get_device(self.hass, self.config_entry.data[CONF_DEVICE]) + + except CannotConnect: + raise ConfigEntryNotReady + + except Exception: # pylint: disable=broad-except + LOGGER.error("Unknown error connecting with Axis device on %s", self.host) + return False + + self.fw_version = self.api.vapix.params.firmware_version + self.product_type = self.api.vapix.params.prodtype + + if self.config_entry.options[CONF_CAMERA]: + + self.hass.async_create_task( + self.hass.config_entries.async_forward_entry_setup( + self.config_entry, "camera" + ) + ) + + if self.config_entry.options[CONF_EVENTS]: + + self.api.stream.connection_status_callback = ( + self.async_connection_status_callback + ) + self.api.enable_events(event_callback=self.async_event_callback) + + platform_tasks = [ + self.hass.config_entries.async_forward_entry_setup( + self.config_entry, platform + ) + for platform in ["binary_sensor", "switch"] + ] + self.hass.async_create_task(self.start(platform_tasks)) + + self.config_entry.add_update_listener(self.async_new_address_callback) + + return True + + @property + def event_new_address(self): + """Device specific event to signal new device address.""" + return f"axis_new_address_{self.serial}" + + @staticmethod + async def async_new_address_callback(hass, entry): + """Handle signals of device getting new address. + + This is a static method because a class method (bound method), + can not be used with weak references. + """ + device = hass.data[DOMAIN][entry.data[CONF_MAC]] + device.api.config.host = device.host + async_dispatcher_send(hass, device.event_new_address) + + @property + def event_reachable(self): + """Device specific event to signal a change in connection status.""" + return f"axis_reachable_{self.serial}" + + @callback + def async_connection_status_callback(self, status): + """Handle signals of device connection status. + + This is called on every RTSP keep-alive message. + Only signal state change if state change is true. + """ + + if self.available != (status == SIGNAL_PLAYING): + self.available = not self.available + async_dispatcher_send(self.hass, self.event_reachable, True) + + @property + def event_new_sensor(self): + """Device specific event to signal new sensor available.""" + return f"axis_add_sensor_{self.serial}" + + @callback + def async_event_callback(self, action, event_id): + """Call to configure events when initialized on event stream.""" + if action == "add": + async_dispatcher_send(self.hass, self.event_new_sensor, event_id) + + async def start(self, platform_tasks): + """Start the event stream when all platforms are loaded.""" + await asyncio.gather(*platform_tasks) + self.api.start() + + @callback + def shutdown(self, event): + """Stop the event stream.""" + self.api.stop() + + async def async_reset(self): + """Reset this device to default state.""" + platform_tasks = [] + + if self.config_entry.options[CONF_CAMERA]: + platform_tasks.append( + self.hass.config_entries.async_forward_entry_unload( + self.config_entry, "camera" + ) + ) + + if self.config_entry.options[CONF_EVENTS]: + self.api.stop() + platform_tasks += [ + self.hass.config_entries.async_forward_entry_unload( + self.config_entry, platform + ) + for platform in ["binary_sensor", "switch"] + ] + + await asyncio.gather(*platform_tasks) + + for unsub_dispatcher in self.listeners: + unsub_dispatcher() + self.listeners = [] + + return True + + +async def get_device(hass, config): + """Create a Axis device.""" + + device = axis.AxisDevice( + loop=hass.loop, + host=config[CONF_HOST], + username=config[CONF_USERNAME], + password=config[CONF_PASSWORD], + port=config[CONF_PORT], + web_proto="http", + ) + + device.vapix.initialize_params(preload_data=False) + device.vapix.initialize_ports() + + try: + with async_timeout.timeout(15): + + await asyncio.gather( + hass.async_add_executor_job(device.vapix.params.update_brand), + hass.async_add_executor_job(device.vapix.params.update_properties), + hass.async_add_executor_job(device.vapix.ports.update), + ) + + return device + + except axis.Unauthorized: + LOGGER.warning( + "Connected to device at %s but not registered.", config[CONF_HOST] + ) + raise AuthenticationRequired + + except (asyncio.TimeoutError, axis.RequestError): + LOGGER.error("Error connecting to the Axis device at %s", config[CONF_HOST]) + raise CannotConnect + + except axis.AxisException: + LOGGER.exception("Unknown Axis communication error occurred") + raise AuthenticationRequired diff --git a/homeassistant/components/axis/errors.py b/homeassistant/components/axis/errors.py new file mode 100644 index 000000000..56105b28b --- /dev/null +++ b/homeassistant/components/axis/errors.py @@ -0,0 +1,22 @@ +"""Errors for the Axis component.""" +from homeassistant.exceptions import HomeAssistantError + + +class AxisException(HomeAssistantError): + """Base class for Axis exceptions.""" + + +class AlreadyConfigured(AxisException): + """Device is already configured.""" + + +class AuthenticationRequired(AxisException): + """Unknown error occurred.""" + + +class CannotConnect(AxisException): + """Unable to connect to the device.""" + + +class UserLevel(AxisException): + """User level too low.""" diff --git a/homeassistant/components/axis/manifest.json b/homeassistant/components/axis/manifest.json new file mode 100644 index 000000000..348f61483 --- /dev/null +++ b/homeassistant/components/axis/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "axis", + "name": "Axis", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/axis", + "requirements": ["axis==25"], + "dependencies": [], + "zeroconf": ["_axis-video._tcp.local."], + "codeowners": ["@kane610"] +} diff --git a/homeassistant/components/axis/strings.json b/homeassistant/components/axis/strings.json new file mode 100644 index 000000000..2dc23f3e4 --- /dev/null +++ b/homeassistant/components/axis/strings.json @@ -0,0 +1,29 @@ +{ + "config": { + "title": "Axis device", + "flow_title": "Axis device: {name} ({host})", + "step": { + "user": { + "title": "Set up Axis device", + "data": { + "host": "Host", + "username": "Username", + "password": "Password", + "port": "Port" + } + } + }, + "error": { + "already_configured": "Device is already configured", + "already_in_progress": "Config flow for device is already in progress.", + "device_unavailable": "Device is not available", + "faulty_credentials": "Bad user credentials" + }, + "abort": { + "already_configured": "Device is already configured", + "bad_config_file": "Bad data from config file", + "link_local_address": "Link local addresses are not supported", + "not_axis_device": "Discovered device not an Axis device" + } + } +} diff --git a/homeassistant/components/axis/switch.py b/homeassistant/components/axis/switch.py new file mode 100644 index 000000000..a64ffc3fa --- /dev/null +++ b/homeassistant/components/axis/switch.py @@ -0,0 +1,62 @@ +"""Support for Axis switches.""" + +from axis.event_stream import CLASS_OUTPUT + +from homeassistant.components.switch import SwitchDevice +from homeassistant.const import CONF_MAC +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .axis_base import AxisEventBase +from .const import DOMAIN as AXIS_DOMAIN + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up a Axis switch.""" + serial_number = config_entry.data[CONF_MAC] + device = hass.data[AXIS_DOMAIN][serial_number] + + @callback + def async_add_switch(event_id): + """Add switch from Axis device.""" + event = device.api.event.events[event_id] + + if event.CLASS == CLASS_OUTPUT: + async_add_entities([AxisSwitch(event, device)], True) + + device.listeners.append( + async_dispatcher_connect(hass, device.event_new_sensor, async_add_switch) + ) + + +class AxisSwitch(AxisEventBase, SwitchDevice): + """Representation of a Axis switch.""" + + @property + def is_on(self): + """Return true if event is active.""" + return self.event.is_tripped + + async def async_turn_on(self, **kwargs): + """Turn on switch.""" + action = "/" + await self.hass.async_add_executor_job( + self.device.api.vapix.ports[self.event.id].action, action + ) + + async def async_turn_off(self, **kwargs): + """Turn off switch.""" + action = "\\" + await self.hass.async_add_executor_job( + self.device.api.vapix.ports[self.event.id].action, action + ) + + @property + def name(self): + """Return the name of the event.""" + if self.event.id and self.device.api.vapix.ports[self.event.id].name: + return "{} {}".format( + self.device.name, self.device.api.vapix.ports[self.event.id].name + ) + + return super().name diff --git a/homeassistant/components/azure_event_hub/__init__.py b/homeassistant/components/azure_event_hub/__init__.py new file mode 100644 index 000000000..7e141cd80 --- /dev/null +++ b/homeassistant/components/azure_event_hub/__init__.py @@ -0,0 +1,88 @@ +"""Support for Azure Event Hubs.""" +import json +import logging +from typing import Any, Dict + +from azure.eventhub import EventData, EventHubClientAsync +import voluptuous as vol + +from homeassistant.const import ( + EVENT_HOMEASSISTANT_STOP, + EVENT_STATE_CHANGED, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) +from homeassistant.core import Event, HomeAssistant +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entityfilter import FILTER_SCHEMA +from homeassistant.helpers.json import JSONEncoder + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "azure_event_hub" + +CONF_EVENT_HUB_NAMESPACE = "event_hub_namespace" +CONF_EVENT_HUB_INSTANCE_NAME = "event_hub_instance_name" +CONF_EVENT_HUB_SAS_POLICY = "event_hub_sas_policy" +CONF_EVENT_HUB_SAS_KEY = "event_hub_sas_key" +CONF_FILTER = "filter" + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_EVENT_HUB_NAMESPACE): cv.string, + vol.Required(CONF_EVENT_HUB_INSTANCE_NAME): cv.string, + vol.Required(CONF_EVENT_HUB_SAS_POLICY): cv.string, + vol.Required(CONF_EVENT_HUB_SAS_KEY): cv.string, + vol.Required(CONF_FILTER): FILTER_SCHEMA, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass: HomeAssistant, yaml_config: Dict[str, Any]): + """Activate Azure EH component.""" + config = yaml_config[DOMAIN] + + event_hub_address = "amqps://{}.servicebus.windows.net/{}".format( + config[CONF_EVENT_HUB_NAMESPACE], config[CONF_EVENT_HUB_INSTANCE_NAME] + ) + entities_filter = config[CONF_FILTER] + + client = EventHubClientAsync( + event_hub_address, + debug=True, + username=config[CONF_EVENT_HUB_SAS_POLICY], + password=config[CONF_EVENT_HUB_SAS_KEY], + ) + async_sender = client.add_async_sender() + await client.run_async() + + encoder = JSONEncoder() + + async def async_send_to_event_hub(event: Event): + """Send states to Event Hub.""" + state = event.data.get("new_state") + if ( + state is None + or state.state in (STATE_UNKNOWN, "", STATE_UNAVAILABLE) + or not entities_filter(state.entity_id) + ): + return + + event_data = EventData( + json.dumps(obj=state.as_dict(), default=encoder.encode).encode("utf-8") + ) + await async_sender.send(event_data) + + async def async_shutdown(event: Event): + """Shut down the client.""" + await client.stop_async() + + hass.bus.async_listen(EVENT_STATE_CHANGED, async_send_to_event_hub) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_shutdown) + + return True diff --git a/homeassistant/components/azure_event_hub/manifest.json b/homeassistant/components/azure_event_hub/manifest.json new file mode 100644 index 000000000..0b705bddc --- /dev/null +++ b/homeassistant/components/azure_event_hub/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "azure_event_hub", + "name": "Azure Event Hub", + "documentation": "https://www.home-assistant.io/integrations/azure_event_hub", + "requirements": ["azure-eventhub==1.3.1"], + "dependencies": [], + "codeowners": ["@eavanvalkenburg"] + } \ No newline at end of file diff --git a/homeassistant/components/azure_service_bus/__init__.py b/homeassistant/components/azure_service_bus/__init__.py new file mode 100644 index 000000000..f18dc9eb6 --- /dev/null +++ b/homeassistant/components/azure_service_bus/__init__.py @@ -0,0 +1 @@ +"""The Azure Service Bus integration.""" diff --git a/homeassistant/components/azure_service_bus/manifest.json b/homeassistant/components/azure_service_bus/manifest.json new file mode 100644 index 000000000..fa6d1c20b --- /dev/null +++ b/homeassistant/components/azure_service_bus/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "azure_service_bus", + "name": "Azure Service Bus", + "documentation": "https://www.home-assistant.io/integrations/azure_service_bus", + "requirements": [ + "azure-servicebus==0.50.1" + ], + "dependencies": [], + "codeowners": [ + "@hfurubotten" + ] +} \ No newline at end of file diff --git a/homeassistant/components/azure_service_bus/notify.py b/homeassistant/components/azure_service_bus/notify.py new file mode 100644 index 000000000..e7c85aded --- /dev/null +++ b/homeassistant/components/azure_service_bus/notify.py @@ -0,0 +1,106 @@ +"""Support for azure service bus notification.""" +import json +import logging + +from azure.servicebus.aio import Message, ServiceBusClient +from azure.servicebus.common.errors import ( + MessageSendFailed, + ServiceBusConnectionError, + ServiceBusResourceNotFound, +) +import voluptuous as vol + +from homeassistant.components.notify import ( + ATTR_DATA, + ATTR_TARGET, + ATTR_TITLE, + PLATFORM_SCHEMA, + BaseNotificationService, +) +from homeassistant.const import CONTENT_TYPE_JSON +import homeassistant.helpers.config_validation as cv + +CONF_CONNECTION_STRING = "connection_string" +CONF_QUEUE_NAME = "queue" +CONF_TOPIC_NAME = "topic" + +ATTR_ASB_MESSAGE = "message" +ATTR_ASB_TITLE = "title" +ATTR_ASB_TARGET = "target" + +PLATFORM_SCHEMA = vol.All( + cv.has_at_least_one_key(CONF_QUEUE_NAME, CONF_TOPIC_NAME), + PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_CONNECTION_STRING): cv.string, + vol.Exclusive( + CONF_QUEUE_NAME, "output", "Can only send to a queue or a topic." + ): cv.string, + vol.Exclusive( + CONF_TOPIC_NAME, "output", "Can only send to a queue or a topic." + ): cv.string, + } + ), +) + +_LOGGER = logging.getLogger(__name__) + + +def get_service(hass, config, discovery_info=None): + """Get the notification service.""" + connection_string = config[CONF_CONNECTION_STRING] + queue_name = config.get(CONF_QUEUE_NAME) + topic_name = config.get(CONF_TOPIC_NAME) + + # Library can do synchronous IO when creating the clients. + # Passes in loop here, but can't run setup on the event loop. + servicebus = ServiceBusClient.from_connection_string( + connection_string, loop=hass.loop + ) + + try: + if queue_name: + client = servicebus.get_queue(queue_name) + else: + client = servicebus.get_topic(topic_name) + except (ServiceBusConnectionError, ServiceBusResourceNotFound) as err: + _LOGGER.error( + "Connection error while creating client for queue/topic '%s'. %s", + queue_name or topic_name, + err, + ) + return None + + return ServiceBusNotificationService(client) + + +class ServiceBusNotificationService(BaseNotificationService): + """Implement the notification service for the service bus service.""" + + def __init__(self, client): + """Initialize the service.""" + self._client = client + + async def async_send_message(self, message, **kwargs): + """Send a message.""" + dto = {ATTR_ASB_MESSAGE: message} + + if ATTR_TITLE in kwargs: + dto[ATTR_ASB_TITLE] = kwargs[ATTR_TITLE] + if ATTR_TARGET in kwargs: + dto[ATTR_ASB_TARGET] = kwargs[ATTR_TARGET] + + data = kwargs.get(ATTR_DATA) + if data: + dto.update(data) + + queue_message = Message(json.dumps(dto)) + queue_message.properties.content_type = CONTENT_TYPE_JSON + try: + await self._client.send(queue_message) + except MessageSendFailed as err: + _LOGGER.error( + "Could not send service bus notification to %s. %s", + self._client.name, + err, + ) diff --git a/homeassistant/components/baidu/__init__.py b/homeassistant/components/baidu/__init__.py new file mode 100644 index 000000000..8a332cf52 --- /dev/null +++ b/homeassistant/components/baidu/__init__.py @@ -0,0 +1 @@ +"""Support for Baidu integration.""" diff --git a/homeassistant/components/baidu/manifest.json b/homeassistant/components/baidu/manifest.json new file mode 100644 index 000000000..756a1c5ad --- /dev/null +++ b/homeassistant/components/baidu/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "baidu", + "name": "Baidu", + "documentation": "https://www.home-assistant.io/integrations/baidu", + "requirements": [ + "baidu-aip==1.6.6" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/baidu/tts.py b/homeassistant/components/baidu/tts.py new file mode 100644 index 000000000..4208750b7 --- /dev/null +++ b/homeassistant/components/baidu/tts.py @@ -0,0 +1,135 @@ +"""Support for Baidu speech service.""" +import logging + +from aip import AipSpeech +import voluptuous as vol + +from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider +from homeassistant.const import CONF_API_KEY +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +SUPPORTED_LANGUAGES = ["zh"] +DEFAULT_LANG = "zh" + +CONF_APP_ID = "app_id" +CONF_SECRET_KEY = "secret_key" +CONF_SPEED = "speed" +CONF_PITCH = "pitch" +CONF_VOLUME = "volume" +CONF_PERSON = "person" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_LANG, default=DEFAULT_LANG): vol.In(SUPPORTED_LANGUAGES), + vol.Required(CONF_APP_ID): cv.string, + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_SECRET_KEY): cv.string, + vol.Optional(CONF_SPEED, default=5): vol.All( + vol.Coerce(int), vol.Range(min=0, max=9) + ), + vol.Optional(CONF_PITCH, default=5): vol.All( + vol.Coerce(int), vol.Range(min=0, max=9) + ), + vol.Optional(CONF_VOLUME, default=5): vol.All( + vol.Coerce(int), vol.Range(min=0, max=15) + ), + vol.Optional(CONF_PERSON, default=0): vol.All( + vol.Coerce(int), vol.Range(min=0, max=4) + ), + } +) + +# Keys are options in the config file, and Values are options +# required by Baidu TTS API. +_OPTIONS = { + CONF_PERSON: "per", + CONF_PITCH: "pit", + CONF_SPEED: "spd", + CONF_VOLUME: "vol", +} +SUPPORTED_OPTIONS = [CONF_PERSON, CONF_PITCH, CONF_SPEED, CONF_VOLUME] + + +def get_engine(hass, config, discovery_info=None): + """Set up Baidu TTS component.""" + return BaiduTTSProvider(hass, config) + + +class BaiduTTSProvider(Provider): + """Baidu TTS speech api provider.""" + + def __init__(self, hass, conf): + """Init Baidu TTS service.""" + self.hass = hass + self._lang = conf.get(CONF_LANG) + self._codec = "mp3" + self.name = "BaiduTTS" + + self._app_data = { + "appid": conf.get(CONF_APP_ID), + "apikey": conf.get(CONF_API_KEY), + "secretkey": conf.get(CONF_SECRET_KEY), + } + + self._speech_conf_data = { + _OPTIONS[CONF_PERSON]: conf.get(CONF_PERSON), + _OPTIONS[CONF_PITCH]: conf.get(CONF_PITCH), + _OPTIONS[CONF_SPEED]: conf.get(CONF_SPEED), + _OPTIONS[CONF_VOLUME]: conf.get(CONF_VOLUME), + } + + @property + def default_language(self): + """Return the default language.""" + return self._lang + + @property + def supported_languages(self): + """Return a list of supported languages.""" + return SUPPORTED_LANGUAGES + + @property + def default_options(self): + """Return a dict including default options.""" + return { + CONF_PERSON: self._speech_conf_data[_OPTIONS[CONF_PERSON]], + CONF_PITCH: self._speech_conf_data[_OPTIONS[CONF_PITCH]], + CONF_SPEED: self._speech_conf_data[_OPTIONS[CONF_SPEED]], + CONF_VOLUME: self._speech_conf_data[_OPTIONS[CONF_VOLUME]], + } + + @property + def supported_options(self): + """Return a list of supported options.""" + return SUPPORTED_OPTIONS + + def get_tts_audio(self, message, language, options=None): + """Load TTS from BaiduTTS.""" + + aip_speech = AipSpeech( + self._app_data["appid"], + self._app_data["apikey"], + self._app_data["secretkey"], + ) + + if options is None: + result = aip_speech.synthesis(message, language, 1, self._speech_conf_data) + else: + speech_data = self._speech_conf_data.copy() + for key, value in options.items(): + speech_data[_OPTIONS[key]] = value + + result = aip_speech.synthesis(message, language, 1, speech_data) + + if isinstance(result, dict): + _LOGGER.error( + "Baidu TTS error-- err_no:%d; err_msg:%s; err_detail:%s", + result["err_no"], + result["err_msg"], + result["err_detail"], + ) + return None, None + + return self._codec, result diff --git a/homeassistant/components/bayesian/__init__.py b/homeassistant/components/bayesian/__init__.py new file mode 100644 index 000000000..971ff8427 --- /dev/null +++ b/homeassistant/components/bayesian/__init__.py @@ -0,0 +1 @@ +"""The bayesian component.""" diff --git a/homeassistant/components/bayesian/binary_sensor.py b/homeassistant/components/bayesian/binary_sensor.py new file mode 100644 index 000000000..1d3720f67 --- /dev/null +++ b/homeassistant/components/bayesian/binary_sensor.py @@ -0,0 +1,260 @@ +"""Use Bayesian Inference to trigger a binary sensor.""" +from collections import OrderedDict + +import voluptuous as vol + +from homeassistant.components.binary_sensor import PLATFORM_SCHEMA, BinarySensorDevice +from homeassistant.const import ( + CONF_ABOVE, + CONF_BELOW, + CONF_DEVICE_CLASS, + CONF_ENTITY_ID, + CONF_NAME, + CONF_PLATFORM, + CONF_STATE, + CONF_VALUE_TEMPLATE, + STATE_UNKNOWN, +) +from homeassistant.core import callback +from homeassistant.helpers import condition +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import async_track_state_change + +ATTR_OBSERVATIONS = "observations" +ATTR_PROBABILITY = "probability" +ATTR_PROBABILITY_THRESHOLD = "probability_threshold" + +CONF_OBSERVATIONS = "observations" +CONF_PRIOR = "prior" +CONF_TEMPLATE = "template" +CONF_PROBABILITY_THRESHOLD = "probability_threshold" +CONF_P_GIVEN_F = "prob_given_false" +CONF_P_GIVEN_T = "prob_given_true" +CONF_TO_STATE = "to_state" + +DEFAULT_NAME = "Bayesian Binary Sensor" +DEFAULT_PROBABILITY_THRESHOLD = 0.5 + +NUMERIC_STATE_SCHEMA = vol.Schema( + { + CONF_PLATFORM: "numeric_state", + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Optional(CONF_ABOVE): vol.Coerce(float), + vol.Optional(CONF_BELOW): vol.Coerce(float), + vol.Required(CONF_P_GIVEN_T): vol.Coerce(float), + vol.Optional(CONF_P_GIVEN_F): vol.Coerce(float), + }, + required=True, +) + +STATE_SCHEMA = vol.Schema( + { + CONF_PLATFORM: CONF_STATE, + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TO_STATE): cv.string, + vol.Required(CONF_P_GIVEN_T): vol.Coerce(float), + vol.Optional(CONF_P_GIVEN_F): vol.Coerce(float), + }, + required=True, +) + +TEMPLATE_SCHEMA = vol.Schema( + { + CONF_PLATFORM: CONF_TEMPLATE, + vol.Required(CONF_VALUE_TEMPLATE): cv.template, + vol.Required(CONF_P_GIVEN_T): vol.Coerce(float), + vol.Optional(CONF_P_GIVEN_F): vol.Coerce(float), + }, + required=True, +) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + 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, TEMPLATE_SCHEMA)], + ) + ), + vol.Required(CONF_PRIOR): vol.Coerce(float), + vol.Optional( + CONF_PROBABILITY_THRESHOLD, default=DEFAULT_PROBABILITY_THRESHOLD + ): vol.Coerce(float), + } +) + + +def update_probability(prior, prob_true, prob_false): + """Update probability using Bayes' rule.""" + numerator = prob_true * prior + denominator = numerator + prob_false * (1 - prior) + probability = numerator / denominator + return probability + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """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) + device_class = config.get(CONF_DEVICE_CLASS) + + async_add_entities( + [ + BayesianBinarySensor( + name, prior, observations, probability_threshold, device_class + ) + ], + True, + ) + + +class BayesianBinarySensor(BinarySensorDevice): + """Representation of a Bayesian sensor.""" + + def __init__(self, name, prior, observations, probability_threshold, device_class): + """Initialize the Bayesian sensor.""" + self._name = name + self._observations = observations + self._probability_threshold = probability_threshold + self._device_class = device_class + self._deviation = False + self.prior = prior + self.probability = prior + + self.current_obs = OrderedDict({}) + + to_observe = set() + for obs in self._observations: + if "entity_id" in obs: + to_observe.update(set([obs.get("entity_id")])) + if "value_template" in obs: + to_observe.update(set(obs.get(CONF_VALUE_TEMPLATE).extract_entities())) + self.entity_obs = {key: [] for key in to_observe} + + for ind, obs in enumerate(self._observations): + obs["id"] = ind + if "entity_id" in obs: + self.entity_obs[obs["entity_id"]].append(obs) + if "value_template" in obs: + for ent in obs.get(CONF_VALUE_TEMPLATE).extract_entities(): + self.entity_obs[ent].append(obs) + + self.watchers = { + "numeric_state": self._process_numeric_state, + "state": self._process_state, + "template": self._process_template, + } + + async def async_added_to_hass(self): + """Call when entity about to be added.""" + + @callback + def async_threshold_sensor_state_listener(entity, old_state, new_state): + """Handle sensor state changes.""" + if new_state.state == STATE_UNKNOWN: + return + + entity_obs_list = self.entity_obs[entity] + + for entity_obs in entity_obs_list: + platform = entity_obs["platform"] + + self.watchers[platform](entity_obs) + + prior = self.prior + for obs in self.current_obs.values(): + prior = update_probability(prior, obs["prob_true"], obs["prob_false"]) + self.probability = prior + + self.hass.async_add_job(self.async_update_ha_state, True) + + async_track_state_change( + self.hass, self.entity_obs, async_threshold_sensor_state_listener + ) + + def _update_current_obs(self, entity_observation, should_trigger): + """Update current observation.""" + 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[obs_id] = { + "prob_true": prob_true, + "prob_false": prob_false, + } + + else: + 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.""" + entity = entity_observation["entity_id"] + + should_trigger = condition.async_numeric_state( + self.hass, + entity, + entity_observation.get("below"), + entity_observation.get("above"), + None, + entity_observation, + ) + + self._update_current_obs(entity_observation, should_trigger) + + def _process_state(self, entity_observation): + """Add entity to current observations if state conditions are met.""" + entity = entity_observation["entity_id"] + + should_trigger = condition.state( + self.hass, entity, entity_observation.get("to_state") + ) + + self._update_current_obs(entity_observation, should_trigger) + + def _process_template(self, entity_observation): + """Add entity to current_obs if template is true.""" + template = entity_observation.get(CONF_VALUE_TEMPLATE) + template.hass = self.hass + should_trigger = condition.async_template( + self.hass, template, entity_observation + ) + self._update_current_obs(entity_observation, should_trigger) + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def is_on(self): + """Return true if sensor is on.""" + return self._deviation + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def 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_OBSERVATIONS: list(self.current_obs.values()), + ATTR_PROBABILITY: round(self.probability, 2), + ATTR_PROBABILITY_THRESHOLD: self._probability_threshold, + } + + async def async_update(self): + """Get the latest data and update the states.""" + self._deviation = bool(self.probability >= self._probability_threshold) diff --git a/homeassistant/components/bayesian/manifest.json b/homeassistant/components/bayesian/manifest.json new file mode 100644 index 000000000..7060dbd39 --- /dev/null +++ b/homeassistant/components/bayesian/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "bayesian", + "name": "Bayesian", + "documentation": "https://www.home-assistant.io/integrations/bayesian", + "requirements": [], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/bbb_gpio.py b/homeassistant/components/bbb_gpio.py deleted file mode 100644 index e3f327f1d..000000000 --- a/homeassistant/components/bbb_gpio.py +++ /dev/null @@ -1,71 +0,0 @@ -""" -Support for controlling GPIO pins of a Beaglebone Black. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/bbb_gpio/ -""" -import logging - -from homeassistant.const import ( - EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) - -REQUIREMENTS = ['Adafruit_BBIO==1.0.0'] - -_LOGGER = logging.getLogger(__name__) - -DOMAIN = 'bbb_gpio' - - -def setup(hass, config): - """Set up the BeagleBone Black GPIO component.""" - # pylint: disable=import-error - from Adafruit_BBIO import GPIO - - def cleanup_gpio(event): - """Stuff to do before stopping.""" - GPIO.cleanup() - - def prepare_gpio(event): - """Stuff to do when home assistant starts.""" - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, cleanup_gpio) - - hass.bus.listen_once(EVENT_HOMEASSISTANT_START, prepare_gpio) - return True - - -def setup_output(pin): - """Set up a GPIO as output.""" - # pylint: disable=import-error - from Adafruit_BBIO import GPIO - GPIO.setup(pin, GPIO.OUT) - - -def setup_input(pin, pull_mode): - """Set up a GPIO as input.""" - # pylint: disable=import-error - from Adafruit_BBIO import GPIO - GPIO.setup(pin, GPIO.IN, - GPIO.PUD_DOWN if pull_mode == 'DOWN' - else GPIO.PUD_UP) - - -def write_output(pin, value): - """Write a value to a GPIO.""" - # pylint: disable=import-error - from Adafruit_BBIO import GPIO - GPIO.output(pin, value) - - -def read_input(pin): - """Read a value from a GPIO.""" - # pylint: disable=import-error - from Adafruit_BBIO import GPIO - return GPIO.input(pin) is GPIO.HIGH - - -def edge_detect(pin, event_callback, bounce): - """Add detection for RISING and FALLING events.""" - # pylint: disable=import-error - from Adafruit_BBIO import GPIO - GPIO.add_event_detect( - pin, GPIO.BOTH, callback=event_callback, bouncetime=bounce) diff --git a/homeassistant/components/bbb_gpio/__init__.py b/homeassistant/components/bbb_gpio/__init__.py new file mode 100644 index 000000000..e68633c06 --- /dev/null +++ b/homeassistant/components/bbb_gpio/__init__.py @@ -0,0 +1,56 @@ +"""Support for controlling GPIO pins of a Beaglebone Black.""" +import logging + +from Adafruit_BBIO import GPIO # pylint: disable=import-error + +from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "bbb_gpio" + + +def setup(hass, config): + """Set up the BeagleBone Black GPIO component.""" + # pylint: disable=import-error + + def cleanup_gpio(event): + """Stuff to do before stopping.""" + GPIO.cleanup() + + def prepare_gpio(event): + """Stuff to do when home assistant starts.""" + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, cleanup_gpio) + + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, prepare_gpio) + return True + + +def setup_output(pin): + """Set up a GPIO as output.""" + + GPIO.setup(pin, GPIO.OUT) + + +def setup_input(pin, pull_mode): + """Set up a GPIO as input.""" + + GPIO.setup(pin, GPIO.IN, GPIO.PUD_DOWN if pull_mode == "DOWN" else GPIO.PUD_UP) + + +def write_output(pin, value): + """Write a value to a GPIO.""" + + GPIO.output(pin, value) + + +def read_input(pin): + """Read a value from a GPIO.""" + + return GPIO.input(pin) is GPIO.HIGH + + +def edge_detect(pin, event_callback, bounce): + """Add detection for RISING and FALLING events.""" + + GPIO.add_event_detect(pin, GPIO.BOTH, callback=event_callback, bouncetime=bounce) diff --git a/homeassistant/components/bbb_gpio/binary_sensor.py b/homeassistant/components/bbb_gpio/binary_sensor.py new file mode 100644 index 000000000..3ef13c117 --- /dev/null +++ b/homeassistant/components/bbb_gpio/binary_sensor.py @@ -0,0 +1,81 @@ +"""Support for binary sensor using Beaglebone Black GPIO.""" +import logging + +import voluptuous as vol + +from homeassistant.components import bbb_gpio +from homeassistant.components.binary_sensor import PLATFORM_SCHEMA, BinarySensorDevice +from homeassistant.const import CONF_NAME, DEVICE_DEFAULT_NAME +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +CONF_PINS = "pins" +CONF_BOUNCETIME = "bouncetime" +CONF_INVERT_LOGIC = "invert_logic" +CONF_PULL_MODE = "pull_mode" + +DEFAULT_BOUNCETIME = 50 +DEFAULT_INVERT_LOGIC = False +DEFAULT_PULL_MODE = "UP" + +PIN_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_BOUNCETIME, default=DEFAULT_BOUNCETIME): cv.positive_int, + vol.Optional(CONF_INVERT_LOGIC, default=DEFAULT_INVERT_LOGIC): cv.boolean, + vol.Optional(CONF_PULL_MODE, default=DEFAULT_PULL_MODE): vol.In(["UP", "DOWN"]), + } +) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + {vol.Required(CONF_PINS, default={}): vol.Schema({cv.string: PIN_SCHEMA})} +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Beaglebone Black GPIO devices.""" + pins = config.get(CONF_PINS) + + binary_sensors = [] + + for pin, params in pins.items(): + binary_sensors.append(BBBGPIOBinarySensor(pin, params)) + add_entities(binary_sensors) + + +class BBBGPIOBinarySensor(BinarySensorDevice): + """Representation of a binary sensor that uses Beaglebone Black GPIO.""" + + def __init__(self, pin, params): + """Initialize the Beaglebone Black binary sensor.""" + self._pin = pin + self._name = params.get(CONF_NAME) or DEVICE_DEFAULT_NAME + self._bouncetime = params.get(CONF_BOUNCETIME) + self._pull_mode = params.get(CONF_PULL_MODE) + self._invert_logic = params.get(CONF_INVERT_LOGIC) + + bbb_gpio.setup_input(self._pin, self._pull_mode) + self._state = bbb_gpio.read_input(self._pin) + + def read_gpio(pin): + """Read state from GPIO.""" + self._state = bbb_gpio.read_input(self._pin) + self.schedule_update_ha_state() + + bbb_gpio.edge_detect(self._pin, read_gpio, self._bouncetime) + + @property + def should_poll(self): + """No polling needed.""" + return False + + @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 != self._invert_logic diff --git a/homeassistant/components/bbb_gpio/manifest.json b/homeassistant/components/bbb_gpio/manifest.json new file mode 100644 index 000000000..edd603268 --- /dev/null +++ b/homeassistant/components/bbb_gpio/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "bbb_gpio", + "name": "Bbb gpio", + "documentation": "https://www.home-assistant.io/integrations/bbb_gpio", + "requirements": [ + "Adafruit_BBIO==1.0.0" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/bbb_gpio/switch.py b/homeassistant/components/bbb_gpio/switch.py new file mode 100644 index 000000000..eb75c6f37 --- /dev/null +++ b/homeassistant/components/bbb_gpio/switch.py @@ -0,0 +1,83 @@ +"""Allows to configure a switch using BeagleBone Black GPIO.""" +import logging + +import voluptuous as vol + +from homeassistant.components import bbb_gpio +from homeassistant.components.switch import PLATFORM_SCHEMA +from homeassistant.const import CONF_NAME, DEVICE_DEFAULT_NAME +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import ToggleEntity + +_LOGGER = logging.getLogger(__name__) + +CONF_PINS = "pins" +CONF_INITIAL = "initial" +CONF_INVERT_LOGIC = "invert_logic" + +PIN_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_INITIAL, default=False): cv.boolean, + vol.Optional(CONF_INVERT_LOGIC, default=False): cv.boolean, + } +) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + {vol.Required(CONF_PINS, default={}): vol.Schema({cv.string: PIN_SCHEMA})} +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the BeagleBone Black GPIO devices.""" + pins = config.get(CONF_PINS) + + switches = [] + for pin, params in pins.items(): + switches.append(BBBGPIOSwitch(pin, params)) + add_entities(switches) + + +class BBBGPIOSwitch(ToggleEntity): + """Representation of a BeagleBone Black GPIO.""" + + def __init__(self, pin, params): + """Initialize the pin.""" + self._pin = pin + self._name = params.get(CONF_NAME) or DEVICE_DEFAULT_NAME + self._state = params.get(CONF_INITIAL) + self._invert_logic = params.get(CONF_INVERT_LOGIC) + + bbb_gpio.setup_output(self._pin) + + if self._state is False: + bbb_gpio.write_output(self._pin, 1 if self._invert_logic else 0) + else: + bbb_gpio.write_output(self._pin, 0 if self._invert_logic else 1) + + @property + def name(self): + """Return the name of the switch.""" + return self._name + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def is_on(self): + """Return true if device is on.""" + return self._state + + def turn_on(self, **kwargs): + """Turn the device on.""" + bbb_gpio.write_output(self._pin, 0 if self._invert_logic else 1) + self._state = True + self.schedule_update_ha_state() + + def turn_off(self, **kwargs): + """Turn the device off.""" + bbb_gpio.write_output(self._pin, 1 if self._invert_logic else 0) + self._state = False + self.schedule_update_ha_state() diff --git a/homeassistant/components/bbox/__init__.py b/homeassistant/components/bbox/__init__.py new file mode 100644 index 000000000..8c3bbf0d5 --- /dev/null +++ b/homeassistant/components/bbox/__init__.py @@ -0,0 +1 @@ +"""The bbox component.""" diff --git a/homeassistant/components/bbox/device_tracker.py b/homeassistant/components/bbox/device_tracker.py new file mode 100644 index 000000000..8097c11eb --- /dev/null +++ b/homeassistant/components/bbox/device_tracker.py @@ -0,0 +1,96 @@ +"""Support for French FAI Bouygues Bbox routers.""" +from collections import namedtuple +from datetime import timedelta +import logging +from typing import List + +import pybbox +import voluptuous as vol + +from homeassistant.components.device_tracker import ( + DOMAIN, + PLATFORM_SCHEMA, + DeviceScanner, +) +from homeassistant.const import CONF_HOST +import homeassistant.helpers.config_validation as cv +from homeassistant.util import Throttle +import homeassistant.util.dt as dt_util + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_HOST = "192.168.1.254" + +MIN_TIME_BETWEEN_SCANS = timedelta(seconds=60) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + {vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string} +) + + +def get_scanner(hass, config): + """Validate the configuration and return a Bbox scanner.""" + scanner = BboxDeviceScanner(config[DOMAIN]) + + return scanner if scanner.success_init else None + + +Device = namedtuple("Device", ["mac", "name", "ip", "last_update"]) + + +class BboxDeviceScanner(DeviceScanner): + """This class scans for devices connected to the bbox.""" + + def __init__(self, config): + """Get host from config.""" + + self.host = config[CONF_HOST] + + """Initialize the scanner.""" + self.last_results: List[Device] = [] + + 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 + + @Throttle(MIN_TIME_BETWEEN_SCANS) + def _update_info(self): + """Check the Bbox for devices. + + Returns boolean if scanning successful. + """ + _LOGGER.info("Scanning...") + + box = pybbox.Bbox(ip=self.host) + result = box.get_all_connected_devices() + + now = dt_util.now() + last_results = [] + for device in result: + if device["active"] != 1: + continue + last_results.append( + Device( + device["macaddress"], device["hostname"], device["ipaddress"], now + ) + ) + + self.last_results = last_results + + _LOGGER.info("Scan successful") + return True diff --git a/homeassistant/components/bbox/manifest.json b/homeassistant/components/bbox/manifest.json new file mode 100644 index 000000000..15a648167 --- /dev/null +++ b/homeassistant/components/bbox/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "bbox", + "name": "Bbox", + "documentation": "https://www.home-assistant.io/integrations/bbox", + "requirements": [ + "pybbox==0.0.5-alpha" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/bbox/sensor.py b/homeassistant/components/bbox/sensor.py new file mode 100644 index 000000000..f5e5865f6 --- /dev/null +++ b/homeassistant/components/bbox/sensor.py @@ -0,0 +1,210 @@ +"""Support for Bbox Bouygues Modem Router.""" +from datetime import timedelta +import logging + +import pybbox +import requests +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + ATTR_ATTRIBUTION, + CONF_MONITORED_VARIABLES, + CONF_NAME, + DEVICE_CLASS_TIMESTAMP, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle +from homeassistant.util.dt import utcnow + +_LOGGER = logging.getLogger(__name__) + +BANDWIDTH_MEGABITS_SECONDS = "Mb/s" + +ATTRIBUTION = "Powered by Bouygues Telecom" + +DEFAULT_NAME = "Bbox" + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) + +# Sensor types are defined like so: Name, unit, icon +SENSOR_TYPES = { + "down_max_bandwidth": [ + "Maximum Download Bandwidth", + BANDWIDTH_MEGABITS_SECONDS, + "mdi:download", + ], + "up_max_bandwidth": [ + "Maximum Upload Bandwidth", + BANDWIDTH_MEGABITS_SECONDS, + "mdi:upload", + ], + "current_down_bandwidth": [ + "Currently Used Download Bandwidth", + BANDWIDTH_MEGABITS_SECONDS, + "mdi:download", + ], + "current_up_bandwidth": [ + "Currently Used Upload Bandwidth", + BANDWIDTH_MEGABITS_SECONDS, + "mdi:upload", + ], + "uptime": ["Uptime", None, "mdi:clock"], + "number_of_reboots": ["Number of reboot", None, "mdi:restart"], +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_MONITORED_VARIABLES): vol.All( + cv.ensure_list, [vol.In(SENSOR_TYPES)] + ), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Bbox sensor.""" + # Create a data fetcher to support all of the configured sensors. Then make + # the first call to init the data. + try: + bbox_data = BboxData() + bbox_data.update() + except requests.exceptions.HTTPError as error: + _LOGGER.error(error) + return False + + name = config.get(CONF_NAME) + + sensors = [] + for variable in config[CONF_MONITORED_VARIABLES]: + if variable == "uptime": + sensors.append(BboxUptimeSensor(bbox_data, variable, name)) + else: + sensors.append(BboxSensor(bbox_data, variable, name)) + + add_entities(sensors, True) + + +class BboxUptimeSensor(Entity): + """Bbox uptime sensor.""" + + def __init__(self, bbox_data, sensor_type, name): + """Initialize the sensor.""" + self.client_name = name + self.type = sensor_type + self._name = SENSOR_TYPES[sensor_type][0] + self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self._icon = SENSOR_TYPES[sensor_type][2] + self.bbox_data = bbox_data + self._state = None + + @property + def name(self): + """Return the name of the sensor.""" + return f"{self.client_name} {self._name}" + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return self._icon + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return {ATTR_ATTRIBUTION: ATTRIBUTION} + + @property + def device_class(self): + """Return the class of this sensor.""" + return DEVICE_CLASS_TIMESTAMP + + def update(self): + """Get the latest data from Bbox and update the state.""" + self.bbox_data.update() + uptime = utcnow() - timedelta( + seconds=self.bbox_data.router_infos["device"]["uptime"] + ) + self._state = uptime.replace(microsecond=0).isoformat() + + +class BboxSensor(Entity): + """Implementation of a Bbox sensor.""" + + def __init__(self, bbox_data, sensor_type, name): + """Initialize the sensor.""" + self.client_name = name + self.type = sensor_type + self._name = SENSOR_TYPES[sensor_type][0] + self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self._icon = SENSOR_TYPES[sensor_type][2] + self.bbox_data = bbox_data + self._state = None + + @property + def name(self): + """Return the name of the sensor.""" + return f"{self.client_name} {self._name}" + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._unit_of_measurement + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return self._icon + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return {ATTR_ATTRIBUTION: ATTRIBUTION} + + def update(self): + """Get the latest data from Bbox and update the state.""" + self.bbox_data.update() + if self.type == "down_max_bandwidth": + self._state = round(self.bbox_data.data["rx"]["maxBandwidth"] / 1000, 2) + elif self.type == "up_max_bandwidth": + self._state = round(self.bbox_data.data["tx"]["maxBandwidth"] / 1000, 2) + elif self.type == "current_down_bandwidth": + self._state = round(self.bbox_data.data["rx"]["bandwidth"] / 1000, 2) + elif self.type == "current_up_bandwidth": + self._state = round(self.bbox_data.data["tx"]["bandwidth"] / 1000, 2) + elif self.type == "number_of_reboots": + self._state = self.bbox_data.router_infos["device"]["numberofboots"] + + +class BboxData: + """Get data from the Bbox.""" + + def __init__(self): + """Initialize the data object.""" + self.data = None + self.router_infos = None + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data from the Bbox.""" + + try: + box = pybbox.Bbox() + self.data = box.get_ip_stats() + self.router_infos = box.get_bbox_info() + except requests.exceptions.HTTPError as error: + _LOGGER.error(error) + self.data = None + self.router_infos = None + return False diff --git a/homeassistant/components/beewi_smartclim/__init__.py b/homeassistant/components/beewi_smartclim/__init__.py new file mode 100644 index 000000000..f907ce95a --- /dev/null +++ b/homeassistant/components/beewi_smartclim/__init__.py @@ -0,0 +1 @@ +"""The beewi_smartclim component.""" diff --git a/homeassistant/components/beewi_smartclim/manifest.json b/homeassistant/components/beewi_smartclim/manifest.json new file mode 100644 index 000000000..bc69efb64 --- /dev/null +++ b/homeassistant/components/beewi_smartclim/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "beewi_smartclim", + "name": "BeeWi SmartClim BLE sensor", + "documentation": "https://www.home-assistant.io/integrations/beewi_smartclim", + "requirements": [ + "beewi_smartclim==0.0.7" + ], + "dependencies": [], + "codeowners": [ + "@alemuro" + ] +} \ No newline at end of file diff --git a/homeassistant/components/beewi_smartclim/sensor.py b/homeassistant/components/beewi_smartclim/sensor.py new file mode 100644 index 000000000..be1697e4f --- /dev/null +++ b/homeassistant/components/beewi_smartclim/sensor.py @@ -0,0 +1,108 @@ +"""Platform for beewi_smartclim integration.""" +import logging + +from beewi_smartclim import BeewiSmartClimPoller +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_MAC, + CONF_NAME, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_TEMPERATURE, + TEMP_CELSIUS, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +# Default values +DEFAULT_NAME = "BeeWi SmartClim" + +# Sensor config +SENSOR_TYPES = [ + [DEVICE_CLASS_TEMPERATURE, "Temperature", TEMP_CELSIUS], + [DEVICE_CLASS_HUMIDITY, "Humidity", "%"], + [DEVICE_CLASS_BATTERY, "Battery", "%"], +] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_MAC): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the beewi_smartclim platform.""" + + mac = config[CONF_MAC] + prefix = config[CONF_NAME] + poller = BeewiSmartClimPoller(mac) + + sensors = [] + + for sensor_type in SENSOR_TYPES: + device = sensor_type[0] + name = sensor_type[1] + unit = sensor_type[2] + # `prefix` is the name configured by the user for the sensor, we're appending + # the device type at the end of the name (garden -> garden temperature) + if prefix: + name = f"{prefix} {name}" + + sensors.append(BeewiSmartclimSensor(poller, name, mac, device, unit)) + + add_entities(sensors) + + +class BeewiSmartclimSensor(Entity): + """Representation of a Sensor.""" + + def __init__(self, poller, name, mac, device, unit): + """Initialize the sensor.""" + self._poller = poller + self._name = name + self._mac = mac + self._device = device + self._unit = unit + self._state = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor. State is returned in Celsius.""" + return self._state + + @property + def device_class(self): + """Device class of this entity.""" + return self._device + + @property + def unique_id(self): + """Return a unique, HASS-friendly identifier for this entity.""" + return f"{self._mac}_{self._device}" + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit + + def update(self): + """Fetch new state data from the poller.""" + self._poller.update_sensor() + self._state = None + if self._device == DEVICE_CLASS_TEMPERATURE: + self._state = self._poller.get_temperature() + if self._device == DEVICE_CLASS_HUMIDITY: + self._state = self._poller.get_humidity() + if self._device == DEVICE_CLASS_BATTERY: + self._state = self._poller.get_battery() diff --git a/homeassistant/components/bh1750/__init__.py b/homeassistant/components/bh1750/__init__.py new file mode 100644 index 000000000..ce7ecc653 --- /dev/null +++ b/homeassistant/components/bh1750/__init__.py @@ -0,0 +1 @@ +"""The bh1750 component.""" diff --git a/homeassistant/components/bh1750/manifest.json b/homeassistant/components/bh1750/manifest.json new file mode 100644 index 000000000..63816f967 --- /dev/null +++ b/homeassistant/components/bh1750/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "bh1750", + "name": "Bh1750", + "documentation": "https://www.home-assistant.io/integrations/bh1750", + "requirements": [ + "i2csense==0.0.4", + "smbus-cffi==0.5.1" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/bh1750/sensor.py b/homeassistant/components/bh1750/sensor.py new file mode 100644 index 000000000..924bfcd55 --- /dev/null +++ b/homeassistant/components/bh1750/sensor.py @@ -0,0 +1,140 @@ +"""Support for BH1750 light sensor.""" +from functools import partial +import logging + +from i2csense.bh1750 import BH1750 # pylint: disable=import-error +import smbus # pylint: disable=import-error +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_NAME, DEVICE_CLASS_ILLUMINANCE +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +CONF_I2C_ADDRESS = "i2c_address" +CONF_I2C_BUS = "i2c_bus" +CONF_OPERATION_MODE = "operation_mode" +CONF_SENSITIVITY = "sensitivity" +CONF_DELAY = "measurement_delay_ms" +CONF_MULTIPLIER = "multiplier" + +# Operation modes for BH1750 sensor (from the datasheet). Time typically 120ms +# In one time measurements, device is set to Power Down after each sample. +CONTINUOUS_LOW_RES_MODE = "continuous_low_res_mode" +CONTINUOUS_HIGH_RES_MODE_1 = "continuous_high_res_mode_1" +CONTINUOUS_HIGH_RES_MODE_2 = "continuous_high_res_mode_2" +ONE_TIME_LOW_RES_MODE = "one_time_low_res_mode" +ONE_TIME_HIGH_RES_MODE_1 = "one_time_high_res_mode_1" +ONE_TIME_HIGH_RES_MODE_2 = "one_time_high_res_mode_2" +OPERATION_MODES = { + CONTINUOUS_LOW_RES_MODE: (0x13, True), # 4lx resolution + CONTINUOUS_HIGH_RES_MODE_1: (0x10, True), # 1lx resolution. + CONTINUOUS_HIGH_RES_MODE_2: (0x11, True), # 0.5lx resolution. + ONE_TIME_LOW_RES_MODE: (0x23, False), # 4lx resolution. + ONE_TIME_HIGH_RES_MODE_1: (0x20, False), # 1lx resolution. + ONE_TIME_HIGH_RES_MODE_2: (0x21, False), # 0.5lx resolution. +} + +SENSOR_UNIT = "lx" +DEFAULT_NAME = "BH1750 Light Sensor" +DEFAULT_I2C_ADDRESS = "0x23" +DEFAULT_I2C_BUS = 1 +DEFAULT_MODE = CONTINUOUS_HIGH_RES_MODE_1 +DEFAULT_DELAY_MS = 120 +DEFAULT_SENSITIVITY = 69 # from 31 to 254 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_I2C_ADDRESS, default=DEFAULT_I2C_ADDRESS): cv.string, + vol.Optional(CONF_I2C_BUS, default=DEFAULT_I2C_BUS): vol.Coerce(int), + vol.Optional(CONF_OPERATION_MODE, default=DEFAULT_MODE): vol.In( + OPERATION_MODES + ), + vol.Optional(CONF_SENSITIVITY, default=DEFAULT_SENSITIVITY): cv.positive_int, + vol.Optional(CONF_DELAY, default=DEFAULT_DELAY_MS): cv.positive_int, + vol.Optional(CONF_MULTIPLIER, default=1.0): vol.Range(min=0.1, max=10), + } +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the BH1750 sensor.""" + + name = config.get(CONF_NAME) + bus_number = config.get(CONF_I2C_BUS) + i2c_address = config.get(CONF_I2C_ADDRESS) + operation_mode = config.get(CONF_OPERATION_MODE) + + bus = smbus.SMBus(bus_number) + + sensor = await hass.async_add_job( + partial( + BH1750, + bus, + i2c_address, + operation_mode=operation_mode, + measurement_delay=config.get(CONF_DELAY), + sensitivity=config.get(CONF_SENSITIVITY), + logger=_LOGGER, + ) + ) + if not sensor.sample_ok: + _LOGGER.error("BH1750 sensor not detected at %s", i2c_address) + return False + + dev = [BH1750Sensor(sensor, name, SENSOR_UNIT, config.get(CONF_MULTIPLIER))] + _LOGGER.info( + "Setup of BH1750 light sensor at %s in mode %s is complete", + i2c_address, + operation_mode, + ) + + async_add_entities(dev, True) + + +class BH1750Sensor(Entity): + """Implementation of the BH1750 sensor.""" + + def __init__(self, bh1750_sensor, name, unit, multiplier=1.0): + """Initialize the sensor.""" + self._name = name + self._unit_of_measurement = unit + self._multiplier = multiplier + self.bh1750_sensor = bh1750_sensor + if self.bh1750_sensor.light_level >= 0: + self._state = int(round(self.bh1750_sensor.light_level)) + else: + self._state = None + + @property + def name(self) -> str: + """Return the name of the sensor.""" + return self._name + + @property + def state(self) -> int: + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self) -> str: + """Return the unit of measurement of the sensor.""" + return self._unit_of_measurement + + @property + def device_class(self) -> str: + """Return the class of this device, from component DEVICE_CLASSES.""" + return DEVICE_CLASS_ILLUMINANCE + + async def async_update(self): + """Get the latest data from the BH1750 and update the states.""" + await self.hass.async_add_job(self.bh1750_sensor.update) + if self.bh1750_sensor.sample_ok and self.bh1750_sensor.light_level >= 0: + self._state = int(round(self.bh1750_sensor.light_level * self._multiplier)) + else: + _LOGGER.warning( + "Bad Update of sensor.%s: %s", self.name, self.bh1750_sensor.light_level + ) diff --git a/homeassistant/components/binary_sensor/.translations/bg.json b/homeassistant/components/binary_sensor/.translations/bg.json new file mode 100644 index 000000000..373866ecd --- /dev/null +++ b/homeassistant/components/binary_sensor/.translations/bg.json @@ -0,0 +1,94 @@ +{ + "device_automation": { + "condition_type": { + "is_bat_low": "{entity_name} \u0431\u0430\u0442\u0435\u0440\u0438\u044f\u0442\u0430 \u0435 \u0438\u0437\u0442\u043e\u0449\u0435\u043d\u0430", + "is_cold": "{entity_name} \u0435 \u0441\u0442\u0443\u0434\u0435\u043d", + "is_connected": "{entity_name} \u0435 \u0441\u0432\u044a\u0440\u0437\u0430\u043d", + "is_gas": "{entity_name} \u043e\u0442\u043a\u0440\u0438\u0432\u0430 \u0433\u0430\u0437", + "is_hot": "{entity_name} \u0435 \u0433\u043e\u0440\u0435\u0449", + "is_light": "{entity_name} \u0437\u0430\u0441\u0438\u0447\u0430 \u0441\u0432\u0435\u0442\u043b\u0438\u043d\u0430", + "is_locked": "{entity_name} \u0435 \u0437\u0430\u043a\u043b\u044e\u0447\u0435\u043d", + "is_moist": "{entity_name} \u0435 \u0432\u043b\u0430\u0436\u0435\u043d", + "is_motion": "{entity_name} \u043e\u0442\u043a\u0440\u0438\u0432\u0430 \u0434\u0432\u0438\u0436\u0435\u043d\u0438\u0435", + "is_moving": "{entity_name} \u0441\u0435 \u0434\u0432\u0438\u0436\u0438", + "is_no_gas": "{entity_name} \u043d\u0435 \u043e\u0442\u043a\u0440\u0438\u0432\u0430 \u0433\u0430\u0437", + "is_no_light": "{entity_name} \u043d\u0435 \u0437\u0430\u0441\u0438\u0447\u0430 \u0441\u0432\u0435\u0442\u043b\u0438\u043d\u0430", + "is_no_motion": "{entity_name} \u043d\u0435 \u043e\u0442\u043a\u0440\u0438\u0432\u0430 \u0434\u0432\u0438\u0436\u0435\u043d\u0438\u0435", + "is_no_problem": "{entity_name} \u043d\u0435 \u043e\u0442\u043a\u0440\u0438\u0432\u0430 \u043f\u0440\u043e\u0431\u043b\u0435\u043c", + "is_no_smoke": "{entity_name} \u043d\u0435 \u043e\u0442\u043a\u0440\u0438\u0432\u0430 \u0434\u0438\u043c", + "is_no_sound": "{entity_name} \u043d\u0435 \u0437\u0430\u0441\u0438\u0447\u0430 \u0437\u0432\u0443\u043a", + "is_no_vibration": "{entity_name} \u043d\u0435 \u0437\u0430\u0441\u0438\u0447\u0430 \u0432\u0438\u0431\u0440\u0430\u0446\u0438\u0438", + "is_not_bat_low": "{entity_name} \u0431\u0430\u0442\u0435\u0440\u0438\u044f\u0442\u0430 \u0435 \u0437\u0430\u0440\u0435\u0434\u0435\u043d\u0430", + "is_not_cold": "{entity_name} \u043d\u0435 \u0435 \u0441\u0442\u0443\u0434\u0435\u043d", + "is_not_connected": "{entity_name} \u0435 \u0440\u0430\u0437\u043a\u0430\u0447\u0435\u043d", + "is_not_hot": "{entity_name} \u043d\u0435 \u0435 \u0433\u043e\u0440\u0435\u0449", + "is_not_locked": "{entity_name} \u0435 \u043e\u0442\u043a\u043b\u044e\u0447\u0435\u043d", + "is_not_moist": "{entity_name} \u0435 \u0441\u0443\u0445", + "is_not_moving": "{entity_name} \u043d\u0435 \u0441\u0435 \u0434\u0432\u0438\u0436\u0438", + "is_not_occupied": "{entity_name} \u043d\u0435 \u0435 \u0437\u0430\u0435\u0442", + "is_not_open": "{entity_name} \u0435 \u0437\u0430\u0442\u0432\u043e\u0440\u0435\u043d", + "is_not_plugged_in": "{entity_name} \u0435 \u0438\u0437\u043a\u043b\u044e\u0447\u0435\u043d", + "is_not_powered": "{entity_name} \u043d\u0435 \u0441\u0435 \u0437\u0430\u0445\u0440\u0430\u043d\u0432\u0430", + "is_not_present": "{entity_name} \u043d\u0435 \u0435 \u043d\u0430\u043b\u0438\u0446\u0435", + "is_not_unsafe": "{entity_name} \u0435 \u0431\u0435\u0437\u043e\u043f\u0430\u0441\u0435\u043d", + "is_occupied": "{entity_name} \u0435 \u0437\u0430\u0435\u0442", + "is_off": "{entity_name} \u0435 \u0438\u0437\u043a\u043b\u044e\u0447\u0435\u043d", + "is_on": "{entity_name} \u0435 \u0432\u043a\u043b\u044e\u0447\u0435\u043d", + "is_open": "{entity_name} \u0435 \u043e\u0442\u0432\u043e\u0440\u0435\u043d", + "is_plugged_in": "{entity_name} \u0435 \u0432\u043a\u043b\u044e\u0447\u0435\u043d", + "is_powered": "{entity_name} \u0441\u0435 \u0437\u0430\u0445\u0440\u0430\u043d\u0432\u0430", + "is_present": "{entity_name} \u043f\u0440\u0438\u0441\u044a\u0441\u0442\u0432\u0430", + "is_problem": "{entity_name} \u043e\u0442\u043a\u0440\u0438\u0432\u0430 \u043f\u0440\u043e\u0431\u043b\u0435\u043c", + "is_smoke": "{entity_name} \u043e\u0442\u043a\u0440\u0438\u0432\u0430 \u0434\u0438\u043c", + "is_sound": "{entity_name} \u043e\u0442\u043a\u0440\u0438\u0432\u0430 \u0437\u0432\u0443\u043a", + "is_unsafe": "{entity_name} \u043d\u0435 \u0435 \u0431\u0435\u0437\u043e\u043f\u0430\u0441\u0435\u043d", + "is_vibration": "{entity_name} \u0437\u0430\u0441\u0438\u0447\u0430 \u0432\u0438\u0431\u0440\u0430\u0446\u0438\u0438" + }, + "trigger_type": { + "bat_low": "{entity_name} \u0438\u0437\u0442\u043e\u0449\u0435\u043d\u0430 \u0431\u0430\u0442\u0435\u0440\u0438\u044f", + "closed": "{entity_name} \u0437\u0430\u0442\u0432\u043e\u0440\u0435\u043d", + "cold": "{entity_name} \u0441\u0435 \u0438\u0437\u0441\u0442\u0443\u0434\u0438", + "connected": "{entity_name} \u0441\u0432\u044a\u0440\u0437\u0430\u043d", + "gas": "{entity_name} \u0437\u0430\u043f\u043e\u0447\u043d\u0430 \u0434\u0430 \u043e\u0442\u043a\u0440\u0438\u0432\u0430 \u0433\u0430\u0437", + "hot": "{entity_name} \u0441\u0435 \u0441\u0442\u043e\u043f\u043b\u0438", + "light": "{entity_name} \u0437\u0430\u043f\u043e\u0447\u043d\u0430 \u0434\u0430 \u043e\u0442\u043a\u0440\u0438\u0432\u0430 \u0441\u0432\u0435\u0442\u043b\u0438\u043d\u0430", + "locked": "{entity_name} \u0437\u0430\u043a\u043b\u044e\u0447\u0435\u043d", + "moist": "{entity_name} \u0441\u0442\u0430\u043d\u0430 \u0432\u043b\u0430\u0436\u0435\u043d", + "moist\u00a7": "{entity_name} \u0441\u0442\u0430\u0432\u0430 \u0432\u043b\u0430\u0436\u0435\u043d", + "motion": "{entity_name} \u0437\u0430\u043f\u043e\u0447\u043d\u0430 \u043e\u0442\u043a\u0440\u0438\u0432\u0430\u043d\u0435 \u043d\u0430 \u0434\u0432\u0438\u0436\u0435\u043d\u0438\u0435", + "moving": "{entity_name} \u0437\u0430\u043f\u043e\u0447\u043d\u0430 \u043e\u0442\u043a\u0440\u0438\u0432\u0430 \u0434\u0432\u0438\u0436\u0435\u043d\u0438\u0435", + "no_gas": "{entity_name} \u0441\u043f\u0440\u044f \u0434\u0430 \u043e\u0442\u043a\u0440\u0438\u0432\u0430 \u0433\u0430\u0437", + "no_light": "{entity_name} \u0441\u043f\u0440\u044f \u0434\u0430 \u0437\u0430\u0441\u0438\u0447\u0430 \u0441\u0432\u0435\u0442\u043b\u0438\u043d\u0430", + "no_motion": "{entity_name} \u0441\u043f\u0440\u044f \u0434\u0430 \u043e\u0442\u043a\u0440\u0438\u0432\u0430 \u0434\u0432\u0438\u0436\u0435\u043d\u0438\u0435", + "no_problem": "{entity_name} \u0441\u043f\u0440\u044f \u0434\u0430 \u043e\u0442\u043a\u0440\u0438\u0432\u0430 \u043f\u0440\u043e\u0431\u043b\u0435\u043c", + "no_smoke": "{entity_name} \u0441\u043f\u0440\u044f \u0434\u0430 \u043e\u0442\u043a\u0440\u0438\u0432\u0430 \u0434\u0438\u043c", + "no_sound": "{entity_name} \u0441\u043f\u0440\u044f \u0434\u0430 \u043e\u0442\u043a\u0440\u0438\u0432\u0430 \u0437\u0432\u0443\u043a", + "no_vibration": "{entity_name} \u0441\u043f\u0440\u044f \u0434\u0430 \u0437\u0430\u0441\u0438\u0447\u0430 \u0432\u0438\u0431\u0440\u0430\u0446\u0438\u0438", + "not_bat_low": "{entity_name} \u0431\u0430\u0442\u0435\u0440\u0438\u044f\u0442\u0430 \u043d\u0435 \u0435 \u0438\u0437\u0442\u043e\u0449\u0435\u043d\u0430", + "not_cold": "{entity_name} \u0441\u0435 \u0441\u0442\u043e\u043f\u043b\u0438", + "not_connected": "{entity_name} \u0438\u0437\u043a\u043b\u044e\u0447\u0435\u043d", + "not_hot": "{entity_name} \u043e\u0445\u043b\u0430\u0434\u043d\u044f", + "not_locked": "{entity_name} \u043e\u0442\u043a\u043b\u044e\u0447\u0435\u043d", + "not_moist": "{entity_name} \u0441\u0442\u0430\u0432\u0430 \u0441\u0443\u0445", + "not_moving": "{entity_name} \u0441\u043f\u0440\u044f \u0434\u0430 \u0441\u0435 \u0434\u0432\u0438\u0436\u0438", + "not_occupied": "{entity_name} \u0432\u0435\u0447\u0435 \u043d\u0435 \u0435 \u0437\u0430\u0435\u0442", + "not_opened": "{entity_name} \u0437\u0430\u0442\u0432\u043e\u0440\u0435\u043d", + "not_plugged_in": "{entity_name} \u0438\u0437\u043a\u043b\u044e\u0447\u0435\u043d", + "not_powered": "{entity_name} \u043d\u0435 \u0441\u0435 \u0437\u0430\u0445\u0440\u0430\u043d\u0432\u0430", + "not_present": "{entity_name} \u043d\u0435 \u043f\u0440\u0438\u0441\u044a\u0441\u0442\u0432\u0430", + "not_unsafe": "{entity_name} \u0441\u0442\u0430\u043d\u0430 \u0431\u0435\u0437\u043e\u043f\u0430\u0441\u0435\u043d", + "occupied": "{entity_name} \u0441\u0442\u0430\u043d\u0430 \u0437\u0430\u0435\u0442", + "opened": "{entity_name} \u0441\u0435 \u043e\u0442\u0432\u043e\u0440\u0438", + "plugged_in": "{entity_name} \u0441\u0435 \u0432\u043a\u043b\u044e\u0447\u0438", + "powered": "{entity_name} \u0441\u0435 \u0437\u0430\u0445\u0440\u0430\u043d\u0432\u0430", + "present": "{entity_name} \u043f\u0440\u0438\u0441\u044a\u0441\u0442\u0432\u0430", + "problem": "{entity_name} \u0437\u0430\u043f\u043e\u0447\u043d\u0430 \u0434\u0430 \u043e\u0442\u043a\u0440\u0438\u0432\u0430 \u043f\u0440\u043e\u0431\u043b\u0435\u043c", + "smoke": "{entity_name} \u0437\u0430\u043f\u043e\u0447\u043d\u0430 \u0434\u0430 \u043e\u0442\u043a\u0440\u0438\u0432\u0430 \u0434\u0438\u043c", + "sound": "{entity_name} \u0437\u0430\u043f\u043e\u0447\u043d\u0430 \u0434\u0430 \u0437\u0430\u0441\u0438\u0447\u0430 \u0437\u0432\u0443\u043a", + "turned_off": "{entity_name} \u0435 \u0438\u0437\u043a\u043b\u044e\u0447\u0435\u043d", + "turned_on": "{entity_name} \u0435 \u0432\u043a\u043b\u044e\u0447\u0435\u043d", + "unsafe": "{entity_name} \u0441\u0442\u0430\u043d\u0430 \u043e\u043f\u0430\u0441\u0435\u043d", + "vibration": "{entity_name} \u0437\u0430\u043f\u043e\u0447\u043d\u0430 \u0434\u0430 \u0437\u0430\u0441\u0438\u0447\u0430 \u0432\u0438\u0431\u0440\u0430\u0446\u0438\u0438" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/.translations/ca.json b/homeassistant/components/binary_sensor/.translations/ca.json new file mode 100644 index 000000000..8bbd19a0d --- /dev/null +++ b/homeassistant/components/binary_sensor/.translations/ca.json @@ -0,0 +1,94 @@ +{ + "device_automation": { + "condition_type": { + "is_bat_low": "Bateria de {entity_name} baixa", + "is_cold": "{entity_name} est\u00e0 fred", + "is_connected": "{entity_name} est\u00e0 connectat", + "is_gas": "{entity_name} est\u00e0 detectant gas", + "is_hot": "{entity_name} est\u00e0 calent", + "is_light": "{entity_name} est\u00e0 detectant llum", + "is_locked": "{entity_name} est\u00e0 bloquejat", + "is_moist": "{entity_name} est\u00e0 humit", + "is_motion": "{entity_name} est\u00e0 detectant moviment", + "is_moving": "{entity_name} s'est\u00e0 movent", + "is_no_gas": "{entity_name} no detecta gas", + "is_no_light": "{entity_name} no detecta llum", + "is_no_motion": "{entity_name} no detecta moviment", + "is_no_problem": "{entity_name} no est\u00e0 detectant cap problema", + "is_no_smoke": "{entity_name} no detecta fum", + "is_no_sound": "{entity_name} no detecta so", + "is_no_vibration": "{entity_name} no detecta vibraci\u00f3", + "is_not_bat_low": "Bateria de {entity_name} normal", + "is_not_cold": "{entity_name} no est\u00e0 fred", + "is_not_connected": "{entity_name} est\u00e0 desconnectat", + "is_not_hot": "{entity_name} no est\u00e0 calent", + "is_not_locked": "{entity_name} est\u00e0 desbloquejat", + "is_not_moist": "{entity_name} est\u00e0 sec", + "is_not_moving": "{entity_name} no s'est\u00e0 movent", + "is_not_occupied": "{entity_name} no est\u00e0 ocupat", + "is_not_open": "{entity_name} est\u00e0 tancat", + "is_not_plugged_in": "{entity_name} est\u00e0 desendollat", + "is_not_powered": "{entity_name} no est\u00e0 alimentat", + "is_not_present": "{entity_name} no est\u00e0 present", + "is_not_unsafe": "{entity_name} \u00e9s segur", + "is_occupied": "{entity_name} est\u00e0 ocupat", + "is_off": "{entity_name} est\u00e0 apagat", + "is_on": "{entity_name} est\u00e0 enc\u00e8s", + "is_open": "{entity_name} est\u00e0 obert", + "is_plugged_in": "{entity_name} est\u00e0 endollat", + "is_powered": "{entity_name} est\u00e0 alimentat", + "is_present": "{entity_name} est\u00e0 present", + "is_problem": "{entity_name} est\u00e0 detectant un problema", + "is_smoke": "{entity_name} est\u00e0 detectant fum", + "is_sound": "{entity_name} est\u00e0 detectant so", + "is_unsafe": "{entity_name} \u00e9s insegur", + "is_vibration": "{entity_name} est\u00e0 detectant vibraci\u00f3" + }, + "trigger_type": { + "bat_low": "Bateria de {entity_name} baixa", + "closed": "{entity_name} est\u00e0 tancat", + "cold": "{entity_name} es torna fred", + "connected": "{entity_name} est\u00e0 connectat", + "gas": "{entity_name} ha comen\u00e7at a detectar gas", + "hot": "{entity_name} es torna calent", + "light": "{entity_name} ha comen\u00e7at a detectar llum", + "locked": "{entity_name} est\u00e0 bloquejat", + "moist": "{entity_name} es torna humit", + "moist\u00a7": "{entity_name} es torna humit", + "motion": "{entity_name} ha comen\u00e7at a detectar moviment", + "moving": "{entity_name} ha comen\u00e7at a moure's", + "no_gas": "{entity_name} ha deixat de detectar gas", + "no_light": "{entity_name} ha deixat de detectar llum", + "no_motion": "{entity_name} ha deixat de detectar moviment", + "no_problem": "{entity_name} ha deixat de detectar un problema", + "no_smoke": "{entity_name} ha deixat de detectar fum", + "no_sound": "{entity_name} ha deixat de detectar so", + "no_vibration": "{entity_name} ha deixat de detectar vibraci\u00f3", + "not_bat_low": "Bateria de {entity_name} normal", + "not_cold": "{entity_name} es torna no-fred", + "not_connected": "{entity_name} est\u00e0 desconnectat", + "not_hot": "{entity_name} es torna no-calent", + "not_locked": "{entity_name} est\u00e0 desbloquejat", + "not_moist": "{entity_name} es torna sec", + "not_moving": "{entity_name} ha parat de moure's", + "not_occupied": "{entity_name} es desocupa", + "not_opened": "{entity_name} es tanca", + "not_plugged_in": "{entity_name} desendollat", + "not_powered": "{entity_name} no est\u00e0 alimentat", + "not_present": "{entity_name} no est\u00e0 present", + "not_unsafe": "{entity_name} es torna segur", + "occupied": "{entity_name} s'ocupa", + "opened": "{entity_name} s'ha obert", + "plugged_in": "{entity_name} s'ha endollat", + "powered": "{entity_name} alimentat", + "present": "{entity_name} present", + "problem": "{entity_name} ha comen\u00e7at a detectar un problema", + "smoke": "{entity_name} ha comen\u00e7at a detectar fum", + "sound": "{entity_name} ha comen\u00e7at a detectar so", + "turned_off": "{entity_name} apagat", + "turned_on": "{entity_name} enc\u00e8s", + "unsafe": "{entity_name} es torna insegur", + "vibration": "{entity_name} ha comen\u00e7at a detectar vibraci\u00f3" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/.translations/cs.json b/homeassistant/components/binary_sensor/.translations/cs.json new file mode 100644 index 000000000..cb941e678 --- /dev/null +++ b/homeassistant/components/binary_sensor/.translations/cs.json @@ -0,0 +1,8 @@ +{ + "device_automation": { + "trigger_type": { + "moist": "{entity_name} se navlh\u010dil", + "not_opened": "{entity_name} uzav\u0159eno" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/.translations/da.json b/homeassistant/components/binary_sensor/.translations/da.json new file mode 100644 index 000000000..f7bd83456 --- /dev/null +++ b/homeassistant/components/binary_sensor/.translations/da.json @@ -0,0 +1,67 @@ +{ + "device_automation": { + "condition_type": { + "is_cold": "{entity_name} er kold", + "is_connected": "{entity_name} er tilsluttet", + "is_gas": "{entity_name} registrerer gas", + "is_hot": "{entity_name} er varm", + "is_light": "{entity_name} registrerer lys", + "is_locked": "{entity_name} er l\u00e5st", + "is_moist": "{entity_name} er fugtig", + "is_motion": "{entity_name} registrerer bev\u00e6gelse", + "is_moving": "{entity_name} bev\u00e6ger sig", + "is_no_gas": "{entity_name} registrerer ikke gas", + "is_no_light": "{entity_name} registrerer ikke lys", + "is_no_motion": "{entity_name} registrerer ikke bev\u00e6gelse", + "is_no_problem": "{entity_name} registrerer ikke noget problem", + "is_no_smoke": "{entity_name} registrerer ikke r\u00f8g", + "is_no_sound": "{entity_name} registrerer ikke lyd", + "is_no_vibration": "{entity_name} registrerer ikke vibration", + "is_not_cold": "{entity_name} er ikke kold", + "is_not_connected": "{entity_name} er afbrudt", + "is_not_hot": "{entity_name} er ikke varm", + "is_not_locked": "{entity_name} er l\u00e5st op", + "is_not_moist": "{entity_name} er t\u00f8r", + "is_not_moving": "{entity_name} bev\u00e6ger sig ikke", + "is_not_occupied": "{entity_name} er ikke optaget", + "is_not_open": "{entity_name} er lukket", + "is_not_present": "{entity_name} er ikke til stede", + "is_not_unsafe": "{entity_name} er sikker", + "is_occupied": "{entity_name} er optaget", + "is_open": "{entity_name} er \u00e5ben", + "is_problem": "{entity_name} registrerer problem", + "is_smoke": "{entity_name} registrerer r\u00f8g", + "is_sound": "{entity_name} registrerer lyd", + "is_unsafe": "{entity_name} er usikker", + "is_vibration": "{entity_name} registrerer vibration" + }, + "trigger_type": { + "closed": "{entity_name} lukket", + "cold": "{entity_name} blev kold", + "connected": "{entity_name} tilsluttet", + "moist": "{entity_name} blev fugtig", + "moist\u00a7": "{entity_name} blev fugtig", + "motion": "{entity_name} begyndte at registrere bev\u00e6gelse", + "moving": "{entity_name} begyndte at bev\u00e6ge sig", + "no_gas": "{entity_name} stoppede med at registrere gas", + "no_light": "{entity_name} stoppede med at registrere lys", + "no_motion": "{entity_name} stoppede med at registrere bev\u00e6gelse", + "no_problem": "{entity_name} stoppede med at registrere problem", + "no_smoke": "{entity_name} stoppede med at registrere r\u00f8g", + "no_sound": "{entity_name} stoppede med at registrere lyd", + "no_vibration": "{entity_name} stoppede med at registrere vibration", + "not_connected": "{entity_name} afbrudt", + "not_hot": "{entity_name} blev ikke varm", + "not_locked": "{entity_name} l\u00e5st op", + "not_moist": "{entity_name} blev t\u00f8r", + "not_opened": "{entity_name} lukket", + "not_present": "{entity_name} ikke til stede", + "not_unsafe": "{entity_name} blev sikker", + "occupied": "{entity_name} blev optaget", + "present": "{entity_name} til stede", + "problem": "{entity_name} begyndte at registrere problem", + "smoke": "{entity_name} begyndte at registrere r\u00f8g", + "sound": "{entity_name} begyndte at registrere lyd" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/.translations/de.json b/homeassistant/components/binary_sensor/.translations/de.json new file mode 100644 index 000000000..e24619886 --- /dev/null +++ b/homeassistant/components/binary_sensor/.translations/de.json @@ -0,0 +1,94 @@ +{ + "device_automation": { + "condition_type": { + "is_bat_low": "{entity_name} Batterie ist schwach", + "is_cold": "{entity_name} ist kalt", + "is_connected": "{entity_name} ist verbunden", + "is_gas": "{entity_name} erkennt Gas", + "is_hot": "{entity_name} ist hei\u00df", + "is_light": "{entity_name} erkennt Licht", + "is_locked": "{entity_name} ist gesperrt", + "is_moist": "{entity_name} ist feucht", + "is_motion": "{entity_name} erkennt Bewegung", + "is_moving": "{entity_name} bewegt sich", + "is_no_gas": "{entity_name} erkennt kein Gas", + "is_no_light": "{entity_name} erkennt kein Licht", + "is_no_motion": "{entity_name} erkennt keine Bewegung", + "is_no_problem": "{entity_name} erkennt kein Problem", + "is_no_smoke": "{entity_name} erkennt keinen Rauch", + "is_no_sound": "{entity_name} erkennt keine Ger\u00e4usche", + "is_no_vibration": "{entity_name} erkennt keine Vibrationen", + "is_not_bat_low": "{entity_name} Batterie ist normal", + "is_not_cold": "{entity_name} ist nicht kalt", + "is_not_connected": "{entity_name} ist nicht verbunden", + "is_not_hot": "{entity_name} ist nicht hei\u00df", + "is_not_locked": "{entity_name} ist entsperrt", + "is_not_moist": "{entity_name} ist trocken", + "is_not_moving": "{entity_name} bewegt sich nicht", + "is_not_occupied": "{entity_name} ist nicht besch\u00e4ftigt / besetzt", + "is_not_open": "{entity_name} ist geschlossen", + "is_not_plugged_in": "{entity_name} ist nicht angeschlossen", + "is_not_powered": "{entity_name} wird nicht mit Strom versorgt", + "is_not_present": "{entity_name} ist nicht vorhanden", + "is_not_unsafe": "{entity_name} ist sicher", + "is_occupied": "{entity_name} ist besch\u00e4ftigt / besetzt", + "is_off": "{entity_name} ist ausgeschaltet", + "is_on": "{entity_name} ist eingeschaltet", + "is_open": "{entity_name} ist offen", + "is_plugged_in": "{entity_name} ist eingesteckt", + "is_powered": "{entity_name} wird mit Strom versorgt", + "is_present": "{entity_name} ist vorhanden", + "is_problem": "{entity_name} hat ein Problem festgestellt", + "is_smoke": "{entity_name} hat Rauch detektiert", + "is_sound": "{entity_name} hat Ger\u00e4usche detektiert", + "is_unsafe": "{entity_name} ist unsicher", + "is_vibration": "{entity_name} erkennt Vibrationen." + }, + "trigger_type": { + "bat_low": "{entity_name} Batterie schwach", + "closed": "{entity_name} geschlossen", + "cold": "{entity_name} wurde kalt", + "connected": "{entity_name} verbunden", + "gas": "{entity_name} hat Gas detektiert", + "hot": "{entity_name} wurde hei\u00df", + "light": "{entity_name} hat Licht detektiert", + "locked": "{entity_name} gesperrt", + "moist": "{entity_name} wurde feucht", + "moist\u00a7": "{entity_name} wurde feucht", + "motion": "{entity_name} hat Bewegungen detektiert", + "moving": "{entity_name} hat angefangen sich zu bewegen", + "no_gas": "{entity_name} hat kein Gas mehr erkannt", + "no_light": "{entity_name} hat kein Licht mehr erkannt", + "no_motion": "{entity_name} hat keine Bewegung mehr erkannt", + "no_problem": "{entity_name} hat kein Problem mehr erkannt", + "no_smoke": "{entity_name} hat keinen Rauch mehr erkannt", + "no_sound": "{entity_name} hat keine Ger\u00e4usche mehr erkannt", + "no_vibration": "{entity_name}hat keine Vibrationen mehr erkannt", + "not_bat_low": "{entity_name} Batterie normal", + "not_cold": "{entity_name} w\u00e4rmte auf", + "not_connected": "{entity_name} getrennt", + "not_hot": "{entity_name} k\u00fchlte ab", + "not_locked": "{entity_name} entsperrt", + "not_moist": "{entity_name} wurde trocken", + "not_moving": "{entity_name} bewegt sich nicht mehr", + "not_occupied": "{entity_name} wurde frei / inaktiv", + "not_opened": "{entity_name} geschlossen", + "not_plugged_in": "{entity_name} ist nicht angeschlossen", + "not_powered": "{entity_name} nicht mit Strom versorgt", + "not_present": "{entity_name} nicht anwesend", + "not_unsafe": "{entity_name} wurde sicher", + "occupied": "{entity_name} wurde besch\u00e4ftigt / besetzt", + "opened": "{entity_name} ge\u00f6ffnet", + "plugged_in": "{entity_name} eingesteckt", + "powered": "{entity_name} wird mit Strom versorgt", + "present": "{entity_name} anwesend", + "problem": "{entity_name} hat ein Problem festgestellt", + "smoke": "{entity_name} detektiert Rauch", + "sound": "{entity_name} detektiert Ger\u00e4usche", + "turned_off": "{entity_name} ausgeschaltet", + "turned_on": "{entity_name} eingeschaltet", + "unsafe": "{entity_name} ist unsicher", + "vibration": "{entity_name} detektiert Vibrationen" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/.translations/en.json b/homeassistant/components/binary_sensor/.translations/en.json new file mode 100644 index 000000000..93b618939 --- /dev/null +++ b/homeassistant/components/binary_sensor/.translations/en.json @@ -0,0 +1,94 @@ +{ + "device_automation": { + "condition_type": { + "is_bat_low": "{entity_name} battery is low", + "is_cold": "{entity_name} is cold", + "is_connected": "{entity_name} is connected", + "is_gas": "{entity_name} is detecting gas", + "is_hot": "{entity_name} is hot", + "is_light": "{entity_name} is detecting light", + "is_locked": "{entity_name} is locked", + "is_moist": "{entity_name} is moist", + "is_motion": "{entity_name} is detecting motion", + "is_moving": "{entity_name} is moving", + "is_no_gas": "{entity_name} is not detecting gas", + "is_no_light": "{entity_name} is not detecting light", + "is_no_motion": "{entity_name} is not detecting motion", + "is_no_problem": "{entity_name} is not detecting problem", + "is_no_smoke": "{entity_name} is not detecting smoke", + "is_no_sound": "{entity_name} is not detecting sound", + "is_no_vibration": "{entity_name} is not detecting vibration", + "is_not_bat_low": "{entity_name} battery is normal", + "is_not_cold": "{entity_name} is not cold", + "is_not_connected": "{entity_name} is disconnected", + "is_not_hot": "{entity_name} is not hot", + "is_not_locked": "{entity_name} is unlocked", + "is_not_moist": "{entity_name} is dry", + "is_not_moving": "{entity_name} is not moving", + "is_not_occupied": "{entity_name} is not occupied", + "is_not_open": "{entity_name} is closed", + "is_not_plugged_in": "{entity_name} is unplugged", + "is_not_powered": "{entity_name} is not powered", + "is_not_present": "{entity_name} is not present", + "is_not_unsafe": "{entity_name} is safe", + "is_occupied": "{entity_name} is occupied", + "is_off": "{entity_name} is off", + "is_on": "{entity_name} is on", + "is_open": "{entity_name} is open", + "is_plugged_in": "{entity_name} is plugged in", + "is_powered": "{entity_name} is powered", + "is_present": "{entity_name} is present", + "is_problem": "{entity_name} is detecting problem", + "is_smoke": "{entity_name} is detecting smoke", + "is_sound": "{entity_name} is detecting sound", + "is_unsafe": "{entity_name} is unsafe", + "is_vibration": "{entity_name} is detecting vibration" + }, + "trigger_type": { + "bat_low": "{entity_name} battery low", + "closed": "{entity_name} closed", + "cold": "{entity_name} became cold", + "connected": "{entity_name} connected", + "gas": "{entity_name} started detecting gas", + "hot": "{entity_name} became hot", + "light": "{entity_name} started detecting light", + "locked": "{entity_name} locked", + "moist": "{entity_name} became moist", + "moist\u00a7": "{entity_name} became moist", + "motion": "{entity_name} started detecting motion", + "moving": "{entity_name} started moving", + "no_gas": "{entity_name} stopped detecting gas", + "no_light": "{entity_name} stopped detecting light", + "no_motion": "{entity_name} stopped detecting motion", + "no_problem": "{entity_name} stopped detecting problem", + "no_smoke": "{entity_name} stopped detecting smoke", + "no_sound": "{entity_name} stopped detecting sound", + "no_vibration": "{entity_name} stopped detecting vibration", + "not_bat_low": "{entity_name} battery normal", + "not_cold": "{entity_name} became not cold", + "not_connected": "{entity_name} disconnected", + "not_hot": "{entity_name} became not hot", + "not_locked": "{entity_name} unlocked", + "not_moist": "{entity_name} became dry", + "not_moving": "{entity_name} stopped moving", + "not_occupied": "{entity_name} became not occupied", + "not_opened": "{entity_name} closed", + "not_plugged_in": "{entity_name} unplugged", + "not_powered": "{entity_name} not powered", + "not_present": "{entity_name} not present", + "not_unsafe": "{entity_name} became safe", + "occupied": "{entity_name} became occupied", + "opened": "{entity_name} opened", + "plugged_in": "{entity_name} plugged in", + "powered": "{entity_name} powered", + "present": "{entity_name} present", + "problem": "{entity_name} started detecting problem", + "smoke": "{entity_name} started detecting smoke", + "sound": "{entity_name} started detecting sound", + "turned_off": "{entity_name} turned off", + "turned_on": "{entity_name} turned on", + "unsafe": "{entity_name} became unsafe", + "vibration": "{entity_name} started detecting vibration" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/.translations/es-419.json b/homeassistant/components/binary_sensor/.translations/es-419.json new file mode 100644 index 000000000..f1c20e534 --- /dev/null +++ b/homeassistant/components/binary_sensor/.translations/es-419.json @@ -0,0 +1,65 @@ +{ + "device_automation": { + "condition_type": { + "is_bat_low": "{entity_name} la bater\u00eda est\u00e1 baja", + "is_cold": "{entity_name} est\u00e1 fr\u00edo", + "is_connected": "{entity_name} est\u00e1 conectado", + "is_gas": "{entity_name} est\u00e1 detectando gas", + "is_hot": "{entity_name} est\u00e1 caliente", + "is_light": "{entity_name} est\u00e1 detectando luz", + "is_locked": "{entity_name} est\u00e1 bloqueado", + "is_moist": "{entity_name} est\u00e1 h\u00famedo", + "is_motion": "{entity_name} est\u00e1 detectando movimiento", + "is_moving": "{entity_name} se est\u00e1 moviendo", + "is_no_gas": "{entity_name} no detecta gas", + "is_no_light": "{entity_name} no detecta luz", + "is_no_motion": "{entity_name} no detecta movimiento", + "is_no_problem": "{entity_name} no detecta el problema", + "is_no_smoke": "{entity_name} no detecta humo", + "is_no_sound": "{entity_name} no detecta sonido", + "is_no_vibration": "{entity_name} no detecta vibraciones", + "is_not_bat_low": "{entity_name} bater\u00eda est\u00e1 normal", + "is_not_cold": "{entity_name} no est\u00e1 fr\u00edo", + "is_not_connected": "{entity_name} est\u00e1 desconectado", + "is_not_hot": "{entity_name} no est\u00e1 caliente", + "is_not_locked": "{entity_name} est\u00e1 desbloqueado", + "is_not_moist": "{entity_name} est\u00e1 seco", + "is_not_moving": "{entity_name} no se mueve", + "is_not_occupied": "{entity_name} no est\u00e1 ocupado", + "is_not_open": "{entity_name} est\u00e1 cerrado", + "is_not_plugged_in": "{entity_name} est\u00e1 desconectado", + "is_powered": "{entity_name} est\u00e1 encendido", + "is_present": "{entity_name} est\u00e1 presente", + "is_problem": "{entity_name} est\u00e1 detectando un problema", + "is_smoke": "{entity_name} est\u00e1 detectando humo", + "is_sound": "{entity_name} est\u00e1 detectando sonido", + "is_unsafe": "{entity_name} es inseguro", + "is_vibration": "{entity_name} est\u00e1 detectando vibraciones" + }, + "trigger_type": { + "bat_low": "{entity_name} bater\u00eda baja", + "closed": "{entity_name} cerrado", + "cold": "{entity_name} se enfri\u00f3", + "connected": "{entity_name} conectado", + "gas": "{entity_name} comenz\u00f3 a detectar gas", + "hot": "{entity_name} se calent\u00f3", + "light": "{entity_name} comenz\u00f3 a detectar luz", + "locked": "{entity_name} bloqueado", + "moist\u00a7": "{entity_name} se humedeci\u00f3", + "motion": "{entity_name} comenz\u00f3 a detectar movimiento", + "moving": "{entity_name} comenz\u00f3 a moverse", + "no_gas": "{entity_name} dej\u00f3 de detectar gas", + "no_light": "{entity_name} dej\u00f3 de detectar luz", + "no_motion": "{entity_name} dej\u00f3 de detectar movimiento", + "no_problem": "{entity_name} dej\u00f3 de detectar problemas", + "no_smoke": "{entity_name} dej\u00f3 de detectar humo", + "no_sound": "{entity_name} dej\u00f3 de detectar sonido", + "no_vibration": "{entity_name} dej\u00f3 de detectar vibraciones", + "not_bat_low": "{entity_name} bater\u00eda normal", + "not_cold": "{entity_name} no se enfri\u00f3", + "not_connected": "{entity_name} desconectado", + "not_hot": "{entity_name} no se calent\u00f3", + "not_locked": "{entity_name} desbloqueado" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/.translations/es.json b/homeassistant/components/binary_sensor/.translations/es.json new file mode 100644 index 000000000..756a370ca --- /dev/null +++ b/homeassistant/components/binary_sensor/.translations/es.json @@ -0,0 +1,94 @@ +{ + "device_automation": { + "condition_type": { + "is_bat_low": "{entity_name} la bater\u00eda est\u00e1 baja", + "is_cold": "{entity_name} est\u00e1 fr\u00edo", + "is_connected": "{entity_name} est\u00e1 conectado", + "is_gas": "{entity_name} est\u00e1 detectando gas", + "is_hot": "{entity_name} est\u00e1 caliente", + "is_light": "{entity_name} est\u00e1 detectando luz", + "is_locked": "{entity_name} est\u00e1 bloqueado", + "is_moist": "{entity_name} est\u00e1 h\u00famedo", + "is_motion": "{entity_name} est\u00e1 detectando movimiento", + "is_moving": "{entity_name} se est\u00e1 moviendo", + "is_no_gas": "{entity_name} no detecta gas", + "is_no_light": "{entity_name} no detecta la luz", + "is_no_motion": "{entity_name} no detecta movimiento", + "is_no_problem": "{entity_name} no detecta el problema", + "is_no_smoke": "{entity_name} no detecta humo", + "is_no_sound": "{entity_name} no detecta sonido", + "is_no_vibration": "{entity_name} no detecta vibraci\u00f3n", + "is_not_bat_low": "La bater\u00eda de {entity_name} es normal", + "is_not_cold": "{entity_name} no est\u00e1 fr\u00edo", + "is_not_connected": "{entity_name} est\u00e1 desconectado", + "is_not_hot": "{entity_name} no est\u00e1 caliente", + "is_not_locked": "{entity_name} est\u00e1 desbloqueado", + "is_not_moist": "{entity_name} est\u00e1 seco", + "is_not_moving": "{entity_name} no se mueve", + "is_not_occupied": "{entity_name} no est\u00e1 ocupado", + "is_not_open": "{entity_name} est\u00e1 cerrado", + "is_not_plugged_in": "{entity_name} est\u00e1 desconectado", + "is_not_powered": "{entity_name} no tiene alimentaci\u00f3n", + "is_not_present": "{entity_name} no est\u00e1 presente", + "is_not_unsafe": "{entity_name} es seguro", + "is_occupied": "{entity_name} est\u00e1 ocupado", + "is_off": "{entity_name} est\u00e1 apagado", + "is_on": "{entity_name} est\u00e1 activado", + "is_open": "{entity_name} est\u00e1 abierto", + "is_plugged_in": "{entity_name} est\u00e1 conectado", + "is_powered": "{entity_name} est\u00e1 activado", + "is_present": "{entity_name} est\u00e1 presente", + "is_problem": "{entity_name} est\u00e1 detectando un problema", + "is_smoke": "{entity_name} est\u00e1 detectando humo", + "is_sound": "{entity_name} est\u00e1 detectando sonido", + "is_unsafe": "{entity_name} no es seguro", + "is_vibration": "{entity_name} est\u00e1 detectando vibraciones" + }, + "trigger_type": { + "bat_low": "{entity_name} bater\u00eda baja", + "closed": "{entity_name} cerrado", + "cold": "{entity_name} se enfri\u00f3", + "connected": "{entity_name} conectado", + "gas": "{entity_name} empez\u00f3 a detectar gas", + "hot": "{entity_name} se est\u00e1 calentando", + "light": "{entity_name} empez\u00f3 a detectar la luz", + "locked": "{entity_name} bloqueado", + "moist": "{entity_name} se humedece", + "moist\u00a7": "{entity_name} se humedeci\u00f3", + "motion": "{entity_name} comenz\u00f3 a detectar movimiento", + "moving": "{entity_name} empez\u00f3 a moverse", + "no_gas": "{entity_name} dej\u00f3 de detectar gas", + "no_light": "{entity_name} dej\u00f3 de detectar la luz", + "no_motion": "{entity_name} dej\u00f3 de detectar movimiento", + "no_problem": "{entity_name} dej\u00f3 de detectar el problema", + "no_smoke": "{entity_name} dej\u00f3 de detectar humo", + "no_sound": "{entity_name} dej\u00f3 de detectar sonido", + "no_vibration": "{entity_name} dej\u00f3 de detectar vibraci\u00f3n", + "not_bat_low": "{entity_name} bater\u00eda normal", + "not_cold": "{entity_name} no se enfri\u00f3", + "not_connected": "{entity_name} desconectado", + "not_hot": "{entity_name} no se calent\u00f3", + "not_locked": "{entity_name} desbloqueado", + "not_moist": "{entity_name} se sec\u00f3", + "not_moving": "{entity_name} dej\u00f3 de moverse", + "not_occupied": "{entity_name} no est\u00e1 ocupado", + "not_opened": "{nombre_de_la_entidad} cerrado", + "not_plugged_in": "{entity_name} desconectado", + "not_powered": "{entity_name} no est\u00e1 activado", + "not_present": "{entity_name} no est\u00e1 presente", + "not_unsafe": "{entity_name} se volvi\u00f3 seguro", + "occupied": "{entity_name} se convirti\u00f3 en ocupado", + "opened": "{entity_name} abierto", + "plugged_in": "{nombre_de_la_entidad} conectado", + "powered": "{entity_name} alimentado", + "present": "{entity_name} presente", + "problem": "{entity_name} empez\u00f3 a detectar problemas", + "smoke": "{entity_name} empez\u00f3 a detectar humo", + "sound": "{entity_name} empez\u00f3 a detectar sonido", + "turned_off": "{entity_name} desactivado", + "turned_on": "{entity_name} activado", + "unsafe": "{entity_name} se volvi\u00f3 inseguro", + "vibration": "{entity_name} empez\u00f3 a detectar vibraciones" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/.translations/fr.json b/homeassistant/components/binary_sensor/.translations/fr.json new file mode 100644 index 000000000..65abfbcd0 --- /dev/null +++ b/homeassistant/components/binary_sensor/.translations/fr.json @@ -0,0 +1,94 @@ +{ + "device_automation": { + "condition_type": { + "is_bat_low": "{entity_name} batterie faible", + "is_cold": "{entity_name} est froid", + "is_connected": "{entity_name} est connect\u00e9", + "is_gas": "{entity_name} d\u00e9tecte du gaz", + "is_hot": "{entity_name} est chaud", + "is_light": "{entity_name} d\u00e9tecte de la lumi\u00e8re", + "is_locked": "{entity_name} est verrouill\u00e9", + "is_moist": "{entity_name} est humide", + "is_motion": "{entity_name} d\u00e9tecte du mouvement", + "is_moving": "{entity_name} se d\u00e9place", + "is_no_gas": "{entity_name} ne d\u00e9tecte pas de gaz", + "is_no_light": "{entity_name} ne d\u00e9tecte pas de lumi\u00e8re", + "is_no_motion": "{entity_name} ne d\u00e9tecte pas de mouvement", + "is_no_problem": "{entity_name} ne d\u00e9tecte pas de probl\u00e8me", + "is_no_smoke": "{entity_name} ne d\u00e9tecte pas de fum\u00e9e", + "is_no_sound": "{entity_name} ne d\u00e9tecte pas de son", + "is_no_vibration": "{entity_name} ne d\u00e9tecte pas de vibration", + "is_not_bat_low": "{entity_name} batterie normale", + "is_not_cold": "{entity_name} n'est pas froid", + "is_not_connected": "{entity_name} est d\u00e9connect\u00e9", + "is_not_hot": "{entity_name} n'est pas chaud", + "is_not_locked": "{entity_name} est d\u00e9verrouill\u00e9", + "is_not_moist": "{entity_name} est sec", + "is_not_moving": "{entity_name} ne bouge pas", + "is_not_occupied": "{entity_name} n'est pas occup\u00e9", + "is_not_open": "{entity_name} est ferm\u00e9", + "is_not_plugged_in": "{entity_name} est d\u00e9branch\u00e9", + "is_not_powered": "{entity_name} n'est pas aliment\u00e9", + "is_not_present": "{entity_name} n'est pas pr\u00e9sent", + "is_not_unsafe": "{entity_name} est en s\u00e9curit\u00e9", + "is_occupied": "{entity_name} est occup\u00e9", + "is_off": "{entity_name} est d\u00e9sactiv\u00e9", + "is_on": "{entity_name} est activ\u00e9", + "is_open": "{entity_name} est ouvert", + "is_plugged_in": "{entity_name} est branch\u00e9", + "is_powered": "{entity_name} est aliment\u00e9", + "is_present": "{entity_name} est pr\u00e9sent", + "is_problem": "{entity_name} d\u00e9tecte un probl\u00e8me", + "is_smoke": "{entity_name} d\u00e9tecte de la fum\u00e9e", + "is_sound": "{entity_name} d\u00e9tecte du son", + "is_unsafe": "{entity_name} est dangereux", + "is_vibration": "{entity_name} d\u00e9tecte des vibrations" + }, + "trigger_type": { + "bat_low": "{entity_name} batterie faible", + "closed": "{entity_name} ferm\u00e9", + "cold": "{entity_name} est devenu froid", + "connected": "{entity_name} connect\u00e9", + "gas": "{entity_name} a commenc\u00e9 \u00e0 d\u00e9tecter du gaz", + "hot": "{entity_name} est devenu chaud", + "light": "{entity_name} a commenc\u00e9 \u00e0 d\u00e9tecter la lumi\u00e8re", + "locked": "{entity_name} verrouill\u00e9", + "moist": "{entity_name} est devenu humide", + "moist\u00a7": "{entity_name} est devenu humide", + "motion": "{entity_name} a commenc\u00e9 \u00e0 d\u00e9tecter du mouvement", + "moving": "{entity_name} a commenc\u00e9 \u00e0 se d\u00e9placer", + "no_gas": "{entity_name} a arr\u00eat\u00e9 de d\u00e9tecter le gaz", + "no_light": "{entity_name} a arr\u00eat\u00e9 de d\u00e9tecter la lumi\u00e8re", + "no_motion": "{entity_name} a arr\u00eat\u00e9 de d\u00e9tecter le mouvement", + "no_problem": "{entity_name} a cess\u00e9 de d\u00e9tecter un probl\u00e8me", + "no_smoke": "{entity_name} a cess\u00e9 de d\u00e9tecter de la fum\u00e9e", + "no_sound": "{entity_name} a cess\u00e9 de d\u00e9tecter du bruit", + "no_vibration": "{entity_name} a cess\u00e9 de d\u00e9tecter des vibrations", + "not_bat_low": "{entity_name} batterie normale", + "not_cold": "{entity_name} n'est plus froid", + "not_connected": "{entity_name} d\u00e9connect\u00e9", + "not_hot": "{entity_name} n'est plus chaud", + "not_locked": "{entity_name} d\u00e9verrouill\u00e9", + "not_moist": "{entity_name} est devenu sec", + "not_moving": "{entity_name} a cess\u00e9 de bouger", + "not_occupied": "{entity_name} est devenu non occup\u00e9", + "not_opened": "{entity_name} ferm\u00e9", + "not_plugged_in": "{entity_name} d\u00e9branch\u00e9", + "not_powered": "{entity_name} non aliment\u00e9", + "not_present": "{entity_name} non pr\u00e9sent", + "not_unsafe": "{entity_name} est devenu s\u00fbr", + "occupied": "{entity_name} est devenu occup\u00e9", + "opened": "{entity_name} ouvert", + "plugged_in": "{entity_name} branch\u00e9", + "powered": "{entity_name} aliment\u00e9", + "present": "{entity_name} pr\u00e9sent", + "problem": "{entity_name} a commenc\u00e9 \u00e0 d\u00e9tecter un probl\u00e8me", + "smoke": "{entity_name} commenc\u00e9 \u00e0 d\u00e9tecter la fum\u00e9e", + "sound": "{entity_name} commenc\u00e9 \u00e0 d\u00e9tecter le son", + "turned_off": "{entity_name} est d\u00e9sactiv\u00e9", + "turned_on": "{entity_name} est activ\u00e9", + "unsafe": "{entity_name} est devenu dangereux", + "vibration": "{entity_name} a commenc\u00e9 \u00e0 d\u00e9tecter les vibrations" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/.translations/hu.json b/homeassistant/components/binary_sensor/.translations/hu.json new file mode 100644 index 000000000..7ec9b5268 --- /dev/null +++ b/homeassistant/components/binary_sensor/.translations/hu.json @@ -0,0 +1,94 @@ +{ + "device_automation": { + "condition_type": { + "is_bat_low": "{entity_name} akkufesz\u00fclts\u00e9g alacsony", + "is_cold": "{entity_name} hideg", + "is_connected": "{entity_name} csatlakoztatva van", + "is_gas": "{entity_name} g\u00e1zt \u00e9rz\u00e9kel", + "is_hot": "{entity_name} forr\u00f3", + "is_light": "{entity_name} f\u00e9nyt \u00e9rz\u00e9kel", + "is_locked": "{entity_name} z\u00e1rva van", + "is_moist": "{entity_name} nedves", + "is_motion": "{entity_name} mozg\u00e1st \u00e9rz\u00e9kel", + "is_moving": "{entity_name} mozog", + "is_no_gas": "{entity_name} nem \u00e9rz\u00e9kel g\u00e1zt", + "is_no_light": "{entity_name} nem \u00e9rz\u00e9kel f\u00e9nyt", + "is_no_motion": "{entity_name} nem \u00e9rz\u00e9kel mozg\u00e1st", + "is_no_problem": "{entity_name} nem \u00e9szlel probl\u00e9m\u00e1t", + "is_no_smoke": "{entity_name} nem \u00e9rz\u00e9kel f\u00fcst\u00f6t", + "is_no_sound": "{entity_name} nem \u00e9rz\u00e9kel hangot", + "is_no_vibration": "{entity_name} nem \u00e9rz\u00e9kel rezg\u00e9st", + "is_not_bat_low": "{entity_name} akkufesz\u00fclts\u00e9g megfelel\u0151", + "is_not_cold": "{entity_name} nem hideg", + "is_not_connected": "{entity_name} le van csatlakoztatva", + "is_not_hot": "{entity_name} nem forr\u00f3", + "is_not_locked": "{entity_name} nyitva van", + "is_not_moist": "{entity_name} sz\u00e1raz", + "is_not_moving": "{entity_name} nem mozog", + "is_not_occupied": "{entity_name} nem foglalt", + "is_not_open": "{entity_name} z\u00e1rva van", + "is_not_plugged_in": "{entity_name} nincs csatlakoztatva", + "is_not_powered": "{entity_name} nincs fesz\u00fcts\u00e9g alatt", + "is_not_present": "{entity_name} nincs jelen", + "is_not_unsafe": "{entity_name} biztons\u00e1gos", + "is_occupied": "{entity_name} foglalt", + "is_off": "{entity_name} ki van kapcsolva", + "is_on": "{entity_name} be van kapcsolva", + "is_open": "{entity_name} nyitva van", + "is_plugged_in": "{entity_name} csatlakoztatva van", + "is_powered": "{entity_name} fesz\u00fclts\u00e9g alatt van", + "is_present": "{entity_name} jelen van", + "is_problem": "{entity_name} probl\u00e9m\u00e1t \u00e9szlel", + "is_smoke": "{entity_name} f\u00fcst\u00f6t \u00e9rz\u00e9kel", + "is_sound": "{entity_name} hangot \u00e9rz\u00e9kel", + "is_unsafe": "{entity_name} nem biztons\u00e1gos", + "is_vibration": "{entity_name} rezg\u00e9st \u00e9rz\u00e9kel" + }, + "trigger_type": { + "bat_low": "{entity_name} akkufesz\u00fclts\u00e9g alacsony", + "closed": "{entity_name} be lett csukva", + "cold": "{entity_name} hideg lett", + "connected": "{entity_name} csatlakozik", + "gas": "{entity_name} g\u00e1zt \u00e9rz\u00e9kel", + "hot": "{entity_name} felforr\u00f3sodik", + "light": "{entity_name} f\u00e9nyt \u00e9rz\u00e9kel", + "locked": "{entity_name} be lett z\u00e1rva", + "moist": "{entity_name} nedves lett", + "moist\u00a7": "{entity_name} nedves lett", + "motion": "{entity_name} mozg\u00e1st \u00e9rz\u00e9kel", + "moving": "{entity_name} mozog", + "no_gas": "{entity_name} m\u00e1r nem \u00e9rz\u00e9kel g\u00e1zt", + "no_light": "{entity_name} m\u00e1r nem \u00e9rz\u00e9kel f\u00e9nyt", + "no_motion": "{entity_name} m\u00e1r nem \u00e9rz\u00e9kel mozg\u00e1st", + "no_problem": "{entity_name} m\u00e1r nem \u00e9szlel probl\u00e9m\u00e1t", + "no_smoke": "{entity_name} m\u00e1r nem \u00e9rz\u00e9kel f\u00fcst\u00f6t", + "no_sound": "{entity_name} m\u00e1r nem \u00e9rz\u00e9kel hangot", + "no_vibration": "{entity_name} m\u00e1r nem \u00e9rz\u00e9kel rezg\u00e9st", + "not_bat_low": "{entity_name} akkufesz\u00fclts\u00e9g megfelel\u0151", + "not_cold": "{entity_name} m\u00e1r nem hideg", + "not_connected": "{entity_name} lecsatlakozik", + "not_hot": "{entity_name} m\u00e1r nem forr\u00f3", + "not_locked": "{entity_name} ki lett nyitva", + "not_moist": "{entity_name} sz\u00e1raz lett", + "not_moving": "{entity_name} m\u00e1r nem mozog", + "not_occupied": "{entity_name} m\u00e1r nem foglalt", + "not_opened": "{entity_name} be lett csukva", + "not_plugged_in": "{entity_name} m\u00e1r nincs csatlakoztatva", + "not_powered": "{entity_name} m\u00e1r nincs fesz\u00fcts\u00e9g alatt", + "not_present": "{entity_name} m\u00e1r nincs jelen", + "not_unsafe": "{entity_name} biztons\u00e1gos lett", + "occupied": "{entity_name} foglalt lett", + "opened": "{entity_name} ki lett nyitva", + "plugged_in": "{entity_name} csatlakoztatva lett", + "powered": "{entity_name} m\u00e1r fesz\u00fclts\u00e9g alatt van", + "present": "{entity_name} m\u00e1r jelen van", + "problem": "{entity_name} probl\u00e9m\u00e1t \u00e9szlel", + "smoke": "{entity_name} f\u00fcst\u00f6t \u00e9rz\u00e9kel", + "sound": "{entity_name} hangot \u00e9rz\u00e9kel", + "turned_off": "{entity_name} ki lett kapcsolva", + "turned_on": "{entity_name} be lett kapcsolva", + "unsafe": "{entity_name} m\u00e1r nem biztons\u00e1gos", + "vibration": "{entity_name} rezg\u00e9st \u00e9rz\u00e9kel" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/.translations/it.json b/homeassistant/components/binary_sensor/.translations/it.json new file mode 100644 index 000000000..c69f5a07a --- /dev/null +++ b/homeassistant/components/binary_sensor/.translations/it.json @@ -0,0 +1,94 @@ +{ + "device_automation": { + "condition_type": { + "is_bat_low": "{entity_name} la batteria \u00e8 scarica", + "is_cold": "{entity_name} \u00e8 freddo", + "is_connected": "{entity_name} \u00e8 collegato", + "is_gas": "{entity_name} sta rilevando il gas", + "is_hot": "{entity_name} \u00e8 caldo", + "is_light": "{entity_name} sta rilevando la luce", + "is_locked": "{entity_name} \u00e8 bloccato", + "is_moist": "{entity_name} \u00e8 umido", + "is_motion": "{entity_name} sta rilevando il movimento", + "is_moving": "{entity_name} si sta muovendo", + "is_no_gas": "{entity_name} non sta rilevando il gas", + "is_no_light": "{entity_name} non sta rilevando la luce", + "is_no_motion": "{entity_name} non sta rilevando il movimento", + "is_no_problem": "{entity_name} non sta rilevando un problema", + "is_no_smoke": "{entity_name} non sta rilevando il fumo", + "is_no_sound": "{entity_name} non sta rilevando il suono", + "is_no_vibration": "{entity_name} non sta rilevando la vibrazione", + "is_not_bat_low": "{entity_name} la batteria \u00e8 normale", + "is_not_cold": "{entity_name} non \u00e8 freddo", + "is_not_connected": "{entity_name} \u00e8 disconnesso", + "is_not_hot": "{entity_name} non \u00e8 caldo", + "is_not_locked": "{entity_name} \u00e8 sbloccato", + "is_not_moist": "{entity_name} \u00e8 asciutto", + "is_not_moving": "{entity_name} non si sta muovendo", + "is_not_occupied": "{entity_name} non \u00e8 occupato", + "is_not_open": "{entity_name} \u00e8 chiuso", + "is_not_plugged_in": "{entity_name} \u00e8 collegato", + "is_not_powered": "{entity_name} non \u00e8 alimentato", + "is_not_present": "{entity_name} non \u00e8 presente", + "is_not_unsafe": "{entity_name} \u00e8 sicuro", + "is_occupied": "{entity_name} \u00e8 occupato", + "is_off": "{entity_name} \u00e8 spento", + "is_on": "{entity_name} \u00e8 acceso", + "is_open": "{entity_name} \u00e8 aperto", + "is_plugged_in": "{entity_name} \u00e8 collegato", + "is_powered": "{entity_name} \u00e8 alimentato", + "is_present": "{entity_name} \u00e8 presente", + "is_problem": "{entity_name} sta rilevando un problema", + "is_smoke": "{entity_name} sta rilevando il fumo", + "is_sound": "{entity_name} sta rilevando il suono", + "is_unsafe": "{entity_name} non \u00e8 sicuro", + "is_vibration": "{entity_name} sta rilevando la vibrazione" + }, + "trigger_type": { + "bat_low": "{entity_name} batteria scarica", + "closed": "{entity_name} \u00e8 chiuso", + "cold": "{entity_name} \u00e8 diventato freddo", + "connected": "{entity_name} connesso", + "gas": "{entity_name} ha iniziato a rilevare il gas", + "hot": "{entity_name} \u00e8 diventato caldo", + "light": "{entity_name} ha iniziato a rilevare la luce", + "locked": "{entity_name} bloccato", + "moist": "{entity_name} diventato umido", + "moist\u00a7": "{entity_name} \u00e8 diventato umido", + "motion": "{entity_name} ha iniziato a rilevare il movimento", + "moving": "{entity_name} ha iniziato a muoversi", + "no_gas": "{entity_name} ha smesso la rilevazione di gas", + "no_light": "{entity_name} smesso il rilevamento di luce", + "no_motion": "{nome_entit\u00e0} ha smesso di rilevare il movimento", + "no_problem": "{nome_entit\u00e0} ha smesso di rilevare un problema", + "no_smoke": "{entity_name} ha smesso la rilevazione di fumo", + "no_sound": "{nome_entit\u00e0} ha smesso di rilevare il suono", + "no_vibration": "{nome_entit\u00e0} ha smesso di rilevare le vibrazioni", + "not_bat_low": "{entity_name} batteria normale", + "not_cold": "{entity_name} non \u00e8 diventato freddo", + "not_connected": "{entity_name} \u00e8 disconnesso", + "not_hot": "{entity_name} non \u00e8 diventato caldo", + "not_locked": "{entity_name} \u00e8 sbloccato", + "not_moist": "{entity_name} \u00e8 diventato asciutto", + "not_moving": "{entity_name} ha smesso di muoversi", + "not_occupied": "{entity_name} non \u00e8 occupato", + "not_opened": "{entity_name} chiuso", + "not_plugged_in": "{entity_name} \u00e8 scollegato", + "not_powered": "{entity_name} non \u00e8 alimentato", + "not_present": "{entity_name} non \u00e8 presente", + "not_unsafe": "{entity_name} \u00e8 diventato sicuro", + "occupied": "{entity_name} \u00e8 diventato occupato", + "opened": "{entity_name} \u00e8 aperto", + "plugged_in": "{entity_name} \u00e8 collegato", + "powered": "{entity_name} \u00e8 alimentato", + "present": "{entity_name} \u00e8 presente", + "problem": "{entity_name} ha iniziato a rilevare un problema", + "smoke": "{entity_name} ha iniziato la rilevazione di fumo", + "sound": "{entity_name} ha iniziato il rilevamento del suono", + "turned_off": "{entity_name} disattivato", + "turned_on": "{entity_name} attivato", + "unsafe": "{entity_name} diventato non sicuro", + "vibration": "{entity_name} iniziato a rilevare le vibrazioni" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/.translations/ko.json b/homeassistant/components/binary_sensor/.translations/ko.json new file mode 100644 index 000000000..167708c2c --- /dev/null +++ b/homeassistant/components/binary_sensor/.translations/ko.json @@ -0,0 +1,94 @@ +{ + "device_automation": { + "condition_type": { + "is_bat_low": "{entity_name} \uc758 \ubc30\ud130\ub9ac \uc794\ub7c9\uc774 \ubd80\uc871\ud569\ub2c8\ub2e4", + "is_cold": "{entity_name} \uc774(\uac00) \ucc28\uac11\uc2b5\ub2c8\ub2e4", + "is_connected": "{entity_name} \uc774(\uac00) \uc5f0\uacb0\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "is_gas": "{entity_name} \uc774(\uac00) \uac00\uc2a4\ub97c \uac10\uc9c0\ud588\uc2b5\ub2c8\ub2e4", + "is_hot": "{entity_name} \uc774(\uac00) \ub728\uac81\uc2b5\ub2c8\ub2e4", + "is_light": "{entity_name} \uc774(\uac00) \ube5b\uc744 \uac10\uc9c0\ud588\uc2b5\ub2c8\ub2e4", + "is_locked": "{entity_name} \uc774(\uac00) \uc7a0\uacbc\uc2b5\ub2c8\ub2e4", + "is_moist": "{entity_name} \uc774(\uac00) \uc2b5\ud569\ub2c8\ub2e4", + "is_motion": "{entity_name} \uc774(\uac00) \uc6c0\uc9c1\uc784\uc744 \uac10\uc9c0\ud588\uc2b5\ub2c8\ub2e4", + "is_moving": "{entity_name} \uc774(\uac00) \uc6c0\uc9c1\uc600\uc2b5\ub2c8\ub2e4", + "is_no_gas": "{entity_name} \uc774(\uac00) \uac00\uc2a4\ub97c \uac10\uc9c0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "is_no_light": "{entity_name} \uc774(\uac00) \ube5b\uc744 \uac10\uc9c0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "is_no_motion": "{entity_name} \uc774(\uac00) \uc6c0\uc9c1\uc784\uc744 \uac10\uc9c0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "is_no_problem": "{entity_name} \uc774(\uac00) \ubb38\uc81c\ub97c \uac10\uc9c0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "is_no_smoke": "{entity_name} \uc774(\uac00) \uc5f0\uae30\ub97c \uac10\uc9c0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "is_no_sound": "{entity_name} \uc774(\uac00) \uc18c\ub9ac\ub97c \uac10\uc9c0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "is_no_vibration": "{entity_name} \uc774(\uac00) \uc9c4\ub3d9\uc744 \uac10\uc9c0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "is_not_bat_low": "{entity_name} \uc758 \ubc30\ud130\ub9ac\uac00 \uc815\uc0c1\uc785\ub2c8\ub2e4", + "is_not_cold": "{entity_name} \uc774(\uac00) \ucc28\uac11\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4.", + "is_not_connected": "{entity_name} \uc758 \uc5f0\uacb0\uc774 \ub04a\uc5b4\uc84c\uc2b5\ub2c8\ub2e4", + "is_not_hot": "{entity_name} \uc774(\uac00) \ub728\uac81\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4.", + "is_not_locked": "{entity_name} \uc758 \uc7a0\uae08\uc774 \ud574\uc81c\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "is_not_moist": "{entity_name} \uc774(\uac00) \uac74\uc870\ud569\ub2c8\ub2e4", + "is_not_moving": "{entity_name} \uc774(\uac00) \uc6c0\uc9c1\uc774\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4", + "is_not_occupied": "{entity_name} \uc774(\uac00) \uc0ac\uc6a9\uc911\uc774\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4", + "is_not_open": "{entity_name} \uc774(\uac00) \ub2eb\ud614\uc2b5\ub2c8\ub2e4", + "is_not_plugged_in": "{entity_name} \uc774(\uac00) \ubf51\ud614\uc2b5\ub2c8\ub2e4", + "is_not_powered": "{entity_name} \uc5d0 \uc804\uc6d0\uc774 \uacf5\uae09\ub418\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4", + "is_not_present": "{entity_name} \uc774(\uac00) \uc5c6\uc2b5\ub2c8\ub2e4", + "is_not_unsafe": "{entity_name} \uc740(\ub294) \uc548\uc804\ud569\ub2c8\ub2e4", + "is_occupied": "{entity_name} \uc774(\uac00) \uc0ac\uc6a9\uc911\uc785\ub2c8\ub2e4", + "is_off": "{entity_name} \uc774(\uac00) \uaebc\uc84c\uc2b5\ub2c8\ub2e4", + "is_on": "{entity_name} \uc774(\uac00) \ucf1c\uc84c\uc2b5\ub2c8\ub2e4", + "is_open": "{entity_name} \uc774(\uac00) \uc5f4\ub838\uc2b5\ub2c8\ub2e4", + "is_plugged_in": "{entity_name} \uc774(\uac00) \uaf3d\ud614\uc2b5\ub2c8\ub2e4", + "is_powered": "{entity_name} \uc5d0 \uc804\uc6d0\uc774 \uacf5\uae09\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "is_present": "{entity_name} \uc774(\uac00) \uc788\uc2b5\ub2c8\ub2e4", + "is_problem": "{entity_name} \uc774(\uac00) \ubb38\uc81c\ub97c \uac10\uc9c0\ud588\uc2b5\ub2c8\ub2e4", + "is_smoke": "{entity_name} \uc774(\uac00) \uc5f0\uae30\ub97c \uac10\uc9c0\ud588\uc2b5\ub2c8\ub2e4", + "is_sound": "{entity_name} \uc774(\uac00) \uc18c\ub9ac\ub97c \uac10\uc9c0\ud588\uc2b5\ub2c8\ub2e4", + "is_unsafe": "{entity_name} \uc740(\ub294) \uc548\uc804\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4", + "is_vibration": "{entity_name} \uc774(\uac00) \uc9c4\ub3d9\uc744 \uac10\uc9c0\ud588\uc2b5\ub2c8\ub2e4" + }, + "trigger_type": { + "bat_low": "{entity_name} \uc758 \ubc30\ud130\ub9ac \uc794\ub7c9 \ubd80\uc871", + "closed": "{entity_name} \uc774(\uac00) \ub2eb\ud798", + "cold": "{entity_name} \uc774(\uac00) \ucc28\uac00\uc6cc\uc9d0", + "connected": "{entity_name} \uc774(\uac00) \uc5f0\uacb0\ub428", + "gas": "{entity_name} \uc774(\uac00) \uac00\uc2a4\ub97c \uac10\uc9c0\ud568", + "hot": "{entity_name} \uc774(\uac00) \ub728\uac70\uc6cc\uc9d0", + "light": "{entity_name} \uc774(\uac00) \ube5b\uc744 \uac10\uc9c0\ud568", + "locked": "{entity_name} \uc774(\uac00) \uc7a0\uae40", + "moist": "{entity_name} \uc774(\uac00) \uc2b5\ud574\uc9d0", + "moist\u00a7": "{entity_name} \uc774(\uac00) \uc2b5\ud574\uc9d0", + "motion": "{entity_name} \uc774(\uac00) \uc6c0\uc9c1\uc784\uc744 \uac10\uc9c0\ud568", + "moving": "{entity_name} \uc774(\uac00) \uc6c0\uc9c1\uc784", + "no_gas": "{entity_name} \uc774(\uac00) \uac00\uc2a4\ub97c \uac10\uc9c0 \ubabb\ud568", + "no_light": "{entity_name} \uc774(\uac00) \ube5b\uc744 \uac10\uc9c0 \ubabb\ud568", + "no_motion": "{entity_name} \uc774(\uac00) \uc6c0\uc9c1\uc784\uc744 \uac10\uc9c0 \ubabb\ud568", + "no_problem": "{entity_name} \uc774(\uac00) \ubb38\uc81c\ub97c \uac10\uc9c0 \ubabb\ud568", + "no_smoke": "{entity_name} \uc774(\uac00) \uc5f0\uae30\ub97c \uac10\uc9c0 \ubabb\ud568", + "no_sound": "{entity_name} \uc774(\uac00) \uc18c\ub9ac\ub97c \uac10\uc9c0 \ubabb\ud568", + "no_vibration": "{entity_name} \uc774(\uac00) \uc9c4\ub3d9\uc744 \uac10\uc9c0 \ubabb\ud568", + "not_bat_low": "{entity_name} \uc758 \ubc30\ud130\ub9ac \uc815\uc0c1", + "not_cold": "{entity_name} \uc774(\uac00) \ucc28\uac11\uc9c0 \uc54a\uc74c", + "not_connected": "{entity_name} \uc758 \uc5f0\uacb0\uc774 \ub04a\uc5b4\uc9d0", + "not_hot": "{entity_name} \uc774(\uac00) \ub728\uac81\uc9c0 \uc54a\uc74c", + "not_locked": "{entity_name} \uc758 \uc7a0\uae08\uc774 \ud574\uc81c\ub428", + "not_moist": "{entity_name} \uc774(\uac00) \uac74\uc870\ud574\uc9d0", + "not_moving": "{entity_name} \uc774(\uac00) \uc6c0\uc9c1\uc774\uc9c0 \uc54a\uc74c", + "not_occupied": "{entity_name} \uc774(\uac00) \uc0ac\uc6a9\uc911\uc774\uc9c0 \uc54a\uc74c", + "not_opened": "{entity_name} \uc774(\uac00) \ub2eb\ud798", + "not_plugged_in": "{entity_name} \uc774(\uac00) \ubf51\ud798", + "not_powered": "{entity_name} \uc5d0 \uc804\uc6d0\uc774 \uacf5\uae09\ub418\uc9c0 \uc54a\uc74c", + "not_present": "{entity_name} \uc774(\uac00) \uc5c6\uc74c", + "not_unsafe": "{entity_name} \uc740(\ub294) \uc548\uc804\ud574\uc9d0", + "occupied": "{entity_name} \uc774(\uac00) \uc0ac\uc6a9\uc911", + "opened": "{entity_name} \uc774(\uac00) \uc5f4\ub9bc", + "plugged_in": "{entity_name} \uc774(\uac00) \uaf3d\ud798", + "powered": "{entity_name} \uc5d0 \uc804\uc6d0\uc774 \uacf5\uae09\ub428", + "present": "{entity_name} \uc774(\uac00) \uc788\uc74c", + "problem": "{entity_name} \uc774(\uac00) \ubb38\uc81c\ub97c \uac10\uc9c0\ud568", + "smoke": "{entity_name} \uc774(\uac00) \uc5f0\uae30\ub97c \uac10\uc9c0\ud568", + "sound": "{entity_name} \uc774(\uac00) \uc18c\ub9ac\ub97c \uac10\uc9c0\ud568", + "turned_off": "{entity_name} \uc774(\uac00) \uaebc\uc9d0", + "turned_on": "{entity_name} \uc774(\uac00) \ucf1c\uc9d0", + "unsafe": "{entity_name} \uc740(\ub294) \uc548\uc804\ud558\uc9c0 \uc54a\uc74c", + "vibration": "{entity_name} \uc774(\uac00) \uc9c4\ub3d9\uc744 \uac10\uc9c0\ud568" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/.translations/lb.json b/homeassistant/components/binary_sensor/.translations/lb.json new file mode 100644 index 000000000..c65ae9439 --- /dev/null +++ b/homeassistant/components/binary_sensor/.translations/lb.json @@ -0,0 +1,94 @@ +{ + "device_automation": { + "condition_type": { + "is_bat_low": "{entity_name} Batterie ass niddereg", + "is_cold": "{entity_name} ass kal", + "is_connected": "{entity_name} ass verbonnen", + "is_gas": "{entity_name} entdeckt Gas", + "is_hot": "{entity_name} ass waarm", + "is_light": "{entity_name} entdeckt Luucht", + "is_locked": "{entity_name} ass gespaart", + "is_moist": "{entity_name} ass fiicht", + "is_motion": "{entity_name} entdeckt Beweegung", + "is_moving": "{entity_name} beweegt sech", + "is_no_gas": "{entity_name} entdeckt kee Gas", + "is_no_light": "{entity_name} entdeckt keng Luucht", + "is_no_motion": "{entity_name} entdeckt keng Beweegung", + "is_no_problem": "{entity_name} entdeckt keng Problemer", + "is_no_smoke": "{entity_name} entdeckt keen Damp", + "is_no_sound": "{entity_name} entdeckt keen Toun", + "is_no_vibration": "{entity_name} entdeckt keng Vibratiounen", + "is_not_bat_low": "{entity_name} Batterie ass normal", + "is_not_cold": "{entity_name} ass net kal", + "is_not_connected": "{entity_name} ass d\u00e9connect\u00e9iert", + "is_not_hot": "{entity_name} ass net waarm", + "is_not_locked": "{entity_name} ass entspaart", + "is_not_moist": "{entity_name} ass dr\u00e9chen", + "is_not_moving": "{entity_name} beweegt sech net", + "is_not_occupied": "{entity_name} ass fr\u00e4i", + "is_not_open": "{entity_name} ass zou", + "is_not_plugged_in": "{entity_name} ass net ugeschloss", + "is_not_powered": "{entity_name} ass net aliment\u00e9iert", + "is_not_present": "{entity_name} ass net pr\u00e4sent", + "is_not_unsafe": "{entity_name} ass s\u00e9cher", + "is_occupied": "{entity_name} ass besat", + "is_off": "{entity_name} ass aus", + "is_on": "{entity_name} ass un", + "is_open": "{entity_name} ass op", + "is_plugged_in": "{entity_name} ass ugeschloss", + "is_powered": "{entity_name} ass aliment\u00e9iert", + "is_present": "{entity_name} ass pr\u00e4sent", + "is_problem": "{entity_name} entdeckt Problemer", + "is_smoke": "{entity_name} entdeckt Damp", + "is_sound": "{entity_name} entdeckt Toun", + "is_unsafe": "{entity_name} ass ons\u00e9cher", + "is_vibration": "{entity_name} entdeckt Vibratiounen" + }, + "trigger_type": { + "bat_low": "{entity_name} Batterie niddereg", + "closed": "{entity_name} gouf zougemaach", + "cold": "{entity_name} gouf kal", + "connected": "{entity_name} ass verbonnen", + "gas": "{entity_name} huet ugefaangen Gas z'entdecken", + "hot": "{entity_name} gouf waarm", + "light": "{entity_name} huet ugefange Luucht z'entdecken", + "locked": "{entity_name} gespaart", + "moist": "{entity_name} gouf fiicht", + "moist\u00a7": "{entity_name} gouf fiicht", + "motion": "{entity_name} huet ugefaange Beweegung z'entdecken", + "moving": "{entity_name} huet ugefaangen sech ze beweegen", + "no_gas": "{entity_name} huet opgehale Gas z'entdecken", + "no_light": "{entity_name} huet opgehale Luucht z'entdecken", + "no_motion": "{entity_name} huet opgehale Beweegung z'entdecken", + "no_problem": "{entity_name} huet opgehale Problemer z'entdecken", + "no_smoke": "{entity_name} huet opgehale Damp z'entdecken", + "no_sound": "{entity_name} huet opgehale Toun z'entdecken", + "no_vibration": "{entity_name} huet opgehale Vibratiounen z'entdecken", + "not_bat_low": "{entity_name} Batterie normal", + "not_cold": "{entity_name} gouf net kal", + "not_connected": "{entity_name} d\u00e9connect\u00e9iert", + "not_hot": "{entity_name} gouf net waarm", + "not_locked": "{entity_name} entspaart", + "not_moist": "{entity_name} gouf dr\u00e9chen", + "not_moving": "{entity_name} huet opgehale sech ze beweegen", + "not_occupied": "{entity_name} gouf fr\u00e4i", + "not_opened": "{entity_name} gouf zougemaach", + "not_plugged_in": "{entity_name} net ugeschloss", + "not_powered": "{entity_name} net aliment\u00e9iert", + "not_present": "{entity_name} net pr\u00e4sent", + "not_unsafe": "{entity_name} gouf s\u00e9cher", + "occupied": "{entity_name} gouf besat", + "opened": "{entity_name} gouf opgemaach", + "plugged_in": "{entity_name} ugeschloss", + "powered": "{entity_name} aliment\u00e9iert", + "present": "{entity_name} pr\u00e4sent", + "problem": "{entity_name} huet ugefaange Problemer z'entdecken", + "smoke": "{entity_name} huet ugefaangen Damp z'entdecken", + "sound": "{entity_name} huet ugefaangen Toun z'entdecken", + "turned_off": "{entity_name} gouf ausgeschalt", + "turned_on": "{entity_name} gouf ugeschalt", + "unsafe": "{entity_name} gouf ons\u00e9cher", + "vibration": "{entity_name} huet ugefaange Vibratiounen z'entdecken" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/.translations/lv.json b/homeassistant/components/binary_sensor/.translations/lv.json new file mode 100644 index 000000000..7668dfa5a --- /dev/null +++ b/homeassistant/components/binary_sensor/.translations/lv.json @@ -0,0 +1,8 @@ +{ + "device_automation": { + "trigger_type": { + "turned_off": "{entity_name} tika izsl\u0113gta", + "turned_on": "{entity_name} tika iesl\u0113gta" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/.translations/nl.json b/homeassistant/components/binary_sensor/.translations/nl.json new file mode 100644 index 000000000..508a06b38 --- /dev/null +++ b/homeassistant/components/binary_sensor/.translations/nl.json @@ -0,0 +1,94 @@ +{ + "device_automation": { + "condition_type": { + "is_bat_low": "{entity_name} batterij is bijna leeg", + "is_cold": "{entity_name} is koud", + "is_connected": "{entity_name} is verbonden", + "is_gas": "{entity_name} detecteert gas", + "is_hot": "{entity_name} is hot", + "is_light": "{entity_name} detecteert licht", + "is_locked": "{entity_name} is vergrendeld", + "is_moist": "{entity_name} is vochtig", + "is_motion": "{entity_name} detecteert beweging", + "is_moving": "{entity_name} is in beweging", + "is_no_gas": "{entity_name} detecteert geen gas", + "is_no_light": "{entity_name} detecteert geen licht", + "is_no_motion": "{entity_name} detecteert geen beweging", + "is_no_problem": "{entity_name} detecteert geen probleem", + "is_no_smoke": "{entity_name} detecteert geen rook", + "is_no_sound": "{entity_name} detecteert geen geluid", + "is_no_vibration": "{entity_name} detecteert geen trillingen", + "is_not_bat_low": "{entity_name} batterij is normaal", + "is_not_cold": "{entity_name} is niet koud", + "is_not_connected": "{entity_name} is niet verbonden", + "is_not_hot": "{entity_name} is niet heet", + "is_not_locked": "{entity_name} is ontgrendeld", + "is_not_moist": "{entity_name} is droog", + "is_not_moving": "{entity_name} beweegt niet", + "is_not_occupied": "{entity_name} is niet bezet", + "is_not_open": "{entity_name} is gesloten", + "is_not_plugged_in": "{entity_name} is niet aangesloten", + "is_not_powered": "{entity_name} is niet van stroom voorzien...", + "is_not_present": "{entity_name} is niet aanwezig", + "is_not_unsafe": "{entity_name} is veilig", + "is_occupied": "{entity_name} bezet is", + "is_off": "{entity_name} is uitgeschakeld", + "is_on": "{entity_name} is ingeschakeld", + "is_open": "{entity_name} is open", + "is_plugged_in": "{entity_name} is aangesloten", + "is_powered": "{entity_name} is van stroom voorzien....", + "is_present": "{entity_name} is aanwezig", + "is_problem": "{entity_name} detecteert een probleem", + "is_smoke": "{entity_name} detecteert rook", + "is_sound": "{entity_name} detecteert geluid", + "is_unsafe": "{entity_name} is onveilig", + "is_vibration": "{entity_name} detecteert trillingen" + }, + "trigger_type": { + "bat_low": "{entity_name} batterij bijna leeg", + "closed": "{entity_name} gesloten", + "cold": "{entity_name} werd koud", + "connected": "{entity_name} verbonden", + "gas": "{entity_name} begon gas te detecteren", + "hot": "{entity_name} werd heet", + "light": "{entity_name} begon licht te detecteren", + "locked": "{entity_name} vergrendeld", + "moist": "{entity_name} werd vochtig", + "moist\u00a7": "{entity_name} werd vochtig", + "motion": "{entity_name} begon beweging te detecteren", + "moving": "{entity_name} begon te bewegen", + "no_gas": "{entity_name} is gestopt met het detecteren van gas", + "no_light": "{entity_name} gestopt met het detecteren van licht", + "no_motion": "{entity_name} gestopt met het detecteren van beweging", + "no_problem": "{entity_name} gestopt met het detecteren van het probleem", + "no_smoke": "{entity_name} gestopt met het detecteren van rook", + "no_sound": "{entity_name} gestopt met het detecteren van geluid", + "no_vibration": "{entity_name} gestopt met het detecteren van trillingen", + "not_bat_low": "{entity_name} batterij normaal", + "not_cold": "{entity_name} werd niet koud", + "not_connected": "{entity_name} verbroken", + "not_hot": "{entity_name} werd niet warm", + "not_locked": "{entity_name} ontgrendeld", + "not_moist": "{entity_name} werd droog", + "not_moving": "{entity_name} gestopt met bewegen", + "not_occupied": "{entity_name} werd niet bezet", + "not_opened": "{entity_name} gesloten", + "not_plugged_in": "{entity_name} niet verbonden", + "not_powered": "{entity_name} niet ingeschakeld", + "not_present": "{entity_name} is niet aanwezig", + "not_unsafe": "{entity_name} werd veilig", + "occupied": "{entity_name} werd bezet", + "opened": "{entity_name} geopend", + "plugged_in": "{entity_name} aangesloten", + "powered": "{entity_name} heeft vermogen", + "present": "{entity_name} aanwezig", + "problem": "{entity_name} begonnen met het detecteren van een probleem", + "smoke": "{entity_name} begon rook te detecteren", + "sound": "{entity_name} begon geluid te detecteren", + "turned_off": "{entity_name} uitgeschakeld", + "turned_on": "{entity_name} ingeschakeld", + "unsafe": "{entity_name} werd onveilig", + "vibration": "{entity_name} begon trillingen te detecteren" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/.translations/no.json b/homeassistant/components/binary_sensor/.translations/no.json new file mode 100644 index 000000000..419410294 --- /dev/null +++ b/homeassistant/components/binary_sensor/.translations/no.json @@ -0,0 +1,94 @@ +{ + "device_automation": { + "condition_type": { + "is_bat_low": "{entity_name} batteriniv\u00e5et er lavt", + "is_cold": "{entity_name} er kald", + "is_connected": "{entity_name} er tilkoblet", + "is_gas": "{entity_name} registrerer gass", + "is_hot": "{entity_name} er varm", + "is_light": "{entity_name} registrerer lys", + "is_locked": "{entity_name} er l\u00e5st", + "is_moist": "{entity_name} er fuktig", + "is_motion": "{entity_name} registrerer bevegelse", + "is_moving": "{entity_name} er i bevegelse", + "is_no_gas": "{entity_name} registrerer ikke gass", + "is_no_light": "{entity_name} registrerer ikke lys", + "is_no_motion": "{entity_name} registrerer ikke bevegelse", + "is_no_problem": "{entity_name} registrerer ikke et problem", + "is_no_smoke": "{entity_name} registrerer ikke r\u00f8yk", + "is_no_sound": "{entity_name} registrerer ikke lyd", + "is_no_vibration": "{entity_name} registrerer ikke bevegelse", + "is_not_bat_low": "{entity_name} batteri er normalt", + "is_not_cold": "{entity_name} er ikke kald", + "is_not_connected": "{entity_name} er frakoblet", + "is_not_hot": "{entity_name} er ikke varm", + "is_not_locked": "{entity_name} er ul\u00e5st", + "is_not_moist": "{entity_name} er t\u00f8rr", + "is_not_moving": "{entity_name} er ikke i bevegelse", + "is_not_occupied": "{entity_name} er ledig", + "is_not_open": "{entity_name} er lukket", + "is_not_plugged_in": "{entity_name} er koblet fra", + "is_not_powered": "{entity_name} er spenningsl\u00f8s", + "is_not_present": "{entity_name} er ikke tilstede", + "is_not_unsafe": "{entity_name} er trygg", + "is_occupied": "{entity_name} er opptatt", + "is_off": "{entity_name} er sl\u00e5tt av", + "is_on": "{entity_name} er sl\u00e5tt p\u00e5", + "is_open": "{entity_name} er \u00e5pen", + "is_plugged_in": "{entity_name} er koblet til", + "is_powered": "{entity_name} er spenningssatt", + "is_present": "{entity_name} er tilstede", + "is_problem": "{entity_name} registrerer et problem", + "is_smoke": "{entity_name} registrerer r\u00f8yk", + "is_sound": "{entity_name} registrerer lyd", + "is_unsafe": "{entity_name} er utrygg", + "is_vibration": "{entity_name} registrerer vibrasjon" + }, + "trigger_type": { + "bat_low": "{entity_name} lavt batteri", + "closed": "{entity_name} stengt", + "cold": "{entity_name} ble kald", + "connected": "{entity_name} tilkoblet", + "gas": "{entity_name} begynte \u00e5 registrere gass", + "hot": "{entity_name} ble varm", + "light": "{entity_name} begynte \u00e5 registrere lys", + "locked": "{entity_name} l\u00e5st", + "moist": "{entity_name} ble fuktig", + "moist\u00a7": "{entity_name} ble fuktig", + "motion": "{entity_name} begynte \u00e5 registrere bevegelse", + "moving": "{entity_name} begynte \u00e5 bevege seg", + "no_gas": "{entity_name} sluttet \u00e5 registrere gass", + "no_light": "{entity_name} sluttet \u00e5 registrere lys", + "no_motion": "{entity_name} sluttet \u00e5 registrere bevegelse", + "no_problem": "{entity_name} sluttet \u00e5 registrere problem", + "no_smoke": "{entity_name} sluttet \u00e5 registrere r\u00f8yk", + "no_sound": "{entity_name} sluttet \u00e5 registrere lyd", + "no_vibration": "{entity_name} sluttet \u00e5 registrere vibrasjon", + "not_bat_low": "{entity_name} batteri normalt", + "not_cold": "{entity_name} ble ikke lenger kald", + "not_connected": "{entity_name} koblet fra", + "not_hot": "{entity_name} ble ikke lenger varm", + "not_locked": "{entity_name} l\u00e5st opp", + "not_moist": "{entity_name} ble t\u00f8rr", + "not_moving": "{entity_name} sluttet \u00e5 bevege seg", + "not_occupied": "{entity_name} ble ledig", + "not_opened": "{entity_name} stengt", + "not_plugged_in": "{entity_name} koblet fra", + "not_powered": "{entity_name} spenningsl\u00f8s", + "not_present": "{entity_name} ikke til stede", + "not_unsafe": "{entity_name} ble trygg", + "occupied": "{entity_name} ble opptatt", + "opened": "{entity_name} \u00e5pnet", + "plugged_in": "{entity_name} koblet til", + "powered": "{entity_name} spenningssatt", + "present": "{entity_name} tilstede", + "problem": "{entity_name} begynte \u00e5 registrere et problem", + "smoke": "{entity_name} begynte \u00e5 registrere r\u00f8yk", + "sound": "{entity_name} begynte \u00e5 registrere lyd", + "turned_off": "{entity_name} sl\u00e5tt av", + "turned_on": "{entity_name} sl\u00e5tt p\u00e5", + "unsafe": "{entity_name} ble usikker", + "vibration": "{entity_name} begynte \u00e5 oppdage vibrasjon" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/.translations/pl.json b/homeassistant/components/binary_sensor/.translations/pl.json new file mode 100644 index 000000000..bc474e3d5 --- /dev/null +++ b/homeassistant/components/binary_sensor/.translations/pl.json @@ -0,0 +1,94 @@ +{ + "device_automation": { + "condition_type": { + "is_bat_low": "bateria {entity_name} jest roz\u0142adowana", + "is_cold": "sensor {entity_name} wykrywa zimno", + "is_connected": "sensor {entity_name} raportuje po\u0142\u0105czenie", + "is_gas": "sensor {entity_name} wykrywa gaz", + "is_hot": "sensor {entity_name} wykrywa gor\u0105co", + "is_light": "sensor {entity_name} wykrywa \u015bwiat\u0142o", + "is_locked": "sensor {entity_name} wykrywa zamkni\u0119cie", + "is_moist": "sensor {entity_name} wykrywa wilgo\u0107", + "is_motion": "sensor {entity_name} wykrywa ruch", + "is_moving": "sensor {entity_name} porusza si\u0119", + "is_no_gas": "sensor {entity_name} nie wykrywa gazu", + "is_no_light": "sensor {entity_name} nie wykrywa \u015bwiat\u0142a", + "is_no_motion": "sensor {entity_name} nie wykrywa ruchu", + "is_no_problem": "sensor {entity_name} nie wykrywa problemu", + "is_no_smoke": "sensor {entity_name} nie wykrywa dymu", + "is_no_sound": "sensor {entity_name} nie wykrywa d\u017awi\u0119ku", + "is_no_vibration": "sensor {entity_name} nie wykrywa wibracji", + "is_not_bat_low": "bateria {entity_name} nie jest roz\u0142adowana", + "is_not_cold": "sensor {entity_name} nie wykrywa zimna", + "is_not_connected": "sensor {entity_name} nie wykrywa roz\u0142\u0105czenia", + "is_not_hot": "sensor {entity_name} nie wykrywa gor\u0105ca", + "is_not_locked": "sensor {entity_name} nie wykrywa otwarcia", + "is_not_moist": "sensor {entity_name} nie wykrywa wilgoci", + "is_not_moving": "sensor {entity_name} nie porusza si\u0119", + "is_not_occupied": "sensor {entity_name} nie jest zaj\u0119ty", + "is_not_open": "sensor {entity_name} jest zamkni\u0119ty", + "is_not_plugged_in": "sensor {entity_name} wykrywa od\u0142\u0105czenie", + "is_not_powered": "sensor {entity_name} nie wykrywa zasilania", + "is_not_present": "sensor {entity_name} nie wykrywa obecno\u015bci", + "is_not_unsafe": "sensor {entity_name} nie wykrywa niebezpiecze\u0144stwa", + "is_occupied": "sensor {entity_name} jest zaj\u0119ty", + "is_off": "sensor {entity_name} jest wy\u0142\u0105czony", + "is_on": "sensor {entity_name} jest w\u0142\u0105czony", + "is_open": "sensor {entity_name} jest otwarty", + "is_plugged_in": "sensor {entity_name} wykrywa pod\u0142\u0105czenie", + "is_powered": "sensor {entity_name} wykrywa zasilanie", + "is_present": "sensor {entity_name} wykrywa obecno\u015b\u0107", + "is_problem": "sensor {entity_name} wykrywa problem", + "is_smoke": "sensor {entity_name} wykrywa dym", + "is_sound": "sensor {entity_name} wykrywa d\u017awi\u0119k", + "is_unsafe": "sensor {entity_name} wykrywa niebezpiecze\u0144stwo", + "is_vibration": "sensor {entity_name} wykrywa wibracje" + }, + "trigger_type": { + "bat_low": "nast\u0105pi roz\u0142adowanie baterii {entity_name}", + "closed": "nast\u0105pi zamkni\u0119cie {entity_name}", + "cold": "sensor {entity_name} wykryje zimno", + "connected": "nast\u0105pi pod\u0142\u0105czenie {entity_name}", + "gas": "sensor {entity_name} wykryje gaz", + "hot": "sensor {entity_name} wykryje gor\u0105co", + "light": "sensor {entity_name} wykryje \u015bwiat\u0142o", + "locked": "nast\u0105pi zamkni\u0119cie {entity_name}", + "moist": "nast\u0105pi wykrycie wilgoci {entity_name}", + "moist\u00a7": "sensor {entity_name} wykryje wilgo\u0107", + "motion": "sensor {entity_name} wykryje ruch", + "moving": "sensor {entity_name} zacznie porusza\u0107 si\u0119", + "no_gas": "sensor {entity_name} przestanie wykrywa\u0107 gaz", + "no_light": "sensor {entity_name} przestanie wykrywa\u0107 \u015bwiat\u0142o", + "no_motion": "sensor {entity_name} przestanie wykrywa\u0107 ruch", + "no_problem": "sensor {entity_name} przestanie wykrywa\u0107 problem", + "no_smoke": "sensor {entity_name} przestanie wykrywa\u0107 dym", + "no_sound": "sensor {entity_name} przestanie wykrywa\u0107 d\u017awi\u0119k", + "no_vibration": "sensor {entity_name} przestanie wykrywa\u0107 wibracje", + "not_bat_low": "nast\u0105pi na\u0142adowanie baterii {entity_name}", + "not_cold": "sensor {entity_name} przestanie wykrywa\u0107 zimno", + "not_connected": "nast\u0105pi roz\u0142\u0105czenie {entity_name}", + "not_hot": "sensor {entity_name} przestanie wykrywa\u0107 gor\u0105co", + "not_locked": "nast\u0105pi otwarcie {entity_name}", + "not_moist": "sensor {entity_name} przestanie wykrywa\u0107 wilgo\u0107", + "not_moving": "sensor {entity_name} przestanie porusza\u0107 si\u0119", + "not_occupied": "sensor {entity_name} przestanie by\u0107 zaj\u0119ty", + "not_opened": "nast\u0105pi zamkni\u0119cie {entity_name}", + "not_plugged_in": "nast\u0105pi od\u0142\u0105czenie {entity_name}", + "not_powered": "nast\u0105pi od\u0142\u0105czenie zasilania {entity_name}", + "not_present": "sensor {entity_name} przestanie wykrywa\u0107 obecno\u015b\u0107", + "not_unsafe": "sensor {entity_name} przestanie wykrywa\u0107 niebezpiecze\u0144stwo", + "occupied": "sensor {entity_name} stanie si\u0119 zaj\u0119ty", + "opened": "nast\u0105pi otwarcie {entity_name}", + "plugged_in": "nast\u0105pi pod\u0142\u0105czenie {entity_name}", + "powered": "nast\u0105pi pod\u0142\u0105czenie zasilenia {entity_name}", + "present": "sensor {entity_name} wykryje obecno\u015b\u0107", + "problem": "sensor {entity_name} wykryje problem", + "smoke": "sensor {entity_name} wykryje dym", + "sound": "sensor {entity_name} wykryje d\u017awi\u0119k", + "turned_off": "nast\u0105pi wy\u0142\u0105czenie {entity_name}", + "turned_on": "nast\u0105pi w\u0142\u0105czenie {entity_name}", + "unsafe": "sensor {entity_name} wykryje niebezpiecze\u0144stwo", + "vibration": "sensor {entity_name} wykryje wibracje" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/.translations/pt.json b/homeassistant/components/binary_sensor/.translations/pt.json new file mode 100644 index 000000000..aa16576d2 --- /dev/null +++ b/homeassistant/components/binary_sensor/.translations/pt.json @@ -0,0 +1,41 @@ +{ + "device_automation": { + "condition_type": { + "is_bat_low": "a bateria {entity_name} est\u00e1 baixa", + "is_cold": "{entity_name} est\u00e1 frio", + "is_connected": "{entity_name} est\u00e1 ligado", + "is_gas": "{entity_name} est\u00e1 a detectar g\u00e1s", + "is_hot": "{entity_name} est\u00e1 quente", + "is_light": "{entity_name} est\u00e1 a detectar luz", + "is_locked": "{entity_name} est\u00e1 fechado", + "is_moist": "{entity_name} est\u00e1 h\u00famido", + "is_motion": "{entity_name} est\u00e1 a detectar movimento", + "is_moving": "{entity_name} est\u00e1 a mexer", + "is_not_open": "{entity_name} est\u00e1 fechada", + "is_off": "{entity_name} est\u00e1 desligado", + "is_on": "{entity_name} est\u00e1 ligado", + "is_vibration": "{entity_name} est\u00e1 a detectar vibra\u00e7\u00f5es" + }, + "trigger_type": { + "closed": "{entity_name} est\u00e1 fechado", + "moist": "ficou h\u00famido {entity_name}", + "not_opened": "fechado {entity_name}", + "not_plugged_in": "{entity_name} desligado", + "not_powered": "{entity_name} n\u00e3o alimentado", + "not_present": "ausente {entity_name}", + "not_unsafe": "ficou seguro {entity_name}", + "occupied": "ficou ocupado {entity_name}", + "opened": "{entity_name} aberto", + "plugged_in": "{entity_name} ligado", + "powered": "{entity_name} alimentado", + "present": "{entity_name} presente", + "problem": "foi detectado problema em {entity_name}", + "smoke": "foi detectado fumo em {entity_name}", + "sound": "foram detectadas sons em {entity_name}", + "turned_off": "foi desligado {entity_name}", + "turned_on": "foi ligado {entity_name}", + "unsafe": "ficou inseguro {entity_name}", + "vibration": "foram detectadas vibra\u00e7\u00f5es em {entity_name}" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/.translations/ro.json b/homeassistant/components/binary_sensor/.translations/ro.json new file mode 100644 index 000000000..438822a97 --- /dev/null +++ b/homeassistant/components/binary_sensor/.translations/ro.json @@ -0,0 +1,45 @@ +{ + "device_automation": { + "condition_type": { + "is_off": "{entity_name} oprit", + "is_on": "{entity_name} pornit" + }, + "trigger_type": { + "gas": "{entity_name} a \u00eenceput s\u0103 detecteze gaz", + "hot": "{entity_name} a devenit fierbinte", + "locked": "{entity_name} blocat", + "motion": "{entity_name} a \u00eenceput s\u0103 detecteze mi\u0219care", + "moving": "{entity_name} a \u00eenceput s\u0103 se mi\u0219te", + "no_light": "{entity_name} a oprit detectarea luminii", + "no_motion": "{entity_name} a oprit detectarea mi\u0219c\u0103rii", + "no_problem": "{entity_name} a oprit detectarea problemei", + "no_smoke": "{entity_name} a oprit detectarea fumului", + "no_sound": "{entity_name} a oprit detectarea de sunet", + "no_vibration": "{entity_name} a oprit detectarea vibra\u021biilor", + "not_bat_low": "{entity_name} baterie normal\u0103", + "not_cold": "{entity_name} nu mai este rece", + "not_connected": "{entity_name} deconectat", + "not_hot": "{entity_name} nu mai este fierbinte", + "not_locked": "{entity_name} deblocat", + "not_moist": "{entity_name} a devenit uscat", + "not_moving": "{entity_name} a \u00eencetat mi\u0219carea", + "not_occupied": "{entity_name} a devenit neocupat", + "not_plugged_in": "{entity_name} deconectat", + "not_powered": "{entity_name} nu este alimentat", + "not_present": "{entity_name} nu este prezent", + "not_unsafe": "{entity_name} a devenit sigur", + "occupied": "{entity_name} a devenit ocupat", + "opened": "{entity_name} deschis", + "plugged_in": "{entity_name} conectat", + "powered": "{entity_name} alimentat", + "present": "{entity_name} prezent", + "problem": "{entity_name} a \u00eenceput detectarea unei probleme", + "smoke": "{entity_name} a \u00eenceput s\u0103 detecteze fum", + "sound": "{entity_name} a \u00eenceput s\u0103 detecteze sunetul", + "turned_off": "{entity_name} oprit", + "turned_on": "{entity_name} pornit", + "unsafe": "{entity_name} a devenit nesigur", + "vibration": "{entity_name} a \u00eenceput s\u0103 detecteze vibra\u021biile" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/.translations/ru.json b/homeassistant/components/binary_sensor/.translations/ru.json new file mode 100644 index 000000000..4c9cfb99a --- /dev/null +++ b/homeassistant/components/binary_sensor/.translations/ru.json @@ -0,0 +1,94 @@ +{ + "device_automation": { + "condition_type": { + "is_bat_low": "{entity_name} \u0432 \u0440\u0430\u0437\u0440\u044f\u0436\u0435\u043d\u043d\u043e\u043c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0438", + "is_cold": "{entity_name} \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u043e\u0445\u043b\u0430\u0436\u0434\u0435\u043d\u0438\u0435", + "is_connected": "{entity_name} \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435", + "is_gas": "{entity_name} \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u0433\u0430\u0437", + "is_hot": "{entity_name} \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u043d\u0430\u0433\u0440\u0435\u0432", + "is_light": "{entity_name} \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u0441\u0432\u0435\u0442", + "is_locked": "{entity_name} \u0432 \u0437\u0430\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u043e\u043c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0438", + "is_moist": "{entity_name} \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u0432\u043b\u0430\u0433\u0443", + "is_motion": "{entity_name} \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u0434\u0432\u0438\u0436\u0435\u043d\u0438\u0435", + "is_moving": "{entity_name} \u043f\u0435\u0440\u0435\u043c\u0435\u0449\u0430\u0435\u0442\u0441\u044f", + "is_no_gas": "{entity_name} \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u0433\u0430\u0437", + "is_no_light": "{entity_name} \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u0441\u0432\u0435\u0442", + "is_no_motion": "{entity_name} \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u0434\u0432\u0438\u0436\u0435\u043d\u0438\u0435", + "is_no_problem": "{entity_name} \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u0443", + "is_no_smoke": "{entity_name} \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u0434\u044b\u043c", + "is_no_sound": "{entity_name} \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u0437\u0432\u0443\u043a", + "is_no_vibration": "{entity_name} \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u0432\u0438\u0431\u0440\u0430\u0446\u0438\u044e", + "is_not_bat_low": "{entity_name} \u0432 \u043d\u043e\u0440\u043c\u0430\u043b\u044c\u043d\u043e\u043c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0438", + "is_not_cold": "{entity_name} \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u043e\u0445\u043b\u0430\u0436\u0434\u0435\u043d\u0438\u0435", + "is_not_connected": "{entity_name} \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435", + "is_not_hot": "{entity_name} \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u043d\u0430\u0433\u0440\u0435\u0432", + "is_not_locked": "{entity_name} \u0432 \u0440\u0430\u0437\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u043e\u043c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0438", + "is_not_moist": "{entity_name} \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u0432\u043b\u0430\u0433\u0443", + "is_not_moving": "{entity_name} \u043d\u0435 \u043f\u0435\u0440\u0435\u043c\u0435\u0449\u0430\u0435\u0442\u0441\u044f", + "is_not_occupied": "{entity_name} \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u043f\u0440\u0438\u0441\u0443\u0442\u0441\u0442\u0432\u0438\u0435", + "is_not_open": "{entity_name} \u0432 \u0437\u0430\u043a\u0440\u044b\u0442\u043e\u043c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0438", + "is_not_plugged_in": "{entity_name} \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435", + "is_not_powered": "{entity_name} \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u043f\u0438\u0442\u0430\u043d\u0438\u0435", + "is_not_present": "{entity_name} \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u043f\u0440\u0438\u0441\u0443\u0442\u0441\u0442\u0432\u0438\u0435", + "is_not_unsafe": "{entity_name} \u0432 \u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u043e\u043c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0438", + "is_occupied": "{entity_name} \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u043f\u0440\u0438\u0441\u0443\u0442\u0441\u0442\u0432\u0438\u0435", + "is_off": "{entity_name} \u0432 \u0432\u044b\u043a\u043b\u044e\u0447\u0435\u043d\u043d\u043e\u043c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0438", + "is_on": "{entity_name} \u0432\u043e \u0432\u043a\u043b\u044e\u0447\u0435\u043d\u043d\u043e\u043c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0438", + "is_open": "{entity_name} \u0432 \u043e\u0442\u043a\u0440\u044b\u0442\u043e\u043c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0438", + "is_plugged_in": "{entity_name} \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435", + "is_powered": "{entity_name} \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u043f\u0438\u0442\u0430\u043d\u0438\u0435", + "is_present": "{entity_name} \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u043f\u0440\u0438\u0441\u0443\u0442\u0441\u0442\u0432\u0438\u0435", + "is_problem": "{entity_name} \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u0443", + "is_smoke": "{entity_name} \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u0434\u044b\u043c", + "is_sound": "{entity_name} \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u0437\u0432\u0443\u043a", + "is_unsafe": "{entity_name} \u0432 \u043d\u0435\u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u043e\u043c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0438", + "is_vibration": "{entity_name} \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u0432\u0438\u0431\u0440\u0430\u0446\u0438\u044e" + }, + "trigger_type": { + "bat_low": "{entity_name} \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0443\u0435\u0442 \u043d\u0438\u0437\u043a\u0438\u0439 \u0437\u0430\u0440\u044f\u0434", + "closed": "{entity_name} \u0437\u0430\u043a\u0440\u044b\u0432\u0430\u0435\u0442\u0441\u044f", + "cold": "{entity_name} \u043e\u0445\u043b\u0430\u0436\u0434\u0430\u0435\u0442\u0441\u044f", + "connected": "{entity_name} \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0430\u0435\u0442\u0441\u044f", + "gas": "{entity_name} \u043d\u0430\u0447\u0438\u043d\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u0433\u0430\u0437", + "hot": "{entity_name} \u043d\u0430\u0433\u0440\u0435\u0432\u0430\u0435\u0442\u0441\u044f", + "light": "{entity_name} \u043d\u0430\u0447\u0438\u043d\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u0441\u0432\u0435\u0442", + "locked": "{entity_name} \u0431\u043b\u043e\u043a\u0438\u0440\u0443\u0435\u0442\u0441\u044f", + "moist": "{entity_name} \u043d\u0430\u0447\u0438\u043d\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u0432\u043b\u0430\u0433\u0443", + "moist\u00a7": "{entity_name} \u043d\u0430\u0447\u0438\u043d\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u0432\u043b\u0430\u0433\u0443", + "motion": "{entity_name} \u043d\u0430\u0447\u0438\u043d\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u0434\u0432\u0438\u0436\u0435\u043d\u0438\u0435", + "moving": "{entity_name} \u043d\u0430\u0447\u0438\u043d\u0430\u0435\u0442 \u043f\u0435\u0440\u0435\u043c\u0435\u0449\u0435\u043d\u0438\u0435", + "no_gas": "{entity_name} \u043f\u0440\u0435\u043a\u0440\u0430\u0449\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u0433\u0430\u0437", + "no_light": "{entity_name} \u043f\u0440\u0435\u043a\u0440\u0430\u0449\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u0441\u0432\u0435\u0442", + "no_motion": "{entity_name} \u043f\u0440\u0435\u043a\u0440\u0430\u0449\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u0434\u0432\u0438\u0436\u0435\u043d\u0438\u0435", + "no_problem": "{entity_name} \u043f\u0440\u0435\u043a\u0440\u0430\u0449\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u0443", + "no_smoke": "{entity_name} \u043f\u0440\u0435\u043a\u0440\u0430\u0449\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u0434\u044b\u043c", + "no_sound": "{entity_name} \u043f\u0440\u0435\u043a\u0440\u0430\u0449\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u0437\u0432\u0443\u043a", + "no_vibration": "{entity_name} \u043f\u0440\u0435\u043a\u0440\u0430\u0449\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u0432\u0438\u0431\u0440\u0430\u0446\u0438\u044e", + "not_bat_low": "{entity_name} \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0443\u0435\u0442 \u043d\u043e\u0440\u043c\u0430\u043b\u044c\u043d\u044b\u0439 \u0437\u0430\u0440\u044f\u0434", + "not_cold": "{entity_name} \u043f\u0440\u0435\u043a\u0440\u0430\u0449\u0430\u0435\u0442 \u043e\u0445\u043b\u0430\u0436\u0434\u0430\u0442\u044c\u0441\u044f", + "not_connected": "{entity_name} \u043e\u0442\u043a\u043b\u044e\u0447\u0430\u0435\u0442\u0441\u044f", + "not_hot": "{entity_name} \u043f\u0440\u0435\u043a\u0440\u0430\u0449\u0430\u0435\u0442 \u043d\u0430\u0433\u0440\u0435\u0432\u0430\u0442\u044c\u0441\u044f", + "not_locked": "{entity_name} \u0440\u0430\u0437\u0431\u043b\u043e\u043a\u0438\u0440\u0443\u0435\u0442\u0441\u044f", + "not_moist": "{entity_name} \u043f\u0440\u0435\u043a\u0440\u0430\u0449\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u0432\u043b\u0430\u0433\u0443", + "not_moving": "{entity_name} \u043f\u0440\u0435\u043a\u0440\u0430\u0449\u0430\u0435\u0442 \u043f\u0435\u0440\u0435\u043c\u0435\u0449\u0435\u043d\u0438\u0435", + "not_occupied": "{entity_name} \u043f\u0440\u0435\u043a\u0440\u0430\u0449\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u043f\u0440\u0438\u0441\u0443\u0442\u0441\u0442\u0432\u0438\u0435", + "not_opened": "{entity_name} \u0437\u0430\u043a\u0440\u044b\u0432\u0430\u0435\u0442\u0441\u044f", + "not_plugged_in": "{entity_name} \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0443\u0435\u0442 \u043e\u0442\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435", + "not_powered": "{entity_name} \u043d\u0435 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0443\u0435\u0442 \u043d\u0430\u043b\u0438\u0447\u0438\u0435 \u043f\u0438\u0442\u0430\u043d\u0438\u044f", + "not_present": "{entity_name} \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0443\u0435\u0442 \u043e\u0442\u0441\u0443\u0442\u0441\u0442\u0432\u0438\u0435", + "not_unsafe": "{entity_name} \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0443\u0435\u0442 \u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u043e\u0441\u0442\u044c", + "occupied": "{entity_name} \u043d\u0430\u0447\u0438\u043d\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u043f\u0440\u0438\u0441\u0443\u0442\u0441\u0442\u0432\u0438\u0435", + "opened": "{entity_name} \u043e\u0442\u043a\u0440\u044b\u0432\u0430\u0435\u0442\u0441\u044f", + "plugged_in": "{entity_name} \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0443\u0435\u0442 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435", + "powered": "{entity_name} \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0443\u0435\u0442 \u043d\u0430\u043b\u0438\u0447\u0438\u0435 \u043f\u0438\u0442\u0430\u043d\u0438\u044f", + "present": "{entity_name} \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0443\u0435\u0442 \u043f\u0440\u0438\u0441\u0443\u0442\u0441\u0442\u0432\u0438\u0435", + "problem": "{entity_name} \u043d\u0430\u0447\u0438\u043d\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u0443", + "smoke": "{entity_name} \u043d\u0430\u0447\u0438\u043d\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u0434\u044b\u043c", + "sound": "{entity_name} \u043d\u0430\u0447\u0438\u043d\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u0437\u0432\u0443\u043a", + "turned_off": "{entity_name} \u0432\u044b\u043a\u043b\u044e\u0447\u0430\u0435\u0442\u0441\u044f", + "turned_on": "{entity_name} \u0432\u043a\u043b\u044e\u0447\u0430\u0435\u0442\u0441\u044f", + "unsafe": "{entity_name} \u043d\u0435 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0443\u0435\u0442 \u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u043e\u0441\u0442\u044c", + "vibration": "{entity_name} \u043d\u0430\u0447\u0438\u043d\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u0432\u0438\u0431\u0440\u0430\u0446\u0438\u044e" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/.translations/sl.json b/homeassistant/components/binary_sensor/.translations/sl.json new file mode 100644 index 000000000..2004caeb3 --- /dev/null +++ b/homeassistant/components/binary_sensor/.translations/sl.json @@ -0,0 +1,94 @@ +{ + "device_automation": { + "condition_type": { + "is_bat_low": "{entity_name} ima prazno baterijo", + "is_cold": "{entity_name} je hladen", + "is_connected": "{entity_name} je povezan", + "is_gas": "{entity_name} zaznava plin", + "is_hot": "{entity_name} je vro\u010d", + "is_light": "{entity_name} zaznava svetlobo", + "is_locked": "{entity_name} je zaklenjen", + "is_moist": "{entity_name} je vla\u017een", + "is_motion": "{entity_name} zaznava gibanje", + "is_moving": "{entity_name} se premika", + "is_no_gas": "{entity_name} ne zaznava plina", + "is_no_light": "{entity_name} ne zaznava svetlobe", + "is_no_motion": "{entity_name} ne zaznava gibanja", + "is_no_problem": "{entity_name} ne zaznava te\u017eav", + "is_no_smoke": "{entity_name} ne zaznava dima", + "is_no_sound": "{entity_name} ne zaznava zvoka", + "is_no_vibration": "{entity_name} ne zazna vibracij", + "is_not_bat_low": "{entity_name} baterija je polna", + "is_not_cold": "{entity_name} ni hladen", + "is_not_connected": "{entity_name} ni povezan", + "is_not_hot": "{entity_name} ni vro\u010d", + "is_not_locked": "{entity_name} je odklenjen", + "is_not_moist": "{entity_name} je suh", + "is_not_moving": "{entity_name} se ne premika", + "is_not_occupied": "{entity_name} ni zaseden", + "is_not_open": "{entity_name} je zaprt", + "is_not_plugged_in": "{entity_name} je odklopljen", + "is_not_powered": "{entity_name} ni napajan", + "is_not_present": "{entity_name} ni prisoten", + "is_not_unsafe": "{entity_name} je varen", + "is_occupied": "{entity_name} je zaseden", + "is_off": "{entity_name} je izklopljen", + "is_on": "{entity_name} je vklopljen", + "is_open": "{entity_name} je odprt", + "is_plugged_in": "{entity_name} je priklju\u010den", + "is_powered": "{entity_name} je vklopljen", + "is_present": "{entity_name} je prisoten", + "is_problem": "{entity_name} zaznava te\u017eavo", + "is_smoke": "{entity_name} zaznava dim", + "is_sound": "{entity_name} zaznava zvok", + "is_unsafe": "{entity_name} ni varen", + "is_vibration": "{entity_name} zaznava vibracije" + }, + "trigger_type": { + "bat_low": "{entity_name} ima prazno baterijo", + "closed": "{entity_name} zaprto", + "cold": "{entity_name} je postal hladen", + "connected": "{entity_name} povezan", + "gas": "{entity_name} za\u010del zaznavati plin", + "hot": "{entity_name} je postal vro\u010d", + "light": "{entity_name} za\u010del zaznavati svetlobo", + "locked": "{entity_name} zaklenjen", + "moist": "{entity_name} postal vla\u017een", + "moist\u00a7": "{entity_name} postal vla\u017een", + "motion": "{entity_name} za\u010del zaznavati gibanje", + "moving": "{entity_name} se je za\u010del premikati", + "no_gas": "{entity_name} prenehal zaznavati plin", + "no_light": "{entity_name} prenehal zaznavati svetlobo", + "no_motion": "{entity_name} prenehal zaznavati gibanje", + "no_problem": "{entity_name} prenehal odkrivati te\u017eavo", + "no_smoke": "{entity_name} prenehal zaznavati dim", + "no_sound": "{entity_name} prenehal zaznavati zvok", + "no_vibration": "{entity_name} prenehal zaznavati vibracije", + "not_bat_low": "{entity_name} ima polno baterijo", + "not_cold": "{entity_name} ni ve\u010d hladen", + "not_connected": "{entity_name} prekinjen", + "not_hot": "{entity_name} ni ve\u010d vro\u010d", + "not_locked": "{entity_name} odklenjen", + "not_moist": "{entity_name} je postalo suh", + "not_moving": "{entity_name} se je prenehal premikati", + "not_occupied": "{entity_name} ni zaseden", + "not_opened": "{entity_name} zaprto", + "not_plugged_in": "{entity_name} odklopljen", + "not_powered": "{entity_name} ni napajan", + "not_present": "{entity_name} ni prisoten", + "not_unsafe": "{entity_name} je postal varen", + "occupied": "{entity_name} postal zaseden", + "opened": "{entity_name} odprl", + "plugged_in": "{entity_name} priklju\u010den", + "powered": "{entity_name} priklopljen", + "present": "{entity_name} prisoten", + "problem": "{entity_name} za\u010del odkrivati te\u017eavo", + "smoke": "{entity_name} za\u010del zaznavati dim", + "sound": "{entity_name} za\u010del zaznavati zvok", + "turned_off": "{entity_name} izklopljen", + "turned_on": "{entity_name} vklopljen", + "unsafe": "{entity_name} je postal nevaren", + "vibration": "{entity_name} je za\u010del odkrivat vibracije" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/.translations/zh-Hant.json b/homeassistant/components/binary_sensor/.translations/zh-Hant.json new file mode 100644 index 000000000..046b999cb --- /dev/null +++ b/homeassistant/components/binary_sensor/.translations/zh-Hant.json @@ -0,0 +1,94 @@ +{ + "device_automation": { + "condition_type": { + "is_bat_low": "{entity_name} \u96fb\u91cf\u904e\u4f4e", + "is_cold": "{entity_name} \u51b7", + "is_connected": "{entity_name} \u5df2\u9023\u7dda", + "is_gas": "{entity_name} \u5075\u6e2c\u5230\u6c23\u9ad4", + "is_hot": "{entity_name} \u71b1", + "is_light": "{entity_name} \u5075\u6e2c\u5230\u5149\u7dda\u4e2d", + "is_locked": "{entity_name} \u5df2\u4e0a\u9396", + "is_moist": "{entity_name} \u6f6e\u6fd5", + "is_motion": "{entity_name} \u5075\u6e2c\u5230\u52d5\u4f5c\u4e2d", + "is_moving": "{entity_name} \u79fb\u52d5\u4e2d", + "is_no_gas": "{entity_name} \u672a\u5075\u6e2c\u5230\u6c23\u9ad4", + "is_no_light": "{entity_name} \u672a\u5075\u6e2c\u5230\u5149\u7dda", + "is_no_motion": "{entity_name} \u672a\u5075\u6e2c\u5230\u52d5\u4f5c", + "is_no_problem": "{entity_name} \u672a\u5075\u6e2c\u5230\u554f\u984c", + "is_no_smoke": "{entity_name} \u672a\u5075\u6e2c\u5230\u7159\u9727", + "is_no_sound": "{entity_name} \u672a\u5075\u6e2c\u5230\u8072\u97f3", + "is_no_vibration": "{entity_name} \u672a\u5075\u6e2c\u5230\u9707\u52d5", + "is_not_bat_low": "{entity_name} \u96fb\u91cf\u6b63\u5e38", + "is_not_cold": "{entity_name} \u4e0d\u51b7", + "is_not_connected": "{entity_name} \u65b7\u7dda", + "is_not_hot": "{entity_name} \u4e0d\u71b1", + "is_not_locked": "{entity_name} \u89e3\u9396", + "is_not_moist": "{entity_name} \u4e7e\u71e5", + "is_not_moving": "{entity_name} \u672a\u5728\u79fb\u52d5", + "is_not_occupied": "{entity_name} \u672a\u6709\u4eba", + "is_not_open": "{entity_name} \u95dc\u9589", + "is_not_plugged_in": "{entity_name} \u672a\u63d2\u5165", + "is_not_powered": "{entity_name} \u672a\u901a\u96fb", + "is_not_present": "{entity_name} \u672a\u51fa\u73fe", + "is_not_unsafe": "{entity_name} \u5b89\u5168", + "is_occupied": "{entity_name} \u6709\u4eba", + "is_off": "{entity_name} \u95dc\u9589", + "is_on": "{entity_name} \u958b\u555f", + "is_open": "{entity_name} \u958b\u555f", + "is_plugged_in": "{entity_name} \u63d2\u5165", + "is_powered": "{entity_name} \u901a\u96fb", + "is_present": "{entity_name} \u51fa\u73fe", + "is_problem": "{entity_name} \u6b63\u5075\u6e2c\u5230\u554f\u984c", + "is_smoke": "{entity_name} \u6b63\u5075\u6e2c\u5230\u7159\u9727", + "is_sound": "{entity_name} \u6b63\u5075\u6e2c\u5230\u8072\u97f3", + "is_unsafe": "{entity_name} \u4e0d\u5b89\u5168", + "is_vibration": "{entity_name} \u6b63\u5075\u6e2c\u5230\u9707\u52d5" + }, + "trigger_type": { + "bat_low": "{entity_name} \u96fb\u91cf\u4f4e", + "closed": "{entity_name} \u5df2\u95dc\u9589", + "cold": "{entity_name} \u5df2\u8b8a\u51b7", + "connected": "{entity_name} \u5df2\u9023\u7dda", + "gas": "{entity_name} \u5df2\u958b\u59cb\u5075\u6e2c\u6c23\u9ad4", + "hot": "{entity_name} \u5df2\u8b8a\u71b1", + "light": "{entity_name} \u5df2\u958b\u59cb\u5075\u6e2c\u5149\u7dda", + "locked": "{entity_name} \u5df2\u4e0a\u9396", + "moist": "{entity_name} \u5df2\u8b8a\u6f6e\u6fd5", + "moist\u00a7": "{entity_name} \u5df2\u8b8a\u6f6e\u6fd5", + "motion": "{entity_name} \u5df2\u5075\u6e2c\u5230\u52d5\u4f5c", + "moving": "{entity_name} \u958b\u59cb\u79fb\u52d5", + "no_gas": "{entity_name} \u5df2\u505c\u6b62\u5075\u6e2c\u6c23\u9ad4", + "no_light": "{entity_name} \u5df2\u505c\u6b62\u5075\u6e2c\u5149\u7dda", + "no_motion": "{entity_name} \u5df2\u505c\u6b62\u5075\u6e2c\u52d5\u4f5c", + "no_problem": "{entity_name} \u5df2\u505c\u6b62\u5075\u6e2c\u554f\u984c", + "no_smoke": "{entity_name} \u5df2\u505c\u6b62\u5075\u6e2c\u7159\u9727", + "no_sound": "{entity_name} \u5df2\u505c\u6b62\u5075\u6e2c\u8072\u97f3", + "no_vibration": "{entity_name} \u5df2\u505c\u6b62\u5075\u6e2c\u9707\u52d5", + "not_bat_low": "{entity_name} \u96fb\u91cf\u6b63\u5e38", + "not_cold": "{entity_name} \u5df2\u4e0d\u51b7", + "not_connected": "{entity_name} \u5df2\u65b7\u7dda", + "not_hot": "{entity_name} \u5df2\u4e0d\u71b1", + "not_locked": "{entity_name} \u5df2\u89e3\u9396", + "not_moist": "{entity_name} \u5df2\u8b8a\u4e7e", + "not_moving": "{entity_name} \u505c\u6b62\u79fb\u52d5", + "not_occupied": "{entity_name} \u672a\u6709\u4eba", + "not_opened": "{entity_name} \u5df2\u95dc\u9589", + "not_plugged_in": "{entity_name} \u672a\u63d2\u5165", + "not_powered": "{entity_name} \u672a\u901a\u96fb", + "not_present": "{entity_name} \u672a\u51fa\u73fe", + "not_unsafe": "{entity_name} \u5df2\u5b89\u5168", + "occupied": "{entity_name} \u8b8a\u6210\u6709\u4eba", + "opened": "{entity_name} \u5df2\u958b\u555f", + "plugged_in": "{entity_name} \u5df2\u63d2\u5165", + "powered": "{entity_name} \u5df2\u901a\u96fb", + "present": "{entity_name} \u5df2\u51fa\u73fe", + "problem": "{entity_name} \u5df2\u5075\u6e2c\u5230\u554f\u984c", + "smoke": "{entity_name} \u5df2\u5075\u6e2c\u5230\u7159\u9727", + "sound": "{entity_name} \u5df2\u5075\u6e2c\u5230\u8072\u97f3", + "turned_off": "{entity_name} \u5df2\u95dc\u9589", + "turned_on": "{entity_name} \u5df2\u958b\u555f", + "unsafe": "{entity_name} \u5df2\u4e0d\u5b89\u5168", + "vibration": "{entity_name} \u5df2\u5075\u6e2c\u5230\u9707\u52d5" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py index 7b2da21ff..73d5e0be4 100644 --- a/homeassistant/components/binary_sensor/__init__.py +++ b/homeassistant/components/binary_sensor/__init__.py @@ -1,48 +1,118 @@ -""" -Component to interface with binary sensors. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/binary_sensor/ -""" +"""Component to interface with binary sensors.""" from datetime import timedelta import logging import voluptuous as vol -from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.helpers.config_validation import ( # noqa: F401 + PLATFORM_SCHEMA, + PLATFORM_SCHEMA_BASE, +) from homeassistant.helpers.entity import Entity -from homeassistant.const import (STATE_ON, STATE_OFF) -from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa +from homeassistant.helpers.entity_component import EntityComponent -DOMAIN = 'binary_sensor' +# mypy: allow-untyped-defs, no-check-untyped-defs + +DOMAIN = "binary_sensor" SCAN_INTERVAL = timedelta(seconds=30) -ENTITY_ID_FORMAT = DOMAIN + '.{}' +ENTITY_ID_FORMAT = DOMAIN + ".{}" + +# On means low, Off means normal +DEVICE_CLASS_BATTERY = "battery" + +# On means cold, Off means normal +DEVICE_CLASS_COLD = "cold" + +# On means connected, Off means disconnected +DEVICE_CLASS_CONNECTIVITY = "connectivity" + +# On means open, Off means closed +DEVICE_CLASS_DOOR = "door" + +# On means open, Off means closed +DEVICE_CLASS_GARAGE_DOOR = "garage_door" + +# On means gas detected, Off means no gas (clear) +DEVICE_CLASS_GAS = "gas" + +# On means hot, Off means normal +DEVICE_CLASS_HEAT = "heat" + +# On means light detected, Off means no light +DEVICE_CLASS_LIGHT = "light" + +# On means open (unlocked), Off means closed (locked) +DEVICE_CLASS_LOCK = "lock" + +# On means wet, Off means dry +DEVICE_CLASS_MOISTURE = "moisture" + +# On means motion detected, Off means no motion (clear) +DEVICE_CLASS_MOTION = "motion" + +# On means moving, Off means not moving (stopped) +DEVICE_CLASS_MOVING = "moving" + +# On means occupied, Off means not occupied (clear) +DEVICE_CLASS_OCCUPANCY = "occupancy" + +# On means open, Off means closed +DEVICE_CLASS_OPENING = "opening" + +# On means plugged in, Off means unplugged +DEVICE_CLASS_PLUG = "plug" + +# On means power detected, Off means no power +DEVICE_CLASS_POWER = "power" + +# On means home, Off means away +DEVICE_CLASS_PRESENCE = "presence" + +# On means problem detected, Off means no problem (OK) +DEVICE_CLASS_PROBLEM = "problem" + +# On means unsafe, Off means safe +DEVICE_CLASS_SAFETY = "safety" + +# On means smoke detected, Off means no smoke (clear) +DEVICE_CLASS_SMOKE = "smoke" + +# On means sound detected, Off means no sound (clear) +DEVICE_CLASS_SOUND = "sound" + +# On means vibration detected, Off means no vibration +DEVICE_CLASS_VIBRATION = "vibration" + +# On means open, Off means closed +DEVICE_CLASS_WINDOW = "window" + DEVICE_CLASSES = [ - '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_CLASS_BATTERY, + DEVICE_CLASS_COLD, + DEVICE_CLASS_CONNECTIVITY, + DEVICE_CLASS_DOOR, + DEVICE_CLASS_GARAGE_DOOR, + DEVICE_CLASS_GAS, + DEVICE_CLASS_HEAT, + DEVICE_CLASS_LIGHT, + DEVICE_CLASS_LOCK, + DEVICE_CLASS_MOISTURE, + DEVICE_CLASS_MOTION, + DEVICE_CLASS_MOVING, + DEVICE_CLASS_OCCUPANCY, + DEVICE_CLASS_OPENING, + DEVICE_CLASS_PLUG, + DEVICE_CLASS_POWER, + DEVICE_CLASS_PRESENCE, + DEVICE_CLASS_PROBLEM, + DEVICE_CLASS_SAFETY, + DEVICE_CLASS_SMOKE, + DEVICE_CLASS_SOUND, + DEVICE_CLASS_VIBRATION, + DEVICE_CLASS_WINDOW, ] DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.In(DEVICE_CLASSES)) @@ -51,7 +121,8 @@ DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.In(DEVICE_CLASSES)) async def async_setup(hass, config): """Track states and offer events for binary sensors.""" component = hass.data[DOMAIN] = EntityComponent( - logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL) + logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL + ) await component.async_setup(config) return True diff --git a/homeassistant/components/binary_sensor/abode.py b/homeassistant/components/binary_sensor/abode.py deleted file mode 100644 index a821abf44..000000000 --- a/homeassistant/components/binary_sensor/abode.py +++ /dev/null @@ -1,74 +0,0 @@ -""" -This component provides HA binary_sensor support for Abode Security System. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.abode/ -""" -import logging - -from homeassistant.components.abode import (AbodeDevice, AbodeAutomation, - DOMAIN as ABODE_DOMAIN) -from homeassistant.components.binary_sensor import BinarySensorDevice - - -DEPENDENCIES = ['abode'] - -_LOGGER = logging.getLogger(__name__) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up a sensor for an Abode device.""" - import abodepy.helpers.constants as CONST - import abodepy.helpers.timeline as TIMELINE - - data = hass.data[ABODE_DOMAIN] - - device_types = [CONST.TYPE_CONNECTIVITY, CONST.TYPE_MOISTURE, - CONST.TYPE_MOTION, CONST.TYPE_OCCUPANCY, - CONST.TYPE_OPENING] - - devices = [] - for device in data.abode.get_devices(generic_type=device_types): - if data.is_excluded(device): - continue - - devices.append(AbodeBinarySensor(data, device)) - - 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)) - - data.devices.extend(devices) - - add_entities(devices) - - -class AbodeBinarySensor(AbodeDevice, BinarySensorDevice): - """A binary sensor implementation for Abode device.""" - - @property - def is_on(self): - """Return True if the binary sensor is on.""" - return self._device.is_on - - @property - def device_class(self): - """Return the class of the binary sensor.""" - 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 deleted file mode 100644 index d46ff5ec2..000000000 --- a/homeassistant/components/binary_sensor/ads.py +++ /dev/null @@ -1,84 +0,0 @@ -""" -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_entities, 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_entities([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 deleted file mode 100644 index 82bcc5025..000000000 --- a/homeassistant/components/binary_sensor/alarmdecoder.py +++ /dev/null @@ -1,142 +0,0 @@ -""" -Support for AlarmDecoder 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.alarmdecoder/ -""" -import asyncio -import logging - -from homeassistant.components.binary_sensor import BinarySensorDevice -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, SIGNAL_REL_MESSAGE, CONF_RELAY_ADDR, - CONF_RELAY_CHAN) - -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' - - -def setup_platform(hass, config, add_entities, 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) - relay_addr = device_config_data.get(CONF_RELAY_ADDR) - relay_chan = device_config_data.get(CONF_RELAY_CHAN) - device = AlarmDecoderBinarySensor( - zone_num, zone_name, zone_type, zone_rfid, relay_addr, relay_chan) - devices.append(device) - - add_entities(devices) - - return True - - -class AlarmDecoderBinarySensor(BinarySensorDevice): - """Representation of an AlarmDecoder binary sensor.""" - - def __init__(self, zone_number, zone_name, zone_type, zone_rfid, - relay_addr, relay_chan): - """Initialize the binary_sensor.""" - self._zone_number = zone_number - self._zone_type = zone_type - self._state = None - self._name = zone_name - self._rfid = zone_rfid - self._rfstate = None - self._relay_addr = relay_addr - self._relay_chan = relay_chan - - @asyncio.coroutine - def async_added_to_hass(self): - """Register callbacks.""" - 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) - - self.hass.helpers.dispatcher.async_dispatcher_connect( - SIGNAL_RFX_MESSAGE, self._rfx_message_callback) - - self.hass.helpers.dispatcher.async_dispatcher_connect( - SIGNAL_REL_MESSAGE, self._rel_message_callback) - - @property - def name(self): - """Return the name of the entity.""" - return self._name - - @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.""" - return self._state == 1 - - @property - def device_class(self): - """Return the class of this sensor, from DEVICE_CLASSES.""" - return self._zone_type - - 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.schedule_update_ha_state() - - 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.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() - - def _rel_message_callback(self, message): - """Update relay state.""" - if (self._relay_addr == message.address and - self._relay_chan == message.channel): - _LOGGER.debug("Relay %d:%d value:%d", message.address, - message.channel, message.value) - self._state = message.value - self.schedule_update_ha_state() diff --git a/homeassistant/components/binary_sensor/android_ip_webcam.py b/homeassistant/components/binary_sensor/android_ip_webcam.py deleted file mode 100644 index 58de81c30..000000000 --- a/homeassistant/components/binary_sensor/android_ip_webcam.py +++ /dev/null @@ -1,63 +0,0 @@ -""" -Support for IP Webcam binary sensors. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.android_ip_webcam/ -""" -import asyncio - -from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.components.android_ip_webcam import ( - KEY_MAP, DATA_IP_WEBCAM, AndroidIPCamEntity, CONF_HOST, CONF_NAME) - -DEPENDENCIES = ['android_ip_webcam'] - - -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): - """Set up the IP Webcam binary sensors.""" - if discovery_info is None: - return - - host = discovery_info[CONF_HOST] - name = discovery_info[CONF_NAME] - ipcam = hass.data[DATA_IP_WEBCAM][host] - - async_add_entities( - [IPWebcamBinarySensor(name, host, ipcam, 'motion_active')], True) - - -class IPWebcamBinarySensor(AndroidIPCamEntity, BinarySensorDevice): - """Representation of an IP Webcam binary sensor.""" - - def __init__(self, name, host, ipcam, sensor): - """Initialize the binary sensor.""" - super().__init__(host, ipcam) - - self._sensor = sensor - self._mapped_name = KEY_MAP.get(self._sensor, self._sensor) - self._name = '{} {}'.format(name, self._mapped_name) - self._state = None - self._unit = None - - @property - def name(self): - """Return the name of the binary sensor, if any.""" - return self._name - - @property - def is_on(self): - """Return true if the binary sensor is on.""" - return self._state - - @asyncio.coroutine - def async_update(self): - """Retrieve latest state.""" - state, _ = self._ipcam.export_sensor(self._sensor) - self._state = state == 1.0 - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return 'motion' diff --git a/homeassistant/components/binary_sensor/apcupsd.py b/homeassistant/components/binary_sensor/apcupsd.py deleted file mode 100644 index f876b8cc3..000000000 --- a/homeassistant/components/binary_sensor/apcupsd.py +++ /dev/null @@ -1,49 +0,0 @@ -""" -Support for tracking the online status of a UPS. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.apcupsd/ -""" -import voluptuous as vol - -from homeassistant.components.binary_sensor import ( - BinarySensorDevice, PLATFORM_SCHEMA) -from homeassistant.const import CONF_NAME -import homeassistant.helpers.config_validation as cv -from homeassistant.components import apcupsd - -DEFAULT_NAME = 'UPS Online Status' -DEPENDENCIES = [apcupsd.DOMAIN] - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up an APCUPSd Online Status binary sensor.""" - add_entities([OnlineStatus(config, apcupsd.DATA)], True) - - -class OnlineStatus(BinarySensorDevice): - """Representation of an UPS online status.""" - - def __init__(self, config, data): - """Initialize the APCUPSd binary device.""" - self._config = config - self._data = data - self._state = None - - @property - def name(self): - """Return the name of the UPS online status sensor.""" - return self._config.get(CONF_NAME) - - @property - def is_on(self): - """Return true if the UPS is online, else false.""" - return self._state == apcupsd.VALUE_ONLINE - - def update(self): - """Get the status report from APCUPSd and set this entity's state.""" - self._state = self._data.status[apcupsd.KEY_STATUS] diff --git a/homeassistant/components/binary_sensor/arest.py b/homeassistant/components/binary_sensor/arest.py deleted file mode 100644 index b70620df3..000000000 --- a/homeassistant/components/binary_sensor/arest.py +++ /dev/null @@ -1,109 +0,0 @@ -""" -Support for an exposed aREST RESTful API of a device. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.arest/ -""" -import logging -from datetime import timedelta - -import requests -import voluptuous as vol - -from homeassistant.components.binary_sensor import ( - BinarySensorDevice, PLATFORM_SCHEMA, DEVICE_CLASSES_SCHEMA) -from homeassistant.const import ( - CONF_RESOURCE, CONF_PIN, CONF_NAME, CONF_DEVICE_CLASS) -from homeassistant.util import Throttle -import homeassistant.helpers.config_validation as cv - -_LOGGER = logging.getLogger(__name__) - -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_RESOURCE): cv.url, - vol.Optional(CONF_NAME): cv.string, - vol.Required(CONF_PIN): cv.string, - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the aREST binary sensor.""" - resource = config.get(CONF_RESOURCE) - pin = config.get(CONF_PIN) - device_class = config.get(CONF_DEVICE_CLASS) - - try: - response = requests.get(resource, timeout=10).json() - except requests.exceptions.MissingSchema: - _LOGGER.error("Missing resource or schema in configuration. " - "Add http:// to your URL") - return False - except requests.exceptions.ConnectionError: - _LOGGER.error("No route to device at %s", resource) - return False - - arest = ArestData(resource, pin) - - add_entities([ArestBinarySensor( - arest, resource, config.get(CONF_NAME, response[CONF_NAME]), - device_class, pin)], True) - - -class ArestBinarySensor(BinarySensorDevice): - """Implement an aREST binary sensor for a pin.""" - - def __init__(self, arest, resource, name, device_class, pin): - """Initialize the aREST device.""" - self.arest = arest - self._resource = resource - self._name = name - self._device_class = device_class - self._pin = pin - - if self._pin is not None: - request = requests.get( - '{}/mode/{}/i'.format(self._resource, self._pin), timeout=10) - if request.status_code != 200: - _LOGGER.error("Can't set mode of %s", self._resource) - - @property - def name(self): - """Return the name of the binary sensor.""" - return self._name - - @property - def is_on(self): - """Return true if the binary sensor is on.""" - return bool(self.arest.data.get('state')) - - @property - def device_class(self): - """Return the class of this sensor.""" - return self._device_class - - def update(self): - """Get the latest data from aREST API.""" - self.arest.update() - - -class ArestData: - """Class for handling the data retrieval for pins.""" - - def __init__(self, resource, pin): - """Initialize the aREST data object.""" - self._resource = resource - self._pin = pin - self.data = {} - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): - """Get the latest data from aREST device.""" - try: - response = requests.get('{}/digital/{}'.format( - self._resource, self._pin), timeout=10) - self.data = {'state': response.json()['return_value']} - except requests.exceptions.ConnectionError: - _LOGGER.error("No route to device '%s'", self._resource) diff --git a/homeassistant/components/binary_sensor/august.py b/homeassistant/components/binary_sensor/august.py deleted file mode 100644 index 7f5da3909..000000000 --- a/homeassistant/components/binary_sensor/august.py +++ /dev/null @@ -1,97 +0,0 @@ -""" -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_entities, 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_entities(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 deleted file mode 100644 index 04b402722..000000000 --- a/homeassistant/components/binary_sensor/aurora.py +++ /dev/null @@ -1,149 +0,0 @@ -""" -Support for aurora forecast data sensor. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.aurora/ -""" -from datetime import timedelta -import logging - -from aiohttp.hdrs import USER_AGENT -import requests -import voluptuous as vol - -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 - -_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_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, -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the aurora sensor.""" - if None in (hass.config.latitude, hass.config.longitude): - _LOGGER.error("Lat. or long. not set in Home Assistant config") - return False - - name = config.get(CONF_NAME) - threshold = config.get(CONF_THRESHOLD) - - try: - aurora_data = AuroraData( - hass.config.latitude, hass.config.longitude, threshold) - aurora_data.update() - except requests.exceptions.HTTPError as error: - _LOGGER.error( - "Connection to aurora forecast service failed: %s", error) - return False - - add_entities([AuroraSensor(aurora_data, name)], True) - - -class AuroraSensor(BinarySensorDevice): - """Implementation of an aurora sensor.""" - - def __init__(self, aurora_data, name): - """Initialize the sensor.""" - self.aurora_data = aurora_data - self._name = name - - @property - def name(self): - """Return the name of the sensor.""" - return '{}'.format(self._name) - - @property - def is_on(self): - """Return true if aurora is visible.""" - return self.aurora_data.is_visible if self.aurora_data else False - - @property - def device_class(self): - """Return the class of this device.""" - return DEFAULT_DEVICE_CLASS - - @property - def device_state_attributes(self): - """Return the state attributes.""" - attrs = {} - - if self.aurora_data: - 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): - """Get the latest data from Aurora API and updates the states.""" - self.aurora_data.update() - - -class AuroraData: - """Get aurora forecast.""" - - def __init__(self, latitude, longitude, threshold): - """Initialize the data object.""" - self.latitude = latitude - self.longitude = longitude - self.number_of_latitude_intervals = 513 - self.number_of_longitude_intervals = 1024 - self.headers = {USER_AGENT: HA_USER_AGENT} - self.threshold = int(threshold) - self.is_visible = None - self.is_visible_text = None - self.visibility_level = None - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): - """Get the latest data from the Aurora service.""" - try: - self.visibility_level = self.get_aurora_forecast() - if int(self.visibility_level) > self.threshold: - self.is_visible = True - self.is_visible_text = "visible!" - else: - self.is_visible = False - self.is_visible_text = "nothing's out" - - except requests.exceptions.HTTPError as error: - _LOGGER.error( - "Connection to aurora forecast service failed: %s", error) - return False - - def get_aurora_forecast(self): - """Get forecast data and parse for given long/lat.""" - 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 - converted_latitude = round((self.latitude / 180) - * self.number_of_latitude_intervals) - converted_longitude = round((self.longitude / 360) - * self.number_of_longitude_intervals) - - return forecast_table[converted_latitude][converted_longitude] diff --git a/homeassistant/components/binary_sensor/axis.py b/homeassistant/components/binary_sensor/axis.py deleted file mode 100644 index b66a766ca..000000000 --- a/homeassistant/components/binary_sensor/axis.py +++ /dev/null @@ -1,67 +0,0 @@ -""" -Support for Axis binary sensors. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.axis/ -""" -from datetime import timedelta -import logging - -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 - -DEPENDENCIES = ['axis'] - -_LOGGER = logging.getLogger(__name__) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Axis binary devices.""" - add_entities([AxisBinarySensor(hass, discovery_info)], True) - - -class AxisBinarySensor(AxisDeviceEvent, BinarySensorDevice): - """Representation of a binary Axis event.""" - - def __init__(self, hass, event_config): - """Initialize the Axis binary sensor.""" - self.hass = hass - self._state = False - self._delay = event_config[CONF_TRIGGER_TIME] - self._timer = None - AxisDeviceEvent.__init__(self, event_config) - - @property - def is_on(self): - """Return true if event is active.""" - return self._state - - def update(self): - """Get the latest data and update the state.""" - self._state = self.axis_event.is_tripped - - def _update_callback(self): - """Update the sensor's state, if needed.""" - self.update() - - if self._timer is not None: - self._timer() - self._timer = None - - if self._delay > 0 and not self.is_on: - # Set timer to wait until updating the state - def _delay_update(now): - """Timer callback for sensor update.""" - _LOGGER.debug("%s called delayed (%s sec) update", - self._name, self._delay) - self.schedule_update_ha_state() - self._timer = None - - self._timer = track_point_in_utc_time( - self.hass, _delay_update, - utcnow() + timedelta(seconds=self._delay)) - else: - self.schedule_update_ha_state() diff --git a/homeassistant/components/binary_sensor/bayesian.py b/homeassistant/components/binary_sensor/bayesian.py deleted file mode 100644 index 88669d67d..000000000 --- a/homeassistant/components/binary_sensor/bayesian.py +++ /dev/null @@ -1,220 +0,0 @@ -""" -Use Bayesian Inference to trigger a binary sensor. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.bayesian/ -""" -import asyncio -import logging -from collections import OrderedDict - -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_ABOVE, CONF_BELOW, CONF_DEVICE_CLASS, CONF_ENTITY_ID, CONF_NAME, - CONF_PLATFORM, CONF_STATE, STATE_UNKNOWN) -from homeassistant.core import callback -from homeassistant.helpers import condition -from homeassistant.helpers.event import async_track_state_change - -_LOGGER = logging.getLogger(__name__) - -ATTR_OBSERVATIONS = 'observations' -ATTR_PROBABILITY = 'probability' -ATTR_PROBABILITY_THRESHOLD = 'probability_threshold' - -CONF_OBSERVATIONS = 'observations' -CONF_PRIOR = 'prior' -CONF_PROBABILITY_THRESHOLD = 'probability_threshold' -CONF_P_GIVEN_F = 'prob_given_false' -CONF_P_GIVEN_T = 'prob_given_true' -CONF_TO_STATE = 'to_state' - -DEFAULT_NAME = "Bayesian Binary Sensor" -DEFAULT_PROBABILITY_THRESHOLD = 0.5 - -NUMERIC_STATE_SCHEMA = vol.Schema({ - CONF_PLATFORM: 'numeric_state', - vol.Required(CONF_ENTITY_ID): cv.entity_id, - vol.Optional(CONF_ABOVE): vol.Coerce(float), - vol.Optional(CONF_BELOW): vol.Coerce(float), - vol.Required(CONF_P_GIVEN_T): vol.Coerce(float), - vol.Optional(CONF_P_GIVEN_F): vol.Coerce(float) -}, required=True) - -STATE_SCHEMA = vol.Schema({ - CONF_PLATFORM: CONF_STATE, - vol.Required(CONF_ENTITY_ID): cv.entity_id, - vol.Required(CONF_TO_STATE): cv.string, - vol.Required(CONF_P_GIVEN_T): vol.Coerce(float), - vol.Optional(CONF_P_GIVEN_F): vol.Coerce(float) -}, required=True) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - 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_PRIOR): vol.Coerce(float), - vol.Optional(CONF_PROBABILITY_THRESHOLD, - default=DEFAULT_PROBABILITY_THRESHOLD): vol.Coerce(float), -}) - - -def update_probability(prior, prob_true, prob_false): - """Update probability using Bayes' rule.""" - numerator = prob_true * prior - denominator = numerator + prob_false * (1 - prior) - - probability = numerator / denominator - return probability - - -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): - """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) - device_class = config.get(CONF_DEVICE_CLASS) - - async_add_entities([ - BayesianBinarySensor( - name, prior, observations, probability_threshold, device_class) - ], True) - - -class BayesianBinarySensor(BinarySensorDevice): - """Representation of a Bayesian sensor.""" - - def __init__(self, name, prior, observations, probability_threshold, - device_class): - """Initialize the Bayesian sensor.""" - self._name = name - self._observations = observations - self._probability_threshold = probability_threshold - self._device_class = device_class - self._deviation = False - self.prior = prior - self.probability = prior - - self.current_obs = OrderedDict({}) - - 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, - 'state': self._process_state - } - - @asyncio.coroutine - def async_added_to_hass(self): - """Call when entity about to be added.""" - @callback - def async_threshold_sensor_state_listener(entity, old_state, - new_state): - """Handle sensor state changes.""" - if new_state.state == STATE_UNKNOWN: - return - - entity_obs_list = self.entity_obs[entity] - - for entity_obs in entity_obs_list: - platform = entity_obs['platform'] - - self.watchers[platform](entity_obs) - - prior = self.prior - for obs in self.current_obs.values(): - prior = update_probability( - prior, obs['prob_true'], obs['prob_false']) - self.probability = prior - - self.hass.async_add_job(self.async_update_ha_state, True) - - entities = [obs['entity_id'] for obs in self._observations] - async_track_state_change( - self.hass, entities, async_threshold_sensor_state_listener) - - def _update_current_obs(self, entity_observation, should_trigger): - """Update current observation.""" - 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[obs_id] = { - 'prob_true': prob_true, - 'prob_false': prob_false - } - - else: - 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.""" - entity = entity_observation['entity_id'] - - should_trigger = condition.async_numeric_state( - self.hass, entity, - entity_observation.get('below'), - entity_observation.get('above'), None, entity_observation) - - self._update_current_obs(entity_observation, should_trigger) - - def _process_state(self, entity_observation): - """Add entity to current observations if state conditions are met.""" - entity = entity_observation['entity_id'] - - should_trigger = condition.state( - self.hass, entity, entity_observation.get('to_state')) - - self._update_current_obs(entity_observation, should_trigger) - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def is_on(self): - """Return true if sensor is on.""" - return self._deviation - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def 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_OBSERVATIONS: [val for val in self.current_obs.values()], - ATTR_PROBABILITY: round(self.probability, 2), - ATTR_PROBABILITY_THRESHOLD: self._probability_threshold, - } - - @asyncio.coroutine - def async_update(self): - """Get the latest data and update the states.""" - self._deviation = bool(self.probability >= self._probability_threshold) diff --git a/homeassistant/components/binary_sensor/bbb_gpio.py b/homeassistant/components/binary_sensor/bbb_gpio.py deleted file mode 100644 index 8968b6803..000000000 --- a/homeassistant/components/binary_sensor/bbb_gpio.py +++ /dev/null @@ -1,89 +0,0 @@ -""" -Support for binary sensor using Beaglebone Black GPIO. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.bbb_gpio/ -""" -import logging - -import voluptuous as vol - -from homeassistant.components import bbb_gpio -from homeassistant.components.binary_sensor import ( - BinarySensorDevice, PLATFORM_SCHEMA) -from homeassistant.const import (DEVICE_DEFAULT_NAME, CONF_NAME) -import homeassistant.helpers.config_validation as cv - -_LOGGER = logging.getLogger(__name__) - -DEPENDENCIES = ['bbb_gpio'] - -CONF_PINS = 'pins' -CONF_BOUNCETIME = 'bouncetime' -CONF_INVERT_LOGIC = 'invert_logic' -CONF_PULL_MODE = 'pull_mode' - -DEFAULT_BOUNCETIME = 50 -DEFAULT_INVERT_LOGIC = False -DEFAULT_PULL_MODE = 'UP' - -PIN_SCHEMA = vol.Schema({ - vol.Required(CONF_NAME): cv.string, - vol.Optional(CONF_BOUNCETIME, default=DEFAULT_BOUNCETIME): cv.positive_int, - vol.Optional(CONF_INVERT_LOGIC, default=DEFAULT_INVERT_LOGIC): cv.boolean, - vol.Optional(CONF_PULL_MODE, default=DEFAULT_PULL_MODE): - vol.In(['UP', 'DOWN']) -}) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_PINS, default={}): - vol.Schema({cv.string: PIN_SCHEMA}), -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Beaglebone Black GPIO devices.""" - pins = config.get(CONF_PINS) - - binary_sensors = [] - - for pin, params in pins.items(): - binary_sensors.append(BBBGPIOBinarySensor(pin, params)) - add_entities(binary_sensors) - - -class BBBGPIOBinarySensor(BinarySensorDevice): - """Representation of a binary sensor that uses Beaglebone Black GPIO.""" - - def __init__(self, pin, params): - """Initialize the Beaglebone Black binary sensor.""" - self._pin = pin - self._name = params.get(CONF_NAME) or DEVICE_DEFAULT_NAME - self._bouncetime = params.get(CONF_BOUNCETIME) - self._pull_mode = params.get(CONF_PULL_MODE) - self._invert_logic = params.get(CONF_INVERT_LOGIC) - - bbb_gpio.setup_input(self._pin, self._pull_mode) - self._state = bbb_gpio.read_input(self._pin) - - def read_gpio(pin): - """Read state from GPIO.""" - self._state = bbb_gpio.read_input(self._pin) - self.schedule_update_ha_state() - - bbb_gpio.edge_detect(self._pin, read_gpio, self._bouncetime) - - @property - def should_poll(self): - """No polling needed.""" - return False - - @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 != self._invert_logic diff --git a/homeassistant/components/binary_sensor/blink.py b/homeassistant/components/binary_sensor/blink.py deleted file mode 100644 index 6ade20b72..000000000 --- a/homeassistant/components/binary_sensor/blink.py +++ /dev/null @@ -1,74 +0,0 @@ -""" -Support for Blink system camera control. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.blink/ -""" -from homeassistant.components.blink import DOMAIN -from homeassistant.components.binary_sensor import BinarySensorDevice - -DEPENDENCIES = ['blink'] - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the blink binary sensors.""" - if discovery_info is None: - return - - data = hass.data[DOMAIN].blink - devs = list() - for name in data.cameras: - devs.append(BlinkCameraMotionSensor(name, data)) - devs.append(BlinkSystemSensor(data)) - add_entities(devs, True) - - -class BlinkCameraMotionSensor(BinarySensorDevice): - """Representation of a Blink binary sensor.""" - - def __init__(self, name, data): - """Initialize the sensor.""" - self._name = 'blink_' + name + '_motion_enabled' - self._camera_name = name - self.data = data - self._state = self.data.cameras[self._camera_name].armed - - @property - def name(self): - """Return the name of the blink sensor.""" - return self._name - - @property - def is_on(self): - """Return the status of the sensor.""" - return self._state - - def update(self): - """Update sensor state.""" - self.data.refresh() - self._state = self.data.cameras[self._camera_name].armed - - -class BlinkSystemSensor(BinarySensorDevice): - """A representation of a Blink system sensor.""" - - def __init__(self, data): - """Initialize the sensor.""" - self._name = 'blink armed status' - self.data = data - self._state = self.data.arm - - @property - def name(self): - """Return the name of the blink sensor.""" - return self._name.replace(" ", "_") - - @property - def is_on(self): - """Return the status of the sensor.""" - return self._state - - def update(self): - """Update sensor state.""" - self.data.refresh() - self._state = self.data.arm diff --git a/homeassistant/components/binary_sensor/bloomsky.py b/homeassistant/components/binary_sensor/bloomsky.py deleted file mode 100644 index ecffb3acc..000000000 --- a/homeassistant/components/binary_sensor/bloomsky.py +++ /dev/null @@ -1,74 +0,0 @@ -""" -Support the binary sensors of a BloomSky weather station. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.bloomsky/ -""" -import logging - -import voluptuous as vol - -from homeassistant.components.binary_sensor import ( - BinarySensorDevice, PLATFORM_SCHEMA) -from homeassistant.const import CONF_MONITORED_CONDITIONS -import homeassistant.helpers.config_validation as cv - -_LOGGER = logging.getLogger(__name__) - -DEPENDENCIES = ['bloomsky'] - -SENSOR_TYPES = { - 'Rain': 'moisture', - 'Night': None, -} - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): - vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the available BloomSky weather binary sensors.""" - bloomsky = hass.components.bloomsky - # Default needed in case of discovery - sensors = config.get(CONF_MONITORED_CONDITIONS, SENSOR_TYPES) - - for device in bloomsky.BLOOMSKY.devices.values(): - for variable in sensors: - add_entities( - [BloomSkySensor(bloomsky.BLOOMSKY, device, variable)], True) - - -class BloomSkySensor(BinarySensorDevice): - """Representation of a single binary sensor in a BloomSky device.""" - - def __init__(self, bs, device, sensor_name): - """Initialize a BloomSky binary sensor.""" - self._bloomsky = bs - self._device_id = device['DeviceID'] - self._sensor_name = sensor_name - self._name = '{} {}'.format(device['DeviceName'], sensor_name) - self._state = None - - @property - def name(self): - """Return the name of the BloomSky device and this sensor.""" - return self._name - - @property - def device_class(self): - """Return the class of this sensor, from DEVICE_CLASSES.""" - return SENSOR_TYPES.get(self._sensor_name) - - @property - def is_on(self): - """Return true if binary sensor is on.""" - return self._state - - def update(self): - """Request an update from the BloomSky API.""" - self._bloomsky.refresh_devices() - - self._state = \ - self._bloomsky.devices[self._device_id]['Data'][self._sensor_name] diff --git a/homeassistant/components/binary_sensor/bmw_connected_drive.py b/homeassistant/components/binary_sensor/bmw_connected_drive.py deleted file mode 100644 index 36229828d..000000000 --- a/homeassistant/components/binary_sensor/bmw_connected_drive.py +++ /dev/null @@ -1,199 +0,0 @@ -""" -Reads vehicle status from BMW connected drive portal. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/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_entities, 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_entities(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: - """Return False. - - 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=protected-access - result['last_charging_end_result'] = \ - vehicle_state._attributes['lastChargingEndResult'] - if self._attribute == 'connection_status': - # pylint: disable=protected-access - 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=protected-access - 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/command_line.py b/homeassistant/components/binary_sensor/command_line.py deleted file mode 100644 index a3f159578..000000000 --- a/homeassistant/components/binary_sensor/command_line.py +++ /dev/null @@ -1,102 +0,0 @@ -""" -Support for custom shell commands to retrieve values. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.command_line/ -""" -import logging -from datetime import timedelta - -import voluptuous as vol - -import homeassistant.helpers.config_validation as cv -from homeassistant.components.binary_sensor import ( - BinarySensorDevice, DEVICE_CLASSES_SCHEMA, PLATFORM_SCHEMA) -from homeassistant.components.sensor.command_line import CommandSensorData -from homeassistant.const import ( - CONF_PAYLOAD_OFF, CONF_PAYLOAD_ON, CONF_NAME, CONF_VALUE_TEMPLATE, - CONF_COMMAND, CONF_DEVICE_CLASS) - -_LOGGER = logging.getLogger(__name__) - -DEFAULT_NAME = 'Binary Command Sensor' -DEFAULT_PAYLOAD_ON = 'ON' -DEFAULT_PAYLOAD_OFF = 'OFF' - -SCAN_INTERVAL = timedelta(seconds=60) - -CONF_COMMAND_TIMEOUT = 'command_timeout' -DEFAULT_TIMEOUT = 15 - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_COMMAND): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - 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_VALUE_TEMPLATE): cv.template, - vol.Optional( - CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Command line Binary Sensor.""" - name = config.get(CONF_NAME) - command = config.get(CONF_COMMAND) - payload_off = config.get(CONF_PAYLOAD_OFF) - payload_on = config.get(CONF_PAYLOAD_ON) - device_class = config.get(CONF_DEVICE_CLASS) - value_template = config.get(CONF_VALUE_TEMPLATE) - command_timeout = config.get(CONF_COMMAND_TIMEOUT) - if value_template is not None: - value_template.hass = hass - data = CommandSensorData(hass, command, command_timeout) - - add_entities([CommandBinarySensor( - hass, data, name, device_class, payload_on, payload_off, - value_template)], True) - - -class CommandBinarySensor(BinarySensorDevice): - """Representation of a command line binary sensor.""" - - def __init__(self, hass, data, name, device_class, payload_on, - payload_off, value_template): - """Initialize the Command line binary sensor.""" - self._hass = hass - self.data = data - self._name = name - self._device_class = device_class - self._state = False - self._payload_on = payload_on - self._payload_off = payload_off - self._value_template = value_template - - @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 - - def update(self): - """Get the latest data and updates the state.""" - self.data.update() - value = self.data.value - - if self._value_template is not None: - value = self._value_template.render_with_possible_json_value( - value, False) - if value == self._payload_on: - self._state = True - elif value == self._payload_off: - self._state = False diff --git a/homeassistant/components/binary_sensor/concord232.py b/homeassistant/components/binary_sensor/concord232.py deleted file mode 100644 index 26f35d603..000000000 --- a/homeassistant/components/binary_sensor/concord232.py +++ /dev/null @@ -1,141 +0,0 @@ -""" -Support for exposing Concord232 elements as sensors. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.concord232/ -""" -import datetime -import logging - -import requests -import voluptuous as vol - -from homeassistant.components.binary_sensor import ( - BinarySensorDevice, PLATFORM_SCHEMA, DEVICE_CLASSES) -from homeassistant.const import (CONF_HOST, CONF_PORT) -import homeassistant.helpers.config_validation as cv - -REQUIREMENTS = ['concord232==0.15'] - -_LOGGER = logging.getLogger(__name__) - -CONF_EXCLUDE_ZONES = 'exclude_zones' -CONF_ZONE_TYPES = 'zone_types' - -DEFAULT_HOST = 'localhost' -DEFAULT_NAME = 'Alarm' -DEFAULT_PORT = '5007' -DEFAULT_SSL = False - -SCAN_INTERVAL = datetime.timedelta(seconds=10) - -ZONE_TYPES_SCHEMA = vol.Schema({ - cv.positive_int: vol.In(DEVICE_CLASSES), -}) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_EXCLUDE_ZONES, default=[]): - vol.All(cv.ensure_list, [cv.positive_int]), - vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_ZONE_TYPES, default={}): ZONE_TYPES_SCHEMA, -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Concord232 binary sensor platform.""" - from concord232 import client as concord232_client - - host = config.get(CONF_HOST) - port = config.get(CONF_PORT) - exclude = config.get(CONF_EXCLUDE_ZONES) - zone_types = config.get(CONF_ZONE_TYPES) - sensors = [] - - try: - _LOGGER.debug("Initializing client") - client = concord232_client.Client('http://{}:{}'.format(host, port)) - client.zones = client.list_zones() - client.last_zone_update = datetime.datetime.now() - - except requests.exceptions.ConnectionError as ex: - _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: - sensors.append( - Concord232ZoneSensor( - hass, client, zone, zone_types.get( - zone['number'], get_opening_type(zone)) - ) - ) - - add_entities(sensors, True) - - -def get_opening_type(zone): - """Return the result of the type guessing from name.""" - if 'MOTION' in zone['name']: - return 'motion' - if 'KEY' in zone['name']: - return 'safety' - if 'SMOKE' in zone['name']: - return 'smoke' - if 'WATER' in zone['name']: - return 'water' - return 'opening' - - -class Concord232ZoneSensor(BinarySensorDevice): - """Representation of a Concord232 zone as a sensor.""" - - def __init__(self, hass, client, zone, zone_type): - """Initialize the Concord232 binary sensor.""" - self._hass = hass - self._client = client - self._zone = zone - self._number = zone['number'] - self._zone_type = zone_type - - @property - def device_class(self): - """Return the class of this sensor, from DEVICE_CLASSES.""" - return self._zone_type - - @property - def should_poll(self): - """No polling needed.""" - return True - - @property - def name(self): - """Return the name of the binary sensor.""" - return self._zone['name'] - - @property - 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') - - def update(self): - """Get updated stats from API.""" - last_update = datetime.datetime.now() - self._client.last_zone_update - _LOGGER.debug("Zone: %s ", self._zone) - if last_update > datetime.timedelta(seconds=1): - self._client.zones = self._client.list_zones() - self._client.last_zone_update = datetime.datetime.now() - _LOGGER.debug("Updated from zone: %s", self._zone['name']) - - if hasattr(self._client, 'zones'): - self._zone = next((x for x in self._client.zones - if x['number'] == self._number), None) diff --git a/homeassistant/components/binary_sensor/deconz.py b/homeassistant/components/binary_sensor/deconz.py deleted file mode 100644 index d2ca9e7c5..000000000 --- a/homeassistant/components/binary_sensor/deconz.py +++ /dev/null @@ -1,137 +0,0 @@ -""" -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.const import ( - ATTR_DARK, ATTR_ON, CONF_ALLOW_CLIP_SENSOR, DOMAIN as DATA_DECONZ, - DATA_DECONZ_ID, DATA_DECONZ_UNSUB, DECONZ_DOMAIN) -from homeassistant.const import ATTR_BATTERY_LEVEL -from homeassistant.core import callback -from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE -from homeassistant.helpers.dispatcher import async_dispatcher_connect - -DEPENDENCIES = ['deconz'] - - -async def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): - """Old way of setting up deCONZ binary sensors.""" - pass - - -async def async_setup_entry(hass, config_entry, async_add_entities): - """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 = [] - allow_clip_sensor = config_entry.data.get(CONF_ALLOW_CLIP_SENSOR, True) - for sensor in sensors: - if sensor.type in DECONZ_BINARY_SENSOR and \ - not (not allow_clip_sensor and sensor.type.startswith('CLIP')): - entities.append(DeconzBinarySensor(sensor)) - async_add_entities(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 - - async def async_will_remove_from_hass(self) -> None: - """Disconnect sensor object when removed.""" - self._sensor.remove_callback(self.async_update_callback) - self._sensor = None - - @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'] or \ - 'on' 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.on is not None: - attr[ATTR_ON] = self._sensor.on - if self._sensor.type in PRESENCE and self._sensor.dark is not None: - attr[ATTR_DARK] = self._sensor.dark - return attr - - @property - def device_info(self): - """Return a device description for device registry.""" - if (self._sensor.uniqueid is None or - self._sensor.uniqueid.count(':') != 7): - return None - serial = self._sensor.uniqueid.split('-', 1)[0] - return { - 'connections': {(CONNECTION_ZIGBEE, serial)}, - 'identifiers': {(DECONZ_DOMAIN, serial)}, - 'manufacturer': self._sensor.manufacturer, - 'model': self._sensor.modelid, - 'name': self._sensor.name, - 'sw_version': self._sensor.swversion, - } diff --git a/homeassistant/components/binary_sensor/demo.py b/homeassistant/components/binary_sensor/demo.py deleted file mode 100644 index d656b79e8..000000000 --- a/homeassistant/components/binary_sensor/demo.py +++ /dev/null @@ -1,45 +0,0 @@ -""" -Demo platform that has two fake binary sensors. - -For more details about this platform, please refer to the documentation -https://home-assistant.io/components/demo/ -""" -from homeassistant.components.binary_sensor import BinarySensorDevice - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Demo binary sensor platform.""" - add_entities([ - DemoBinarySensor('Basement Floor Wet', False, 'moisture'), - DemoBinarySensor('Movement Backyard', True, 'motion'), - ]) - - -class DemoBinarySensor(BinarySensorDevice): - """representation of a Demo binary sensor.""" - - def __init__(self, name, state, device_class): - """Initialize the demo sensor.""" - self._name = name - self._state = state - self._sensor_type = device_class - - @property - def device_class(self): - """Return the class of this sensor.""" - return self._sensor_type - - @property - def should_poll(self): - """No polling needed for a demo binary sensor.""" - return False - - @property - def name(self): - """Return the name of the binary sensor.""" - return self._name - - @property - def is_on(self): - """Return true if the binary sensor is on.""" - return self._state diff --git a/homeassistant/components/binary_sensor/device_condition.py b/homeassistant/components/binary_sensor/device_condition.py new file mode 100644 index 000000000..842790e01 --- /dev/null +++ b/homeassistant/components/binary_sensor/device_condition.py @@ -0,0 +1,263 @@ +"""Implemenet device conditions for binary sensor.""" +from typing import Dict, List + +import voluptuous as vol + +from homeassistant.components.device_automation.const import CONF_IS_OFF, CONF_IS_ON +from homeassistant.const import ATTR_DEVICE_CLASS, CONF_ENTITY_ID, CONF_FOR, CONF_TYPE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import condition, config_validation as cv +from homeassistant.helpers.entity_registry import ( + async_entries_for_device, + async_get_registry, +) +from homeassistant.helpers.typing import ConfigType + +from . import ( + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_COLD, + DEVICE_CLASS_CONNECTIVITY, + DEVICE_CLASS_DOOR, + DEVICE_CLASS_GARAGE_DOOR, + DEVICE_CLASS_GAS, + DEVICE_CLASS_HEAT, + DEVICE_CLASS_LIGHT, + DEVICE_CLASS_LOCK, + DEVICE_CLASS_MOISTURE, + DEVICE_CLASS_MOTION, + DEVICE_CLASS_MOVING, + DEVICE_CLASS_OCCUPANCY, + DEVICE_CLASS_OPENING, + DEVICE_CLASS_PLUG, + DEVICE_CLASS_POWER, + DEVICE_CLASS_PRESENCE, + DEVICE_CLASS_PROBLEM, + DEVICE_CLASS_SAFETY, + DEVICE_CLASS_SMOKE, + DEVICE_CLASS_SOUND, + DEVICE_CLASS_VIBRATION, + DEVICE_CLASS_WINDOW, + DOMAIN, +) + +DEVICE_CLASS_NONE = "none" + +CONF_IS_BAT_LOW = "is_bat_low" +CONF_IS_NOT_BAT_LOW = "is_not_bat_low" +CONF_IS_COLD = "is_cold" +CONF_IS_NOT_COLD = "is_not_cold" +CONF_IS_CONNECTED = "is_connected" +CONF_IS_NOT_CONNECTED = "is_not_connected" +CONF_IS_GAS = "is_gas" +CONF_IS_NO_GAS = "is_no_gas" +CONF_IS_HOT = "is_hot" +CONF_IS_NOT_HOT = "is_not_hot" +CONF_IS_LIGHT = "is_light" +CONF_IS_NO_LIGHT = "is_no_light" +CONF_IS_LOCKED = "is_locked" +CONF_IS_NOT_LOCKED = "is_not_locked" +CONF_IS_MOIST = "is_moist" +CONF_IS_NOT_MOIST = "is_not_moist" +CONF_IS_MOTION = "is_motion" +CONF_IS_NO_MOTION = "is_no_motion" +CONF_IS_MOVING = "is_moving" +CONF_IS_NOT_MOVING = "is_not_moving" +CONF_IS_OCCUPIED = "is_occupied" +CONF_IS_NOT_OCCUPIED = "is_not_occupied" +CONF_IS_PLUGGED_IN = "is_plugged_in" +CONF_IS_NOT_PLUGGED_IN = "is_not_plugged_in" +CONF_IS_POWERED = "is_powered" +CONF_IS_NOT_POWERED = "is_not_powered" +CONF_IS_PRESENT = "is_present" +CONF_IS_NOT_PRESENT = "is_not_present" +CONF_IS_PROBLEM = "is_problem" +CONF_IS_NO_PROBLEM = "is_no_problem" +CONF_IS_UNSAFE = "is_unsafe" +CONF_IS_NOT_UNSAFE = "is_not_unsafe" +CONF_IS_SMOKE = "is_smoke" +CONF_IS_NO_SMOKE = "is_no_smoke" +CONF_IS_SOUND = "is_sound" +CONF_IS_NO_SOUND = "is_no_sound" +CONF_IS_VIBRATION = "is_vibration" +CONF_IS_NO_VIBRATION = "is_no_vibration" +CONF_IS_OPEN = "is_open" +CONF_IS_NOT_OPEN = "is_not_open" + +IS_ON = [ + CONF_IS_BAT_LOW, + CONF_IS_COLD, + CONF_IS_CONNECTED, + CONF_IS_GAS, + CONF_IS_HOT, + CONF_IS_LIGHT, + CONF_IS_LOCKED, + CONF_IS_MOIST, + CONF_IS_MOTION, + CONF_IS_MOVING, + CONF_IS_OCCUPIED, + CONF_IS_OPEN, + CONF_IS_PLUGGED_IN, + CONF_IS_POWERED, + CONF_IS_PRESENT, + CONF_IS_PROBLEM, + CONF_IS_SMOKE, + CONF_IS_SOUND, + CONF_IS_UNSAFE, + CONF_IS_VIBRATION, + CONF_IS_ON, +] + +IS_OFF = [ + CONF_IS_NOT_BAT_LOW, + CONF_IS_NOT_COLD, + CONF_IS_NOT_CONNECTED, + CONF_IS_NOT_HOT, + CONF_IS_NOT_LOCKED, + CONF_IS_NOT_MOIST, + CONF_IS_NOT_MOVING, + CONF_IS_NOT_OCCUPIED, + CONF_IS_NOT_OPEN, + CONF_IS_NOT_PLUGGED_IN, + CONF_IS_NOT_POWERED, + CONF_IS_NOT_PRESENT, + CONF_IS_NOT_UNSAFE, + CONF_IS_NO_GAS, + CONF_IS_NO_LIGHT, + CONF_IS_NO_MOTION, + CONF_IS_NO_PROBLEM, + CONF_IS_NO_SMOKE, + CONF_IS_NO_SOUND, + CONF_IS_NO_VIBRATION, + CONF_IS_OFF, +] + +ENTITY_CONDITIONS = { + DEVICE_CLASS_BATTERY: [ + {CONF_TYPE: CONF_IS_BAT_LOW}, + {CONF_TYPE: CONF_IS_NOT_BAT_LOW}, + ], + DEVICE_CLASS_COLD: [{CONF_TYPE: CONF_IS_COLD}, {CONF_TYPE: CONF_IS_NOT_COLD}], + DEVICE_CLASS_CONNECTIVITY: [ + {CONF_TYPE: CONF_IS_CONNECTED}, + {CONF_TYPE: CONF_IS_NOT_CONNECTED}, + ], + DEVICE_CLASS_DOOR: [{CONF_TYPE: CONF_IS_OPEN}, {CONF_TYPE: CONF_IS_NOT_OPEN}], + DEVICE_CLASS_GARAGE_DOOR: [ + {CONF_TYPE: CONF_IS_OPEN}, + {CONF_TYPE: CONF_IS_NOT_OPEN}, + ], + DEVICE_CLASS_GAS: [{CONF_TYPE: CONF_IS_GAS}, {CONF_TYPE: CONF_IS_NO_GAS}], + DEVICE_CLASS_HEAT: [{CONF_TYPE: CONF_IS_HOT}, {CONF_TYPE: CONF_IS_NOT_HOT}], + DEVICE_CLASS_LIGHT: [{CONF_TYPE: CONF_IS_LIGHT}, {CONF_TYPE: CONF_IS_NO_LIGHT}], + DEVICE_CLASS_LOCK: [{CONF_TYPE: CONF_IS_LOCKED}, {CONF_TYPE: CONF_IS_NOT_LOCKED}], + DEVICE_CLASS_MOISTURE: [{CONF_TYPE: CONF_IS_MOIST}, {CONF_TYPE: CONF_IS_NOT_MOIST}], + DEVICE_CLASS_MOTION: [{CONF_TYPE: CONF_IS_MOTION}, {CONF_TYPE: CONF_IS_NO_MOTION}], + DEVICE_CLASS_MOVING: [{CONF_TYPE: CONF_IS_MOVING}, {CONF_TYPE: CONF_IS_NOT_MOVING}], + DEVICE_CLASS_OCCUPANCY: [ + {CONF_TYPE: CONF_IS_OCCUPIED}, + {CONF_TYPE: CONF_IS_NOT_OCCUPIED}, + ], + DEVICE_CLASS_OPENING: [{CONF_TYPE: CONF_IS_OPEN}, {CONF_TYPE: CONF_IS_NOT_OPEN}], + DEVICE_CLASS_PLUG: [ + {CONF_TYPE: CONF_IS_PLUGGED_IN}, + {CONF_TYPE: CONF_IS_NOT_PLUGGED_IN}, + ], + DEVICE_CLASS_POWER: [ + {CONF_TYPE: CONF_IS_POWERED}, + {CONF_TYPE: CONF_IS_NOT_POWERED}, + ], + DEVICE_CLASS_PRESENCE: [ + {CONF_TYPE: CONF_IS_PRESENT}, + {CONF_TYPE: CONF_IS_NOT_PRESENT}, + ], + DEVICE_CLASS_PROBLEM: [ + {CONF_TYPE: CONF_IS_PROBLEM}, + {CONF_TYPE: CONF_IS_NO_PROBLEM}, + ], + DEVICE_CLASS_SAFETY: [{CONF_TYPE: CONF_IS_UNSAFE}, {CONF_TYPE: CONF_IS_NOT_UNSAFE}], + DEVICE_CLASS_SMOKE: [{CONF_TYPE: CONF_IS_SMOKE}, {CONF_TYPE: CONF_IS_NO_SMOKE}], + DEVICE_CLASS_SOUND: [{CONF_TYPE: CONF_IS_SOUND}, {CONF_TYPE: CONF_IS_NO_SOUND}], + DEVICE_CLASS_VIBRATION: [ + {CONF_TYPE: CONF_IS_VIBRATION}, + {CONF_TYPE: CONF_IS_NO_VIBRATION}, + ], + DEVICE_CLASS_WINDOW: [{CONF_TYPE: CONF_IS_OPEN}, {CONF_TYPE: CONF_IS_NOT_OPEN}], + DEVICE_CLASS_NONE: [{CONF_TYPE: CONF_IS_ON}, {CONF_TYPE: CONF_IS_OFF}], +} + +CONDITION_SCHEMA = cv.DEVICE_CONDITION_BASE_SCHEMA.extend( + { + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TYPE): vol.In(IS_OFF + IS_ON), + vol.Optional(CONF_FOR): cv.positive_time_period_dict, + } +) + + +async def async_get_conditions( + hass: HomeAssistant, device_id: str +) -> List[Dict[str, str]]: + """List device conditions.""" + conditions: List[Dict[str, str]] = [] + entity_registry = await async_get_registry(hass) + entries = [ + entry + for entry in async_entries_for_device(entity_registry, device_id) + if entry.domain == DOMAIN + ] + + for entry in entries: + device_class = DEVICE_CLASS_NONE + state = hass.states.get(entry.entity_id) + if state and ATTR_DEVICE_CLASS in state.attributes: + device_class = state.attributes[ATTR_DEVICE_CLASS] + + templates = ENTITY_CONDITIONS.get( + device_class, ENTITY_CONDITIONS[DEVICE_CLASS_NONE] + ) + + conditions.extend( + ( + { + **template, + "condition": "device", + "device_id": device_id, + "entity_id": entry.entity_id, + "domain": DOMAIN, + } + for template in templates + ) + ) + + return conditions + + +def async_condition_from_config( + config: ConfigType, config_validation: bool +) -> condition.ConditionCheckerType: + """Evaluate state based on configuration.""" + if config_validation: + config = CONDITION_SCHEMA(config) + condition_type = config[CONF_TYPE] + if condition_type in IS_ON: + stat = "on" + else: + stat = "off" + state_config = { + condition.CONF_CONDITION: "state", + condition.CONF_ENTITY_ID: config[CONF_ENTITY_ID], + condition.CONF_STATE: stat, + } + if CONF_FOR in config: + state_config[CONF_FOR] = config[CONF_FOR] + + return condition.state_from_config(state_config) + + +async def async_get_condition_capabilities(hass: HomeAssistant, config: dict) -> dict: + """List condition capabilities.""" + return { + "extra_fields": vol.Schema( + {vol.Optional(CONF_FOR): cv.positive_time_period_dict} + ) + } diff --git a/homeassistant/components/binary_sensor/device_trigger.py b/homeassistant/components/binary_sensor/device_trigger.py new file mode 100644 index 000000000..288cc101d --- /dev/null +++ b/homeassistant/components/binary_sensor/device_trigger.py @@ -0,0 +1,250 @@ +"""Provides device triggers for binary sensors.""" +import voluptuous as vol + +from homeassistant.components.automation import state as state_automation +from homeassistant.components.device_automation import TRIGGER_BASE_SCHEMA +from homeassistant.components.device_automation.const import ( + CONF_TURNED_OFF, + CONF_TURNED_ON, +) +from homeassistant.const import ATTR_DEVICE_CLASS, CONF_ENTITY_ID, CONF_FOR, CONF_TYPE +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_registry import async_entries_for_device + +from . import ( + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_COLD, + DEVICE_CLASS_CONNECTIVITY, + DEVICE_CLASS_DOOR, + DEVICE_CLASS_GARAGE_DOOR, + DEVICE_CLASS_GAS, + DEVICE_CLASS_HEAT, + DEVICE_CLASS_LIGHT, + DEVICE_CLASS_LOCK, + DEVICE_CLASS_MOISTURE, + DEVICE_CLASS_MOTION, + DEVICE_CLASS_MOVING, + DEVICE_CLASS_OCCUPANCY, + DEVICE_CLASS_OPENING, + DEVICE_CLASS_PLUG, + DEVICE_CLASS_POWER, + DEVICE_CLASS_PRESENCE, + DEVICE_CLASS_PROBLEM, + DEVICE_CLASS_SAFETY, + DEVICE_CLASS_SMOKE, + DEVICE_CLASS_SOUND, + DEVICE_CLASS_VIBRATION, + DEVICE_CLASS_WINDOW, + DOMAIN, +) + +# mypy: allow-untyped-defs, no-check-untyped-defs + +DEVICE_CLASS_NONE = "none" + +CONF_BAT_LOW = "bat_low" +CONF_NOT_BAT_LOW = "not_bat_low" +CONF_COLD = "cold" +CONF_NOT_COLD = "not_cold" +CONF_CONNECTED = "connected" +CONF_NOT_CONNECTED = "not_connected" +CONF_GAS = "gas" +CONF_NO_GAS = "no_gas" +CONF_HOT = "hot" +CONF_NOT_HOT = "not_hot" +CONF_LIGHT = "light" +CONF_NO_LIGHT = "no_light" +CONF_LOCKED = "locked" +CONF_NOT_LOCKED = "not_locked" +CONF_MOIST = "moist" +CONF_NOT_MOIST = "not_moist" +CONF_MOTION = "motion" +CONF_NO_MOTION = "no_motion" +CONF_MOVING = "moving" +CONF_NOT_MOVING = "not_moving" +CONF_OCCUPIED = "occupied" +CONF_NOT_OCCUPIED = "not_occupied" +CONF_PLUGGED_IN = "plugged_in" +CONF_NOT_PLUGGED_IN = "not_plugged_in" +CONF_POWERED = "powered" +CONF_NOT_POWERED = "not_powered" +CONF_PRESENT = "present" +CONF_NOT_PRESENT = "not_present" +CONF_PROBLEM = "problem" +CONF_NO_PROBLEM = "no_problem" +CONF_UNSAFE = "unsafe" +CONF_NOT_UNSAFE = "not_unsafe" +CONF_SMOKE = "smoke" +CONF_NO_SMOKE = "no_smoke" +CONF_SOUND = "sound" +CONF_NO_SOUND = "no_sound" +CONF_VIBRATION = "vibration" +CONF_NO_VIBRATION = "no_vibration" +CONF_OPENED = "opened" +CONF_NOT_OPENED = "not_opened" + + +TURNED_ON = [ + CONF_BAT_LOW, + CONF_COLD, + CONF_CONNECTED, + CONF_GAS, + CONF_HOT, + CONF_LIGHT, + CONF_LOCKED, + CONF_MOIST, + CONF_MOTION, + CONF_MOVING, + CONF_OCCUPIED, + CONF_OPENED, + CONF_PLUGGED_IN, + CONF_POWERED, + CONF_PRESENT, + CONF_PROBLEM, + CONF_SMOKE, + CONF_SOUND, + CONF_UNSAFE, + CONF_VIBRATION, + CONF_TURNED_ON, +] + +TURNED_OFF = [ + CONF_NOT_BAT_LOW, + CONF_NOT_COLD, + CONF_NOT_CONNECTED, + CONF_NOT_HOT, + CONF_NOT_LOCKED, + CONF_NOT_MOIST, + CONF_NOT_MOVING, + CONF_NOT_OCCUPIED, + CONF_NOT_OPENED, + CONF_NOT_PLUGGED_IN, + CONF_NOT_POWERED, + CONF_NOT_PRESENT, + CONF_NOT_UNSAFE, + CONF_NO_GAS, + CONF_NO_LIGHT, + CONF_NO_MOTION, + CONF_NO_PROBLEM, + CONF_NO_SMOKE, + CONF_NO_SOUND, + CONF_NO_VIBRATION, + CONF_TURNED_OFF, +] + + +ENTITY_TRIGGERS = { + DEVICE_CLASS_BATTERY: [{CONF_TYPE: CONF_BAT_LOW}, {CONF_TYPE: CONF_NOT_BAT_LOW}], + DEVICE_CLASS_COLD: [{CONF_TYPE: CONF_COLD}, {CONF_TYPE: CONF_NOT_COLD}], + DEVICE_CLASS_CONNECTIVITY: [ + {CONF_TYPE: CONF_CONNECTED}, + {CONF_TYPE: CONF_NOT_CONNECTED}, + ], + DEVICE_CLASS_DOOR: [{CONF_TYPE: CONF_OPENED}, {CONF_TYPE: CONF_NOT_OPENED}], + DEVICE_CLASS_GARAGE_DOOR: [{CONF_TYPE: CONF_OPENED}, {CONF_TYPE: CONF_NOT_OPENED}], + DEVICE_CLASS_GAS: [{CONF_TYPE: CONF_GAS}, {CONF_TYPE: CONF_NO_GAS}], + DEVICE_CLASS_HEAT: [{CONF_TYPE: CONF_HOT}, {CONF_TYPE: CONF_NOT_HOT}], + DEVICE_CLASS_LIGHT: [{CONF_TYPE: CONF_LIGHT}, {CONF_TYPE: CONF_NO_LIGHT}], + DEVICE_CLASS_LOCK: [{CONF_TYPE: CONF_LOCKED}, {CONF_TYPE: CONF_NOT_LOCKED}], + DEVICE_CLASS_MOISTURE: [{CONF_TYPE: CONF_MOIST}, {CONF_TYPE: CONF_NOT_MOIST}], + DEVICE_CLASS_MOTION: [{CONF_TYPE: CONF_MOTION}, {CONF_TYPE: CONF_NO_MOTION}], + DEVICE_CLASS_MOVING: [{CONF_TYPE: CONF_MOVING}, {CONF_TYPE: CONF_NOT_MOVING}], + DEVICE_CLASS_OCCUPANCY: [ + {CONF_TYPE: CONF_OCCUPIED}, + {CONF_TYPE: CONF_NOT_OCCUPIED}, + ], + DEVICE_CLASS_OPENING: [{CONF_TYPE: CONF_OPENED}, {CONF_TYPE: CONF_NOT_OPENED}], + DEVICE_CLASS_PLUG: [{CONF_TYPE: CONF_PLUGGED_IN}, {CONF_TYPE: CONF_NOT_PLUGGED_IN}], + DEVICE_CLASS_POWER: [{CONF_TYPE: CONF_POWERED}, {CONF_TYPE: CONF_NOT_POWERED}], + DEVICE_CLASS_PRESENCE: [{CONF_TYPE: CONF_PRESENT}, {CONF_TYPE: CONF_NOT_PRESENT}], + DEVICE_CLASS_PROBLEM: [{CONF_TYPE: CONF_PROBLEM}, {CONF_TYPE: CONF_NO_PROBLEM}], + DEVICE_CLASS_SAFETY: [{CONF_TYPE: CONF_UNSAFE}, {CONF_TYPE: CONF_NOT_UNSAFE}], + DEVICE_CLASS_SMOKE: [{CONF_TYPE: CONF_SMOKE}, {CONF_TYPE: CONF_NO_SMOKE}], + DEVICE_CLASS_SOUND: [{CONF_TYPE: CONF_SOUND}, {CONF_TYPE: CONF_NO_SOUND}], + DEVICE_CLASS_VIBRATION: [ + {CONF_TYPE: CONF_VIBRATION}, + {CONF_TYPE: CONF_NO_VIBRATION}, + ], + DEVICE_CLASS_WINDOW: [{CONF_TYPE: CONF_OPENED}, {CONF_TYPE: CONF_NOT_OPENED}], + DEVICE_CLASS_NONE: [{CONF_TYPE: CONF_TURNED_ON}, {CONF_TYPE: CONF_TURNED_OFF}], +} + + +TRIGGER_SCHEMA = TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TYPE): vol.In(TURNED_OFF + TURNED_ON), + vol.Optional(CONF_FOR): cv.positive_time_period_dict, + } +) + + +async def async_attach_trigger(hass, config, action, automation_info): + """Listen for state changes based on configuration.""" + trigger_type = config[CONF_TYPE] + if trigger_type in TURNED_ON: + from_state = "off" + to_state = "on" + else: + from_state = "on" + to_state = "off" + + state_config = { + state_automation.CONF_PLATFORM: "state", + state_automation.CONF_ENTITY_ID: config[CONF_ENTITY_ID], + state_automation.CONF_FROM: from_state, + state_automation.CONF_TO: to_state, + } + if CONF_FOR in config: + state_config[CONF_FOR] = config[CONF_FOR] + + state_config = state_automation.TRIGGER_SCHEMA(state_config) + return await state_automation.async_attach_trigger( + hass, state_config, action, automation_info, platform_type="device" + ) + + +async def async_get_triggers(hass, device_id): + """List device triggers.""" + triggers = [] + entity_registry = await hass.helpers.entity_registry.async_get_registry() + + entries = [ + entry + for entry in async_entries_for_device(entity_registry, device_id) + if entry.domain == DOMAIN + ] + + for entry in entries: + device_class = DEVICE_CLASS_NONE + state = hass.states.get(entry.entity_id) + if state: + device_class = state.attributes.get(ATTR_DEVICE_CLASS) + + templates = ENTITY_TRIGGERS.get( + device_class, ENTITY_TRIGGERS[DEVICE_CLASS_NONE] + ) + + triggers.extend( + ( + { + **automation, + "platform": "device", + "device_id": device_id, + "entity_id": entry.entity_id, + "domain": DOMAIN, + } + for automation in templates + ) + ) + + return triggers + + +async def async_get_trigger_capabilities(hass, config): + """List trigger capabilities.""" + return { + "extra_fields": vol.Schema( + {vol.Optional(CONF_FOR): cv.positive_time_period_dict} + ) + } diff --git a/homeassistant/components/binary_sensor/digital_ocean.py b/homeassistant/components/binary_sensor/digital_ocean.py deleted file mode 100644 index 0f604c525..000000000 --- a/homeassistant/components/binary_sensor/digital_ocean.py +++ /dev/null @@ -1,97 +0,0 @@ -""" -Support for monitoring the state of Digital Ocean droplets. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.digital_ocean/ -""" -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.digital_ocean import ( - CONF_DROPLETS, ATTR_CREATED_AT, ATTR_DROPLET_ID, ATTR_DROPLET_NAME, - ATTR_FEATURES, ATTR_IPV4_ADDRESS, ATTR_IPV6_ADDRESS, ATTR_MEMORY, - ATTR_REGION, ATTR_VCPUS, CONF_ATTRIBUTION, DATA_DIGITAL_OCEAN) -from homeassistant.const import ATTR_ATTRIBUTION - -_LOGGER = logging.getLogger(__name__) - -DEFAULT_NAME = 'Droplet' -DEFAULT_DEVICE_CLASS = 'moving' -DEPENDENCIES = ['digital_ocean'] - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_DROPLETS): vol.All(cv.ensure_list, [cv.string]), -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Digital Ocean droplet sensor.""" - digital = hass.data.get(DATA_DIGITAL_OCEAN) - if not digital: - return False - - droplets = config.get(CONF_DROPLETS) - - dev = [] - for droplet in droplets: - droplet_id = digital.get_droplet_id(droplet) - if droplet_id is None: - _LOGGER.error("Droplet %s is not available", droplet) - return False - dev.append(DigitalOceanBinarySensor(digital, droplet_id)) - - add_entities(dev, True) - - -class DigitalOceanBinarySensor(BinarySensorDevice): - """Representation of a Digital Ocean droplet sensor.""" - - def __init__(self, do, droplet_id): - """Initialize a new Digital Ocean sensor.""" - self._digital_ocean = do - self._droplet_id = droplet_id - self._state = None - self.data = None - - @property - def name(self): - """Return the name of the sensor.""" - return self.data.name - - @property - def is_on(self): - """Return true if the binary sensor is on.""" - return self.data.status == 'active' - - @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 Digital Ocean droplet.""" - return { - ATTR_ATTRIBUTION: CONF_ATTRIBUTION, - ATTR_CREATED_AT: self.data.created_at, - ATTR_DROPLET_ID: self.data.id, - ATTR_DROPLET_NAME: self.data.name, - ATTR_FEATURES: self.data.features, - ATTR_IPV4_ADDRESS: self.data.ip_address, - ATTR_IPV6_ADDRESS: self.data.ip_v6_address, - ATTR_MEMORY: self.data.memory, - ATTR_REGION: self.data.region['name'], - ATTR_VCPUS: self.data.vcpus, - } - - def update(self): - """Update state of sensor.""" - self._digital_ocean.update() - - for droplet in self._digital_ocean.data: - if droplet.id == self._droplet_id: - self.data = droplet diff --git a/homeassistant/components/binary_sensor/ecobee.py b/homeassistant/components/binary_sensor/ecobee.py deleted file mode 100644 index 37f25476b..000000000 --- a/homeassistant/components/binary_sensor/ecobee.py +++ /dev/null @@ -1,66 +0,0 @@ -""" -Support for Ecobee sensors. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.ecobee/ -""" -from homeassistant.components import ecobee -from homeassistant.components.binary_sensor import BinarySensorDevice - -DEPENDENCIES = ['ecobee'] - -ECOBEE_CONFIG_FILE = 'ecobee.conf' - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Ecobee sensors.""" - if discovery_info is None: - return - data = ecobee.NETWORK - dev = list() - for index in range(len(data.ecobee.thermostats)): - for sensor in data.ecobee.get_remote_sensors(index): - for item in sensor['capability']: - if item['type'] != 'occupancy': - continue - - dev.append(EcobeeBinarySensor(sensor['name'], index)) - - add_entities(dev, True) - - -class EcobeeBinarySensor(BinarySensorDevice): - """Representation of an Ecobee sensor.""" - - def __init__(self, sensor_name, sensor_index): - """Initialize the sensor.""" - self._name = sensor_name + ' Occupancy' - self.sensor_name = sensor_name - self.index = sensor_index - self._state = None - self._device_class = 'occupancy' - - @property - def name(self): - """Return the name of the Ecobee sensor.""" - return self._name.rstrip() - - @property - def is_on(self): - """Return the status of the sensor.""" - return self._state == 'true' - - @property - def device_class(self): - """Return the class of this sensor, from DEVICE_CLASSES.""" - return self._device_class - - def update(self): - """Get the latest state of the sensor.""" - data = ecobee.NETWORK - data.update() - for sensor in data.ecobee.get_remote_sensors(self.index): - for item in sensor['capability']: - if (item['type'] == 'occupancy' and - self.sensor_name == sensor['name']): - self._state = item['value'] diff --git a/homeassistant/components/binary_sensor/egardia.py b/homeassistant/components/binary_sensor/egardia.py deleted file mode 100644 index 0db2cac66..000000000 --- a/homeassistant/components/binary_sensor/egardia.py +++ /dev/null @@ -1,79 +0,0 @@ -""" -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_entities, - 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_entities( - ( - 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): - """Return 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): - """Return the device class.""" - return self._device_class diff --git a/homeassistant/components/binary_sensor/eight_sleep.py b/homeassistant/components/binary_sensor/eight_sleep.py deleted file mode 100644 index 34d3a7a13..000000000 --- a/homeassistant/components/binary_sensor/eight_sleep.py +++ /dev/null @@ -1,67 +0,0 @@ -""" -Support for Eight Sleep binary sensors. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.eight_sleep/ -""" -import logging - -from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.components.eight_sleep import ( - DATA_EIGHT, EightSleepHeatEntity, CONF_BINARY_SENSORS, NAME_MAP) - -_LOGGER = logging.getLogger(__name__) - -DEPENDENCIES = ['eight_sleep'] - - -async def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): - """Set up the eight sleep binary sensor.""" - if discovery_info is None: - return - - name = 'Eight' - sensors = discovery_info[CONF_BINARY_SENSORS] - eight = hass.data[DATA_EIGHT] - - all_sensors = [] - - for sensor in sensors: - all_sensors.append(EightHeatSensor(name, eight, sensor)) - - async_add_entities(all_sensors, True) - - -class EightHeatSensor(EightSleepHeatEntity, BinarySensorDevice): - """Representation of a Eight Sleep heat-based sensor.""" - - def __init__(self, name, eight, sensor): - """Initialize the sensor.""" - super().__init__(eight) - - self._sensor = sensor - self._mapped_name = NAME_MAP.get(self._sensor, self._sensor) - self._name = '{} {}'.format(name, self._mapped_name) - self._state = None - - self._side = self._sensor.split('_')[0] - self._userid = self._eight.fetch_userid(self._side) - self._usrobj = self._eight.users[self._userid] - - _LOGGER.debug("Presence Sensor: %s, Side: %s, User: %s", - self._sensor, self._side, self._userid) - - @property - def name(self): - """Return the name of the sensor, if any.""" - return self._name - - @property - def is_on(self): - """Return true if the binary sensor is on.""" - return self._state - - async def async_update(self): - """Retrieve latest state.""" - self._state = self._usrobj.bed_presence diff --git a/homeassistant/components/binary_sensor/enocean.py b/homeassistant/components/binary_sensor/enocean.py deleted file mode 100644 index c883897c2..000000000 --- a/homeassistant/components/binary_sensor/enocean.py +++ /dev/null @@ -1,90 +0,0 @@ -""" -Support for EnOcean binary sensors. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.enocean/ -""" -import logging - -import voluptuous as vol - -from homeassistant.components.binary_sensor import ( - BinarySensorDevice, PLATFORM_SCHEMA, DEVICE_CLASSES_SCHEMA) -from homeassistant.components import enocean -from homeassistant.const import ( - CONF_NAME, CONF_ID, CONF_DEVICE_CLASS) -import homeassistant.helpers.config_validation as cv - -_LOGGER = logging.getLogger(__name__) - -DEPENDENCIES = ['enocean'] -DEFAULT_NAME = 'EnOcean binary sensor' - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_ID): vol.All(cv.ensure_list, [vol.Coerce(int)]), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Binary Sensor platform for EnOcean.""" - dev_id = config.get(CONF_ID) - devname = config.get(CONF_NAME) - device_class = config.get(CONF_DEVICE_CLASS) - - add_entities([EnOceanBinarySensor(dev_id, devname, device_class)]) - - -class EnOceanBinarySensor(enocean.EnOceanDevice, BinarySensorDevice): - """Representation of EnOcean binary sensors such as wall switches.""" - - def __init__(self, dev_id, devname, device_class): - """Initialize the EnOcean binary sensor.""" - enocean.EnOceanDevice.__init__(self) - self.stype = 'listener' - self.dev_id = dev_id - self.which = -1 - self.onoff = -1 - self.devname = devname - self._device_class = device_class - - @property - def name(self): - """Return the default name for the binary sensor.""" - return self.devname - - @property - def device_class(self): - """Return the class of this sensor.""" - return self._device_class - - def value_changed(self, value, value2): - """Fire an event with the data that have changed. - - This method is called when there is an incoming packet associated - with this platform. - """ - self.schedule_update_ha_state() - if value2 == 0x70: - self.which = 0 - self.onoff = 0 - elif value2 == 0x50: - self.which = 0 - self.onoff = 1 - elif value2 == 0x30: - self.which = 1 - self.onoff = 0 - elif value2 == 0x10: - self.which = 1 - self.onoff = 1 - elif value2 == 0x37: - self.which = 10 - self.onoff = 0 - elif value2 == 0x15: - self.which = 10 - self.onoff = 1 - self.hass.bus.fire('button_pressed', {'id': self.dev_id, - 'pushed': value, - 'which': self.which, - 'onoff': self.onoff}) diff --git a/homeassistant/components/binary_sensor/envisalink.py b/homeassistant/components/binary_sensor/envisalink.py deleted file mode 100644 index 2568879bc..000000000 --- a/homeassistant/components/binary_sensor/envisalink.py +++ /dev/null @@ -1,104 +0,0 @@ -""" -Support for Envisalink 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.envisalink/ -""" -import asyncio -import logging -import datetime - -from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.components.envisalink import ( - DATA_EVL, ZONE_SCHEMA, CONF_ZONENAME, CONF_ZONETYPE, EnvisalinkDevice, - SIGNAL_ZONE_UPDATE) -from homeassistant.const import ATTR_LAST_TRIP_TIME -from homeassistant.util import dt as dt_util - -_LOGGER = logging.getLogger(__name__) - -DEPENDENCIES = ['envisalink'] - - -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): - """Set up the Envisalink binary sensor devices.""" - configured_zones = discovery_info['zones'] - - devices = [] - for zone_num in configured_zones: - device_config_data = ZONE_SCHEMA(configured_zones[zone_num]) - device = EnvisalinkBinarySensor( - hass, - zone_num, - device_config_data[CONF_ZONENAME], - device_config_data[CONF_ZONETYPE], - hass.data[DATA_EVL].alarm_state['zone'][zone_num], - hass.data[DATA_EVL] - ) - devices.append(device) - - async_add_entities(devices) - - -class EnvisalinkBinarySensor(EnvisalinkDevice, BinarySensorDevice): - """Representation of an Envisalink binary sensor.""" - - def __init__(self, hass, zone_number, zone_name, zone_type, info, - controller): - """Initialize the binary_sensor.""" - self._zone_type = zone_type - self._zone_number = zone_number - - _LOGGER.debug('Setting up zone: %s', zone_name) - super().__init__(zone_name, info, controller) - - @asyncio.coroutine - def async_added_to_hass(self): - """Register callbacks.""" - async_dispatcher_connect( - self.hass, SIGNAL_ZONE_UPDATE, self._update_callback) - - @property - def device_state_attributes(self): - """Return the state attributes.""" - attr = {} - - # The Envisalink library returns a "last_fault" value that's the - # number of seconds since the last fault, up to a maximum of 327680 - # seconds (65536 5-second ticks). - # - # We don't want the HA event log to fill up with a bunch of no-op - # "state changes" that are just that number ticking up once per poll - # interval, so we subtract it from the current second-accurate time - # unless it is already at the maximum value, in which case we set it - # to None since we can't determine the actual value. - seconds_ago = self._info['last_fault'] - if seconds_ago < 65536 * 5: - now = dt_util.now().replace(microsecond=0) - delta = datetime.timedelta(seconds=seconds_ago) - last_trip_time = (now - delta).isoformat() - else: - last_trip_time = None - - attr[ATTR_LAST_TRIP_TIME] = last_trip_time - return attr - - @property - def is_on(self): - """Return true if sensor is on.""" - return self._info['status']['open'] - - @property - def device_class(self): - """Return the class of this sensor, from DEVICE_CLASSES.""" - return self._zone_type - - @callback - def _update_callback(self, zone): - """Update the zone's state, if needed.""" - if zone is None or int(zone) == self._zone_number: - self.async_schedule_update_ha_state() diff --git a/homeassistant/components/binary_sensor/ffmpeg_motion.py b/homeassistant/components/binary_sensor/ffmpeg_motion.py deleted file mode 100644 index 365bcafbd..000000000 --- a/homeassistant/components/binary_sensor/ffmpeg_motion.py +++ /dev/null @@ -1,127 +0,0 @@ -""" -Provides a binary sensor which is a collection of ffmpeg tools. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.ffmpeg_motion/ -""" -import asyncio -import logging - -import voluptuous as vol - -from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv -from homeassistant.components.binary_sensor import ( - BinarySensorDevice, PLATFORM_SCHEMA) -from homeassistant.components.ffmpeg import ( - FFmpegBase, DATA_FFMPEG, CONF_INPUT, CONF_EXTRA_ARGUMENTS, - CONF_INITIAL_STATE) -from homeassistant.const import CONF_NAME - -DEPENDENCIES = ['ffmpeg'] - -_LOGGER = logging.getLogger(__name__) - -CONF_RESET = 'reset' -CONF_CHANGES = 'changes' -CONF_REPEAT = 'repeat' -CONF_REPEAT_TIME = 'repeat_time' - -DEFAULT_NAME = 'FFmpeg Motion' -DEFAULT_INIT_STATE = True - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_INPUT): cv.string, - vol.Optional(CONF_INITIAL_STATE, default=DEFAULT_INIT_STATE): cv.boolean, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_EXTRA_ARGUMENTS): cv.string, - vol.Optional(CONF_RESET, default=10): - vol.All(vol.Coerce(int), vol.Range(min=1)), - vol.Optional(CONF_CHANGES, default=10): - vol.All(vol.Coerce(float), vol.Range(min=0, max=99)), - vol.Inclusive(CONF_REPEAT, 'repeat'): - vol.All(vol.Coerce(int), vol.Range(min=1)), - vol.Inclusive(CONF_REPEAT_TIME, 'repeat'): - vol.All(vol.Coerce(int), vol.Range(min=1)), -}) - - -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): - """Set up the FFmpeg binary motion sensor.""" - manager = hass.data[DATA_FFMPEG] - - if not manager.async_run_test(config.get(CONF_INPUT)): - return - - entity = FFmpegMotion(hass, manager, config) - async_add_entities([entity]) - - -class FFmpegBinarySensor(FFmpegBase, BinarySensorDevice): - """A binary sensor which use FFmpeg for noise detection.""" - - def __init__(self, config): - """Init for the binary sensor noise detection.""" - super().__init__(config.get(CONF_INITIAL_STATE)) - - self._state = False - self._config = config - self._name = config.get(CONF_NAME) - - @callback - def _async_callback(self, state): - """HA-FFmpeg callback for noise detection.""" - self._state = state - self.async_schedule_update_ha_state() - - @property - def is_on(self): - """Return true if the binary sensor is on.""" - return self._state - - @property - def name(self): - """Return the name of the entity.""" - return self._name - - -class FFmpegMotion(FFmpegBinarySensor): - """A binary sensor which use FFmpeg for noise detection.""" - - def __init__(self, hass, manager, config): - """Initialize FFmpeg motion binary sensor.""" - from haffmpeg import SensorMotion - - super().__init__(config) - self.ffmpeg = SensorMotion( - manager.binary, hass.loop, self._async_callback) - - @asyncio.coroutine - def _async_start_ffmpeg(self, entity_ids): - """Start a FFmpeg instance. - - This method is a coroutine. - """ - if entity_ids is not None and self.entity_id not in entity_ids: - return - - # init config - self.ffmpeg.set_options( - time_reset=self._config.get(CONF_RESET), - time_repeat=self._config.get(CONF_REPEAT_TIME, 0), - repeat=self._config.get(CONF_REPEAT, 0), - changes=self._config.get(CONF_CHANGES), - ) - - # run - yield from self.ffmpeg.open_sensor( - input_source=self._config.get(CONF_INPUT), - extra_cmd=self._config.get(CONF_EXTRA_ARGUMENTS), - ) - - @property - def device_class(self): - """Return the class of this sensor, from DEVICE_CLASSES.""" - return 'motion' diff --git a/homeassistant/components/binary_sensor/ffmpeg_noise.py b/homeassistant/components/binary_sensor/ffmpeg_noise.py deleted file mode 100644 index 73c84ac33..000000000 --- a/homeassistant/components/binary_sensor/ffmpeg_noise.py +++ /dev/null @@ -1,94 +0,0 @@ -""" -Provides a binary sensor which is a collection of ffmpeg tools. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.ffmpeg_noise/ -""" -import asyncio -import logging - -import voluptuous as vol - -import homeassistant.helpers.config_validation as cv -from homeassistant.components.binary_sensor import PLATFORM_SCHEMA -from homeassistant.components.binary_sensor.ffmpeg_motion import ( - FFmpegBinarySensor) -from homeassistant.components.ffmpeg import ( - DATA_FFMPEG, CONF_INPUT, CONF_OUTPUT, CONF_EXTRA_ARGUMENTS, - CONF_INITIAL_STATE) -from homeassistant.const import CONF_NAME - -DEPENDENCIES = ['ffmpeg'] - -_LOGGER = logging.getLogger(__name__) - -CONF_PEAK = 'peak' -CONF_DURATION = 'duration' -CONF_RESET = 'reset' - -DEFAULT_NAME = 'FFmpeg Noise' -DEFAULT_INIT_STATE = True - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_INPUT): cv.string, - vol.Optional(CONF_INITIAL_STATE, default=DEFAULT_INIT_STATE): cv.boolean, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_EXTRA_ARGUMENTS): cv.string, - vol.Optional(CONF_OUTPUT): cv.string, - vol.Optional(CONF_PEAK, default=-30): vol.Coerce(int), - vol.Optional(CONF_DURATION, default=1): - vol.All(vol.Coerce(int), vol.Range(min=1)), - vol.Optional(CONF_RESET, default=10): - vol.All(vol.Coerce(int), vol.Range(min=1)), -}) - - -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): - """Set up the FFmpeg noise binary sensor.""" - manager = hass.data[DATA_FFMPEG] - - if not manager.async_run_test(config.get(CONF_INPUT)): - return - - entity = FFmpegNoise(hass, manager, config) - async_add_entities([entity]) - - -class FFmpegNoise(FFmpegBinarySensor): - """A binary sensor which use FFmpeg for noise detection.""" - - def __init__(self, hass, manager, config): - """Initialize FFmpeg noise binary sensor.""" - from haffmpeg import SensorNoise - - super().__init__(config) - self.ffmpeg = SensorNoise( - manager.binary, hass.loop, self._async_callback) - - @asyncio.coroutine - def _async_start_ffmpeg(self, entity_ids): - """Start a FFmpeg instance. - - This method is a coroutine. - """ - if entity_ids is not None and self.entity_id not in entity_ids: - return - - self.ffmpeg.set_options( - time_duration=self._config.get(CONF_DURATION), - time_reset=self._config.get(CONF_RESET), - peak=self._config.get(CONF_PEAK), - ) - - yield from self.ffmpeg.open_sensor( - input_source=self._config.get(CONF_INPUT), - output_dest=self._config.get(CONF_OUTPUT), - extra_cmd=self._config.get(CONF_EXTRA_ARGUMENTS), - ) - - @property - def device_class(self): - """Return the class of this sensor, from DEVICE_CLASSES.""" - return 'sound' diff --git a/homeassistant/components/binary_sensor/flic.py b/homeassistant/components/binary_sensor/flic.py deleted file mode 100644 index baf1d469b..000000000 --- a/homeassistant/components/binary_sensor/flic.py +++ /dev/null @@ -1,242 +0,0 @@ -""" -Support to use flic buttons as a binary sensor. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.flic/ -""" -import logging -import threading - -import voluptuous as vol - -import homeassistant.helpers.config_validation as cv -from homeassistant.const import ( - CONF_HOST, CONF_PORT, CONF_DISCOVERY, CONF_TIMEOUT, - EVENT_HOMEASSISTANT_STOP) -from homeassistant.components.binary_sensor import ( - BinarySensorDevice, PLATFORM_SCHEMA) - -REQUIREMENTS = ['pyflic-homeassistant==0.4.dev0'] - -_LOGGER = logging.getLogger(__name__) - -DEFAULT_TIMEOUT = 3 - -CLICK_TYPE_SINGLE = 'single' -CLICK_TYPE_DOUBLE = 'double' -CLICK_TYPE_HOLD = 'hold' -CLICK_TYPES = [CLICK_TYPE_SINGLE, CLICK_TYPE_DOUBLE, CLICK_TYPE_HOLD] - -CONF_IGNORED_CLICK_TYPES = 'ignored_click_types' - -DEFAULT_HOST = 'localhost' -DEFAULT_PORT = 5551 - -EVENT_NAME = 'flic_click' -EVENT_DATA_NAME = 'button_name' -EVENT_DATA_ADDRESS = 'button_address' -EVENT_DATA_TYPE = 'click_type' -EVENT_DATA_QUEUED_TIME = 'queued_time' - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_DISCOVERY, default=True): cv.boolean, - vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, - vol.Optional(CONF_IGNORED_CLICK_TYPES): - vol.All(cv.ensure_list, [vol.In(CLICK_TYPES)]) -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the flic platform.""" - import pyflic - - # Initialize flic client responsible for - # connecting to buttons and retrieving events - host = config.get(CONF_HOST) - port = config.get(CONF_PORT) - discovery = config.get(CONF_DISCOVERY) - - try: - client = pyflic.FlicClient(host, port) - except ConnectionRefusedError: - _LOGGER.error("Failed to connect to flic server") - return - - def new_button_callback(address): - """Set up newly verified button as device in Home Assistant.""" - setup_button(hass, config, add_entities, client, address) - - client.on_new_verified_button = new_button_callback - if discovery: - start_scanning(config, add_entities, client) - - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, - lambda event: client.close()) - - # Start the pyflic event handling thread - threading.Thread(target=client.handle_events).start() - - def get_info_callback(items): - """Add entities for already verified buttons.""" - addresses = items['bd_addr_of_verified_buttons'] or [] - for address in addresses: - setup_button(hass, config, add_entities, client, address) - - # Get addresses of already verified buttons - client.get_info(get_info_callback) - - -def start_scanning(config, add_entities, client): - """Start a new flic client for scanning and connecting to new buttons.""" - import pyflic - - scan_wizard = pyflic.ScanWizard() - - def scan_completed_callback(scan_wizard, result, address, name): - """Restart scan wizard to constantly check for new buttons.""" - if result == pyflic.ScanWizardResult.WizardSuccess: - _LOGGER.info("Found new button %s", address) - elif result != pyflic.ScanWizardResult.WizardFailedTimeout: - _LOGGER.warning( - "Failed to connect to button %s. Reason: %s", address, result) - - # Restart scan wizard - start_scanning(config, add_entities, client) - - scan_wizard.on_completed = scan_completed_callback - client.add_scan_wizard(scan_wizard) - - -def setup_button(hass, config, add_entities, client, address): - """Set up a single button device.""" - timeout = config.get(CONF_TIMEOUT) - ignored_click_types = config.get(CONF_IGNORED_CLICK_TYPES) - button = FlicButton(hass, client, address, timeout, ignored_click_types) - _LOGGER.info("Connected to button %s", address) - - add_entities([button]) - - -class FlicButton(BinarySensorDevice): - """Representation of a flic button.""" - - def __init__(self, hass, client, address, timeout, ignored_click_types): - """Initialize the flic button.""" - import pyflic - - self._hass = hass - self._address = address - self._timeout = timeout - self._is_down = False - self._ignored_click_types = ignored_click_types or [] - self._hass_click_types = { - pyflic.ClickType.ButtonClick: CLICK_TYPE_SINGLE, - pyflic.ClickType.ButtonSingleClick: CLICK_TYPE_SINGLE, - pyflic.ClickType.ButtonDoubleClick: CLICK_TYPE_DOUBLE, - pyflic.ClickType.ButtonHold: CLICK_TYPE_HOLD, - } - - self._channel = self._create_channel() - client.add_connection_channel(self._channel) - - def _create_channel(self): - """Create a new connection channel to the button.""" - import pyflic - - channel = pyflic.ButtonConnectionChannel(self._address) - channel.on_button_up_or_down = self._on_up_down - - # If all types of clicks should be ignored, skip registering callbacks - if set(self._ignored_click_types) == set(CLICK_TYPES): - return channel - - if CLICK_TYPE_DOUBLE in self._ignored_click_types: - # Listen to all but double click type events - channel.on_button_click_or_hold = self._on_click - elif CLICK_TYPE_HOLD in self._ignored_click_types: - # Listen to all but hold click type events - channel.on_button_single_or_double_click = self._on_click - else: - # Listen to all click type events - channel.on_button_single_or_double_click_or_hold = self._on_click - - return channel - - @property - def name(self): - """Return the name of the device.""" - return 'flic_{}'.format(self.address.replace(':', '')) - - @property - def address(self): - """Return the bluetooth address of the device.""" - return self._address - - @property - def is_on(self): - """Return true if sensor is on.""" - return self._is_down - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def device_state_attributes(self): - """Return device specific state attributes.""" - return {'address': self.address} - - def _queued_event_check(self, click_type, time_diff): - """Generate a log message and returns true if timeout exceeded.""" - time_string = "{:d} {}".format( - time_diff, 'second' if time_diff == 1 else 'seconds') - - if time_diff > self._timeout: - _LOGGER.warning( - "Queued %s dropped for %s. Time in queue was %s", - click_type, self.address, time_string) - return True - _LOGGER.info( - "Queued %s allowed for %s. Time in queue was %s", - click_type, self.address, time_string) - return False - - def _on_up_down(self, channel, click_type, was_queued, time_diff): - """Update device state, if event was not queued.""" - import pyflic - - if was_queued and self._queued_event_check(click_type, time_diff): - return - - self._is_down = click_type == pyflic.ClickType.ButtonDown - self.schedule_update_ha_state() - - def _on_click(self, channel, click_type, was_queued, time_diff): - """Fire click event, if event was not queued.""" - # Return if click event was queued beyond allowed timeout - if was_queued and self._queued_event_check(click_type, time_diff): - return - - # Return if click event is in ignored click types - hass_click_type = self._hass_click_types[click_type] - if hass_click_type in self._ignored_click_types: - return - - self._hass.bus.fire(EVENT_NAME, { - EVENT_DATA_NAME: self.name, - EVENT_DATA_ADDRESS: self.address, - EVENT_DATA_QUEUED_TIME: time_diff, - EVENT_DATA_TYPE: hass_click_type - }) - - def _connection_status_changed( - self, channel, connection_status, disconnect_reason): - """Remove device, if button disconnects.""" - import pyflic - - if connection_status == pyflic.ConnectionStatus.Disconnected: - _LOGGER.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 deleted file mode 100644 index 27466f64c..000000000 --- a/homeassistant/components/binary_sensor/gc100.py +++ /dev/null @@ -1,67 +0,0 @@ -""" -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]) -}) - - -def setup_platform(hass, config, add_entities, 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_entities(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.""" - 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 deleted file mode 100644 index 78e8f9a97..000000000 --- a/homeassistant/components/binary_sensor/hikvision.py +++ /dev/null @@ -1,280 +0,0 @@ -""" -Support for Hikvision event stream events represented as binary sensors. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.hikvision/ -""" -import logging -from datetime import timedelta -import voluptuous as vol - -from homeassistant.helpers.event import track_point_in_utc_time -from homeassistant.util.dt import utcnow -from homeassistant.components.binary_sensor import ( - BinarySensorDevice, PLATFORM_SCHEMA) -import homeassistant.helpers.config_validation as cv -from homeassistant.const import ( - CONF_HOST, CONF_PORT, CONF_NAME, CONF_USERNAME, CONF_PASSWORD, - CONF_SSL, EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START, - ATTR_LAST_TRIP_TIME, CONF_CUSTOMIZE) - -REQUIREMENTS = ['pyhik==0.1.8'] -_LOGGER = logging.getLogger(__name__) - -CONF_IGNORED = 'ignored' -CONF_DELAY = 'delay' - -DEFAULT_PORT = 80 -DEFAULT_IGNORED = False -DEFAULT_DELAY = 0 - -ATTR_DELAY = 'delay' - -DEVICE_CLASS_MAP = { - 'Motion': 'motion', - 'Line Crossing': 'motion', - 'Field Detection': 'motion', - 'Video Loss': None, - 'Tamper Detection': 'motion', - 'Shelter Alarm': None, - 'Disk Full': None, - 'Disk Error': None, - 'Net Interface Broken': 'connectivity', - 'IP Conflict': 'connectivity', - 'Illegal Access': None, - 'Video Mismatch': None, - 'Bad Video': None, - 'PIR Alarm': 'motion', - 'Face Detection': 'motion', - 'Scene Change Detection': 'motion', - 'I/O': None, - 'Unattended Baggage': 'motion', - 'Attended Baggage': 'motion', - 'Recording Failure': None, -} - -CUSTOMIZE_SCHEMA = vol.Schema({ - vol.Optional(CONF_IGNORED, default=DEFAULT_IGNORED): cv.boolean, - vol.Optional(CONF_DELAY, default=DEFAULT_DELAY): cv.positive_int - }) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_NAME): cv.string, - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_SSL, default=False): cv.boolean, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_CUSTOMIZE, default={}): - vol.Schema({cv.string: CUSTOMIZE_SCHEMA}), -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Hikvision binary sensor devices.""" - name = config.get(CONF_NAME) - host = config.get(CONF_HOST) - port = config.get(CONF_PORT) - username = config.get(CONF_USERNAME) - password = config.get(CONF_PASSWORD) - - customize = config.get(CONF_CUSTOMIZE) - - if config.get(CONF_SSL): - protocol = 'https' - else: - protocol = 'http' - - url = '{}://{}'.format(protocol, host) - - data = HikvisionData(hass, url, port, name, username, password) - - if data.sensors is None: - _LOGGER.error("Hikvision event stream has no data, unable to set up") - return False - - entities = [] - - for sensor, channel_list in data.sensors.items(): - for channel in channel_list: - # Build sensor name, then parse customize config. - if data.type == 'NVR': - sensor_name = '{}_{}'.format( - sensor.replace(' ', '_'), channel[1]) - else: - sensor_name = sensor.replace(' ', '_') - - custom = customize.get(sensor_name.lower(), {}) - ignore = custom.get(CONF_IGNORED) - delay = custom.get(CONF_DELAY) - - _LOGGER.debug("Entity: %s - %s, Options - Ignore: %s, Delay: %s", - data.name, sensor_name, ignore, delay) - if not ignore: - entities.append(HikvisionBinarySensor( - hass, sensor, channel[1], data, delay)) - - add_entities(entities) - - -class HikvisionData: - """Hikvision device event stream object.""" - - def __init__(self, hass, url, port, name, username, password): - """Initialize the data object.""" - from pyhik.hikvision import HikCamera - self._url = url - self._port = port - self._name = name - self._username = username - self._password = password - - # Establish camera - self.camdata = HikCamera( - self._url, self._port, self._username, self._password) - - if self._name is None: - self._name = self.camdata.get_name - - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, self.stop_hik) - hass.bus.listen_once(EVENT_HOMEASSISTANT_START, self.start_hik) - - def stop_hik(self, event): - """Shutdown Hikvision subscriptions and subscription thread on exit.""" - self.camdata.disconnect() - - def start_hik(self, event): - """Start Hikvision event stream thread.""" - self.camdata.start_stream() - - @property - def sensors(self): - """Return list of available sensors and their states.""" - return self.camdata.current_event_states - - @property - def cam_id(self): - """Return device id.""" - return self.camdata.get_id - - @property - def name(self): - """Return device name.""" - return self._name - - @property - def type(self): - """Return device type.""" - return self.camdata.get_type - - def get_attributes(self, sensor, channel): - """Return attribute list for sensor/channel.""" - return self.camdata.fetch_attributes(sensor, channel) - - -class HikvisionBinarySensor(BinarySensorDevice): - """Representation of a Hikvision binary sensor.""" - - def __init__(self, hass, sensor, channel, cam, delay): - """Initialize the binary_sensor.""" - self._hass = hass - self._cam = cam - self._sensor = sensor - self._channel = channel - - if self._cam.type == 'NVR': - self._name = '{} {} {}'.format(self._cam.name, sensor, channel) - else: - self._name = '{} {}'.format(self._cam.name, sensor) - - self._id = '{}.{}.{}'.format(self._cam.cam_id, sensor, channel) - - if delay is None: - self._delay = 0 - else: - self._delay = delay - - self._timer = None - - # Register callback function with pyHik - self._cam.camdata.add_update_callback(self._update_callback, self._id) - - def _sensor_state(self): - """Extract sensor state.""" - return self._cam.get_attributes(self._sensor, self._channel)[0] - - def _sensor_last_update(self): - """Extract sensor last update time.""" - return self._cam.get_attributes(self._sensor, self._channel)[3] - - @property - def name(self): - """Return the name of the Hikvision sensor.""" - return self._name - - @property - def unique_id(self): - """Return a unique ID.""" - return self._id - - @property - def is_on(self): - """Return true if sensor is on.""" - return self._sensor_state() - - @property - def device_class(self): - """Return the class of this sensor, from DEVICE_CLASSES.""" - try: - return DEVICE_CLASS_MAP[self._sensor] - except KeyError: - # Sensor must be unknown to us, add as generic - return None - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def device_state_attributes(self): - """Return the state attributes.""" - attr = {} - attr[ATTR_LAST_TRIP_TIME] = self._sensor_last_update() - - if self._delay != 0: - attr[ATTR_DELAY] = self._delay - - return attr - - def _update_callback(self, msg): - """Update the sensor's state, if needed.""" - _LOGGER.debug('Callback signal from: %s', msg) - - if self._delay > 0 and not self.is_on: - # Set timer to wait until updating the state - def _delay_update(now): - """Timer callback for sensor update.""" - _LOGGER.debug("%s Called delayed (%ssec) update", - self._name, self._delay) - self.schedule_update_ha_state() - self._timer = None - - if self._timer is not None: - self._timer() - self._timer = None - - self._timer = track_point_in_utc_time( - self._hass, _delay_update, - utcnow() + timedelta(seconds=self._delay)) - - elif self._delay > 0 and self.is_on: - # For delayed sensors kill any callbacks on true events and update - if self._timer is not None: - self._timer() - self._timer = None - - self.schedule_update_ha_state() - - else: - self.schedule_update_ha_state() diff --git a/homeassistant/components/binary_sensor/hive.py b/homeassistant/components/binary_sensor/hive.py deleted file mode 100644 index 68f326418..000000000 --- a/homeassistant/components/binary_sensor/hive.py +++ /dev/null @@ -1,71 +0,0 @@ -""" -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_entities, discovery_info=None): - """Set up Hive sensor devices.""" - if discovery_info is None: - return - session = hass.data.get(DATA_HIVE) - - add_entities([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 deleted file mode 100644 index 9cfe4bbd6..000000000 --- a/homeassistant/components/binary_sensor/homematic.py +++ /dev/null @@ -1,68 +0,0 @@ -""" -Support for HomeMatic binary sensors. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.homematic/ -""" -import logging - -from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.components.homematic import ATTR_DISCOVER_DEVICES, HMDevice -from homeassistant.const import STATE_UNKNOWN - -_LOGGER = logging.getLogger(__name__) - -DEPENDENCIES = ['homematic'] - -SENSOR_TYPES_CLASS = { - 'IPShutterContact': 'opening', - 'MaxShutterContact': 'opening', - 'Motion': 'motion', - 'MotionV2': 'motion', - 'PresenceIP': 'motion', - 'Remote': None, - 'RemoteMotion': None, - 'ShutterContact': 'opening', - 'Smoke': 'smoke', - 'SmokeV2': 'smoke', - 'TiltSensor': None, - 'WeatherSensor': None, -} - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the HomeMatic binary sensor platform.""" - if discovery_info is None: - return - - devices = [] - for conf in discovery_info[ATTR_DISCOVER_DEVICES]: - new_device = HMBinarySensor(conf) - devices.append(new_device) - - add_entities(devices) - - -class HMBinarySensor(HMDevice, BinarySensorDevice): - """Representation of a binary HomeMatic device.""" - - @property - def is_on(self): - """Return true if switch is on.""" - if not self.available: - return False - return bool(self._hm_get_state()) - - @property - def device_class(self): - """Return the class of this sensor from DEVICE_CLASSES.""" - # If state is MOTION (Only RemoteMotion working) - if self._state == 'MOTION': - return 'motion' - return SENSOR_TYPES_CLASS.get(self._hmdevice.__class__.__name__, None) - - def _init_data_struct(self): - """Generate the data dictionary (self._data) from metadata.""" - # Add state to data struct - if self._state: - self._data.update({self._state: STATE_UNKNOWN}) diff --git a/homeassistant/components/binary_sensor/homematicip_cloud.py b/homeassistant/components/binary_sensor/homematicip_cloud.py deleted file mode 100644 index 6c8b7ff19..000000000 --- a/homeassistant/components/binary_sensor/homematicip_cloud.py +++ /dev/null @@ -1,110 +0,0 @@ -""" -Support for HomematicIP Cloud binary sensor. - -For more details about this platform, 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 ( - HMIPC_HAPID, HomematicipGenericDevice) -from homeassistant.components.homematicip_cloud import DOMAIN as HMIPC_DOMAIN - -DEPENDENCIES = ['homematicip_cloud'] - -_LOGGER = logging.getLogger(__name__) - -STATE_SMOKE_OFF = 'IDLE_OFF' - - -async def async_setup_platform( - hass, config, async_add_entities, discovery_info=None): - """Set up the HomematicIP Cloud binary sensor devices.""" - pass - - -async def async_setup_entry(hass, config_entry, async_add_entities): - """Set up the HomematicIP Cloud binary sensor from a config entry.""" - from homematicip.aio.device import ( - AsyncShutterContact, AsyncMotionDetectorIndoor, AsyncSmokeDetector, - AsyncWaterSensor, AsyncRotaryHandleSensor) - - home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home - devices = [] - for device in home.devices: - if isinstance(device, (AsyncShutterContact, AsyncRotaryHandleSensor)): - devices.append(HomematicipShutterContact(home, device)) - elif isinstance(device, AsyncMotionDetectorIndoor): - devices.append(HomematicipMotionDetector(home, device)) - elif isinstance(device, AsyncSmokeDetector): - devices.append(HomematicipSmokeDetector(home, device)) - elif isinstance(device, AsyncWaterSensor): - devices.append(HomematicipWaterDetector(home, device)) - - if devices: - async_add_entities(devices) - - -class HomematicipShutterContact(HomematicipGenericDevice, BinarySensorDevice): - """Representation of a HomematicIP Cloud shutter contact.""" - - @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.""" - from homematicip.base.enums import WindowState - - if self._device.sabotage: - return True - if self._device.windowState is None: - return None - return self._device.windowState == WindowState.OPEN - - -class HomematicipMotionDetector(HomematicipGenericDevice, BinarySensorDevice): - """Representation of a HomematicIP Cloud motion detector.""" - - @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 - - -class HomematicipSmokeDetector(HomematicipGenericDevice, BinarySensorDevice): - """Representation of a HomematicIP Cloud smoke detector.""" - - @property - def device_class(self): - """Return the class of this sensor.""" - return 'smoke' - - @property - def is_on(self): - """Return true if smoke is detected.""" - return self._device.smokeDetectorAlarmType != STATE_SMOKE_OFF - - -class HomematicipWaterDetector(HomematicipGenericDevice, BinarySensorDevice): - """Representation of a HomematicIP Cloud water detector.""" - - @property - def device_class(self): - """Return the class of this sensor.""" - return 'moisture' - - @property - def is_on(self): - """Return true if moisture or waterlevel is detected.""" - return self._device.moistureDetected or self._device.waterlevelDetected diff --git a/homeassistant/components/binary_sensor/hydrawise.py b/homeassistant/components/binary_sensor/hydrawise.py deleted file mode 100644 index 38b660c50..000000000 --- a/homeassistant/components/binary_sensor/hydrawise.py +++ /dev/null @@ -1,81 +0,0 @@ -""" -Support for Hydrawise sprinkler. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.hydrawise/ -""" -import logging - -import voluptuous as vol - -import homeassistant.helpers.config_validation as cv -from homeassistant.components.hydrawise import ( - BINARY_SENSORS, DATA_HYDRAWISE, HydrawiseEntity, DEVICE_MAP, - DEVICE_MAP_INDEX) -from homeassistant.components.binary_sensor import ( - BinarySensorDevice, PLATFORM_SCHEMA) -from homeassistant.const import CONF_MONITORED_CONDITIONS - -DEPENDENCIES = ['hydrawise'] - -_LOGGER = logging.getLogger(__name__) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_MONITORED_CONDITIONS, default=BINARY_SENSORS): - vol.All(cv.ensure_list, [vol.In(BINARY_SENSORS)]), -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up a sensor for a Hydrawise device.""" - hydrawise = hass.data[DATA_HYDRAWISE].data - - sensors = [] - for sensor_type in config.get(CONF_MONITORED_CONDITIONS): - if sensor_type in ['status', 'rain_sensor']: - sensors.append( - HydrawiseBinarySensor( - hydrawise.controller_status, sensor_type)) - - else: - # create a sensor for each zone - for zone in hydrawise.relays: - zone_data = zone - zone_data['running'] = \ - hydrawise.controller_status.get('running', False) - sensors.append(HydrawiseBinarySensor(zone_data, sensor_type)) - - add_entities(sensors, True) - - -class HydrawiseBinarySensor(HydrawiseEntity, BinarySensorDevice): - """A sensor implementation for Hydrawise device.""" - - @property - def is_on(self): - """Return true if the binary sensor is on.""" - return self._state - - def update(self): - """Get the latest data and updates the state.""" - _LOGGER.debug("Updating Hydrawise binary sensor: %s", self._name) - mydata = self.hass.data[DATA_HYDRAWISE].data - if self._sensor_type == 'status': - self._state = mydata.status == 'All good!' - elif self._sensor_type == 'rain_sensor': - for sensor in mydata.sensors: - if sensor['name'] == 'Rain': - self._state = sensor['active'] == 1 - elif self._sensor_type == 'is_watering': - if not mydata.running: - self._state = False - elif int(mydata.running[0]['relay']) == self.data['relay']: - self._state = True - else: - self._state = False - - @property - def device_class(self): - """Return the device class of the sensor type.""" - return DEVICE_MAP[self._sensor_type][ - DEVICE_MAP_INDEX.index('DEVICE_CLASS_INDEX')] diff --git a/homeassistant/components/binary_sensor/ihc.py b/homeassistant/components/binary_sensor/ihc.py deleted file mode 100644 index 20937af6b..000000000 --- a/homeassistant/components/binary_sensor/ihc.py +++ /dev/null @@ -1,94 +0,0 @@ -"""IHC binary sensor platform. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.ihc/ -""" -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_entities, 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_entities(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=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.py b/homeassistant/components/binary_sensor/insteon.py deleted file mode 100644 index 533ff2d76..000000000 --- a/homeassistant/components/binary_sensor/insteon.py +++ /dev/null @@ -1,65 +0,0 @@ -""" -Support for INSTEON dimmers via PowerLinc Modem. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.insteon/ -""" -import asyncio -import logging - -from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.components.insteon import InsteonEntity - -DEPENDENCIES = ['insteon'] - -_LOGGER = logging.getLogger(__name__) - -SENSOR_TYPES = {'openClosedSensor': 'opening', - 'motionSensor': 'motion', - 'doorSensor': 'door', - 'wetLeakSensor': 'moisture', - 'lightSensor': 'light', - 'batterySensor': 'battery'} - - -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): - """Set up the INSTEON device class for the hass platform.""" - insteon_modem = hass.data['insteon'].get('modem') - - address = discovery_info['address'] - device = insteon_modem.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) - - new_entity = InsteonBinarySensor(device, state_key) - - async_add_entities([new_entity]) - - -class InsteonBinarySensor(InsteonEntity, BinarySensorDevice): - """A Class for an Insteon device entity.""" - - def __init__(self, device, state_key): - """Initialize the INSTEON binary sensor.""" - super().__init__(device, state_key) - self._sensor_type = SENSOR_TYPES.get(self._insteon_device_state.name) - - @property - 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.""" - on_val = bool(self._insteon_device_state.value) - - if self._insteon_device_state.name == 'lightSensor': - return not on_val - - return on_val diff --git a/homeassistant/components/binary_sensor/iss.py b/homeassistant/components/binary_sensor/iss.py deleted file mode 100644 index b986c51dd..000000000 --- a/homeassistant/components/binary_sensor/iss.py +++ /dev/null @@ -1,129 +0,0 @@ -""" -Support for International Space Station data sensor. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/sensor.iss/ -""" -import logging -from datetime import timedelta - -import requests -import voluptuous as vol - -import homeassistant.helpers.config_validation as cv -from homeassistant.components.binary_sensor import ( - BinarySensorDevice, PLATFORM_SCHEMA) -from homeassistant.const import ( - CONF_NAME, ATTR_LONGITUDE, ATTR_LATITUDE, CONF_SHOW_ON_MAP) -from homeassistant.util import Throttle - -REQUIREMENTS = ['pyiss==1.0.1'] - -_LOGGER = logging.getLogger(__name__) - -ATTR_ISS_NEXT_RISE = 'next_rise' -ATTR_ISS_NUMBER_PEOPLE_SPACE = 'number_of_people_in_space' - -DEFAULT_NAME = 'ISS' -DEFAULT_DEVICE_CLASS = 'visible' - -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_SHOW_ON_MAP, default=False): cv.boolean, -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the ISS sensor.""" - if None in (hass.config.latitude, hass.config.longitude): - _LOGGER.error("Latitude or longitude not set in Home Assistant config") - return False - - try: - iss_data = IssData(hass.config.latitude, hass.config.longitude) - iss_data.update() - except requests.exceptions.HTTPError as error: - _LOGGER.error(error) - return False - - name = config.get(CONF_NAME) - show_on_map = config.get(CONF_SHOW_ON_MAP) - - add_entities([IssBinarySensor(iss_data, name, show_on_map)], True) - - -class IssBinarySensor(BinarySensorDevice): - """Implementation of the ISS binary sensor.""" - - def __init__(self, iss_data, name, show): - """Initialize the sensor.""" - self.iss_data = iss_data - self._state = None - self._name = name - self._show_on_map = show - - @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.iss_data.is_above if self.iss_data else 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.""" - if self.iss_data: - attrs = { - ATTR_ISS_NUMBER_PEOPLE_SPACE: - self.iss_data.number_of_people_in_space, - ATTR_ISS_NEXT_RISE: self.iss_data.next_rise, - } - if self._show_on_map: - attrs[ATTR_LONGITUDE] = self.iss_data.position.get('longitude') - attrs[ATTR_LATITUDE] = self.iss_data.position.get('latitude') - else: - attrs['long'] = self.iss_data.position.get('longitude') - attrs['lat'] = self.iss_data.position.get('latitude') - return attrs - - def update(self): - """Get the latest data from ISS API and updates the states.""" - self.iss_data.update() - - -class IssData: - """Get data from the ISS API.""" - - def __init__(self, latitude, longitude): - """Initialize the data object.""" - self.is_above = None - self.next_rise = None - self.number_of_people_in_space = None - self.position = None - self.latitude = latitude - self.longitude = longitude - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): - """Get the latest data from the ISS API.""" - import pyiss - - try: - iss = pyiss.ISS() - self.is_above = iss.is_ISS_above(self.latitude, self.longitude) - self.next_rise = iss.next_rise(self.latitude, self.longitude) - self.number_of_people_in_space = iss.number_of_people_in_space() - self.position = iss.current_location() - except requests.exceptions.HTTPError as error: - _LOGGER.error(error) - return False diff --git a/homeassistant/components/binary_sensor/isy994.py b/homeassistant/components/binary_sensor/isy994.py deleted file mode 100644 index 36dacb067..000000000 --- a/homeassistant/components/binary_sensor/isy994.py +++ /dev/null @@ -1,370 +0,0 @@ -""" -Support for ISY994 binary sensors. - -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 - -from homeassistant.core import callback -from homeassistant.components.binary_sensor import BinarySensorDevice, DOMAIN -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__) - -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'] -} - - -def setup_platform(hass, config: ConfigType, - add_entities: Callable[[list], None], discovery_info=None): - """Set up the ISY994 binary sensor platform.""" - devices = [] - devices_by_nid = {} - child_nodes = [] - - 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 node in child_nodes: - try: - 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: - device_type = _detect_device_type(node) - subnode_id = int(node.nid[-1]) - if device_type in ('opening', '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_entities(devices) - - -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.""" - 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 - self._status_was_unknown = True - else: - self._computed_state = bool(self._node.status._val) - self._status_was_unknown = False - - @asyncio.coroutine - def async_added_to_hass(self) -> None: - """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. Next we need to check to see if the - # negative and positive nodes disagree on the state (both ON or - # both OFF). - if self._negative_node.status._val == self._node.status._val: - # The states disagree, therefore we cannot determine the state - # of the sensor until we receive our first ON event. - self._computed_state = None - - def _negative_node_control_handler(self, event: object) -> None: - """Handle an "On" control event from the "negative" node.""" - 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() - - def on_update(self, event: object) -> None: - """Primary node status updates. - - We MOSTLY ignore these updates, as we listen directly to the Control - events on all nodes for this device. However, there is one edge case: - If a leak sensor is unknown, due to a recent reboot of the ISY, the - status will get updated to dry upon the first heartbeat. This status - update is the only way that a leak sensor's status changes without - an accompanying Control event, so we need to watch for it. - """ - if self._status_was_unknown and self._computed_state is None: - self._computed_state = bool(int(self._node.status)) - self._status_was_unknown = False - self.schedule_update_ha_state() - self._heartbeat() - - @property - def value(self) -> object: - """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. - - 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 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 - - @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) - - 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.""" - 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 deleted file mode 100644 index d0707b0f0..000000000 --- a/homeassistant/components/binary_sensor/knx.py +++ /dev/null @@ -1,144 +0,0 @@ -""" -Support for KNX/IP binary sensors. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.knx/ -""" - -import voluptuous as vol - -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 - -CONF_ADDRESS = 'address' -CONF_DEVICE_CLASS = 'device_class' -CONF_SIGNIFICANT_BIT = 'significant_bit' -CONF_DEFAULT_SIGNIFICANT_BIT = 1 -CONF_AUTOMATION = 'automation' -CONF_HOOK = 'hook' -CONF_DEFAULT_HOOK = 'on' -CONF_COUNTER = 'counter' -CONF_DEFAULT_COUNTER = 1 -CONF_ACTION = 'action' -CONF_RESET_AFTER = 'reset_after' - -CONF__ACTION = 'turn_off_action' - -DEFAULT_NAME = 'KNX Binary Sensor' -DEPENDENCIES = ['knx'] - -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): cv.SCRIPT_SCHEMA -}) - -AUTOMATIONS_SCHEMA = vol.All( - cv.ensure_list, - [AUTOMATION_SCHEMA] -) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_ADDRESS): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_DEVICE_CLASS): cv.string, - vol.Optional(CONF_SIGNIFICANT_BIT, default=CONF_DEFAULT_SIGNIFICANT_BIT): - cv.positive_int, - vol.Optional(CONF_RESET_AFTER): cv.positive_int, - vol.Optional(CONF_AUTOMATION): AUTOMATIONS_SCHEMA, -}) - - -async def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): - """Set up binary sensor(s) for KNX platform.""" - if discovery_info is not None: - async_add_entities_discovery(hass, discovery_info, async_add_entities) - else: - async_add_entities_config(hass, config, async_add_entities) - - -@callback -def async_add_entities_discovery(hass, discovery_info, async_add_entities): - """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)) - async_add_entities(entities) - - -@callback -def async_add_entities_config(hass, config, async_add_entities): - """Set up binary senor for KNX platform configured within platform.""" - name = config.get(CONF_NAME) - import xknx - binary_sensor = xknx.devices.BinarySensor( - hass.data[DATA_KNX].xknx, - name=name, - group_address=config.get(CONF_ADDRESS), - device_class=config.get(CONF_DEVICE_CLASS), - 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) - automations = config.get(CONF_AUTOMATION) - if automations is not None: - for automation in automations: - counter = automation.get(CONF_COUNTER) - hook = automation.get(CONF_HOOK) - action = automation.get(CONF_ACTION) - entity.automations.append(KNXAutomation( - hass=hass, device=binary_sensor, hook=hook, - action=action, counter=counter)) - async_add_entities([entity]) - - -class KNXBinarySensor(BinarySensorDevice): - """Representation of a KNX binary sensor.""" - - def __init__(self, hass, device): - """Initialize of KNX binary sensor.""" - self.device = device - self.hass = hass - self.async_register_callbacks() - self.automations = [] - - @callback - def async_register_callbacks(self): - """Register callbacks to update hass after device was changed.""" - async def after_update_callback(device): - """Call after device was updated.""" - await self.async_update_ha_state() - self.device.register_device_updated_cb(after_update_callback) - - @property - 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.""" - return False - - @property - def device_class(self): - """Return the class of this sensor.""" - return self.device.device_class - - @property - def is_on(self): - """Return true if the binary sensor is on.""" - return self.device.is_on() diff --git a/homeassistant/components/binary_sensor/konnected.py b/homeassistant/components/binary_sensor/konnected.py deleted file mode 100644 index e91d3f613..000000000 --- a/homeassistant/components/binary_sensor/konnected.py +++ /dev/null @@ -1,82 +0,0 @@ -""" -Support for wired binary sensors attached to a Konnected device. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.konnected/ -""" -import logging - -from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.components.konnected import ( - DOMAIN as KONNECTED_DOMAIN, PIN_TO_ZONE, SIGNAL_SENSOR_UPDATE) -from homeassistant.const import ( - CONF_DEVICES, CONF_TYPE, CONF_NAME, CONF_BINARY_SENSORS, ATTR_ENTITY_ID, - ATTR_STATE) -from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect - -_LOGGER = logging.getLogger(__name__) - -DEPENDENCIES = ['konnected'] - - -async def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): - """Set up binary sensors attached to a Konnected device.""" - if discovery_info is None: - return - - data = hass.data[KONNECTED_DOMAIN] - device_id = discovery_info['device_id'] - sensors = [KonnectedBinarySensor(device_id, pin_num, pin_data) - for pin_num, pin_data in - data[CONF_DEVICES][device_id][CONF_BINARY_SENSORS].items()] - async_add_entities(sensors) - - -class KonnectedBinarySensor(BinarySensorDevice): - """Representation of a Konnected binary sensor.""" - - def __init__(self, device_id, pin_num, data): - """Initialize the binary sensor.""" - self._data = data - self._device_id = device_id - self._pin_num = pin_num - self._state = self._data.get(ATTR_STATE) - self._device_class = self._data.get(CONF_TYPE) - self._name = self._data.get(CONF_NAME, 'Konnected {} Zone {}'.format( - device_id, PIN_TO_ZONE[pin_num])) - _LOGGER.debug('Created new Konnected sensor: %s', self._name) - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def is_on(self): - """Return the state of the sensor.""" - return self._state - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def device_class(self): - """Return the device class.""" - return self._device_class - - async def async_added_to_hass(self): - """Store entity_id and register state change callback.""" - self._data[ATTR_ENTITY_ID] = self.entity_id - async_dispatcher_connect( - self.hass, SIGNAL_SENSOR_UPDATE.format(self.entity_id), - self.async_set_state) - - @callback - def async_set_state(self, state): - """Update the sensor's state.""" - self._state = state - self.async_schedule_update_ha_state() diff --git a/homeassistant/components/binary_sensor/linode.py b/homeassistant/components/binary_sensor/linode.py deleted file mode 100644 index 24abc3dd8..000000000 --- a/homeassistant/components/binary_sensor/linode.py +++ /dev/null @@ -1,97 +0,0 @@ -""" -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_entities, 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_entities(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 - self._attrs = {} - self._name = 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 this sensor.""" - return DEFAULT_DEVICE_CLASS - - @property - def device_state_attributes(self): - """Return the state attributes of the Linode Node.""" - return self._attrs - - 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 - if self.data is not None: - self._state = self.data.status == 'running' - self._attrs = { - 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, - } - self._name = self.data.label diff --git a/homeassistant/components/binary_sensor/manifest.json b/homeassistant/components/binary_sensor/manifest.json new file mode 100644 index 000000000..ea29314eb --- /dev/null +++ b/homeassistant/components/binary_sensor/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "binary_sensor", + "name": "Binary sensor", + "documentation": "https://www.home-assistant.io/integrations/binary_sensor", + "requirements": [], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/binary_sensor/maxcube.py b/homeassistant/components/binary_sensor/maxcube.py deleted file mode 100644 index 6bb9278d8..000000000 --- a/homeassistant/components/binary_sensor/maxcube.py +++ /dev/null @@ -1,69 +0,0 @@ -""" -Support for MAX! Window Shutter via MAX! Cube. - -For more details about this platform, please refer to the documentation -https://home-assistant.io/components/maxcube/ -""" -import logging - -from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.components.maxcube import DATA_KEY -from homeassistant.const import STATE_UNKNOWN - -_LOGGER = logging.getLogger(__name__) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Iterate through all MAX! Devices and add window shutters.""" - devices = [] - for handler in hass.data[DATA_KEY].values(): - cube = handler.cube - for device in cube.devices: - name = "{} {}".format( - cube.room_by_id(device.room_id).name, device.name) - - # Only add Window Shutters - if cube.is_windowshutter(device): - devices.append( - MaxCubeShutter(handler, name, device.rf_address)) - - if devices: - add_entities(devices) - - -class MaxCubeShutter(BinarySensorDevice): - """Representation of a MAX! Cube Binary Sensor device.""" - - def __init__(self, handler, name, rf_address): - """Initialize MAX! Cube BinarySensorDevice.""" - self._name = name - self._sensor_type = 'window' - self._rf_address = rf_address - self._cubehandle = handler - self._state = STATE_UNKNOWN - - @property - def should_poll(self): - """Return the polling state.""" - return True - - @property - def name(self): - """Return the name of the BinarySensorDevice.""" - return self._name - - @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 update(self): - """Get latest data from MAX! Cube.""" - self._cubehandle.update() - device = self._cubehandle.cube.device_by_rf(self._rf_address) - self._state = device.is_open diff --git a/homeassistant/components/binary_sensor/modbus.py b/homeassistant/components/binary_sensor/modbus.py deleted file mode 100644 index f9f259759..000000000 --- a/homeassistant/components/binary_sensor/modbus.py +++ /dev/null @@ -1,71 +0,0 @@ -""" -Support for Modbus Coil sensors. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.modbus/ -""" -import logging -import voluptuous as vol - -from homeassistant.components import modbus -from homeassistant.const import CONF_NAME, CONF_SLAVE -from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.helpers import config_validation as cv -from homeassistant.components.sensor import PLATFORM_SCHEMA - -_LOGGER = logging.getLogger(__name__) -DEPENDENCIES = ['modbus'] - -CONF_COIL = 'coil' -CONF_COILS = 'coils' - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_COILS): [{ - vol.Required(CONF_COIL): cv.positive_int, - vol.Required(CONF_NAME): cv.string, - vol.Optional(CONF_SLAVE): cv.positive_int - }] -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Modbus binary sensors.""" - sensors = [] - for coil in config.get(CONF_COILS): - sensors.append(ModbusCoilSensor( - coil.get(CONF_NAME), - coil.get(CONF_SLAVE), - coil.get(CONF_COIL))) - add_entities(sensors) - - -class ModbusCoilSensor(BinarySensorDevice): - """Modbus coil sensor.""" - - def __init__(self, name, slave, coil): - """Initialize the modbus coil sensor.""" - self._name = name - self._slave = int(slave) if slave else None - self._coil = int(coil) - self._value = None - - @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._value - - def update(self): - """Update the state of the sensor.""" - result = modbus.HUB.read_coils(self._slave, self._coil, 1) - try: - self._value = result.bits[0] - except AttributeError: - _LOGGER.error( - 'No response from modbus slave %s coil %s', - self._slave, - self._coil) diff --git a/homeassistant/components/binary_sensor/mqtt.py b/homeassistant/components/binary_sensor/mqtt.py deleted file mode 100644 index 37a26a272..000000000 --- a/homeassistant/components/binary_sensor/mqtt.py +++ /dev/null @@ -1,149 +0,0 @@ -""" -Support for MQTT binary sensors. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.mqtt/ -""" -import asyncio -import logging -from typing import Optional - -import voluptuous as vol - -from homeassistant.core import callback -from homeassistant.components import mqtt -from homeassistant.components.binary_sensor import ( - BinarySensorDevice, DEVICE_CLASSES_SCHEMA) -from homeassistant.const import ( - 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' -CONF_UNIQUE_ID = 'unique_id' -DEFAULT_PAYLOAD_OFF = 'OFF' -DEFAULT_PAYLOAD_ON = 'ON' -DEFAULT_FORCE_UPDATE = False - -DEPENDENCIES = ['mqtt'] - -PLATFORM_SCHEMA = mqtt.MQTT_RO_PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - 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, - # Integrations shouldn't never expose unique_id through configuration - # this here is an exception because MQTT is a msg transport, not a protocol - vol.Optional(CONF_UNIQUE_ID): cv.string, -}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) - - -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): - """Set up the MQTT binary sensor.""" - 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 - - async_add_entities([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, - config.get(CONF_UNIQUE_ID), - )]) - - -class MqttBinarySensor(MqttAvailability, BinarySensorDevice): - """Representation a binary sensor that is updated by MQTT.""" - - def __init__(self, name, state_topic, availability_topic, device_class, - qos, force_update, payload_on, payload_off, payload_available, - payload_not_available, value_template, - unique_id: Optional[str]): - """Initialize the MQTT binary sensor.""" - super().__init__(availability_topic, qos, payload_available, - payload_not_available) - self._name = name - 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 - self._unique_id = unique_id - - @asyncio.coroutine - def async_added_to_hass(self): - """Subscribe mqtt events.""" - yield from super().async_added_to_hass() - - @callback - 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) - if payload == self._payload_on: - 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.async_schedule_update_ha_state() - - yield from mqtt.async_subscribe( - self.hass, self._state_topic, state_message_received, self._qos) - - @property - def should_poll(self): - """Return the polling state.""" - return False - - @property - def name(self): - """Return the name of the binary 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 this sensor.""" - return self._device_class - - @property - def force_update(self): - """Force update.""" - return self._force_update - - @property - def unique_id(self): - """Return a unique ID.""" - return self._unique_id diff --git a/homeassistant/components/binary_sensor/mychevy.py b/homeassistant/components/binary_sensor/mychevy.py deleted file mode 100644 index d1438379d..000000000 --- a/homeassistant/components/binary_sensor/mychevy.py +++ /dev/null @@ -1,94 +0,0 @@ -"""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_entities, - discovery_info=None): - """Set up the MyChevy sensors.""" - if discovery_info is None: - return - - sensors = [] - hub = hass.data[MYCHEVY_DOMAIN] - for sconfig in SENSORS: - for car in hub.cars: - sensors.append(EVBinarySensor(hub, sconfig, car.vid)) - - async_add_entities(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, car_vid): - """Initialize sensor with car connection.""" - self._conn = connection - self._name = config.name - self._attr = config.attr - self._type = config.device_class - self._is_on = None - self._car_vid = car_vid - self.entity_id = ENTITY_ID_FORMAT.format( - '{}_{}_{}'.format(MYCHEVY_DOMAIN, - slugify(self._car.name), - 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 - - @property - def _car(self): - """Return the car.""" - return self._conn.get_car(self._car_vid) - - @asyncio.coroutine - def async_added_to_hass(self): - """Register callbacks.""" - self.hass.helpers.dispatcher.async_dispatcher_connect( - UPDATE_TOPIC, self.async_update_callback) - - @callback - def async_update_callback(self): - """Update state.""" - if self._car is not None: - self._is_on = getattr(self._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 deleted file mode 100644 index f0b7832cf..000000000 --- a/homeassistant/components/binary_sensor/mysensors.py +++ /dev/null @@ -1,48 +0,0 @@ -""" -Support for MySensors binary sensors. - -For more details about this platform, please refer to the documentation at -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.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', -} - - -async def async_setup_platform( - hass, config, async_add_entities, discovery_info=None): - """Set up the mysensors platform for binary sensors.""" - mysensors.setup_mysensors_platform( - hass, DOMAIN, discovery_info, MySensorsBinarySensor, - async_add_entities=async_add_entities) - - -class MySensorsBinarySensor( - mysensors.device.MySensorsEntity, BinarySensorDevice): - """Representation of a MySensors Binary Sensor child node.""" - - @property - def is_on(self): - """Return True if the binary sensor is on.""" - return self._values.get(self.value_type) == STATE_ON - - @property - def device_class(self): - """Return the class of this sensor, from DEVICE_CLASSES.""" - pres = self.gateway.const.Presentation - 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 deleted file mode 100644 index 23f40ce0a..000000000 --- a/homeassistant/components/binary_sensor/mystrom.py +++ /dev/null @@ -1,99 +0,0 @@ -""" -Support for the myStrom buttons. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.mystrom/ -""" -import asyncio -import logging - -from homeassistant.components.binary_sensor import DOMAIN, BinarySensorDevice -from homeassistant.components.http import HomeAssistantView -from homeassistant.const import HTTP_UNPROCESSABLE_ENTITY - -_LOGGER = logging.getLogger(__name__) - -DEPENDENCIES = ['http'] - - -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): - """Set up myStrom Binary Sensor.""" - hass.http.register_view(MyStromView(async_add_entities)) - - return True - - -class MyStromView(HomeAssistantView): - """View to handle requests from myStrom buttons.""" - - url = '/api/mystrom' - name = 'api:mystrom' - supported_actions = ['single', 'double', 'long', 'touch'] - - def __init__(self, add_entities): - """Initialize the myStrom URL endpoint.""" - self.buttons = {} - self.add_entities = add_entities - - @asyncio.coroutine - def get(self, request): - """Handle the GET request received from a myStrom button.""" - res = yield from self._handle(request.app['hass'], request.query) - return res - - @asyncio.coroutine - def _handle(self, hass, data): - """Handle requests to the myStrom endpoint.""" - button_action = next(( - parameter for parameter in data - if parameter in self.supported_actions), None) - - if button_action is None: - _LOGGER.error( - "Received unidentified message from myStrom button: %s", data) - return ("Received unidentified message: {}".format(data), - HTTP_UNPROCESSABLE_ENTITY) - - button_id = data[button_action] - entity_id = '{}.{}_{}'.format(DOMAIN, button_id, button_action) - if entity_id not in self.buttons: - _LOGGER.info("New myStrom button/action detected: %s/%s", - button_id, button_action) - self.buttons[entity_id] = MyStromBinarySensor( - '{}_{}'.format(button_id, button_action)) - self.add_entities([self.buttons[entity_id]]) - else: - new_state = True if self.buttons[entity_id].state == 'off' \ - else False - self.buttons[entity_id].async_on_update(new_state) - - -class MyStromBinarySensor(BinarySensorDevice): - """Representation of a myStrom button.""" - - def __init__(self, button_id): - """Initialize the myStrom Binary sensor.""" - self._button_id = button_id - self._state = None - - @property - def name(self): - """Return the name of the sensor.""" - return self._button_id - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def is_on(self): - """Return true if the binary sensor is on.""" - return self._state - - def async_on_update(self, value): - """Receive an update.""" - self._state = value - self.async_schedule_update_ha_state() diff --git a/homeassistant/components/binary_sensor/nest.py b/homeassistant/components/binary_sensor/nest.py deleted file mode 100644 index c60463a86..000000000 --- a/homeassistant/components/binary_sensor/nest.py +++ /dev/null @@ -1,157 +0,0 @@ -""" -Support for Nest Thermostat Binary Sensors. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.nest/ -""" -from itertools import chain -import logging - -from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.components.nest import ( - DATA_NEST, DATA_NEST_CONFIG, CONF_BINARY_SENSORS, NestSensorDevice) -from homeassistant.const import CONF_MONITORED_CONDITIONS - -DEPENDENCIES = ['nest'] - -BINARY_TYPES = {'online': 'connectivity'} - -CLIMATE_BINARY_TYPES = { - 'fan': None, - 'is_using_emergency_heat': 'heat', - 'is_locked': None, - 'has_leaf': None, -} - -CAMERA_BINARY_TYPES = { - 'motion_detected': 'motion', - 'sound_detected': 'sound', - 'person_detected': 'occupancy', -} - -STRUCTURE_BINARY_TYPES = { - 'away': None, -} - -STRUCTURE_BINARY_STATE_MAP = { - 'away': {'away': True, 'home': False}, -} - -_BINARY_TYPES_DEPRECATED = [ - 'hvac_ac_state', - 'hvac_aux_heater_state', - 'hvac_heater_state', - 'hvac_heat_x2_state', - 'hvac_heat_x3_state', - 'hvac_alt_heat_state', - 'hvac_alt_heat_x2_state', - 'hvac_emer_heat_state', -] - -_VALID_BINARY_SENSOR_TYPES = {**BINARY_TYPES, **CLIMATE_BINARY_TYPES, - **CAMERA_BINARY_TYPES, **STRUCTURE_BINARY_TYPES} - -_LOGGER = logging.getLogger(__name__) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Nest binary sensors. - - No longer used. - """ - - -async def async_setup_entry(hass, entry, async_add_entities): - """Set up a Nest binary sensor based on a config entry.""" - nest = hass.data[DATA_NEST] - - discovery_info = \ - hass.data.get(DATA_NEST_CONFIG, {}).get(CONF_BINARY_SENSORS, {}) - - # Add all available binary sensors if no Nest binary sensor config is set - if discovery_info == {}: - conditions = _VALID_BINARY_SENSOR_TYPES - else: - conditions = discovery_info.get(CONF_MONITORED_CONDITIONS, {}) - - for variable in conditions: - if variable in _BINARY_TYPES_DEPRECATED: - wstr = (variable + " is no a longer supported " - "monitored_conditions. See " - "https://home-assistant.io/components/binary_sensor.nest/ " - "for valid options.") - _LOGGER.error(wstr) - - def get_binary_sensors(): - """Get the Nest binary sensors.""" - sensors = [] - for structure in nest.structures(): - sensors += [NestBinarySensor(structure, None, variable) - for variable in conditions - if variable in STRUCTURE_BINARY_TYPES] - device_chain = chain(nest.thermostats(), - nest.smoke_co_alarms(), - nest.cameras()) - for structure, device in device_chain: - sensors += [NestBinarySensor(structure, device, variable) - for variable in conditions - if variable in BINARY_TYPES] - sensors += [NestBinarySensor(structure, device, variable) - for variable in conditions - if variable in CLIMATE_BINARY_TYPES - and device.is_thermostat] - - if device.is_camera: - sensors += [NestBinarySensor(structure, device, variable) - for variable in conditions - if variable in CAMERA_BINARY_TYPES] - for activity_zone in device.activity_zones: - sensors += [NestActivityZoneSensor(structure, - device, - activity_zone)] - - return sensors - - async_add_entities(await hass.async_add_job(get_binary_sensors), True) - - -class NestBinarySensor(NestSensorDevice, BinarySensorDevice): - """Represents a Nest binary sensor.""" - - @property - def is_on(self): - """Return true if the binary sensor is on.""" - return self._state - - @property - def device_class(self): - """Return the device class of the binary sensor.""" - return _VALID_BINARY_SENSOR_TYPES.get(self.variable) - - def update(self): - """Retrieve latest state.""" - value = getattr(self.device, self.variable) - if self.variable in STRUCTURE_BINARY_TYPES: - self._state = bool(STRUCTURE_BINARY_STATE_MAP - [self.variable].get(value)) - else: - self._state = bool(value) - - -class NestActivityZoneSensor(NestBinarySensor): - """Represents a Nest binary sensor for activity in a zone.""" - - def __init__(self, structure, device, zone): - """Initialize the sensor.""" - super(NestActivityZoneSensor, self).__init__(structure, device, "") - self.zone = zone - self._name = "{} {} activity".format(self._name, self.zone.name) - - @property - def device_class(self): - """Return the device class of the binary sensor.""" - return 'motion' - - def update(self): - """Retrieve latest state.""" - self._state = self.device.has_ongoing_motion_in_zone(self.zone.zone_id) diff --git a/homeassistant/components/binary_sensor/netatmo.py b/homeassistant/components/binary_sensor/netatmo.py deleted file mode 100644 index 2cafacf40..000000000 --- a/homeassistant/components/binary_sensor/netatmo.py +++ /dev/null @@ -1,198 +0,0 @@ -""" -Support for the Netatmo binary sensors. - -The binary sensors based on events seen by the Netatmo cameras. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.netatmo/. -""" -import logging - -import voluptuous as vol - -from homeassistant.components.binary_sensor import ( - BinarySensorDevice, PLATFORM_SCHEMA) -from homeassistant.components.netatmo import CameraData -from homeassistant.const import CONF_TIMEOUT -from homeassistant.helpers import config_validation as cv - -_LOGGER = logging.getLogger(__name__) - -DEPENDENCIES = ['netatmo'] - -# These are the available sensors mapped to binary_sensor class -WELCOME_SENSOR_TYPES = { - "Someone known": "motion", - "Someone unknown": "motion", - "Motion": "motion", -} -PRESENCE_SENSOR_TYPES = { - "Outdoor motion": "motion", - "Outdoor human": "motion", - "Outdoor animal": "motion", - "Outdoor vehicle": "motion" -} -TAG_SENSOR_TYPES = { - "Tag Vibration": "vibration", - "Tag Open": "opening" -} - -CONF_HOME = 'home' -CONF_CAMERAS = 'cameras' -CONF_WELCOME_SENSORS = 'welcome_sensors' -CONF_PRESENCE_SENSORS = 'presence_sensors' -CONF_TAG_SENSORS = 'tag_sensors' - -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_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=list(WELCOME_SENSOR_TYPES)): - vol.All(cv.ensure_list, [vol.In(WELCOME_SENSOR_TYPES)]), -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the access to Netatmo binary sensor.""" - netatmo = hass.components.netatmo - home = config.get(CONF_HOME) - timeout = config.get(CONF_TIMEOUT) - if timeout is None: - timeout = DEFAULT_TIMEOUT - - module_name = None - - import pyatmo - try: - data = CameraData(netatmo.NETATMO_AUTH, home) - if not data.get_camera_names(): - return None - except pyatmo.NoDevice: - return None - - welcome_sensors = config.get( - CONF_WELCOME_SENSORS, WELCOME_SENSOR_TYPES) - presence_sensors = config.get( - CONF_PRESENCE_SENSORS, PRESENCE_SENSOR_TYPES) - tag_sensors = config.get(CONF_TAG_SENSORS, TAG_SENSOR_TYPES) - - for camera_name in data.get_camera_names(): - camera_type = data.get_camera_type(camera=camera_name, home=home) - if camera_type == 'NACamera': - if CONF_CAMERAS in config: - if config[CONF_CAMERAS] != [] and \ - camera_name not in config[CONF_CAMERAS]: - continue - for variable in welcome_sensors: - add_entities([NetatmoBinarySensor( - data, camera_name, module_name, home, timeout, - camera_type, variable)], True) - if camera_type == 'NOC': - if CONF_CAMERAS in config: - if config[CONF_CAMERAS] != [] and \ - camera_name not in config[CONF_CAMERAS]: - continue - for variable in presence_sensors: - add_entities([NetatmoBinarySensor( - 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_entities([NetatmoBinarySensor( - data, camera_name, module_name, home, timeout, - camera_type, variable)], True) - - -class NetatmoBinarySensor(BinarySensorDevice): - """Represent a single binary sensor in a Netatmo Camera device.""" - - def __init__(self, data, camera_name, module_name, home, - 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 - if home: - self._name = '{} / {}'.format(home, camera_name) - else: - self._name = camera_name - if module_name: - self._name += ' / ' + module_name - self._sensor_name = sensor - self._name += ' ' + sensor - self._cameratype = camera_type - self._state = None - - @property - def name(self): - """Return the name of the Netatmo device and this sensor.""" - return self._name - - @property - def device_class(self): - """Return the class of this sensor, from DEVICE_CLASSES.""" - if self._cameratype == 'NACamera': - return WELCOME_SENSOR_TYPES.get(self._sensor_name) - if self._cameratype == 'NOC': - return PRESENCE_SENSOR_TYPES.get(self._sensor_name) - return TAG_SENSOR_TYPES.get(self._sensor_name) - - @property - def is_on(self): - """Return true if binary sensor is on.""" - return self._state - - def update(self): - """Request an update from the Netatmo API.""" - self._data.update() - self._data.update_event() - - if self._cameratype == 'NACamera': - if self._sensor_name == "Someone known": - self._state =\ - self._data.camera_data.someoneKnownSeen( - 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) - elif self._sensor_name == "Motion": - self._state =\ - self._data.camera_data.motionDetected( - 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._timeout) - elif self._sensor_name == "Outdoor human": - self._state =\ - self._data.camera_data.humanDetected( - 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._timeout) - elif self._sensor_name == "Outdoor vehicle": - self._state =\ - self._data.camera_data.carDetected( - 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) - elif self._sensor_name == "Tag Open": - self._state =\ - self._data.camera_data.moduleOpened( - self._home, self._module_name, self._camera_name, - self._timeout) diff --git a/homeassistant/components/binary_sensor/nx584.py b/homeassistant/components/binary_sensor/nx584.py deleted file mode 100644 index 2929acc27..000000000 --- a/homeassistant/components/binary_sensor/nx584.py +++ /dev/null @@ -1,148 +0,0 @@ -""" -Support for exposing NX584 elements as sensors. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.nx584/ -""" -import logging -import threading -import time - -import requests -import voluptuous as vol - -from homeassistant.components.binary_sensor import ( - DEVICE_CLASSES, BinarySensorDevice, PLATFORM_SCHEMA) -from homeassistant.const import (CONF_HOST, CONF_PORT) -import homeassistant.helpers.config_validation as cv - -REQUIREMENTS = ['pynx584==0.4'] - -_LOGGER = logging.getLogger(__name__) - -CONF_EXCLUDE_ZONES = 'exclude_zones' -CONF_ZONE_TYPES = 'zone_types' - -DEFAULT_HOST = 'localhost' -DEFAULT_PORT = '5007' -DEFAULT_SSL = False - -ZONE_TYPES_SCHEMA = vol.Schema({ - cv.positive_int: vol.In(DEVICE_CLASSES), -}) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_EXCLUDE_ZONES, default=[]): - vol.All(cv.ensure_list, [cv.positive_int]), - vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_ZONE_TYPES, default={}): ZONE_TYPES_SCHEMA, -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the NX584 binary sensor platform.""" - from nx584 import client as nx584_client - - host = config.get(CONF_HOST) - port = config.get(CONF_PORT) - exclude = config.get(CONF_EXCLUDE_ZONES) - zone_types = config.get(CONF_ZONE_TYPES) - - try: - client = nx584_client.Client('http://{}:{}'.format(host, port)) - zones = client.list_zones() - except requests.exceptions.ConnectionError as ex: - _LOGGER.error("Unable to connect to NX584: %s", str(ex)) - return False - - version = [int(v) for v in client.get_version().split('.')] - if version < [1, 1]: - _LOGGER.error("NX584 is too old to use for sensors (>=0.2 required)") - return False - - zone_sensors = { - zone['number']: NX584ZoneSensor( - zone, - zone_types.get(zone['number'], 'opening')) - for zone in zones - if zone['number'] not in exclude} - if zone_sensors: - add_entities(zone_sensors.values()) - watcher = NX584Watcher(client, zone_sensors) - watcher.start() - else: - _LOGGER.warning("No zones found on NX584") - return True - - -class NX584ZoneSensor(BinarySensorDevice): - """Representation of a NX584 zone as a sensor.""" - - def __init__(self, zone, zone_type): - """Initialize the nx594 binary sensor.""" - self._zone = zone - self._zone_type = zone_type - - @property - def device_class(self): - """Return the class of this sensor, from DEVICE_CLASSES.""" - return self._zone_type - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def name(self): - """Return the name of the binary sensor.""" - return self._zone['name'] - - @property - def is_on(self): - """Return true if the binary sensor is on.""" - # True means "faulted" or "open" or "abnormal state" - return self._zone['state'] - - -class NX584Watcher(threading.Thread): - """Event listener thread to process NX584 events.""" - - def __init__(self, client, zone_sensors): - """Initialize NX584 watcher thread.""" - super(NX584Watcher, self).__init__() - self.daemon = True - self._client = client - self._zone_sensors = zone_sensors - - def _process_zone_event(self, event): - zone = event['zone'] - zone_sensor = self._zone_sensors.get(zone) - # pylint: disable=protected-access - if not zone_sensor: - return - zone_sensor._zone['state'] = event['zone_state'] - zone_sensor.schedule_update_ha_state() - - def _process_events(self, events): - for event in events: - if event.get('type') == 'zone_status': - self._process_zone_event(event) - - def _run(self): - """Throw away any existing events so we don't replay history.""" - self._client.get_events() - while True: - events = self._client.get_events() - if events: - self._process_events(events) - - def run(self): - """Run the watcher.""" - while True: - try: - self._run() - except requests.exceptions.ConnectionError: - _LOGGER.error("Failed to reach NX584 server") - time.sleep(10) diff --git a/homeassistant/components/binary_sensor/octoprint.py b/homeassistant/components/binary_sensor/octoprint.py deleted file mode 100644 index 3dd1ee2be..000000000 --- a/homeassistant/components/binary_sensor/octoprint.py +++ /dev/null @@ -1,96 +0,0 @@ -""" -Support for monitoring OctoPrint binary sensors. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.octoprint/ -""" -import logging - -import requests -import voluptuous as vol - -from homeassistant.const import CONF_NAME, CONF_MONITORED_CONDITIONS -from homeassistant.components.binary_sensor import ( - BinarySensorDevice, PLATFORM_SCHEMA) -import homeassistant.helpers.config_validation as cv - -_LOGGER = logging.getLogger(__name__) - -DEPENDENCIES = ['octoprint'] -DOMAIN = "octoprint" -DEFAULT_NAME = 'OctoPrint' - -SENSOR_TYPES = { - # API Endpoint, Group, Key, unit - 'Printing': ['printer', 'state', 'printing', None], - 'Printing Error': ['printer', 'state', 'error', None] -} - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): - vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the available OctoPrint binary sensors.""" - octoprint_api = hass.data[DOMAIN]["api"] - name = config.get(CONF_NAME) - monitored_conditions = config.get( - CONF_MONITORED_CONDITIONS, SENSOR_TYPES.keys()) - - devices = [] - for octo_type in monitored_conditions: - new_sensor = OctoPrintBinarySensor( - octoprint_api, octo_type, SENSOR_TYPES[octo_type][2], - name, SENSOR_TYPES[octo_type][3], SENSOR_TYPES[octo_type][0], - SENSOR_TYPES[octo_type][1], 'flags') - devices.append(new_sensor) - add_entities(devices, True) - - -class OctoPrintBinarySensor(BinarySensorDevice): - """Representation an OctoPrint binary sensor.""" - - def __init__(self, api, condition, sensor_type, sensor_name, unit, - endpoint, group, tool=None): - """Initialize a new OctoPrint sensor.""" - self.sensor_name = sensor_name - if tool is None: - self._name = '{} {}'.format(sensor_name, condition) - else: - self._name = '{} {}'.format(sensor_name, condition) - self.sensor_type = sensor_type - self.api = api - self._state = False - self._unit_of_measurement = unit - self.api_endpoint = endpoint - self.api_group = group - self.api_tool = tool - _LOGGER.debug("Created OctoPrint binary sensor %r", self) - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def is_on(self): - """Return true if binary sensor is on.""" - return bool(self._state) - - @property - def device_class(self): - """Return the class of this sensor, from DEVICE_CLASSES.""" - return None - - def update(self): - """Update state of sensor.""" - try: - self._state = self.api.update( - self.sensor_type, self.api_endpoint, self.api_group, - self.api_tool) - except requests.exceptions.ConnectionError: - # Error calling the api, already logged in api.update() - return diff --git a/homeassistant/components/binary_sensor/openuv.py b/homeassistant/components/binary_sensor/openuv.py deleted file mode 100644 index c7c27d73e..000000000 --- a/homeassistant/components/binary_sensor/openuv.py +++ /dev/null @@ -1,108 +0,0 @@ -""" -This platform provides binary sensors for OpenUV data. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.openuv/ -""" -import logging - -from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.components.openuv import ( - BINARY_SENSORS, DATA_OPENUV_CLIENT, DATA_PROTECTION_WINDOW, DOMAIN, - TOPIC_UPDATE, TYPE_PROTECTION_WINDOW, OpenUvEntity) -from homeassistant.util.dt import as_local, parse_datetime, utcnow - -DEPENDENCIES = ['openuv'] -_LOGGER = logging.getLogger(__name__) - -ATTR_PROTECTION_WINDOW_STARTING_TIME = 'start_time' -ATTR_PROTECTION_WINDOW_STARTING_UV = 'start_uv' -ATTR_PROTECTION_WINDOW_ENDING_TIME = 'end_time' -ATTR_PROTECTION_WINDOW_ENDING_UV = 'end_uv' - - -async def async_setup_platform( - hass, config, async_add_entities, discovery_info=None): - """Set up an OpenUV sensor based on existing config.""" - pass - - -async def async_setup_entry(hass, entry, async_add_entities): - """Set up an OpenUV sensor based on a config entry.""" - openuv = hass.data[DOMAIN][DATA_OPENUV_CLIENT][entry.entry_id] - - binary_sensors = [] - for sensor_type in openuv.binary_sensor_conditions: - name, icon = BINARY_SENSORS[sensor_type] - binary_sensors.append( - OpenUvBinarySensor( - openuv, sensor_type, name, icon, entry.entry_id)) - - async_add_entities(binary_sensors, True) - - -class OpenUvBinarySensor(OpenUvEntity, BinarySensorDevice): - """Define a binary sensor for OpenUV.""" - - def __init__(self, openuv, sensor_type, name, icon, entry_id): - """Initialize the sensor.""" - super().__init__(openuv) - - self._entry_id = entry_id - self._icon = icon - self._latitude = openuv.client.latitude - self._longitude = openuv.client.longitude - self._name = name - self._dispatch_remove = None - self._sensor_type = sensor_type - self._state = None - - @property - def icon(self): - """Return the icon.""" - return self._icon - - @property - def is_on(self): - """Return the status of the sensor.""" - return self._state - - @property - def should_poll(self): - """Disable polling.""" - return False - - @property - def unique_id(self) -> str: - """Return a unique, HASS-friendly identifier for this entity.""" - return '{0}_{1}_{2}'.format( - self._latitude, self._longitude, self._sensor_type) - - @callback - def _update_data(self): - """Update the state.""" - self.async_schedule_update_ha_state(True) - - async def async_added_to_hass(self): - """Register callbacks.""" - self._dispatch_remove = async_dispatcher_connect( - self.hass, TOPIC_UPDATE, self._update_data) - self.async_on_remove(self._dispatch_remove) - - async def async_update(self): - """Update the state.""" - data = self.openuv.data[DATA_PROTECTION_WINDOW]['result'] - if self._sensor_type == TYPE_PROTECTION_WINDOW: - self._state = parse_datetime( - data['from_time']) <= utcnow() <= parse_datetime( - data['to_time']) - self._attrs.update({ - ATTR_PROTECTION_WINDOW_ENDING_TIME: - as_local(parse_datetime(data['to_time'])), - ATTR_PROTECTION_WINDOW_ENDING_UV: data['to_uv'], - ATTR_PROTECTION_WINDOW_STARTING_UV: data['from_uv'], - ATTR_PROTECTION_WINDOW_STARTING_TIME: - as_local(parse_datetime(data['from_time'])), - }) diff --git a/homeassistant/components/binary_sensor/pilight.py b/homeassistant/components/binary_sensor/pilight.py deleted file mode 100644 index abffffe86..000000000 --- a/homeassistant/components/binary_sensor/pilight.py +++ /dev/null @@ -1,188 +0,0 @@ -""" -Support for Pilight binary sensors. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.pilight/ -""" -import datetime -import logging - -import voluptuous as vol -from homeassistant.components import pilight -from homeassistant.components.binary_sensor import ( - PLATFORM_SCHEMA, - BinarySensorDevice, -) -from homeassistant.const import ( - CONF_DISARM_AFTER_TRIGGER, - CONF_NAME, - CONF_PAYLOAD, - CONF_PAYLOAD_OFF, - CONF_PAYLOAD_ON -) -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.event import track_point_in_time -from homeassistant.util import dt as dt_util - - -_LOGGER = logging.getLogger(__name__) - -CONF_VARIABLE = 'variable' -CONF_RESET_DELAY_SEC = 'reset_delay_sec' - -DEFAULT_NAME = 'Pilight Binary Sensor' -DEPENDENCIES = ['pilight'] - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_VARIABLE): cv.string, - vol.Required(CONF_PAYLOAD): vol.Schema(dict), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PAYLOAD_ON, default='on'): cv.string, - vol.Optional(CONF_PAYLOAD_OFF, default='off'): cv.string, - vol.Optional(CONF_DISARM_AFTER_TRIGGER, default=False): cv.boolean, - vol.Optional(CONF_RESET_DELAY_SEC, default=30): cv.positive_int -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up Pilight Binary Sensor.""" - disarm = config.get(CONF_DISARM_AFTER_TRIGGER) - if disarm: - add_entities([PilightTriggerSensor( - hass=hass, - name=config.get(CONF_NAME), - variable=config.get(CONF_VARIABLE), - payload=config.get(CONF_PAYLOAD), - on_value=config.get(CONF_PAYLOAD_ON), - off_value=config.get(CONF_PAYLOAD_OFF), - rst_dly_sec=config.get(CONF_RESET_DELAY_SEC), - )]) - else: - add_entities([PilightBinarySensor( - hass=hass, - name=config.get(CONF_NAME), - variable=config.get(CONF_VARIABLE), - payload=config.get(CONF_PAYLOAD), - on_value=config.get(CONF_PAYLOAD_ON), - off_value=config.get(CONF_PAYLOAD_OFF), - )]) - - -class PilightBinarySensor(BinarySensorDevice): - """Representation of a binary sensor that can be updated using Pilight.""" - - def __init__(self, hass, name, variable, payload, on_value, off_value): - """Initialize the sensor.""" - self._state = False - self._hass = hass - self._name = name - self._variable = variable - self._payload = payload - self._on_value = on_value - self._off_value = off_value - - hass.bus.listen(pilight.EVENT, self._handle_code) - - @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 - - def _handle_code(self, call): - """Handle received code by the pilight-daemon. - - If the code matches the defined payload - of this sensor the sensor state is changed accordingly. - """ - # Check if received code matches defined playoad - # True if payload is contained in received code dict - payload_ok = True - for key in self._payload: - if key not in call.data: - payload_ok = False - continue - if self._payload[key] != call.data[key]: - payload_ok = False - # Read out variable if payload ok - if payload_ok: - if self._variable not in call.data: - return - value = call.data[self._variable] - self._state = (value == self._on_value) - self.schedule_update_ha_state() - - -class PilightTriggerSensor(BinarySensorDevice): - """Representation of a binary sensor that can be updated using Pilight.""" - - def __init__( - self, - hass, - name, - variable, - payload, - on_value, - off_value, - rst_dly_sec=30): - """Initialize the sensor.""" - self._state = False - self._hass = hass - self._name = name - self._variable = variable - self._payload = payload - self._on_value = on_value - self._off_value = off_value - self._reset_delay_sec = rst_dly_sec - self._delay_after = None - self._hass = hass - - hass.bus.listen(pilight.EVENT, self._handle_code) - - @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 - - def _reset_state(self, call): - self._state = False - self._delay_after = None - self.schedule_update_ha_state() - - def _handle_code(self, call): - """Handle received code by the pilight-daemon. - - If the code matches the defined payload - of this sensor the sensor state is changed accordingly. - """ - # Check if received code matches defined payload - # True if payload is contained in received code dict - payload_ok = True - for key in self._payload: - if key not in call.data: - payload_ok = False - continue - if self._payload[key] != call.data[key]: - payload_ok = False - # Read out variable if payload ok - if payload_ok: - if self._variable not in call.data: - return - value = call.data[self._variable] - self._state = (value == self._on_value) - if self._delay_after is None: - self._delay_after = dt_util.utcnow() + datetime.timedelta( - seconds=self._reset_delay_sec) - track_point_in_time( - self._hass, self._reset_state, - self._delay_after) - self.schedule_update_ha_state() diff --git a/homeassistant/components/binary_sensor/ping.py b/homeassistant/components/binary_sensor/ping.py deleted file mode 100644 index 4c597dd63..000000000 --- a/homeassistant/components/binary_sensor/ping.py +++ /dev/null @@ -1,153 +0,0 @@ -""" -Tracks the latency of a host by sending ICMP echo requests (ping). - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.ping/ -""" -import logging -import subprocess -import re -import sys -from datetime import timedelta - -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, CONF_HOST - -_LOGGER = logging.getLogger(__name__) - -ATTR_ROUND_TRIP_TIME_AVG = 'round_trip_time_avg' -ATTR_ROUND_TRIP_TIME_MAX = 'round_trip_time_max' -ATTR_ROUND_TRIP_TIME_MDEV = 'round_trip_time_mdev' -ATTR_ROUND_TRIP_TIME_MIN = 'round_trip_time_min' - -CONF_PING_COUNT = 'count' - -DEFAULT_NAME = 'Ping Binary sensor' -DEFAULT_PING_COUNT = 5 -DEFAULT_DEVICE_CLASS = 'connectivity' - -SCAN_INTERVAL = timedelta(minutes=5) - -PING_MATCHER = re.compile( - r'(?P\d+.\d+)\/(?P\d+.\d+)\/(?P\d+.\d+)\/(?P\d+.\d+)') - -PING_MATCHER_BUSYBOX = re.compile( - r'(?P\d+.\d+)\/(?P\d+.\d+)\/(?P\d+.\d+)') - -WIN32_PING_MATCHER = re.compile( - r'(?P\d+)ms.+(?P\d+)ms.+(?P\d+)ms') - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PING_COUNT, default=DEFAULT_PING_COUNT): cv.positive_int, -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Ping Binary sensor.""" - name = config.get(CONF_NAME) - host = config.get(CONF_HOST) - count = config.get(CONF_PING_COUNT) - - add_entities([PingBinarySensor(name, PingData(host, count))], True) - - -class PingBinarySensor(BinarySensorDevice): - """Representation of a Ping Binary sensor.""" - - def __init__(self, name, ping): - """Initialize the Ping Binary sensor.""" - self._name = name - self.ping = ping - - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def device_class(self): - """Return the class of this sensor.""" - return DEFAULT_DEVICE_CLASS - - @property - def is_on(self): - """Return true if the binary sensor is on.""" - return self.ping.available - - @property - def device_state_attributes(self): - """Return the state attributes of the ICMP checo request.""" - if self.ping.data is not False: - return { - ATTR_ROUND_TRIP_TIME_AVG: self.ping.data['avg'], - ATTR_ROUND_TRIP_TIME_MAX: self.ping.data['max'], - ATTR_ROUND_TRIP_TIME_MDEV: self.ping.data['mdev'], - ATTR_ROUND_TRIP_TIME_MIN: self.ping.data['min'], - } - - def update(self): - """Get the latest data.""" - self.ping.update() - - -class PingData: - """The Class for handling the data retrieval.""" - - def __init__(self, host, count): - """Initialize the data object.""" - self._ip_address = host - self._count = count - self.data = {} - self.available = False - - if sys.platform == 'win32': - self._ping_cmd = [ - 'ping', '-n', str(self._count), '-w', '1000', self._ip_address] - else: - self._ping_cmd = [ - 'ping', '-n', '-q', '-c', str(self._count), '-W1', - self._ip_address] - - def ping(self): - """Send ICMP echo request and return details if success.""" - pinger = subprocess.Popen( - self._ping_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - try: - out = pinger.communicate() - _LOGGER.debug("Output is %s", str(out)) - if sys.platform == 'win32': - match = WIN32_PING_MATCHER.search(str(out).split('\n')[-1]) - rtt_min, rtt_avg, rtt_max = match.groups() - return { - 'min': rtt_min, - 'avg': rtt_avg, - 'max': rtt_max, - 'mdev': ''} - if 'max/' not in str(out): - match = PING_MATCHER_BUSYBOX.search(str(out).split('\n')[-1]) - rtt_min, rtt_avg, rtt_max = match.groups() - return { - 'min': rtt_min, - 'avg': rtt_avg, - 'max': rtt_max, - 'mdev': ''} - match = PING_MATCHER.search(str(out).split('\n')[-1]) - rtt_min, rtt_avg, rtt_max, rtt_mdev = match.groups() - return { - 'min': rtt_min, - 'avg': rtt_avg, - 'max': rtt_max, - 'mdev': rtt_mdev} - except (subprocess.CalledProcessError, AttributeError): - return False - - def update(self): - """Retrieve the latest details from the host.""" - self.data = self.ping() - self.available = bool(self.data) diff --git a/homeassistant/components/binary_sensor/qwikswitch.py b/homeassistant/components/binary_sensor/qwikswitch.py deleted file mode 100644 index 2fe14773d..000000000 --- a/homeassistant/components/binary_sensor/qwikswitch.py +++ /dev/null @@ -1,70 +0,0 @@ -""" -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_entities, 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_entities(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/rachio.py b/homeassistant/components/binary_sensor/rachio.py deleted file mode 100644 index 798b6a754..000000000 --- a/homeassistant/components/binary_sensor/rachio.py +++ /dev/null @@ -1,126 +0,0 @@ -""" -Integration with the Rachio Iro sprinkler system controller. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.rachio/ -""" -from abc import abstractmethod -import logging - -from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.components.rachio import (DOMAIN as DOMAIN_RACHIO, - KEY_DEVICE_ID, - KEY_STATUS, - KEY_SUBTYPE, - SIGNAL_RACHIO_CONTROLLER_UPDATE, - STATUS_OFFLINE, - STATUS_ONLINE, - SUBTYPE_OFFLINE, - SUBTYPE_ONLINE,) -from homeassistant.helpers.dispatcher import dispatcher_connect - -DEPENDENCIES = ['rachio'] - -_LOGGER = logging.getLogger(__name__) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Rachio binary sensors.""" - devices = [] - for controller in hass.data[DOMAIN_RACHIO].controllers: - devices.append(RachioControllerOnlineBinarySensor(hass, controller)) - - add_entities(devices) - _LOGGER.info("%d Rachio binary sensor(s) added", len(devices)) - - -class RachioControllerBinarySensor(BinarySensorDevice): - """Represent a binary sensor that reflects a Rachio state.""" - - def __init__(self, hass, controller, poll=True): - """Set up a new Rachio controller binary sensor.""" - self._controller = controller - - if poll: - self._state = self._poll_update() - else: - self._state = None - - dispatcher_connect(hass, SIGNAL_RACHIO_CONTROLLER_UPDATE, - self._handle_any_update) - - @property - def should_poll(self) -> bool: - """Declare that this entity pushes its state to HA.""" - return False - - @property - def is_on(self) -> bool: - """Return whether the sensor has a 'true' value.""" - return self._state - - def _handle_any_update(self, *args, **kwargs) -> None: - """Determine whether an update event applies to this device.""" - if args[0][KEY_DEVICE_ID] != self._controller.controller_id: - # For another device - return - - # For this device - self._handle_update() - - @abstractmethod - def _poll_update(self, data=None) -> bool: - """Request the state from the API.""" - pass - - @abstractmethod - def _handle_update(self, *args, **kwargs) -> None: - """Handle an update to the state of this sensor.""" - pass - - -class RachioControllerOnlineBinarySensor(RachioControllerBinarySensor): - """Represent a binary sensor that reflects if the controller is online.""" - - def __init__(self, hass, controller): - """Set up a new Rachio controller online binary sensor.""" - super().__init__(hass, controller, poll=False) - self._state = self._poll_update(controller.init_data) - - @property - def name(self) -> str: - """Return the name of this sensor including the controller name.""" - return "{} online".format(self._controller.name) - - @property - def device_class(self) -> str: - """Return the class of this device, from component DEVICE_CLASSES.""" - return 'connectivity' - - @property - def icon(self) -> str: - """Return the name of an icon for this sensor.""" - return 'mdi:wifi-strength-4' if self.is_on\ - else 'mdi:wifi-strength-off-outline' - - def _poll_update(self, data=None) -> bool: - """Request the state from the API.""" - if data is None: - data = self._controller.rachio.device.get( - self._controller.controller_id)[1] - - if data[KEY_STATUS] == STATUS_ONLINE: - return True - if data[KEY_STATUS] == STATUS_OFFLINE: - return False - _LOGGER.warning('"%s" reported in unknown state "%s"', self.name, - data[KEY_STATUS]) - - def _handle_update(self, *args, **kwargs) -> None: - """Handle an update to the state of this sensor.""" - if args[0][KEY_SUBTYPE] == SUBTYPE_ONLINE: - self._state = True - elif args[0][KEY_SUBTYPE] == SUBTYPE_OFFLINE: - self._state = False - - self.schedule_update_ha_state() diff --git a/homeassistant/components/binary_sensor/raincloud.py b/homeassistant/components/binary_sensor/raincloud.py deleted file mode 100644 index 810c7d201..000000000 --- a/homeassistant/components/binary_sensor/raincloud.py +++ /dev/null @@ -1,72 +0,0 @@ -""" -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_entities, 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_entities(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' - if 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/rainmachine.py b/homeassistant/components/binary_sensor/rainmachine.py deleted file mode 100644 index 12c9b3e98..000000000 --- a/homeassistant/components/binary_sensor/rainmachine.py +++ /dev/null @@ -1,103 +0,0 @@ -""" -This platform provides binary sensors for key RainMachine data. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.rainmachine/ -""" -import logging - -from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.components.rainmachine import ( - BINARY_SENSORS, DATA_RAINMACHINE, SENSOR_UPDATE_TOPIC, TYPE_FREEZE, - TYPE_FREEZE_PROTECTION, TYPE_HOT_DAYS, TYPE_HOURLY, TYPE_MONTH, - TYPE_RAINDELAY, TYPE_RAINSENSOR, TYPE_WEEKDAY, RainMachineEntity) -from homeassistant.const import CONF_MONITORED_CONDITIONS -from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect - -DEPENDENCIES = ['rainmachine'] - -_LOGGER = logging.getLogger(__name__) - - -async def async_setup_platform( - hass, config, async_add_entities, discovery_info=None): - """Set up the RainMachine Switch platform.""" - if discovery_info is None: - return - - rainmachine = hass.data[DATA_RAINMACHINE] - - binary_sensors = [] - for sensor_type in discovery_info[CONF_MONITORED_CONDITIONS]: - name, icon = BINARY_SENSORS[sensor_type] - binary_sensors.append( - RainMachineBinarySensor(rainmachine, sensor_type, name, icon)) - - async_add_entities(binary_sensors, True) - - -class RainMachineBinarySensor(RainMachineEntity, BinarySensorDevice): - """A sensor implementation for raincloud device.""" - - def __init__(self, rainmachine, sensor_type, name, icon): - """Initialize the sensor.""" - super().__init__(rainmachine) - - self._icon = icon - self._name = name - self._sensor_type = sensor_type - self._state = None - - @property - def icon(self) -> str: - """Return the icon.""" - return self._icon - - @property - def is_on(self): - """Return the status of the sensor.""" - return self._state - - @property - def should_poll(self): - """Disable polling.""" - return False - - @property - def unique_id(self) -> str: - """Return a unique, HASS-friendly identifier for this entity.""" - return '{0}_{1}'.format( - self.rainmachine.device_mac.replace(':', ''), self._sensor_type) - - @callback - def _update_data(self): - """Update the state.""" - self.async_schedule_update_ha_state(True) - - async def async_added_to_hass(self): - """Register callbacks.""" - async_dispatcher_connect( - self.hass, SENSOR_UPDATE_TOPIC, self._update_data) - - async def async_update(self): - """Update the state.""" - if self._sensor_type == TYPE_FREEZE: - self._state = self.rainmachine.restrictions['current']['freeze'] - elif self._sensor_type == TYPE_FREEZE_PROTECTION: - self._state = self.rainmachine.restrictions['global'][ - 'freezeProtectEnabled'] - elif self._sensor_type == TYPE_HOT_DAYS: - self._state = self.rainmachine.restrictions['global'][ - 'hotDaysExtraWatering'] - elif self._sensor_type == TYPE_HOURLY: - self._state = self.rainmachine.restrictions['current']['hourly'] - elif self._sensor_type == TYPE_MONTH: - self._state = self.rainmachine.restrictions['current']['month'] - elif self._sensor_type == TYPE_RAINDELAY: - self._state = self.rainmachine.restrictions['current']['rainDelay'] - elif self._sensor_type == TYPE_RAINSENSOR: - self._state = self.rainmachine.restrictions['current'][ - 'rainSensor'] - elif self._sensor_type == TYPE_WEEKDAY: - self._state = self.rainmachine.restrictions['current']['weekDay'] diff --git a/homeassistant/components/binary_sensor/random.py b/homeassistant/components/binary_sensor/random.py deleted file mode 100644 index 9bdc57c6e..000000000 --- a/homeassistant/components/binary_sensor/random.py +++ /dev/null @@ -1,62 +0,0 @@ -""" -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 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, -}) - - -async def async_setup_platform( - hass, config, async_add_entities, discovery_info=None): - """Set up the Random binary sensor.""" - name = config.get(CONF_NAME) - device_class = config.get(CONF_DEVICE_CLASS) - - async_add_entities([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 - - async def async_update(self): - """Get new state and update the sensor's state.""" - from random import getrandbits - self._state = bool(getrandbits(1)) diff --git a/homeassistant/components/binary_sensor/raspihats.py b/homeassistant/components/binary_sensor/raspihats.py deleted file mode 100644 index feef5396d..000000000 --- a/homeassistant/components/binary_sensor/raspihats.py +++ /dev/null @@ -1,119 +0,0 @@ -""" -Configure a binary_sensor using a digital input from a raspihats board. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.raspihats/ -""" -import logging - -import voluptuous as vol - -from homeassistant.components.binary_sensor import ( - PLATFORM_SCHEMA, BinarySensorDevice) -from homeassistant.components.raspihats import ( - 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__) - -DEPENDENCIES = ['raspihats'] - -DEFAULT_INVERT_LOGIC = False -DEFAULT_DEVICE_CLASS = None - -_CHANNELS_SCHEMA = vol.Schema([{ - vol.Required(CONF_INDEX): cv.positive_int, - vol.Required(CONF_NAME): cv.string, - vol.Optional(CONF_INVERT_LOGIC, default=DEFAULT_INVERT_LOGIC): cv.boolean, - vol.Optional(CONF_DEVICE_CLASS, default=DEFAULT_DEVICE_CLASS): cv.string, -}]) - -_I2C_HATS_SCHEMA = vol.Schema([{ - vol.Required(CONF_BOARD): vol.In(I2C_HAT_NAMES), - vol.Required(CONF_ADDRESS): vol.Coerce(int), - vol.Required(CONF_CHANNELS): _CHANNELS_SCHEMA -}]) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_I2C_HATS): _I2C_HATS_SCHEMA, -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """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) - for i2c_hat_config in i2c_hat_configs: - address = i2c_hat_config[CONF_ADDRESS] - board = i2c_hat_config[CONF_BOARD] - try: - I2CHatBinarySensor.I2C_HATS_MANAGER.register_board(board, address) - for channel_config in i2c_hat_config[CONF_CHANNELS]: - binary_sensors.append( - I2CHatBinarySensor( - address, - channel_config[CONF_INDEX], - channel_config[CONF_NAME], - channel_config[CONF_INVERT_LOGIC], - channel_config[CONF_DEVICE_CLASS] - ) - ) - except I2CHatsException as ex: - _LOGGER.error("Failed to register %s I2CHat@%s %s", - board, hex(address), str(ex)) - add_entities(binary_sensors) - - -class I2CHatBinarySensor(BinarySensorDevice): - """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 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) - - def online_callback(): - """Call fired when board is online.""" - self.schedule_update_ha_state() - - self.I2C_HATS_MANAGER.register_online_callback( - self._address, self._channel, online_callback) - - def edge_callback(state): - """Read digital input state.""" - self._state = state - self.schedule_update_ha_state() - - self.I2C_HATS_MANAGER.register_di_callback( - self._address, self._channel, edge_callback) - - @property - def device_class(self): - """Return the class of this sensor.""" - return self._device_class - - @property - def name(self): - """Return the name of this sensor.""" - return self._name - - @property - def should_poll(self): - """No polling needed for this sensor.""" - return False - - @property - def is_on(self): - """Return the state of this sensor.""" - return self._state != self._invert_logic diff --git a/homeassistant/components/binary_sensor/rest.py b/homeassistant/components/binary_sensor/rest.py deleted file mode 100644 index 412aeb46a..000000000 --- a/homeassistant/components/binary_sensor/rest.py +++ /dev/null @@ -1,126 +0,0 @@ -""" -Support for RESTful binary sensors. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.rest/ -""" -import logging - -import voluptuous as vol -from requests.auth import HTTPBasicAuth, HTTPDigestAuth - -from homeassistant.components.binary_sensor import ( - BinarySensorDevice, DEVICE_CLASSES_SCHEMA, PLATFORM_SCHEMA) -from homeassistant.components.sensor.rest import RestData -from homeassistant.const import ( - CONF_PAYLOAD, CONF_NAME, CONF_VALUE_TEMPLATE, CONF_METHOD, CONF_RESOURCE, - CONF_VERIFY_SSL, CONF_USERNAME, CONF_PASSWORD, - CONF_HEADERS, CONF_AUTHENTICATION, HTTP_BASIC_AUTHENTICATION, - HTTP_DIGEST_AUTHENTICATION, CONF_DEVICE_CLASS) -import homeassistant.helpers.config_validation as cv - -_LOGGER = logging.getLogger(__name__) - -DEFAULT_METHOD = 'GET' -DEFAULT_NAME = 'REST Binary Sensor' -DEFAULT_VERIFY_SSL = True - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_RESOURCE): cv.url, - vol.Optional(CONF_AUTHENTICATION): - vol.In([HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION]), - vol.Optional(CONF_HEADERS): {cv.string: cv.string}, - vol.Optional(CONF_METHOD, default=DEFAULT_METHOD): vol.In(['POST', 'GET']), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PASSWORD): cv.string, - vol.Optional(CONF_PAYLOAD): cv.string, - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_USERNAME): cv.string, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the REST binary sensor.""" - name = config.get(CONF_NAME) - resource = config.get(CONF_RESOURCE) - method = config.get(CONF_METHOD) - payload = config.get(CONF_PAYLOAD) - verify_ssl = config.get(CONF_VERIFY_SSL) - username = config.get(CONF_USERNAME) - password = config.get(CONF_PASSWORD) - headers = config.get(CONF_HEADERS) - device_class = config.get(CONF_DEVICE_CLASS) - value_template = config.get(CONF_VALUE_TEMPLATE) - if value_template is not None: - value_template.hass = hass - - if username and password: - if config.get(CONF_AUTHENTICATION) == HTTP_DIGEST_AUTHENTICATION: - auth = HTTPDigestAuth(username, password) - else: - auth = HTTPBasicAuth(username, password) - else: - auth = None - - rest = RestData(method, resource, auth, headers, payload, verify_ssl) - rest.update() - - if rest.data is None: - _LOGGER.error("Unable to fetch REST data from %s", resource) - return False - - add_entities([RestBinarySensor( - hass, rest, name, device_class, value_template)], True) - - -class RestBinarySensor(BinarySensorDevice): - """Representation of a REST binary sensor.""" - - def __init__(self, hass, rest, name, device_class, value_template): - """Initialize a REST binary sensor.""" - self._hass = hass - self.rest = rest - self._name = name - self._device_class = device_class - self._state = False - self._previous_data = None - self._value_template = value_template - - @property - def name(self): - """Return the name of the binary sensor.""" - return self._name - - @property - 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.""" - if self.rest.data is None: - return False - - response = self.rest.data - - if self._value_template is not None: - response = self._value_template.\ - async_render_with_possible_json_value(self.rest.data, False) - - try: - return bool(int(response)) - except ValueError: - return {'true': True, 'on': True, 'open': True, - 'yes': True}.get(response.lower(), False) - - def update(self): - """Get the latest data from REST API and updates the state.""" - self.rest.update() diff --git a/homeassistant/components/binary_sensor/rfxtrx.py b/homeassistant/components/binary_sensor/rfxtrx.py deleted file mode 100644 index 1e88c72e1..000000000 --- a/homeassistant/components/binary_sensor/rfxtrx.py +++ /dev/null @@ -1,228 +0,0 @@ -""" -Support for RFXtrx binary sensors. - -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.components.binary_sensor import ( - DEVICE_CLASSES_SCHEMA, PLATFORM_SCHEMA, BinarySensorDevice) -from homeassistant.components.rfxtrx import ( - ATTR_NAME, CONF_AUTOMATIC_ADD, CONF_DATA_BITS, CONF_DEVICES, - CONF_FIRE_EVENT, CONF_OFF_DELAY) -from homeassistant.const import ( - 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__) - -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_entities, discovery_info=None): - """Set up the Binary Sensor platform to RFXtrx.""" - import RFXtrx as rfxtrxmod - sensors = [] - - 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.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 - sensors.append(device) - rfxtrx.RFX_DEVICES[device_id] = device - - add_entities(sensors) - - def binary_sensor_update(event): - """Call for control updates from the RFXtrx gateway.""" - if not isinstance(event, rfxtrxmod.ControlEvent): - return - - device_id = slugify(event.device.id_string.lower()) - - if device_id in rfxtrx.RFX_DEVICES: - sensor = rfxtrx.RFX_DEVICES[device_id] - else: - sensor = rfxtrx.get_pt2262_device(device_id) - - if sensor is None: - # Add the entity if not exists and automatic_add is True - if not config[CONF_AUTOMATIC_ADD]: - return - - 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 - rfxtrx.RFX_DEVICES[device_id] = sensor - add_entities([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.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) - sensor.apply_cmd(int(cmd, 16)) - else: - sensor.update_state(True) - else: - rfxtrx.apply_received_command(event) - - if (sensor.is_on and sensor.off_delay is not None and - sensor.delay_listener is None): - - def off_delay_listener(now): - """Switch device off after a delay.""" - sensor.delay_listener = None - sensor.update_state(False) - - sensor.delay_listener = evt.track_point_in_time( - hass, off_delay_listener, dt_util.utcnow() + sensor.off_delay) - - # Subscribe to main RFXtrx events - if binary_sensor_update not in rfxtrx.RECEIVED_EVT_SUBSCRIBERS: - rfxtrx.RECEIVED_EVT_SUBSCRIBERS.append(binary_sensor_update) - - -class RfxtrxBinarySensor(BinarySensorDevice): - """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 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 = (event.device.packettype == 0x13) - self.delay_listener = None - self._data_bits = data_bits - self._cmd_on = cmd_on - self._cmd_off = cmd_off - - if data_bits is not None: - self._masked_id = rfxtrx.get_pt2262_deviceid( - event.device.id_string.lower(), data_bits) - else: - self._masked_id = None - - @property - def name(self): - """Return the device name.""" - return self._name - - @property - def masked_id(self): - """Return the masked device id (isolated address bits).""" - return self._masked_id - - @property - def data_bits(self): - """Return the number of data bits.""" - return self._data_bits - - @property - def cmd_on(self): - """Return the value of the 'On' command.""" - return self._cmd_on - - @property - def cmd_off(self): - """Return the value of the 'Off' command.""" - return self._cmd_off - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def should_fire_event(self): - """Return is the device must fire event.""" - return self._should_fire_event - - @property - def device_class(self): - """Return the sensor class.""" - return self._device_class - - @property - def off_delay(self): - """Return the off_delay attribute value.""" - return self._off_delay - - @property - def is_on(self): - """Return true if the sensor state is True.""" - return self._state - - def apply_cmd(self, cmd): - """Apply a command for updating the state.""" - if cmd == self.cmd_on: - self.update_state(True) - elif cmd == self.cmd_off: - self.update_state(False) - - def update_state(self, state): - """Update the state of the device.""" - self._state = state - self.schedule_update_ha_state() diff --git a/homeassistant/components/binary_sensor/ring.py b/homeassistant/components/binary_sensor/ring.py deleted file mode 100644 index 102e22cbe..000000000 --- a/homeassistant/components/binary_sensor/ring.py +++ /dev/null @@ -1,116 +0,0 @@ -""" -This component provides HA sensor support for Ring Door Bell/Chimes. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.ring/ -""" -import logging -from datetime import timedelta - -import voluptuous as vol -import homeassistant.helpers.config_validation as cv - -from homeassistant.components.ring import ( - CONF_ATTRIBUTION, DEFAULT_ENTITY_NAMESPACE, DATA_RING) - -from homeassistant.const import ( - ATTR_ATTRIBUTION, CONF_ENTITY_NAMESPACE, CONF_MONITORED_CONDITIONS) - -from homeassistant.components.binary_sensor import ( - BinarySensorDevice, PLATFORM_SCHEMA) - -DEPENDENCIES = ['ring'] - -_LOGGER = logging.getLogger(__name__) - -SCAN_INTERVAL = timedelta(seconds=10) - -# Sensor types: Name, category, device_class -SENSOR_TYPES = { - 'ding': ['Ding', ['doorbell'], 'occupancy'], - '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=list(SENSOR_TYPES)): - vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up a sensor for a Ring device.""" - ring = hass.data[DATA_RING] - - sensors = [] - for sensor_type in config.get(CONF_MONITORED_CONDITIONS): - for device in ring.doorbells: - if 'doorbell' in SENSOR_TYPES[sensor_type][1]: - 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_entities(sensors, True) - return True - - -class RingBinarySensor(BinarySensorDevice): - """A binary sensor implementation for Ring device.""" - - def __init__(self, hass, data, sensor_type): - """Initialize a sensor for Ring device.""" - super(RingBinarySensor, self).__init__() - self._sensor_type = sensor_type - self._data = data - self._name = "{0} {1}".format(self._data.name, - SENSOR_TYPES.get(self._sensor_type)[0]) - self._device_class = SENSOR_TYPES.get(self._sensor_type)[2] - 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 = {} - attrs[ATTR_ATTRIBUTION] = CONF_ATTRIBUTION - - attrs['device_id'] = self._data.id - attrs['firmware'] = self._data.firmware - attrs['timezone'] = self._data.timezone - - if self._data.alert and self._data.alert_expires_at: - attrs['expires_at'] = self._data.alert_expires_at - attrs['state'] = self._data.alert.get('state') - - return attrs - - def update(self): - """Get the latest data and updates the state.""" - self._data.check_alerts() - - if self._data.alert: - if self._sensor_type == self._data.alert.get('kind') and \ - self._data.account_id == self._data.alert.get('doorbot_id'): - self._state = True - else: - self._state = False diff --git a/homeassistant/components/binary_sensor/rpi_gpio.py b/homeassistant/components/binary_sensor/rpi_gpio.py deleted file mode 100644 index 2fe4e0766..000000000 --- a/homeassistant/components/binary_sensor/rpi_gpio.py +++ /dev/null @@ -1,94 +0,0 @@ -""" -Support for binary sensor using RPi GPIO. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.rpi_gpio/ -""" -import logging - -import voluptuous as vol - -from homeassistant.components import rpi_gpio -from homeassistant.components.binary_sensor import ( - BinarySensorDevice, PLATFORM_SCHEMA) -from homeassistant.const import DEVICE_DEFAULT_NAME -import homeassistant.helpers.config_validation as cv - -_LOGGER = logging.getLogger(__name__) - -CONF_BOUNCETIME = 'bouncetime' -CONF_INVERT_LOGIC = 'invert_logic' -CONF_PORTS = 'ports' -CONF_PULL_MODE = 'pull_mode' - -DEFAULT_BOUNCETIME = 50 -DEFAULT_INVERT_LOGIC = False -DEFAULT_PULL_MODE = 'UP' - -DEPENDENCIES = ['rpi_gpio'] - -_SENSORS_SCHEMA = vol.Schema({ - cv.positive_int: cv.string, -}) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_PORTS): _SENSORS_SCHEMA, - vol.Optional(CONF_BOUNCETIME, default=DEFAULT_BOUNCETIME): cv.positive_int, - vol.Optional(CONF_INVERT_LOGIC, default=DEFAULT_INVERT_LOGIC): cv.boolean, - vol.Optional(CONF_PULL_MODE, default=DEFAULT_PULL_MODE): cv.string, -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Raspberry PI GPIO devices.""" - pull_mode = config.get(CONF_PULL_MODE) - bouncetime = config.get(CONF_BOUNCETIME) - invert_logic = config.get(CONF_INVERT_LOGIC) - - binary_sensors = [] - ports = config.get('ports') - for port_num, port_name in ports.items(): - binary_sensors.append(RPiGPIOBinarySensor( - port_name, port_num, pull_mode, bouncetime, invert_logic)) - add_entities(binary_sensors, True) - - -class RPiGPIOBinarySensor(BinarySensorDevice): - """Represent a binary sensor that uses Raspberry Pi GPIO.""" - - def __init__(self, name, port, pull_mode, bouncetime, invert_logic): - """Initialize the RPi binary sensor.""" - self._name = name or DEVICE_DEFAULT_NAME - self._port = port - self._pull_mode = pull_mode - self._bouncetime = bouncetime - self._invert_logic = invert_logic - self._state = None - - rpi_gpio.setup_input(self._port, self._pull_mode) - - def read_gpio(port): - """Read state from GPIO.""" - self._state = rpi_gpio.read_input(self._port) - self.schedule_update_ha_state() - - rpi_gpio.edge_detect(self._port, read_gpio, self._bouncetime) - - @property - def should_poll(self): - """No polling needed.""" - return False - - @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 != self._invert_logic - - def update(self): - """Update the GPIO state.""" - self._state = rpi_gpio.read_input(self._port) diff --git a/homeassistant/components/binary_sensor/rpi_pfio.py b/homeassistant/components/binary_sensor/rpi_pfio.py deleted file mode 100644 index 61d1f8ac2..000000000 --- a/homeassistant/components/binary_sensor/rpi_pfio.py +++ /dev/null @@ -1,92 +0,0 @@ -""" -Support for binary sensor using the PiFace Digital I/O module on a RPi. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.rpi_pfio/ -""" -import logging - -import voluptuous as vol - -from homeassistant.components.binary_sensor import ( - PLATFORM_SCHEMA, BinarySensorDevice) -from homeassistant.components import rpi_pfio -from homeassistant.const import CONF_NAME, DEVICE_DEFAULT_NAME -import homeassistant.helpers.config_validation as cv - -_LOGGER = logging.getLogger(__name__) - -CONF_INVERT_LOGIC = 'invert_logic' -CONF_PORTS = 'ports' -CONF_SETTLE_TIME = 'settle_time' - -DEFAULT_INVERT_LOGIC = False -DEFAULT_SETTLE_TIME = 20 - -DEPENDENCIES = ['rpi_pfio'] - -PORT_SCHEMA = vol.Schema({ - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_SETTLE_TIME, default=DEFAULT_SETTLE_TIME): - cv.positive_int, - 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, - }) -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the PiFace Digital Input devices.""" - binary_sensors = [] - ports = config.get(CONF_PORTS) - for port, port_entity in ports.items(): - 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)) - add_entities(binary_sensors, True) - - rpi_pfio.activate_listener(hass) - - -class RPiPFIOBinarySensor(BinarySensorDevice): - """Represent a binary sensor that a PiFace Digital Input.""" - - def __init__(self, hass, port, name, settle_time, invert_logic): - """Initialize the RPi binary sensor.""" - self._port = port - self._name = name or DEVICE_DEFAULT_NAME - self._invert_logic = invert_logic - self._state = None - - def read_pfio(port): - """Read state from PFIO.""" - self._state = rpi_pfio.read_input(self._port) - self.schedule_update_ha_state() - - rpi_pfio.edge_detect(hass, self._port, read_pfio, settle_time) - - @property - def should_poll(self): - """No polling needed.""" - return False - - @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 != self._invert_logic - - def update(self): - """Update the PFIO state.""" - self._state = rpi_pfio.read_input(self._port) diff --git a/homeassistant/components/binary_sensor/satel_integra.py b/homeassistant/components/binary_sensor/satel_integra.py deleted file mode 100644 index 3500f0a05..000000000 --- a/homeassistant/components/binary_sensor/satel_integra.py +++ /dev/null @@ -1,91 +0,0 @@ -""" -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_entities, - 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_entities(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 deleted file mode 100644 index 7d8b3a84a..000000000 --- a/homeassistant/components/binary_sensor/skybell.py +++ /dev/null @@ -1,97 +0,0 @@ -""" -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_entities, 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_entities(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 or {} diff --git a/homeassistant/components/binary_sensor/sleepiq.py b/homeassistant/components/binary_sensor/sleepiq.py deleted file mode 100644 index 808eda496..000000000 --- a/homeassistant/components/binary_sensor/sleepiq.py +++ /dev/null @@ -1,52 +0,0 @@ -""" -Support for SleepIQ sensors. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.sleepiq/ -""" -from homeassistant.components import sleepiq -from homeassistant.components.binary_sensor import BinarySensorDevice - -DEPENDENCIES = ['sleepiq'] - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the SleepIQ sensors.""" - if discovery_info is None: - return - - data = sleepiq.DATA - data.update() - - dev = list() - for bed_id, _ in data.beds.items(): - for side in sleepiq.SIDES: - dev.append(IsInBedBinarySensor(data, bed_id, side)) - add_entities(dev) - - -class IsInBedBinarySensor(sleepiq.SleepIQSensor, BinarySensorDevice): - """Implementation of a SleepIQ presence sensor.""" - - def __init__(self, sleepiq_data, bed_id, side): - """Initialize the sensor.""" - sleepiq.SleepIQSensor.__init__(self, sleepiq_data, bed_id, side) - self.type = sleepiq.IS_IN_BED - self._state = None - self._name = sleepiq.SENSOR_TYPES[self.type] - self.update() - - @property - def is_on(self): - """Return the status of the sensor.""" - return self._state is True - - @property - def device_class(self): - """Return the class of this sensor.""" - return "occupancy" - - def update(self): - """Get the latest data from SleepIQ and updates the states.""" - sleepiq.SleepIQSensor.update(self) - self._state = self.side.is_in_bed diff --git a/homeassistant/components/binary_sensor/spc.py b/homeassistant/components/binary_sensor/spc.py deleted file mode 100644 index 9afd4fe40..000000000 --- a/homeassistant/components/binary_sensor/spc.py +++ /dev/null @@ -1,103 +0,0 @@ -""" -Support for Vanderbilt (formerly Siemens) SPC alarm systems. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.spc/ -""" -import asyncio -import logging - -from homeassistant.components.binary_sensor import BinarySensorDevice -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_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): - """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): - """Set up the SPC binary sensor.""" - if (discovery_info is None or - discovery_info[ATTR_DISCOVER_DEVICES] is None): - return - - async_add_entities( - _create_sensor(hass, zone) - for zone in discovery_info[ATTR_DISCOVER_DEVICES] - if _get_device_class(zone['type'])) - - -class SpcBinarySensor(BinarySensorDevice): - """Representation of a sensor based on a SPC zone.""" - - def __init__(self, zone_id, name, state, device_class, spc_registry): - """Initialize the sensor device.""" - self._zone_id = zone_id - self._name = name - self._state = state - self._device_class = device_class - - spc_registry.register_sensor_device(zone_id, self) - - @asyncio.coroutine - 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): - """Return 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) -> bool: - """Whether the device is hidden by default.""" - # These type of sensors are probably mainly used for automations - return True - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def device_class(self): - """Return the device class.""" - return self._device_class diff --git a/homeassistant/components/binary_sensor/strings.json b/homeassistant/components/binary_sensor/strings.json new file mode 100644 index 000000000..e01af8d18 --- /dev/null +++ b/homeassistant/components/binary_sensor/strings.json @@ -0,0 +1,93 @@ +{ + "device_automation": { + "condition_type": { + "is_bat_low": "{entity_name} battery is low", + "is_not_bat_low": "{entity_name} battery is normal", + "is_cold": "{entity_name} is cold", + "is_not_cold": "{entity_name} is not cold", + "is_connected": "{entity_name} is connected", + "is_not_connected": "{entity_name} is disconnected", + "is_gas": "{entity_name} is detecting gas", + "is_no_gas": "{entity_name} is not detecting gas", + "is_hot": "{entity_name} is hot", + "is_not_hot": "{entity_name} is not hot", + "is_light": "{entity_name} is detecting light", + "is_no_light": "{entity_name} is not detecting light", + "is_locked": "{entity_name} is locked", + "is_not_locked": "{entity_name} is unlocked", + "is_moist": "{entity_name} is moist", + "is_not_moist": "{entity_name} is dry", + "is_motion": "{entity_name} is detecting motion", + "is_no_motion": "{entity_name} is not detecting motion", + "is_moving": "{entity_name} is moving", + "is_not_moving": "{entity_name} is not moving", + "is_occupied": "{entity_name} is occupied", + "is_not_occupied": "{entity_name} is not occupied", + "is_plugged_in": "{entity_name} is plugged in", + "is_not_plugged_in": "{entity_name} is unplugged", + "is_powered": "{entity_name} is powered", + "is_not_powered": "{entity_name} is not powered", + "is_present": "{entity_name} is present", + "is_not_present": "{entity_name} is not present", + "is_problem": "{entity_name} is detecting problem", + "is_no_problem": "{entity_name} is not detecting problem", + "is_unsafe": "{entity_name} is unsafe", + "is_not_unsafe": "{entity_name} is safe", + "is_smoke": "{entity_name} is detecting smoke", + "is_no_smoke": "{entity_name} is not detecting smoke", + "is_sound": "{entity_name} is detecting sound", + "is_no_sound": "{entity_name} is not detecting sound", + "is_vibration": "{entity_name} is detecting vibration", + "is_no_vibration": "{entity_name} is not detecting vibration", + "is_open": "{entity_name} is open", + "is_not_open": "{entity_name} is closed", + "is_on": "{entity_name} is on", + "is_off": "{entity_name} is off" + }, + "trigger_type": { + "bat_low": "{entity_name} battery low", + "not_bat_low": "{entity_name} battery normal", + "cold": "{entity_name} became cold", + "not_cold": "{entity_name} became not cold", + "connected": "{entity_name} connected", + "not_connected": "{entity_name} disconnected", + "gas": "{entity_name} started detecting gas", + "no_gas": "{entity_name} stopped detecting gas", + "hot": "{entity_name} became hot", + "not_hot": "{entity_name} became not hot", + "light": "{entity_name} started detecting light", + "no_light": "{entity_name} stopped detecting light", + "locked": "{entity_name} locked", + "not_locked": "{entity_name} unlocked", + "moist": "{entity_name} became moist", + "not_moist": "{entity_name} became dry", + "motion": "{entity_name} started detecting motion", + "no_motion": "{entity_name} stopped detecting motion", + "moving": "{entity_name} started moving", + "not_moving": "{entity_name} stopped moving", + "occupied": "{entity_name} became occupied", + "not_occupied": "{entity_name} became not occupied", + "plugged_in": "{entity_name} plugged in", + "not_plugged_in": "{entity_name} unplugged", + "powered": "{entity_name} powered", + "not_powered": "{entity_name} not powered", + "present": "{entity_name} present", + "not_present": "{entity_name} not present", + "problem": "{entity_name} started detecting problem", + "no_problem": "{entity_name} stopped detecting problem", + "unsafe": "{entity_name} became unsafe", + "not_unsafe": "{entity_name} became safe", + "smoke": "{entity_name} started detecting smoke", + "no_smoke": "{entity_name} stopped detecting smoke", + "sound": "{entity_name} started detecting sound", + "no_sound": "{entity_name} stopped detecting sound", + "vibration": "{entity_name} started detecting vibration", + "no_vibration": "{entity_name} stopped detecting vibration", + "opened": "{entity_name} opened", + "not_opened": "{entity_name} closed", + "turned_on": "{entity_name} turned on", + "turned_off": "{entity_name} turned off" + + } + } +} diff --git a/homeassistant/components/binary_sensor/tahoma.py b/homeassistant/components/binary_sensor/tahoma.py deleted file mode 100644 index 7af5a730c..000000000 --- a/homeassistant/components/binary_sensor/tahoma.py +++ /dev/null @@ -1,98 +0,0 @@ -""" -Support for Tahoma binary sensors. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.tahoma/ -""" - -import logging -from datetime import timedelta - -from homeassistant.components.binary_sensor import ( - BinarySensorDevice) -from homeassistant.components.tahoma import ( - DOMAIN as TAHOMA_DOMAIN, TahomaDevice) -from homeassistant.const import (STATE_OFF, STATE_ON, ATTR_BATTERY_LEVEL) - -DEPENDENCIES = ['tahoma'] - -_LOGGER = logging.getLogger(__name__) - -SCAN_INTERVAL = timedelta(seconds=120) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up Tahoma controller devices.""" - _LOGGER.debug("Setup Tahoma Binary sensor platform") - controller = hass.data[TAHOMA_DOMAIN]['controller'] - devices = [] - for device in hass.data[TAHOMA_DOMAIN]['devices']['smoke']: - devices.append(TahomaBinarySensor(device, controller)) - add_entities(devices, True) - - -class TahomaBinarySensor(TahomaDevice, BinarySensorDevice): - """Representation of a Tahoma Binary Sensor.""" - - def __init__(self, tahoma_device, controller): - """Initialize the sensor.""" - super().__init__(tahoma_device, controller) - - self._state = None - self._icon = None - self._battery = None - - @property - def is_on(self): - """Return the state of the sensor.""" - return bool(self._state == STATE_ON) - - @property - def device_class(self): - """Return the class of the device.""" - if self.tahoma_device.type == 'rtds:RTDSSmokeSensor': - return 'smoke' - return None - - @property - def icon(self): - """Icon for device by its type.""" - return self._icon - - @property - def device_state_attributes(self): - """Return the device state attributes.""" - attr = {} - super_attr = super().device_state_attributes - if super_attr is not None: - attr.update(super_attr) - - if self._battery is not None: - attr[ATTR_BATTERY_LEVEL] = self._battery - return attr - - def update(self): - """Update the state.""" - self.controller.get_states([self.tahoma_device]) - if self.tahoma_device.type == 'rtds:RTDSSmokeSensor': - if self.tahoma_device.active_states['core:SmokeState']\ - == 'notDetected': - self._state = STATE_OFF - else: - self._state = STATE_ON - - if 'core:SensorDefectState' in self.tahoma_device.active_states: - # Set to 'lowBattery' for low battery warning. - self._battery = self.tahoma_device.active_states[ - 'core:SensorDefectState'] - else: - self._battery = None - - if self._state == STATE_ON: - self._icon = "mdi:fire" - elif self._battery == 'lowBattery': - self._icon = "mdi:battery-alert" - else: - self._icon = None - - _LOGGER.debug("Update %s, state: %s", self._name, self._state) diff --git a/homeassistant/components/binary_sensor/tapsaff.py b/homeassistant/components/binary_sensor/tapsaff.py deleted file mode 100644 index 1978a127c..000000000 --- a/homeassistant/components/binary_sensor/tapsaff.py +++ /dev/null @@ -1,86 +0,0 @@ -""" -Support for Taps Affs. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.tapsaff/ -""" -from datetime import timedelta -import logging - -import voluptuous as vol - -from homeassistant.components.binary_sensor import ( - PLATFORM_SCHEMA, BinarySensorDevice) -from homeassistant.const import CONF_NAME -import homeassistant.helpers.config_validation as cv - -REQUIREMENTS = ['tapsaff==0.2.0'] - -_LOGGER = logging.getLogger(__name__) - -CONF_LOCATION = 'location' - -DEFAULT_NAME = 'Taps Aff' - -SCAN_INTERVAL = timedelta(minutes=30) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_LOCATION): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Taps Aff binary sensor.""" - name = config.get(CONF_NAME) - location = config.get(CONF_LOCATION) - - taps_aff_data = TapsAffData(location) - - add_entities([TapsAffSensor(taps_aff_data, name)], True) - - -class TapsAffSensor(BinarySensorDevice): - """Implementation of a Taps Aff binary sensor.""" - - def __init__(self, taps_aff_data, name): - """Initialize the Taps Aff sensor.""" - self.data = taps_aff_data - self._name = name - - @property - def name(self): - """Return the name of the sensor.""" - return '{}'.format(self._name) - - @property - def is_on(self): - """Return true if taps aff.""" - return self.data.is_taps_aff - - def update(self): - """Get the latest data.""" - self.data.update() - - -class TapsAffData: - """Class for handling the data retrieval for pins.""" - - def __init__(self, location): - """Initialize the data object.""" - from tapsaff import TapsAff - - self._is_taps_aff = None - self.taps_aff = TapsAff(location) - - @property - def is_taps_aff(self): - """Return true if taps aff.""" - return self._is_taps_aff - - def update(self): - """Get the latest data from the Taps Aff API and updates the states.""" - try: - self._is_taps_aff = self.taps_aff.is_taps_aff - except RuntimeError: - _LOGGER.error("Update failed. Check configured location") diff --git a/homeassistant/components/binary_sensor/tcp.py b/homeassistant/components/binary_sensor/tcp.py deleted file mode 100644 index 764b6829c..000000000 --- a/homeassistant/components/binary_sensor/tcp.py +++ /dev/null @@ -1,31 +0,0 @@ -""" -Provides a binary sensor which gets its values from a TCP socket. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.tcp/ -""" -import logging - -from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.components.sensor.tcp import ( - TcpSensor, CONF_VALUE_ON, PLATFORM_SCHEMA) - -_LOGGER = logging.getLogger(__name__) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the TCP binary sensor.""" - add_entities([TcpBinarySensor(hass, config)]) - - -class TcpBinarySensor(BinarySensorDevice, TcpSensor): - """A binary sensor which is on when its state == CONF_VALUE_ON.""" - - required = (CONF_VALUE_ON,) - - @property - def is_on(self): - """Return true if the binary sensor is on.""" - return self._state == self._config[CONF_VALUE_ON] diff --git a/homeassistant/components/binary_sensor/tellduslive.py b/homeassistant/components/binary_sensor/tellduslive.py deleted file mode 100644 index 450a5e580..000000000 --- a/homeassistant/components/binary_sensor/tellduslive.py +++ /dev/null @@ -1,34 +0,0 @@ -""" -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_entities, discovery_info=None): - """Set up Tellstick sensors.""" - if discovery_info is None: - return - add_entities( - 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 deleted file mode 100644 index c5bfa5930..000000000 --- a/homeassistant/components/binary_sensor/template.py +++ /dev/null @@ -1,223 +0,0 @@ -""" -Support for exposing a templated binary sensor. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.template/ -""" -import asyncio -import logging - -import voluptuous as vol - -from homeassistant.core import callback -from homeassistant.components.binary_sensor import ( - BinarySensorDevice, ENTITY_ID_FORMAT, PLATFORM_SCHEMA, - DEVICE_CLASSES_SCHEMA) -from homeassistant.const import ( - ATTR_FRIENDLY_NAME, ATTR_ENTITY_ID, CONF_VALUE_TEMPLATE, - 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) - -_LOGGER = logging.getLogger(__name__) - -CONF_DELAY_ON = 'delay_on' -CONF_DELAY_OFF = 'delay_off' - -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, - vol.Optional(CONF_DELAY_ON): - vol.All(cv.time_period, cv.positive_timedelta), - vol.Optional(CONF_DELAY_OFF): - vol.All(cv.time_period, cv.positive_timedelta), -}) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_SENSORS): vol.Schema({cv.slug: SENSOR_SCHEMA}), -}) - - -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): - """Set up template binary sensors.""" - sensors = [] - - 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) - device_class = device_config.get(CONF_DEVICE_CLASS) - delay_on = device_config.get(CONF_DELAY_ON) - delay_off = device_config.get(CONF_DELAY_OFF) - - 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, - icon_template, entity_picture_template, entity_ids, - delay_on, delay_off) - ) - if not sensors: - _LOGGER.error("No sensors added") - return False - - async_add_entities(sensors) - return True - - -class BinarySensorTemplate(BinarySensorDevice): - """A virtual binary sensor that triggers from another sensor.""" - - def __init__(self, hass, device, friendly_name, device_class, - 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( - ENTITY_ID_FORMAT, device, hass=hass) - self._name = friendly_name - 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 - - @asyncio.coroutine - def async_added_to_hass(self): - """Register callbacks.""" - @callback - def template_bsensor_state_listener(entity, old_state, new_state): - """Handle the target device state changes.""" - self.async_check_state() - - @callback - def template_bsensor_startup(event): - """Update template on startup.""" - async_track_state_change( - self.hass, self._entities, template_bsensor_state_listener) - - self.hass.async_add_job(self.async_check_state) - - self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_START, template_bsensor_startup) - - @property - 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.""" - return self._state - - @property - def device_class(self): - """Return the sensor class of the sensor.""" - return self._device_class - - @property - def should_poll(self): - """No polling needed.""" - return False - - @callback - def _async_render(self): - """Get the state of template.""" - state = None - try: - state = (self._template.async_render().lower() == 'true') - except TemplateError as ex: - 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 template %s, " - "the state is unknown", self._name) - 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.""" - state = self._async_render() - - # return if the state don't change or is invalid - if state is None or state == self.state: - return - - @callback - def set_state(): - """Set state of template binary sensor.""" - self._state = state - self.async_schedule_update_ha_state() - - # state without delay - if (state and not self._delay_on) or \ - (not state and not self._delay_off): - set_state() - return - - period = self._delay_on if state else self._delay_off - async_track_same_state( - 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 deleted file mode 100644 index f7613d74d..000000000 --- a/homeassistant/components/binary_sensor/tesla.py +++ /dev/null @@ -1,56 +0,0 @@ -""" -Support for Tesla binary sensor. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.tesla/ -""" -import logging - -from homeassistant.components.binary_sensor import ( - BinarySensorDevice, ENTITY_ID_FORMAT) -from homeassistant.components.tesla import DOMAIN as TESLA_DOMAIN, TeslaDevice - -_LOGGER = logging.getLogger(__name__) - -DEPENDENCIES = ['tesla'] - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Tesla binary sensor.""" - devices = [ - TeslaBinarySensor( - device, hass.data[TESLA_DOMAIN]['controller'], 'connectivity') - for device in hass.data[TESLA_DOMAIN]['devices']['binary_sensor']] - add_entities(devices, True) - - -class TeslaBinarySensor(TeslaDevice, BinarySensorDevice): - """Implement an Tesla binary sensor for parking and charger.""" - - def __init__(self, tesla_device, controller, sensor_type): - """Initialise of a Tesla binary sensor.""" - super().__init__(tesla_device, controller) - self._state = False - self.entity_id = ENTITY_ID_FORMAT.format(self.tesla_id) - self._sensor_type = sensor_type - - @property - def device_class(self): - """Return the class of this binary sensor.""" - return self._sensor_type - - @property - def name(self): - """Return the name of the binary sensor.""" - return self._name - - @property - def is_on(self): - """Return the state of the binary sensor.""" - return self._state - - def update(self): - """Update the state of the device.""" - _LOGGER.debug("Updating sensor: %s", self._name) - self.tesla_device.update() - self._state = self.tesla_device.get_value() diff --git a/homeassistant/components/binary_sensor/threshold.py b/homeassistant/components/binary_sensor/threshold.py deleted file mode 100644 index fd7ead088..000000000 --- a/homeassistant/components/binary_sensor/threshold.py +++ /dev/null @@ -1,190 +0,0 @@ -""" -Support for monitoring if a sensor value is below/above a threshold. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/sensor.threshold/ -""" -import asyncio -import logging - -import voluptuous as vol - -from homeassistant.components.binary_sensor import ( - DEVICE_CLASSES_SCHEMA, PLATFORM_SCHEMA, BinarySensorDevice) -from homeassistant.const import ( - 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_TYPE = 'type' -ATTR_UPPER = 'upper' - -CONF_HYSTERESIS = 'hysteresis' -CONF_LOWER = 'lower' -CONF_UPPER = 'upper' - -DEFAULT_NAME = 'Threshold' -DEFAULT_HYSTERESIS = 0.0 - -POSITION_ABOVE = 'above' -POSITION_BELOW = 'below' -POSITION_IN_RANGE = 'in_range' -POSITION_UNKNOWN = 'unknown' - -TYPE_LOWER = 'lower' -TYPE_RANGE = 'range' -TYPE_UPPER = 'upper' - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_ENTITY_ID): cv.entity_id, - 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), -}) - - -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): - """Set up the Threshold sensor.""" - entity_id = config.get(CONF_ENTITY_ID) - name = config.get(CONF_NAME) - lower = config.get(CONF_LOWER) - upper = config.get(CONF_UPPER) - hysteresis = config.get(CONF_HYSTERESIS) - device_class = config.get(CONF_DEVICE_CLASS) - - async_add_entities([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, lower, upper, hysteresis, - device_class): - """Initialize the Threshold sensor.""" - self._hass = hass - self._entity_id = entity_id - self._name = name - self._threshold_lower = lower - self._threshold_upper = upper - self._hysteresis = hysteresis - self._device_class = device_class - - self._state_position = None - self._state = False - self.sensor_value = None - - @callback - def async_threshold_sensor_state_listener( - entity, old_state, new_state): - """Handle sensor state changes.""" - try: - 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) - - async_track_state_change( - hass, entity_id, async_threshold_sensor_state_listener) - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def is_on(self): - """Return true if sensor is on.""" - return self._state - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - 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 - if self._threshold_lower is not None: - return TYPE_LOWER - if 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_TYPE: self.threshold_type, - ATTR_UPPER: self._threshold_upper, - } - - @asyncio.coroutine - def async_update(self): - """Get the latest data and updates the states.""" - 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 deleted file mode 100644 index ae6fd5562..000000000 --- a/homeassistant/components/binary_sensor/trend.py +++ /dev/null @@ -1,196 +0,0 @@ -""" -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.components.binary_sensor import ( - DEVICE_CLASSES_SCHEMA, ENTITY_ID_FORMAT, PLATFORM_SCHEMA, - BinarySensorDevice) -from homeassistant.const import ( - 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 async_track_state_change -from homeassistant.util import utcnow - -REQUIREMENTS = ['numpy==1.15.1'] - -_LOGGER = logging.getLogger(__name__) - -ATTR_ATTRIBUTE = 'attribute' -ATTR_GRADIENT = 'gradient' -ATTR_MIN_GRADIENT = 'min_gradient' -ATTR_INVERT = 'invert' -ATTR_SAMPLE_DURATION = 'sample_duration' -ATTR_SAMPLE_COUNT = 'sample_count' - -CONF_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(CONF_ENTITY_ID): cv.entity_id, - vol.Optional(CONF_ATTRIBUTE): cv.string, - 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({ - vol.Required(CONF_SENSORS): vol.Schema({cv.slug: SENSOR_SCHEMA}), -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the trend sensors.""" - sensors = [] - - for device_id, device_config in config[CONF_SENSORS].items(): - entity_id = device_config[ATTR_ENTITY_ID] - attribute = device_config.get(CONF_ATTRIBUTE) - 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_id, friendly_name, entity_id, attribute, - device_class, invert, max_samples, min_gradient, - sample_duration) - ) - if not sensors: - _LOGGER.error("No sensors added") - return False - add_entities(sensors) - return True - - -class SensorTrend(BinarySensorDevice): - """Representation of a trend Sensor.""" - - 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._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.samples = deque(maxlen=max_samples) - - @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 - - @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.""" - # 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 - - # 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 deleted file mode 100644 index c7b8a284d..000000000 --- a/homeassistant/components/binary_sensor/upcloud.py +++ /dev/null @@ -1,38 +0,0 @@ -""" -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_entities, 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_entities(devices, True) - - -class UpCloudBinarySensor(UpCloudServerEntity, BinarySensorDevice): - """Representation of an UpCloud server sensor.""" diff --git a/homeassistant/components/binary_sensor/uptimerobot.py b/homeassistant/components/binary_sensor/uptimerobot.py deleted file mode 100644 index dbb83e53e..000000000 --- a/homeassistant/components/binary_sensor/uptimerobot.py +++ /dev/null @@ -1,92 +0,0 @@ -""" -A platform that to monitor Uptime Robot monitors. - -For more details about this platform, please refer to the documentation at -https://www.home-assistant.io/components/binary_sensor.uptimerobot/ -""" -import logging - -import voluptuous as vol - -from homeassistant.components.binary_sensor import ( - PLATFORM_SCHEMA, BinarySensorDevice) -from homeassistant.const import ATTR_ATTRIBUTION, CONF_API_KEY -import homeassistant.helpers.config_validation as cv - -REQUIREMENTS = ['pyuptimerobot==0.0.5'] - -_LOGGER = logging.getLogger(__name__) - -ATTR_TARGET = 'target' - -CONF_ATTRIBUTION = "Data provided by Uptime Robot" - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_API_KEY): cv.string, -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Uptime Robot binary_sensors.""" - from pyuptimerobot import UptimeRobot - - up_robot = UptimeRobot() - api_key = config.get(CONF_API_KEY) - monitors = up_robot.getMonitors(api_key) - - devices = [] - if not monitors or monitors.get('stat') != 'ok': - _LOGGER.error("Error connecting to Uptime Robot") - return - - for monitor in monitors['monitors']: - devices.append(UptimeRobotBinarySensor( - api_key, up_robot, monitor['id'], monitor['friendly_name'], - monitor['url'])) - - add_entities(devices, True) - - -class UptimeRobotBinarySensor(BinarySensorDevice): - """Representation of a Uptime Robot binary sensor.""" - - def __init__(self, api_key, up_robot, monitor_id, name, target): - """Initialize Uptime Robot the binary sensor.""" - self._api_key = api_key - self._monitor_id = str(monitor_id) - self._name = name - self._target = target - self._up_robot = up_robot - self._state = None - - @property - def name(self): - """Return the name of the binary sensor.""" - return self._name - - @property - def is_on(self): - """Return the state of the binary sensor.""" - return self._state - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return 'connectivity' - - @property - def device_state_attributes(self): - """Return the state attributes of the binary sensor.""" - return { - ATTR_ATTRIBUTION: CONF_ATTRIBUTION, - ATTR_TARGET: self._target, - } - - def update(self): - """Get the latest state of the binary sensor.""" - monitor = self._up_robot.getMonitors(self._api_key, self._monitor_id) - if not monitor or monitor.get('stat') != 'ok': - _LOGGER.warning("Failed to get new state") - return - status = monitor['monitors'][0]['status'] - self._state = 1 if status == 2 else 0 diff --git a/homeassistant/components/binary_sensor/velbus.py b/homeassistant/components/binary_sensor/velbus.py deleted file mode 100644 index b123b9585..000000000 --- a/homeassistant/components/binary_sensor/velbus.py +++ /dev/null @@ -1,37 +0,0 @@ -""" -Support for Velbus Binary Sensors. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.velbus/ -""" -import logging - -from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.components.velbus import ( - DOMAIN as VELBUS_DOMAIN, VelbusEntity) - -_LOGGER = logging.getLogger(__name__) - -DEPENDENCIES = ['velbus'] - - -async def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): - """Set up Velbus binary sensors.""" - if discovery_info is None: - return - sensors = [] - for sensor in discovery_info: - module = hass.data[VELBUS_DOMAIN].get_module(sensor[0]) - channel = sensor[1] - sensors.append(VelbusBinarySensor(module, channel)) - async_add_entities(sensors) - - -class VelbusBinarySensor(VelbusEntity, BinarySensorDevice): - """Representation of a Velbus Binary Sensor.""" - - @property - def is_on(self): - """Return true if the sensor is on.""" - return self._module.is_closed(self._channel) diff --git a/homeassistant/components/binary_sensor/vera.py b/homeassistant/components/binary_sensor/vera.py deleted file mode 100644 index bb1e7331d..000000000 --- a/homeassistant/components/binary_sensor/vera.py +++ /dev/null @@ -1,42 +0,0 @@ -""" -Support for Vera binary sensors. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.vera/ -""" -import logging - -from homeassistant.components.binary_sensor import ( - BinarySensorDevice, ENTITY_ID_FORMAT) -from homeassistant.components.vera import ( - VERA_CONTROLLER, VERA_DEVICES, VeraDevice) - -DEPENDENCIES = ['vera'] - -_LOGGER = logging.getLogger(__name__) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Perform the setup for Vera controller devices.""" - add_entities( - [VeraBinarySensor(device, hass.data[VERA_CONTROLLER]) - for device in hass.data[VERA_DEVICES]['binary_sensor']], True) - - -class VeraBinarySensor(VeraDevice, BinarySensorDevice): - """Representation of a Vera Binary Sensor.""" - - def __init__(self, vera_device, controller): - """Initialize the binary_sensor.""" - self._state = False - VeraDevice.__init__(self, vera_device, controller) - self.entity_id = ENTITY_ID_FORMAT.format(self.vera_id) - - @property - def is_on(self): - """Return true if sensor is on.""" - return self._state - - def update(self): - """Get the latest data and update the state.""" - self._state = self.vera_device.is_tripped diff --git a/homeassistant/components/binary_sensor/verisure.py b/homeassistant/components/binary_sensor/verisure.py deleted file mode 100644 index e040da959..000000000 --- a/homeassistant/components/binary_sensor/verisure.py +++ /dev/null @@ -1,60 +0,0 @@ -""" -Interfaces with Verisure sensors. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.verisure/ -""" -import logging - -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_entities, discovery_info=None): - """Set up the Verisure binary sensors.""" - sensors = [] - hub.update_overview() - - if int(hub.config.get(CONF_DOOR_WINDOW, 1)): - sensors.extend([ - VerisureDoorWindowSensor(device_label) - for device_label in hub.get( - "$.doorWindow.doorWindowDevice[*].deviceLabel")]) - add_entities(sensors) - - -class VerisureDoorWindowSensor(BinarySensorDevice): - """Representation of a Verisure door window sensor.""" - - def __init__(self, device_label): - """Initialize the Verisure door window sensor.""" - self._device_label = device_label - - @property - def name(self): - """Return the name of the binary sensor.""" - return hub.get_first( - "$.doorWindow.doorWindowDevice[?(@.deviceLabel=='%s')].area", - self._device_label) - - @property - def is_on(self): - """Return the state of the sensor.""" - return hub.get_first( - "$.doorWindow.doorWindowDevice[?(@.deviceLabel=='%s')].state", - self._device_label) == "OPEN" - - @property - def available(self): - """Return True if entity is available.""" - return hub.get_first( - "$.doorWindow.doorWindowDevice[?(@.deviceLabel=='%s')]", - self._device_label) is not None - - # pylint: disable=no-self-use - def update(self): - """Update the state of the sensor.""" - hub.update_overview() diff --git a/homeassistant/components/binary_sensor/volvooncall.py b/homeassistant/components/binary_sensor/volvooncall.py deleted file mode 100644 index e70d30988..000000000 --- a/homeassistant/components/binary_sensor/volvooncall.py +++ /dev/null @@ -1,38 +0,0 @@ -""" -Support for VOC. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.volvooncall/ -""" -import logging - -from homeassistant.components.volvooncall import VolvoEntity -from homeassistant.components.binary_sensor import BinarySensorDevice - -_LOGGER = logging.getLogger(__name__) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Volvo sensors.""" - if discovery_info is None: - return - add_entities([VolvoSensor(hass, *discovery_info)]) - - -class VolvoSensor(VolvoEntity, BinarySensorDevice): - """Representation of a Volvo sensor.""" - - @property - def is_on(self): - """Return True if the binary sensor is on.""" - val = getattr(self.vehicle, self._attribute) - if self._attribute == 'bulb_failures': - return bool(val) - if self._attribute in ['doors', 'windows']: - return any([val[key] for key in val if 'Open' in key]) - return val != 'Normal' - - @property - def device_class(self): - """Return the class of this sensor, from DEVICE_CLASSES.""" - return 'safety' diff --git a/homeassistant/components/binary_sensor/vultr.py b/homeassistant/components/binary_sensor/vultr.py deleted file mode 100644 index 149a6c282..000000000 --- a/homeassistant/components/binary_sensor/vultr.py +++ /dev/null @@ -1,103 +0,0 @@ -""" -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_entities, 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_entities([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 deleted file mode 100644 index 1071aae50..000000000 --- a/homeassistant/components/binary_sensor/wemo.py +++ /dev/null @@ -1,88 +0,0 @@ -""" -Support for WeMo sensors. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.wemo/ -""" -import logging -import requests - -from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.exceptions import PlatformNotReady - -DEPENDENCIES = ['wemo'] - -_LOGGER = logging.getLogger(__name__) - - -def setup_platform(hass, config, add_entities_callback, discovery_info=None): - """Register discovered WeMo binary sensors.""" - from pywemo import discovery - - if discovery_info is not None: - location = discovery_info['ssdp_description'] - mac = discovery_info['mac_address'] - - try: - device = discovery.device_from_description(location, mac) - except (requests.exceptions.ConnectionError, - requests.exceptions.Timeout) as err: - _LOGGER.error('Unable to access %s (%s)', location, err) - raise PlatformNotReady - - if device: - add_entities_callback([WemoBinarySensor(hass, device)]) - - -class WemoBinarySensor(BinarySensorDevice): - """Representation a WeMo binary sensor.""" - - def __init__(self, hass, device): - """Initialize the WeMo sensor.""" - self.wemo = device - self._state = None - - wemo = hass.components.wemo - wemo.SUBSCRIPTION_REGISTRY.register(self.wemo) - wemo.SUBSCRIPTION_REGISTRY.on(self.wemo, None, self._update_callback) - - def _update_callback(self, _device, _type, _params): - """Handle state changes.""" - _LOGGER.info("Subscription update for %s", _device) - updated = self.wemo.subscription_update(_type, _params) - self._update(force_update=(not updated)) - - if not hasattr(self, 'hass'): - return - self.schedule_update_ha_state() - - @property - def should_poll(self): - """No polling needed with subscriptions.""" - return False - - @property - def unique_id(self): - """Return the id of this WeMo device.""" - return self.wemo.serialnumber - - @property - def name(self): - """Return the name of the service if any.""" - return self.wemo.name - - @property - def is_on(self): - """Return true if sensor is on.""" - return self._state - - def update(self): - """Update WeMo state.""" - self._update(force_update=True) - - def _update(self, force_update=True): - try: - self._state = self.wemo.get_state(force_update) - except AttributeError as err: - _LOGGER.warning( - "Could not update status for %s (%s)", self.name, err) diff --git a/homeassistant/components/binary_sensor/wink.py b/homeassistant/components/binary_sensor/wink.py deleted file mode 100644 index 1976e49f4..000000000 --- a/homeassistant/components/binary_sensor/wink.py +++ /dev/null @@ -1,193 +0,0 @@ -""" -Support for Wink binary sensors. - -For more details about this platform, please refer to the documentation at -at https://home-assistant.io/components/binary_sensor.wink/ -""" -import asyncio -import logging - -from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.components.wink import DOMAIN, WinkDevice - -_LOGGER = logging.getLogger(__name__) - -DEPENDENCIES = ['wink'] - -# These are the available sensors mapped to binary_sensor class -SENSOR_TYPES = { - 'brightness': 'light', - 'capturing_audio': 'sound', - 'capturing_video': None, - 'co_detected': 'gas', - 'liquid_detected': 'moisture', - 'loudness': 'sound', - 'motion': 'motion', - 'noise': 'sound', - 'opened': 'opening', - 'presence': 'occupancy', - 'smoke_detected': 'smoke', - 'vibration': 'vibration', -} - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Wink binary sensor platform.""" - import pywink - - for sensor in pywink.get_sensors(): - _id = sensor.object_id() + sensor.name() - if _id not in hass.data[DOMAIN]['unique_ids']: - if sensor.capability() in SENSOR_TYPES: - add_entities([WinkBinarySensorDevice(sensor, hass)]) - - for key in pywink.get_keys(): - _id = key.object_id() + key.name() - if _id not in hass.data[DOMAIN]['unique_ids']: - add_entities([WinkBinarySensorDevice(key, hass)]) - - for sensor in pywink.get_smoke_and_co_detectors(): - _id = sensor.object_id() + sensor.name() - if _id not in hass.data[DOMAIN]['unique_ids']: - add_entities([WinkSmokeDetector(sensor, hass)]) - - for hub in pywink.get_hubs(): - _id = hub.object_id() + hub.name() - if _id not in hass.data[DOMAIN]['unique_ids']: - add_entities([WinkHub(hub, hass)]) - - for remote in pywink.get_remotes(): - _id = remote.object_id() + remote.name() - if _id not in hass.data[DOMAIN]['unique_ids']: - add_entities([WinkRemote(remote, hass)]) - - for button in pywink.get_buttons(): - _id = button.object_id() + button.name() - if _id not in hass.data[DOMAIN]['unique_ids']: - add_entities([WinkButton(button, hass)]) - - for gang in pywink.get_gangs(): - _id = gang.object_id() + gang.name() - if _id not in hass.data[DOMAIN]['unique_ids']: - add_entities([WinkGang(gang, hass)]) - - for door_bell_sensor in pywink.get_door_bells(): - _id = door_bell_sensor.object_id() + door_bell_sensor.name() - if _id not in hass.data[DOMAIN]['unique_ids']: - add_entities([WinkBinarySensorDevice(door_bell_sensor, hass)]) - - for camera_sensor in pywink.get_cameras(): - _id = camera_sensor.object_id() + camera_sensor.name() - if _id not in hass.data[DOMAIN]['unique_ids']: - try: - if camera_sensor.capability() in SENSOR_TYPES: - add_entities([WinkBinarySensorDevice(camera_sensor, hass)]) - except AttributeError: - _LOGGER.info("Device isn't a sensor, skipping") - - -class WinkBinarySensorDevice(WinkDevice, BinarySensorDevice): - """Representation of a Wink binary sensor.""" - - def __init__(self, wink, hass): - """Initialize the Wink binary sensor.""" - super().__init__(wink, hass) - if hasattr(self.wink, 'unit'): - self._unit_of_measurement = self.wink.unit() - else: - self._unit_of_measurement = None - if hasattr(self.wink, 'capability'): - self.capability = self.wink.capability() - else: - self.capability = None - - @asyncio.coroutine - def async_added_to_hass(self): - """Call when entity is added to hass.""" - self.hass.data[DOMAIN]['entities']['binary_sensor'].append(self) - - @property - def is_on(self): - """Return true if the binary sensor is on.""" - return self.wink.state() - - @property - 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 device state attributes.""" - _attributes = super().device_state_attributes - _attributes['test_activated'] = self.wink.test_activated() - return _attributes - - -class WinkHub(WinkBinarySensorDevice): - """Representation of a Wink Hub.""" - - @property - def device_state_attributes(self): - """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): - """Representation of a Wink Lutron Connected bulb remote.""" - - @property - def device_state_attributes(self): - """Return the state attributes.""" - _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): - """Return the class of this sensor, from DEVICE_CLASSES.""" - return None - - -class WinkButton(WinkBinarySensorDevice): - """Representation of a Wink Relay button.""" - - @property - def device_state_attributes(self): - """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): - """Representation of a Wink Relay gang.""" - - @property - def is_on(self): - """Return true if the gang is connected.""" - return self.wink.state() diff --git a/homeassistant/components/binary_sensor/wirelesstag.py b/homeassistant/components/binary_sensor/wirelesstag.py deleted file mode 100644 index 190b408ab..000000000 --- a/homeassistant/components/binary_sensor/wirelesstag.py +++ /dev/null @@ -1,148 +0,0 @@ -""" -Binary sensor support for Wireless Sensor Tags. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.wirelesstag/ -""" -import logging - -import voluptuous as vol - -from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.components.binary_sensor import ( - BinarySensorDevice, PLATFORM_SCHEMA) -from homeassistant.components.wirelesstag import ( - DOMAIN as WIRELESSTAG_DOMAIN, - SIGNAL_BINARY_EVENT_UPDATE, - WirelessTagBaseSensor) -from homeassistant.const import ( - CONF_MONITORED_CONDITIONS, STATE_ON, STATE_OFF) -import homeassistant.helpers.config_validation as cv - -DEPENDENCIES = ['wirelesstag'] - -_LOGGER = logging.getLogger(__name__) - -# On means in range, Off means out of range -SENSOR_PRESENCE = 'presence' - -# On means motion detected, Off means clear -SENSOR_MOTION = 'motion' - -# On means open, Off means closed -SENSOR_DOOR = 'door' - -# On means temperature become too cold, Off means normal -SENSOR_COLD = 'cold' - -# On means hot, Off means normal -SENSOR_HEAT = 'heat' - -# On means too dry (humidity), Off means normal -SENSOR_DRY = 'dry' - -# On means too wet (humidity), Off means normal -SENSOR_WET = 'wet' - -# On means light detected, Off means no light -SENSOR_LIGHT = 'light' - -# On means moisture detected (wet), Off means no moisture (dry) -SENSOR_MOISTURE = 'moisture' - -# On means tag battery is low, Off means normal -SENSOR_BATTERY = 'battery' - -# Sensor types: Name, device_class, push notification type representing 'on', -# attr to check -SENSOR_TYPES = { - SENSOR_PRESENCE: 'Presence', - SENSOR_MOTION: 'Motion', - SENSOR_DOOR: 'Door', - SENSOR_COLD: 'Cold', - SENSOR_HEAT: 'Heat', - SENSOR_DRY: 'Too dry', - SENSOR_WET: 'Too wet', - SENSOR_LIGHT: 'Light', - SENSOR_MOISTURE: 'Leak', - SENSOR_BATTERY: 'Low Battery' -} - - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_MONITORED_CONDITIONS, default=[]): - vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the platform for a WirelessTags.""" - platform = hass.data.get(WIRELESSTAG_DOMAIN) - - sensors = [] - tags = platform.tags - for tag in tags.values(): - allowed_sensor_types = tag.supported_binary_events_types - for sensor_type in config.get(CONF_MONITORED_CONDITIONS): - if sensor_type in allowed_sensor_types: - sensors.append(WirelessTagBinarySensor(platform, tag, - sensor_type)) - - add_entities(sensors, True) - hass.add_job(platform.install_push_notifications, sensors) - - -class WirelessTagBinarySensor(WirelessTagBaseSensor, BinarySensorDevice): - """A binary sensor implementation for WirelessTags.""" - - def __init__(self, api, tag, sensor_type): - """Initialize a binary sensor for a Wireless Sensor Tags.""" - super().__init__(api, tag) - self._sensor_type = sensor_type - self._name = '{0} {1}'.format(self._tag.name, - self.event.human_readable_name) - - async def async_added_to_hass(self): - """Register callbacks.""" - tag_id = self.tag_id - event_type = self.device_class - mac = self.tag_manager_mac - async_dispatcher_connect( - self.hass, - SIGNAL_BINARY_EVENT_UPDATE.format(tag_id, event_type, mac), - self._on_binary_event_callback) - - @property - def is_on(self): - """Return True if the binary sensor is on.""" - return self._state == STATE_ON - - @property - def device_class(self): - """Return the class of the binary sensor.""" - return self._sensor_type - - @property - def event(self): - """Binary event of tag.""" - return self._tag.event[self._sensor_type] - - @property - def principal_value(self): - """Return value of tag. - - Subclasses need override based on type of sensor. - """ - return STATE_ON if self.event.is_state_on else STATE_OFF - - def updated_state_value(self): - """Use raw princial value.""" - return self.principal_value - - @callback - def _on_binary_event_callback(self, event): - """Update state from arrived push notification.""" - # state should be 'on' or 'off' - self._state = event.data.get('state') - self.async_schedule_update_ha_state() diff --git a/homeassistant/components/binary_sensor/workday.py b/homeassistant/components/binary_sensor/workday.py deleted file mode 100644 index 1d85d9c9a..000000000 --- a/homeassistant/components/binary_sensor/workday.py +++ /dev/null @@ -1,180 +0,0 @@ -""" -Sensor to indicate whether the current day is a workday. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.workday/ -""" -import asyncio -import logging -from datetime import datetime, timedelta - -import voluptuous as vol - -from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_NAME, WEEKDAYS -from homeassistant.components.binary_sensor import BinarySensorDevice -import homeassistant.helpers.config_validation as cv - -REQUIREMENTS = ['holidays==0.9.7'] - -_LOGGER = logging.getLogger(__name__) - -# List of all countries currently supported by holidays -# There seems to be no way to get the list out at runtime -ALL_COUNTRIES = [ - 'Argentina', 'AR', 'Australia', 'AU', 'Austria', 'AT', 'Belarus', 'BY' - 'Belgium', 'BE', 'Canada', 'CA', 'Colombia', 'CO', 'Czech', 'CZ', - 'Denmark', 'DK', 'England', 'EuropeanCentralBank', 'ECB', 'TAR', - 'Finland', 'FI', 'France', 'FRA', 'Germany', 'DE', 'Hungary', 'HU', - 'India', 'IND', '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', -] - -ALLOWED_DAYS = WEEKDAYS + ['holiday'] - -CONF_COUNTRY = 'country' -CONF_PROVINCE = 'province' -CONF_WORKDAYS = 'workdays' -CONF_EXCLUDES = 'excludes' -CONF_OFFSET = 'days_offset' - -# By default, Monday - Friday are workdays -DEFAULT_WORKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri'] -# By default, public holidays, Saturdays and Sundays are excluded from workdays -DEFAULT_EXCLUDES = ['sat', 'sun', 'holiday'] -DEFAULT_NAME = 'Workday Sensor' -DEFAULT_OFFSET = 0 - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_COUNTRY): vol.In(ALL_COUNTRIES), - 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)]), -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Workday sensor.""" - import holidays - - sensor_name = config.get(CONF_NAME) - country = config.get(CONF_COUNTRY) - province = config.get(CONF_PROVINCE) - workdays = config.get(CONF_WORKDAYS) - excludes = config.get(CONF_EXCLUDES) - days_offset = config.get(CONF_OFFSET) - - 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 - province in obj_holidays.PROVINCES): - 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) - else: - _LOGGER.error("There is no province/state %s in country %s", - province, country) - return - - _LOGGER.debug("Found the following holidays for your configuration:") - for date, name in sorted(obj_holidays.items()): - _LOGGER.debug("%s %s", date, name) - - add_entities([IsWorkdaySensor( - obj_holidays, workdays, excludes, days_offset, sensor_name)], True) - - -def day_to_string(day): - """Convert day index 0 - 7 to string.""" - try: - return ALLOWED_DAYS[day] - except IndexError: - return None - - -def get_date(date): - """Return date. Needed for testing.""" - return date - - -class IsWorkdaySensor(BinarySensorDevice): - """Implementation of a Workday sensor.""" - - def __init__(self, obj_holidays, workdays, excludes, days_offset, name): - """Initialize the Workday sensor.""" - self._name = name - self._obj_holidays = obj_holidays - self._workdays = workdays - self._excludes = excludes - self._days_offset = days_offset - self._state = None - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def is_on(self): - """Return the state of the device.""" - return self._state - - def is_include(self, day, now): - """Check if given day is in the includes list.""" - if day in self._workdays: - return True - if 'holiday' in self._workdays and now in self._obj_holidays: - return True - - return False - - def is_exclude(self, day, now): - """Check if given day is in the excludes list.""" - if day in self._excludes: - return True - if 'holiday' in self._excludes and now in self._obj_holidays: - return True - - return False - - @property - def state_attributes(self): - """Return the attributes of the entity.""" - # return self._attributes - return { - CONF_WORKDAYS: self._workdays, - CONF_EXCLUDES: self._excludes, - CONF_OFFSET: self._days_offset - } - - @asyncio.coroutine - def async_update(self): - """Get date and look whether it is a holiday.""" - # Default is no workday - self._state = False - - # Get iso day of the week (1 = Monday, 7 = Sunday) - date = get_date(datetime.today()) + timedelta(days=self._days_offset) - day = date.isoweekday() - 1 - day_of_week = day_to_string(day) - - if self.is_include(day_of_week, date): - self._state = True - - if self.is_exclude(day_of_week, date): - self._state = False diff --git a/homeassistant/components/binary_sensor/xiaomi_aqara.py b/homeassistant/components/binary_sensor/xiaomi_aqara.py deleted file mode 100644 index 730b662b9..000000000 --- a/homeassistant/components/binary_sensor/xiaomi_aqara.py +++ /dev/null @@ -1,407 +0,0 @@ -"""Support for Xiaomi aqara binary sensors.""" -import logging - -from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.components.xiaomi_aqara import (PY_XIAOMI_GATEWAY, - XiaomiDevice) - -_LOGGER = logging.getLogger(__name__) - -NO_CLOSE = 'no_close' -ATTR_OPEN_SINCE = 'Open since' - -MOTION = 'motion' -NO_MOTION = 'no_motion' -ATTR_LAST_ACTION = 'last_action' -ATTR_NO_MOTION_SINCE = 'No motion since' - -DENSITY = 'density' -ATTR_DENSITY = 'Density' - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Perform the setup for Xiaomi devices.""" - devices = [] - for (_, gateway) in hass.data[PY_XIAOMI_GATEWAY].gateways.items(): - for device in gateway.devices['binary_sensor']: - model = device['model'] - if model in ['motion', 'sensor_motion', 'sensor_motion.aq2']: - devices.append(XiaomiMotionSensor(device, hass, gateway)) - 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 in ['smoke', 'sensor_smoke']: - devices.append(XiaomiSmokeSensor(device, gateway)) - elif model in ['natgas', 'sensor_natgas']: - devices.append(XiaomiNatgasSensor(device, gateway)) - 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 = 'button_0' - devices.append(XiaomiButton(device, 'Switch', data_key, - hass, gateway)) - elif model in ['86sw1', 'sensor_86sw1', 'sensor_86sw1.aq1']: - if 'proto' not in device or int(device['proto'][0:1]) == 1: - data_key = 'channel_0' - else: - data_key = 'button_0' - devices.append(XiaomiButton(device, 'Wall Switch', data_key, - hass, gateway)) - elif model in ['86sw2', 'sensor_86sw2', 'sensor_86sw2.aq1']: - if 'proto' not in device or int(device['proto'][0:1]) == 1: - data_key_left = 'channel_0' - data_key_right = 'channel_1' - else: - data_key_left = 'button_0' - data_key_right = 'button_1' - devices.append(XiaomiButton(device, 'Wall Switch (Left)', - data_key_left, hass, gateway)) - devices.append(XiaomiButton(device, 'Wall Switch (Right)', - data_key_right, hass, gateway)) - devices.append(XiaomiButton(device, 'Wall Switch (Both)', - 'dual_channel', hass, gateway)) - elif model in ['cube', 'sensor_cube', 'sensor_cube.aqgl01']: - devices.append(XiaomiCube(device, hass, gateway)) - add_entities(devices) - - -class XiaomiBinarySensor(XiaomiDevice, BinarySensorDevice): - """Representation of a base XiaomiBinarySensor.""" - - def __init__(self, device, name, xiaomi_hub, data_key, device_class): - """Initialize the XiaomiSmokeSensor.""" - self._data_key = data_key - self._device_class = device_class - self._should_poll = False - self._density = 0 - XiaomiDevice.__init__(self, device, name, xiaomi_hub) - - @property - def should_poll(self): - """Return True if entity has to be polled for state.""" - return self._should_poll - - @property - def is_on(self): - """Return true if sensor is on.""" - return self._state - - @property - def device_class(self): - """Return the class of binary sensor.""" - return self._device_class - - def update(self): - """Update the sensor state.""" - _LOGGER.debug('Updating xiaomi sensor by polling') - self._get_from_hub(self._sid) - - -class XiaomiNatgasSensor(XiaomiBinarySensor): - """Representation of a XiaomiNatgasSensor.""" - - def __init__(self, device, xiaomi_hub): - """Initialize the XiaomiSmokeSensor.""" - self._density = None - XiaomiBinarySensor.__init__(self, device, 'Natgas Sensor', xiaomi_hub, - 'alarm', 'gas') - - @property - def device_state_attributes(self): - """Return the state attributes.""" - attrs = {ATTR_DENSITY: self._density} - attrs.update(super().device_state_attributes) - return attrs - - def parse_data(self, data, raw_data): - """Parse data sent by gateway.""" - if DENSITY in data: - self._density = int(data.get(DENSITY)) - - value = data.get(self._data_key) - if value is None: - return False - - if value in ('1', '2'): - if self._state: - return False - self._state = True - return True - if value == '0': - if self._state: - self._state = False - return True - return False - - -class XiaomiMotionSensor(XiaomiBinarySensor): - """Representation of a XiaomiMotionSensor.""" - - 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, - data_key, 'motion') - - @property - def device_state_attributes(self): - """Return the state attributes.""" - attrs = {ATTR_NO_MOTION_SINCE: self._no_motion_since} - attrs.update(super().device_state_attributes) - return attrs - - 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] - self._state = False - return True - - value = data.get(self._data_key) - if value is None: - return False - - if value == MOTION: - self._should_poll = True - if self.entity_id is not None: - self._hass.bus.fire('motion', { - 'entity_id': self.entity_id - }) - - self._no_motion_since = 0 - if self._state: - return False - self._state = True - return True - if value == NO_MOTION: - if not self._state: - return False - self._state = False - return True - - -class XiaomiDoorSensor(XiaomiBinarySensor): - """Representation of a XiaomiDoorSensor.""" - - def __init__(self, device, xiaomi_hub): - """Initialize the XiaomiDoorSensor.""" - self._open_since = 0 - if 'proto' not in device or int(device['proto'][0:1]) == 1: - data_key = 'status' - else: - data_key = 'window_status' - XiaomiBinarySensor.__init__(self, device, 'Door Window Sensor', - xiaomi_hub, data_key, 'opening') - - @property - def device_state_attributes(self): - """Return the state attributes.""" - attrs = {ATTR_OPEN_SINCE: self._open_since} - attrs.update(super().device_state_attributes) - return attrs - - 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 - self._open_since = data[NO_CLOSE] - return True - - value = data.get(self._data_key) - if value is None: - return False - - if value == 'open': - self._should_poll = True - if self._state: - return False - self._state = True - return True - if value == 'close': - self._open_since = 0 - if self._state: - self._state = False - return True - return False - - -class XiaomiWaterLeakSensor(XiaomiBinarySensor): - """Representation of a XiaomiWaterLeakSensor.""" - - def __init__(self, device, xiaomi_hub): - """Initialize the XiaomiWaterLeakSensor.""" - if 'proto' not in device or int(device['proto'][0:1]) == 1: - data_key = 'status' - else: - data_key = 'wleak_status' - XiaomiBinarySensor.__init__(self, device, 'Water Leak Sensor', - xiaomi_hub, data_key, 'moisture') - - def parse_data(self, data, raw_data): - """Parse data sent by gateway.""" - self._should_poll = False - - value = data.get(self._data_key) - if value is None: - return False - - if value == 'leak': - self._should_poll = True - if self._state: - return False - self._state = True - return True - if value == 'no_leak': - if self._state: - self._state = False - return True - return False - - -class XiaomiSmokeSensor(XiaomiBinarySensor): - """Representation of a XiaomiSmokeSensor.""" - - def __init__(self, device, xiaomi_hub): - """Initialize the XiaomiSmokeSensor.""" - self._density = 0 - XiaomiBinarySensor.__init__(self, device, 'Smoke Sensor', xiaomi_hub, - 'alarm', 'smoke') - - @property - def device_state_attributes(self): - """Return the state attributes.""" - attrs = {ATTR_DENSITY: self._density} - attrs.update(super().device_state_attributes) - return attrs - - def parse_data(self, data, raw_data): - """Parse data sent by gateway.""" - if DENSITY in data: - self._density = int(data.get(DENSITY)) - value = data.get(self._data_key) - if value is None: - return False - - if value in ('1', '2'): - if self._state: - return False - self._state = True - return True - if value == '0': - if self._state: - self._state = False - return True - return False - - -class XiaomiButton(XiaomiBinarySensor): - """Representation of a Xiaomi Button.""" - - def __init__(self, device, name, data_key, hass, xiaomi_hub): - """Initialize the XiaomiButton.""" - self._hass = hass - self._last_action = None - XiaomiBinarySensor.__init__(self, device, name, xiaomi_hub, - data_key, None) - - @property - def device_state_attributes(self): - """Return the state attributes.""" - attrs = {ATTR_LAST_ACTION: self._last_action} - attrs.update(super().device_state_attributes) - return attrs - - def parse_data(self, data, raw_data): - """Parse data sent by gateway.""" - value = data.get(self._data_key) - if value is None: - return False - - if value == 'long_click_press': - self._state = True - click_type = 'long_click_press' - elif value == 'long_click_release': - self._state = False - click_type = 'hold' - elif value == 'click': - click_type = 'single' - elif value == 'double_click': - click_type = 'double' - elif value == 'both_click': - click_type = 'both' - elif value == 'shake': - click_type = 'shake' - elif value in ['long_click', 'long_both_click']: - return False - 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 - - -class XiaomiCube(XiaomiBinarySensor): - """Representation of a Xiaomi Cube.""" - - def __init__(self, device, hass, xiaomi_hub): - """Initialize the Xiaomi Cube.""" - self._hass = hass - self._last_action = None - self._state = False - if 'proto' not in device or int(device['proto'][0:1]) == 1: - data_key = 'status' - else: - data_key = 'cube_status' - XiaomiBinarySensor.__init__(self, device, 'Cube', xiaomi_hub, - data_key, None) - - @property - def device_state_attributes(self): - """Return the state attributes.""" - attrs = {ATTR_LAST_ACTION: self._last_action} - attrs.update(super().device_state_attributes) - return attrs - - def parse_data(self, data, raw_data): - """Parse data sent by gateway.""" - if self._data_key in data: - self._hass.bus.fire('cube_action', { - 'entity_id': self.entity_id, - 'action_type': data[self._data_key] - }) - self._last_action = data[self._data_key] - - if 'rotate' in data: - self._hass.bus.fire('cube_action', { - 'entity_id': self.entity_id, - 'action_type': 'rotate', - 'action_value': float(data['rotate'].replace(",", ".")) - }) - self._last_action = 'rotate' - - return True diff --git a/homeassistant/components/binary_sensor/zha.py b/homeassistant/components/binary_sensor/zha.py deleted file mode 100644 index aa07a673c..000000000 --- a/homeassistant/components/binary_sensor/zha.py +++ /dev/null @@ -1,257 +0,0 @@ -""" -Binary sensors on Zigbee Home Automation networks. - -For more details on this platform, please refer to the documentation -at https://home-assistant.io/components/binary_sensor.zha/ -""" -import logging - -from homeassistant.components.binary_sensor import DOMAIN, BinarySensorDevice -from homeassistant.components import zha - -_LOGGER = logging.getLogger(__name__) - -DEPENDENCIES = ['zha'] - -# ZigBee Cluster Library Zone Type to Home Assistant device class -CLASS_MAPPING = { - 0x000d: 'motion', - 0x0015: 'opening', - 0x0028: 'smoke', - 0x002a: 'moisture', - 0x002b: 'gas', - 0x002d: 'vibration', -} - - -async def async_setup_platform(hass, config, async_add_entities, - 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 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_entities, - discovery_info) - elif OnOff.cluster_id in discovery_info['out_clusters']: - await _async_setup_remote(hass, config, async_add_entities, - discovery_info) - - -async def _async_setup_iaszone(hass, config, async_add_entities, - discovery_info): - device_class = None - from zigpy.zcl.clusters.security import IasZone - cluster = discovery_info['in_clusters'][IasZone.cluster_id] - if discovery_info['new_join']: - await cluster.bind() - ieee = cluster.endpoint.device.application.ieee - await cluster.write_attributes({'cie_addr': ieee}) - - try: - 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_entities([sensor], update_before_add=True) - - -async def _async_setup_remote(hass, config, async_add_entities, - discovery_info): - - remote = Remote(**discovery_info) - - 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 zha.configure_reporting( - remote.entity_id, cluster, 0, min_report=0, max_report=600, - reportable_change=1 - ) - if LevelControl.cluster_id in out_clusters: - cluster = out_clusters[LevelControl.cluster_id] - await zha.configure_reporting( - remote.entity_id, cluster, 0, min_report=1, max_report=600, - reportable_change=1 - ) - - async_add_entities([remote], update_before_add=True) - - -class BinarySensor(zha.Entity, BinarySensorDevice): - """The ZHA Binary Sensor.""" - - _domain = DOMAIN - - def __init__(self, device_class, **kwargs): - """Initialize the ZHA binary sensor.""" - super().__init__(**kwargs) - self._device_class = device_class - 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 is None: - return False - return bool(self._state) - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return self._device_class - - 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.async_schedule_update_ha_state() - elif command_id == 1: - _LOGGER.debug("Enroll requested") - res = self._ias_zone_cluster.enroll_response(0, 0) - self.hass.async_add_job(res) - - async def async_update(self): - """Retrieve latest state.""" - from zigpy.types.basic import uint16_t - - result = await zha.safe_read(self._endpoint.ias_zone, - ['zone_status'], - allow_cache=False, - only_cache=(not self._initialized)) - state = result.get('zone_status', self._state) - if isinstance(state, (int, uint16_t)): - self._state = result.get('zone_status', self._state) & 3 - - -class Remote(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 in (0x0002, 0x0006): # step, -with_on_off - # Step (technically may change on/off) - self._entity.move_level(-args[1] if args[0] else args[1]) - - def attribute_update(self, attrid, value): - """Handle attribute updates on this cluster.""" - if attrid == 0: - self._entity.set_level(value) - - def zdo_command(self, *args, **kwargs): - """Handle ZDO commands on this cluster.""" - pass - - def __init__(self, **kwargs): - """Initialize Switch.""" - super().__init__(**kwargs) - self._state = False - self._level = 0 - from zigpy.zcl.clusters import general - self._out_listeners = { - general.OnOff.cluster_id: self.OnOffListener(self), - general.LevelControl.cluster_id: self.LevelListener(self), - } - - @property - def should_poll(self) -> bool: - """Let zha handle polling.""" - return False - - @property - def is_on(self) -> bool: - """Return true if the binary sensor is on.""" - return self._state - - @property - def device_state_attributes(self): - """Return the device state attributes.""" - self._device_state_attributes.update({ - 'level': self._state and self._level or 0 - }) - return self._device_state_attributes - - def move_level(self, change): - """Increment the level, setting state if appropriate.""" - if not self._state and change > 0: - self._level = 0 - self._level = min(255, max(0, self._level + change)) - self._state = bool(self._level) - self.async_schedule_update_ha_state() - - def set_level(self, level): - """Set the level, setting state if appropriate.""" - self._level = level - self._state = bool(self._level) - self.async_schedule_update_ha_state() - - def set_state(self, state): - """Set the state.""" - self._state = state - if self._level == 0: - self._level = 255 - self.async_schedule_update_ha_state() - - async def async_update(self): - """Retrieve latest state.""" - from zigpy.zcl.clusters.general import OnOff - result = await zha.safe_read( - self._endpoint.out_clusters[OnOff.cluster_id], ['on_off']) - self._state = result.get('on_off', self._state) diff --git a/homeassistant/components/binary_sensor/zigbee.py b/homeassistant/components/binary_sensor/zigbee.py deleted file mode 100644 index 6b8925820..000000000 --- a/homeassistant/components/binary_sensor/zigbee.py +++ /dev/null @@ -1,34 +0,0 @@ -""" -Contains functionality to use a ZigBee device as a binary sensor. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.zigbee/ -""" -import voluptuous as vol - -from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.components.zigbee import ( - ZigBeeDigitalIn, ZigBeeDigitalInConfig, PLATFORM_SCHEMA) - -CONF_ON_STATE = 'on_state' - -DEFAULT_ON_STATE = 'high' -DEPENDENCIES = ['zigbee'] - -STATES = ['high', 'low'] - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_ON_STATE): vol.In(STATES), -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the ZigBee binary sensor platform.""" - add_entities( - [ZigBeeBinarySensor(hass, ZigBeeDigitalInConfig(config))], True) - - -class ZigBeeBinarySensor(ZigBeeDigitalIn, BinarySensorDevice): - """Use ZigBeeDigitalIn as binary sensor.""" - - pass diff --git a/homeassistant/components/binary_sensor/zwave.py b/homeassistant/components/binary_sensor/zwave.py deleted file mode 100644 index 3bb3a3c79..000000000 --- a/homeassistant/components/binary_sensor/zwave.py +++ /dev/null @@ -1,93 +0,0 @@ -""" -Interfaces with Z-Wave sensors. - -For more details about this platform, please refer to the documentation -https://home-assistant.io/components/binary_sensor.zwave/ -""" -import logging -import datetime -import homeassistant.util.dt as dt_util -from homeassistant.helpers.event import track_point_in_time -from homeassistant.components import zwave -from homeassistant.components.zwave import ( # noqa pylint: disable=unused-import - async_setup_platform, workaround) -from homeassistant.components.binary_sensor import ( - DOMAIN, - BinarySensorDevice) - -_LOGGER = logging.getLogger(__name__) -DEPENDENCIES = [] - - -def get_device(values, **kwargs): - """Create Z-Wave entity device.""" - device_mapping = workaround.get_device_mapping(values.primary) - if device_mapping == workaround.WORKAROUND_NO_OFF_EVENT: - return ZWaveTriggerSensor(values, "motion") - - if workaround.get_device_component_mapping(values.primary) == DOMAIN: - return ZWaveBinarySensor(values, None) - - if values.primary.command_class == zwave.const.COMMAND_CLASS_SENSOR_BINARY: - return ZWaveBinarySensor(values, None) - return None - - -class ZWaveBinarySensor(BinarySensorDevice, zwave.ZWaveDeviceEntity): - """Representation of a binary sensor within Z-Wave.""" - - def __init__(self, values, device_class): - """Initialize the sensor.""" - zwave.ZWaveDeviceEntity.__init__(self, values, DOMAIN) - self._sensor_type = device_class - self._state = self.values.primary.data - - def update_properties(self): - """Handle data changes for node values.""" - self._state = self.values.primary.data - - @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 sensor, from DEVICE_CLASSES.""" - return self._sensor_type - - -class ZWaveTriggerSensor(ZWaveBinarySensor): - """Representation of a stateless sensor within Z-Wave.""" - - def __init__(self, values, device_class): - """Initialize the sensor.""" - super(ZWaveTriggerSensor, self).__init__(values, device_class) - # Set default off delay to 60 sec - self.re_arm_sec = 60 - self.invalidate_after = None - - def update_properties(self): - """Handle value changes for this entity's node.""" - self._state = self.values.primary.data - _LOGGER.debug('off_delay=%s', self.values.off_delay) - # Set re_arm_sec if off_delay is provided from the sensor - if self.values.off_delay: - _LOGGER.debug('off_delay.data=%s', self.values.off_delay.data) - self.re_arm_sec = self.values.off_delay.data * 8 - # only allow this value to be true for re_arm secs - if not self.hass: - return - - self.invalidate_after = dt_util.utcnow() + datetime.timedelta( - seconds=self.re_arm_sec) - track_point_in_time( - self.hass, self.async_update_ha_state, - self.invalidate_after) - - @property - def is_on(self): - """Return true if movement has happened within the rearm time.""" - return self._state and \ - (self.invalidate_after is None or - self.invalidate_after > dt_util.utcnow()) diff --git a/homeassistant/components/bitcoin/__init__.py b/homeassistant/components/bitcoin/__init__.py new file mode 100644 index 000000000..cfdfb53c0 --- /dev/null +++ b/homeassistant/components/bitcoin/__init__.py @@ -0,0 +1 @@ +"""The bitcoin component.""" diff --git a/homeassistant/components/bitcoin/manifest.json b/homeassistant/components/bitcoin/manifest.json new file mode 100644 index 000000000..1ffc34fcd --- /dev/null +++ b/homeassistant/components/bitcoin/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "bitcoin", + "name": "Bitcoin", + "documentation": "https://www.home-assistant.io/integrations/bitcoin", + "requirements": [ + "blockchain==1.4.4" + ], + "dependencies": [], + "codeowners": [ + "@fabaff" + ] +} diff --git a/homeassistant/components/bitcoin/sensor.py b/homeassistant/components/bitcoin/sensor.py new file mode 100644 index 000000000..b62bb434e --- /dev/null +++ b/homeassistant/components/bitcoin/sensor.py @@ -0,0 +1,174 @@ +"""Bitcoin information service that uses blockchain.info.""" +from datetime import timedelta +import logging + +from blockchain import exchangerates, statistics +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ATTR_ATTRIBUTION, CONF_CURRENCY, CONF_DISPLAY_OPTIONS +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +ATTRIBUTION = "Data provided by blockchain.info" + +DEFAULT_CURRENCY = "USD" + +ICON = "mdi:currency-btc" + +SCAN_INTERVAL = timedelta(minutes=5) + +OPTION_TYPES = { + "exchangerate": ["Exchange rate (1 BTC)", None], + "trade_volume_btc": ["Trade volume", "BTC"], + "miners_revenue_usd": ["Miners revenue", "USD"], + "btc_mined": ["Mined", "BTC"], + "trade_volume_usd": ["Trade volume", "USD"], + "difficulty": ["Difficulty", None], + "minutes_between_blocks": ["Time between Blocks", "min"], + "number_of_transactions": ["No. of Transactions", None], + "hash_rate": ["Hash rate", "PH/s"], + "timestamp": ["Timestamp", None], + "mined_blocks": ["Mined Blocks", None], + "blocks_size": ["Block size", None], + "total_fees_btc": ["Total fees", "BTC"], + "total_btc_sent": ["Total sent", "BTC"], + "estimated_btc_sent": ["Estimated sent", "BTC"], + "total_btc": ["Total", "BTC"], + "total_blocks": ["Total Blocks", None], + "next_retarget": ["Next retarget", None], + "estimated_transaction_volume_usd": ["Est. Transaction volume", "USD"], + "miners_revenue_btc": ["Miners revenue", "BTC"], + "market_price_usd": ["Market price", "USD"], +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_DISPLAY_OPTIONS, default=[]): vol.All( + cv.ensure_list, [vol.In(OPTION_TYPES)] + ), + vol.Optional(CONF_CURRENCY, default=DEFAULT_CURRENCY): cv.string, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Bitcoin sensors.""" + + currency = config.get(CONF_CURRENCY) + + if currency not in exchangerates.get_ticker(): + _LOGGER.warning("Currency %s is not available. Using USD", currency) + currency = DEFAULT_CURRENCY + + data = BitcoinData() + dev = [] + for variable in config[CONF_DISPLAY_OPTIONS]: + dev.append(BitcoinSensor(data, variable, currency)) + + add_entities(dev, True) + + +class BitcoinSensor(Entity): + """Representation of a Bitcoin sensor.""" + + def __init__(self, data, option_type, currency): + """Initialize the sensor.""" + self.data = data + self._name = OPTION_TYPES[option_type][0] + self._unit_of_measurement = OPTION_TYPES[option_type][1] + self._currency = currency + self.type = option_type + self._state = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self._unit_of_measurement + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return ICON + + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + return {ATTR_ATTRIBUTION: ATTRIBUTION} + + def update(self): + """Get the latest data and updates the states.""" + self.data.update() + stats = self.data.stats + ticker = self.data.ticker + + if self.type == "exchangerate": + self._state = ticker[self._currency].p15min + self._unit_of_measurement = self._currency + elif self.type == "trade_volume_btc": + self._state = "{0:.1f}".format(stats.trade_volume_btc) + elif self.type == "miners_revenue_usd": + self._state = "{0:.0f}".format(stats.miners_revenue_usd) + elif self.type == "btc_mined": + self._state = "{}".format(stats.btc_mined * 0.00000001) + elif self.type == "trade_volume_usd": + self._state = "{0:.1f}".format(stats.trade_volume_usd) + elif self.type == "difficulty": + self._state = "{0:.0f}".format(stats.difficulty) + elif self.type == "minutes_between_blocks": + self._state = "{0:.2f}".format(stats.minutes_between_blocks) + elif self.type == "number_of_transactions": + self._state = "{}".format(stats.number_of_transactions) + elif self.type == "hash_rate": + self._state = "{0:.1f}".format(stats.hash_rate * 0.000001) + elif self.type == "timestamp": + self._state = stats.timestamp + elif self.type == "mined_blocks": + self._state = "{}".format(stats.mined_blocks) + elif self.type == "blocks_size": + self._state = "{0:.1f}".format(stats.blocks_size) + elif self.type == "total_fees_btc": + self._state = "{0:.2f}".format(stats.total_fees_btc * 0.00000001) + elif self.type == "total_btc_sent": + self._state = "{0:.2f}".format(stats.total_btc_sent * 0.00000001) + elif self.type == "estimated_btc_sent": + self._state = "{0:.2f}".format(stats.estimated_btc_sent * 0.00000001) + elif self.type == "total_btc": + self._state = "{0:.2f}".format(stats.total_btc * 0.00000001) + elif self.type == "total_blocks": + self._state = "{0:.2f}".format(stats.total_blocks) + elif self.type == "next_retarget": + self._state = "{0:.2f}".format(stats.next_retarget) + elif self.type == "estimated_transaction_volume_usd": + self._state = "{0:.2f}".format(stats.estimated_transaction_volume_usd) + elif self.type == "miners_revenue_btc": + self._state = "{0:.1f}".format(stats.miners_revenue_btc * 0.00000001) + elif self.type == "market_price_usd": + self._state = "{0:.2f}".format(stats.market_price_usd) + + +class BitcoinData: + """Get the latest data and update the states.""" + + def __init__(self): + """Initialize the data object.""" + self.stats = None + self.ticker = None + + def update(self): + """Get the latest data from blockchain.info.""" + + self.stats = statistics.get() + self.ticker = exchangerates.get_ticker() diff --git a/homeassistant/components/bizkaibus/__init__.py b/homeassistant/components/bizkaibus/__init__.py new file mode 100644 index 000000000..e37c17e57 --- /dev/null +++ b/homeassistant/components/bizkaibus/__init__.py @@ -0,0 +1 @@ +"""The Bizkaibus bus tracker component.""" diff --git a/homeassistant/components/bizkaibus/manifest.json b/homeassistant/components/bizkaibus/manifest.json new file mode 100644 index 000000000..63c0494c2 --- /dev/null +++ b/homeassistant/components/bizkaibus/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "bizkaibus", + "name": "Bizkaibus", + "documentation": "https://www.home-assistant.io/integrations/bizkaibus", + "dependencies": [], + "codeowners": ["@UgaitzEtxebarria"], + "requirements": ["bizkaibus==0.1.1"] +} diff --git a/homeassistant/components/bizkaibus/sensor.py b/homeassistant/components/bizkaibus/sensor.py new file mode 100644 index 000000000..931fbbb83 --- /dev/null +++ b/homeassistant/components/bizkaibus/sensor.py @@ -0,0 +1,89 @@ +"""Support for Bizkaibus, Biscay (Basque Country, Spain) Bus service.""" + +import logging + +from bizkaibus.bizkaibus import BizkaibusData +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_NAME +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +ATTR_DUE_IN = "Due in" + +CONF_STOP_ID = "stopid" +CONF_ROUTE = "route" + +DEFAULT_NAME = "Next bus" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_STOP_ID): cv.string, + vol.Required(CONF_ROUTE): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Bizkaibus public transport sensor.""" + name = config.get(CONF_NAME) + stop = config[CONF_STOP_ID] + route = config[CONF_ROUTE] + + data = Bizkaibus(stop, route) + add_entities([BizkaibusSensor(data, stop, route, name)], True) + + +class BizkaibusSensor(Entity): + """The class for handling the data.""" + + def __init__(self, data, stop, route, name): + """Initialize the sensor.""" + self.data = data + self.stop = stop + self.route = route + self._name = name + self._state = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement of the sensor.""" + return "minutes" + + def update(self): + """Get the latest data from the webservice.""" + self.data.update() + try: + self._state = self.data.info[0][ATTR_DUE_IN] + except TypeError: + pass + + +class Bizkaibus: + """The class for handling the data retrieval.""" + + def __init__(self, stop, route): + """Initialize the data object.""" + self.stop = stop + self.route = route + self.info = None + + def update(self): + """Retrieve the information from API.""" + bridge = BizkaibusData(self.stop, self.route) + bridge.getNextBus() + self.info = bridge.info diff --git a/homeassistant/components/blackbird/__init__.py b/homeassistant/components/blackbird/__init__.py new file mode 100644 index 000000000..b901bda04 --- /dev/null +++ b/homeassistant/components/blackbird/__init__.py @@ -0,0 +1 @@ +"""The blackbird component.""" diff --git a/homeassistant/components/blackbird/const.py b/homeassistant/components/blackbird/const.py new file mode 100644 index 000000000..aa8d7e7d5 --- /dev/null +++ b/homeassistant/components/blackbird/const.py @@ -0,0 +1,3 @@ +"""Constants for the Monoprice Blackbird Matrix Switch component.""" +DOMAIN = "blackbird" +SERVICE_SETALLZONES = "set_all_zones" diff --git a/homeassistant/components/blackbird/manifest.json b/homeassistant/components/blackbird/manifest.json new file mode 100644 index 000000000..c5b3a632c --- /dev/null +++ b/homeassistant/components/blackbird/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "blackbird", + "name": "Blackbird", + "documentation": "https://www.home-assistant.io/integrations/blackbird", + "requirements": [ + "pyblackbird==0.5" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/blackbird/media_player.py b/homeassistant/components/blackbird/media_player.py new file mode 100644 index 000000000..a0ea369bb --- /dev/null +++ b/homeassistant/components/blackbird/media_player.py @@ -0,0 +1,216 @@ +"""Support for interfacing with Monoprice Blackbird 4k 8x8 HDBaseT Matrix.""" +import logging +import socket + +from pyblackbird import get_blackbird +from serial import SerialException +import voluptuous as vol + +from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerDevice +from homeassistant.components.media_player.const import ( + SUPPORT_SELECT_SOURCE, + SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_HOST, + CONF_NAME, + CONF_PORT, + CONF_TYPE, + STATE_OFF, + STATE_ON, +) +import homeassistant.helpers.config_validation as cv + +from .const import DOMAIN, SERVICE_SETALLZONES + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_BLACKBIRD = SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_SELECT_SOURCE + +MEDIA_PLAYER_SCHEMA = vol.Schema({ATTR_ENTITY_ID: cv.comp_entity_ids}) + +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_BLACKBIRD = "blackbird" + +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}), + } + ), +) + + +def setup_platform(hass, config, add_entities, 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) + + 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 = f"{connection}-{zone_id}" + device = BlackbirdZone(blackbird, sources, zone_id, extra[CONF_NAME]) + hass.data[DATA_BLACKBIRD][unique_id] = device + devices.append(device) + + add_entities(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/blackbird/services.yaml b/homeassistant/components/blackbird/services.yaml new file mode 100644 index 000000000..d541e2104 --- /dev/null +++ b/homeassistant/components/blackbird/services.yaml @@ -0,0 +1,10 @@ +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/blink.py b/homeassistant/components/blink.py deleted file mode 100644 index e84643711..000000000 --- a/homeassistant/components/blink.py +++ /dev/null @@ -1,89 +0,0 @@ -""" -Support for Blink Home Camera System. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/blink/ -""" -import logging - -import voluptuous as vol - -import homeassistant.helpers.config_validation as cv -from homeassistant.const import ( - CONF_USERNAME, CONF_PASSWORD, ATTR_FRIENDLY_NAME, ATTR_ARMED) -from homeassistant.helpers import discovery - -REQUIREMENTS = ['blinkpy==0.6.0'] - -_LOGGER = logging.getLogger(__name__) - -DOMAIN = 'blink' - -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string - }) -}, extra=vol.ALLOW_EXTRA) - -ARM_SYSTEM_SCHEMA = vol.Schema({ - vol.Optional(ATTR_ARMED): cv.boolean -}) - -ARM_CAMERA_SCHEMA = vol.Schema({ - vol.Required(ATTR_FRIENDLY_NAME): cv.string, - vol.Optional(ATTR_ARMED): cv.boolean -}) - -SNAP_PICTURE_SCHEMA = vol.Schema({ - vol.Required(ATTR_FRIENDLY_NAME): cv.string -}) - - -class BlinkSystem: - """Blink System class.""" - - def __init__(self, config_info): - """Initialize the system.""" - import blinkpy - self.blink = blinkpy.Blink(username=config_info[DOMAIN][CONF_USERNAME], - password=config_info[DOMAIN][CONF_PASSWORD]) - self.blink.setup_system() - - -def setup(hass, config): - """Set up Blink System.""" - hass.data[DOMAIN] = BlinkSystem(config) - discovery.load_platform(hass, 'camera', DOMAIN, {}, config) - discovery.load_platform(hass, 'sensor', DOMAIN, {}, config) - discovery.load_platform(hass, 'binary_sensor', DOMAIN, {}, config) - - def snap_picture(call): - """Take a picture.""" - cameras = hass.data[DOMAIN].blink.cameras - name = call.data.get(ATTR_FRIENDLY_NAME, '') - if name in cameras: - cameras[name].snap_picture() - - def arm_camera(call): - """Arm a camera.""" - cameras = hass.data[DOMAIN].blink.cameras - name = call.data.get(ATTR_FRIENDLY_NAME, '') - value = call.data.get(ATTR_ARMED, True) - if name in cameras: - cameras[name].set_motion_detect(value) - - def arm_system(call): - """Arm the system.""" - value = call.data.get(ATTR_ARMED, True) - hass.data[DOMAIN].blink.arm = value - hass.data[DOMAIN].blink.refresh() - - hass.services.register( - DOMAIN, 'snap_picture', snap_picture, schema=SNAP_PICTURE_SCHEMA) - hass.services.register( - DOMAIN, 'arm_camera', arm_camera, schema=ARM_CAMERA_SCHEMA) - hass.services.register( - DOMAIN, 'arm_system', arm_system, schema=ARM_SYSTEM_SCHEMA) - - return True diff --git a/homeassistant/components/blink/__init__.py b/homeassistant/components/blink/__init__.py new file mode 100644 index 000000000..e233a8b21 --- /dev/null +++ b/homeassistant/components/blink/__init__.py @@ -0,0 +1,171 @@ +"""Support for Blink Home Camera System.""" +from datetime import timedelta +import logging + +from blinkpy import blinkpy +import voluptuous as vol + +from homeassistant.const import ( + CONF_BINARY_SENSORS, + CONF_FILENAME, + CONF_MODE, + CONF_MONITORED_CONDITIONS, + CONF_NAME, + CONF_OFFSET, + CONF_PASSWORD, + CONF_SCAN_INTERVAL, + CONF_SENSORS, + CONF_USERNAME, + TEMP_FAHRENHEIT, +) +from homeassistant.helpers import config_validation as cv, discovery + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "blink" +BLINK_DATA = "blink" + +CONF_CAMERA = "camera" +CONF_ALARM_CONTROL_PANEL = "alarm_control_panel" + +DEFAULT_BRAND = "Blink" +DEFAULT_ATTRIBUTION = "Data provided by immedia-semi.com" +SIGNAL_UPDATE_BLINK = "blink_update" + +DEFAULT_SCAN_INTERVAL = timedelta(seconds=300) + +TYPE_CAMERA_ARMED = "motion_enabled" +TYPE_MOTION_DETECTED = "motion_detected" +TYPE_TEMPERATURE = "temperature" +TYPE_BATTERY = "battery" +TYPE_WIFI_STRENGTH = "wifi_strength" + +SERVICE_REFRESH = "blink_update" +SERVICE_TRIGGER = "trigger_camera" +SERVICE_SAVE_VIDEO = "save_video" + +BINARY_SENSORS = { + TYPE_CAMERA_ARMED: ["Camera Armed", "mdi:verified"], + TYPE_MOTION_DETECTED: ["Motion Detected", "mdi:run-fast"], +} + +SENSORS = { + TYPE_TEMPERATURE: ["Temperature", TEMP_FAHRENHEIT, "mdi:thermometer"], + TYPE_BATTERY: ["Battery", "", "mdi:battery-80"], + TYPE_WIFI_STRENGTH: ["Wifi Signal", "dBm", "mdi:wifi-strength-2"], +} + +BINARY_SENSOR_SCHEMA = vol.Schema( + { + vol.Optional(CONF_MONITORED_CONDITIONS, default=list(BINARY_SENSORS)): vol.All( + cv.ensure_list, [vol.In(BINARY_SENSORS)] + ) + } +) + +SENSOR_SCHEMA = vol.Schema( + { + vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSORS)): vol.All( + cv.ensure_list, [vol.In(SENSORS)] + ) + } +) + +SERVICE_TRIGGER_SCHEMA = vol.Schema({vol.Required(CONF_NAME): cv.string}) + +SERVICE_SAVE_VIDEO_SCHEMA = vol.Schema( + {vol.Required(CONF_NAME): cv.string, vol.Required(CONF_FILENAME): cv.string} +) + +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=DEFAULT_SCAN_INTERVAL + ): cv.time_period, + vol.Optional(CONF_BINARY_SENSORS, default={}): BINARY_SENSOR_SCHEMA, + vol.Optional(CONF_SENSORS, default={}): SENSOR_SCHEMA, + vol.Optional(CONF_OFFSET, default=1): int, + vol.Optional(CONF_MODE, default=""): cv.string, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +def setup(hass, config): + """Set up Blink System.""" + + conf = config[BLINK_DATA] + username = conf[CONF_USERNAME] + password = conf[CONF_PASSWORD] + scan_interval = conf[CONF_SCAN_INTERVAL] + is_legacy = bool(conf[CONF_MODE] == "legacy") + motion_interval = conf[CONF_OFFSET] + hass.data[BLINK_DATA] = blinkpy.Blink( + username=username, + password=password, + motion_interval=motion_interval, + legacy_subdomain=is_legacy, + ) + hass.data[BLINK_DATA].refresh_rate = scan_interval.total_seconds() + hass.data[BLINK_DATA].start() + + platforms = [ + ("alarm_control_panel", {}), + ("binary_sensor", conf[CONF_BINARY_SENSORS]), + ("camera", {}), + ("sensor", conf[CONF_SENSORS]), + ] + + for component, schema in platforms: + discovery.load_platform(hass, component, DOMAIN, schema, config) + + def trigger_camera(call): + """Trigger a camera.""" + cameras = hass.data[BLINK_DATA].cameras + name = call.data[CONF_NAME] + if name in cameras: + cameras[name].snap_picture() + hass.data[BLINK_DATA].refresh(force_cache=True) + + def blink_refresh(event_time): + """Call blink to refresh info.""" + hass.data[BLINK_DATA].refresh(force_cache=True) + + async def async_save_video(call): + """Call save video service handler.""" + await async_handle_save_video_service(hass, call) + + hass.services.register(DOMAIN, SERVICE_REFRESH, blink_refresh) + hass.services.register( + DOMAIN, SERVICE_TRIGGER, trigger_camera, schema=SERVICE_TRIGGER_SCHEMA + ) + hass.services.register( + DOMAIN, SERVICE_SAVE_VIDEO, async_save_video, schema=SERVICE_SAVE_VIDEO_SCHEMA + ) + return True + + +async def async_handle_save_video_service(hass, call): + """Handle save video service calls.""" + camera_name = call.data[CONF_NAME] + video_path = call.data[CONF_FILENAME] + if not hass.config.is_allowed_path(video_path): + _LOGGER.error("Can't write %s, no access to path!", video_path) + return + + def _write_video(camera_name, video_path): + """Call video write.""" + all_cameras = hass.data[BLINK_DATA].cameras + if camera_name in all_cameras: + all_cameras[camera_name].video_to_file(video_path) + + try: + await hass.async_add_executor_job(_write_video, camera_name, video_path) + except OSError as err: + _LOGGER.error("Can't write image to file: %s", err) diff --git a/homeassistant/components/blink/alarm_control_panel.py b/homeassistant/components/blink/alarm_control_panel.py new file mode 100644 index 000000000..9b23c1606 --- /dev/null +++ b/homeassistant/components/blink/alarm_control_panel.py @@ -0,0 +1,93 @@ +"""Support for Blink Alarm Control Panel.""" +import logging + +from homeassistant.components.alarm_control_panel import AlarmControlPanel +from homeassistant.components.alarm_control_panel.const import SUPPORT_ALARM_ARM_AWAY +from homeassistant.const import ( + ATTR_ATTRIBUTION, + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_DISARMED, +) + +from . import BLINK_DATA, DEFAULT_ATTRIBUTION + +_LOGGER = logging.getLogger(__name__) + +ICON = "mdi:security" + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Arlo Alarm Control Panels.""" + if discovery_info is None: + return + data = hass.data[BLINK_DATA] + + sync_modules = [] + for sync_name, sync_module in data.sync.items(): + sync_modules.append(BlinkSyncModule(data, sync_name, sync_module)) + add_entities(sync_modules, True) + + +class BlinkSyncModule(AlarmControlPanel): + """Representation of a Blink Alarm Control Panel.""" + + def __init__(self, data, name, sync): + """Initialize the alarm control panel.""" + self.data = data + self.sync = sync + self._name = name + self._state = None + + @property + def unique_id(self): + """Return the unique id for the sync module.""" + return self.sync.serial + + @property + def icon(self): + """Return icon.""" + return ICON + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @property + def supported_features(self) -> int: + """Return the list of supported features.""" + return SUPPORT_ALARM_ARM_AWAY + + @property + def name(self): + """Return the name of the panel.""" + return f"{BLINK_DATA} {self._name}" + + @property + def device_state_attributes(self): + """Return the state attributes.""" + attr = self.sync.attributes + attr["network_info"] = self.data.networks + attr["associated_cameras"] = list(self.sync.cameras.keys()) + attr[ATTR_ATTRIBUTION] = DEFAULT_ATTRIBUTION + return attr + + def update(self): + """Update the state of the device.""" + _LOGGER.debug("Updating Blink Alarm Control Panel %s", self._name) + self.data.refresh() + mode = self.sync.arm + if mode: + self._state = STATE_ALARM_ARMED_AWAY + else: + self._state = STATE_ALARM_DISARMED + + def alarm_disarm(self, code=None): + """Send disarm command.""" + self.sync.arm = False + self.sync.refresh() + + def alarm_arm_away(self, code=None): + """Send arm command.""" + self.sync.arm = True + self.sync.refresh() diff --git a/homeassistant/components/blink/binary_sensor.py b/homeassistant/components/blink/binary_sensor.py new file mode 100644 index 000000000..e8c01953b --- /dev/null +++ b/homeassistant/components/blink/binary_sensor.py @@ -0,0 +1,48 @@ +"""Support for Blink system camera control.""" +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.const import CONF_MONITORED_CONDITIONS + +from . import BINARY_SENSORS, BLINK_DATA + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the blink binary sensors.""" + if discovery_info is None: + return + data = hass.data[BLINK_DATA] + + devs = [] + for camera in data.cameras: + for sensor_type in discovery_info[CONF_MONITORED_CONDITIONS]: + devs.append(BlinkBinarySensor(data, camera, sensor_type)) + add_entities(devs, True) + + +class BlinkBinarySensor(BinarySensorDevice): + """Representation of a Blink binary sensor.""" + + def __init__(self, data, camera, sensor_type): + """Initialize the sensor.""" + self.data = data + self._type = sensor_type + name, icon = BINARY_SENSORS[sensor_type] + self._name = f"{BLINK_DATA} {camera} {name}" + self._icon = icon + self._camera = data.cameras[camera] + self._state = None + self._unique_id = f"{self._camera.serial}-{self._type}" + + @property + def name(self): + """Return the name of the blink sensor.""" + return self._name + + @property + def is_on(self): + """Return the status of the sensor.""" + return self._state + + def update(self): + """Update sensor state.""" + self.data.refresh() + self._state = self._camera.attributes[self._type] diff --git a/homeassistant/components/blink/camera.py b/homeassistant/components/blink/camera.py new file mode 100644 index 000000000..52043324a --- /dev/null +++ b/homeassistant/components/blink/camera.py @@ -0,0 +1,76 @@ +"""Support for Blink system camera.""" +import logging + +from homeassistant.components.camera import Camera + +from . import BLINK_DATA, DEFAULT_BRAND + +_LOGGER = logging.getLogger(__name__) + +ATTR_VIDEO_CLIP = "video" +ATTR_IMAGE = "image" + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up a Blink Camera.""" + if discovery_info is None: + return + data = hass.data[BLINK_DATA] + devs = [] + for name, camera in data.cameras.items(): + devs.append(BlinkCamera(data, name, camera)) + + add_entities(devs) + + +class BlinkCamera(Camera): + """An implementation of a Blink Camera.""" + + def __init__(self, data, name, camera): + """Initialize a camera.""" + super().__init__() + self.data = data + self._name = f"{BLINK_DATA} {name}" + self._camera = camera + self._unique_id = f"{camera.serial}-camera" + self.response = None + self.current_image = None + self.last_image = None + _LOGGER.debug("Initialized blink camera %s", self._name) + + @property + def name(self): + """Return the camera name.""" + return self._name + + @property + def unique_id(self): + """Return the unique camera id.""" + return self._unique_id + + @property + def device_state_attributes(self): + """Return the camera attributes.""" + return self._camera.attributes + + def enable_motion_detection(self): + """Enable motion detection for the camera.""" + self._camera.set_motion_detect(True) + + def disable_motion_detection(self): + """Disable motion detection for the camera.""" + self._camera.set_motion_detect(False) + + @property + def motion_detection_enabled(self): + """Return the state of the camera.""" + return self._camera.motion_enabled + + @property + def brand(self): + """Return the camera brand.""" + return DEFAULT_BRAND + + def camera_image(self): + """Return a still image response from the camera.""" + return self._camera.image_from_cache.content diff --git a/homeassistant/components/blink/manifest.json b/homeassistant/components/blink/manifest.json new file mode 100644 index 000000000..47cded00c --- /dev/null +++ b/homeassistant/components/blink/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "blink", + "name": "Blink", + "documentation": "https://www.home-assistant.io/integrations/blink", + "requirements": [ + "blinkpy==0.14.2" + ], + "dependencies": [], + "codeowners": [ + "@fronzbot" + ] +} diff --git a/homeassistant/components/blink/sensor.py b/homeassistant/components/blink/sensor.py new file mode 100644 index 000000000..81616b463 --- /dev/null +++ b/homeassistant/components/blink/sensor.py @@ -0,0 +1,78 @@ +"""Support for Blink system camera sensors.""" +import logging + +from homeassistant.const import CONF_MONITORED_CONDITIONS +from homeassistant.helpers.entity import Entity + +from . import BLINK_DATA, SENSORS + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up a Blink sensor.""" + if discovery_info is None: + return + data = hass.data[BLINK_DATA] + devs = [] + for camera in data.cameras: + for sensor_type in discovery_info[CONF_MONITORED_CONDITIONS]: + devs.append(BlinkSensor(data, camera, sensor_type)) + + add_entities(devs, True) + + +class BlinkSensor(Entity): + """A Blink camera sensor.""" + + def __init__(self, data, camera, sensor_type): + """Initialize sensors from Blink camera.""" + name, units, icon = SENSORS[sensor_type] + self._name = f"{BLINK_DATA} {camera} {name}" + self._camera_name = name + self._type = sensor_type + self.data = data + self._camera = data.cameras[camera] + self._state = None + self._unit_of_measurement = units + self._icon = icon + self._unique_id = f"{self._camera.serial}-{self._type}" + self._sensor_key = self._type + if self._type == "temperature": + self._sensor_key = "temperature_calibrated" + + @property + def name(self): + """Return the name of the camera.""" + return self._name + + @property + def unique_id(self): + """Return the unique id for the camera sensor.""" + return self._unique_id + + @property + def icon(self): + """Return the icon of the sensor.""" + return self._icon + + @property + def state(self): + """Return the camera's current state.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit_of_measurement + + def update(self): + """Retrieve sensor data from the camera.""" + self.data.refresh() + try: + self._state = self._camera.attributes[self._sensor_key] + except KeyError: + self._state = None + _LOGGER.error( + "%s not a valid camera attribute. Did the API change?", self._sensor_key + ) diff --git a/homeassistant/components/blink/services.yaml b/homeassistant/components/blink/services.yaml new file mode 100644 index 000000000..fc042b0d5 --- /dev/null +++ b/homeassistant/components/blink/services.yaml @@ -0,0 +1,21 @@ +# Describes the format for available Blink services + +blink_update: + description: Force a refresh. + +trigger_camera: + description: Request named camera to take new image. + fields: + name: + description: Name of camera to take new image. + example: 'Living Room' + +save_video: + description: Save last recorded video clip to local file. + fields: + name: + description: Name of camera to grab video from. + example: 'Living Room' + filename: + description: Filename to writable path (directory may need to be included in whitelist_dirs in config) + example: '/tmp/video.mp4' diff --git a/homeassistant/components/blinksticklight/__init__.py b/homeassistant/components/blinksticklight/__init__.py new file mode 100644 index 000000000..dd45fbcd6 --- /dev/null +++ b/homeassistant/components/blinksticklight/__init__.py @@ -0,0 +1 @@ +"""The blinksticklight component.""" diff --git a/homeassistant/components/blinksticklight/light.py b/homeassistant/components/blinksticklight/light.py new file mode 100644 index 000000000..197213f74 --- /dev/null +++ b/homeassistant/components/blinksticklight/light.py @@ -0,0 +1,110 @@ +"""Support for Blinkstick lights.""" +import logging + +from blinkstick import blinkstick +import voluptuous as vol + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_HS_COLOR, + PLATFORM_SCHEMA, + SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, + Light, +) +from homeassistant.const import CONF_NAME +import homeassistant.helpers.config_validation as cv +import homeassistant.util.color as color_util + +_LOGGER = logging.getLogger(__name__) + +CONF_SERIAL = "serial" + +DEFAULT_NAME = "Blinkstick" + +SUPPORT_BLINKSTICK = SUPPORT_BRIGHTNESS | SUPPORT_COLOR + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_SERIAL): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up Blinkstick device specified by serial number.""" + + name = config.get(CONF_NAME) + serial = config.get(CONF_SERIAL) + + stick = blinkstick.find_by_serial(serial) + + add_entities([BlinkStickLight(stick, name)], True) + + +class BlinkStickLight(Light): + """Representation of a BlinkStick light.""" + + def __init__(self, stick, name): + """Initialize the light.""" + self._stick = stick + self._name = name + self._serial = stick.get_serial() + self._hs_color = None + self._brightness = None + + @property + def should_poll(self): + """Set up polling.""" + return True + + @property + def name(self): + """Return the name of the light.""" + return self._name + + @property + 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._hs_color + + @property + def is_on(self): + """Return True if entity is on.""" + return self._brightness > 0 + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_BLINKSTICK + + def update(self): + """Read back the device state.""" + 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_HS_COLOR in kwargs: + self._hs_color = kwargs[ATTR_HS_COLOR] + if ATTR_BRIGHTNESS in kwargs: + self._brightness = kwargs[ATTR_BRIGHTNESS] + else: + self._brightness = 255 + + 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.""" + self._stick.turn_off() diff --git a/homeassistant/components/blinksticklight/manifest.json b/homeassistant/components/blinksticklight/manifest.json new file mode 100644 index 000000000..0a6e25407 --- /dev/null +++ b/homeassistant/components/blinksticklight/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "blinksticklight", + "name": "Blinksticklight", + "documentation": "https://www.home-assistant.io/integrations/blinksticklight", + "requirements": [ + "blinkstick==1.1.8" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/blinkt/__init__.py b/homeassistant/components/blinkt/__init__.py new file mode 100644 index 000000000..0f61a2115 --- /dev/null +++ b/homeassistant/components/blinkt/__init__.py @@ -0,0 +1 @@ +"""The blinkt component.""" diff --git a/homeassistant/components/blinkt/light.py b/homeassistant/components/blinkt/light.py new file mode 100644 index 000000000..0fedc2b79 --- /dev/null +++ b/homeassistant/components/blinkt/light.py @@ -0,0 +1,121 @@ +"""Support for Blinkt! lights on Raspberry Pi.""" +import importlib +import logging + +import voluptuous as vol + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_HS_COLOR, + PLATFORM_SCHEMA, + SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, + Light, +) +from homeassistant.const import CONF_NAME +import homeassistant.helpers.config_validation as cv +import homeassistant.util.color as color_util + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_BLINKT = SUPPORT_BRIGHTNESS | SUPPORT_COLOR + +DEFAULT_NAME = "blinkt" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + {vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string} +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Blinkt Light platform.""" + # pylint: disable=no-member + blinkt = importlib.import_module("blinkt") + + # ensure that the lights are off when exiting + blinkt.set_clear_on_exit() + + name = config.get(CONF_NAME) + + add_entities( + [BlinktLight(blinkt, name, index) for index in range(blinkt.NUM_PIXELS)] + ) + + +class BlinktLight(Light): + """Representation of a Blinkt! Light.""" + + def __init__(self, blinkt, name, index): + """Initialize a Blinkt Light. + + Default brightness and white color. + """ + self._blinkt = blinkt + self._name = f"{name}_{index}" + self._index = index + self._is_on = False + self._brightness = 255 + self._hs_color = [0, 0] + + @property + def name(self): + """Return the display name of this light.""" + return self._name + + @property + def brightness(self): + """Read back the brightness of the light. + + Returns integer in the range of 1-255. + """ + return self._brightness + + @property + def hs_color(self): + """Read back the color of the light.""" + return self._hs_color + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_BLINKT + + @property + def is_on(self): + """Return true if light is on.""" + return self._is_on + + @property + def should_poll(self): + """Return if we should poll this device.""" + return False + + @property + def assumed_state(self) -> bool: + """Return True if unable to access real state of the entity.""" + return True + + def turn_on(self, **kwargs): + """Instruct the light to turn on and set correct brightness & color.""" + if ATTR_HS_COLOR in kwargs: + self._hs_color = kwargs[ATTR_HS_COLOR] + if ATTR_BRIGHTNESS in kwargs: + self._brightness = kwargs[ATTR_BRIGHTNESS] + + percent_bright = self._brightness / 255 + rgb_color = color_util.color_hs_to_RGB(*self._hs_color) + self._blinkt.set_pixel( + self._index, rgb_color[0], rgb_color[1], rgb_color[2], percent_bright + ) + + self._blinkt.show() + + self._is_on = True + self.schedule_update_ha_state() + + def turn_off(self, **kwargs): + """Instruct the light to turn off.""" + 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/blinkt/manifest.json b/homeassistant/components/blinkt/manifest.json new file mode 100644 index 000000000..629bdebf2 --- /dev/null +++ b/homeassistant/components/blinkt/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "blinkt", + "name": "Blinkt", + "documentation": "https://www.home-assistant.io/integrations/blinkt", + "requirements": [ + "blinkt==0.1.0" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/blockchain/__init__.py b/homeassistant/components/blockchain/__init__.py new file mode 100644 index 000000000..a8ee9884b --- /dev/null +++ b/homeassistant/components/blockchain/__init__.py @@ -0,0 +1 @@ +"""The blockchain component.""" diff --git a/homeassistant/components/blockchain/manifest.json b/homeassistant/components/blockchain/manifest.json new file mode 100644 index 000000000..773b52e72 --- /dev/null +++ b/homeassistant/components/blockchain/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "blockchain", + "name": "Blockchain", + "documentation": "https://www.home-assistant.io/integrations/blockchain", + "requirements": [ + "python-blockchain-api==0.0.2" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/blockchain/sensor.py b/homeassistant/components/blockchain/sensor.py new file mode 100644 index 000000000..6d17484bd --- /dev/null +++ b/homeassistant/components/blockchain/sensor.py @@ -0,0 +1,85 @@ +"""Support for Blockchain.info sensors.""" +from datetime import timedelta +import logging + +from pyblockchain import get_balance, validate_address +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +ATTRIBUTION = "Data provided by blockchain.info" + +CONF_ADDRESSES = "addresses" + +DEFAULT_NAME = "Bitcoin Balance" + +ICON = "mdi:currency-btc" + +SCAN_INTERVAL = timedelta(minutes=5) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_ADDRESSES): [cv.string], + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Blockchain.info sensors.""" + + addresses = config.get(CONF_ADDRESSES) + name = config.get(CONF_NAME) + + for address in addresses: + if not validate_address(address): + _LOGGER.error("Bitcoin address is not valid: %s", address) + return False + + add_entities([BlockchainSensor(name, addresses)], True) + + +class BlockchainSensor(Entity): + """Representation of a Blockchain.info sensor.""" + + def __init__(self, name, addresses): + """Initialize the sensor.""" + self._name = name + self.addresses = addresses + self._state = None + self._unit_of_measurement = "BTC" + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement this sensor expresses itself in.""" + return self._unit_of_measurement + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return ICON + + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + return {ATTR_ATTRIBUTION: ATTRIBUTION} + + def update(self): + """Get the latest state of the sensor.""" + + self._state = get_balance(self.addresses) diff --git a/homeassistant/components/bloomsky.py b/homeassistant/components/bloomsky.py deleted file mode 100644 index 00377b3f1..000000000 --- a/homeassistant/components/bloomsky.py +++ /dev/null @@ -1,80 +0,0 @@ -""" -Support for BloomSky weather station. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/bloomsky/ -""" -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 -import homeassistant.helpers.config_validation as cv -from homeassistant.util import Throttle - -_LOGGER = logging.getLogger(__name__) - -BLOOMSKY = None -BLOOMSKY_TYPE = ['camera', 'binary_sensor', 'sensor'] - -DOMAIN = 'bloomsky' - -# The BloomSky only updates every 5-8 minutes as per the API spec so there's -# no point in polling the API more frequently -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=300) - -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - vol.Required(CONF_API_KEY): cv.string, - }), -}, extra=vol.ALLOW_EXTRA) - - -def setup(hass, config): - """Set up the BloomSky component.""" - api_key = config[DOMAIN][CONF_API_KEY] - - global BLOOMSKY - try: - BLOOMSKY = BloomSky(api_key) - except RuntimeError: - return False - - for component in BLOOMSKY_TYPE: - discovery.load_platform(hass, component, DOMAIN, {}, config) - - return True - - -class BloomSky: - """Handle all communication with the BloomSky API.""" - - # API documentation at http://weatherlution.com/bloomsky-api/ - API_URL = 'https://api.bloomsky.com/api/skydata' - - def __init__(self, api_key): - """Initialize the BookSky.""" - self._api_key = api_key - self.devices = {} - _LOGGER.debug("Initial BloomSky device load...") - self.refresh_devices() - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - 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) - if response.status_code == 401: - raise RuntimeError("Invalid API_KEY") - elif response.status_code != 200: - _LOGGER.error("Invalid HTTP response: %s", response.status_code) - return - # Create dictionary keyed off of the device unique id - self.devices.update({ - device['DeviceID']: device for device in response.json() - }) diff --git a/homeassistant/components/bloomsky/__init__.py b/homeassistant/components/bloomsky/__init__.py new file mode 100644 index 000000000..6373471fe --- /dev/null +++ b/homeassistant/components/bloomsky/__init__.py @@ -0,0 +1,79 @@ +"""Support for BloomSky weather station.""" +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 +import homeassistant.helpers.config_validation as cv +from homeassistant.util import Throttle + +_LOGGER = logging.getLogger(__name__) + +BLOOMSKY = None +BLOOMSKY_TYPE = ["camera", "binary_sensor", "sensor"] + +DOMAIN = "bloomsky" + +# The BloomSky only updates every 5-8 minutes as per the API spec so there's +# no point in polling the API more frequently +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=300) + +CONFIG_SCHEMA = vol.Schema( + {DOMAIN: vol.Schema({vol.Required(CONF_API_KEY): cv.string})}, extra=vol.ALLOW_EXTRA +) + + +def setup(hass, config): + """Set up the BloomSky component.""" + api_key = config[DOMAIN][CONF_API_KEY] + + global BLOOMSKY + try: + BLOOMSKY = BloomSky(api_key, hass.config.units.is_metric) + except RuntimeError: + return False + + for component in BLOOMSKY_TYPE: + discovery.load_platform(hass, component, DOMAIN, {}, config) + + return True + + +class BloomSky: + """Handle all communication with the BloomSky API.""" + + # API documentation at http://weatherlution.com/bloomsky-api/ + API_URL = "http://api.bloomsky.com/api/skydata" + + def __init__(self, api_key, is_metric): + """Initialize the BookSky.""" + self._api_key = api_key + self._endpoint_argument = "unit=intl" if is_metric else "" + self.devices = {} + self.is_metric = is_metric + _LOGGER.debug("Initial BloomSky device load...") + self.refresh_devices() + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def refresh_devices(self): + """Use the API to retrieve a list of devices.""" + _LOGGER.debug("Fetching BloomSky update") + response = requests.get( + f"{self.API_URL}?{self._endpoint_argument}", + headers={AUTHORIZATION: self._api_key}, + timeout=10, + ) + if response.status_code == 401: + raise RuntimeError("Invalid API_KEY") + if response.status_code == 405: + _LOGGER.error("You have no bloomsky devices configured") + return + if response.status_code != 200: + _LOGGER.error("Invalid HTTP response: %s", response.status_code) + return + # Create dictionary keyed off of the device unique id + self.devices.update({device["DeviceID"]: device for device in response.json()}) diff --git a/homeassistant/components/bloomsky/binary_sensor.py b/homeassistant/components/bloomsky/binary_sensor.py new file mode 100644 index 000000000..cc6562a0b --- /dev/null +++ b/homeassistant/components/bloomsky/binary_sensor.py @@ -0,0 +1,71 @@ +"""Support the binary sensors of a BloomSky weather station.""" +import logging + +import voluptuous as vol + +from homeassistant.components.binary_sensor import PLATFORM_SCHEMA, BinarySensorDevice +from homeassistant.const import CONF_MONITORED_CONDITIONS +import homeassistant.helpers.config_validation as cv + +from . import BLOOMSKY + +_LOGGER = logging.getLogger(__name__) + +SENSOR_TYPES = {"Rain": "moisture", "Night": None} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): vol.All( + cv.ensure_list, [vol.In(SENSOR_TYPES)] + ) + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the available BloomSky weather binary sensors.""" + # Default needed in case of discovery + sensors = config.get(CONF_MONITORED_CONDITIONS, SENSOR_TYPES) + + for device in BLOOMSKY.devices.values(): + for variable in sensors: + add_entities([BloomSkySensor(BLOOMSKY, device, variable)], True) + + +class BloomSkySensor(BinarySensorDevice): + """Representation of a single binary sensor in a BloomSky device.""" + + def __init__(self, bs, device, sensor_name): + """Initialize a BloomSky binary sensor.""" + self._bloomsky = bs + self._device_id = device["DeviceID"] + self._sensor_name = sensor_name + self._name = "{} {}".format(device["DeviceName"], sensor_name) + self._state = None + self._unique_id = f"{self._device_id}-{self._sensor_name}" + + @property + def unique_id(self): + """Return a unique ID.""" + return self._unique_id + + @property + def name(self): + """Return the name of the BloomSky device and this sensor.""" + return self._name + + @property + def device_class(self): + """Return the class of this sensor, from DEVICE_CLASSES.""" + return SENSOR_TYPES.get(self._sensor_name) + + @property + def is_on(self): + """Return true if binary sensor is on.""" + return self._state + + def update(self): + """Request an update from the BloomSky API.""" + self._bloomsky.refresh_devices() + + self._state = self._bloomsky.devices[self._device_id]["Data"][self._sensor_name] diff --git a/homeassistant/components/bloomsky/camera.py b/homeassistant/components/bloomsky/camera.py new file mode 100644 index 000000000..d62dede5a --- /dev/null +++ b/homeassistant/components/bloomsky/camera.py @@ -0,0 +1,58 @@ +"""Support for a camera of a BloomSky weather station.""" +import logging + +import requests + +from homeassistant.components.camera import Camera + +from . import BLOOMSKY + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up access to BloomSky cameras.""" + for device in BLOOMSKY.devices.values(): + add_entities([BloomSkyCamera(BLOOMSKY, device)]) + + +class BloomSkyCamera(Camera): + """Representation of the images published from the BloomSky's camera.""" + + def __init__(self, bs, device): + """Initialize access to the BloomSky camera images.""" + super().__init__() + self._name = device["DeviceName"] + self._id = device["DeviceID"] + self._bloomsky = bs + self._url = "" + self._last_url = "" + # last_image will store images as they are downloaded so that the + # frequent updates in home-assistant don't keep poking the server + # to download the same image over and over. + self._last_image = "" + self._logger = logging.getLogger(__name__) + + def camera_image(self): + """Update the camera's image if it has changed.""" + try: + self._url = self._bloomsky.devices[self._id]["Data"]["ImageURL"] + self._bloomsky.refresh_devices() + # If the URL hasn't changed then the image hasn't changed. + if self._url != self._last_url: + response = requests.get(self._url, timeout=10) + self._last_url = self._url + self._last_image = response.content + except requests.exceptions.RequestException as error: + self._logger.error("Error getting bloomsky image: %s", error) + return None + + return self._last_image + + @property + def unique_id(self): + """Return a unique ID.""" + return self._id + + @property + def name(self): + """Return the name of this BloomSky device.""" + return self._name diff --git a/homeassistant/components/bloomsky/manifest.json b/homeassistant/components/bloomsky/manifest.json new file mode 100644 index 000000000..49da6534b --- /dev/null +++ b/homeassistant/components/bloomsky/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "bloomsky", + "name": "Bloomsky", + "documentation": "https://www.home-assistant.io/integrations/bloomsky", + "requirements": [], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/bloomsky/sensor.py b/homeassistant/components/bloomsky/sensor.py new file mode 100644 index 000000000..84871b7b3 --- /dev/null +++ b/homeassistant/components/bloomsky/sensor.py @@ -0,0 +1,108 @@ +"""Support the sensor of a BloomSky weather station.""" +import logging + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_MONITORED_CONDITIONS, TEMP_CELSIUS, TEMP_FAHRENHEIT +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +from . import BLOOMSKY + +LOGGER = logging.getLogger(__name__) + +# These are the available sensors +SENSOR_TYPES = [ + "Temperature", + "Humidity", + "Pressure", + "Luminance", + "UVIndex", + "Voltage", +] + +# Sensor units - these do not currently align with the API documentation +SENSOR_UNITS_IMPERIAL = { + "Temperature": TEMP_FAHRENHEIT, + "Humidity": "%", + "Pressure": "inHg", + "Luminance": "cd/m²", + "Voltage": "mV", +} + +# Metric units +SENSOR_UNITS_METRIC = { + "Temperature": TEMP_CELSIUS, + "Humidity": "%", + "Pressure": "mbar", + "Luminance": "cd/m²", + "Voltage": "mV", +} + +# Which sensors to format numerically +FORMAT_NUMBERS = ["Temperature", "Pressure", "Voltage"] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_TYPES): vol.All( + cv.ensure_list, [vol.In(SENSOR_TYPES)] + ) + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the available BloomSky weather sensors.""" + # Default needed in case of discovery + sensors = config.get(CONF_MONITORED_CONDITIONS, SENSOR_TYPES) + + for device in BLOOMSKY.devices.values(): + for variable in sensors: + add_entities([BloomSkySensor(BLOOMSKY, device, variable)], True) + + +class BloomSkySensor(Entity): + """Representation of a single sensor in a BloomSky device.""" + + def __init__(self, bs, device, sensor_name): + """Initialize a BloomSky sensor.""" + self._bloomsky = bs + self._device_id = device["DeviceID"] + self._sensor_name = sensor_name + self._name = "{} {}".format(device["DeviceName"], sensor_name) + self._state = None + self._unique_id = f"{self._device_id}-{self._sensor_name}" + + @property + def unique_id(self): + """Return a unique ID.""" + return self._unique_id + + @property + def name(self): + """Return the name of the BloomSky device and this sensor.""" + return self._name + + @property + def state(self): + """Return the current state, eg. value, of this sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the sensor units.""" + if self._bloomsky.is_metric: + return SENSOR_UNITS_METRIC.get(self._sensor_name, None) + return SENSOR_UNITS_IMPERIAL.get(self._sensor_name, None) + + def update(self): + """Request an update from the BloomSky API.""" + self._bloomsky.refresh_devices() + + state = self._bloomsky.devices[self._device_id]["Data"][self._sensor_name] + + if self._sensor_name in FORMAT_NUMBERS: + self._state = f"{state:.2f}" + else: + self._state = state diff --git a/homeassistant/components/bluesound/__init__.py b/homeassistant/components/bluesound/__init__.py new file mode 100644 index 000000000..9dbe0f754 --- /dev/null +++ b/homeassistant/components/bluesound/__init__.py @@ -0,0 +1 @@ +"""The bluesound component.""" diff --git a/homeassistant/components/bluesound/const.py b/homeassistant/components/bluesound/const.py new file mode 100644 index 000000000..af1a8e518 --- /dev/null +++ b/homeassistant/components/bluesound/const.py @@ -0,0 +1,6 @@ +"""Constants for the Bluesound HiFi wireless speakers and audio integrations component.""" +DOMAIN = "bluesound" +SERVICE_CLEAR_TIMER = "clear_sleep_timer" +SERVICE_JOIN = "join" +SERVICE_SET_TIMER = "set_sleep_timer" +SERVICE_UNJOIN = "unjoin" diff --git a/homeassistant/components/bluesound/manifest.json b/homeassistant/components/bluesound/manifest.json new file mode 100644 index 000000000..e64e3e61f --- /dev/null +++ b/homeassistant/components/bluesound/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "bluesound", + "name": "Bluesound", + "documentation": "https://www.home-assistant.io/integrations/bluesound", + "requirements": [ + "xmltodict==0.12.0" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py new file mode 100644 index 000000000..04ba21555 --- /dev/null +++ b/homeassistant/components/bluesound/media_player.py @@ -0,0 +1,1056 @@ +"""Support for Bluesound devices.""" +import asyncio +from asyncio import CancelledError +from datetime import timedelta +import logging +from urllib import parse + +import aiohttp +from aiohttp.client_exceptions import ClientError +from aiohttp.hdrs import CONNECTION, KEEP_ALIVE +import async_timeout +import voluptuous as vol +import xmltodict + +from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerDevice +from homeassistant.components.media_player.const import ( + ATTR_MEDIA_ENQUEUE, + MEDIA_TYPE_MUSIC, + 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, +) +from homeassistant.const import ( + 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 + +from .const import ( + DOMAIN, + SERVICE_CLEAR_TIMER, + SERVICE_JOIN, + SERVICE_SET_TIMER, + SERVICE_UNJOIN, +) + +_LOGGER = logging.getLogger(__name__) + +ATTR_BLUESOUND_GROUP = "bluesound_group" +ATTR_MASTER = "master" + +DATA_BLUESOUND = "bluesound" +DEFAULT_PORT = 11000 + +NODE_OFFLINE_CHECK_TIMEOUT = 180 +NODE_RETRY_INITIATION = timedelta(minutes=3) + +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_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_entities, host, port=None, name=None): + """Add Bluesound players.""" + if host in [x.host for x in hass.data[DATA_BLUESOUND]]: + return + + @callback + def _init_player(event=None): + """Start polling.""" + hass.async_create_task(player.async_init()) + + @callback + def _start_polling(event=None): + """Start polling.""" + player.start_polling() + + @callback + def _stop_polling(): + """Stop polling.""" + player.stop_polling() + + @callback + def _add_player_cb(): + """Add player after first sync fetch.""" + async_add_entities([player]) + _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) + + 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) + + if hass.is_running: + _init_player() + else: + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _init_player) + + +async def async_setup_platform(hass, config, async_add_entities, 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_entities, + 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_entities, + 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): + """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._polling_session = async_get_clientsession(hass) + self._polling_task = None # The actual polling task. + self._name = name + self._icon = None + self._capture_items = [] + self._services_items = [] + self._preset_items = [] + self._sync_status = {} + self._status = None + self._last_status_update = None + self._is_online = False + self._retry_remove = None + self._lastvol = None + self._master = None + self._is_master = False + self._group_name = None + self._group_list = [] + self._bluesound_device_name = None + + self._init_callback = init_callback + if self.port is None: + self.port = DEFAULT_PORT + + class _TimeoutException(Exception): + pass + + @staticmethod + def _try_get_index(string, search_string): + """Get the index.""" + try: + return string.index(search_string) + except ValueError: + return -1 + + async def force_update_sync_status(self, on_updated_cb=None, raise_timeout=False): + """Update the internal status.""" + resp = await self.send_bluesound_command( + "SyncStatus", raise_timeout, raise_timeout + ) + + if not resp: + return None + self._sync_status = resp["SyncStatus"].copy() + + if not self._name: + self._name = self._sync_status.get("@name", self.host) + if not self._bluesound_device_name: + self._bluesound_device_name = self._sync_status.get("@name", self.host) + if not self._icon: + self._icon = self._sync_status.get("@icon", 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 + + async def _start_poll_command(self): + """Loop which polls the status of the player.""" + try: + while True: + await self.async_update_status() + + except (asyncio.TimeoutError, ClientError, BluesoundPlayer._TimeoutException): + _LOGGER.info("Node %s is offline, retrying later", self._name) + await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT) + self.start_polling() + + except CancelledError: + _LOGGER.debug("Stopping the polling of node %s", self._name) + except Exception: + _LOGGER.exception("Unexpected error in %s", self._name) + raise + + def start_polling(self): + """Start the polling task.""" + self._polling_task = self._hass.async_create_task(self._start_poll_command()) + + def stop_polling(self): + """Stop the polling task.""" + self._polling_task.cancel() + + 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 + + await self.force_update_sync_status(self._init_callback, True) + except (asyncio.TimeoutError, ClientError): + _LOGGER.info("Node %s is offline, retrying later", self.host) + self._retry_remove = async_track_time_interval( + self._hass, self.async_init, NODE_RETRY_INITIATION + ) + except Exception: + _LOGGER.exception("Unexpected when initiating error in %s", self.host) + raise + + async def async_update(self): + """Update internal status of the entity.""" + if not self._is_online: + return + + await self.async_update_sync_status() + await self.async_update_presets() + await self.async_update_captures() + await self.async_update_services() + + async def send_bluesound_command( + self, method, raise_timeout=False, allow_offline=False + ): + """Send command to the player.""" + if not self._is_online and not allow_offline: + return + + if method[0] == "/": + method = method[1:] + url = f"http://{self.host}:{self.port}/{method}" + + _LOGGER.debug("Calling URL: %s", url) + response = None + + try: + websession = async_get_clientsession(self._hass) + with async_timeout.timeout(10): + response = await websession.get(url) + + if response.status == 200: + result = await response.text() + if result: + data = xmltodict.parse(result) + else: + data = None + 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: %s", self.host) + raise + _LOGGER.debug("Failed communicating: %s", self.host) + return None + + return data + + async def async_update_status(self): + """Use the poll session to always get the status of the player.""" + response = None + + url = "Status" + etag = "" + if self._status is not None: + etag = self._status.get("@etag", "") + + if etag != "": + url = f"Status?etag={etag}&timeout=120.0" + url = f"http://{self.host}:{self.port}/{url}" + + _LOGGER.debug("Calling URL: %s", url) + + try: + + with async_timeout.timeout(125): + response = await self._polling_session.get( + url, headers={CONNECTION: KEEP_ALIVE} + ) + + 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 + + # rebuild ordered list of entity_ids that are in the group, master is first + self._group_list = self.rebuild_bluesound_group() + + # the sleep is needed to make sure that the + # devices is synced + await asyncio.sleep(1) + 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.async_schedule_update_ha_state() + _LOGGER.info("Client connection error, marking %s as offline", self._name) + raise + + 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) + async def async_update_sync_status(self, on_updated_cb=None, raise_timeout=False): + """Update sync status.""" + await self.force_update_sync_status(on_updated_cb, raise_timeout=False) + + @Throttle(UPDATE_CAPTURE_INTERVAL) + async def async_update_captures(self): + """Update Capture sources.""" + resp = await self.send_bluesound_command("RadioBrowse?service=Capture") + if not resp: + return + self._capture_items = [] + + def _create_capture_item(item): + self._capture_items.append( + { + "title": item.get("@text", ""), + "name": item.get("@text", ""), + "type": item.get("@serviceType", "Capture"), + "image": item.get("@image", ""), + "url": item.get("@URL", ""), + } + ) + + if "radiotime" in resp and "item" in resp["radiotime"]: + if isinstance(resp["radiotime"]["item"], list): + for item in resp["radiotime"]["item"]: + _create_capture_item(item) + else: + _create_capture_item(resp["radiotime"]["item"]) + + return self._capture_items + + @Throttle(UPDATE_PRESETS_INTERVAL) + async def async_update_presets(self): + """Update Presets.""" + resp = await self.send_bluesound_command("Presets") + if not resp: + return + self._preset_items = [] + + def _create_preset_item(item): + self._preset_items.append( + { + "title": item.get("@name", ""), + "name": item.get("@name", ""), + "type": "preset", + "image": item.get("@image", ""), + "is_raw_url": True, + "url2": item.get("@url", ""), + "url": "Preset?id={}".format(item.get("@id", "")), + } + ) + + if "presets" in resp and "preset" in resp["presets"]: + if isinstance(resp["presets"]["preset"], list): + for item in resp["presets"]["preset"]: + _create_preset_item(item) + else: + _create_preset_item(resp["presets"]["preset"]) + + return self._preset_items + + @Throttle(UPDATE_SERVICES_INTERVAL) + async def async_update_services(self): + """Update Services.""" + resp = await self.send_bluesound_command("Services") + if not resp: + return + self._services_items = [] + + def _create_service_item(item): + self._services_items.append( + { + "title": item.get("@displayname", ""), + "name": item.get("@name", ""), + "type": item.get("@type", ""), + "image": item.get("@icon", ""), + "url": item.get("@name", ""), + } + ) + + if "services" in resp and "service" in resp["services"]: + if isinstance(resp["services"]["service"], list): + for item in resp["services"]["service"]: + _create_service_item(item) + else: + _create_service_item(resp["services"]["service"]) + + return self._services_items + + @property + def media_content_type(self): + """Content type of current playing media.""" + return MEDIA_TYPE_MUSIC + + @property + def state(self): + """Return the state of the device.""" + if self._status is None: + return STATE_OFF + + if self.is_grouped and not self.is_master: + return STATE_GROUPED + + status = self._status.get("state", None) + if status in ("pause", "stop"): + return STATE_PAUSED + if status in ("stream", "play"): + return STATE_PLAYING + return STATE_IDLE + + @property + def media_title(self): + """Title of current playing media.""" + if self._status is None or (self.is_grouped and not self.is_master): + return None + + return self._status.get("title1", None) + + @property + def media_artist(self): + """Artist of current playing media (Music track only).""" + 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) + return artist + + @property + def media_album_name(self): + """Artist of current playing media (Music track only).""" + if self._status is None or (self.is_grouped and not self.is_master): + return None + + album = self._status.get("album", None) + if not album: + album = self._status.get("title3", None) + return album + + @property + def media_image_url(self): + """Image url of current playing media.""" + 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 = f"http://{self.host}:{self.port}{url}" + + return url + + @property + def media_position(self): + """Position of current playing media in seconds.""" + if self._status is None or (self.is_grouped and not self.is_master): + return None + + mediastate = self.state + if self._last_status_update is None or mediastate == STATE_IDLE: + return None + + position = self._status.get("secs", None) + if position is None: + return None + + position = float(position) + if mediastate == STATE_PLAYING: + position += (dt_util.utcnow() - self._last_status_update).total_seconds() + + return position + + @property + def media_duration(self): + """Duration of current playing media in seconds.""" + if self._status is None or (self.is_grouped and not self.is_master): + return None + + duration = self._status.get("totlen", None) + if duration is None: + return None + return float(duration) + + @property + def media_position_updated_at(self): + """Last time status was updated.""" + return self._last_status_update + + @property + def volume_level(self): + """Volume level of the media player (0..1).""" + 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 + + @property + def is_volume_muted(self): + """Boolean if volume is currently muted.""" + volume = self.volume_level + if not volume: + return None + return 0 <= volume < 0.001 + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def bluesound_device_name(self): + """Return the device name as returned by the device.""" + return self._bluesound_device_name + + @property + def icon(self): + """Return the icon of the device.""" + return self._icon + + @property + def source_list(self): + """List of available input sources.""" + if self._status is None or (self.is_grouped and not self.is_master): + return None + + sources = [] + + for source in self._preset_items: + sources.append(source["title"]) + + for source in [ + x + for x in self._services_items + if x["type"] == "LocalMusic" or x["type"] == "RadioService" + ]: + sources.append(source["title"]) + + for source in self._capture_items: + sources.append(source["title"]) + + return sources + + @property + def source(self): + """Name of the current input source.""" + if self._status is None or (self.is_grouped and not self.is_master): + return None + + current_service = self._status.get("service", "") + if current_service == "": + return "" + 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. + items = [ + x + for x in self._preset_items + if "url2" in x and parse.unquote(x["url2"]) == stream_url + ] + if items: + return items[0]["title"] + + # 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. + 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 items: + return items[0]["title"] + + items = [x for x in self._capture_items if x["url"] == stream_url] + if items: + return items[0]["title"] + + if stream_url[:8] == "Capture:": + stream_url = stream_url[8:] + + idx = BluesoundPlayer._try_get_index(stream_url, ":") + if idx > 0: + stream_url = stream_url[:idx] + for item in self._capture_items: + url = parse.unquote(item["url"]) + if url[:8] == "Capture:": + url = url[8:] + idx = BluesoundPlayer._try_get_index(url, ":") + if idx > 0: + url = url[:idx] + if url.lower() == stream_url.lower(): + return item["title"] + + items = [x for x in self._capture_items if x["name"] == current_service] + if items: + return items[0]["title"] + + items = [x for x in self._services_items if x["name"] == current_service] + if items: + return items[0]["title"] + + if self._status.get("streamUrl", "") != "": + _LOGGER.debug( + "Couldn't find source of stream URL: %s", + self._status.get("streamUrl", ""), + ) + return None + + @property + def supported_features(self): + """Flag of media commands that are supported.""" + 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_SHUFFLE_SET + ) + + current_vol = self.volume_level + if current_vol is not None and current_vol >= 0: + supported = ( + supported + | SUPPORT_VOLUME_STEP + | SUPPORT_VOLUME_SET + | SUPPORT_VOLUME_MUTE + ) + + if self._status.get("canSeek", "") == "1": + supported = supported | SUPPORT_SEEK + + return supported + + @property + 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 self._status.get("shuffle", "0") == "1" + + 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) + + @property + def device_state_attributes(self): + """List members in group.""" + attributes = {} + if self._group_list: + attributes = {ATTR_BLUESOUND_GROUP: self._group_list} + + attributes[ATTR_MASTER] = self._is_master + + return attributes + + def rebuild_bluesound_group(self): + """Rebuild the list of entities in speaker group.""" + if self._group_name is None: + return None + + bluesound_group = [] + + device_group = self._group_name.split("+") + + sorted_entities = sorted( + self._hass.data[DATA_BLUESOUND], + key=lambda entity: entity.is_master, + reverse=True, + ) + bluesound_group = [ + entity.name + for entity in sorted_entities + if entity.bluesound_device_name in device_group + ] + + return bluesound_group + + 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( + f"/AddSlave?slave={slave_device.host}&port={slave_device.port}" + ) + + async def async_remove_slave(self, slave_device): + """Remove slave to master.""" + return await self.send_bluesound_command( + f"/RemoveSlave?slave={slave_device.host}&port={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(f"/Shuffle?state={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 not items: + items = [x for x in self._services_items if x["title"] == source] + if not items: + items = [x for x in self._capture_items if x["title"] == source] + + if not items: + return + + selected_source = items[0] + 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 await self.send_bluesound_command(url) + + async def async_clear_playlist(self): + """Clear players playlist.""" + if self.is_grouped and not self.is_master: + return + + 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"]: + if "@name" in action and "@url" in action and action["@name"] == "skip": + cmd = action["@url"] + + return await self.send_bluesound_command(cmd) + + 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"]: + if "@name" in action and "@url" in action and action["@name"] == "back": + cmd = action["@url"] + + return await self.send_bluesound_command(cmd) + + async def async_media_play(self): + """Send media_play command to media player.""" + if self.is_grouped and not self.is_master: + return + + return await self.send_bluesound_command("Play") + + async def async_media_pause(self): + """Send media_pause command to media player.""" + if self.is_grouped and not self.is_master: + return + + return await self.send_bluesound_command("Pause") + + async def async_media_stop(self): + """Send stop command.""" + if self.is_grouped and not self.is_master: + return + + return await self.send_bluesound_command("Pause") + + async def async_media_seek(self, position): + """Send media_seek command to media player.""" + if self.is_grouped and not self.is_master: + return + + return 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 = f"Play?url={media_id}" + + if kwargs.get(ATTR_MEDIA_ENQUEUE): + return await self.send_bluesound_command(url) + + return await self.send_bluesound_command(url) + + 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) + + 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) + + 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 await self.send_bluesound_command( + "Volume?level=" + str(float(volume) * 100) + ) + + 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 await self.send_bluesound_command("Volume?level=0") + return await self.send_bluesound_command( + "Volume?level=" + str(float(self._lastvol) * 100) + ) diff --git a/homeassistant/components/bluesound/services.yaml b/homeassistant/components/bluesound/services.yaml new file mode 100644 index 000000000..6c85c77e9 --- /dev/null +++ b/homeassistant/components/bluesound/services.yaml @@ -0,0 +1,30 @@ +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' + +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' + +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' + +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' \ No newline at end of file diff --git a/homeassistant/components/bluetooth_le_tracker/__init__.py b/homeassistant/components/bluetooth_le_tracker/__init__.py new file mode 100644 index 000000000..d6886e1b3 --- /dev/null +++ b/homeassistant/components/bluetooth_le_tracker/__init__.py @@ -0,0 +1 @@ +"""The bluetooth_le_tracker component.""" diff --git a/homeassistant/components/bluetooth_le_tracker/device_tracker.py b/homeassistant/components/bluetooth_le_tracker/device_tracker.py new file mode 100644 index 000000000..40f25f2fc --- /dev/null +++ b/homeassistant/components/bluetooth_le_tracker/device_tracker.py @@ -0,0 +1,134 @@ +"""Tracking for bluetooth low energy devices.""" +import asyncio +import logging + +import pygatt # pylint: disable=import-error + +from homeassistant.components.device_tracker.const import ( + CONF_SCAN_INTERVAL, + CONF_TRACK_NEW, + SCAN_INTERVAL, + SOURCE_TYPE_BLUETOOTH_LE, +) +from homeassistant.components.device_tracker.legacy import ( + YAML_DEVICES, + async_load_config, +) +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.helpers.event import track_point_in_utc_time +import homeassistant.util.dt as dt_util + +_LOGGER = logging.getLogger(__name__) + +DATA_BLE = "BLE" +DATA_BLE_ADAPTER = "ADAPTER" +BLE_PREFIX = "BLE_" +MIN_SEEN_NEW = 5 + + +def setup_scanner(hass, config, see, discovery_info=None): + """Set up the Bluetooth LE Scanner.""" + + new_devices = {} + hass.data.setdefault(DATA_BLE, {DATA_BLE_ADAPTER: None}) + + def handle_stop(event): + """Try to shut down the bluetooth child process nicely.""" + # These should never be unset at the point this runs, but just for + # safety's sake, use `get`. + adapter = hass.data.get(DATA_BLE, {}).get(DATA_BLE_ADAPTER) + if adapter is not None: + adapter.kill() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, handle_stop) + + def see_device(address, name, new_device=False): + """Mark a device as seen.""" + if new_device: + if address in new_devices: + _LOGGER.debug("Seen %s %s times", address, new_devices[address]) + new_devices[address] += 1 + if new_devices[address] >= MIN_SEEN_NEW: + _LOGGER.debug("Adding %s to tracked devices", address) + devs_to_track.append(address) + else: + return + else: + _LOGGER.debug("Seen %s for the first time", address) + new_devices[address] = 1 + return + + if name is not None: + name = name.strip("\x00") + + see( + mac=BLE_PREFIX + address, + host_name=name, + source_type=SOURCE_TYPE_BLUETOOTH_LE, + ) + + def discover_ble_devices(): + """Discover Bluetooth LE devices.""" + _LOGGER.debug("Discovering Bluetooth LE devices") + try: + adapter = pygatt.GATTToolBackend() + hass.data[DATA_BLE][DATA_BLE_ADAPTER] = adapter + devs = adapter.scan() + + devices = {x["address"]: x["name"] for x in devs} + _LOGGER.debug("Bluetooth LE devices discovered = %s", devices) + except RuntimeError as error: + _LOGGER.error("Error during Bluetooth LE scan: %s", error) + return {} + return devices + + yaml_path = hass.config.path(YAML_DEVICES) + devs_to_track = [] + devs_donot_track = [] + + # Load all known devices. + # We just need the devices so set consider_home and home range + # to 0 + for device in asyncio.run_coroutine_threadsafe( + async_load_config(yaml_path, hass, 0), hass.loop + ).result(): + # check if device is a valid bluetooth device + if device.mac and device.mac[:4].upper() == BLE_PREFIX: + if device.track: + _LOGGER.debug("Adding %s to BLE tracker", device.mac) + devs_to_track.append(device.mac[4:]) + else: + _LOGGER.debug("Adding %s to BLE do not track", device.mac) + devs_donot_track.append(device.mac[4:]) + + # if track new devices is true discover new devices + # on every scan. + track_new = config.get(CONF_TRACK_NEW) + + if not devs_to_track and not track_new: + _LOGGER.warning("No Bluetooth LE devices to track!") + return False + + interval = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) + + def update_ble(now): + """Lookup Bluetooth LE devices and update status.""" + devs = discover_ble_devices() + for mac in devs_to_track: + if mac not in devs: + continue + + if devs[mac] is None: + devs[mac] = mac + see_device(mac, devs[mac]) + + if track_new: + for address in devs: + if address not in devs_to_track and address not in devs_donot_track: + _LOGGER.info("Discovered Bluetooth LE device %s", address) + see_device(address, devs[address], new_device=True) + + track_point_in_utc_time(hass, update_ble, dt_util.utcnow() + interval) + + update_ble(dt_util.utcnow()) + return True diff --git a/homeassistant/components/bluetooth_le_tracker/manifest.json b/homeassistant/components/bluetooth_le_tracker/manifest.json new file mode 100644 index 000000000..d9f4cb0a2 --- /dev/null +++ b/homeassistant/components/bluetooth_le_tracker/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "bluetooth_le_tracker", + "name": "Bluetooth le tracker", + "documentation": "https://www.home-assistant.io/integrations/bluetooth_le_tracker", + "requirements": [ + "pygatt[GATTTOOL]==4.0.5" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/bluetooth_tracker/__init__.py b/homeassistant/components/bluetooth_tracker/__init__.py new file mode 100644 index 000000000..e58d5abab --- /dev/null +++ b/homeassistant/components/bluetooth_tracker/__init__.py @@ -0,0 +1 @@ +"""The bluetooth_tracker component.""" diff --git a/homeassistant/components/bluetooth_tracker/const.py b/homeassistant/components/bluetooth_tracker/const.py new file mode 100644 index 000000000..b481efa29 --- /dev/null +++ b/homeassistant/components/bluetooth_tracker/const.py @@ -0,0 +1,3 @@ +"""Constants for the Bluetooth Tracker component.""" +DOMAIN = "bluetooth_tracker" +SERVICE_UPDATE = "update" diff --git a/homeassistant/components/bluetooth_tracker/device_tracker.py b/homeassistant/components/bluetooth_tracker/device_tracker.py new file mode 100644 index 000000000..d833f60c8 --- /dev/null +++ b/homeassistant/components/bluetooth_tracker/device_tracker.py @@ -0,0 +1,189 @@ +"""Tracking for bluetooth devices.""" +import asyncio +import logging +from typing import List, Optional, Set, Tuple + +# pylint: disable=import-error +import bluetooth +from bt_proximity import BluetoothRSSI +import voluptuous as vol + +from homeassistant.components.device_tracker import PLATFORM_SCHEMA +from homeassistant.components.device_tracker.const import ( + CONF_SCAN_INTERVAL, + CONF_TRACK_NEW, + DEFAULT_TRACK_NEW, + SCAN_INTERVAL, + SOURCE_TYPE_BLUETOOTH, +) +from homeassistant.components.device_tracker.legacy import ( + YAML_DEVICES, + async_load_config, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.typing import HomeAssistantType + +from .const import DOMAIN, SERVICE_UPDATE + +_LOGGER = logging.getLogger(__name__) + +BT_PREFIX = "BT_" + +CONF_REQUEST_RSSI = "request_rssi" + +CONF_DEVICE_ID = "device_id" + +DEFAULT_DEVICE_ID = -1 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_TRACK_NEW): cv.boolean, + vol.Optional(CONF_REQUEST_RSSI): cv.boolean, + vol.Optional(CONF_DEVICE_ID, default=DEFAULT_DEVICE_ID): vol.All( + vol.Coerce(int), vol.Range(min=-1) + ), + } +) + + +def is_bluetooth_device(device) -> bool: + """Check whether a device is a bluetooth device by its mac.""" + return device.mac and device.mac[:3].upper() == BT_PREFIX + + +def discover_devices(device_id: int) -> List[Tuple[str, str]]: + """Discover Bluetooth devices.""" + result = bluetooth.discover_devices( + duration=8, + lookup_names=True, + flush_cache=True, + lookup_class=False, + device_id=device_id, + ) + _LOGGER.debug("Bluetooth devices discovered = %d", len(result)) + return result + + +async def see_device( + hass: HomeAssistantType, async_see, mac: str, device_name: str, rssi=None +) -> None: + """Mark a device as seen.""" + attributes = {} + if rssi is not None: + attributes["rssi"] = rssi + + await async_see( + mac=f"{BT_PREFIX}{mac}", + host_name=device_name, + attributes=attributes, + source_type=SOURCE_TYPE_BLUETOOTH, + ) + + +async def get_tracking_devices(hass: HomeAssistantType) -> Tuple[Set[str], Set[str]]: + """ + Load all known devices. + + We just need the devices so set consider_home and home range to 0 + """ + yaml_path: str = hass.config.path(YAML_DEVICES) + + devices = await async_load_config(yaml_path, hass, 0) + bluetooth_devices = [device for device in devices if is_bluetooth_device(device)] + + devices_to_track: Set[str] = { + device.mac[3:] for device in bluetooth_devices if device.track + } + devices_to_not_track: Set[str] = { + device.mac[3:] for device in bluetooth_devices if not device.track + } + + return devices_to_track, devices_to_not_track + + +def lookup_name(mac: str) -> Optional[str]: + """Lookup a Bluetooth device name.""" + _LOGGER.debug("Scanning %s", mac) + return bluetooth.lookup_name(mac, timeout=5) + + +async def async_setup_scanner( + hass: HomeAssistantType, config: dict, async_see, discovery_info=None +): + """Set up the Bluetooth Scanner.""" + device_id: int = config.get(CONF_DEVICE_ID) + interval = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) + request_rssi = config.get(CONF_REQUEST_RSSI, False) + update_bluetooth_lock = asyncio.Lock() + + # If track new devices is true discover new devices on startup. + track_new: bool = config.get(CONF_TRACK_NEW, DEFAULT_TRACK_NEW) + _LOGGER.debug("Tracking new devices is set to %s", track_new) + + devices_to_track, devices_to_not_track = await get_tracking_devices(hass) + + if not devices_to_track and not track_new: + _LOGGER.debug("No Bluetooth devices to track and not tracking new devices") + + if request_rssi: + _LOGGER.debug("Detecting RSSI for devices") + + async def perform_bluetooth_update(): + """Discover Bluetooth devices and update status.""" + + _LOGGER.debug("Performing Bluetooth devices discovery and update") + tasks = [] + + try: + if track_new: + devices = await hass.async_add_executor_job(discover_devices, device_id) + for mac, device_name in devices: + if mac not in devices_to_track and mac not in devices_to_not_track: + devices_to_track.add(mac) + + for mac in devices_to_track: + device_name = await hass.async_add_executor_job(lookup_name, mac) + if device_name is None: + # Could not lookup device name + continue + + rssi = None + if request_rssi: + client = BluetoothRSSI(mac) + rssi = await hass.async_add_executor_job(client.request_rssi) + client.close() + + tasks.append(see_device(hass, async_see, mac, device_name, rssi)) + + if tasks: + await asyncio.wait(tasks) + + except bluetooth.BluetoothError: + _LOGGER.exception("Error looking up Bluetooth device") + + async def update_bluetooth(now=None): + """Lookup Bluetooth devices and update status.""" + + # If an update is in progress, we don't do anything + if update_bluetooth_lock.locked(): + _LOGGER.debug( + "Previous execution of update_bluetooth is taking longer than the scheduled update of interval %s", + interval, + ) + return + + async with update_bluetooth_lock: + await perform_bluetooth_update() + + async def handle_manual_update_bluetooth(call): + """Update bluetooth devices on demand.""" + + await update_bluetooth() + + hass.async_create_task(update_bluetooth()) + async_track_time_interval(hass, update_bluetooth, interval) + + hass.services.async_register(DOMAIN, SERVICE_UPDATE, handle_manual_update_bluetooth) + + return True diff --git a/homeassistant/components/bluetooth_tracker/manifest.json b/homeassistant/components/bluetooth_tracker/manifest.json new file mode 100644 index 000000000..20fe51c56 --- /dev/null +++ b/homeassistant/components/bluetooth_tracker/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "bluetooth_tracker", + "name": "Bluetooth tracker", + "documentation": "https://www.home-assistant.io/integrations/bluetooth_tracker", + "requirements": [ + "bt_proximity==0.2", + "pybluez==0.22" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/bluetooth_tracker/services.yaml b/homeassistant/components/bluetooth_tracker/services.yaml new file mode 100644 index 000000000..b48c48a89 --- /dev/null +++ b/homeassistant/components/bluetooth_tracker/services.yaml @@ -0,0 +1,2 @@ +update: + description: Trigger manual tracker update \ No newline at end of file diff --git a/homeassistant/components/bme280/__init__.py b/homeassistant/components/bme280/__init__.py new file mode 100644 index 000000000..87de36fdf --- /dev/null +++ b/homeassistant/components/bme280/__init__.py @@ -0,0 +1 @@ +"""The bme280 component.""" diff --git a/homeassistant/components/bme280/manifest.json b/homeassistant/components/bme280/manifest.json new file mode 100644 index 000000000..9d01b301d --- /dev/null +++ b/homeassistant/components/bme280/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "bme280", + "name": "Bme280", + "documentation": "https://www.home-assistant.io/integrations/bme280", + "requirements": [ + "i2csense==0.0.4", + "smbus-cffi==0.5.1" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/bme280/sensor.py b/homeassistant/components/bme280/sensor.py new file mode 100644 index 000000000..e1e33210c --- /dev/null +++ b/homeassistant/components/bme280/sensor.py @@ -0,0 +1,176 @@ +"""Support for BME280 temperature, humidity and pressure sensor.""" +from datetime import timedelta +from functools import partial +import logging + +from i2csense.bme280 import BME280 # pylint: disable=import-error +import smbus # pylint: disable=import-error +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_NAME, TEMP_FAHRENHEIT +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle +from homeassistant.util.temperature import celsius_to_fahrenheit + +_LOGGER = logging.getLogger(__name__) + +CONF_I2C_ADDRESS = "i2c_address" +CONF_I2C_BUS = "i2c_bus" +CONF_OVERSAMPLING_TEMP = "oversampling_temperature" +CONF_OVERSAMPLING_PRES = "oversampling_pressure" +CONF_OVERSAMPLING_HUM = "oversampling_humidity" +CONF_OPERATION_MODE = "operation_mode" +CONF_T_STANDBY = "time_standby" +CONF_FILTER_MODE = "filter_mode" +CONF_DELTA_TEMP = "delta_temperature" + +DEFAULT_NAME = "BME280 Sensor" +DEFAULT_I2C_ADDRESS = "0x76" +DEFAULT_I2C_BUS = 1 +DEFAULT_OVERSAMPLING_TEMP = 1 # Temperature oversampling x 1 +DEFAULT_OVERSAMPLING_PRES = 1 # Pressure oversampling x 1 +DEFAULT_OVERSAMPLING_HUM = 1 # Humidity oversampling x 1 +DEFAULT_OPERATION_MODE = 3 # Normal mode (forced mode: 2) +DEFAULT_T_STANDBY = 5 # Tstandby 5ms +DEFAULT_FILTER_MODE = 0 # Filter off +DEFAULT_DELTA_TEMP = 0.0 + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=3) + +SENSOR_TEMP = "temperature" +SENSOR_HUMID = "humidity" +SENSOR_PRESS = "pressure" +SENSOR_TYPES = { + SENSOR_TEMP: ["Temperature", None], + SENSOR_HUMID: ["Humidity", "%"], + SENSOR_PRESS: ["Pressure", "mb"], +} +DEFAULT_MONITORED = [SENSOR_TEMP, SENSOR_HUMID, SENSOR_PRESS] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_I2C_ADDRESS, default=DEFAULT_I2C_ADDRESS): cv.string, + vol.Optional(CONF_MONITORED_CONDITIONS, default=DEFAULT_MONITORED): vol.All( + cv.ensure_list, [vol.In(SENSOR_TYPES)] + ), + vol.Optional(CONF_I2C_BUS, default=DEFAULT_I2C_BUS): vol.Coerce(int), + vol.Optional( + CONF_OVERSAMPLING_TEMP, default=DEFAULT_OVERSAMPLING_TEMP + ): vol.Coerce(int), + vol.Optional( + CONF_OVERSAMPLING_PRES, default=DEFAULT_OVERSAMPLING_PRES + ): vol.Coerce(int), + vol.Optional( + CONF_OVERSAMPLING_HUM, default=DEFAULT_OVERSAMPLING_HUM + ): vol.Coerce(int), + vol.Optional(CONF_OPERATION_MODE, default=DEFAULT_OPERATION_MODE): vol.Coerce( + int + ), + vol.Optional(CONF_T_STANDBY, default=DEFAULT_T_STANDBY): vol.Coerce(int), + vol.Optional(CONF_FILTER_MODE, default=DEFAULT_FILTER_MODE): vol.Coerce(int), + vol.Optional(CONF_DELTA_TEMP, default=DEFAULT_DELTA_TEMP): vol.Coerce(float), + } +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the BME280 sensor.""" + + SENSOR_TYPES[SENSOR_TEMP][1] = hass.config.units.temperature_unit + name = config.get(CONF_NAME) + i2c_address = config.get(CONF_I2C_ADDRESS) + + bus = smbus.SMBus(config.get(CONF_I2C_BUS)) + sensor = await hass.async_add_job( + partial( + BME280, + bus, + i2c_address, + osrs_t=config.get(CONF_OVERSAMPLING_TEMP), + osrs_p=config.get(CONF_OVERSAMPLING_PRES), + osrs_h=config.get(CONF_OVERSAMPLING_HUM), + mode=config.get(CONF_OPERATION_MODE), + t_sb=config.get(CONF_T_STANDBY), + filter_mode=config.get(CONF_FILTER_MODE), + delta_temp=config.get(CONF_DELTA_TEMP), + logger=_LOGGER, + ) + ) + if not sensor.sample_ok: + _LOGGER.error("BME280 sensor not detected at %s", i2c_address) + return False + + sensor_handler = await hass.async_add_job(BME280Handler, sensor) + + dev = [] + try: + for variable in config[CONF_MONITORED_CONDITIONS]: + dev.append( + BME280Sensor(sensor_handler, variable, SENSOR_TYPES[variable][1], name) + ) + except KeyError: + pass + + async_add_entities(dev, True) + + +class BME280Handler: + """BME280 sensor working in i2C bus.""" + + def __init__(self, sensor): + """Initialize the sensor handler.""" + self.sensor = sensor + self.update(True) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self, first_reading=False): + """Read sensor data.""" + self.sensor.update(first_reading) + + +class BME280Sensor(Entity): + """Implementation of the BME280 sensor.""" + + def __init__(self, bme280_client, sensor_type, temp_unit, name): + """Initialize the sensor.""" + self.client_name = name + self._name = SENSOR_TYPES[sensor_type][0] + self.bme280_client = bme280_client + self.temp_unit = temp_unit + self.type = sensor_type + self._state = None + self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + + @property + def name(self): + """Return the name of the sensor.""" + return f"{self.client_name} {self._name}" + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement of the sensor.""" + return self._unit_of_measurement + + async def async_update(self): + """Get the latest data from the BME280 and update the states.""" + await self.hass.async_add_job(self.bme280_client.update) + if self.bme280_client.sensor.sample_ok: + if self.type == SENSOR_TEMP: + temperature = round(self.bme280_client.sensor.temperature, 1) + if self.temp_unit == TEMP_FAHRENHEIT: + temperature = round(celsius_to_fahrenheit(temperature), 1) + self._state = temperature + elif self.type == SENSOR_HUMID: + self._state = round(self.bme280_client.sensor.humidity, 1) + elif self.type == SENSOR_PRESS: + self._state = round(self.bme280_client.sensor.pressure, 1) + else: + _LOGGER.warning("Bad update of sensor.%s", self.name) diff --git a/homeassistant/components/bme680/__init__.py b/homeassistant/components/bme680/__init__.py new file mode 100644 index 000000000..dc88286a6 --- /dev/null +++ b/homeassistant/components/bme680/__init__.py @@ -0,0 +1 @@ +"""The bme680 component.""" diff --git a/homeassistant/components/bme680/manifest.json b/homeassistant/components/bme680/manifest.json new file mode 100644 index 000000000..c062d14f8 --- /dev/null +++ b/homeassistant/components/bme680/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "bme680", + "name": "Bme680", + "documentation": "https://www.home-assistant.io/integrations/bme680", + "requirements": [ + "bme680==1.0.5", + "smbus-cffi==0.5.1" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/bme680/sensor.py b/homeassistant/components/bme680/sensor.py new file mode 100644 index 000000000..65c878902 --- /dev/null +++ b/homeassistant/components/bme680/sensor.py @@ -0,0 +1,360 @@ +"""Support for BME680 Sensor over SMBus.""" +import logging +import threading +from time import sleep, time + +import bme680 # pylint: disable=import-error +from smbus import SMBus # pylint: disable=import-error +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_NAME, TEMP_FAHRENHEIT +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util.temperature import celsius_to_fahrenheit + +_LOGGER = logging.getLogger(__name__) + +CONF_I2C_ADDRESS = "i2c_address" +CONF_I2C_BUS = "i2c_bus" +CONF_OVERSAMPLING_TEMP = "oversampling_temperature" +CONF_OVERSAMPLING_PRES = "oversampling_pressure" +CONF_OVERSAMPLING_HUM = "oversampling_humidity" +CONF_FILTER_SIZE = "filter_size" +CONF_GAS_HEATER_TEMP = "gas_heater_temperature" +CONF_GAS_HEATER_DURATION = "gas_heater_duration" +CONF_AQ_BURN_IN_TIME = "aq_burn_in_time" +CONF_AQ_HUM_BASELINE = "aq_humidity_baseline" +CONF_AQ_HUM_WEIGHTING = "aq_humidity_bias" +CONF_TEMP_OFFSET = "temp_offset" + + +DEFAULT_NAME = "BME680 Sensor" +DEFAULT_I2C_ADDRESS = 0x77 +DEFAULT_I2C_BUS = 1 +DEFAULT_OVERSAMPLING_TEMP = 8 # Temperature oversampling x 8 +DEFAULT_OVERSAMPLING_PRES = 4 # Pressure oversampling x 4 +DEFAULT_OVERSAMPLING_HUM = 2 # Humidity oversampling x 2 +DEFAULT_FILTER_SIZE = 3 # IIR Filter Size +DEFAULT_GAS_HEATER_TEMP = 320 # Temperature in celsius 200 - 400 +DEFAULT_GAS_HEATER_DURATION = 150 # Heater duration in ms 1 - 4032 +DEFAULT_AQ_BURN_IN_TIME = 300 # 300 second burn in time for AQ gas measurement +DEFAULT_AQ_HUM_BASELINE = 40 # 40%, an optimal indoor humidity. +DEFAULT_AQ_HUM_WEIGHTING = 25 # 25% Weighting of humidity to gas in AQ score +DEFAULT_TEMP_OFFSET = 0 # No calibration out of the box. + +SENSOR_TEMP = "temperature" +SENSOR_HUMID = "humidity" +SENSOR_PRESS = "pressure" +SENSOR_GAS = "gas" +SENSOR_AQ = "airquality" +SENSOR_TYPES = { + SENSOR_TEMP: ["Temperature", None], + SENSOR_HUMID: ["Humidity", "%"], + SENSOR_PRESS: ["Pressure", "mb"], + SENSOR_GAS: ["Gas Resistance", "Ohms"], + SENSOR_AQ: ["Air Quality", "%"], +} +DEFAULT_MONITORED = [SENSOR_TEMP, SENSOR_HUMID, SENSOR_PRESS, SENSOR_AQ] +OVERSAMPLING_VALUES = set([0, 1, 2, 4, 8, 16]) +FILTER_VALUES = set([0, 1, 3, 7, 15, 31, 63, 127]) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_I2C_ADDRESS, default=DEFAULT_I2C_ADDRESS): cv.positive_int, + vol.Optional(CONF_MONITORED_CONDITIONS, default=DEFAULT_MONITORED): vol.All( + cv.ensure_list, [vol.In(SENSOR_TYPES)] + ), + vol.Optional(CONF_I2C_BUS, default=DEFAULT_I2C_BUS): cv.positive_int, + vol.Optional( + CONF_OVERSAMPLING_TEMP, default=DEFAULT_OVERSAMPLING_TEMP + ): vol.All(vol.Coerce(int), vol.In(OVERSAMPLING_VALUES)), + vol.Optional( + CONF_OVERSAMPLING_PRES, default=DEFAULT_OVERSAMPLING_PRES + ): vol.All(vol.Coerce(int), vol.In(OVERSAMPLING_VALUES)), + vol.Optional(CONF_OVERSAMPLING_HUM, default=DEFAULT_OVERSAMPLING_HUM): vol.All( + vol.Coerce(int), vol.In(OVERSAMPLING_VALUES) + ), + vol.Optional(CONF_FILTER_SIZE, default=DEFAULT_FILTER_SIZE): vol.All( + vol.Coerce(int), vol.In(FILTER_VALUES) + ), + vol.Optional(CONF_GAS_HEATER_TEMP, default=DEFAULT_GAS_HEATER_TEMP): vol.All( + vol.Coerce(int), vol.Range(200, 400) + ), + vol.Optional( + CONF_GAS_HEATER_DURATION, default=DEFAULT_GAS_HEATER_DURATION + ): vol.All(vol.Coerce(int), vol.Range(1, 4032)), + vol.Optional( + CONF_AQ_BURN_IN_TIME, default=DEFAULT_AQ_BURN_IN_TIME + ): cv.positive_int, + vol.Optional(CONF_AQ_HUM_BASELINE, default=DEFAULT_AQ_HUM_BASELINE): vol.All( + vol.Coerce(int), vol.Range(1, 100) + ), + vol.Optional(CONF_AQ_HUM_WEIGHTING, default=DEFAULT_AQ_HUM_WEIGHTING): vol.All( + vol.Coerce(int), vol.Range(1, 100) + ), + vol.Optional(CONF_TEMP_OFFSET, default=DEFAULT_TEMP_OFFSET): vol.All( + vol.Coerce(float), vol.Range(-100.0, 100.0) + ), + } +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the BME680 sensor.""" + SENSOR_TYPES[SENSOR_TEMP][1] = hass.config.units.temperature_unit + name = config.get(CONF_NAME) + + sensor_handler = await hass.async_add_job(_setup_bme680, config) + if sensor_handler is None: + return + + dev = [] + for variable in config[CONF_MONITORED_CONDITIONS]: + dev.append( + BME680Sensor(sensor_handler, variable, SENSOR_TYPES[variable][1], name) + ) + + async_add_entities(dev) + return + + +def _setup_bme680(config): + """Set up and configure the BME680 sensor.""" + + sensor_handler = None + sensor = None + try: + # pylint: disable=no-member + i2c_address = config.get(CONF_I2C_ADDRESS) + bus = SMBus(config.get(CONF_I2C_BUS)) + sensor = bme680.BME680(i2c_address, bus) + + # Configure Oversampling + os_lookup = { + 0: bme680.OS_NONE, + 1: bme680.OS_1X, + 2: bme680.OS_2X, + 4: bme680.OS_4X, + 8: bme680.OS_8X, + 16: bme680.OS_16X, + } + sensor.set_temperature_oversample(os_lookup[config.get(CONF_OVERSAMPLING_TEMP)]) + sensor.set_temp_offset(config.get(CONF_TEMP_OFFSET)) + sensor.set_humidity_oversample(os_lookup[config.get(CONF_OVERSAMPLING_HUM)]) + sensor.set_pressure_oversample(os_lookup[config.get(CONF_OVERSAMPLING_PRES)]) + + # Configure IIR Filter + filter_lookup = { + 0: bme680.FILTER_SIZE_0, + 1: bme680.FILTER_SIZE_1, + 3: bme680.FILTER_SIZE_3, + 7: bme680.FILTER_SIZE_7, + 15: bme680.FILTER_SIZE_15, + 31: bme680.FILTER_SIZE_31, + 63: bme680.FILTER_SIZE_63, + 127: bme680.FILTER_SIZE_127, + } + sensor.set_filter(filter_lookup[config.get(CONF_FILTER_SIZE)]) + + # Configure the Gas Heater + if ( + SENSOR_GAS in config[CONF_MONITORED_CONDITIONS] + or SENSOR_AQ in config[CONF_MONITORED_CONDITIONS] + ): + sensor.set_gas_status(bme680.ENABLE_GAS_MEAS) + sensor.set_gas_heater_duration(config[CONF_GAS_HEATER_DURATION]) + sensor.set_gas_heater_temperature(config[CONF_GAS_HEATER_TEMP]) + sensor.select_gas_heater_profile(0) + else: + sensor.set_gas_status(bme680.DISABLE_GAS_MEAS) + except (RuntimeError, OSError): + _LOGGER.error("BME680 sensor not detected at 0x%02x", i2c_address) + return None + + sensor_handler = BME680Handler( + sensor, + ( + SENSOR_GAS in config[CONF_MONITORED_CONDITIONS] + or SENSOR_AQ in config[CONF_MONITORED_CONDITIONS] + ), + config[CONF_AQ_BURN_IN_TIME], + config[CONF_AQ_HUM_BASELINE], + config[CONF_AQ_HUM_WEIGHTING], + ) + sleep(0.5) # Wait for device to stabilize + if not sensor_handler.sensor_data.temperature: + _LOGGER.error("BME680 sensor failed to Initialize") + return None + + return sensor_handler + + +class BME680Handler: + """BME680 sensor working in i2C bus.""" + + class SensorData: + """Sensor data representation.""" + + def __init__(self): + """Initialize the sensor data object.""" + self.temperature = None + self.humidity = None + self.pressure = None + self.gas_resistance = None + self.air_quality = None + + def __init__( + self, + sensor, + gas_measurement=False, + burn_in_time=300, + hum_baseline=40, + hum_weighting=25, + ): + """Initialize the sensor handler.""" + self.sensor_data = BME680Handler.SensorData() + self._sensor = sensor + self._gas_sensor_running = False + self._hum_baseline = hum_baseline + self._hum_weighting = hum_weighting + self._gas_baseline = None + + if gas_measurement: + + threading.Thread( + target=self._run_gas_sensor, + kwargs={"burn_in_time": burn_in_time}, + name="BME680Handler_run_gas_sensor", + ).start() + self.update(first_read=True) + + def _run_gas_sensor(self, burn_in_time): + """Calibrate the Air Quality Gas Baseline.""" + if self._gas_sensor_running: + return + + self._gas_sensor_running = True + + # Pause to allow initial data read for device validation. + sleep(1) + + start_time = time() + curr_time = time() + burn_in_data = [] + + _LOGGER.info( + "Beginning %d second gas sensor burn in for Air Quality", burn_in_time + ) + while curr_time - start_time < burn_in_time: + curr_time = time() + if self._sensor.get_sensor_data() and self._sensor.data.heat_stable: + gas_resistance = self._sensor.data.gas_resistance + burn_in_data.append(gas_resistance) + self.sensor_data.gas_resistance = gas_resistance + _LOGGER.debug( + "AQ Gas Resistance Baseline reading %2f Ohms", gas_resistance + ) + sleep(1) + + _LOGGER.debug( + "AQ Gas Resistance Burn In Data (Size: %d): \n\t%s", + len(burn_in_data), + burn_in_data, + ) + self._gas_baseline = sum(burn_in_data[-50:]) / 50.0 + _LOGGER.info("Completed gas sensor burn in for Air Quality") + _LOGGER.info("AQ Gas Resistance Baseline: %f", self._gas_baseline) + while True: + if self._sensor.get_sensor_data() and self._sensor.data.heat_stable: + self.sensor_data.gas_resistance = self._sensor.data.gas_resistance + self.sensor_data.air_quality = self._calculate_aq_score() + sleep(1) + + def update(self, first_read=False): + """Read sensor data.""" + if first_read: + # Attempt first read, it almost always fails first attempt + self._sensor.get_sensor_data() + if self._sensor.get_sensor_data(): + self.sensor_data.temperature = self._sensor.data.temperature + self.sensor_data.humidity = self._sensor.data.humidity + self.sensor_data.pressure = self._sensor.data.pressure + + def _calculate_aq_score(self): + """Calculate the Air Quality Score.""" + hum_baseline = self._hum_baseline + hum_weighting = self._hum_weighting + gas_baseline = self._gas_baseline + + gas_resistance = self.sensor_data.gas_resistance + gas_offset = gas_baseline - gas_resistance + + hum = self.sensor_data.humidity + hum_offset = hum - hum_baseline + + # Calculate hum_score as the distance from the hum_baseline. + if hum_offset > 0: + hum_score = ( + (100 - hum_baseline - hum_offset) / (100 - hum_baseline) * hum_weighting + ) + else: + hum_score = (hum_baseline + hum_offset) / hum_baseline * hum_weighting + + # Calculate gas_score as the distance from the gas_baseline. + if gas_offset > 0: + gas_score = (gas_resistance / gas_baseline) * (100 - hum_weighting) + else: + gas_score = 100 - hum_weighting + + # Calculate air quality score. + return hum_score + gas_score + + +class BME680Sensor(Entity): + """Implementation of the BME680 sensor.""" + + def __init__(self, bme680_client, sensor_type, temp_unit, name): + """Initialize the sensor.""" + self.client_name = name + self._name = SENSOR_TYPES[sensor_type][0] + self.bme680_client = bme680_client + self.temp_unit = temp_unit + self.type = sensor_type + self._state = None + self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + + @property + def name(self): + """Return the name of the sensor.""" + return f"{self.client_name} {self._name}" + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement of the sensor.""" + return self._unit_of_measurement + + async def async_update(self): + """Get the latest data from the BME680 and update the states.""" + await self.hass.async_add_job(self.bme680_client.update) + if self.type == SENSOR_TEMP: + temperature = round(self.bme680_client.sensor_data.temperature, 1) + if self.temp_unit == TEMP_FAHRENHEIT: + temperature = round(celsius_to_fahrenheit(temperature), 1) + self._state = temperature + elif self.type == SENSOR_HUMID: + self._state = round(self.bme680_client.sensor_data.humidity, 1) + elif self.type == SENSOR_PRESS: + self._state = round(self.bme680_client.sensor_data.pressure, 1) + elif self.type == SENSOR_GAS: + self._state = int(round(self.bme680_client.sensor_data.gas_resistance, 0)) + elif self.type == SENSOR_AQ: + aq_score = self.bme680_client.sensor_data.air_quality + if aq_score is not None: + self._state = round(aq_score, 1) diff --git a/homeassistant/components/bmw_connected_drive/__init__.py b/homeassistant/components/bmw_connected_drive/__init__.py index b0ad1a867..6e7723b16 100644 --- a/homeassistant/components/bmw_connected_drive/__init__.py +++ b/homeassistant/components/bmw_connected_drive/__init__.py @@ -1,56 +1,46 @@ -""" -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 +"""Reads vehicle status from BMW connected drive portal.""" import logging +from bimmer_connected.account import ConnectedDriveAccount +from bimmer_connected.country_selector import get_region_from_name import voluptuous as vol -from homeassistant.const import CONF_USERNAME, CONF_PASSWORD +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME 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'] +from homeassistant.helpers.event import track_utc_time_change +import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) -DOMAIN = 'bmw_connected_drive' -CONF_REGION = 'region' -CONF_READ_ONLY = 'read_only' -ATTR_VIN = 'vin' +DOMAIN = "bmw_connected_drive" +CONF_REGION = "region" +CONF_READ_ONLY = "read_only" +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'), - vol.Optional(CONF_READ_ONLY, default=False): cv.boolean, -}) +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"), + vol.Optional(CONF_READ_ONLY, default=False): cv.boolean, + } +) -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: { - cv.string: ACCOUNT_SCHEMA - }, -}, extra=vol.ALLOW_EXTRA) +CONFIG_SCHEMA = vol.Schema({DOMAIN: {cv.string: ACCOUNT_SCHEMA}}, extra=vol.ALLOW_EXTRA) -SERVICE_SCHEMA = vol.Schema({ - vol.Required(ATTR_VIN): cv.string, -}) +SERVICE_SCHEMA = vol.Schema({vol.Required(ATTR_VIN): cv.string}) -BMW_COMPONENTS = ['binary_sensor', 'device_tracker', 'lock', 'sensor'] +BMW_COMPONENTS = ["binary_sensor", "device_tracker", "lock", "sensor"] UPDATE_INTERVAL = 5 # in minutes -SERVICE_UPDATE_STATE = 'update_state' +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', + "light_flash": "trigger_remote_light_flash", + "sound_horn": "trigger_remote_horn", + "activate_air_conditioning": "trigger_remote_air_conditioning", } @@ -78,16 +68,15 @@ def setup(hass, config: dict): return True -def setup_account(account_config: dict, hass, name: str) \ - -> 'BMWConnectedDriveAccount': +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] read_only = account_config[CONF_READ_ONLY] - _LOGGER.debug('Adding new account %s', name) - cd_account = BMWConnectedDriveAccount(username, password, region, name, - read_only) + + _LOGGER.debug("Adding new account %s", name) + cd_account = BMWConnectedDriveAccount(username, password, region, name, read_only) def execute_service(call): """Execute a service for a vehicle. @@ -98,26 +87,28 @@ def setup_account(account_config: dict, hass, name: str) \ 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) + _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() + if not read_only: # register the remote services for service in _SERVICE_MAP: hass.services.register( - DOMAIN, service, - execute_service, - schema=SERVICE_SCHEMA) + 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() + now = dt_util.utcnow() track_utc_time_change( - hass, cd_account.update, + hass, + cd_account.update, minute=range(now.minute % UPDATE_INTERVAL, 60, UPDATE_INTERVAL), - second=now.second) + second=now.second, + ) return cd_account @@ -125,11 +116,10 @@ def setup_account(account_config: dict, hass, name: str) \ class BMWConnectedDriveAccount: """Representation of a BMW vehicle.""" - def __init__(self, username: str, password: str, region_str: str, - name: str, read_only) -> None: - """Constructor.""" - from bimmer_connected.account import ConnectedDriveAccount - from bimmer_connected.country_selector import get_region_from_name + def __init__( + self, username: str, password: str, region_str: str, name: str, read_only + ) -> None: + """Initialize account.""" region = get_region_from_name(region_str) @@ -143,15 +133,20 @@ class BMWConnectedDriveAccount: Notify all listeners about the update. """ - _LOGGER.debug('Updating vehicle state for account %s, ' - 'notifying %d listeners', - self.name, len(self._update_listeners)) + _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.') + except OSError as exception: + _LOGGER.error( + "Could not connect to the BMW Connected Drive portal. " + "The vehicle state could not be updated." + ) _LOGGER.exception(exception) def add_update_listener(self, listener): diff --git a/homeassistant/components/bmw_connected_drive/binary_sensor.py b/homeassistant/components/bmw_connected_drive/binary_sensor.py new file mode 100644 index 000000000..591cdadda --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/binary_sensor.py @@ -0,0 +1,202 @@ +"""Reads vehicle status from BMW connected drive portal.""" +import logging + +from bimmer_connected.state import ChargingState, LockState + +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.const import LENGTH_KILOMETERS + +from . import DOMAIN as BMW_DOMAIN + +_LOGGER = logging.getLogger(__name__) + +SENSOR_TYPES = { + "lids": ["Doors", "opening", "mdi:car-door-lock"], + "windows": ["Windows", "opening", "mdi:car-door"], + "door_lock_state": ["Door lock state", "lock", "mdi:car-key"], + "lights_parking": ["Parking lights", "light", "mdi:car-parking-lights"], + "condition_based_services": ["Condition based services", "problem", "mdi:wrench"], + "check_control_messages": ["Control messages", "problem", "mdi:car-tire-alert"], +} + +SENSOR_TYPES_ELEC = { + "charging_status": ["Charging status", "power", "mdi:ev-station"], + "connection_status": ["Connection status", "plug", "mdi:car-electric"], +} + +SENSOR_TYPES_ELEC.update(SENSOR_TYPES) + + +def setup_platform(hass, config, add_entities, 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()): + if key in vehicle.available_attributes: + device = BMWConnectedDriveSensor( + account, vehicle, key, value[0], value[1], value[2] + ) + 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()): + if key in vehicle.available_attributes: + device = BMWConnectedDriveSensor( + account, vehicle, key, value[0], value[1], value[2] + ) + devices.append(device) + add_entities(devices, True) + + +class BMWConnectedDriveSensor(BinarySensorDevice): + """Representation of a BMW vehicle binary sensor.""" + + def __init__( + self, account, vehicle, attribute: str, sensor_name, device_class, icon + ): + """Initialize sensor.""" + self._account = account + self._vehicle = vehicle + self._attribute = attribute + self._name = f"{self._vehicle.name} {self._attribute}" + self._unique_id = f"{self._vehicle.vin}-{self._attribute}" + self._sensor_name = sensor_name + self._device_class = device_class + self._icon = icon + self._state = None + + @property + def should_poll(self) -> bool: + """Return False. + + 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 icon(self): + """Icon to use in the frontend, if any.""" + return self._icon + + @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 + has_check_control_messages = vehicle_state.has_check_control_messages + if has_check_control_messages: + cbs_list = [] + for message in check_control_messages: + cbs_list.append(message["ccmDescriptionShort"]) + result["check_control_messages"] = cbs_list + else: + result["check_control_messages"] = "OK" + elif self._attribute == "charging_status": + result["charging_status"] = vehicle_state.charging_status.value + result["last_charging_end_result"] = vehicle_state.last_charging_end_result + elif self._attribute == "connection_status": + result["connection_status"] = vehicle_state.connection_status + + return sorted(result.items()) + + def update(self): + """Read new state data from the library.""" + + 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": + self._state = vehicle_state.connection_status == "CONNECTED" + + def _format_cbs_report(self, report): + result = {} + service_type = report.service_type.lower().replace("_", " ") + result[f"{service_type} status"] = report.state.value + if report.due_date is not None: + result[f"{service_type} date"] = report.due_date.strftime("%Y-%m-%d") + if report.due_distance is not None: + distance = round( + self.hass.config.units.length(report.due_distance, LENGTH_KILOMETERS) + ) + result[ + f"{service_type} distance" + ] = f"{distance} {self.hass.config.units.length_unit}" + return result + + def update_callback(self): + """Schedule a state update.""" + self.schedule_update_ha_state(True) + + async 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/bmw_connected_drive/device_tracker.py b/homeassistant/components/bmw_connected_drive/device_tracker.py new file mode 100644 index 000000000..c4e835af0 --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/device_tracker.py @@ -0,0 +1,51 @@ +"""Device tracker for BMW Connected Drive vehicles.""" +import logging + +from homeassistant.util import slugify + +from . import DOMAIN as BMW_DOMAIN + +_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: + """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/bmw_connected_drive/lock.py b/homeassistant/components/bmw_connected_drive/lock.py new file mode 100644 index 000000000..5323e94c1 --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/lock.py @@ -0,0 +1,112 @@ +"""Support for BMW car locks with BMW ConnectedDrive.""" +import logging + +from bimmer_connected.state import LockState + +from homeassistant.components.lock import LockDevice +from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED + +from . import DOMAIN as BMW_DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up 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: + if not account.read_only: + for vehicle in account.account.vehicles: + device = BMWLock(account, vehicle, "lock", "BMW lock") + devices.append(device) + add_entities(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 = f"{self._vehicle.name} {self._attribute}" + self._unique_id = f"{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.""" + + _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) + + async 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/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json new file mode 100644 index 000000000..a88675ef8 --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "bmw_connected_drive", + "name": "BMW Connected Drive", + "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", + "requirements": [ + "bimmer_connected==0.6.2" + ], + "dependencies": [], + "codeowners": [ + "@gerard33" + ] +} \ No newline at end of file diff --git a/homeassistant/components/bmw_connected_drive/sensor.py b/homeassistant/components/bmw_connected_drive/sensor.py new file mode 100644 index 000000000..3c40900be --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/sensor.py @@ -0,0 +1,159 @@ +"""Support for reading vehicle status from BMW connected drive portal.""" +import logging + +from bimmer_connected.state import ChargingState + +from homeassistant.const import ( + CONF_UNIT_SYSTEM_IMPERIAL, + LENGTH_KILOMETERS, + LENGTH_MILES, + VOLUME_GALLONS, + VOLUME_LITERS, +) +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.icon import icon_for_battery_level + +from . import DOMAIN as BMW_DOMAIN + +_LOGGER = logging.getLogger(__name__) + +ATTR_TO_HA_METRIC = { + "mileage": ["mdi:speedometer", LENGTH_KILOMETERS], + "remaining_range_total": ["mdi:map-marker-distance", LENGTH_KILOMETERS], + "remaining_range_electric": ["mdi:map-marker-distance", LENGTH_KILOMETERS], + "remaining_range_fuel": ["mdi:map-marker-distance", LENGTH_KILOMETERS], + "max_range_electric": ["mdi:map-marker-distance", LENGTH_KILOMETERS], + "remaining_fuel": ["mdi:gas-station", VOLUME_LITERS], + "charging_time_remaining": ["mdi:update", "h"], + "charging_status": ["mdi:battery-charging", None], + # No icon as this is dealt with directly as a special case in icon() + "charging_level_hv": [None, "%"], +} + +ATTR_TO_HA_IMPERIAL = { + "mileage": ["mdi:speedometer", LENGTH_MILES], + "remaining_range_total": ["mdi:map-marker-distance", LENGTH_MILES], + "remaining_range_electric": ["mdi:map-marker-distance", LENGTH_MILES], + "remaining_range_fuel": ["mdi:map-marker-distance", LENGTH_MILES], + "max_range_electric": ["mdi:map-marker-distance", LENGTH_MILES], + "remaining_fuel": ["mdi:gas-station", VOLUME_GALLONS], + "charging_time_remaining": ["mdi:update", "h"], + "charging_status": ["mdi:battery-charging", None], + # No icon as this is dealt with directly as a special case in icon() + "charging_level_hv": [None, "%"], +} + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the BMW sensors.""" + if hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL: + attribute_info = ATTR_TO_HA_IMPERIAL + else: + attribute_info = ATTR_TO_HA_METRIC + + 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: + for attribute_name in vehicle.drive_train_attributes: + if attribute_name in vehicle.available_attributes: + device = BMWConnectedDriveSensor( + account, vehicle, attribute_name, attribute_info + ) + devices.append(device) + add_entities(devices, True) + + +class BMWConnectedDriveSensor(Entity): + """Representation of a BMW vehicle sensor.""" + + def __init__(self, account, vehicle, attribute: str, attribute_info): + """Initialize BMW vehicle sensor.""" + self._vehicle = vehicle + self._account = account + self._attribute = attribute + self._state = None + self._name = f"{self._vehicle.name} {self._attribute}" + self._unique_id = f"{self._vehicle.vin}-{self._attribute}" + self._attribute_info = attribute_info + + @property + def should_poll(self) -> bool: + """Return False. + + Data update is triggered from BMWConnectedDriveEntity. + """ + return False + + @property + def unique_id(self): + """Return the unique ID of the sensor.""" + return self._unique_id + + @property + def name(self) -> str: + """Return the name of the sensor.""" + return self._name + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + + vehicle_state = self._vehicle.state + charging_state = vehicle_state.charging_status in [ChargingState.CHARGING] + + if self._attribute == "charging_level_hv": + return icon_for_battery_level( + battery_level=vehicle_state.charging_level_hv, charging=charging_state + ) + icon, _ = self._attribute_info.get(self._attribute, [None, None]) + return icon + + @property + def state(self): + """Return the state of the sensor. + + The return type of this call depends on the attribute that + is configured. + """ + return self._state + + @property + def unit_of_measurement(self) -> str: + """Get the unit of measurement.""" + _, unit = self._attribute_info.get(self._attribute, [None, None]) + return unit + + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + return {"car": self._vehicle.name} + + def update(self) -> None: + """Read new state data from the library.""" + _LOGGER.debug("Updating %s", self._vehicle.name) + vehicle_state = self._vehicle.state + if self._attribute == "charging_status": + self._state = getattr(vehicle_state, self._attribute).value + elif self.unit_of_measurement == VOLUME_GALLONS: + value = getattr(vehicle_state, self._attribute) + value_converted = self.hass.config.units.volume(value, VOLUME_LITERS) + self._state = round(value_converted) + elif self.unit_of_measurement == LENGTH_MILES: + value = getattr(vehicle_state, self._attribute) + value_converted = self.hass.config.units.length(value, LENGTH_KILOMETERS) + self._state = round(value_converted) + else: + self._state = getattr(vehicle_state, self._attribute) + + def update_callback(self): + """Schedule a state update.""" + self.schedule_update_ha_state(True) + + async 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/bom/__init__.py b/homeassistant/components/bom/__init__.py new file mode 100644 index 000000000..7b83a5c98 --- /dev/null +++ b/homeassistant/components/bom/__init__.py @@ -0,0 +1 @@ +"""The bom component.""" diff --git a/homeassistant/components/bom/camera.py b/homeassistant/components/bom/camera.py new file mode 100644 index 000000000..7460b84f7 --- /dev/null +++ b/homeassistant/components/bom/camera.py @@ -0,0 +1,135 @@ +"""Provide animated GIF loops of BOM radar imagery.""" +from bomradarloop import BOMRadarLoop +import voluptuous as vol + +from homeassistant.components.camera import PLATFORM_SCHEMA, Camera +from homeassistant.const import CONF_ID, CONF_NAME +from homeassistant.helpers import config_validation as cv + +CONF_DELTA = "delta" +CONF_FRAMES = "frames" +CONF_LOCATION = "location" +CONF_OUTFILE = "filename" + +LOCATIONS = [ + "Adelaide", + "Albany", + "AliceSprings", + "Bairnsdale", + "Bowen", + "Brisbane", + "Broome", + "Cairns", + "Canberra", + "Carnarvon", + "Ceduna", + "Dampier", + "Darwin", + "Emerald", + "Esperance", + "Geraldton", + "Giles", + "Gladstone", + "Gove", + "Grafton", + "Gympie", + "HallsCreek", + "Hobart", + "Kalgoorlie", + "Katherine", + "Learmonth", + "Longreach", + "Mackay", + "Marburg", + "Melbourne", + "Mildura", + "Moree", + "MorningtonIs", + "MountIsa", + "MtGambier", + "Namoi", + "Newcastle", + "Newdegate", + "NorfolkIs", + "NWTasmania", + "Perth", + "PortHedland", + "SellicksHill", + "SouthDoodlakine", + "Sydney", + "Townsville", + "WaggaWagga", + "Warrego", + "Warruwi", + "Watheroo", + "Weipa", + "WillisIs", + "Wollongong", + "Woomera", + "Wyndham", + "Yarrawonga", +] + + +def _validate_schema(config): + if config.get(CONF_LOCATION) is None: + if not all(config.get(x) for x in (CONF_ID, CONF_DELTA, CONF_FRAMES)): + raise vol.Invalid( + "Specify '{}', '{}' and '{}' when '{}' is unspecified".format( + CONF_ID, CONF_DELTA, CONF_FRAMES, CONF_LOCATION + ) + ) + return config + + +LOCATIONS_MSG = "Set '{}' to one of: {}".format( + CONF_LOCATION, ", ".join(sorted(LOCATIONS)) +) +XOR_MSG = f"Specify exactly one of '{CONF_ID}' or '{CONF_LOCATION}'" + +PLATFORM_SCHEMA = vol.All( + PLATFORM_SCHEMA.extend( + { + vol.Exclusive(CONF_ID, "xor", msg=XOR_MSG): cv.string, + vol.Exclusive(CONF_LOCATION, "xor", msg=XOR_MSG): vol.In( + LOCATIONS, msg=LOCATIONS_MSG + ), + vol.Optional(CONF_DELTA): cv.positive_int, + vol.Optional(CONF_FRAMES): cv.positive_int, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_OUTFILE): cv.string, + } + ), + _validate_schema, +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up BOM radar-loop camera component.""" + location = config.get(CONF_LOCATION) or "ID {}".format(config.get(CONF_ID)) + name = config.get(CONF_NAME) or f"BOM Radar Loop - {location}" + args = [ + config.get(x) + for x in (CONF_LOCATION, CONF_ID, CONF_DELTA, CONF_FRAMES, CONF_OUTFILE) + ] + add_entities([BOMRadarCam(name, *args)]) + + +class BOMRadarCam(Camera): + """A camera component producing animated BOM radar-imagery GIFs.""" + + def __init__(self, name, location, radar_id, delta, frames, outfile): + """Initialize the component.""" + + super().__init__() + self._name = name + self._cam = BOMRadarLoop(location, radar_id, delta, frames, outfile) + + def camera_image(self): + """Return the current BOM radar-loop image.""" + return self._cam.current + + @property + def name(self): + """Return the component name.""" + return self._name diff --git a/homeassistant/components/bom/manifest.json b/homeassistant/components/bom/manifest.json new file mode 100644 index 000000000..b2e7eb08e --- /dev/null +++ b/homeassistant/components/bom/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "bom", + "name": "Bom", + "documentation": "https://www.home-assistant.io/integrations/bom", + "requirements": [ + "bomradarloop==0.1.3" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/bom/sensor.py b/homeassistant/components/bom/sensor.py new file mode 100644 index 000000000..7d951968c --- /dev/null +++ b/homeassistant/components/bom/sensor.py @@ -0,0 +1,345 @@ +"""Support for Australian BOM (Bureau of Meteorology) weather service.""" +import datetime +import ftplib +import gzip +import io +import json +import logging +import os +import re +import zipfile + +import requests +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + ATTR_ATTRIBUTION, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_MONITORED_CONDITIONS, + CONF_NAME, + TEMP_CELSIUS, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle +import homeassistant.util.dt as dt_util + +_RESOURCE = "http://www.bom.gov.au/fwo/{}/{}.{}.json" +_LOGGER = logging.getLogger(__name__) + +ATTR_LAST_UPDATE = "last_update" +ATTR_SENSOR_ID = "sensor_id" +ATTR_STATION_ID = "station_id" +ATTR_STATION_NAME = "station_name" +ATTR_ZONE_ID = "zone_id" + +ATTRIBUTION = "Data provided by the Australian Bureau of Meteorology" + +CONF_STATION = "station" +CONF_ZONE_ID = "zone_id" +CONF_WMO_ID = "wmo_id" + +MIN_TIME_BETWEEN_UPDATES = datetime.timedelta(seconds=60) + +SENSOR_TYPES = { + "wmo": ["wmo", None], + "name": ["Station Name", None], + "history_product": ["Zone", None], + "local_date_time": ["Local Time", None], + "local_date_time_full": ["Local Time Full", None], + "aifstime_utc": ["UTC Time Full", None], + "lat": ["Lat", None], + "lon": ["Long", None], + "apparent_t": ["Feels Like C", TEMP_CELSIUS], + "cloud": ["Cloud", None], + "cloud_base_m": ["Cloud Base", None], + "cloud_oktas": ["Cloud Oktas", None], + "cloud_type_id": ["Cloud Type ID", None], + "cloud_type": ["Cloud Type", None], + "delta_t": ["Delta Temp C", TEMP_CELSIUS], + "gust_kmh": ["Wind Gust kmh", "km/h"], + "gust_kt": ["Wind Gust kt", "kt"], + "air_temp": ["Air Temp C", TEMP_CELSIUS], + "dewpt": ["Dew Point C", TEMP_CELSIUS], + "press": ["Pressure mb", "mbar"], + "press_qnh": ["Pressure qnh", "qnh"], + "press_msl": ["Pressure msl", "msl"], + "press_tend": ["Pressure Tend", None], + "rain_trace": ["Rain Today", "mm"], + "rel_hum": ["Relative Humidity", "%"], + "sea_state": ["Sea State", None], + "swell_dir_worded": ["Swell Direction", None], + "swell_height": ["Swell Height", "m"], + "swell_period": ["Swell Period", None], + "vis_km": ["Visability km", "km"], + "weather": ["Weather", None], + "wind_dir": ["Wind Direction", None], + "wind_spd_kmh": ["Wind Speed kmh", "km/h"], + "wind_spd_kt": ["Wind Speed kt", "kt"], +} + + +def validate_station(station): + """Check that the station ID is well-formed.""" + if station is None: + return + station = station.replace(".shtml", "") + if not re.fullmatch(r"ID[A-Z]\d\d\d\d\d\.\d\d\d\d\d", station): + raise vol.error.Invalid("Malformed station ID") + return station + + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Inclusive(CONF_ZONE_ID, "Deprecated partial station ID"): cv.string, + vol.Inclusive(CONF_WMO_ID, "Deprecated partial station ID"): cv.string, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_STATION): validate_station, + vol.Required(CONF_MONITORED_CONDITIONS, default=[]): vol.All( + cv.ensure_list, [vol.In(SENSOR_TYPES)] + ), + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the BOM sensor.""" + station = config.get(CONF_STATION) + zone_id, wmo_id = config.get(CONF_ZONE_ID), config.get(CONF_WMO_ID) + + if station is not None: + if zone_id and wmo_id: + _LOGGER.warning( + "Using config %s, not %s and %s for BOM sensor", + CONF_STATION, + CONF_ZONE_ID, + CONF_WMO_ID, + ) + elif zone_id and wmo_id: + station = f"{zone_id}.{wmo_id}" + else: + station = closest_station( + config.get(CONF_LATITUDE), + config.get(CONF_LONGITUDE), + hass.config.config_dir, + ) + if station is None: + _LOGGER.error("Could not get BOM weather station from lat/lon") + return + + bom_data = BOMCurrentData(station) + + try: + bom_data.update() + except ValueError as err: + _LOGGER.error("Received error from BOM Current: %s", err) + return + + add_entities( + [ + BOMCurrentSensor(bom_data, variable, config.get(CONF_NAME)) + for variable in config[CONF_MONITORED_CONDITIONS] + ] + ) + + +class BOMCurrentSensor(Entity): + """Implementation of a BOM current sensor.""" + + def __init__(self, bom_data, condition, stationname): + """Initialize the sensor.""" + self.bom_data = bom_data + self._condition = condition + self.stationname = stationname + + @property + def name(self): + """Return the name of the sensor.""" + if self.stationname is None: + return "BOM {}".format(SENSOR_TYPES[self._condition][0]) + + return "BOM {} {}".format(self.stationname, SENSOR_TYPES[self._condition][0]) + + @property + def state(self): + """Return the state of the sensor.""" + return self.bom_data.get_reading(self._condition) + + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + attr = { + ATTR_ATTRIBUTION: ATTRIBUTION, + ATTR_LAST_UPDATE: self.bom_data.last_updated, + ATTR_SENSOR_ID: self._condition, + ATTR_STATION_ID: self.bom_data.latest_data["wmo"], + ATTR_STATION_NAME: self.bom_data.latest_data["name"], + ATTR_ZONE_ID: self.bom_data.latest_data["history_product"], + } + + return attr + + @property + def unit_of_measurement(self): + """Return the units of measurement.""" + return SENSOR_TYPES[self._condition][1] + + def update(self): + """Update current conditions.""" + self.bom_data.update() + + +class BOMCurrentData: + """Get data from BOM.""" + + def __init__(self, station_id): + """Initialize the data object.""" + self._zone_id, self._wmo_id = station_id.split(".") + self._data = None + self.last_updated = None + + def _build_url(self): + """Build the URL for the requests.""" + url = _RESOURCE.format(self._zone_id, self._zone_id, self._wmo_id) + _LOGGER.debug("BOM URL: %s", url) + return url + + @property + def latest_data(self): + """Return the latest data object.""" + if self._data: + return self._data[0] + return None + + def get_reading(self, condition): + """Return the value for the given condition. + + BOM weather publishes condition readings for weather (and a few other + conditions) at intervals throughout the day. To avoid a `-` value in + the frontend for these conditions, we traverse the historical data + for the latest value that is not `-`. + + Iterators are used in this method to avoid iterating needlessly + through the entire BOM provided dataset. + """ + condition_readings = (entry[condition] for entry in self._data) + return next((x for x in condition_readings if x != "-"), None) + + def should_update(self): + """Determine whether an update should occur. + + BOM provides updated data every 30 minutes. We manually define + refreshing logic here rather than a throttle to keep updates + in lock-step with BOM. + + If 35 minutes has passed since the last BOM data update, then + an update should be done. + """ + if self.last_updated is None: + # Never updated before, therefore an update should occur. + return True + + now = dt_util.utcnow() + update_due_at = self.last_updated + datetime.timedelta(minutes=35) + return now > update_due_at + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data from BOM.""" + if not self.should_update(): + _LOGGER.debug( + "BOM was updated %s minutes ago, skipping update as" + " < 35 minutes, Now: %s, LastUpdate: %s", + (dt_util.utcnow() - self.last_updated), + dt_util.utcnow(), + self.last_updated, + ) + return + + try: + result = requests.get(self._build_url(), timeout=10).json() + self._data = result["observations"]["data"] + + # set lastupdate using self._data[0] as the first element in the + # array is the latest date in the json + self.last_updated = dt_util.as_utc( + datetime.datetime.strptime( + str(self._data[0]["local_date_time_full"]), "%Y%m%d%H%M%S" + ) + ) + return + + except ValueError as err: + _LOGGER.error("Check BOM %s", err.args) + self._data = None + raise + + +def _get_bom_stations(): + """Return {CONF_STATION: (lat, lon)} for all stations, for auto-config. + + This function does several MB of internet requests, so please use the + caching version to minimise latency and hit-count. + """ + latlon = {} + with io.BytesIO() as file_obj: + with ftplib.FTP("ftp.bom.gov.au") as ftp: + ftp.login() + ftp.cwd("anon2/home/ncc/metadata/sitelists") + ftp.retrbinary("RETR stations.zip", file_obj.write) + file_obj.seek(0) + with zipfile.ZipFile(file_obj) as zipped: + with zipped.open("stations.txt") as station_txt: + for _ in range(4): + station_txt.readline() # skip header + while True: + line = station_txt.readline().decode().strip() + if len(line) < 120: + break # end while loop, ignoring any footer text + wmo, lat, lon = ( + line[a:b].strip() for a, b in [(128, 134), (70, 78), (79, 88)] + ) + if wmo != "..": + latlon[wmo] = (float(lat), float(lon)) + zones = {} + pattern = ( + r'' + ) + for state in ("nsw", "vic", "qld", "wa", "tas", "nt"): + url = "http://www.bom.gov.au/{0}/observations/{0}all.shtml".format(state) + for zone_id, wmo_id in re.findall(pattern, requests.get(url).text): + zones[wmo_id] = zone_id + return {"{}.{}".format(zones[k], k): latlon[k] for k in set(latlon) & set(zones)} + + +def bom_stations(cache_dir): + """Return {CONF_STATION: (lat, lon)} for all stations, for auto-config. + + Results from internet requests are cached as compressed JSON, making + subsequent calls very much faster. + """ + cache_file = os.path.join(cache_dir, ".bom-stations.json.gz") + if not os.path.isfile(cache_file): + stations = _get_bom_stations() + with gzip.open(cache_file, "wt") as cache: + json.dump(stations, cache, sort_keys=True) + return stations + with gzip.open(cache_file, "rt") as cache: + return {k: tuple(v) for k, v in json.load(cache).items()} + + +def closest_station(lat, lon, cache_dir): + """Return the ZONE_ID.WMO_ID of the closest station to our lat/lon.""" + if lat is None or lon is None or not os.path.isdir(cache_dir): + return + stations = bom_stations(cache_dir) + + def comparable_dist(wmo_id): + """Create a psudeo-distance from latitude/longitude.""" + station_lat, station_lon = stations[wmo_id] + return (lat - station_lat) ** 2 + (lon - station_lon) ** 2 + + return min(stations, key=comparable_dist) diff --git a/homeassistant/components/bom/weather.py b/homeassistant/components/bom/weather.py new file mode 100644 index 000000000..2513c7c4c --- /dev/null +++ b/homeassistant/components/bom/weather.py @@ -0,0 +1,113 @@ +"""Support for Australian BOM (Bureau of Meteorology) weather service.""" +import logging + +import voluptuous as vol + +from homeassistant.components.weather import PLATFORM_SCHEMA, WeatherEntity +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, TEMP_CELSIUS +from homeassistant.helpers import config_validation as cv + +# Reuse data and API logic from the sensor implementation +from .sensor import CONF_STATION, BOMCurrentData, closest_station, validate_station + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + {vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_STATION): validate_station} +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the BOM weather platform.""" + station = config.get(CONF_STATION) or closest_station( + config.get(CONF_LATITUDE), config.get(CONF_LONGITUDE), hass.config.config_dir + ) + if station is None: + _LOGGER.error("Could not get BOM weather station from lat/lon") + return False + bom_data = BOMCurrentData(station) + try: + bom_data.update() + except ValueError as err: + _LOGGER.error("Received error from BOM_Current: %s", err) + return False + add_entities([BOMWeather(bom_data, config.get(CONF_NAME))], True) + + +class BOMWeather(WeatherEntity): + """Representation of a weather condition.""" + + def __init__(self, bom_data, stationname=None): + """Initialise the platform with a data instance and station name.""" + self.bom_data = bom_data + self.stationname = stationname or self.bom_data.latest_data.get("name") + + def update(self): + """Update current conditions.""" + self.bom_data.update() + + @property + def name(self): + """Return the name of the sensor.""" + return "BOM {}".format(self.stationname or "(unknown station)") + + @property + def condition(self): + """Return the current condition.""" + return self.bom_data.get_reading("weather") + + # Now implement the WeatherEntity interface + + @property + def temperature(self): + """Return the platform temperature.""" + return self.bom_data.get_reading("air_temp") + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def pressure(self): + """Return the mean sea-level pressure.""" + return self.bom_data.get_reading("press_msl") + + @property + def humidity(self): + """Return the relative humidity.""" + return self.bom_data.get_reading("rel_hum") + + @property + def wind_speed(self): + """Return the wind speed.""" + return self.bom_data.get_reading("wind_spd_kmh") + + @property + def wind_bearing(self): + """Return the wind bearing.""" + directions = [ + "N", + "NNE", + "NE", + "ENE", + "E", + "ESE", + "SE", + "SSE", + "S", + "SSW", + "SW", + "WSW", + "W", + "WNW", + "NW", + "NNW", + ] + wind = {name: idx * 360 / 16 for idx, name in enumerate(directions)} + return wind.get(self.bom_data.get_reading("wind_dir")) + + @property + def attribution(self): + """Return the attribution.""" + return "Data provided by the Australian Bureau of Meteorology" diff --git a/homeassistant/components/braviatv/__init__.py b/homeassistant/components/braviatv/__init__.py new file mode 100644 index 000000000..47c6f4cf2 --- /dev/null +++ b/homeassistant/components/braviatv/__init__.py @@ -0,0 +1 @@ +"""The braviatv component.""" diff --git a/homeassistant/components/braviatv/manifest.json b/homeassistant/components/braviatv/manifest.json new file mode 100644 index 000000000..e49e45865 --- /dev/null +++ b/homeassistant/components/braviatv/manifest.json @@ -0,0 +1,15 @@ +{ + "domain": "braviatv", + "name": "Braviatv", + "documentation": "https://www.home-assistant.io/integrations/braviatv", + "requirements": [ + "braviarc-homeassistant==0.3.7.dev0", + "getmac==0.8.1" + ], + "dependencies": [ + "configurator" + ], + "codeowners": [ + "@robbiet480" + ] +} diff --git a/homeassistant/components/braviatv/media_player.py b/homeassistant/components/braviatv/media_player.py new file mode 100644 index 000000000..d0458541f --- /dev/null +++ b/homeassistant/components/braviatv/media_player.py @@ -0,0 +1,360 @@ +"""Support for interface with a Sony Bravia TV.""" +import ipaddress +import logging + +from braviarc.braviarc import BraviaRC +from getmac import get_mac_address +import voluptuous as vol + +from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerDevice +from homeassistant.components.media_player.const import ( + SUPPORT_NEXT_TRACK, + SUPPORT_PAUSE, + SUPPORT_PLAY, + SUPPORT_PREVIOUS_TRACK, + SUPPORT_SELECT_SOURCE, + SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, + SUPPORT_VOLUME_MUTE, + SUPPORT_VOLUME_SET, + SUPPORT_VOLUME_STEP, +) +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 + +BRAVIA_CONFIG_FILE = "bravia.conf" + +CLIENTID_PREFIX = "HomeAssistant" + +DEFAULT_NAME = "Sony Bravia TV" + +NICKNAME = "Home Assistant" + +# Map ip to request id for configuring +_CONFIGURING = {} + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_BRAVIA = ( + SUPPORT_PAUSE + | SUPPORT_VOLUME_STEP + | SUPPORT_VOLUME_MUTE + | SUPPORT_VOLUME_SET + | SUPPORT_PREVIOUS_TRACK + | SUPPORT_NEXT_TRACK + | SUPPORT_TURN_ON + | SUPPORT_TURN_OFF + | SUPPORT_SELECT_SOURCE + | SUPPORT_PLAY +) + +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_entities, discovery_info=None): + """Set up the Sony Bravia TV platform.""" + host = config.get(CONF_HOST) + + if host is None: + return + + pin = None + 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() + if host_ip == host: + pin = host_config["pin"] + mac = host_config["mac"] + name = config.get(CONF_NAME) + add_entities([BraviaTVDevice(host, mac, name, pin)]) + return + + setup_bravia(config, pin, hass, add_entities) + + +def setup_bravia(config, pin, hass, add_entities): + """Set up a Sony Bravia TV based on host parameter.""" + host = config.get(CONF_HOST) + name = config.get(CONF_NAME) + + if pin is None: + request_configuration(config, hass, add_entities) + return + + try: + if ipaddress.ip_address(host).version == 6: + mode = "ip6" + else: + mode = "ip" + except ValueError: + mode = "hostname" + mac = get_mac_address(**{mode: host}) + + # 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) + _LOGGER.info("Discovery configuration done") + + # Save config + save_json( + hass.config.path(BRAVIA_CONFIG_FILE), + {host: {"pin": pin, "host": host, "mac": mac}}, + ) + + add_entities([BraviaTVDevice(host, mac, name, pin)]) + + +def request_configuration(config, hass, add_entities): + """Request configuration steps from the user.""" + host = config.get(CONF_HOST) + name = config.get(CONF_NAME) + + configurator = hass.components.configurator + + # We got an error if this method is called while we are configuring + if host in _CONFIGURING: + configurator.notify_errors( + _CONFIGURING[host], "Failed to register, please try again." + ) + return + + def bravia_configuration_callback(data): + """Handle the entry of user PIN.""" + + pin = data.get("pin") + _braviarc = BraviaRC(host) + _braviarc.connect(pin, CLIENTID_PREFIX, NICKNAME) + if _braviarc.is_connected(): + setup_bravia(config, pin, hass, add_entities) + else: + request_configuration(config, hass, add_entities) + + _CONFIGURING[host] = configurator.request_config( + name, + bravia_configuration_callback, + description="Enter the Pin shown on your Sony Bravia TV." + + "If no Pin is shown, enter 0000 to let TV show you a Pin.", + description_image="/static/images/smart-tv.png", + submit_caption="Confirm", + fields=[{"id": "pin", "name": "Enter the pin", "type": ""}], + ) + + +class BraviaTVDevice(MediaPlayerDevice): + """Representation of a Sony Bravia TV.""" + + def __init__(self, host, mac, name, pin): + """Initialize the Sony Bravia device.""" + + self._pin = pin + self._braviarc = BraviaRC(host, mac) + self._name = name + self._state = STATE_OFF + self._muted = False + self._program_name = None + self._channel_name = None + self._channel_number = None + self._source = None + self._source_list = [] + self._original_content_list = [] + self._content_mapping = {} + self._duration = None + self._content_uri = None + self._id = None + self._playing = False + self._start_date_time = None + self._program_media_type = None + self._min_volume = None + self._max_volume = None + self._volume = None + + self._braviarc.connect(pin, CLIENTID_PREFIX, NICKNAME) + if self._braviarc.is_connected(): + self.update() + else: + self._state = STATE_OFF + + def update(self): + """Update TV info.""" + if not self._braviarc.is_connected(): + if self._braviarc.get_power_status() != "off": + self._braviarc.connect(self._pin, CLIENTID_PREFIX, NICKNAME) + if not self._braviarc.is_connected(): + return + + # Retrieve the latest data. + try: + if self._state == STATE_ON: + # refresh volume info: + self._refresh_volume() + self._refresh_channels() + + power_status = self._braviarc.get_power_status() + if power_status == "active": + self._state = STATE_ON + playing_info = self._braviarc.get_playing_info() + self._reset_playing_info() + if playing_info is None or not playing_info: + self._channel_name = "App" + else: + self._program_name = playing_info.get("programTitle") + self._channel_name = playing_info.get("title") + self._program_media_type = playing_info.get("programMediaType") + self._channel_number = playing_info.get("dispNum") + self._source = playing_info.get("source") + self._content_uri = playing_info.get("uri") + self._duration = playing_info.get("durationSec") + self._start_date_time = playing_info.get("startDateTime") + else: + self._state = STATE_OFF + + except Exception as exception_instance: # pylint: disable=broad-except + _LOGGER.error(exception_instance) + self._state = STATE_OFF + + def _reset_playing_info(self): + self._program_name = None + self._channel_name = None + self._program_media_type = None + self._channel_number = None + self._source = None + self._content_uri = None + self._duration = None + self._start_date_time = None + + def _refresh_volume(self): + """Refresh volume information.""" + volume_info = self._braviarc.get_volume_info() + if volume_info is not None: + self._volume = volume_info.get("volume") + self._min_volume = volume_info.get("minVolume") + self._max_volume = volume_info.get("maxVolume") + self._muted = volume_info.get("mute") + + def _refresh_channels(self): + if not self._source_list: + self._content_mapping = self._braviarc.load_source_list() + self._source_list = [] + for key in self._content_mapping: + self._source_list.append(key) + + @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(self): + """Return the current input source.""" + return self._source + + @property + def source_list(self): + """List of available input sources.""" + return self._source_list + + @property + def volume_level(self): + """Volume level of the media player (0..1).""" + if self._volume is not None: + return self._volume / 100 + return None + + @property + def is_volume_muted(self): + """Boolean if volume is currently muted.""" + return self._muted + + @property + def supported_features(self): + """Flag media player features that are supported.""" + return SUPPORT_BRAVIA + + @property + def media_title(self): + """Title of current playing media.""" + return_value = None + if self._channel_name is not None: + return_value = self._channel_name + if self._program_name is not None: + return_value = return_value + ": " + self._program_name + return return_value + + @property + def media_content_id(self): + """Content ID of current playing media.""" + return self._channel_name + + @property + def media_duration(self): + """Duration of current playing media in seconds.""" + return self._duration + + def set_volume_level(self, volume): + """Set volume level, range 0..1.""" + self._braviarc.set_volume_level(volume) + + def turn_on(self): + """Turn the media player on.""" + self._braviarc.turn_on() + + def turn_off(self): + """Turn off media player.""" + self._braviarc.turn_off() + + def volume_up(self): + """Volume up the media player.""" + self._braviarc.volume_up() + + def volume_down(self): + """Volume down media player.""" + self._braviarc.volume_down() + + def mute_volume(self, mute): + """Send mute command.""" + self._braviarc.mute_volume(mute) + + def select_source(self, source): + """Set the input source.""" + if source in self._content_mapping: + uri = self._content_mapping[source] + self._braviarc.play_content(uri) + + def media_play_pause(self): + """Simulate play pause media player.""" + if self._playing: + self.media_pause() + else: + self.media_play() + + def media_play(self): + """Send play command.""" + self._playing = True + self._braviarc.media_play() + + def media_pause(self): + """Send media pause command to media player.""" + self._playing = False + self._braviarc.media_pause() + + def media_next_track(self): + """Send next track command.""" + self._braviarc.media_next_track() + + def media_previous_track(self): + """Send the previous track command.""" + self._braviarc.media_previous_track() diff --git a/homeassistant/components/broadlink/__init__.py b/homeassistant/components/broadlink/__init__.py new file mode 100644 index 000000000..3f9b5cd45 --- /dev/null +++ b/homeassistant/components/broadlink/__init__.py @@ -0,0 +1,130 @@ +"""The broadlink component.""" +import asyncio +from base64 import b64decode, b64encode +from binascii import unhexlify +from datetime import timedelta +import logging +import re +import socket + +import voluptuous as vol + +from homeassistant.const import CONF_HOST +import homeassistant.helpers.config_validation as cv +from homeassistant.util.dt import utcnow + +from .const import CONF_PACKET, DOMAIN, SERVICE_LEARN, SERVICE_SEND + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_RETRY = 3 + + +def data_packet(value): + """Decode a data packet given for broadlink.""" + value = cv.string(value) + extra = len(value) % 4 + if extra > 0: + value = value + ("=" * (4 - extra)) + return b64decode(value) + + +def hostname(value): + """Validate a hostname.""" + host = str(value).lower() + if len(host) > 253: + raise ValueError + if host[-1] == ".": + host = host[:-1] + allowed = re.compile(r"(?!-)[a-z\d-]{1,63}(? 0 and self._auth(): + self._update(retry - 1) + + def _auth(self, retry=3): + try: + auth = self._device.auth() + except OSError: + auth = False + if not auth and retry > 0: + self._connect() + return self._auth(retry - 1) + return auth diff --git a/homeassistant/components/broadlink/services.yaml b/homeassistant/components/broadlink/services.yaml new file mode 100644 index 000000000..2281cb1cc --- /dev/null +++ b/homeassistant/components/broadlink/services.yaml @@ -0,0 +1,9 @@ +send: + description: Send a raw packet to device. + fields: + host: {description: IP address of device to send packet via. This must be an already configured device., example: "192.168.0.1"} + packet: {description: base64 encoded packet.} +learn: + description: Learn a IR or RF code from remote. + fields: + host: {description: IP address of device to send packet via. This must be an already configured device., example: "192.168.0.1"} diff --git a/homeassistant/components/broadlink/switch.py b/homeassistant/components/broadlink/switch.py new file mode 100644 index 000000000..78738870a --- /dev/null +++ b/homeassistant/components/broadlink/switch.py @@ -0,0 +1,404 @@ +"""Support for Broadlink RM devices.""" +import binascii +from datetime import timedelta +import logging +import socket + +import broadlink +import voluptuous as vol + +from homeassistant.components.switch import ( + ENTITY_ID_FORMAT, + PLATFORM_SCHEMA, + SwitchDevice, +) +from homeassistant.const import ( + CONF_COMMAND_OFF, + CONF_COMMAND_ON, + CONF_FRIENDLY_NAME, + CONF_HOST, + CONF_MAC, + CONF_SWITCHES, + CONF_TIMEOUT, + CONF_TYPE, + STATE_ON, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.util import Throttle, slugify + +from . import async_setup_service, data_packet + +_LOGGER = logging.getLogger(__name__) + +TIME_BETWEEN_UPDATES = timedelta(seconds=5) + +DEFAULT_NAME = "Broadlink switch" +DEFAULT_TIMEOUT = 10 +DEFAULT_RETRY = 2 +CONF_SLOTS = "slots" +CONF_RETRY = "retry" + +RM_TYPES = [ + "rm", + "rm2", + "rm_mini", + "rm_pro_phicomm", + "rm2_home_plus", + "rm2_home_plus_gdt", + "rm2_pro_plus", + "rm2_pro_plus2", + "rm2_pro_plus_bl", + "rm_mini_shate", +] +SP1_TYPES = ["sp1"] +SP2_TYPES = ["sp2", "honeywell_sp2", "sp3", "spmini2", "spminiplus"] +MP1_TYPES = ["mp1"] + +SWITCH_TYPES = RM_TYPES + SP1_TYPES + SP2_TYPES + MP1_TYPES + +SWITCH_SCHEMA = vol.Schema( + { + vol.Optional(CONF_COMMAND_OFF): data_packet, + vol.Optional(CONF_COMMAND_ON): data_packet, + vol.Optional(CONF_FRIENDLY_NAME): cv.string, + } +) + +MP1_SWITCH_SLOT_SCHEMA = vol.Schema( + { + vol.Optional("slot_1"): cv.string, + vol.Optional("slot_2"): cv.string, + vol.Optional("slot_3"): cv.string, + vol.Optional("slot_4"): cv.string, + } +) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_SWITCHES, default={}): cv.schema_with_slug_keys( + SWITCH_SCHEMA + ), + vol.Optional(CONF_SLOTS, default={}): MP1_SWITCH_SLOT_SCHEMA, + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_MAC): cv.string, + vol.Optional(CONF_FRIENDLY_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_TYPE, default=SWITCH_TYPES[0]): vol.In(SWITCH_TYPES), + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + vol.Optional(CONF_RETRY, default=DEFAULT_RETRY): cv.positive_int, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Broadlink switches.""" + + devices = config.get(CONF_SWITCHES) + slots = config.get("slots", {}) + ip_addr = config.get(CONF_HOST) + friendly_name = config.get(CONF_FRIENDLY_NAME) + mac_addr = binascii.unhexlify(config.get(CONF_MAC).encode().replace(b":", b"")) + switch_type = config.get(CONF_TYPE) + retry_times = config.get(CONF_RETRY) + + def _get_mp1_slot_name(switch_friendly_name, slot): + """Get slot name.""" + if not slots[f"slot_{slot}"]: + return f"{switch_friendly_name} slot {slot}" + return slots[f"slot_{slot}"] + + if switch_type in RM_TYPES: + broadlink_device = broadlink.rm((ip_addr, 80), mac_addr, None) + hass.add_job(async_setup_service, hass, ip_addr, broadlink_device) + + switches = [] + for object_id, device_config in devices.items(): + switches.append( + BroadlinkRMSwitch( + object_id, + device_config.get(CONF_FRIENDLY_NAME, object_id), + broadlink_device, + device_config.get(CONF_COMMAND_ON), + device_config.get(CONF_COMMAND_OFF), + retry_times, + ) + ) + elif switch_type in SP1_TYPES: + broadlink_device = broadlink.sp1((ip_addr, 80), mac_addr, None) + switches = [BroadlinkSP1Switch(friendly_name, broadlink_device, retry_times)] + elif switch_type in SP2_TYPES: + broadlink_device = broadlink.sp2((ip_addr, 80), mac_addr, None) + switches = [BroadlinkSP2Switch(friendly_name, broadlink_device, retry_times)] + elif switch_type in MP1_TYPES: + switches = [] + broadlink_device = broadlink.mp1((ip_addr, 80), mac_addr, None) + parent_device = BroadlinkMP1Switch(broadlink_device, retry_times) + for i in range(1, 5): + slot = BroadlinkMP1Slot( + _get_mp1_slot_name(friendly_name, i), + broadlink_device, + i, + parent_device, + retry_times, + ) + switches.append(slot) + + broadlink_device.timeout = config.get(CONF_TIMEOUT) + try: + broadlink_device.auth() + except OSError: + _LOGGER.error("Failed to connect to device") + + add_entities(switches) + + +class BroadlinkRMSwitch(SwitchDevice, RestoreEntity): + """Representation of an Broadlink switch.""" + + def __init__( + self, name, friendly_name, device, command_on, command_off, retry_times + ): + """Initialize the switch.""" + self.entity_id = ENTITY_ID_FORMAT.format(slugify(name)) + self._name = friendly_name + self._state = False + self._command_on = command_on + self._command_off = command_off + self._device = device + self._is_available = False + self._retry_times = retry_times + _LOGGER.debug("_retry_times : %s", self._retry_times) + + async def async_added_to_hass(self): + """Call when entity about to be added to hass.""" + await super().async_added_to_hass() + state = await self.async_get_last_state() + if state: + self._state = state.state == STATE_ON + + @property + def name(self): + """Return the name of the switch.""" + return self._name + + @property + def assumed_state(self): + """Return true if unable to access real state of entity.""" + return True + + @property + def available(self): + """Return True if entity is available.""" + return not self.should_poll or self._is_available + + @property + def should_poll(self): + """Return the polling state.""" + return False + + @property + def is_on(self): + """Return true if device is on.""" + return self._state + + def turn_on(self, **kwargs): + """Turn the device on.""" + if self._sendpacket(self._command_on, self._retry_times): + self._state = True + self.schedule_update_ha_state() + + def turn_off(self, **kwargs): + """Turn the device off.""" + if self._sendpacket(self._command_off, self._retry_times): + self._state = False + self.schedule_update_ha_state() + + def _sendpacket(self, packet, retry): + """Send packet to device.""" + if packet is None: + _LOGGER.debug("Empty packet") + return True + try: + self._device.send_data(packet) + except (ValueError, OSError) as error: + if retry < 1: + _LOGGER.error("Error during sending a packet: %s", error) + return False + if not self._auth(self._retry_times): + return False + return self._sendpacket(packet, retry - 1) + return True + + def _auth(self, retry): + _LOGGER.debug("_auth : retry=%s", retry) + try: + auth = self._device.auth() + except OSError: + auth = False + if retry < 1: + _LOGGER.error("Timeout during authorization") + if not auth and retry > 0: + return self._auth(retry - 1) + return auth + + +class BroadlinkSP1Switch(BroadlinkRMSwitch): + """Representation of an Broadlink switch.""" + + def __init__(self, friendly_name, device, retry_times): + """Initialize the switch.""" + super().__init__(friendly_name, friendly_name, device, None, None, retry_times) + self._command_on = 1 + self._command_off = 0 + self._load_power = None + + def _sendpacket(self, packet, retry): + """Send packet to device.""" + try: + self._device.set_power(packet) + except (socket.timeout, ValueError) as error: + if retry < 1: + _LOGGER.error("Error during sending a packet: %s", error) + return False + if not self._auth(self._retry_times): + return False + return self._sendpacket(packet, retry - 1) + return True + + +class BroadlinkSP2Switch(BroadlinkSP1Switch): + """Representation of an Broadlink switch.""" + + @property + def assumed_state(self): + """Return true if unable to access real state of entity.""" + return False + + @property + def should_poll(self): + """Return the polling state.""" + return True + + @property + def current_power_w(self): + """Return the current power usage in Watt.""" + try: + return round(self._load_power, 2) + except (ValueError, TypeError): + return None + + def update(self): + """Synchronize state with switch.""" + self._update(self._retry_times) + + def _update(self, retry): + """Update the state of the device.""" + _LOGGER.debug("_update : retry=%s", retry) + try: + state = self._device.check_power() + load_power = self._device.get_energy() + except (socket.timeout, ValueError) as error: + if retry < 1: + _LOGGER.error("Error during updating the state: %s", error) + self._is_available = False + return + if not self._auth(self._retry_times): + return + return self._update(retry - 1) + if state is None and retry > 0: + return self._update(retry - 1) + self._state = state + self._load_power = load_power + self._is_available = True + + +class BroadlinkMP1Slot(BroadlinkRMSwitch): + """Representation of a slot of Broadlink switch.""" + + def __init__(self, friendly_name, device, slot, parent_device, retry_times): + """Initialize the slot of switch.""" + super().__init__(friendly_name, friendly_name, device, None, None, retry_times) + self._command_on = 1 + self._command_off = 0 + self._slot = slot + self._parent_device = parent_device + + @property + def assumed_state(self): + """Return true if unable to access real state of entity.""" + return False + + def _sendpacket(self, packet, retry): + """Send packet to device.""" + try: + self._device.set_power(self._slot, packet) + except (socket.timeout, ValueError) as error: + if retry < 1: + _LOGGER.error("Error during sending a packet: %s", error) + self._is_available = False + return False + if not self._auth(self._retry_times): + return False + return self._sendpacket(packet, max(0, retry - 1)) + self._is_available = True + return True + + @property + def should_poll(self): + """Return the polling state.""" + return True + + def update(self): + """Trigger update for all switches on the parent device.""" + self._parent_device.update() + self._state = self._parent_device.get_outlet_status(self._slot) + if self._state is None: + self._is_available = False + else: + self._is_available = True + + +class BroadlinkMP1Switch: + """Representation of a Broadlink switch - To fetch states of all slots.""" + + def __init__(self, device, retry_times): + """Initialize the switch.""" + self._device = device + self._states = None + self._retry_times = retry_times + + def get_outlet_status(self, slot): + """Get status of outlet from cached status list.""" + if self._states is None: + return None + return self._states[f"s{slot}"] + + @Throttle(TIME_BETWEEN_UPDATES) + def update(self): + """Fetch new state data for this device.""" + self._update(self._retry_times) + + def _update(self, retry): + """Update the state of the device.""" + try: + states = self._device.check_power() + except (socket.timeout, ValueError) as error: + if retry < 1: + _LOGGER.error("Error during updating the state: %s", error) + return + if not self._auth(self._retry_times): + return + return self._update(max(0, retry - 1)) + if states is None and retry > 0: + return self._update(max(0, retry - 1)) + self._states = states + + def _auth(self, retry): + """Authenticate the device.""" + try: + auth = self._device.auth() + except OSError: + auth = False + if not auth and retry > 0: + return self._auth(retry - 1) + return auth diff --git a/homeassistant/components/brottsplatskartan/__init__.py b/homeassistant/components/brottsplatskartan/__init__.py new file mode 100644 index 000000000..d519909b2 --- /dev/null +++ b/homeassistant/components/brottsplatskartan/__init__.py @@ -0,0 +1 @@ +"""The brottsplatskartan component.""" diff --git a/homeassistant/components/brottsplatskartan/manifest.json b/homeassistant/components/brottsplatskartan/manifest.json new file mode 100644 index 000000000..f3dd46c96 --- /dev/null +++ b/homeassistant/components/brottsplatskartan/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "brottsplatskartan", + "name": "Brottsplatskartan", + "documentation": "https://www.home-assistant.io/integrations/brottsplatskartan", + "requirements": [ + "brottsplatskartan==0.0.1" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/brottsplatskartan/sensor.py b/homeassistant/components/brottsplatskartan/sensor.py new file mode 100644 index 000000000..282433aa7 --- /dev/null +++ b/homeassistant/components/brottsplatskartan/sensor.py @@ -0,0 +1,122 @@ +"""Sensor platform for Brottsplatskartan information.""" +from collections import defaultdict +from datetime import timedelta +import logging +import uuid + +import brottsplatskartan +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + ATTR_ATTRIBUTION, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_NAME, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +CONF_AREA = "area" + +DEFAULT_NAME = "Brottsplatskartan" + +SCAN_INTERVAL = timedelta(minutes=30) + +AREAS = [ + "Blekinge län", + "Dalarnas län", + "Gotlands län", + "Gävleborgs län", + "Hallands län", + "Jämtlands län", + "Jönköpings län", + "Kalmar län", + "Kronobergs län", + "Norrbottens län", + "Skåne län", + "Stockholms län", + "Södermanlands län", + "Uppsala län", + "Värmlands län", + "Västerbottens län", + "Västernorrlands län", + "Västmanlands län", + "Västra Götalands län", + "Örebro län", + "Östergötlands län", +] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Inclusive(CONF_LATITUDE, "coordinates"): cv.latitude, + vol.Inclusive(CONF_LONGITUDE, "coordinates"): cv.longitude, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_AREA, default=[]): vol.All(cv.ensure_list, [vol.In(AREAS)]), + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Brottsplatskartan platform.""" + + area = config.get(CONF_AREA) + latitude = config.get(CONF_LATITUDE, hass.config.latitude) + longitude = config.get(CONF_LONGITUDE, hass.config.longitude) + name = config.get(CONF_NAME) + + # Every Home Assistant instance should have their own unique + # app parameter: https://brottsplatskartan.se/sida/api + app = "ha-{}".format(uuid.getnode()) + + bpk = brottsplatskartan.BrottsplatsKartan( + app=app, area=area, latitude=latitude, longitude=longitude + ) + + add_entities([BrottsplatskartanSensor(bpk, name)], True) + + +class BrottsplatskartanSensor(Entity): + """Representation of a Brottsplatskartan Sensor.""" + + def __init__(self, bpk, name): + """Initialize the Brottsplatskartan sensor.""" + self._attributes = {} + self._brottsplatskartan = bpk + self._name = name + self._state = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._attributes + + def update(self): + """Update device state.""" + + incident_counts = defaultdict(int) + incidents = self._brottsplatskartan.get_incidents() + + if incidents is False: + _LOGGER.debug("Problems fetching incidents") + return + + for incident in incidents: + incident_type = incident.get("title_type") + incident_counts[incident_type] += 1 + + self._attributes = {ATTR_ATTRIBUTION: brottsplatskartan.ATTRIBUTION} + self._attributes.update(incident_counts) + self._state = len(incidents) diff --git a/homeassistant/components/browser.py b/homeassistant/components/browser.py deleted file mode 100644 index 041a0f9cd..000000000 --- a/homeassistant/components/browser.py +++ /dev/null @@ -1,30 +0,0 @@ -""" -Provides functionality to launch a web browser on the host machine. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/browser/ -""" -import voluptuous as vol - -DOMAIN = "browser" -SERVICE_BROWSE_URL = "browse_url" - -ATTR_URL = 'url' -ATTR_URL_DEFAULT = 'https://www.google.com' - -SERVICE_BROWSE_URL_SCHEMA = vol.Schema({ - # pylint: disable=no-value-for-parameter - vol.Required(ATTR_URL, default=ATTR_URL_DEFAULT): vol.Url(), -}) - - -def setup(hass, config): - """Listen for browse_url events.""" - import webbrowser - - hass.services.register(DOMAIN, SERVICE_BROWSE_URL, - lambda service: - webbrowser.open(service.data[ATTR_URL]), - schema=SERVICE_BROWSE_URL_SCHEMA) - - return True diff --git a/homeassistant/components/browser/__init__.py b/homeassistant/components/browser/__init__.py new file mode 100644 index 000000000..fc0e9eccb --- /dev/null +++ b/homeassistant/components/browser/__init__.py @@ -0,0 +1,31 @@ +"""Support for launching a web browser on the host machine.""" +import webbrowser + +import voluptuous as vol + +ATTR_URL = "url" +ATTR_URL_DEFAULT = "https://www.google.com" + +DOMAIN = "browser" + +SERVICE_BROWSE_URL = "browse_url" + +SERVICE_BROWSE_URL_SCHEMA = vol.Schema( + { + # pylint: disable=no-value-for-parameter + vol.Required(ATTR_URL, default=ATTR_URL_DEFAULT): vol.Url() + } +) + + +def setup(hass, config): + """Listen for browse_url events.""" + + hass.services.register( + DOMAIN, + SERVICE_BROWSE_URL, + lambda service: webbrowser.open(service.data[ATTR_URL]), + schema=SERVICE_BROWSE_URL_SCHEMA, + ) + + return True diff --git a/homeassistant/components/browser/manifest.json b/homeassistant/components/browser/manifest.json new file mode 100644 index 000000000..2905bfcfe --- /dev/null +++ b/homeassistant/components/browser/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "browser", + "name": "Browser", + "documentation": "https://www.home-assistant.io/integrations/browser", + "requirements": [], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/browser/services.yaml b/homeassistant/components/browser/services.yaml new file mode 100644 index 000000000..460def22d --- /dev/null +++ b/homeassistant/components/browser/services.yaml @@ -0,0 +1,6 @@ +browse_url: + description: Open a URL in the default browser on the host machine of Home Assistant. + fields: + url: + description: The URL to open. + example: "https://www.home-assistant.io" diff --git a/homeassistant/components/brunt/__init__.py b/homeassistant/components/brunt/__init__.py new file mode 100644 index 000000000..f89d57cde --- /dev/null +++ b/homeassistant/components/brunt/__init__.py @@ -0,0 +1 @@ +"""The brunt component.""" diff --git a/homeassistant/components/brunt/cover.py b/homeassistant/components/brunt/cover.py new file mode 100644 index 000000000..373c33394 --- /dev/null +++ b/homeassistant/components/brunt/cover.py @@ -0,0 +1,179 @@ +"""Support for Brunt Blind Engine covers.""" + +import logging + +from brunt import BruntAPI +import voluptuous as vol + +from homeassistant.components.cover import ( + ATTR_POSITION, + PLATFORM_SCHEMA, + SUPPORT_CLOSE, + SUPPORT_OPEN, + SUPPORT_SET_POSITION, + CoverDevice, +) +from homeassistant.const import ATTR_ATTRIBUTION, CONF_PASSWORD, CONF_USERNAME +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +COVER_FEATURES = SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_SET_POSITION +DEVICE_CLASS = "window" + +ATTR_REQUEST_POSITION = "request_position" +NOTIFICATION_ID = "brunt_notification" +NOTIFICATION_TITLE = "Brunt Cover Setup" +ATTRIBUTION = "Based on an unofficial Brunt SDK." + +CLOSED_POSITION = 0 +OPEN_POSITION = 100 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + {vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string} +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the brunt platform.""" + + username = config[CONF_USERNAME] + password = config[CONF_PASSWORD] + + bapi = BruntAPI(username=username, password=password) + try: + things = bapi.getThings()["things"] + if not things: + _LOGGER.error("No things present in account.") + else: + add_entities( + [ + BruntDevice(bapi, thing["NAME"], thing["thingUri"]) + for thing in things + ], + True, + ) + 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 BruntDevice(CoverDevice): + """ + Representation of a Brunt cover device. + + Contains the common logic for all Brunt devices. + """ + + def __init__(self, bapi, name, thing_uri): + """Init the Brunt device.""" + self._bapi = bapi + self._name = name + self._thing_uri = thing_uri + + self._state = {} + self._available = None + + @property + def name(self): + """Return the name of the device as reported by tellcore.""" + return self._name + + @property + def available(self): + """Could the device be accessed during the last update call.""" + return self._available + + @property + def current_cover_position(self): + """ + Return current position of cover. + + None is unknown, 0 is closed, 100 is fully open. + """ + pos = self._state.get("currentPosition") + return int(pos) if pos else None + + @property + def request_cover_position(self): + """ + Return request position of cover. + + The request position is the position of the last request + to Brunt, at times there is a diff of 1 to current + None is unknown, 0 is closed, 100 is fully open. + """ + pos = self._state.get("requestPosition") + return int(pos) if pos else None + + @property + def move_state(self): + """ + Return current moving state of cover. + + None is unknown, 0 when stopped, 1 when opening, 2 when closing + """ + mov = self._state.get("moveState") + return int(mov) if mov else None + + @property + def is_opening(self): + """Return if the cover is opening or not.""" + return self.move_state == 1 + + @property + def is_closing(self): + """Return if the cover is closing or not.""" + return self.move_state == 2 + + @property + def device_state_attributes(self): + """Return the detailed device state attributes.""" + return { + ATTR_ATTRIBUTION: ATTRIBUTION, + ATTR_REQUEST_POSITION: self.request_cover_position, + } + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return DEVICE_CLASS + + @property + def supported_features(self): + """Flag supported features.""" + return COVER_FEATURES + + @property + def is_closed(self): + """Return true if cover is closed, else False.""" + return self.current_cover_position == CLOSED_POSITION + + def update(self): + """Poll the current state of the device.""" + try: + self._state = self._bapi.getState(thingUri=self._thing_uri).get("thing") + self._available = True + except (TypeError, KeyError, NameError, ValueError) as ex: + _LOGGER.error("%s", ex) + self._available = False + + def open_cover(self, **kwargs): + """Set the cover to the open position.""" + self._bapi.changeRequestPosition(OPEN_POSITION, thingUri=self._thing_uri) + + def close_cover(self, **kwargs): + """Set the cover to the closed position.""" + self._bapi.changeRequestPosition(CLOSED_POSITION, thingUri=self._thing_uri) + + def set_cover_position(self, **kwargs): + """Set the cover to a specific position.""" + self._bapi.changeRequestPosition( + kwargs[ATTR_POSITION], thingUri=self._thing_uri + ) diff --git a/homeassistant/components/brunt/manifest.json b/homeassistant/components/brunt/manifest.json new file mode 100644 index 000000000..6ee4344b9 --- /dev/null +++ b/homeassistant/components/brunt/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "brunt", + "name": "Brunt", + "documentation": "https://www.home-assistant.io/integrations/brunt", + "requirements": [ + "brunt==0.1.3" + ], + "dependencies": [], + "codeowners": [ + "@eavanvalkenburg" + ] +} diff --git a/homeassistant/components/bt_home_hub_5/__init__.py b/homeassistant/components/bt_home_hub_5/__init__.py new file mode 100644 index 000000000..54816d655 --- /dev/null +++ b/homeassistant/components/bt_home_hub_5/__init__.py @@ -0,0 +1 @@ +"""The bt_home_hub_5 component.""" diff --git a/homeassistant/components/bt_home_hub_5/device_tracker.py b/homeassistant/components/bt_home_hub_5/device_tracker.py new file mode 100644 index 000000000..32b8e2aa0 --- /dev/null +++ b/homeassistant/components/bt_home_hub_5/device_tracker.py @@ -0,0 +1,73 @@ +"""Support for BT Home Hub 5.""" +import logging + +import bthomehub5_devicelist +import voluptuous as vol + +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__) + +CONF_DEFAULT_IP = "192.168.1.254" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + {vol.Optional(CONF_HOST, default=CONF_DEFAULT_IP): cv.string} +) + + +def get_scanner(hass, config): + """Return a BT Home Hub 5 scanner if successful.""" + scanner = BTHomeHub5DeviceScanner(config[DOMAIN]) + + return scanner if scanner.success_init else None + + +class BTHomeHub5DeviceScanner(DeviceScanner): + """This class queries a BT Home Hub 5.""" + + def __init__(self, config): + """Initialise the scanner.""" + + _LOGGER.info("Initialising BT Home Hub 5") + self.host = config[CONF_HOST] + self.last_results = {} + + # Test the router is accessible + data = bthomehub5_devicelist.get_devicelist(self.host) + self.success_init = data is not None + + def scan_devices(self): + """Scan for new devices and return a list with found device IDs.""" + self.update_info() + + return (device 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.""" + # If not initialised and not already scanned and not found. + if device not in self.last_results: + self.update_info() + + if not self.last_results: + return None + + return self.last_results.get(device) + + def update_info(self): + """Ensure the information from the BT Home Hub 5 is up to date.""" + + _LOGGER.info("Scanning") + + data = bthomehub5_devicelist.get_devicelist(self.host) + + if not data: + _LOGGER.warning("Error scanning devices") + return + + self.last_results = data diff --git a/homeassistant/components/bt_home_hub_5/manifest.json b/homeassistant/components/bt_home_hub_5/manifest.json new file mode 100644 index 000000000..bee9cefce --- /dev/null +++ b/homeassistant/components/bt_home_hub_5/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "bt_home_hub_5", + "name": "Bt home hub 5", + "documentation": "https://www.home-assistant.io/integrations/bt_home_hub_5", + "requirements": [ + "bthomehub5-devicelist==0.1.1" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/bt_smarthub/__init__.py b/homeassistant/components/bt_smarthub/__init__.py new file mode 100644 index 000000000..07419a2ba --- /dev/null +++ b/homeassistant/components/bt_smarthub/__init__.py @@ -0,0 +1 @@ +"""The bt_smarthub component.""" diff --git a/homeassistant/components/bt_smarthub/device_tracker.py b/homeassistant/components/bt_smarthub/device_tracker.py new file mode 100644 index 000000000..45b18b963 --- /dev/null +++ b/homeassistant/components/bt_smarthub/device_tracker.py @@ -0,0 +1,95 @@ +"""Support for BT Smart Hub (Sometimes referred to as BT Home Hub 6).""" +import logging + +import btsmarthub_devicelist +import voluptuous as vol + +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__) + +CONF_DEFAULT_IP = "192.168.1.254" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + {vol.Optional(CONF_HOST, default=CONF_DEFAULT_IP): cv.string} +) + + +def get_scanner(hass, config): + """Return a BT Smart Hub scanner if successful.""" + scanner = BTSmartHubScanner(config[DOMAIN]) + + return scanner if scanner.success_init else None + + +class BTSmartHubScanner(DeviceScanner): + """This class queries a BT Smart Hub.""" + + def __init__(self, config): + """Initialise the scanner.""" + _LOGGER.debug("Initialising BT Smart Hub") + self.host = config[CONF_HOST] + self.last_results = {} + self.success_init = False + + # Test the router is accessible + data = self.get_bt_smarthub_data() + if data: + self.success_init = True + else: + _LOGGER.info("Failed to connect to %s", self.host) + + 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] + + 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: + return None + for client in self.last_results: + if client["mac"] == device: + return client["host"] + return None + + def _update_info(self): + """Ensure the information from the BT Smart Hub is up to date.""" + if not self.success_init: + return + + _LOGGER.info("Scanning") + data = self.get_bt_smarthub_data() + if not data: + _LOGGER.warning("Error scanning devices") + return + + clients = list(data.values()) + self.last_results = clients + + def get_bt_smarthub_data(self): + """Retrieve data from BT Smart Hub and return parsed result.""" + + # Request data from bt smarthub into a list of dicts. + data = btsmarthub_devicelist.get_devicelist( + router_ip=self.host, only_active_devices=True + ) + # Renaming keys from parsed result. + devices = {} + for device in data: + try: + devices[device["UserHostName"]] = { + "ip": device["IPAddress"], + "mac": device["PhysAddress"], + "host": device["UserHostName"], + "status": device["Active"], + } + except KeyError: + pass + return devices diff --git a/homeassistant/components/bt_smarthub/manifest.json b/homeassistant/components/bt_smarthub/manifest.json new file mode 100644 index 000000000..985f30124 --- /dev/null +++ b/homeassistant/components/bt_smarthub/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "bt_smarthub", + "name": "Bt smarthub", + "documentation": "https://www.home-assistant.io/integrations/bt_smarthub", + "requirements": [ + "btsmarthub_devicelist==0.1.3" + ], + "dependencies": [], + "codeowners": [ + "@jxwolstenholme" + ] +} diff --git a/homeassistant/components/buienradar/__init__.py b/homeassistant/components/buienradar/__init__.py new file mode 100644 index 000000000..680351f9b --- /dev/null +++ b/homeassistant/components/buienradar/__init__.py @@ -0,0 +1 @@ +"""The buienradar component.""" diff --git a/homeassistant/components/buienradar/camera.py b/homeassistant/components/buienradar/camera.py new file mode 100644 index 000000000..3d30e330b --- /dev/null +++ b/homeassistant/components/buienradar/camera.py @@ -0,0 +1,176 @@ +"""Provide animated GIF loops of Buienradar imagery.""" +import asyncio +from datetime import datetime, timedelta +import logging +from typing import Optional + +import aiohttp +import voluptuous as vol + +from homeassistant.components.camera import PLATFORM_SCHEMA, Camera +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.util import dt as dt_util + +CONF_DIMENSION = "dimension" +CONF_DELTA = "delta" + +RADAR_MAP_URL_TEMPLATE = "https://api.buienradar.nl/image/1.0/" "RadarMapNL?w={w}&h={h}" + +_LOG = logging.getLogger(__name__) + +# Maximum range according to docs +DIM_RANGE = vol.All(vol.Coerce(int), vol.Range(min=120, max=700)) + +PLATFORM_SCHEMA = vol.All( + PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_DIMENSION, default=512): DIM_RANGE, + vol.Optional(CONF_DELTA, default=600.0): vol.All( + vol.Coerce(float), vol.Range(min=0) + ), + vol.Optional(CONF_NAME, default="Buienradar loop"): cv.string, + } + ) +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up buienradar radar-loop camera component.""" + dimension = config[CONF_DIMENSION] + delta = config[CONF_DELTA] + name = config[CONF_NAME] + + async_add_entities([BuienradarCam(name, dimension, delta)]) + + +class BuienradarCam(Camera): + """ + A camera component producing animated buienradar radar-imagery GIFs. + + Rain radar imagery camera based on image URL taken from [0]. + + [0]: https://www.buienradar.nl/overbuienradar/gratis-weerdata + """ + + def __init__(self, name: str, dimension: int, delta: float): + """ + Initialize the component. + + This constructor must be run in the event loop. + """ + super().__init__() + + self._name = name + + # dimension (x and y) of returned radar image + self._dimension = dimension + + # time a cached image stays valid for + self._delta = delta + + # Condition that guards the loading indicator. + # + # Ensures that only one reader can cause an http request at the same + # time, and that all readers are notified after this request completes. + # + # invariant: this condition is private to and owned by this instance. + self._condition = asyncio.Condition() + + self._last_image: Optional[bytes] = None + # value of the last seen last modified header + self._last_modified: Optional[str] = None + # loading status + self._loading = False + # deadline for image refresh - self.delta after last successful load + self._deadline: Optional[datetime] = None + + @property + def name(self) -> str: + """Return the component name.""" + return self._name + + def __needs_refresh(self) -> bool: + if not (self._delta and self._deadline and self._last_image): + return True + + return dt_util.utcnow() > self._deadline + + async def __retrieve_radar_image(self) -> bool: + """Retrieve new radar image and return whether this succeeded.""" + session = async_get_clientsession(self.hass) + + url = RADAR_MAP_URL_TEMPLATE.format(w=self._dimension, h=self._dimension) + + if self._last_modified: + headers = {"If-Modified-Since": self._last_modified} + else: + headers = {} + + try: + async with session.get(url, timeout=5, headers=headers) as res: + res.raise_for_status() + + if res.status == 304: + _LOG.debug("HTTP 304 - success") + return True + + last_modified = res.headers.get("Last-Modified", None) + if last_modified: + self._last_modified = last_modified + + self._last_image = await res.read() + _LOG.debug("HTTP 200 - Last-Modified: %s", last_modified) + + return True + except (asyncio.TimeoutError, aiohttp.ClientError) as err: + _LOG.error("Failed to fetch image, %s", type(err)) + return False + + async def async_camera_image(self) -> Optional[bytes]: + """ + Return a still image response from the camera. + + Uses ayncio conditions to make sure only one task enters the critical + section at the same time. Otherwise, two http requests would start + when two tabs with home assistant are open. + + The condition is entered in two sections because otherwise the lock + would be held while doing the http request. + + A boolean (_loading) is used to indicate the loading status instead of + _last_image since that is initialized to None. + + For reference: + * :func:`asyncio.Condition.wait` releases the lock and acquires it + again before continuing. + * :func:`asyncio.Condition.notify_all` requires the lock to be held. + """ + if not self.__needs_refresh(): + return self._last_image + + # get lock, check iff loading, await notification if loading + async with self._condition: + # can not be tested - mocked http response returns immediately + if self._loading: + _LOG.debug("already loading - waiting for notification") + await self._condition.wait() + return self._last_image + + # Set loading status **while holding lock**, makes other tasks wait + self._loading = True + + try: + now = dt_util.utcnow() + was_updated = await self.__retrieve_radar_image() + # was updated? Set new deadline relative to now before loading + if was_updated: + self._deadline = now + timedelta(seconds=self._delta) + + return self._last_image + finally: + # get lock, unset loading status, notify all waiting tasks + async with self._condition: + self._loading = False + self._condition.notify_all() diff --git a/homeassistant/components/buienradar/const.py b/homeassistant/components/buienradar/const.py new file mode 100644 index 000000000..b91d2497d --- /dev/null +++ b/homeassistant/components/buienradar/const.py @@ -0,0 +1,7 @@ +"""Constants for buienradar component.""" +DEFAULT_TIMEFRAME = 60 + +"""Schedule next call after (minutes).""" +SCHEDULE_OK = 10 +"""When an error occurred, new call after (minutes).""" +SCHEDULE_NOK = 2 diff --git a/homeassistant/components/buienradar/manifest.json b/homeassistant/components/buienradar/manifest.json new file mode 100644 index 000000000..cc3c3b029 --- /dev/null +++ b/homeassistant/components/buienradar/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "buienradar", + "name": "Buienradar", + "documentation": "https://www.home-assistant.io/integrations/buienradar", + "requirements": [ + "buienradar==1.0.1" + ], + "dependencies": [], + "codeowners": [ + "@mjj4791", "@ties" + ] +} diff --git a/homeassistant/components/buienradar/sensor.py b/homeassistant/components/buienradar/sensor.py new file mode 100644 index 000000000..4011a928d --- /dev/null +++ b/homeassistant/components/buienradar/sensor.py @@ -0,0 +1,451 @@ +"""Support for Buienradar.nl weather service.""" +import logging + +from buienradar.constants import ( + ATTRIBUTION, + CONDCODE, + CONDITION, + DETAILED, + EXACT, + EXACTNL, + FORECAST, + IMAGE, + MEASURED, + PRECIPITATION_FORECAST, + STATIONNAME, + TIMEFRAME, + VISIBILITY, + WINDGUST, + WINDSPEED, +) +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + ATTR_ATTRIBUTION, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_MONITORED_CONDITIONS, + CONF_NAME, + TEMP_CELSIUS, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import dt as dt_util + +from .const import DEFAULT_TIMEFRAME +from .util import BrData + +_LOGGER = logging.getLogger(__name__) + +MEASURED_LABEL = "Measured" +TIMEFRAME_LABEL = "Timeframe" +SYMBOL = "symbol" + +# Schedule next call after (minutes): +SCHEDULE_OK = 10 +# When an error occurred, new call after (minutes): +SCHEDULE_NOK = 2 + +# Supported sensor types: +# Key: ['label', unit, icon] +SENSOR_TYPES = { + "stationname": ["Stationname", None, None], + # new in json api (>1.0.0): + "barometerfc": ["Barometer value", None, "mdi:gauge"], + # new in json api (>1.0.0): + "barometerfcname": ["Barometer", None, "mdi:gauge"], + # new in json api (>1.0.0): + "barometerfcnamenl": ["Barometer", None, "mdi:gauge"], + "condition": ["Condition", None, None], + "conditioncode": ["Condition code", None, None], + "conditiondetailed": ["Detailed condition", None, None], + "conditionexact": ["Full condition", None, None], + "symbol": ["Symbol", None, None], + # new in json api (>1.0.0): + "feeltemperature": ["Feel temperature", TEMP_CELSIUS, "mdi:thermometer"], + "humidity": ["Humidity", "%", "mdi:water-percent"], + "temperature": ["Temperature", TEMP_CELSIUS, "mdi:thermometer"], + "groundtemperature": ["Ground temperature", TEMP_CELSIUS, "mdi:thermometer"], + "windspeed": ["Wind speed", "km/h", "mdi:weather-windy"], + "windforce": ["Wind force", "Bft", "mdi:weather-windy"], + "winddirection": ["Wind direction", None, "mdi:compass-outline"], + "windazimuth": ["Wind direction azimuth", "°", "mdi:compass-outline"], + "pressure": ["Pressure", "hPa", "mdi:gauge"], + "visibility": ["Visibility", "km", None], + "windgust": ["Wind gust", "km/h", "mdi:weather-windy"], + "precipitation": ["Precipitation", "mm/h", "mdi:weather-pouring"], + "irradiance": ["Irradiance", "W/m2", "mdi:sunglasses"], + "precipitation_forecast_average": [ + "Precipitation forecast average", + "mm/h", + "mdi:weather-pouring", + ], + "precipitation_forecast_total": [ + "Precipitation forecast total", + "mm", + "mdi:weather-pouring", + ], + # new in json api (>1.0.0): + "rainlast24hour": ["Rain last 24h", "mm", "mdi:weather-pouring"], + # new in json api (>1.0.0): + "rainlasthour": ["Rain last hour", "mm", "mdi:weather-pouring"], + "temperature_1d": ["Temperature 1d", TEMP_CELSIUS, "mdi:thermometer"], + "temperature_2d": ["Temperature 2d", TEMP_CELSIUS, "mdi:thermometer"], + "temperature_3d": ["Temperature 3d", TEMP_CELSIUS, "mdi:thermometer"], + "temperature_4d": ["Temperature 4d", TEMP_CELSIUS, "mdi:thermometer"], + "temperature_5d": ["Temperature 5d", TEMP_CELSIUS, "mdi:thermometer"], + "mintemp_1d": ["Minimum temperature 1d", TEMP_CELSIUS, "mdi:thermometer"], + "mintemp_2d": ["Minimum temperature 2d", TEMP_CELSIUS, "mdi:thermometer"], + "mintemp_3d": ["Minimum temperature 3d", TEMP_CELSIUS, "mdi:thermometer"], + "mintemp_4d": ["Minimum temperature 4d", TEMP_CELSIUS, "mdi:thermometer"], + "mintemp_5d": ["Minimum temperature 5d", TEMP_CELSIUS, "mdi:thermometer"], + "rain_1d": ["Rain 1d", "mm", "mdi:weather-pouring"], + "rain_2d": ["Rain 2d", "mm", "mdi:weather-pouring"], + "rain_3d": ["Rain 3d", "mm", "mdi:weather-pouring"], + "rain_4d": ["Rain 4d", "mm", "mdi:weather-pouring"], + "rain_5d": ["Rain 5d", "mm", "mdi:weather-pouring"], + # new in json api (>1.0.0): + "minrain_1d": ["Minimum rain 1d", "mm", "mdi:weather-pouring"], + "minrain_2d": ["Minimum rain 2d", "mm", "mdi:weather-pouring"], + "minrain_3d": ["Minimum rain 3d", "mm", "mdi:weather-pouring"], + "minrain_4d": ["Minimum rain 4d", "mm", "mdi:weather-pouring"], + "minrain_5d": ["Minimum rain 5d", "mm", "mdi:weather-pouring"], + # new in json api (>1.0.0): + "maxrain_1d": ["Maximum rain 1d", "mm", "mdi:weather-pouring"], + "maxrain_2d": ["Maximum rain 2d", "mm", "mdi:weather-pouring"], + "maxrain_3d": ["Maximum rain 3d", "mm", "mdi:weather-pouring"], + "maxrain_4d": ["Maximum rain 4d", "mm", "mdi:weather-pouring"], + "maxrain_5d": ["Maximum rain 5d", "mm", "mdi:weather-pouring"], + "rainchance_1d": ["Rainchance 1d", "%", "mdi:weather-pouring"], + "rainchance_2d": ["Rainchance 2d", "%", "mdi:weather-pouring"], + "rainchance_3d": ["Rainchance 3d", "%", "mdi:weather-pouring"], + "rainchance_4d": ["Rainchance 4d", "%", "mdi:weather-pouring"], + "rainchance_5d": ["Rainchance 5d", "%", "mdi:weather-pouring"], + "sunchance_1d": ["Sunchance 1d", "%", "mdi:weather-partly-cloudy"], + "sunchance_2d": ["Sunchance 2d", "%", "mdi:weather-partly-cloudy"], + "sunchance_3d": ["Sunchance 3d", "%", "mdi:weather-partly-cloudy"], + "sunchance_4d": ["Sunchance 4d", "%", "mdi:weather-partly-cloudy"], + "sunchance_5d": ["Sunchance 5d", "%", "mdi:weather-partly-cloudy"], + "windforce_1d": ["Wind force 1d", "Bft", "mdi:weather-windy"], + "windforce_2d": ["Wind force 2d", "Bft", "mdi:weather-windy"], + "windforce_3d": ["Wind force 3d", "Bft", "mdi:weather-windy"], + "windforce_4d": ["Wind force 4d", "Bft", "mdi:weather-windy"], + "windforce_5d": ["Wind force 5d", "Bft", "mdi:weather-windy"], + "windspeed_1d": ["Wind speed 1d", "km/h", "mdi:weather-windy"], + "windspeed_2d": ["Wind speed 2d", "km/h", "mdi:weather-windy"], + "windspeed_3d": ["Wind speed 3d", "km/h", "mdi:weather-windy"], + "windspeed_4d": ["Wind speed 4d", "km/h", "mdi:weather-windy"], + "windspeed_5d": ["Wind speed 5d", "km/h", "mdi:weather-windy"], + "winddirection_1d": ["Wind direction 1d", None, "mdi:compass-outline"], + "winddirection_2d": ["Wind direction 2d", None, "mdi:compass-outline"], + "winddirection_3d": ["Wind direction 3d", None, "mdi:compass-outline"], + "winddirection_4d": ["Wind direction 4d", None, "mdi:compass-outline"], + "winddirection_5d": ["Wind direction 5d", None, "mdi:compass-outline"], + "windazimuth_1d": ["Wind direction azimuth 1d", "°", "mdi:compass-outline"], + "windazimuth_2d": ["Wind direction azimuth 2d", "°", "mdi:compass-outline"], + "windazimuth_3d": ["Wind direction azimuth 3d", "°", "mdi:compass-outline"], + "windazimuth_4d": ["Wind direction azimuth 4d", "°", "mdi:compass-outline"], + "windazimuth_5d": ["Wind direction azimuth 5d", "°", "mdi:compass-outline"], + "condition_1d": ["Condition 1d", None, None], + "condition_2d": ["Condition 2d", None, None], + "condition_3d": ["Condition 3d", None, None], + "condition_4d": ["Condition 4d", None, None], + "condition_5d": ["Condition 5d", None, None], + "conditioncode_1d": ["Condition code 1d", None, None], + "conditioncode_2d": ["Condition code 2d", None, None], + "conditioncode_3d": ["Condition code 3d", None, None], + "conditioncode_4d": ["Condition code 4d", None, None], + "conditioncode_5d": ["Condition code 5d", None, None], + "conditiondetailed_1d": ["Detailed condition 1d", None, None], + "conditiondetailed_2d": ["Detailed condition 2d", None, None], + "conditiondetailed_3d": ["Detailed condition 3d", None, None], + "conditiondetailed_4d": ["Detailed condition 4d", None, None], + "conditiondetailed_5d": ["Detailed condition 5d", None, None], + "conditionexact_1d": ["Full condition 1d", None, None], + "conditionexact_2d": ["Full condition 2d", None, None], + "conditionexact_3d": ["Full condition 3d", None, None], + "conditionexact_4d": ["Full condition 4d", None, None], + "conditionexact_5d": ["Full condition 5d", None, None], + "symbol_1d": ["Symbol 1d", None, None], + "symbol_2d": ["Symbol 2d", None, None], + "symbol_3d": ["Symbol 3d", None, None], + "symbol_4d": ["Symbol 4d", None, None], + "symbol_5d": ["Symbol 5d", None, None], +} + +CONF_TIMEFRAME = "timeframe" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional( + CONF_MONITORED_CONDITIONS, default=["symbol", "temperature"] + ): vol.All(cv.ensure_list, vol.Length(min=1), [vol.In(SENSOR_TYPES.keys())]), + vol.Inclusive( + CONF_LATITUDE, "coordinates", "Latitude and longitude must exist together" + ): cv.latitude, + vol.Inclusive( + CONF_LONGITUDE, "coordinates", "Latitude and longitude must exist together" + ): cv.longitude, + vol.Optional(CONF_TIMEFRAME, default=60): vol.All( + vol.Coerce(int), vol.Range(min=5, max=120) + ), + vol.Optional(CONF_NAME, default="br"): cv.string, + } +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Create the buienradar sensor.""" + + latitude = config.get(CONF_LATITUDE, hass.config.latitude) + longitude = config.get(CONF_LONGITUDE, hass.config.longitude) + timeframe = config.get(CONF_TIMEFRAME, DEFAULT_TIMEFRAME) + + if None in (latitude, longitude): + _LOGGER.error("Latitude or longitude not set in HomeAssistant config") + return False + + coordinates = {CONF_LATITUDE: float(latitude), CONF_LONGITUDE: float(longitude)} + + _LOGGER.debug( + "Initializing buienradar sensor coordinate %s, timeframe %s", + coordinates, + timeframe, + ) + + dev = [] + for sensor_type in config[CONF_MONITORED_CONDITIONS]: + dev.append(BrSensor(sensor_type, config.get(CONF_NAME), coordinates)) + async_add_entities(dev) + + data = BrData(hass, coordinates, timeframe, dev) + # schedule the first update in 1 minute from now: + await data.schedule_update(1) + + +class BrSensor(Entity): + """Representation of an Buienradar sensor.""" + + def __init__(self, sensor_type, client_name, coordinates): + """Initialize the sensor.""" + + self.client_name = client_name + self._name = SENSOR_TYPES[sensor_type][0] + self.type = sensor_type + self._state = None + self._unit_of_measurement = SENSOR_TYPES[self.type][1] + self._entity_picture = None + self._attribution = None + self._measured = None + self._stationname = None + self._unique_id = self.uid(coordinates) + + # All continuous sensors should be forced to be updated + self._force_update = self.type != SYMBOL and not self.type.startswith(CONDITION) + + if self.type.startswith(PRECIPITATION_FORECAST): + self._timeframe = None + + def uid(self, coordinates): + """Generate a unique id using coordinates and sensor type.""" + # The combination of the location, name and sensor type is unique + return "%2.6f%2.6f%s" % ( + coordinates[CONF_LATITUDE], + coordinates[CONF_LONGITUDE], + self.type, + ) + + def load_data(self, data): + """Load the sensor with relevant data.""" + # Find sensor + + # Check if we have a new measurement, + # otherwise we do not have to update the sensor + if self._measured == data.get(MEASURED): + return False + + self._attribution = data.get(ATTRIBUTION) + self._stationname = data.get(STATIONNAME) + self._measured = data.get(MEASURED) + + if ( + self.type.endswith("_1d") + or self.type.endswith("_2d") + or self.type.endswith("_3d") + or self.type.endswith("_4d") + or self.type.endswith("_5d") + ): + + # update forcasting sensors: + fcday = 0 + if self.type.endswith("_2d"): + fcday = 1 + if self.type.endswith("_3d"): + fcday = 2 + if self.type.endswith("_4d"): + fcday = 3 + if self.type.endswith("_5d"): + fcday = 4 + + # update weather symbol & status text + if self.type.startswith(SYMBOL) or self.type.startswith(CONDITION): + try: + condition = data.get(FORECAST)[fcday].get(CONDITION) + except IndexError: + _LOGGER.warning("No forecast for fcday=%s...", fcday) + return False + + if condition: + new_state = condition.get(CONDITION, None) + if self.type.startswith(SYMBOL): + new_state = condition.get(EXACTNL, None) + if self.type.startswith("conditioncode"): + new_state = condition.get(CONDCODE, None) + if self.type.startswith("conditiondetailed"): + new_state = condition.get(DETAILED, None) + if self.type.startswith("conditionexact"): + new_state = condition.get(EXACT, None) + + img = condition.get(IMAGE, None) + + if new_state != self._state or img != self._entity_picture: + self._state = new_state + self._entity_picture = img + return True + return False + + if self.type.startswith(WINDSPEED): + # hass wants windspeeds in km/h not m/s, so convert: + try: + self._state = data.get(FORECAST)[fcday].get(self.type[:-3]) + if self._state is not None: + self._state = round(self._state * 3.6, 1) + return True + except IndexError: + _LOGGER.warning("No forecast for fcday=%s...", fcday) + return False + + # update all other sensors + try: + self._state = data.get(FORECAST)[fcday].get(self.type[:-3]) + return True + except IndexError: + _LOGGER.warning("No forecast for fcday=%s...", fcday) + return False + + if self.type == SYMBOL or self.type.startswith(CONDITION): + # update weather symbol & status text + condition = data.get(CONDITION, None) + if condition: + if self.type == SYMBOL: + new_state = condition.get(EXACTNL, None) + if self.type == CONDITION: + new_state = condition.get(CONDITION, None) + if self.type == "conditioncode": + new_state = condition.get(CONDCODE, None) + if self.type == "conditiondetailed": + new_state = condition.get(DETAILED, None) + if self.type == "conditionexact": + new_state = condition.get(EXACT, None) + + img = condition.get(IMAGE, None) + + if new_state != self._state or img != self._entity_picture: + self._state = new_state + self._entity_picture = img + return True + + return False + + if self.type.startswith(PRECIPITATION_FORECAST): + # update nested precipitation forecast sensors + nested = data.get(PRECIPITATION_FORECAST) + self._timeframe = nested.get(TIMEFRAME) + self._state = nested.get(self.type[len(PRECIPITATION_FORECAST) + 1 :]) + return True + + if self.type == WINDSPEED or self.type == WINDGUST: + # hass wants windspeeds in km/h not m/s, so convert: + self._state = data.get(self.type) + if self._state is not None: + self._state = round(data.get(self.type) * 3.6, 1) + return True + + if self.type == VISIBILITY: + # hass wants visibility in km (not m), so convert: + self._state = data.get(self.type) + if self._state is not None: + self._state = round(self._state / 1000, 1) + return True + + # update all other sensors + self._state = data.get(self.type) + return True + + @property + def attribution(self): + """Return the attribution.""" + return self._attribution + + @property + def unique_id(self): + """Return the unique id.""" + return self._unique_id + + @property + def name(self): + """Return the name of the sensor.""" + return f"{self.client_name} {self._name}" + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def entity_picture(self): + """Weather symbol if type is symbol.""" + return self._entity_picture + + @property + def device_state_attributes(self): + """Return the state attributes.""" + + if self.type.startswith(PRECIPITATION_FORECAST): + result = {ATTR_ATTRIBUTION: self._attribution} + if self._timeframe is not None: + result[TIMEFRAME_LABEL] = "%d min" % (self._timeframe) + + return result + + result = { + ATTR_ATTRIBUTION: self._attribution, + SENSOR_TYPES["stationname"][0]: self._stationname, + } + if self._measured is not None: + # convert datetime (Europe/Amsterdam) into local datetime + local_dt = dt_util.as_local(self._measured) + result[MEASURED_LABEL] = local_dt.strftime("%c") + + return result + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._unit_of_measurement + + @property + def icon(self): + """Return possible sensor specific icon.""" + return SENSOR_TYPES[self.type][2] + + @property + def force_update(self): + """Return true for continuous sensors, false for discrete sensors.""" + return self._force_update diff --git a/homeassistant/components/buienradar/util.py b/homeassistant/components/buienradar/util.py new file mode 100644 index 000000000..2ef071371 --- /dev/null +++ b/homeassistant/components/buienradar/util.py @@ -0,0 +1,225 @@ +"""Shared utilities for different supported platforms.""" +import asyncio +from datetime import datetime, timedelta +import logging + +import aiohttp +import async_timeout +from buienradar.buienradar import parse_data +from buienradar.constants import ( + ATTRIBUTION, + CONDITION, + CONTENT, + DATA, + FORECAST, + HUMIDITY, + MESSAGE, + PRESSURE, + STATIONNAME, + STATUS_CODE, + SUCCESS, + TEMPERATURE, + VISIBILITY, + WINDAZIMUTH, + WINDSPEED, +) +from buienradar.urls import JSON_FEED_URL, json_precipitation_forecast_url + +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.util import dt as dt_util + +from .const import SCHEDULE_NOK, SCHEDULE_OK + +_LOGGER = logging.getLogger(__name__) + + +class BrData: + """Get the latest data and updates the states.""" + + def __init__(self, hass, coordinates, timeframe, devices): + """Initialize the data object.""" + self.devices = devices + self.data = {} + self.hass = hass + self.coordinates = coordinates + self.timeframe = timeframe + + async def update_devices(self): + """Update all devices/sensors.""" + if self.devices: + tasks = [] + # Update all devices + for dev in self.devices: + if dev.load_data(self.data): + tasks.append(dev.async_update_ha_state()) + + if tasks: + await asyncio.wait(tasks) + + async def schedule_update(self, minute=1): + """Schedule an update after minute minutes.""" + _LOGGER.debug("Scheduling next update in %s minutes.", minute) + nxt = dt_util.utcnow() + timedelta(minutes=minute) + async_track_point_in_utc_time(self.hass, self.async_update, nxt) + + async def get_data(self, url): + """Load data from specified url.""" + _LOGGER.debug("Calling url: %s...", url) + result = {SUCCESS: False, MESSAGE: None} + resp = None + try: + websession = async_get_clientsession(self.hass) + with async_timeout.timeout(10): + resp = await websession.get(url) + + result[STATUS_CODE] = resp.status + result[CONTENT] = await resp.text() + if resp.status == 200: + result[SUCCESS] = True + else: + result[MESSAGE] = "Got http statuscode: %d" % (resp.status) + + return result + except (asyncio.TimeoutError, aiohttp.ClientError) as err: + result[MESSAGE] = "%s" % err + return result + finally: + if resp is not None: + await resp.release() + + async def async_update(self, *_): + """Update the data from buienradar.""" + + content = await self.get_data(JSON_FEED_URL) + + if content.get(SUCCESS) is not True: + # unable to get the data + _LOGGER.warning( + "Unable to retrieve json data from Buienradar." + "(Msg: %s, status: %s,)", + content.get(MESSAGE), + content.get(STATUS_CODE), + ) + # schedule new call + await self.schedule_update(SCHEDULE_NOK) + return + + # rounding coordinates prevents unnecessary redirects/calls + lat = self.coordinates[CONF_LATITUDE] + lon = self.coordinates[CONF_LONGITUDE] + rainurl = json_precipitation_forecast_url(lat, lon) + raincontent = await self.get_data(rainurl) + + if raincontent.get(SUCCESS) is not True: + # unable to get the data + _LOGGER.warning( + "Unable to retrieve raindata from Buienradar." "(Msg: %s, status: %s,)", + raincontent.get(MESSAGE), + raincontent.get(STATUS_CODE), + ) + # schedule new call + await self.schedule_update(SCHEDULE_NOK) + return + + result = parse_data( + content.get(CONTENT), + raincontent.get(CONTENT), + self.coordinates[CONF_LATITUDE], + self.coordinates[CONF_LONGITUDE], + self.timeframe, + False, + ) + + _LOGGER.debug("Buienradar parsed data: %s", result) + if result.get(SUCCESS) is not True: + if int(datetime.now().strftime("%H")) > 0: + _LOGGER.warning( + "Unable to parse data from Buienradar." "(Msg: %s)", + result.get(MESSAGE), + ) + await self.schedule_update(SCHEDULE_NOK) + return + + self.data = result.get(DATA) + await self.update_devices() + await self.schedule_update(SCHEDULE_OK) + + @property + def attribution(self): + """Return the attribution.""" + + return self.data.get(ATTRIBUTION) + + @property + def stationname(self): + """Return the name of the selected weatherstation.""" + + return self.data.get(STATIONNAME) + + @property + def condition(self): + """Return the condition.""" + + return self.data.get(CONDITION) + + @property + def temperature(self): + """Return the temperature, or None.""" + + try: + return float(self.data.get(TEMPERATURE)) + except (ValueError, TypeError): + return None + + @property + def pressure(self): + """Return the pressure, or None.""" + + try: + return float(self.data.get(PRESSURE)) + except (ValueError, TypeError): + return None + + @property + def humidity(self): + """Return the humidity, or None.""" + + try: + return int(self.data.get(HUMIDITY)) + except (ValueError, TypeError): + return None + + @property + def visibility(self): + """Return the visibility, or None.""" + + try: + return int(self.data.get(VISIBILITY)) + except (ValueError, TypeError): + return None + + @property + def wind_speed(self): + """Return the windspeed, or None.""" + + try: + return float(self.data.get(WINDSPEED)) + except (ValueError, TypeError): + return None + + @property + def wind_bearing(self): + """Return the wind bearing, or None.""" + + try: + return int(self.data.get(WINDAZIMUTH)) + except (ValueError, TypeError): + return None + + @property + def forecast(self): + """Return the forecast data.""" + + return self.data.get(FORECAST) diff --git a/homeassistant/components/buienradar/weather.py b/homeassistant/components/buienradar/weather.py new file mode 100644 index 000000000..98cbb2f5e --- /dev/null +++ b/homeassistant/components/buienradar/weather.py @@ -0,0 +1,199 @@ +"""Support for Buienradar.nl weather service.""" +import logging + +from buienradar.constants import ( + CONDCODE, + CONDITION, + DATETIME, + MAX_TEMP, + MIN_TEMP, + RAIN, + WINDAZIMUTH, + WINDSPEED, +) +import voluptuous as vol + +from homeassistant.components.weather import ( + ATTR_FORECAST_CONDITION, + ATTR_FORECAST_PRECIPITATION, + ATTR_FORECAST_TEMP, + ATTR_FORECAST_TEMP_LOW, + ATTR_FORECAST_TIME, + ATTR_FORECAST_WIND_BEARING, + ATTR_FORECAST_WIND_SPEED, + PLATFORM_SCHEMA, + WeatherEntity, +) +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, TEMP_CELSIUS +from homeassistant.helpers import config_validation as cv + +# Reuse data and API logic from the sensor implementation +from .const import DEFAULT_TIMEFRAME +from .util import BrData + +_LOGGER = logging.getLogger(__name__) + +DATA_CONDITION = "buienradar_condition" + + +CONF_FORECAST = "forecast" + + +CONDITION_CLASSES = { + "cloudy": ["c", "p"], + "fog": ["d", "n"], + "hail": [], + "lightning": ["g"], + "lightning-rainy": ["s"], + "partlycloudy": ["b", "j", "o", "r"], + "pouring": ["l", "q"], + "rainy": ["f", "h", "k", "m"], + "snowy": ["u", "i", "v", "t"], + "snowy-rainy": ["w"], + "sunny": ["a"], + "windy": [], + "windy-variant": [], + "exceptional": [], +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_LATITUDE): cv.latitude, + vol.Optional(CONF_LONGITUDE): cv.longitude, + vol.Optional(CONF_FORECAST, default=True): cv.boolean, + } +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the buienradar platform.""" + latitude = config.get(CONF_LATITUDE, hass.config.latitude) + longitude = config.get(CONF_LONGITUDE, hass.config.longitude) + + if None in (latitude, longitude): + _LOGGER.error("Latitude or longitude not set in Home Assistant config") + return False + + coordinates = {CONF_LATITUDE: float(latitude), CONF_LONGITUDE: float(longitude)} + + # create weather data: + data = BrData(hass, coordinates, DEFAULT_TIMEFRAME, None) + # create weather device: + _LOGGER.debug("Initializing buienradar weather: coordinates %s", coordinates) + + # create condition helper + if DATA_CONDITION not in hass.data: + cond_keys = [str(chr(x)) for x in range(97, 123)] + hass.data[DATA_CONDITION] = dict.fromkeys(cond_keys) + for cond, condlst in CONDITION_CLASSES.items(): + for condi in condlst: + hass.data[DATA_CONDITION][condi] = cond + + async_add_entities([BrWeather(data, config)]) + + # schedule the first update in 1 minute from now: + await data.schedule_update(1) + + +class BrWeather(WeatherEntity): + """Representation of a weather condition.""" + + def __init__(self, data, config): + """Initialise the platform with a data instance and station name.""" + self._stationname = config.get(CONF_NAME, None) + self._forecast = config.get(CONF_FORECAST) + self._data = data + + @property + def attribution(self): + """Return the attribution.""" + return self._data.attribution + + @property + def name(self): + """Return the name of the sensor.""" + return self._stationname or "BR {}".format( + self._data.stationname or "(unknown station)" + ) + + @property + def condition(self): + """Return the current condition.""" + + if self._data and self._data.condition: + ccode = self._data.condition.get(CONDCODE) + if ccode: + conditions = self.hass.data.get(DATA_CONDITION) + if conditions: + return conditions.get(ccode) + + @property + def temperature(self): + """Return the current temperature.""" + return self._data.temperature + + @property + def pressure(self): + """Return the current pressure.""" + return self._data.pressure + + @property + def humidity(self): + """Return the name of the sensor.""" + return self._data.humidity + + @property + def visibility(self): + """Return the current visibility in km.""" + if self._data.visibility is None: + return None + return round(self._data.visibility / 1000, 1) + + @property + def wind_speed(self): + """Return the current windspeed in km/h.""" + if self._data.wind_speed is None: + return None + return round(self._data.wind_speed * 3.6, 1) + + @property + def wind_bearing(self): + """Return the current wind bearing (degrees).""" + return self._data.wind_bearing + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def forecast(self): + """Return the forecast array.""" + + if not self._forecast: + return None + + fcdata_out = [] + cond = self.hass.data[DATA_CONDITION] + + if not self._data.forecast: + return None + + for data_in in self._data.forecast: + # remap keys from external library to + # keys understood by the weather component: + condcode = data_in.get(CONDITION, []).get(CONDCODE) + data_out = { + ATTR_FORECAST_TIME: data_in.get(DATETIME), + ATTR_FORECAST_CONDITION: cond[condcode], + ATTR_FORECAST_TEMP_LOW: data_in.get(MIN_TEMP), + ATTR_FORECAST_TEMP: data_in.get(MAX_TEMP), + ATTR_FORECAST_PRECIPITATION: data_in.get(RAIN), + ATTR_FORECAST_WIND_BEARING: data_in.get(WINDAZIMUTH), + ATTR_FORECAST_WIND_SPEED: round(data_in.get(WINDSPEED) * 3.6, 1), + } + + fcdata_out.append(data_out) + + return fcdata_out diff --git a/homeassistant/components/caldav/__init__.py b/homeassistant/components/caldav/__init__.py new file mode 100644 index 000000000..6fe9a8d4d --- /dev/null +++ b/homeassistant/components/caldav/__init__.py @@ -0,0 +1 @@ +"""The caldav component.""" diff --git a/homeassistant/components/caldav/calendar.py b/homeassistant/components/caldav/calendar.py new file mode 100644 index 000000000..ad9dac1f7 --- /dev/null +++ b/homeassistant/components/caldav/calendar.py @@ -0,0 +1,307 @@ +"""Support for WebDav Calendar.""" +import copy +from datetime import datetime, timedelta +import logging +import re + +import caldav +import voluptuous as vol + +from homeassistant.components.calendar import ( + ENTITY_ID_FORMAT, + PLATFORM_SCHEMA, + CalendarEventDevice, + calculate_offset, + get_date, + is_offset_reached, +) +from homeassistant.const import ( + CONF_NAME, + CONF_PASSWORD, + CONF_URL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import generate_entity_id +from homeassistant.util import Throttle, dt + +_LOGGER = logging.getLogger(__name__) + +CONF_CALENDARS = "calendars" +CONF_CUSTOM_CALENDARS = "custom_calendars" +CONF_CALENDAR = "calendar" +CONF_SEARCH = "search" + +OFFSET = "!!" + +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, [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.Required(CONF_CALENDAR): cv.string, + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_SEARCH): cv.string, + } + ) + ], + ), + vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, + } +) + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) + + +def setup_platform(hass, config, add_entities, disc_info=None): + """Set up the WebDav Calendar platform.""" + url = config[CONF_URL] + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + + client = caldav.DAVClient( + url, None, username, password, ssl_verify_cert=config[CONF_VERIFY_SSL] + ) + + 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[CONF_CALENDARS] and calendar.name not in config[CONF_CALENDARS]: + _LOGGER.debug("Ignoring calendar '%s'", calendar.name) + continue + + # Create additional calendars based on custom filtering rules + for cust_calendar in config[CONF_CUSTOM_CALENDARS]: + # Check that the base calendar matches + if cust_calendar[CONF_CALENDAR] != calendar.name: + continue + + name = cust_calendar[CONF_NAME] + device_id = "{} {}".format( + cust_calendar[CONF_CALENDAR], cust_calendar[CONF_NAME] + ) + entity_id = generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass) + calendar_devices.append( + WebDavCalendarEventDevice( + name, calendar, entity_id, True, cust_calendar[CONF_SEARCH] + ) + ) + + # Create a default calendar if there was no custom one + if not config[CONF_CUSTOM_CALENDARS]: + name = calendar.name + device_id = calendar.name + entity_id = generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass) + calendar_devices.append( + WebDavCalendarEventDevice(name, calendar, entity_id) + ) + + add_entities(calendar_devices, True) + + +class WebDavCalendarEventDevice(CalendarEventDevice): + """A device for getting the next Task from a WebDav Calendar.""" + + def __init__(self, name, calendar, entity_id, all_day=False, search=None): + """Create the WebDav Calendar Event Device.""" + self.data = WebDavCalendarData(calendar, all_day, search) + self.entity_id = entity_id + self._event = None + self._name = name + self._offset_reached = False + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + return {"offset_reached": self._offset_reached} + + @property + def event(self): + """Return the next upcoming event.""" + return self._event + + @property + def name(self): + """Return the name of the entity.""" + return self._name + + async def async_get_events(self, hass, start_date, end_date): + """Get all events in a specific time frame.""" + return await self.data.async_get_events(hass, start_date, end_date) + + def update(self): + """Update event data.""" + self.data.update() + event = copy.deepcopy(self.data.event) + if event is None: + self._event = event + return + event = calculate_offset(event, OFFSET) + self._offset_reached = is_offset_reached(event) + self._event = event + + +class WebDavCalendarData: + """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 + + async def async_get_events(self, hass, start_date, end_date): + """Get all events in a specific time frame.""" + # Get event list from the current calendar + vevent_list = await hass.async_add_job( + self.calendar.date_search, start_date, end_date + ) + event_list = [] + for event in vevent_list: + vevent = event.instance.vevent + uid = None + if hasattr(vevent, "uid"): + uid = vevent.uid.value + data = { + "uid": uid, + "title": 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"), + } + + data["start"] = get_date(data["start"]).isoformat() + data["end"] = get_date(data["end"]).isoformat() + + event_list.append(data) + + return event_list + + @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 + + # 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"), + } + + @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): + if obj.tzinfo is None: + # floating value, not bound to any time zone in particular + # represent same time regardless of which time zone is currently being observed + return obj.replace(tzinfo=dt.DEFAULT_TIME_ZONE) + 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/caldav/manifest.json b/homeassistant/components/caldav/manifest.json new file mode 100644 index 000000000..896ace7ba --- /dev/null +++ b/homeassistant/components/caldav/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "caldav", + "name": "Caldav", + "documentation": "https://www.home-assistant.io/integrations/caldav", + "requirements": [ + "caldav==0.6.1" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 9d105fb02..35b25b86d 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -1,130 +1,156 @@ -""" -Support for Google Calendar event device sensors. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/calendar/ -""" -import logging +"""Support for Google Calendar event device sensors.""" from datetime import timedelta +import logging import re from aiohttp import web -from homeassistant.components.google import ( - CONF_OFFSET, CONF_DEVICE_ID, CONF_NAME) +from homeassistant.components import http 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.config_validation import ( # noqa: F401 + PLATFORM_SCHEMA, + PLATFORM_SCHEMA_BASE, + time_period_str, +) +from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.template import DATE_STR_FORMAT from homeassistant.util import dt -from homeassistant.components import http +# mypy: allow-untyped-defs, no-check-untyped-defs _LOGGER = logging.getLogger(__name__) -DOMAIN = 'calendar' - -DEPENDENCIES = ['http'] - -ENTITY_ID_FORMAT = DOMAIN + '.{}' - +DOMAIN = "calendar" +ENTITY_ID_FORMAT = DOMAIN + ".{}" SCAN_INTERVAL = timedelta(seconds=60) async def async_setup(hass, config): """Track states and offer events for calendars.""" - component = EntityComponent( - _LOGGER, DOMAIN, hass, SCAN_INTERVAL, DOMAIN) + component = hass.data[DOMAIN] = EntityComponent( + _LOGGER, DOMAIN, hass, SCAN_INTERVAL, DOMAIN + ) hass.http.register_view(CalendarListView(component)) hass.http.register_view(CalendarEventView(component)) # Doesn't work in prod builds of the frontend: home-assistant-polymer#1289 - # await hass.components.frontend.async_register_built_in_panel( + # hass.components.frontend.async_register_built_in_panel( # 'calendar', 'calendar', 'hass:calendar') await component.async_setup(config) return True -DEFAULT_CONF_TRACK_NEW = True -DEFAULT_CONF_OFFSET = '!!' - - def get_date(date): """Get the dateTime from date or dateTime as a local.""" - if 'date' in date: - return dt.start_of_local_day(dt.dt.datetime.combine( - dt.parse_date(date['date']), dt.dt.time.min)) - return dt.as_local(dt.parse_datetime(date['dateTime'])) + if "date" in date: + return dt.start_of_local_day( + dt.dt.datetime.combine(dt.parse_date(date["date"]), dt.dt.time.min) + ) + return dt.as_local(dt.parse_datetime(date["dateTime"])) + + +def normalize_event(event): + """Normalize a calendar event.""" + normalized_event = {} + + start = event.get("start") + end = event.get("end") + start = get_date(start) if start is not None else None + end = get_date(end) if end is not None else None + normalized_event["dt_start"] = start + normalized_event["dt_end"] = end + + start = start.strftime(DATE_STR_FORMAT) if start is not None else None + end = end.strftime(DATE_STR_FORMAT) if end is not None else None + normalized_event["start"] = start + normalized_event["end"] = end + + # cleanup the string so we don't have a bunch of double+ spaces + summary = event.get("summary", "") + normalized_event["message"] = re.sub(" +", "", summary).strip() + normalized_event["location"] = event.get("location", "") + normalized_event["description"] = event.get("description", "") + normalized_event["all_day"] = "date" in event["start"] + + return normalized_event + + +def calculate_offset(event, offset): + """Calculate event offset. + + Return the updated event with the offset_time included. + """ + summary = event.get("summary", "") + # check if we have an offset tag in the message + # time is HH:MM or MM + reg = "{}([+-]?[0-9]{{0,2}}(:[0-9]{{0,2}})?)".format(offset) + search = re.search(reg, summary) + if search and search.group(1): + time = search.group(1) + if ":" not in time: + if time[0] == "+" or time[0] == "-": + time = "{}0:{}".format(time[0], time[1:]) + else: + time = "0:{}".format(time) + + offset_time = time_period_str(time) + summary = (summary[: search.start()] + summary[search.end() :]).strip() + event["summary"] = summary + else: + offset_time = dt.dt.timedelta() # default it + + event["offset_time"] = offset_time + return event + + +def is_offset_reached(event): + """Have we reached the offset time specified in the event title.""" + start = get_date(event["start"]) + if start is None or event["offset_time"] == dt.dt.timedelta(): + return False + + return start + event["offset_time"] <= dt.now(start.tzinfo) class CalendarEventDevice(Entity): """A calendar event device.""" - # Classes overloading this must set data to an object - # with an update() method - data = None - - def __init__(self, hass, data): - """Create the Calendar Event Device.""" - self._name = data.get(CONF_NAME) - self.dev_id = data.get(CONF_DEVICE_ID) - self._offset = data.get(CONF_OFFSET, DEFAULT_CONF_OFFSET) - self.entity_id = generate_entity_id( - ENTITY_ID_FORMAT, self.dev_id, hass=hass) - - self._cal_data = { - 'all_day': False, - 'offset_time': dt.dt.timedelta(), - 'message': '', - 'start': None, - 'end': None, - 'location': '', - 'description': '', - } - - self.update() - - def offset_reached(self): - """Have we reached the offset time specified in the event title.""" - if self._cal_data['start'] is None or \ - self._cal_data['offset_time'] == dt.dt.timedelta(): - return False - - return self._cal_data['start'] + self._cal_data['offset_time'] <= \ - dt.now(self._cal_data['start'].tzinfo) + @property + def event(self): + """Return the next upcoming event.""" + raise NotImplementedError() @property - def name(self): - """Return the name of the entity.""" - return self._name - - @property - def device_state_attributes(self): - """Return the device state attributes.""" - start = self._cal_data.get('start', None) - end = self._cal_data.get('end', None) - start = start.strftime(DATE_STR_FORMAT) if start is not None else None - end = end.strftime(DATE_STR_FORMAT) if end is not None else None + def state_attributes(self): + """Return the entity state attributes.""" + event = self.event + if event is None: + return None + event = normalize_event(event) return { - 'message': self._cal_data.get('message', ''), - 'all_day': self._cal_data.get('all_day', False), - 'offset_reached': self.offset_reached(), - 'start_time': start, - 'end_time': end, - 'location': self._cal_data.get('location', None), - 'description': self._cal_data.get('description', None), + "message": event["message"], + "all_day": event["all_day"], + "start_time": event["start"], + "end_time": event["end"], + "location": event["location"], + "description": event["description"], } @property def state(self): """Return the state of the calendar event.""" - start = self._cal_data.get('start', None) - end = self._cal_data.get('end', None) + event = self.event + if event is None: + return STATE_OFF + + event = normalize_event(event) + start = event["dt_start"] + end = event["dt_end"] + if start is None or end is None: return STATE_OFF @@ -133,72 +159,18 @@ class CalendarEventDevice(Entity): if start <= now < end: return STATE_ON - if now >= end: - self.cleanup() - return STATE_OFF - def cleanup(self): - """Cleanup any start/end listeners that were setup.""" - self._cal_data = { - 'all_day': False, - 'offset_time': 0, - 'message': '', - 'start': None, - 'end': None, - 'location': None, - 'description': None - } - - def update(self): - """Search for the next event.""" - if not self.data or not self.data.update(): - # update cached, don't do anything - return - - if not self.data.event: - # we have no event to work on, make sure we're clean - self.cleanup() - return - - start = get_date(self.data.event['start']) - end = get_date(self.data.event['end']) - - summary = self.data.event.get('summary', '') - - # check if we have an offset tag in the message - # time is HH:MM or MM - reg = '{}([+-]?[0-9]{{0,2}}(:[0-9]{{0,2}})?)'.format(self._offset) - search = re.search(reg, summary) - if search and search.group(1): - time = search.group(1) - if ':' not in time: - if time[0] == '+' or time[0] == '-': - time = '{}0:{}'.format(time[0], time[1:]) - else: - time = '0:{}'.format(time) - - offset_time = time_period_str(time) - summary = (summary[:search.start()] + summary[search.end():]) \ - .strip() - else: - offset_time = dt.dt.timedelta() # default it - - # cleanup the string so we don't have a bunch of double+ spaces - self._cal_data['message'] = re.sub(' +', '', summary).strip() - self._cal_data['offset_time'] = offset_time - self._cal_data['location'] = self.data.event.get('location', '') - self._cal_data['description'] = self.data.event.get('description', '') - self._cal_data['start'] = start - self._cal_data['end'] = end - self._cal_data['all_day'] = 'date' in self.data.event['start'] + async def async_get_events(self, hass, start_date, end_date): + """Return calendar events within a datetime range.""" + raise NotImplementedError() class CalendarEventView(http.HomeAssistantView): """View to retrieve calendar content.""" - url = '/api/calendars/{entity_id}' - name = 'api:calendars:calendar' + url = "/api/calendars/{entity_id}" + name = "api:calendars:calendar" def __init__(self, component): """Initialize calendar view.""" @@ -207,8 +179,8 @@ class CalendarEventView(http.HomeAssistantView): async def get(self, request, entity_id): """Return calendar events.""" entity = self.component.get_entity(entity_id) - start = request.query.get('start') - end = request.query.get('end') + start = request.query.get("start") + end = request.query.get("end") if None in (start, end, entity): return web.Response(status=400) try: @@ -217,14 +189,15 @@ class CalendarEventView(http.HomeAssistantView): except (ValueError, AttributeError): return web.Response(status=400) event_list = await entity.async_get_events( - request.app['hass'], start_date, end_date) + request.app["hass"], start_date, end_date + ) return self.json(event_list) class CalendarListView(http.HomeAssistantView): """View to retrieve calendar list.""" - url = '/api/calendars' + url = "/api/calendars" name = "api:calendars" def __init__(self, component): @@ -233,14 +206,11 @@ class CalendarListView(http.HomeAssistantView): async def get(self, request): """Retrieve calendar list.""" - get_state = request.app['hass'].states.get + hass = request.app["hass"] calendar_list = [] for entity in self.component.entities: - state = get_state(entity.entity_id) - calendar_list.append({ - "name": state.name, - "entity_id": entity.entity_id, - }) + state = hass.states.get(entity.entity_id) + calendar_list.append({"name": state.name, "entity_id": entity.entity_id}) - return self.json(sorted(calendar_list, key=lambda x: x['name'])) + return self.json(sorted(calendar_list, key=lambda x: x["name"])) diff --git a/homeassistant/components/calendar/caldav.py b/homeassistant/components/calendar/caldav.py deleted file mode 100644 index cb8874a81..000000000 --- a/homeassistant/components/calendar/caldav.py +++ /dev/null @@ -1,266 +0,0 @@ -""" -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, get_date) -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_entities, 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_entities(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 - - async def async_get_events(self, hass, start_date, end_date): - """Get all events in a specific time frame.""" - return await self.data.async_get_events(hass, start_date, end_date) - - -class WebDavCalendarData: - """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 - - async def async_get_events(self, hass, start_date, end_date): - """Get all events in a specific time frame.""" - # Get event list from the current calendar - vevent_list = await hass.async_add_job(self.calendar.date_search, - start_date, end_date) - event_list = [] - for event in vevent_list: - vevent = event.instance.vevent - uid = None - if hasattr(vevent, 'uid'): - uid = vevent.uid.value - data = { - "uid": uid, - "title": 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"), - } - - data['start'] = get_date(data['start']).isoformat() - data['end'] = get_date(data['end']).isoformat() - - event_list.append(data) - - return event_list - - @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 deleted file mode 100644 index bf9d4abeb..000000000 --- a/homeassistant/components/calendar/demo.py +++ /dev/null @@ -1,98 +0,0 @@ -""" -Demo platform that has two fake binary sensors. - -For more details about this platform, please refer to the documentation -https://home-assistant.io/components/demo/ -""" -import copy - -import homeassistant.util.dt as dt_util -from homeassistant.components.calendar import CalendarEventDevice, get_date -from homeassistant.components.google import CONF_DEVICE_ID, CONF_NAME - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Demo Calendar platform.""" - calendar_data_future = DemoGoogleCalendarDataFuture() - calendar_data_current = DemoGoogleCalendarDataCurrent() - add_entities([ - DemoGoogleCalendar(hass, calendar_data_future, { - CONF_NAME: 'Calendar 1', - CONF_DEVICE_ID: 'calendar_1', - }), - - DemoGoogleCalendar(hass, calendar_data_current, { - CONF_NAME: 'Calendar 2', - CONF_DEVICE_ID: 'calendar_2', - }), - ]) - - -class DemoGoogleCalendarData: - """Representation of a Demo Calendar element.""" - - event = {} - - # pylint: disable=no-self-use - def update(self): - """Return true so entity knows we have new data.""" - return True - - async def async_get_events(self, hass, start_date, end_date): - """Get all events in a specific time frame.""" - event = copy.copy(self.event) - event['title'] = event['summary'] - event['start'] = get_date(event['start']).isoformat() - event['end'] = get_date(event['end']).isoformat() - return [event] - - -class DemoGoogleCalendarDataFuture(DemoGoogleCalendarData): - """Representation of a Demo Calendar for a future event.""" - - def __init__(self): - """Set the event to a future event.""" - one_hour_from_now = dt_util.now() \ - + dt_util.dt.timedelta(minutes=30) - self.event = { - 'start': { - 'dateTime': one_hour_from_now.isoformat() - }, - 'end': { - 'dateTime': (one_hour_from_now + dt_util.dt. - timedelta(minutes=60)).isoformat() - }, - 'summary': 'Future Event', - } - - -class DemoGoogleCalendarDataCurrent(DemoGoogleCalendarData): - """Representation of a Demo Calendar for a current event.""" - - def __init__(self): - """Set the event data.""" - middle_of_event = dt_util.now() \ - - dt_util.dt.timedelta(minutes=30) - self.event = { - 'start': { - 'dateTime': middle_of_event.isoformat() - }, - 'end': { - 'dateTime': (middle_of_event + dt_util.dt. - timedelta(minutes=60)).isoformat() - }, - 'summary': 'Current Event', - } - - -class DemoGoogleCalendar(CalendarEventDevice): - """Representation of a Demo Calendar element.""" - - def __init__(self, hass, calendar_data, data): - """Initialize Google Calendar but without the API calls.""" - self.data = calendar_data - super().__init__(hass, data) - - async def async_get_events(self, hass, start_date, end_date): - """Get all events in a specific time frame.""" - return await self.data.async_get_events(hass, start_date, end_date) diff --git a/homeassistant/components/calendar/google.py b/homeassistant/components/calendar/google.py deleted file mode 100644 index 041b98dc2..000000000 --- a/homeassistant/components/calendar/google.py +++ /dev/null @@ -1,128 +0,0 @@ -""" -Support for Google Calendar Search binary sensors. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/calendar.google/ -""" -import logging -from datetime import timedelta - -from homeassistant.components.calendar import CalendarEventDevice -from homeassistant.components.google import ( - CONF_CAL_ID, CONF_ENTITIES, CONF_TRACK, TOKEN_FILE, - CONF_IGNORE_AVAILABILITY, CONF_SEARCH, - GoogleCalendarService) -from homeassistant.util import Throttle, dt - -_LOGGER = logging.getLogger(__name__) - -DEFAULT_GOOGLE_SEARCH_PARAMS = { - 'orderBy': 'startTime', - 'maxResults': 5, - 'singleEvents': True, -} - -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) - - -def setup_platform(hass, config, add_entities, disc_info=None): - """Set up the calendar platform for event devices.""" - if disc_info is None: - return - - if not any(data[CONF_TRACK] for data in disc_info[CONF_ENTITIES]): - return - - calendar_service = GoogleCalendarService(hass.config.path(TOKEN_FILE)) - add_entities([GoogleCalendarEventDevice(hass, calendar_service, - disc_info[CONF_CAL_ID], data) - for data in disc_info[CONF_ENTITIES] if data[CONF_TRACK]]) - - -class GoogleCalendarEventDevice(CalendarEventDevice): - """A calendar event device.""" - - def __init__(self, hass, calendar_service, calendar, data): - """Create the Calendar event device.""" - self.data = GoogleCalendarData(calendar_service, calendar, - data.get(CONF_SEARCH), - data.get(CONF_IGNORE_AVAILABILITY)) - - super().__init__(hass, data) - - async def async_get_events(self, hass, start_date, end_date): - """Get all events in a specific time frame.""" - return await self.data.async_get_events(hass, start_date, end_date) - - -class GoogleCalendarData: - """Class to utilize calendar service object to get next event.""" - - 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 - - def _prepare_query(self): - 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['calendarId'] = self.calendar_id - if self.search: - params['q'] = self.search - - return service, params - - async def async_get_events(self, hass, start_date, end_date): - """Get all events in a specific time frame.""" - service, params = await hass.async_add_job(self._prepare_query) - params['timeMin'] = start_date.isoformat('T') - params['timeMax'] = end_date.isoformat('T') - - events = await hass.async_add_job(service.events) - result = await hass.async_add_job(events.list(**params).execute) - - items = result.get('items', []) - event_list = [] - for item in items: - if (not self.ignore_availability - and 'transparency' in item.keys()): - if item['transparency'] == 'opaque': - event_list.append(item) - else: - event_list.append(item) - return event_list - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): - """Get the latest data.""" - service, params = self._prepare_query() - params['timeMin'] = dt.now().isoformat('T') - - events = service.events() - result = events.list(**params).execute() - - items = result.get('items', []) - - 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/manifest.json b/homeassistant/components/calendar/manifest.json new file mode 100644 index 000000000..3e6ee8422 --- /dev/null +++ b/homeassistant/components/calendar/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "calendar", + "name": "Calendar", + "documentation": "https://www.home-assistant.io/integrations/calendar", + "requirements": [], + "dependencies": [ + "http" + ], + "codeowners": [] +} diff --git a/homeassistant/components/calendar/services.yaml b/homeassistant/components/calendar/services.yaml index ebf0c7b15..d8a0575bc 100644 --- a/homeassistant/components/calendar/services.yaml +++ b/homeassistant/components/calendar/services.yaml @@ -1,26 +1,2 @@ # 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 deleted file mode 100644 index b5eaed4e6..000000000 --- a/homeassistant/components/calendar/todoist.py +++ /dev/null @@ -1,579 +0,0 @@ -""" -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_entities, 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_entities(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] = [] - - async def async_get_events(self, hass, start_date, end_date): - """Get all events in a specific time frame.""" - return await self.data.async_get_events(hass, start_date, end_date) - - @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: - """ - 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 - - async def async_get_events(self, hass, start_date, end_date): - """Get all tasks in a specific time frame.""" - 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] - - events = [] - time_format = '%a %d %b %Y %H:%M:%S %z' - for task in project_task_data: - due_date = datetime.strptime(task['due_date_utc'], time_format) - if start_date < due_date < end_date: - event = { - 'uid': task['id'], - 'title': task['content'], - 'start': due_date.isoformat(), - 'end': due_date.isoformat(), - 'allDay': True, - } - events.append(event) - return events - - @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 a8a486013..b3d593578 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -1,78 +1,117 @@ -""" -Component to interface with cameras. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/camera/ -""" +"""Component to interface with cameras.""" import asyncio import base64 import collections from contextlib import suppress from datetime import timedelta -import logging import hashlib +import logging from random import SystemRandom -import attr from aiohttp import web import async_timeout +import attr import voluptuous as vol +from homeassistant.components import websocket_api +from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView +from homeassistant.components.media_player.const import ( + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, + DOMAIN as DOMAIN_MP, + SERVICE_PLAY_MEDIA, +) +from homeassistant.components.stream import request_stream +from homeassistant.components.stream.const import ( + CONF_DURATION, + CONF_LOOKBACK, + CONF_STREAM_SOURCE, + DOMAIN as DOMAIN_STREAM, + FORMAT_CONTENT_TYPE, + OUTPUT_FORMATS, + SERVICE_RECORD, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_FILENAME, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) from homeassistant.core import callback -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, \ - SERVICE_TURN_ON from homeassistant.exceptions import HomeAssistantError -from homeassistant.loader import bind_hass +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.config_validation import ( # noqa: F401 + PLATFORM_SCHEMA, + PLATFORM_SCHEMA_BASE, +) from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa -from homeassistant.components.http import HomeAssistantView, KEY_AUTHENTICATED -from homeassistant.components import websocket_api -import homeassistant.helpers.config_validation as cv +from homeassistant.loader import bind_hass +from homeassistant.setup import async_when_setup -DOMAIN = 'camera' -DEPENDENCIES = ['http'] +from .const import DATA_CAMERA_PREFS, DOMAIN +from .prefs import CameraPreferences + +# mypy: allow-untyped-calls, allow-untyped-defs _LOGGER = logging.getLogger(__name__) -SERVICE_ENABLE_MOTION = 'enable_motion_detection' -SERVICE_DISABLE_MOTION = 'disable_motion_detection' -SERVICE_SNAPSHOT = 'snapshot' +SERVICE_ENABLE_MOTION = "enable_motion_detection" +SERVICE_DISABLE_MOTION = "disable_motion_detection" +SERVICE_SNAPSHOT = "snapshot" +SERVICE_PLAY_STREAM = "play_stream" SCAN_INTERVAL = timedelta(seconds=30) -ENTITY_ID_FORMAT = DOMAIN + '.{}' +ENTITY_ID_FORMAT = DOMAIN + ".{}" -ATTR_FILENAME = 'filename' +ATTR_FILENAME = "filename" +ATTR_MEDIA_PLAYER = "media_player" +ATTR_FORMAT = "format" -STATE_RECORDING = 'recording' -STATE_STREAMING = 'streaming' -STATE_IDLE = 'idle' +STATE_RECORDING = "recording" +STATE_STREAMING = "streaming" +STATE_IDLE = "idle" # Bitfield of features supported by the camera entity SUPPORT_ON_OFF = 1 +SUPPORT_STREAM = 2 -DEFAULT_CONTENT_TYPE = 'image/jpeg' -ENTITY_IMAGE_URL = '/api/camera_proxy/{0}?token={1}' +DEFAULT_CONTENT_TYPE = "image/jpeg" +ENTITY_IMAGE_URL = "/api/camera_proxy/{0}?token={1}" TOKEN_CHANGE_INTERVAL = timedelta(minutes=5) _RND = SystemRandom() -FALLBACK_STREAM_INTERVAL = 1 # seconds MIN_STREAM_INTERVAL = 0.5 # seconds -CAMERA_SERVICE_SCHEMA = vol.Schema({ - vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, -}) +CAMERA_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids}) -CAMERA_SERVICE_SNAPSHOT = CAMERA_SERVICE_SCHEMA.extend({ - vol.Required(ATTR_FILENAME): cv.template -}) +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({ - vol.Required('type'): WS_TYPE_CAMERA_THUMBNAIL, - vol.Required('entity_id'): cv.entity_id -}) +CAMERA_SERVICE_PLAY_STREAM = CAMERA_SERVICE_SCHEMA.extend( + { + vol.Required(ATTR_MEDIA_PLAYER): cv.entities_domain(DOMAIN_MP), + vol.Optional(ATTR_FORMAT, default="hls"): vol.In(OUTPUT_FORMATS), + } +) + +CAMERA_SERVICE_RECORD = CAMERA_SERVICE_SCHEMA.extend( + { + vol.Required(CONF_FILENAME): cv.template, + vol.Optional(CONF_DURATION, default=30): vol.Coerce(int), + vol.Optional(CONF_LOOKBACK, default=0): vol.Coerce(int), + } +) + +WS_TYPE_CAMERA_THUMBNAIL = "camera_thumbnail" +SCHEMA_WS_CAMERA_THUMBNAIL = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( + { + vol.Required("type"): WS_TYPE_CAMERA_THUMBNAIL, + vol.Required("entity_id"): cv.entity_id, + } +) @attr.s @@ -84,59 +123,20 @@ class Image: @bind_hass -def turn_off(hass, entity_id=None): - """Turn off camera.""" - hass.add_job(async_turn_off, hass, entity_id) +async def async_request_stream(hass, entity_id, fmt): + """Request a stream for a camera entity.""" + camera = _get_camera_from_entity_id(hass, entity_id) + camera_prefs = hass.data[DATA_CAMERA_PREFS].get(entity_id) + async with async_timeout.timeout(10): + source = await camera.stream_source() -@bind_hass -async def async_turn_off(hass, entity_id=None): - """Turn off camera.""" - data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} - await hass.services.async_call(DOMAIN, SERVICE_TURN_OFF, data) + if not source: + raise HomeAssistantError( + f"{camera.entity_id} does not support play stream service" + ) - -@bind_hass -def turn_on(hass, entity_id=None): - """Turn on camera.""" - hass.add_job(async_turn_on, hass, entity_id) - - -@bind_hass -async def async_turn_on(hass, entity_id=None): - """Turn on camera, and set operation mode.""" - data = {} - if entity_id is not None: - data[ATTR_ENTITY_ID] = entity_id - - await hass.services.async_call(DOMAIN, SERVICE_TURN_ON, data) - - -@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_ENABLE_MOTION, data)) - - -@bind_hass -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_DISABLE_MOTION, data)) - - -@bind_hass -@callback -def async_snapshot(hass, filename, entity_id=None): - """Make a snapshot from a camera.""" - data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} - data[ATTR_FILENAME] = filename - - hass.async_add_job(hass.services.async_call( - DOMAIN, SERVICE_SNAPSHOT, data)) + return request_stream(hass, source, fmt=fmt, keepalive=camera_prefs.preload_stream) @bind_hass @@ -145,13 +145,13 @@ async def async_get_image(hass, entity_id, timeout=10): camera = _get_camera_from_entity_id(hass, entity_id) with suppress(asyncio.CancelledError, asyncio.TimeoutError): - with async_timeout.timeout(timeout, loop=hass.loop): + async with async_timeout.timeout(timeout): image = await camera.async_camera_image() if image: return Image(camera.content_type, image) - raise HomeAssistantError('Unable to get image') + raise HomeAssistantError("Unable to get image") @bind_hass @@ -168,18 +168,21 @@ async def async_get_still_stream(request, image_cb, content_type, interval): This method must be run in the event loop. """ response = web.StreamResponse() - response.content_type = ('multipart/x-mixed-replace; ' - 'boundary=--frameboundary') + response.content_type = "multipart/x-mixed-replace; " "boundary=--frameboundary" await response.prepare(request) async def write_to_mjpeg_stream(img_bytes): """Write image to stream.""" - await response.write(bytes( - '--frameboundary\r\n' - 'Content-Type: {}\r\n' - 'Content-Length: {}\r\n\r\n'.format( - content_type, len(img_bytes)), - 'utf-8') + img_bytes + b'\r\n') + await response.write( + bytes( + "--frameboundary\r\n" + "Content-Type: {}\r\n" + "Content-Length: {}\r\n\r\n".format(content_type, len(img_bytes)), + "utf-8", + ) + + img_bytes + + b"\r\n" + ) last_image = None @@ -207,62 +210,87 @@ def _get_camera_from_entity_id(hass, entity_id): component = hass.data.get(DOMAIN) if component is None: - raise HomeAssistantError('Camera component not set up') + raise HomeAssistantError("Camera integration not set up") camera = component.get_entity(entity_id) if camera is None: - raise HomeAssistantError('Camera not found') + raise HomeAssistantError("Camera not found") if not camera.is_on: - raise HomeAssistantError('Camera is off') + raise HomeAssistantError("Camera is off") return camera async def async_setup(hass, config): """Set up the camera component.""" - component = hass.data[DOMAIN] = \ - EntityComponent(_LOGGER, DOMAIN, hass, SCAN_INTERVAL) + component = hass.data[DOMAIN] = EntityComponent( + _LOGGER, DOMAIN, hass, SCAN_INTERVAL + ) + + prefs = CameraPreferences(hass) + await prefs.async_initialize() + hass.data[DATA_CAMERA_PREFS] = prefs 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 + WS_TYPE_CAMERA_THUMBNAIL, websocket_camera_thumbnail, SCHEMA_WS_CAMERA_THUMBNAIL ) + hass.components.websocket_api.async_register_command(ws_camera_stream) + hass.components.websocket_api.async_register_command(websocket_get_prefs) + hass.components.websocket_api.async_register_command(websocket_update_prefs) await component.async_setup(config) + async def preload_stream(hass, _): + for camera in component.entities: + camera_prefs = prefs.get(camera.entity_id) + if not camera_prefs.preload_stream: + continue + + async with async_timeout.timeout(10): + source = await camera.stream_source() + + if not source: + continue + + request_stream(hass, source, keepalive=True) + + async_when_setup(hass, DOMAIN_STREAM, preload_stream) + @callback def update_tokens(time): """Update tokens of the entities.""" for entity in component.entities: entity.async_update_token() - hass.async_add_job(entity.async_update_ha_state()) + hass.async_create_task(entity.async_update_ha_state()) - hass.helpers.event.async_track_time_interval( - update_tokens, TOKEN_CHANGE_INTERVAL) + hass.helpers.event.async_track_time_interval(update_tokens, TOKEN_CHANGE_INTERVAL) component.async_register_entity_service( - SERVICE_ENABLE_MOTION, CAMERA_SERVICE_SCHEMA, - 'async_enable_motion_detection' + SERVICE_ENABLE_MOTION, CAMERA_SERVICE_SCHEMA, "async_enable_motion_detection" ) component.async_register_entity_service( - SERVICE_DISABLE_MOTION, CAMERA_SERVICE_SCHEMA, - 'async_disable_motion_detection' + SERVICE_DISABLE_MOTION, CAMERA_SERVICE_SCHEMA, "async_disable_motion_detection" ) component.async_register_entity_service( - SERVICE_TURN_OFF, CAMERA_SERVICE_SCHEMA, - 'async_turn_off' + SERVICE_TURN_OFF, CAMERA_SERVICE_SCHEMA, "async_turn_off" ) component.async_register_entity_service( - SERVICE_TURN_ON, CAMERA_SERVICE_SCHEMA, - 'async_turn_on' + SERVICE_TURN_ON, CAMERA_SERVICE_SCHEMA, "async_turn_on" ) component.async_register_entity_service( - SERVICE_SNAPSHOT, CAMERA_SERVICE_SNAPSHOT, - async_handle_snapshot_service + SERVICE_SNAPSHOT, CAMERA_SERVICE_SNAPSHOT, async_handle_snapshot_service + ) + component.async_register_entity_service( + SERVICE_PLAY_STREAM, + CAMERA_SERVICE_PLAY_STREAM, + async_handle_play_stream_service, + ) + component.async_register_entity_service( + SERVICE_RECORD, CAMERA_SERVICE_RECORD, async_handle_record_service ) return True @@ -285,7 +313,7 @@ class Camera(Entity): """Initialize a camera.""" self.is_streaming = False self.content_type = DEFAULT_CONTENT_TYPE - self.access_tokens = collections.deque([], 2) + self.access_tokens: collections.deque = collections.deque([], 2) self.async_update_token() @property @@ -328,6 +356,10 @@ class Camera(Entity): """Return the interval between frames of the mjpeg stream.""" return 0.5 + async def stream_source(self): + """Return the source of the stream.""" + return None + def camera_image(self): """Return bytes of camera image.""" raise NotImplementedError() @@ -345,8 +377,9 @@ class Camera(Entity): This method must be run in the event loop. """ - return await async_get_still_stream(request, self.async_camera_image, - self.content_type, interval) + return await async_get_still_stream( + request, self.async_camera_image, self.content_type, interval + ) async def handle_async_mjpeg_stream(self, request): """Serve an HTTP MJPEG stream from the camera. @@ -355,7 +388,7 @@ class Camera(Entity): 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) + return await self.handle_async_still_stream(request, self.frame_interval) @property def state(self): @@ -410,18 +443,16 @@ class Camera(Entity): @property def state_attributes(self): """Return the camera state attributes.""" - attrs = { - 'access_token': self.access_tokens[-1], - } + attrs = {"access_token": self.access_tokens[-1]} if self.model: - attrs['model_name'] = self.model + attrs["model_name"] = self.model if self.brand: - attrs['brand'] = self.brand + attrs["brand"] = self.brand if self.motion_detection_enabled: - attrs['motion_detection'] = self.motion_detection_enabled + attrs["motion_detection"] = self.motion_detection_enabled return attrs @@ -429,8 +460,8 @@ class Camera(Entity): def async_update_token(self): """Update the used token.""" self.access_tokens.append( - hashlib.sha256( - _RND.getrandbits(256).to_bytes(32, 'little')).hexdigest()) + hashlib.sha256(_RND.getrandbits(256).to_bytes(32, "little")).hexdigest() + ) class CameraView(HomeAssistantView): @@ -449,14 +480,16 @@ class CameraView(HomeAssistantView): if camera is None: raise web.HTTPNotFound() - authenticated = (request[KEY_AUTHENTICATED] or - request.query.get('token') in camera.access_tokens) + authenticated = ( + request[KEY_AUTHENTICATED] + or request.query.get("token") in camera.access_tokens + ) if not authenticated: raise web.HTTPUnauthorized() if not camera.is_on: - _LOGGER.debug('Camera is off.') + _LOGGER.debug("Camera is off.") raise web.HTTPServiceUnavailable() return await self.handle(request, camera) @@ -469,18 +502,17 @@ class CameraView(HomeAssistantView): class CameraImageView(CameraView): """Camera view to serve an image.""" - url = '/api/camera_proxy/{entity_id}' - name = 'api:camera:image' + url = "/api/camera_proxy/{entity_id}" + name = "api:camera:image" async def handle(self, request, camera): """Serve camera image.""" with suppress(asyncio.CancelledError, asyncio.TimeoutError): - with async_timeout.timeout(10, loop=request.app['hass'].loop): + async with async_timeout.timeout(10): image = await camera.async_camera_image() if image: - return web.Response(body=image, - content_type=camera.content_type) + return web.Response(body=image, content_type=camera.content_type) raise web.HTTPInternalServerError() @@ -488,47 +520,118 @@ class CameraImageView(CameraView): class CameraMjpegStream(CameraView): """Camera View to serve an MJPEG stream.""" - url = '/api/camera_proxy_stream/{entity_id}' - name = 'api:camera:stream' + url = "/api/camera_proxy_stream/{entity_id}" + name = "api:camera:stream" async def handle(self, request, camera): """Serve camera stream, possibly with interval.""" - interval = request.query.get('interval') + interval = request.query.get("interval") if interval is None: return await camera.handle_async_mjpeg_stream(request) try: # Compose camera stream from stills - interval = float(request.query.get('interval')) + interval = float(request.query.get("interval")) if interval < MIN_STREAM_INTERVAL: - raise ValueError("Stream interval must be be > {}" - .format(MIN_STREAM_INTERVAL)) + raise ValueError(f"Stream interval must be be > {MIN_STREAM_INTERVAL}") return await camera.handle_async_still_stream(request, interval) except ValueError: raise web.HTTPBadRequest() -@callback -def websocket_camera_thumbnail(hass, connection, msg): +@websocket_api.async_response +async 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')) + try: + image = await async_get_image(hass, msg["entity_id"]) + await connection.send_big_result( + msg["id"], + { + "content_type": image.content_type, + "content": base64.b64encode(image.content).decode("utf-8"), + }, + ) + except HomeAssistantError: + connection.send_message( + websocket_api.error_message( + msg["id"], "image_fetch_failed", "Unable to fetch image" + ) + ) - hass.async_add_job(send_camera_still()) + +@websocket_api.async_response +@websocket_api.websocket_command( + { + vol.Required("type"): "camera/stream", + vol.Required("entity_id"): cv.entity_id, + vol.Optional("format", default="hls"): vol.In(OUTPUT_FORMATS), + } +) +async def ws_camera_stream(hass, connection, msg): + """Handle get camera stream websocket command. + + Async friendly. + """ + try: + entity_id = msg["entity_id"] + camera = _get_camera_from_entity_id(hass, entity_id) + camera_prefs = hass.data[DATA_CAMERA_PREFS].get(entity_id) + + async with async_timeout.timeout(10): + source = await camera.stream_source() + + if not source: + raise HomeAssistantError( + f"{camera.entity_id} does not support play stream service" + ) + + fmt = msg["format"] + url = request_stream( + hass, source, fmt=fmt, keepalive=camera_prefs.preload_stream + ) + connection.send_result(msg["id"], {"url": url}) + except HomeAssistantError as ex: + _LOGGER.error("Error requesting stream: %s", ex) + connection.send_error(msg["id"], "start_stream_failed", str(ex)) + except asyncio.TimeoutError: + _LOGGER.error("Timeout getting stream source") + connection.send_error( + msg["id"], "start_stream_failed", "Timeout getting stream source" + ) + + +@websocket_api.async_response +@websocket_api.websocket_command( + {vol.Required("type"): "camera/get_prefs", vol.Required("entity_id"): cv.entity_id} +) +async def websocket_get_prefs(hass, connection, msg): + """Handle request for account info.""" + prefs = hass.data[DATA_CAMERA_PREFS].get(msg["entity_id"]) + connection.send_result(msg["id"], prefs.as_dict()) + + +@websocket_api.async_response +@websocket_api.websocket_command( + { + vol.Required("type"): "camera/update_prefs", + vol.Required("entity_id"): cv.entity_id, + vol.Optional("preload_stream"): bool, + } +) +async def websocket_update_prefs(hass, connection, msg): + """Handle request for account info.""" + prefs = hass.data[DATA_CAMERA_PREFS] + + changes = dict(msg) + changes.pop("id") + changes.pop("type") + entity_id = changes.pop("entity_id") + await prefs.async_update(entity_id, **changes) + + connection.send_result(msg["id"], prefs.get(entity_id).as_dict()) async def async_handle_snapshot_service(camera, service): @@ -537,24 +640,73 @@ async def async_handle_snapshot_service(camera, service): filename = service.data[ATTR_FILENAME] filename.hass = hass - snapshot_file = filename.async_render( - variables={ATTR_ENTITY_ID: camera}) + 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) + _LOGGER.error("Can't write %s, no access to path!", snapshot_file) return image = await camera.async_camera_image() def _write_image(to_file, image_data): """Executor helper to write image.""" - with open(to_file, 'wb') as img_file: + with open(to_file, "wb") as img_file: img_file.write(image_data) try: - await hass.async_add_executor_job( - _write_image, snapshot_file, image) + await hass.async_add_executor_job(_write_image, snapshot_file, image) except OSError as err: _LOGGER.error("Can't write image to file: %s", err) + + +async def async_handle_play_stream_service(camera, service_call): + """Handle play stream services calls.""" + async with async_timeout.timeout(10): + source = await camera.stream_source() + + if not source: + raise HomeAssistantError( + f"{camera.entity_id} does not support play stream service" + ) + + hass = camera.hass + camera_prefs = hass.data[DATA_CAMERA_PREFS].get(camera.entity_id) + fmt = service_call.data[ATTR_FORMAT] + entity_ids = service_call.data[ATTR_MEDIA_PLAYER] + + url = request_stream(hass, source, fmt=fmt, keepalive=camera_prefs.preload_stream) + data = { + ATTR_ENTITY_ID: entity_ids, + ATTR_MEDIA_CONTENT_ID: f"{hass.config.api.base_url}{url}", + ATTR_MEDIA_CONTENT_TYPE: FORMAT_CONTENT_TYPE[fmt], + } + + await hass.services.async_call( + DOMAIN_MP, SERVICE_PLAY_MEDIA, data, blocking=True, context=service_call.context + ) + + +async def async_handle_record_service(camera, call): + """Handle stream recording service calls.""" + async with async_timeout.timeout(10): + source = await camera.stream_source() + + if not source: + raise HomeAssistantError(f"{camera.entity_id} does not support record service") + + hass = camera.hass + filename = call.data[CONF_FILENAME] + filename.hass = hass + video_path = filename.async_render(variables={ATTR_ENTITY_ID: camera}) + + data = { + CONF_STREAM_SOURCE: source, + CONF_FILENAME: video_path, + CONF_DURATION: call.data[CONF_DURATION], + CONF_LOOKBACK: call.data[CONF_LOOKBACK], + } + + await hass.services.async_call( + DOMAIN_STREAM, SERVICE_RECORD, data, blocking=True, context=call.context + ) diff --git a/homeassistant/components/camera/abode.py b/homeassistant/components/camera/abode.py deleted file mode 100644 index fbab1620a..000000000 --- a/homeassistant/components/camera/abode.py +++ /dev/null @@ -1,101 +0,0 @@ -""" -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_entities, 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_entities(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 deleted file mode 100644 index 9f4b84db2..000000000 --- a/homeassistant/components/camera/amcrest.py +++ /dev/null @@ -1,94 +0,0 @@ -""" -This component provides basic support for Amcrest IP cameras. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/camera.amcrest/ -""" -import asyncio -import logging - -from homeassistant.components.amcrest import ( - 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) - -DEPENDENCIES = ['amcrest', 'ffmpeg'] - -_LOGGER = logging.getLogger(__name__) - - -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): - """Set up an Amcrest IP Camera.""" - if discovery_info is None: - return - - device_name = discovery_info[CONF_NAME] - amcrest = hass.data[DATA_AMCREST][device_name] - - async_add_entities([AmcrestCam(hass, amcrest)], True) - - return True - - -class AmcrestCam(Camera): - """An implementation of an Amcrest IP camera.""" - - def __init__(self, hass, amcrest): - """Initialize an Amcrest camera.""" - super(AmcrestCam, self).__init__() - 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 = 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 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 - - @asyncio.coroutine - def handle_async_mjpeg_stream(self, request): - """Return an MJPEG stream.""" - # The snapshot implementation is handled by the parent class - if self._stream_source == STREAM_SOURCE_LIST['snapshot']: - yield from super().handle_async_mjpeg_stream(request) - return - - if self._stream_source == STREAM_SOURCE_LIST['mjpeg']: - # stream an MJPEG image stream directly from the camera - websession = async_get_clientsession(self.hass) - streaming_url = self._camera.mjpeg_url(typeno=self._resolution) - stream_coro = websession.get( - streaming_url, auth=self._token, timeout=TIMEOUT) - - yield from async_aiohttp_proxy_web(self.hass, request, stream_coro) - - else: - # streaming via fmpeg - from haffmpeg import CameraMjpeg - - streaming_url = self._camera.rtsp_url(typeno=self._resolution) - stream = CameraMjpeg(self._ffmpeg.binary, loop=self.hass.loop) - yield from stream.open_camera( - streaming_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 name(self): - """Return the name of this camera.""" - return self._name diff --git a/homeassistant/components/camera/arlo.py b/homeassistant/components/camera/arlo.py deleted file mode 100644 index af931c74c..000000000 --- a/homeassistant/components/camera/arlo.py +++ /dev/null @@ -1,167 +0,0 @@ -""" -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 logging - -import voluptuous as vol - -from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv -from homeassistant.components.arlo import ( - DEFAULT_BRAND, DATA_ARLO, SIGNAL_UPDATE_ARLO) -from homeassistant.components.camera import Camera, PLATFORM_SCHEMA -from homeassistant.components.ffmpeg import DATA_FFMPEG -from homeassistant.const import ATTR_BATTERY_LEVEL -from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream -from homeassistant.helpers.dispatcher import async_dispatcher_connect - -_LOGGER = logging.getLogger(__name__) - -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, -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up an Arlo IP Camera.""" - arlo = hass.data[DATA_ARLO] - - cameras = [] - for camera in arlo.cameras: - cameras.append(ArloCam(hass, camera, config)) - - add_entities(cameras) - - -class ArloCam(Camera): - """An implementation of a Netgear Arlo IP camera.""" - - def __init__(self, hass, camera, device_info): - """Initialize an Arlo camera.""" - super().__init__() - self._camera = camera - self._name = self._camera.name - self._motion_status = False - self._ffmpeg = hass.data[DATA_FFMPEG] - self._ffmpeg_arguments = device_info.get(CONF_FFMPEG_ARGUMENTS) - self._last_refresh = None - self.attrs = {} - - def camera_image(self): - """Return a still image response from the camera.""" - return self._camera.last_image_from_cache - - async def async_added_to_hass(self): - """Register callbacks.""" - async_dispatcher_connect( - self.hass, SIGNAL_UPDATE_ARLO, self._update_callback) - - @callback - def _update_callback(self): - """Call update method.""" - self.async_schedule_update_ha_state() - - async def handle_async_mjpeg_stream(self, request): - """Generate an HTTP MJPEG stream from the camera.""" - from haffmpeg import CameraMjpeg - video = self._camera.last_video - if not video: - error_msg = \ - 'Video not found for {0}. Is it older than {1} days?'.format( - self.name, self._camera.min_days_vdo_cache) - _LOGGER.error(error_msg) - return - - stream = CameraMjpeg(self._ffmpeg.binary, loop=self.hass.loop) - await stream.open_camera( - video.video_url, extra_cmd=self._ffmpeg_arguments) - - await async_aiohttp_proxy_stream( - self.hass, request, stream, - 'multipart/x-mixed-replace;boundary=ffserver') - await stream.close() - - @property - 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): - """Return the camera model.""" - return self._camera.model_id - - @property - def brand(self): - """Return the camera brand.""" - return DEFAULT_BRAND - - @property - def motion_detection_enabled(self): - """Return the camera motion detection status.""" - return self._motion_status - - def set_base_station_mode(self, mode): - """Set the mode in the base station.""" - # Get the list of base stations identified by library - base_stations = self.hass.data[DATA_ARLO].base_stations - - # 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 - if base_stations: - primary_base_station = base_stations[0] - primary_base_station.mode = mode - - def enable_motion_detection(self): - """Enable the Motion detection in base station (Arm).""" - self._motion_status = True - self.set_base_station_mode(ARLO_MODE_ARMED) - - 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) diff --git a/homeassistant/components/camera/august.py b/homeassistant/components/camera/august.py deleted file mode 100644 index dcce5e135..000000000 --- a/homeassistant/components/camera/august.py +++ /dev/null @@ -1,76 +0,0 @@ -""" -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_entities, 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_entities(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 deleted file mode 100644 index 4227cca7e..000000000 --- a/homeassistant/components/camera/axis.py +++ /dev/null @@ -1,61 +0,0 @@ -""" -Support for Axis camera streaming. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/camera.axis/ -""" -import logging - -from homeassistant.components.camera.mjpeg import ( - CONF_MJPEG_URL, CONF_STILL_IMAGE_URL, MjpegCamera) -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__) - -DOMAIN = 'axis' -DEPENDENCIES = [DOMAIN] - - -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, port) - if mode == 'single': - return 'http://{}:{}/axis-cgi/jpg/image.cgi'.format(host, port) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """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], 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_entities([AxisCamera( - hass, camera_config, str(discovery_info[CONF_PORT]))]) - - -class AxisCamera(MjpegCamera): - """Representation of a Axis camera.""" - - def __init__(self, hass, config, port): - """Initialize Axis Communications camera component.""" - super().__init__(config) - 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, 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 deleted file mode 100644 index 217849138..000000000 --- a/homeassistant/components/camera/blink.py +++ /dev/null @@ -1,81 +0,0 @@ -""" -Support for Blink system camera. - -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 - -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) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up a Blink Camera.""" - if discovery_info is None: - return - - data = hass.data[DOMAIN].blink - devs = list() - for name in data.cameras: - devs.append(BlinkCamera(hass, config, data, name)) - - add_entities(devs) - - -class BlinkCamera(Camera): - """An implementation of a Blink Camera.""" - - def __init__(self, hass, config, data, name): - """Initialize a camera.""" - super().__init__() - self.data = data - self.hass = hass - self._name = name - self.notifications = self.data.cameras[self._name].notifications - self.response = None - - _LOGGER.debug("Initialized blink camera %s", self._name) - - @property - def name(self): - """Return the camera name.""" - return self._name - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def request_image(self): - """Request a 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) - - def check_for_motion(self): - """Check if motion has been detected since last update.""" - self.data.refresh() - notifs = self.data.cameras[self._name].notifications - if notifs > self.notifications: - # We detected motion at some point - self.data.last_motion() - self.notifications = notifs - # Returning motion image currently not working - # return self.data.cameras[self._name].motion['image'] - elif notifs < self.notifications: - self.notifications = notifs - - return self.data.camera_thumbs[self._name] - - def camera_image(self): - """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 deleted file mode 100644 index 01e20e3cc..000000000 --- a/homeassistant/components/camera/bloomsky.py +++ /dev/null @@ -1,59 +0,0 @@ -""" -Support for a camera of a BloomSky weather station. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/camera.bloomsky/ -""" -import logging - -import requests - -from homeassistant.components.camera import Camera - -DEPENDENCIES = ['bloomsky'] - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up access to BloomSky cameras.""" - bloomsky = hass.components.bloomsky - for device in bloomsky.BLOOMSKY.devices.values(): - add_entities([BloomSkyCamera(bloomsky.BLOOMSKY, device)]) - - -class BloomSkyCamera(Camera): - """Representation of the images published from the BloomSky's camera.""" - - def __init__(self, bs, device): - """Initialize access to the BloomSky camera images.""" - super(BloomSkyCamera, self).__init__() - self._name = device['DeviceName'] - self._id = device['DeviceID'] - self._bloomsky = bs - self._url = "" - self._last_url = "" - # last_image will store images as they are downloaded so that the - # frequent updates in home-assistant don't keep poking the server - # to download the same image over and over. - self._last_image = "" - self._logger = logging.getLogger(__name__) - - def camera_image(self): - """Update the camera's image if it has changed.""" - try: - self._url = self._bloomsky.devices[self._id]['Data']['ImageURL'] - self._bloomsky.refresh_devices() - # If the URL hasn't changed then the image hasn't changed. - if self._url != self._last_url: - response = requests.get(self._url, timeout=10) - self._last_url = self._url - self._last_image = response.content - except requests.exceptions.RequestException as error: - self._logger.error("Error getting bloomsky image: %s", error) - return None - - return self._last_image - - @property - def name(self): - """Return the name of this BloomSky device.""" - return self._name diff --git a/homeassistant/components/camera/canary.py b/homeassistant/components/camera/canary.py deleted file mode 100644 index 9031c27b1..000000000 --- a/homeassistant/components/camera/canary.py +++ /dev/null @@ -1,112 +0,0 @@ -""" -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_entities, 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_entities(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/const.py b/homeassistant/components/camera/const.py new file mode 100644 index 000000000..563f0554f --- /dev/null +++ b/homeassistant/components/camera/const.py @@ -0,0 +1,6 @@ +"""Constants for Camera component.""" +DOMAIN = "camera" + +DATA_CAMERA_PREFS = "camera_prefs" + +PREF_PRELOAD_STREAM = "preload_stream" diff --git a/homeassistant/components/camera/demo.py b/homeassistant/components/camera/demo.py deleted file mode 100644 index f950edb5c..000000000 --- a/homeassistant/components/camera/demo.py +++ /dev/null @@ -1,91 +0,0 @@ -""" -Demo camera platform that has a fake camera. - -For more details about this platform, please refer to the documentation -https://home-assistant.io/components/demo/ -""" -import logging -import os - -from homeassistant.components.camera import Camera, SUPPORT_ON_OFF - -_LOGGER = logging.getLogger(__name__) - - -async def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): - """Set up the Demo camera platform.""" - async_add_entities([ - DemoCamera('Demo camera') - ]) - - -class DemoCamera(Camera): - """The representation of a Demo camera.""" - - def __init__(self, name): - """Initialize demo camera component.""" - super().__init__() - self._name = name - self._motion_status = False - self.is_streaming = True - self._images_index = 0 - - def camera_image(self): - """Return a faked still image response.""" - self._images_index = (self._images_index + 1) % 4 - - image_path = os.path.join( - os.path.dirname(__file__), - 'demo_{}.jpg'.format(self._images_index)) - _LOGGER.debug('Loading camera_image: %s', image_path) - with open(image_path, 'rb') as file: - return file.read() - - @property - def name(self): - """Return the name of this camera.""" - return self._name - - @property - def should_poll(self): - """Demo camera doesn't need poll. - - Need explicitly call schedule_update_ha_state() after state changed. - """ - return False - - @property - def supported_features(self): - """Camera support turn on/off features.""" - return SUPPORT_ON_OFF - - @property - def is_on(self): - """Whether camera is on (streaming).""" - return self.is_streaming - - @property - def motion_detection_enabled(self): - """Camera Motion Detection Status.""" - return self._motion_status - - def enable_motion_detection(self): - """Enable the Motion detection in base station (Arm).""" - self._motion_status = True - self.schedule_update_ha_state() - - def disable_motion_detection(self): - """Disable the motion detection in base station (Disarm).""" - self._motion_status = False - self.schedule_update_ha_state() - - def turn_off(self): - """Turn off camera.""" - self.is_streaming = False - self.schedule_update_ha_state() - - def turn_on(self): - """Turn on camera.""" - self.is_streaming = True - self.schedule_update_ha_state() diff --git a/homeassistant/components/camera/doorbird.py b/homeassistant/components/camera/doorbird.py deleted file mode 100644 index 7af3e7634..000000000 --- a/homeassistant/components/camera/doorbird.py +++ /dev/null @@ -1,90 +0,0 @@ -""" -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 = "{} Last Ring" -_CAMERA_LAST_MOTION = "{} Last Motion" -_CAMERA_LIVE = "{} 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_entities, - discovery_info=None): - """Set up the DoorBird camera platform.""" - for doorstation in hass.data[DOORBIRD_DOMAIN]: - device = doorstation.device - async_add_entities([ - DoorBirdCamera( - device.live_image_url, - _CAMERA_LIVE.format(doorstation.name), - _LIVE_INTERVAL), - DoorBirdCamera( - device.history_image_url(1, 'doorbell'), - _CAMERA_LAST_VISITOR.format(doorstation.name), - _LAST_VISITOR_INTERVAL), - DoorBirdCamera( - device.history_image_url(1, 'motionsensor'), - _CAMERA_LAST_MOTION.format(doorstation.name), - _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 deleted file mode 100644 index f3dd8b6d0..000000000 --- a/homeassistant/components/camera/familyhub.py +++ /dev/null @@ -1,58 +0,0 @@ -""" -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_entities, 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_entities([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 deleted file mode 100644 index c45818869..000000000 --- a/homeassistant/components/camera/ffmpeg.py +++ /dev/null @@ -1,80 +0,0 @@ -""" -Support for Cameras with FFmpeg as decoder. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/camera.ffmpeg/ -""" -import asyncio -import logging - -import voluptuous as vol - -from homeassistant.const import CONF_NAME -from homeassistant.components.camera import Camera, PLATFORM_SCHEMA -from homeassistant.components.ffmpeg import ( - DATA_FFMPEG, CONF_INPUT, CONF_EXTRA_ARGUMENTS) -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.aiohttp_client import ( - async_aiohttp_proxy_stream) - -_LOGGER = logging.getLogger(__name__) - -DEPENDENCIES = ['ffmpeg'] -DEFAULT_NAME = 'FFmpeg' - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_INPUT): cv.string, - vol.Optional(CONF_EXTRA_ARGUMENTS): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, -}) - - -async def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): - """Set up a FFmpeg camera.""" - if not hass.data[DATA_FFMPEG].async_run_test(config.get(CONF_INPUT)): - return - async_add_entities([FFmpegCamera(hass, config)]) - - -class FFmpegCamera(Camera): - """An implementation of an FFmpeg camera.""" - - def __init__(self, hass, config): - """Initialize a FFmpeg camera.""" - super().__init__() - - self._manager = hass.data[DATA_FFMPEG] - self._name = config.get(CONF_NAME) - self._input = config.get(CONF_INPUT) - self._extra_arguments = config.get(CONF_EXTRA_ARGUMENTS) - - async def async_camera_image(self): - """Return a still image response from the camera.""" - from haffmpeg import ImageFrame, IMAGE_JPEG - ffmpeg = ImageFrame(self._manager.binary, loop=self.hass.loop) - - image = await asyncio.shield(ffmpeg.get_image( - self._input, output_format=IMAGE_JPEG, - extra_cmd=self._extra_arguments), loop=self.hass.loop) - return 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._input, extra_cmd=self._extra_arguments) - - try: - return await async_aiohttp_proxy_stream( - self.hass, request, stream, - 'multipart/x-mixed-replace;boundary=ffserver') - finally: - await stream.close() - - @property - def name(self): - """Return the name of this camera.""" - return self._name diff --git a/homeassistant/components/camera/foscam.py b/homeassistant/components/camera/foscam.py deleted file mode 100644 index ceec57f77..000000000 --- a/homeassistant/components/camera/foscam.py +++ /dev/null @@ -1,96 +0,0 @@ -""" -This component provides basic support for Foscam IP cameras. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/camera.foscam/ -""" -import logging - -import voluptuous as vol - -from homeassistant.components.camera import (Camera, PLATFORM_SCHEMA) -from homeassistant.const import ( - CONF_NAME, CONF_USERNAME, CONF_PASSWORD, CONF_PORT) -from homeassistant.helpers import config_validation as cv - -_LOGGER = logging.getLogger(__name__) - -REQUIREMENTS = ['libpyfoscam==1.0'] - -CONF_IP = 'ip' - -DEFAULT_NAME = 'Foscam Camera' -DEFAULT_PORT = 88 - -FOSCAM_COMM_ERROR = -8 - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_IP): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_USERNAME): 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_entities, discovery_info=None): - """Set up a Foscam IP Camera.""" - add_entities([FoscamCam(config)]) - - -class FoscamCam(Camera): - """An implementation of a Foscam IP 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) - port = device_info.get(CONF_PORT) - self._username = device_info.get(CONF_USERNAME) - self._password = device_info.get(CONF_PASSWORD) - self._name = device_info.get(CONF_NAME) - self._motion_status = False - - self._foscam_session = FoscamCamera( - ip_address, port, self._username, self._password, verbose=False) - - def camera_image(self): - """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() - if result == FOSCAM_COMM_ERROR: - return None - - return response - - @property - def motion_detection_enabled(self): - """Camera Motion Detection Status.""" - return self._motion_status - - def enable_motion_detection(self): - """Enable motion detection in camera.""" - 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.""" - 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 - def name(self): - """Return the name of this camera.""" - return self._name diff --git a/homeassistant/components/camera/generic.py b/homeassistant/components/camera/generic.py deleted file mode 100644 index b707c9134..000000000 --- a/homeassistant/components/camera/generic.py +++ /dev/null @@ -1,145 +0,0 @@ -""" -Support for IP Cameras. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/camera.generic/ -""" -import asyncio -import logging - -import aiohttp -import async_timeout -import requests -from requests.auth import HTTPDigestAuth -import voluptuous as vol - -from homeassistant.const import ( - CONF_NAME, CONF_USERNAME, CONF_PASSWORD, CONF_AUTHENTICATION, - HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION, CONF_VERIFY_SSL) -from homeassistant.exceptions import TemplateError -from homeassistant.components.camera import ( - 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 - -_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' - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_STILL_IMAGE_URL): cv.template, - vol.Optional(CONF_AUTHENTICATION, default=HTTP_BASIC_AUTHENTICATION): - vol.In([HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION]), - vol.Optional(CONF_LIMIT_REFETCH_TO_URL_CHANGE, default=False): cv.boolean, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - 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, - vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, -}) - - -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): - """Set up a generic IP Camera.""" - async_add_entities([GenericCamera(hass, config)]) - - -class GenericCamera(Camera): - """A generic implementation of an IP camera.""" - - def __init__(self, hass, device_info): - """Initialize a generic camera.""" - super().__init__() - self.hass = hass - self._authentication = device_info.get(CONF_AUTHENTICATION) - self._name = device_info.get(CONF_NAME) - 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] - self.verify_ssl = device_info[CONF_VERIFY_SSL] - - username = device_info.get(CONF_USERNAME) - password = device_info.get(CONF_PASSWORD) - - if username and password: - if self._authentication == HTTP_DIGEST_AUTHENTICATION: - self._auth = HTTPDigestAuth(username, password) - else: - self._auth = aiohttp.BasicAuth(username, password=password) - else: - self._auth = None - - 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( - self.async_camera_image(), self.hass.loop).result() - - @asyncio.coroutine - def async_camera_image(self): - """Return a still image response from the camera.""" - try: - url = self._still_image_url.async_render() - except TemplateError as err: - _LOGGER.error( - "Error parsing template %s: %s", self._still_image_url, err) - return self._last_image - - if url == self._last_url and self._limit_refetch: - return self._last_image - - # aiohttp don't support DigestAuth yet - if self._authentication == HTTP_DIGEST_AUTHENTICATION: - def fetch(): - """Read image from a URL.""" - try: - response = requests.get(url, timeout=10, auth=self._auth, - verify=self.verify_ssl) - return response.content - except requests.exceptions.RequestException as error: - _LOGGER.error("Error getting camera image: %s", error) - return self._last_image - - self._last_image = yield from self.hass.async_add_job( - fetch) - # async - else: - try: - websession = async_get_clientsession( - self.hass, verify_ssl=self.verify_ssl) - with async_timeout.timeout(10, loop=self.hass.loop): - response = yield from websession.get( - url, auth=self._auth) - self._last_image = yield from 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 - - self._last_url = url - return self._last_image - - @property - def name(self): - """Return the name of this device.""" - return self._name diff --git a/homeassistant/components/camera/local_file.py b/homeassistant/components/camera/local_file.py deleted file mode 100644 index d306509b7..000000000 --- a/homeassistant/components/camera/local_file.py +++ /dev/null @@ -1,100 +0,0 @@ -""" -Camera that loads a picture from a local file. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/camera.local_file/ -""" -import logging -import mimetypes -import os - -import voluptuous as vol - -from homeassistant.const import CONF_NAME -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_entities, 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) - - 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 - - hass.services.register( - DOMAIN, - SERVICE_UPDATE_FILE_PATH, - update_file_path_service, - schema=CAMERA_SERVICE_UPDATE_FILE_PATH) - - add_entities([camera]) - - -class LocalFile(Camera): - """Representation of a local file camera.""" - - def __init__(self, name, file_path): - """Initialize Local File Camera component.""" - 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) - if content is not None: - self.content_type = content - - def camera_image(self): - """Return image response.""" - try: - with open(self._file_path, 'rb') as file: - return file.read() - except FileNotFoundError: - _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/manifest.json b/homeassistant/components/camera/manifest.json new file mode 100644 index 000000000..25d344d05 --- /dev/null +++ b/homeassistant/components/camera/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "camera", + "name": "Camera", + "documentation": "https://www.home-assistant.io/integrations/camera", + "requirements": [], + "dependencies": ["http"], + "after_dependencies": ["media_player"], + "codeowners": [] +} diff --git a/homeassistant/components/camera/mjpeg.py b/homeassistant/components/camera/mjpeg.py deleted file mode 100644 index f1917aaf2..000000000 --- a/homeassistant/components/camera/mjpeg.py +++ /dev/null @@ -1,143 +0,0 @@ -""" -Support for IP Cameras. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/camera.mjpeg/ -""" -import asyncio -import logging -from contextlib import closing - -import aiohttp -import async_timeout -import requests -from requests.auth import HTTPBasicAuth, HTTPDigestAuth -import voluptuous as vol - -from homeassistant.const import ( - CONF_NAME, CONF_USERNAME, CONF_PASSWORD, CONF_AUTHENTICATION, - HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION) -from homeassistant.components.camera import (PLATFORM_SCHEMA, Camera) -from homeassistant.helpers.aiohttp_client import ( - async_get_clientsession, async_aiohttp_proxy_web) -from homeassistant.helpers import config_validation as cv - -_LOGGER = logging.getLogger(__name__) - -CONF_MJPEG_URL = 'mjpeg_url' -CONF_STILL_IMAGE_URL = 'still_image_url' -CONTENT_TYPE_HEADER = 'Content-Type' - -DEFAULT_NAME = 'Mjpeg Camera' - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_MJPEG_URL): cv.url, - vol.Optional(CONF_STILL_IMAGE_URL): cv.url, - vol.Optional(CONF_AUTHENTICATION, default=HTTP_BASIC_AUTHENTICATION): - vol.In([HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION]), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PASSWORD): cv.string, - vol.Optional(CONF_USERNAME): cv.string, -}) - - -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): - """Set up a MJPEG IP Camera.""" - if discovery_info: - config = PLATFORM_SCHEMA(discovery_info) - async_add_entities([MjpegCamera(config)]) - - -def extract_image_from_mjpeg(stream): - """Take in a MJPEG stream object, return the jpg from it.""" - data = b'' - for chunk in stream: - data += chunk - jpg_start = data.find(b'\xff\xd8') - jpg_end = data.find(b'\xff\xd9') - if jpg_start != -1 and jpg_end != -1: - jpg = data[jpg_start:jpg_end + 2] - return jpg - - -class MjpegCamera(Camera): - """An implementation of an IP camera that is reachable over a URL.""" - - def __init__(self, device_info): - """Initialize a MJPEG camera.""" - super().__init__() - self._name = device_info.get(CONF_NAME) - self._authentication = device_info.get(CONF_AUTHENTICATION) - self._username = device_info.get(CONF_USERNAME) - self._password = device_info.get(CONF_PASSWORD) - self._mjpeg_url = device_info[CONF_MJPEG_URL] - self._still_image_url = device_info.get(CONF_STILL_IMAGE_URL) - - self._auth = None - if self._username and self._password: - if self._authentication == HTTP_BASIC_AUTHENTICATION: - self._auth = aiohttp.BasicAuth( - self._username, password=self._password - ) - - @asyncio.coroutine - def async_camera_image(self): - """Return a still image response from the camera.""" - # DigestAuth is not supported - if self._authentication == HTTP_DIGEST_AUTHENTICATION or \ - self._still_image_url is None: - image = yield from self.hass.async_add_job( - self.camera_image) - return image - - websession = async_get_clientsession(self.hass) - try: - with async_timeout.timeout(10, loop=self.hass.loop): - response = yield from websession.get( - self._still_image_url, auth=self._auth) - - image = yield from response.read() - return image - - except asyncio.TimeoutError: - _LOGGER.error("Timeout getting camera image") - - except aiohttp.ClientError as err: - _LOGGER.error("Error getting new camera image: %s", err) - - def camera_image(self): - """Return a still image response from the camera.""" - if self._username and self._password: - if self._authentication == HTTP_DIGEST_AUTHENTICATION: - auth = HTTPDigestAuth(self._username, self._password) - else: - auth = HTTPBasicAuth(self._username, self._password) - req = requests.get( - self._mjpeg_url, auth=auth, stream=True, timeout=10) - 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)) - - async def handle_async_mjpeg_stream(self, request): - """Generate an HTTP MJPEG stream from the camera.""" - # aiohttp don't support DigestAuth -> Fallback - if self._authentication == HTTP_DIGEST_AUTHENTICATION: - await super().handle_async_mjpeg_stream(request) - return - - # connect to stream - websession = async_get_clientsession(self.hass) - stream_coro = websession.get(self._mjpeg_url, auth=self._auth) - - return await async_aiohttp_proxy_web(self.hass, request, stream_coro) - - @property - def name(self): - """Return the name of this camera.""" - return self._name diff --git a/homeassistant/components/camera/mqtt.py b/homeassistant/components/camera/mqtt.py deleted file mode 100644 index cf5c969c6..000000000 --- a/homeassistant/components/camera/mqtt.py +++ /dev/null @@ -1,76 +0,0 @@ -""" -Camera that loads a picture from an MQTT topic. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/camera.mqtt/ -""" - -import asyncio -import logging - -import voluptuous as vol - -from homeassistant.core import callback -from homeassistant.components import mqtt -from homeassistant.const import CONF_NAME -from homeassistant.components.camera import Camera, PLATFORM_SCHEMA -from homeassistant.helpers import config_validation as cv - -_LOGGER = logging.getLogger(__name__) - -CONF_TOPIC = 'topic' -DEFAULT_NAME = 'MQTT Camera' - -DEPENDENCIES = ['mqtt'] - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_TOPIC): mqtt.valid_subscribe_topic, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string -}) - - -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): - """Set up the MQTT Camera.""" - if discovery_info is not None: - config = PLATFORM_SCHEMA(discovery_info) - - async_add_entities([MqttCamera( - config.get(CONF_NAME), - config.get(CONF_TOPIC) - )]) - - -class MqttCamera(Camera): - """representation of a MQTT camera.""" - - def __init__(self, name, topic): - """Initialize the MQTT Camera.""" - super().__init__() - - self._name = name - self._topic = topic - self._qos = 0 - self._last_image = None - - @asyncio.coroutine - def async_camera_image(self): - """Return image response.""" - return self._last_image - - @property - def name(self): - """Return the name of this camera.""" - return self._name - - @asyncio.coroutine - def async_added_to_hass(self): - """Subscribe MQTT events.""" - @callback - def message_received(topic, payload, qos): - """Handle new MQTT messages.""" - self._last_image = payload - - return mqtt.async_subscribe( - self.hass, self._topic, message_received, self._qos, None) diff --git a/homeassistant/components/camera/neato.py b/homeassistant/components/camera/neato.py deleted file mode 100644 index b080dbbae..000000000 --- a/homeassistant/components/camera/neato.py +++ /dev/null @@ -1,65 +0,0 @@ -""" -Camera that loads a picture from Neato. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/camera.neato/ -""" -import logging - -from datetime import timedelta -from homeassistant.components.camera import Camera -from homeassistant.components.neato import ( - NEATO_MAP_DATA, NEATO_ROBOTS, NEATO_LOGIN) - -_LOGGER = logging.getLogger(__name__) - -DEPENDENCIES = ['neato'] - -SCAN_INTERVAL = timedelta(minutes=10) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Neato Camera.""" - dev = [] - for robot in hass.data[NEATO_ROBOTS]: - if 'maps' in robot.traits: - dev.append(NeatoCleaningMap(hass, robot)) - _LOGGER.debug("Adding robots for cleaning maps %s", dev) - add_entities(dev, True) - - -class NeatoCleaningMap(Camera): - """Neato cleaning map for last clean.""" - - def __init__(self, hass, robot): - """Initialize Neato cleaning map.""" - super().__init__() - self.robot = robot - self._robot_name = '{} {}'.format(self.robot.name, 'Cleaning Map') - self._robot_serial = self.robot.serial - self.neato = hass.data[NEATO_LOGIN] - self._image_url = None - self._image = None - - def camera_image(self): - """Return image response.""" - self.update() - return self._image - - def update(self): - """Check the contents of the map list.""" - self.neato.update_robots() - image_url = None - map_data = self.hass.data[NEATO_MAP_DATA] - image_url = map_data[self._robot_serial]['maps'][0]['url'] - if image_url == self._image_url: - _LOGGER.debug("The map image_url is the same as old") - return - image = self.neato.download_map(image_url) - self._image = image.read() - self._image_url = image_url - - @property - def name(self): - """Return the name of this camera.""" - return self._robot_name diff --git a/homeassistant/components/camera/nest.py b/homeassistant/components/camera/nest.py deleted file mode 100644 index e1d263719..000000000 --- a/homeassistant/components/camera/nest.py +++ /dev/null @@ -1,144 +0,0 @@ -""" -Support for Nest Cameras. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/camera.nest/ -""" -import logging -from datetime import timedelta - -import requests - -from homeassistant.components import nest -from homeassistant.components.camera import (PLATFORM_SCHEMA, Camera, - SUPPORT_ON_OFF) -from homeassistant.util.dt import utcnow - -_LOGGER = logging.getLogger(__name__) - -DEPENDENCIES = ['nest'] - -NEST_BRAND = 'Nest' - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up a Nest Cam. - - No longer in use. - """ - - -async def async_setup_entry(hass, entry, async_add_entities): - """Set up a Nest sensor based on a config entry.""" - camera_devices = \ - await hass.async_add_job(hass.data[nest.DATA_NEST].cameras) - cameras = [NestCamera(structure, device) - for structure, device in camera_devices] - async_add_entities(cameras, True) - - -class NestCamera(Camera): - """Representation of a Nest Camera.""" - - def __init__(self, structure, device): - """Initialize a Nest Camera.""" - super(NestCamera, self).__init__() - self.structure = structure - self.device = device - self._location = None - self._name = None - self._online = None - self._is_streaming = None - self._is_video_history_enabled = False - # Default to non-NestAware subscribed, but will be fixed during update - self._time_between_snapshots = timedelta(seconds=30) - self._last_image = None - self._next_snapshot_at = None - - @property - def name(self): - """Return the name of the nest, if any.""" - return self._name - - @property - def should_poll(self): - """Nest camera should poll periodically.""" - return True - - @property - def is_recording(self): - """Return true if the device is recording.""" - return self._is_streaming - - @property - def brand(self): - """Return the brand of the camera.""" - return NEST_BRAND - - @property - def supported_features(self): - """Nest Cam support turn on and off.""" - return SUPPORT_ON_OFF - - @property - def is_on(self): - """Return true if on.""" - return self._online and self._is_streaming - - def turn_off(self): - """Turn off camera.""" - _LOGGER.debug('Turn off camera %s', self._name) - # Calling Nest API in is_streaming setter. - # device.is_streaming would not immediately change until the process - # finished in Nest Cam. - self.device.is_streaming = False - - def turn_on(self): - """Turn on camera.""" - if not self._online: - _LOGGER.error('Camera %s is offline.', self._name) - return - - _LOGGER.debug('Turn on camera %s', self._name) - # Calling Nest API in is_streaming setter. - # device.is_streaming would not immediately change until the process - # finished in Nest Cam. - self.device.is_streaming = True - - def update(self): - """Cache value from Python-nest.""" - self._location = self.device.where - self._name = self.device.name - self._online = self.device.online - self._is_streaming = self.device.is_streaming - self._is_video_history_enabled = self.device.is_video_history_enabled - - if self._is_video_history_enabled: - # NestAware allowed 10/min - self._time_between_snapshots = timedelta(seconds=6) - else: - # Otherwise, 2/min - self._time_between_snapshots = timedelta(seconds=30) - - def _ready_for_snapshot(self, now): - return (self._next_snapshot_at is None or - now > self._next_snapshot_at) - - def camera_image(self): - """Return a still image response from the camera.""" - now = utcnow() - if self._ready_for_snapshot(now): - url = self.device.snapshot_url - - try: - response = requests.get(url) - except requests.exceptions.RequestException as error: - _LOGGER.error("Error getting camera image: %s", error) - return None - - self._next_snapshot_at = now + self._time_between_snapshots - self._last_image = response.content - - return self._last_image diff --git a/homeassistant/components/camera/netatmo.py b/homeassistant/components/camera/netatmo.py deleted file mode 100644 index 93ad2cd05..000000000 --- a/homeassistant/components/camera/netatmo.py +++ /dev/null @@ -1,110 +0,0 @@ -""" -Support for the Netatmo cameras. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/camera.netatmo/. -""" -import logging - -import requests -import voluptuous as vol - -from homeassistant.const import CONF_VERIFY_SSL -from homeassistant.components.netatmo import CameraData -from homeassistant.components.camera import (Camera, PLATFORM_SCHEMA) -from homeassistant.helpers import config_validation as cv - -DEPENDENCIES = ['netatmo'] - -_LOGGER = logging.getLogger(__name__) - -CONF_HOME = 'home' -CONF_CAMERAS = 'cameras' - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, - vol.Optional(CONF_HOME): cv.string, - vol.Optional(CONF_CAMERAS, default=[]): - vol.All(cv.ensure_list, [cv.string]), -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up access to Netatmo cameras.""" - netatmo = hass.components.netatmo - home = config.get(CONF_HOME) - verify_ssl = config.get(CONF_VERIFY_SSL, True) - import pyatmo - try: - data = CameraData(netatmo.NETATMO_AUTH, home) - for camera_name in data.get_camera_names(): - camera_type = data.get_camera_type(camera=camera_name, home=home) - if CONF_CAMERAS in config: - if config[CONF_CAMERAS] != [] and \ - camera_name not in config[CONF_CAMERAS]: - continue - add_entities([NetatmoCamera(data, camera_name, home, - camera_type, verify_ssl)]) - except pyatmo.NoDevice: - return None - - -class NetatmoCamera(Camera): - """Representation of the images published from a Netatmo camera.""" - - def __init__(self, data, camera_name, home, camera_type, verify_ssl): - """Set up for access to the Netatmo camera images.""" - super(NetatmoCamera, self).__init__() - self._data = data - self._camera_name = camera_name - self._verify_ssl = verify_ssl - if home: - self._name = home + ' / ' + camera_name - else: - self._name = camera_name - self._vpnurl, self._localurl = self._data.camera_data.cameraUrls( - camera=camera_name - ) - self._cameratype = camera_type - - def camera_image(self): - """Return a still image response from the camera.""" - try: - if self._localurl: - response = requests.get('{0}/live/snapshot_720.jpg'.format( - self._localurl), timeout=10) - elif self._vpnurl: - response = requests.get('{0}/live/snapshot_720.jpg'.format( - self._vpnurl), timeout=10, verify=self._verify_ssl) - else: - _LOGGER.error("Welcome VPN URL is None") - self._data.update() - (self._vpnurl, self._localurl) = \ - self._data.camera_data.cameraUrls(camera=self._camera_name) - return None - except requests.exceptions.RequestException as error: - _LOGGER.error("Welcome URL changed: %s", error) - self._data.update() - (self._vpnurl, self._localurl) = \ - self._data.camera_data.cameraUrls(camera=self._camera_name) - return None - return response.content - - @property - def name(self): - """Return the name of this Netatmo camera device.""" - return self._name - - @property - def brand(self): - """Return the camera brand.""" - return "Netatmo" - - @property - def model(self): - """Return the camera model.""" - if self._cameratype == "NOC": - return "Presence" - if self._cameratype == "NACamera": - return "Welcome" - return None diff --git a/homeassistant/components/camera/onvif.py b/homeassistant/components/camera/onvif.py deleted file mode 100644 index 9cf21dca9..000000000 --- a/homeassistant/components/camera/onvif.py +++ /dev/null @@ -1,231 +0,0 @@ -""" -Support for ONVIF Cameras with FFmpeg as decoder. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/camera.onvif/ -""" -import asyncio -import logging -import os - -import voluptuous as vol - -from homeassistant.const import ( - 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, 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__) - -REQUIREMENTS = ['onvif-py3==0.1.3', - 'suds-py3==1.3.3.0', - 'suds-passworddigest-homeassistant==0.1.2a0.dev0'] -DEPENDENCIES = ['ffmpeg'] -DEFAULT_NAME = 'ONVIF Camera' -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, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - 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]) -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up a ONVIF camera.""" - if not hass.data[DATA_FFMPEG].async_run_test(config.get(CONF_HOST)): - return - - 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_entities([ONVIFHassCamera(hass, config)]) - - -class ONVIFHassCamera(Camera): - """An implementation of an ONVIF camera.""" - - def __init__(self, hass, config): - """Initialize a ONVIF camera.""" - super().__init__() - import onvif - - self._username = config.get(CONF_USERNAME) - self._password = config.get(CONF_PASSWORD) - self._host = config.get(CONF_HOST) - self._port = config.get(CONF_PORT) - self._name = config.get(CONF_NAME) - self._ffmpeg_arguments = config.get(CONF_EXTRA_ARGUMENTS) - self._profile_index = config.get(CONF_PROFILE) - self._input = None - 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): - """Handle entity addition 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 = await asyncio.shield(ffmpeg.get_image( - self._input, output_format=IMAGE_JPEG, - extra_cmd=self._ffmpeg_arguments), loop=self.hass.loop) - return image - - 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) - await stream.open_camera( - self._input, extra_cmd=self._ffmpeg_arguments) - - await async_aiohttp_proxy_stream( - self.hass, request, stream, - 'multipart/x-mixed-replace;boundary=ffserver') - await stream.close() - - @property - def name(self): - """Return the name of this camera.""" - return self._name diff --git a/homeassistant/components/camera/prefs.py b/homeassistant/components/camera/prefs.py new file mode 100644 index 000000000..ae182c62d --- /dev/null +++ b/homeassistant/components/camera/prefs.py @@ -0,0 +1,61 @@ +"""Preference management for camera component.""" +from .const import DOMAIN, PREF_PRELOAD_STREAM + +# mypy: allow-untyped-defs, no-check-untyped-defs + +STORAGE_KEY = DOMAIN +STORAGE_VERSION = 1 +_UNDEF = object() + + +class CameraEntityPreferences: + """Handle preferences for camera entity.""" + + def __init__(self, prefs): + """Initialize prefs.""" + self._prefs = prefs + + def as_dict(self): + """Return dictionary version.""" + return self._prefs + + @property + def preload_stream(self): + """Return if stream is loaded on hass start.""" + return self._prefs.get(PREF_PRELOAD_STREAM, False) + + +class CameraPreferences: + """Handle camera preferences.""" + + def __init__(self, hass): + """Initialize camera prefs.""" + self._hass = hass + self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + self._prefs = None + + async def async_initialize(self): + """Finish initializing the preferences.""" + prefs = await self._store.async_load() + + if prefs is None: + prefs = {} + + self._prefs = prefs + + async def async_update( + self, entity_id, *, preload_stream=_UNDEF, stream_options=_UNDEF + ): + """Update camera preferences.""" + if not self._prefs.get(entity_id): + self._prefs[entity_id] = {} + + for key, value in ((PREF_PRELOAD_STREAM, preload_stream),): + if value is not _UNDEF: + self._prefs[entity_id][key] = value + + await self._store.async_save(self._prefs) + + def get(self, entity_id): + """Get preferences for an entity.""" + return CameraEntityPreferences(self._prefs.get(entity_id, {})) diff --git a/homeassistant/components/camera/proxy.py b/homeassistant/components/camera/proxy.py deleted file mode 100644 index 83d873116..000000000 --- a/homeassistant/components/camera/proxy.py +++ /dev/null @@ -1,198 +0,0 @@ -""" -Proxy camera platform that enables image processing of camera data. - -For more details about this platform, please refer to the documentation -https://www.home-assistant.io/components/camera.proxy/ -""" -import asyncio -import logging - -import voluptuous as vol - -from homeassistant.components.camera import PLATFORM_SCHEMA, Camera -from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, HTTP_HEADER_HA_AUTH -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv -from homeassistant.util.async_ import run_coroutine_threadsafe -import homeassistant.util.dt as dt_util -from . import async_get_still_stream - -REQUIREMENTS = ['pillow==5.2.0'] - -_LOGGER = logging.getLogger(__name__) - -CONF_CACHE_IMAGES = 'cache_images' -CONF_FORCE_RESIZE = 'force_resize' -CONF_IMAGE_QUALITY = 'image_quality' -CONF_IMAGE_REFRESH_RATE = 'image_refresh_rate' -CONF_MAX_IMAGE_WIDTH = 'max_image_width' -CONF_MAX_STREAM_WIDTH = 'max_stream_width' -CONF_STREAM_QUALITY = 'stream_quality' - -DEFAULT_BASENAME = "Camera Proxy" -DEFAULT_QUALITY = 75 - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_ENTITY_ID): cv.entity_id, - vol.Optional(CONF_CACHE_IMAGES, False): cv.boolean, - vol.Optional(CONF_FORCE_RESIZE, False): cv.boolean, - vol.Optional(CONF_IMAGE_QUALITY): int, - vol.Optional(CONF_IMAGE_REFRESH_RATE): float, - vol.Optional(CONF_MAX_IMAGE_WIDTH): int, - vol.Optional(CONF_MAX_STREAM_WIDTH): int, - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_STREAM_QUALITY): int, -}) - - -async def async_setup_platform( - hass, config, async_add_entities, discovery_info=None): - """Set up the Proxy camera platform.""" - async_add_entities([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 - - try: - img = Image.open(io.BytesIO(image)) - except IOError: - return image - imgfmt = str(img.format) - if imgfmt not in ('PNG', '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 evaluation 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 - image = await self.hass.components.camera.async_get_image( - self._proxied_camera) - if not image: - _LOGGER.error("Error getting original camera image") - return self._last_image - - image = await self.hass.async_add_job( - _resize_image, image.content, 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.""" - if not self._stream_opts: - return await self.hass.components.camera.async_get_mjpeg_stream( - request, self._proxied_camera) - - return await async_get_still_stream( - request, self._async_stream_image, - self.content_type, self.frame_interval) - - @property - def name(self): - """Return the name of this camera.""" - return self._name - - async def _async_stream_image(self): - """Return a still image response from the camera.""" - try: - image = await self.hass.components.camera.async_get_image( - self._proxied_camera) - if not image: - return None - except HomeAssistantError: - raise asyncio.CancelledError - - return await self.hass.async_add_job( - _resize_image, image.content, self._stream_opts) diff --git a/homeassistant/components/camera/push.py b/homeassistant/components/camera/push.py deleted file mode 100644 index c9deca130..000000000 --- a/homeassistant/components/camera/push.py +++ /dev/null @@ -1,194 +0,0 @@ -""" -Camera platform that receives images through HTTP POST. - -For more details about this platform, please refer to the documentation -https://home-assistant.io/components/camera.push/ -""" -import logging - -from collections import deque -from datetime import timedelta -import voluptuous as vol - -from homeassistant.components.camera import Camera, PLATFORM_SCHEMA,\ - STATE_IDLE, STATE_RECORDING -from homeassistant.core import callback -from homeassistant.components.http.view import KEY_AUTHENTICATED,\ - HomeAssistantView -from homeassistant.const import CONF_NAME, CONF_TIMEOUT,\ - HTTP_NOT_FOUND, HTTP_UNAUTHORIZED, HTTP_BAD_REQUEST -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.event import async_track_point_in_utc_time -import homeassistant.util.dt as dt_util - -_LOGGER = logging.getLogger(__name__) - -DEPENDENCIES = ['http'] - -CONF_BUFFER_SIZE = 'buffer' -CONF_IMAGE_FIELD = 'field' -CONF_TOKEN = 'token' - -DEFAULT_NAME = "Push Camera" - -ATTR_FILENAME = 'filename' -ATTR_LAST_TRIP = 'last_trip' -ATTR_TOKEN = 'token' - -PUSH_CAMERA_DATA = 'push_camera' - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_BUFFER_SIZE, default=1): cv.positive_int, - vol.Optional(CONF_TIMEOUT, default=timedelta(seconds=5)): vol.All( - cv.time_period, cv.positive_timedelta), - vol.Optional(CONF_IMAGE_FIELD, default='image'): cv.string, - vol.Optional(CONF_TOKEN): vol.All(cv.string, vol.Length(min=8)), -}) - - -async def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): - """Set up the Push Camera platform.""" - if PUSH_CAMERA_DATA not in hass.data: - hass.data[PUSH_CAMERA_DATA] = {} - - cameras = [PushCamera(config[CONF_NAME], - config[CONF_BUFFER_SIZE], - config[CONF_TIMEOUT], - config.get(CONF_TOKEN))] - - hass.http.register_view(CameraPushReceiver(hass, - config[CONF_IMAGE_FIELD])) - - async_add_entities(cameras) - - -class CameraPushReceiver(HomeAssistantView): - """Handle pushes from remote camera.""" - - url = "/api/camera_push/{entity_id}" - name = 'api:camera_push:camera_entity' - requires_auth = False - - def __init__(self, hass, image_field): - """Initialize CameraPushReceiver with camera entity.""" - self._cameras = hass.data[PUSH_CAMERA_DATA] - self._image = image_field - - async def post(self, request, entity_id): - """Accept the POST from Camera.""" - _camera = self._cameras.get(entity_id) - - if _camera is None: - _LOGGER.error("Unknown %s", entity_id) - status = HTTP_NOT_FOUND if request[KEY_AUTHENTICATED]\ - else HTTP_UNAUTHORIZED - return self.json_message('Unknown {}'.format(entity_id), - status) - - # Supports HA authentication and token based - # when token has been configured - authenticated = (request[KEY_AUTHENTICATED] or - (_camera.token is not None and - request.query.get('token') == _camera.token)) - - if not authenticated: - return self.json_message( - 'Invalid authorization credentials for {}'.format(entity_id), - HTTP_UNAUTHORIZED) - - try: - data = await request.post() - _LOGGER.debug("Received Camera push: %s", data[self._image]) - await _camera.update_image(data[self._image].file.read(), - data[self._image].filename) - except ValueError as value_error: - _LOGGER.error("Unknown value %s", value_error) - return self.json_message('Invalid POST', HTTP_BAD_REQUEST) - except KeyError as key_error: - _LOGGER.error('In your POST message %s', key_error) - return self.json_message('{} missing'.format(self._image), - HTTP_BAD_REQUEST) - - -class PushCamera(Camera): - """The representation of a Push camera.""" - - def __init__(self, name, buffer_size, timeout, token): - """Initialize push camera component.""" - super().__init__() - self._name = name - self._last_trip = None - self._filename = None - self._expired_listener = None - self._state = STATE_IDLE - self._timeout = timeout - self.queue = deque([], buffer_size) - self._current_image = None - self.token = token - - async def async_added_to_hass(self): - """Call when entity is added to hass.""" - self.hass.data[PUSH_CAMERA_DATA][self.entity_id] = self - - @property - def state(self): - """Return current state of the camera.""" - return self._state - - async def update_image(self, image, filename): - """Update the camera image.""" - if self._state == STATE_IDLE: - self._state = STATE_RECORDING - self._last_trip = dt_util.utcnow() - self.queue.clear() - - self._filename = filename - self.queue.appendleft(image) - - @callback - def reset_state(now): - """Set state to idle after no new images for a period of time.""" - self._state = STATE_IDLE - self._expired_listener = None - _LOGGER.debug("Reset state") - self.async_schedule_update_ha_state() - - if self._expired_listener: - self._expired_listener() - - self._expired_listener = async_track_point_in_utc_time( - self.hass, reset_state, dt_util.utcnow() + self._timeout) - - self.async_schedule_update_ha_state() - - async def async_camera_image(self): - """Return a still image response.""" - if self.queue: - if self._state == STATE_IDLE: - self.queue.rotate(1) - self._current_image = self.queue[0] - - return self._current_image - - @property - def name(self): - """Return the name of this camera.""" - return self._name - - @property - def motion_detection_enabled(self): - """Camera Motion Detection Status.""" - return False - - @property - def device_state_attributes(self): - """Return the state attributes.""" - return { - name: value for name, value in ( - (ATTR_LAST_TRIP, self._last_trip), - (ATTR_FILENAME, self._filename), - (ATTR_TOKEN, self.token), - ) if value is not None - } diff --git a/homeassistant/components/camera/ring.py b/homeassistant/components/camera/ring.py deleted file mode 100644 index f629b5018..000000000 --- a/homeassistant/components/camera/ring.py +++ /dev/null @@ -1,168 +0,0 @@ -""" -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_entities, - 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_entities(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 deleted file mode 100644 index ba6f5e933..000000000 --- a/homeassistant/components/camera/rpi_camera.py +++ /dev/null @@ -1,151 +0,0 @@ -""" -Camera platform that has a Raspberry Pi camera. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/camera.rpi_camera/ -""" -import os -import subprocess -import logging -import shutil -from tempfile import NamedTemporaryFile - -import voluptuous as vol - -from homeassistant.components.camera import (Camera, PLATFORM_SCHEMA) -from homeassistant.const import (CONF_NAME, CONF_FILE_PATH, - EVENT_HOMEASSISTANT_STOP) -from homeassistant.helpers import config_validation as cv - -_LOGGER = logging.getLogger(__name__) - -CONF_HORIZONTAL_FLIP = 'horizontal_flip' -CONF_IMAGE_HEIGHT = 'image_height' -CONF_IMAGE_QUALITY = 'image_quality' -CONF_IMAGE_ROTATION = 'image_rotation' -CONF_IMAGE_WIDTH = 'image_width' -CONF_TIMELAPSE = 'timelapse' -CONF_VERTICAL_FLIP = 'vertical_flip' - -DEFAULT_HORIZONTAL_FLIP = 0 -DEFAULT_IMAGE_HEIGHT = 480 -DEFAULT_IMAGE_QUALITY = 7 -DEFAULT_IMAGE_ROTATION = 0 -DEFAULT_IMAGE_WIDTH = 640 -DEFAULT_NAME = 'Raspberry Pi Camera' -DEFAULT_TIMELAPSE = 1000 -DEFAULT_VERTICAL_FLIP = 0 - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - 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_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)), - vol.Optional(CONF_IMAGE_WIDTH, default=DEFAULT_IMAGE_WIDTH): - vol.Coerce(int), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_TIMELAPSE, default=1000): vol.Coerce(int), - vol.Optional(CONF_VERTICAL_FLIP, default=DEFAULT_VERTICAL_FLIP): - vol.All(vol.Coerce(int), vol.Range(min=0, max=1)), -}) - - -def kill_raspistill(*args): - """Kill any previously running raspistill process..""" - subprocess.Popen(['killall', 'raspistill'], - stdout=subprocess.DEVNULL, - stderr=subprocess.STDOUT) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Raspberry Camera.""" - if shutil.which("raspistill") is None: - _LOGGER.error("'raspistill' was not found") - return False - - setup_config = ( - { - CONF_NAME: config.get(CONF_NAME), - CONF_IMAGE_WIDTH: config.get(CONF_IMAGE_WIDTH), - CONF_IMAGE_HEIGHT: config.get(CONF_IMAGE_HEIGHT), - CONF_IMAGE_QUALITY: config.get(CONF_IMAGE_QUALITY), - CONF_IMAGE_ROTATION: config.get(CONF_IMAGE_ROTATION), - 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) - } - ) - - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, kill_raspistill) - - file_path = setup_config[CONF_FILE_PATH] - - 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_entities([RaspberryCamera(setup_config)]) - - -class RaspberryCamera(Camera): - """Representation of a Raspberry Pi camera.""" - - def __init__(self, device_info): - """Initialize Raspberry Pi camera component.""" - super().__init__() - - self._name = device_info[CONF_NAME] - self._config = device_info - - # Kill if there's raspistill instance - kill_raspistill() - - cmd_args = [ - 'raspistill', '--nopreview', '-o', device_info[CONF_FILE_PATH], - '-t', '0', '-w', str(device_info[CONF_IMAGE_WIDTH]), - '-h', str(device_info[CONF_IMAGE_HEIGHT]), - '-tl', str(device_info[CONF_TIMELAPSE]), - '-q', str(device_info[CONF_IMAGE_QUALITY]), - '-rot', str(device_info[CONF_IMAGE_ROTATION]) - ] - if device_info[CONF_HORIZONTAL_FLIP]: - cmd_args.append("-hf") - - if device_info[CONF_VERTICAL_FLIP]: - cmd_args.append("-vf") - - subprocess.Popen(cmd_args, - stdout=subprocess.DEVNULL, - stderr=subprocess.STDOUT) - - def camera_image(self): - """Return raspistill image response.""" - with open(self._config[CONF_FILE_PATH], 'rb') as file: - return file.read() - - @property - def name(self): - """Return the name of this camera.""" - return self._name diff --git a/homeassistant/components/camera/services.yaml b/homeassistant/components/camera/services.yaml index b977fcd5c..c50e2926a 100644 --- a/homeassistant/components/camera/services.yaml +++ b/homeassistant/components/camera/services.yaml @@ -38,15 +38,35 @@ snapshot: description: Template of a Filename. Variable is entity_id. example: '/tmp/snapshot_{{ entity_id }}' -local_file_update_file_path: - description: Update the file_path for a local_file camera. +play_stream: + description: Play camera stream on supported media player. 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' + description: Name(s) of entities to stream from. + example: 'camera.living_room_camera' + media_player: + description: Name(s) of media player to stream to. + example: 'media_player.living_room_tv' + format: + description: (Optional) Stream format supported by media player. + example: 'hls' + +record: + description: Record live camera feed. + fields: + entity_id: + description: Name of entities to record. + example: 'camera.living_room_camera' + filename: + description: Template of a Filename. Variable is entity_id. Must be mp4. + example: '/tmp/snapshot_{{ entity_id }}.mp4' + duration: + description: (Optional) Target recording length (in seconds). + default: 30 + example: 30 + lookback: + description: (Optional) Target lookback period (in seconds) to include in addition to duration. Only available if there is currently an active HLS stream. + example: 4 onvif_ptz: description: Pan/Tilt/Zoom service for ONVIF camera. diff --git a/homeassistant/components/camera/skybell.py b/homeassistant/components/camera/skybell.py deleted file mode 100644 index 9a7d7a069..000000000 --- a/homeassistant/components/camera/skybell.py +++ /dev/null @@ -1,67 +0,0 @@ -""" -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_entities, 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_entities(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 deleted file mode 100644 index 3e587fff2..000000000 --- a/homeassistant/components/camera/synology.py +++ /dev/null @@ -1,133 +0,0 @@ -""" -Support for Synology Surveillance Station Cameras. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/camera.synology/ -""" -import asyncio -import logging - -import requests -import voluptuous as vol - -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_aiohttp_proxy_web, - async_get_clientsession) -import homeassistant.helpers.config_validation as cv - -REQUIREMENTS = ['py-synology==0.2.0'] - -_LOGGER = logging.getLogger(__name__) - -DEFAULT_NAME = 'Synology Camera' -DEFAULT_TIMEOUT = 5 - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_URL): cv.string, - vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, - vol.Optional(CONF_WHITELIST, default=[]): cv.ensure_list, - vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, -}) - - -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): - """Set up a Synology IP Camera.""" - verify_ssl = config.get(CONF_VERIFY_SSL) - timeout = config.get(CONF_TIMEOUT) - - try: - 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 - - cameras = surveillance.get_all_cameras() - - # add cameras - devices = [] - for camera in cameras: - if not config.get(CONF_WHITELIST): - device = SynologyCamera(surveillance, camera.camera_id, verify_ssl) - devices.append(device) - - async_add_entities(devices) - - -class SynologyCamera(Camera): - """An implementation of a Synology NAS based IP camera.""" - - def __init__(self, surveillance, camera_id, verify_ssl): - """Initialize a Synology Surveillance Station camera.""" - super().__init__() - self._surveillance = surveillance - self._camera_id = camera_id - self._verify_ssl = verify_ssl - self._camera = self._surveillance.get_camera(camera_id) - self._motion_setting = self._surveillance.get_motion_setting(camera_id) - self.is_streaming = self._camera.is_enabled - - def camera_image(self): - """Return bytes of camera 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 = 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._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 deleted file mode 100644 index d23359d8c..000000000 --- a/homeassistant/components/camera/usps.py +++ /dev/null @@ -1,94 +0,0 @@ -""" -Support for a camera made up of usps mail images. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/camera.usps/ -""" -from datetime import timedelta -import logging - -from homeassistant.components.camera import Camera -from homeassistant.components.usps import DATA_USPS - -_LOGGER = logging.getLogger(__name__) - -DEPENDENCIES = ['usps'] - -SCAN_INTERVAL = timedelta(seconds=10) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up USPS mail camera.""" - if discovery_info is None: - return - - usps = hass.data[DATA_USPS] - add_entities([USPSCamera(usps)]) - - -class USPSCamera(Camera): - """Representation of the images available from USPS.""" - - def __init__(self, usps): - """Initialize the USPS camera images.""" - super().__init__() - - self._usps = usps - self._name = self._usps.name - self._session = self._usps.session - - self._mail_img = [] - self._last_mail = None - self._mail_index = 0 - self._mail_count = 0 - - self._timer = None - - def camera_image(self): - """Update the camera's image if it has changed.""" - self._usps.update() - try: - self._mail_count = len(self._usps.mail) - except TypeError: - # No mail - return None - - if self._usps.mail != self._last_mail: - # Mail items must have changed - self._mail_img = [] - if len(self._usps.mail) >= 1: - self._last_mail = self._usps.mail - for article in self._usps.mail: - _LOGGER.debug("Fetching article image: %s", article) - img = self._session.get(article['image']).content - self._mail_img.append(img) - - try: - return self._mail_img[self._mail_index] - except IndexError: - return None - - @property - def name(self): - """Return the name of this camera.""" - return '{} mail'.format(self._name) - - @property - def model(self): - """Return date of mail as model.""" - try: - return 'Date: {}'.format(str(self._usps.mail[0]['date'])) - except IndexError: - return None - - @property - def should_poll(self): - """Update the mail image index periodically.""" - return True - - def update(self): - """Update mail image index.""" - if self._mail_index < (self._mail_count - 1): - self._mail_index += 1 - else: - self._mail_index = 0 diff --git a/homeassistant/components/camera/uvc.py b/homeassistant/components/camera/uvc.py deleted file mode 100644 index 0e65ac77c..000000000 --- a/homeassistant/components/camera/uvc.py +++ /dev/null @@ -1,201 +0,0 @@ -""" -Support for Ubiquiti's UVC cameras. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/camera.uvc/ -""" -import logging -import socket - -import requests -import voluptuous as vol - -from homeassistant.const import CONF_PORT -from homeassistant.components.camera import Camera, PLATFORM_SCHEMA -import homeassistant.helpers.config_validation as cv -from homeassistant.exceptions import PlatformNotReady - -REQUIREMENTS = ['uvcclient==0.10.1'] - -_LOGGER = logging.getLogger(__name__) - -CONF_NVR = 'nvr' -CONF_KEY = 'key' -CONF_PASSWORD = 'password' - -DEFAULT_PASSWORD = 'ubnt' -DEFAULT_PORT = 7080 - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_NVR): cv.string, - vol.Required(CONF_KEY): cv.string, - vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Discover cameras on a Unifi NVR.""" - addr = config[CONF_NVR] - key = config[CONF_KEY] - password = config[CONF_PASSWORD] - port = config[CONF_PORT] - - from uvcclient import nvr - try: - # Exceptions may be raised in all method calls to the nvr library. - nvrconn = nvr.UVCRemote(addr, port, key) - cameras = nvrconn.index() - - identifier = 'id' if nvrconn.server_version >= (3, 2, 0) else 'uuid' - # Filter out airCam models, which are not supported in the latest - # version of UnifiVideo and which are EOL by Ubiquiti - cameras = [ - camera for camera in cameras - if 'airCam' not in nvrconn.get_camera(camera[identifier])['model']] - except nvr.NotAuthorized: - _LOGGER.error("Authorization failure while connecting to NVR") - return False - except nvr.NvrError as ex: - _LOGGER.error("NVR refuses to talk to me: %s", str(ex)) - raise PlatformNotReady - except requests.exceptions.ConnectionError as ex: - _LOGGER.error("Unable to connect to NVR: %s", str(ex)) - raise PlatformNotReady - - add_entities([UnifiVideoCamera(nvrconn, - camera[identifier], - camera['name'], - password) - for camera in cameras]) - return True - - -class UnifiVideoCamera(Camera): - """A Ubiquiti Unifi Video Camera.""" - - def __init__(self, nvr, uuid, name, password): - """Initialize an Unifi camera.""" - super(UnifiVideoCamera, self).__init__() - self._nvr = nvr - self._uuid = uuid - self._name = name - self._password = password - self.is_streaming = False - self._connect_addr = None - self._camera = None - self._motion_status = False - - @property - def name(self): - """Return the name of this camera.""" - return self._name - - @property - def is_recording(self): - """Return true if the camera is recording.""" - 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.""" - return 'Ubiquiti' - - @property - def model(self): - """Return the model of this camera.""" - caminfo = self._nvr.get_camera(self._uuid) - return caminfo['model'] - - def _login(self): - """Login to the camera.""" - from uvcclient import camera as uvc_camera - - caminfo = self._nvr.get_camera(self._uuid) - if self._connect_addr: - addrs = [self._connect_addr] - else: - addrs = [caminfo['host'], caminfo['internalHost']] - - if self._nvr.server_version >= (3, 2, 0): - client_cls = uvc_camera.UVCCameraClientV320 - else: - client_cls = uvc_camera.UVCCameraClient - - if caminfo['username'] is None: - caminfo['username'] = 'ubnt' - - camera = None - for addr in addrs: - try: - camera = client_cls( - addr, caminfo['username'], self._password) - camera.login() - _LOGGER.debug("Logged into UVC camera %(name)s via %(addr)s", - dict(name=self._name, addr=addr)) - self._connect_addr = addr - break - except socket.error: - pass - except uvc_camera.CameraConnectError: - pass - except uvc_camera.CameraAuthError: - pass - if not self._connect_addr: - _LOGGER.error("Unable to login to camera") - return None - - self._camera = camera - return True - - def camera_image(self): - """Return the image of this camera.""" - from uvcclient import camera as uvc_camera - if not self._camera: - if not self._login(): - return - - def _get_image(retry=True): - try: - return self._camera.get_snapshot() - except uvc_camera.CameraConnectError: - _LOGGER.error("Unable to contact camera") - except uvc_camera.CameraAuthError: - if retry: - self._login() - return _get_image(retry=False) - _LOGGER.error( - "Unable to log into camera, unable to get snapshot") - 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/verisure.py b/homeassistant/components/camera/verisure.py deleted file mode 100644 index 01e4e82f3..000000000 --- a/homeassistant/components/camera/verisure.py +++ /dev/null @@ -1,99 +0,0 @@ -""" -Camera that loads a picture from a local file. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/camera.verisure/ -""" -import errno -import logging -import os - -from homeassistant.components.camera import Camera -from homeassistant.const import EVENT_HOMEASSISTANT_STOP -from homeassistant.components.verisure import HUB as hub -from homeassistant.components.verisure import CONF_SMARTCAM - -_LOGGER = logging.getLogger(__name__) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Verisure Camera.""" - if not int(hub.config.get(CONF_SMARTCAM, 1)): - return False - directory_path = hass.config.config_dir - if not os.access(directory_path, os.R_OK): - _LOGGER.error("file path %s is not readable", directory_path) - return False - hub.update_overview() - smartcams = [] - smartcams.extend([ - VerisureSmartcam(hass, device_label, directory_path) - for device_label in hub.get( - "$.customerImageCameras[*].deviceLabel")]) - add_entities(smartcams) - - -class VerisureSmartcam(Camera): - """Representation of a Verisure camera.""" - - def __init__(self, hass, device_label, directory_path): - """Initialize Verisure File Camera component.""" - super().__init__() - - self._device_label = device_label - self._directory_path = directory_path - self._image = None - self._image_id = None - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, - self.delete_image) - - def camera_image(self): - """Return image response.""" - self.check_imagelist() - if not self._image: - _LOGGER.debug("No image to display") - return - _LOGGER.debug("Trying to open %s", self._image) - with open(self._image, 'rb') as file: - return file.read() - - def check_imagelist(self): - """Check the contents of the image list.""" - hub.update_smartcam_imageseries() - image_ids = hub.get_image_info( - "$.imageSeries[?(@.deviceLabel=='%s')].image[0].imageId", - self._device_label) - if not image_ids: - return - new_image_id = image_ids[0] - if new_image_id in ('-1', self._image_id): - _LOGGER.debug("The image is the same, or loading image_id") - return - _LOGGER.debug("Download new image %s", new_image_id) - new_image_path = os.path.join( - self._directory_path, '{}{}'.format(new_image_id, '.jpg')) - hub.session.download_image( - self._device_label, new_image_id, new_image_path) - _LOGGER.debug("Old image_id=%s", self._image_id) - self.delete_image(self) - - self._image_id = new_image_id - self._image = new_image_path - - def delete_image(self, event): - """Delete an old image.""" - remove_image = os.path.join( - self._directory_path, '{}{}'.format(self._image_id, '.jpg')) - try: - os.remove(remove_image) - _LOGGER.debug("Deleting old image %s", remove_image) - except OSError as error: - if error.errno != errno.ENOENT: - raise - - @property - def name(self): - """Return the name of this camera.""" - return hub.get_first( - "$.customerImageCameras[?(@.deviceLabel=='%s')].area", - self._device_label) diff --git a/homeassistant/components/camera/xeoma.py b/homeassistant/components/camera/xeoma.py deleted file mode 100644 index c268c3533..000000000 --- a/homeassistant/components/camera/xeoma.py +++ /dev/null @@ -1,118 +0,0 @@ -""" -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_entities, - 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, []): - 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_entities( - [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/xiaomi.py b/homeassistant/components/camera/xiaomi.py deleted file mode 100644 index da36299a2..000000000 --- a/homeassistant/components/camera/xiaomi.py +++ /dev/null @@ -1,164 +0,0 @@ -""" -This component provides support for Xiaomi Cameras. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/camera.xiaomi/ -""" -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 = 'Xiaomi Home Camera' -DEFAULT_PATH = '/media/mmcblk0p1/record' -DEFAULT_PORT = 21 -DEFAULT_USERNAME = 'root' - -CONF_FFMPEG_ARGUMENTS = 'ffmpeg_arguments' -CONF_MODEL = 'model' - -MODEL_YI = 'yi' -MODEL_XIAOFANG = 'xiaofang' - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_NAME): cv.string, - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_MODEL): vol.Any(MODEL_YI, - MODEL_XIAOFANG), - 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_entities, - discovery_info=None): - """Set up a Xiaomi Camera.""" - _LOGGER.debug('Received configuration for model %s', config[CONF_MODEL]) - async_add_entities([XiaomiCamera(hass, config)]) - - -class XiaomiCamera(Camera): - """Define an implementation of a Xiaomi 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[CONF_NAME] - self.host = config[CONF_HOST] - self._model = config[CONF_MODEL] - self.port = config[CONF_PORT] - self.path = config[CONF_PATH] - self.user = config[CONF_USERNAME] - self.passwd = config[CONF_PASSWORD] - - @property - def name(self): - """Return the name of this camera.""" - return self._name - - @property - def brand(self): - """Return the camera brand.""" - return DEFAULT_BRAND - - @property - def model(self): - """Return the camera model.""" - return self._model - - def get_latest_video_url(self): - """Retrieve the latest video file from the Xiaomi Camera 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('Camera login failed: %s', exc) - return False - - try: - ftp.cwd(self.path) - except error_perm as exc: - _LOGGER.error('Unable to find path: %s - %s', self.path, 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 folders") - return False - - first_dir = dirs[-1] - try: - ftp.cwd(first_dir) - except error_perm as exc: - _LOGGER.error('Unable to find path: %s - %s', first_dir, exc) - return False - - if self._model == MODEL_XIAOFANG: - 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 = [v for v in ftp.nlst() if '.tmp' not in v] - if not videos: - _LOGGER.info('Video folder "%s" is empty; delaying', latest_dir) - return False - - if self._model == MODEL_XIAOFANG: - video = videos[-2] - else: - video = videos[-1] - - return 'ftp://{0}:{1}@{2}:{3}{4}/{5}'.format( - self.user, self.passwd, self.host, self.port, ftp.pwd(), video) - - 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/camera/yi.py b/homeassistant/components/camera/yi.py deleted file mode 100644 index eb26c1cc8..000000000 --- a/homeassistant/components/camera/yi.py +++ /dev/null @@ -1,150 +0,0 @@ -""" -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 -from homeassistant.exceptions import PlatformNotReady - -REQUIREMENTS = ['aioftp==0.10.1'] -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_entities, discovery_info=None): - """Set up a Yi Camera.""" - async_add_entities([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[CONF_NAME] - self._is_on = True - self.host = config[CONF_HOST] - self.port = config[CONF_PORT] - self.path = config[CONF_PATH] - self.user = config[CONF_USERNAME] - self.passwd = config[CONF_PASSWORD] - - @property - def brand(self): - """Camera brand.""" - return DEFAULT_BRAND - - @property - def is_on(self): - """Determine whether the camera is on.""" - return self._is_on - - @property - def name(self): - """Return the name of this camera.""" - return self._name - - async def _get_latest_video_url(self): - """Retrieve the latest video file from the customized Yi FTP server.""" - from aioftp import Client, StatusCodeError - - ftp = Client(loop=self.hass.loop) - try: - await ftp.connect(self.host) - await ftp.login(self.user, self.passwd) - except (ConnectionRefusedError, StatusCodeError) as err: - raise PlatformNotReady(err) - - try: - await ftp.change_directory(self.path) - dirs = [] - for path, attrs in await ftp.list(): - if attrs['type'] == 'dir' and '.' not in str(path): - dirs.append(path) - latest_dir = dirs[-1] - await ftp.change_directory(latest_dir) - - videos = [] - for path, _ in await ftp.list(): - videos.append(path) - if not videos: - _LOGGER.info('Video folder "%s" empty; delaying', latest_dir) - return None - - await ftp.quit() - self._is_on = True - return 'ftp://{0}:{1}@{2}:{3}{4}/{5}/{6}'.format( - self.user, self.passwd, self.host, self.port, self.path, - latest_dir, videos[-1]) - except (ConnectionRefusedError, StatusCodeError) as err: - _LOGGER.error('Error while fetching video: %s', err) - self._is_on = False - return None - - async def async_camera_image(self): - """Return a still image response from the camera.""" - from haffmpeg import ImageFrame, IMAGE_JPEG - - url = await self._get_latest_video_url() - if url and 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 - - if not self._is_on: - return - - 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/camera/zoneminder.py b/homeassistant/components/camera/zoneminder.py deleted file mode 100644 index 55d8d91d3..000000000 --- a/homeassistant/components/camera/zoneminder.py +++ /dev/null @@ -1,62 +0,0 @@ -""" -Support for ZoneMinder camera streaming. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/camera.zoneminder/ -""" -import logging - -from homeassistant.const import CONF_NAME -from homeassistant.components.camera.mjpeg import ( - CONF_MJPEG_URL, CONF_STILL_IMAGE_URL, MjpegCamera) -from homeassistant.components.zoneminder import DOMAIN as ZONEMINDER_DOMAIN - -_LOGGER = logging.getLogger(__name__) - -DEPENDENCIES = ['zoneminder'] - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the ZoneMinder cameras.""" - zm_client = hass.data[ZONEMINDER_DOMAIN] - - monitors = zm_client.get_monitors() - if not monitors: - _LOGGER.warning("Could not fetch monitors from ZoneMinder") - return - - cameras = [] - for monitor in monitors: - _LOGGER.info("Initializing camera %s", monitor.id) - cameras.append(ZoneMinderCamera(monitor)) - add_entities(cameras) - - -class ZoneMinderCamera(MjpegCamera): - """Representation of a ZoneMinder Monitor Stream.""" - - def __init__(self, monitor): - """Initialize as a subclass of MjpegCamera.""" - device_info = { - CONF_NAME: monitor.name, - CONF_MJPEG_URL: monitor.mjpeg_image_url, - CONF_STILL_IMAGE_URL: monitor.still_image_url - } - super().__init__(device_info) - self._is_recording = None - self._monitor = monitor - - @property - def should_poll(self): - """Update the recording state periodically.""" - return True - - def update(self): - """Update our recording state from the ZM API.""" - _LOGGER.debug("Updating camera state for monitor %i", self._monitor.id) - self._is_recording = self._monitor.is_recording - - @property - def is_recording(self): - """Return whether the monitor is in alarm mode.""" - return self._is_recording diff --git a/homeassistant/components/canary.py b/homeassistant/components/canary.py deleted file mode 100644 index 04c33d83f..000000000 --- a/homeassistant/components/canary.py +++ /dev/null @@ -1,128 +0,0 @@ -""" -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: - """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/canary/__init__.py b/homeassistant/components/canary/__init__.py new file mode 100644 index 000000000..a8a45f5b9 --- /dev/null +++ b/homeassistant/components/canary/__init__.py @@ -0,0 +1,133 @@ +"""Support for Canary devices.""" +from datetime import timedelta +import logging + +from canary.api import Api +from requests import ConnectTimeout, HTTPError +import voluptuous as vol + +from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv +from homeassistant.util import Throttle + +_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: + """Get the latest data and update the states.""" + + def __init__(self, username, password, timeout): + """Init the Canary data object.""" + + 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/canary/alarm_control_panel.py b/homeassistant/components/canary/alarm_control_panel.py new file mode 100644 index 000000000..cceb78743 --- /dev/null +++ b/homeassistant/components/canary/alarm_control_panel.py @@ -0,0 +1,96 @@ +"""Support for Canary alarm.""" +import logging + +from canary.api import LOCATION_MODE_AWAY, LOCATION_MODE_HOME, LOCATION_MODE_NIGHT + +from homeassistant.components.alarm_control_panel import AlarmControlPanel +from homeassistant.components.alarm_control_panel.const import ( + SUPPORT_ALARM_ARM_AWAY, + SUPPORT_ALARM_ARM_HOME, + SUPPORT_ALARM_ARM_NIGHT, +) +from homeassistant.const import ( + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_DISARMED, +) + +from . import DATA_CANARY + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Canary alarms.""" + data = hass.data[DATA_CANARY] + devices = [] + + for location in data.locations: + devices.append(CanaryAlarm(data, location.location_id)) + + add_entities(devices, True) + + +class CanaryAlarm(AlarmControlPanel): + """Representation of a Canary alarm control panel.""" + + def __init__(self, data, location_id): + """Initialize a Canary security camera.""" + self._data = data + self._location_id = location_id + + @property + def name(self): + """Return the name of the alarm.""" + location = self._data.get_location(self._location_id) + return location.name + + @property + def state(self): + """Return the state of the device.""" + + location = self._data.get_location(self._location_id) + + if location.is_private: + return STATE_ALARM_DISARMED + + mode = location.mode + if mode.name == LOCATION_MODE_AWAY: + return STATE_ALARM_ARMED_AWAY + if mode.name == LOCATION_MODE_HOME: + return STATE_ALARM_ARMED_HOME + if mode.name == LOCATION_MODE_NIGHT: + return STATE_ALARM_ARMED_NIGHT + return None + + @property + def supported_features(self) -> int: + """Return the list of supported features.""" + return SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY | SUPPORT_ALARM_ARM_NIGHT + + @property + def device_state_attributes(self): + """Return the state attributes.""" + location = self._data.get_location(self._location_id) + return {"private": location.is_private} + + def alarm_disarm(self, code=None): + """Send disarm command.""" + location = self._data.get_location(self._location_id) + self._data.set_location_mode(self._location_id, location.mode.name, True) + + def alarm_arm_home(self, code=None): + """Send arm home command.""" + + self._data.set_location_mode(self._location_id, LOCATION_MODE_HOME) + + def alarm_arm_away(self, code=None): + """Send arm away command.""" + + self._data.set_location_mode(self._location_id, LOCATION_MODE_AWAY) + + def alarm_arm_night(self, code=None): + """Send arm night command.""" + + self._data.set_location_mode(self._location_id, LOCATION_MODE_NIGHT) diff --git a/homeassistant/components/canary/camera.py b/homeassistant/components/canary/camera.py new file mode 100644 index 000000000..7ed1e62ab --- /dev/null +++ b/homeassistant/components/canary/camera.py @@ -0,0 +1,120 @@ +"""Support for Canary camera.""" +import asyncio +from datetime import timedelta +import logging + +from haffmpeg.camera import CameraMjpeg +from haffmpeg.tools import IMAGE_JPEG, ImageFrame +import voluptuous as vol + +from homeassistant.components.camera import PLATFORM_SCHEMA, Camera +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 + +from . import DATA_CANARY, DEFAULT_TIMEOUT + +_LOGGER = logging.getLogger(__name__) + +CONF_FFMPEG_ARGUMENTS = "ffmpeg_arguments" +DEFAULT_ARGUMENTS = "-pred 1" + +MIN_TIME_BETWEEN_SESSION_RENEW = timedelta(seconds=90) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + {vol.Optional(CONF_FFMPEG_ARGUMENTS, default=DEFAULT_ARGUMENTS): cv.string} +) + + +def setup_platform(hass, config, add_entities, 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_entities(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 + + async def async_camera_image(self): + """Return a still image response from the camera.""" + self.renew_live_stream_session() + + ffmpeg = ImageFrame(self._ffmpeg.binary, loop=self.hass.loop) + image = await asyncio.shield( + ffmpeg.get_image( + self._live_stream_session.live_stream_url, + output_format=IMAGE_JPEG, + extra_cmd=self._ffmpeg_arguments, + ) + ) + return image + + async def handle_async_mjpeg_stream(self, request): + """Generate an HTTP MJPEG stream from the camera.""" + if self._live_stream_session is None: + return + + stream = CameraMjpeg(self._ffmpeg.binary, loop=self.hass.loop) + await stream.open_camera( + self._live_stream_session.live_stream_url, extra_cmd=self._ffmpeg_arguments + ) + + try: + stream_reader = await stream.get_reader() + return await async_aiohttp_proxy_stream( + self.hass, + request, + stream_reader, + self._ffmpeg.ffmpeg_stream_content_type, + ) + finally: + await 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/canary/manifest.json b/homeassistant/components/canary/manifest.json new file mode 100644 index 000000000..f76d52185 --- /dev/null +++ b/homeassistant/components/canary/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "canary", + "name": "Canary", + "documentation": "https://www.home-assistant.io/integrations/canary", + "requirements": [ + "py-canary==0.5.0" + ], + "dependencies": [ + "ffmpeg" + ], + "codeowners": [] +} diff --git a/homeassistant/components/canary/sensor.py b/homeassistant/components/canary/sensor.py new file mode 100644 index 000000000..67654c99f --- /dev/null +++ b/homeassistant/components/canary/sensor.py @@ -0,0 +1,122 @@ +"""Support for Canary sensors.""" +from canary.api import SensorType + +from homeassistant.const import TEMP_CELSIUS +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.icon import icon_for_battery_level + +from . import DATA_CANARY + +SENSOR_VALUE_PRECISION = 2 +ATTR_AIR_QUALITY = "air_quality" + +# Sensor types are defined like so: +# sensor type name, unit_of_measurement, icon +SENSOR_TYPES = [ + ["temperature", TEMP_CELSIUS, "mdi:thermometer", ["Canary"]], + ["humidity", "%", "mdi:water-percent", ["Canary"]], + ["air_quality", None, "mdi:weather-windy", ["Canary"]], + ["wifi", "dBm", "mdi:wifi", ["Canary Flex"]], + ["battery", "%", "mdi:battery-50", ["Canary Flex"]], +] + +STATE_AIR_QUALITY_NORMAL = "normal" +STATE_AIR_QUALITY_ABNORMAL = "abnormal" +STATE_AIR_QUALITY_VERY_ABNORMAL = "very_abnormal" + + +def setup_platform(hass, config, add_entities, 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: + device_type = device.device_type + for sensor_type in SENSOR_TYPES: + if device_type.get("name") in sensor_type[3]: + devices.append( + CanarySensor(data, sensor_type, location, device) + ) + + add_entities(devices, True) + + +class CanarySensor(Entity): + """Representation of a Canary sensor.""" + + def __init__(self, data, sensor_type, location, device): + """Initialize the sensor.""" + self._data = data + self._sensor_type = sensor_type + self._device_id = device.device_id + self._sensor_value = None + + sensor_type_name = sensor_type[0].replace("_", " ").title() + self._name = f"{location.name} {device.name} {sensor_type_name}" + + @property + def name(self): + """Return the name of the Canary sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._sensor_value + + @property + def unique_id(self): + """Return the unique ID of this sensor.""" + return "{}_{}".format(self._device_id, self._sensor_type[0]) + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._sensor_type[1] + + @property + def icon(self): + """Icon for the sensor.""" + if self.state is not None and self._sensor_type[0] == "battery": + return icon_for_battery_level(battery_level=self.state) + + return self._sensor_type[2] + + @property + def device_state_attributes(self): + """Return the state attributes.""" + if self._sensor_type[0] == "air_quality" and self._sensor_value is not None: + air_quality = None + if self._sensor_value <= 0.4: + air_quality = STATE_AIR_QUALITY_VERY_ABNORMAL + elif self._sensor_value <= 0.59: + air_quality = STATE_AIR_QUALITY_ABNORMAL + elif self._sensor_value <= 1.0: + air_quality = STATE_AIR_QUALITY_NORMAL + + return {ATTR_AIR_QUALITY: air_quality} + + return None + + def update(self): + """Get the latest state of the sensor.""" + self._data.update() + + canary_sensor_type = None + if self._sensor_type[0] == "air_quality": + canary_sensor_type = SensorType.AIR_QUALITY + elif self._sensor_type[0] == "temperature": + canary_sensor_type = SensorType.TEMPERATURE + elif self._sensor_type[0] == "humidity": + canary_sensor_type = SensorType.HUMIDITY + elif self._sensor_type[0] == "wifi": + canary_sensor_type = SensorType.WIFI + elif self._sensor_type[0] == "battery": + canary_sensor_type = SensorType.BATTERY + + value = self._data.get_reading(self._device_id, canary_sensor_type) + + if value is not None: + self._sensor_value = round(float(value), SENSOR_VALUE_PRECISION) diff --git a/homeassistant/components/cast/.translations/bg.json b/homeassistant/components/cast/.translations/bg.json new file mode 100644 index 000000000..c56bf118d --- /dev/null +++ b/homeassistant/components/cast/.translations/bg.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u0412 \u043c\u0440\u0435\u0436\u0430\u0442\u0430 \u043d\u044f\u043c\u0430 \u043d\u0430\u043c\u0435\u0440\u0435\u043d\u0438 Google Cast \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430.", + "single_instance_allowed": "\u0420\u0430\u0437\u0440\u0435\u0448\u0435\u043d\u0430 \u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f \u043d\u0430 Google Cast." + }, + "step": { + "confirm": { + "description": "\u0418\u0441\u043a\u0430\u0442\u0435 \u043b\u0438 \u0434\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u0435 Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/ca.json b/homeassistant/components/cast/.translations/ca.json index 570cc7fdc..26236397d 100644 --- a/homeassistant/components/cast/.translations/ca.json +++ b/homeassistant/components/cast/.translations/ca.json @@ -6,7 +6,7 @@ }, "step": { "confirm": { - "description": "Voleu configurar Google Cast?", + "description": "Vols configurar Google Cast?", "title": "Google Cast" } }, diff --git a/homeassistant/components/cast/.translations/de.json b/homeassistant/components/cast/.translations/de.json index a37dbd6f5..ac1ebbeb2 100644 --- a/homeassistant/components/cast/.translations/de.json +++ b/homeassistant/components/cast/.translations/de.json @@ -6,7 +6,7 @@ }, "step": { "confirm": { - "description": "M\u00f6chten Sie Google Cast einrichten?", + "description": "M\u00f6chtest du Google Cast einrichten?", "title": "Google Cast" } }, diff --git a/homeassistant/components/cast/.translations/es.json b/homeassistant/components/cast/.translations/es.json index 918805584..6dc41196a 100644 --- a/homeassistant/components/cast/.translations/es.json +++ b/homeassistant/components/cast/.translations/es.json @@ -1,5 +1,15 @@ { "config": { + "abort": { + "no_devices_found": "No se encontraron dispositivos de Google Cast en la red.", + "single_instance_allowed": "S\u00f3lo es necesaria una \u00fanica configuraci\u00f3n de Google Cast." + }, + "step": { + "confirm": { + "description": "\u00bfQuieres configurar Google Cast?", + "title": "Google Cast" + } + }, "title": "Google Cast" } } \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/et.json b/homeassistant/components/cast/.translations/et.json new file mode 100644 index 000000000..987c54955 --- /dev/null +++ b/homeassistant/components/cast/.translations/et.json @@ -0,0 +1,10 @@ +{ + "config": { + "step": { + "confirm": { + "title": "" + } + }, + "title": "" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/hr.json b/homeassistant/components/cast/.translations/hr.json new file mode 100644 index 000000000..91dafab02 --- /dev/null +++ b/homeassistant/components/cast/.translations/hr.json @@ -0,0 +1,10 @@ +{ + "config": { + "step": { + "confirm": { + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/hu.json b/homeassistant/components/cast/.translations/hu.json index f59a1b43e..66dc4ea8d 100644 --- a/homeassistant/components/cast/.translations/hu.json +++ b/homeassistant/components/cast/.translations/hu.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "no_devices_found": "Nem tal\u00e1lhat\u00f3k Google Cast eszk\u00f6z\u00f6k a h\u00e1l\u00f3zaton." + "no_devices_found": "Nem tal\u00e1lhat\u00f3k Google Cast eszk\u00f6z\u00f6k a h\u00e1l\u00f3zaton.", + "single_instance_allowed": "Csak egyetlen Google Cast konfigur\u00e1ci\u00f3 sz\u00fcks\u00e9ges." }, "step": { "confirm": { diff --git a/homeassistant/components/cast/.translations/id.json b/homeassistant/components/cast/.translations/id.json new file mode 100644 index 000000000..86fb32c08 --- /dev/null +++ b/homeassistant/components/cast/.translations/id.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Tidak ada perangkat Google Cast yang ditemukan pada jaringan.", + "single_instance_allowed": "Hanya satu konfigurasi Google Cast yang diperlukan." + }, + "step": { + "confirm": { + "description": "Apakah Anda ingin menyiapkan Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/ko.json b/homeassistant/components/cast/.translations/ko.json index e4472c88c..1374372aa 100644 --- a/homeassistant/components/cast/.translations/ko.json +++ b/homeassistant/components/cast/.translations/ko.json @@ -1,15 +1,15 @@ { "config": { "abort": { - "no_devices_found": "Googgle Cast \uc7a5\uce58\uac00 \ub124\ud2b8\uc6cc\ud06c\uc5d0\uc11c \ubc1c\uacac\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4.", - "single_instance_allowed": "\ud558\ub098\uc758 Google Cast \ub9cc \uad6c\uc131 \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4." + "no_devices_found": "Google \uce90\uc2a4\ud2b8 \uae30\uae30\uac00 \ub124\ud2b8\uc6cc\ud06c\uc5d0\uc11c \ubc1c\uacac\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4.", + "single_instance_allowed": "\ud558\ub098\uc758 Google \uce90\uc2a4\ud2b8\ub9cc \uad6c\uc131 \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4." }, "step": { "confirm": { - "description": "Google Cast\ub97c \uc124\uc815 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", - "title": "Google Cast" + "description": "Google \uce90\uc2a4\ud2b8\ub97c \uc124\uc815 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "title": "Google \uce90\uc2a4\ud2b8" } }, - "title": "Google Cast" + "title": "Google \uce90\uc2a4\ud2b8" } } \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/nn.json b/homeassistant/components/cast/.translations/nn.json new file mode 100644 index 000000000..7f5501556 --- /dev/null +++ b/homeassistant/components/cast/.translations/nn.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Klar", + "single_instance_allowed": "Du treng berre \u00e5 sette opp \u00e9in Google Cast-konfigurasjon." + }, + "step": { + "confirm": { + "description": "Vil du sette opp Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/no.json b/homeassistant/components/cast/.translations/no.json index d36c929e7..6b8166f23 100644 --- a/homeassistant/components/cast/.translations/no.json +++ b/homeassistant/components/cast/.translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "no_devices_found": "Ingen Google Cast enheter funnet p\u00e5 nettverket.", - "single_instance_allowed": "Kun en enkelt konfigurasjon av Google Cast er n\u00f8dvendig." + "single_instance_allowed": "Kun en konfigurasjon av Google Cast er n\u00f8dvendig." }, "step": { "confirm": { diff --git a/homeassistant/components/cast/.translations/pt-BR.json b/homeassistant/components/cast/.translations/pt-BR.json index bd670d7c7..e26a82948 100644 --- a/homeassistant/components/cast/.translations/pt-BR.json +++ b/homeassistant/components/cast/.translations/pt-BR.json @@ -7,9 +7,9 @@ "step": { "confirm": { "description": "Deseja configurar o Google Cast?", - "title": "" + "title": "Google Cast" } }, - "title": "" + "title": "Google Cast" } } \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/pt.json b/homeassistant/components/cast/.translations/pt.json index a6d285383..85d1b1448 100644 --- a/homeassistant/components/cast/.translations/pt.json +++ b/homeassistant/components/cast/.translations/pt.json @@ -7,9 +7,9 @@ "step": { "confirm": { "description": "Deseja configurar o Google Cast?", - "title": "" + "title": "Google Cast" } }, - "title": "" + "title": "Google Cast" } } \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/ro.json b/homeassistant/components/cast/.translations/ro.json new file mode 100644 index 000000000..8a1d19c0e --- /dev/null +++ b/homeassistant/components/cast/.translations/ro.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nu s-au g\u0103sit dispozitive Google Cast \u00een re\u021bea.", + "single_instance_allowed": "Este necesar\u0103 o singur\u0103 configura\u021bie a serviciului Google Cast." + }, + "step": { + "confirm": { + "description": "Dori\u021bi s\u0103 configura\u021bi Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/ru.json b/homeassistant/components/cast/.translations/ru.json index 9c9353da3..da03eae70 100644 --- a/homeassistant/components/cast/.translations/ru.json +++ b/homeassistant/components/cast/.translations/ru.json @@ -2,11 +2,11 @@ "config": { "abort": { "no_devices_found": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 Google Cast \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b.", - "single_instance_allowed": "\u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u0430 \u0442\u043e\u043b\u044c\u043a\u043e \u043e\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f Google Cast." + "single_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430." }, "step": { "confirm": { - "description": "\u0412\u044b \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c Google Cast?", + "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c Google Cast?", "title": "Google Cast" } }, diff --git a/homeassistant/components/cast/.translations/th.json b/homeassistant/components/cast/.translations/th.json new file mode 100644 index 000000000..372a9cf07 --- /dev/null +++ b/homeassistant/components/cast/.translations/th.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u0e44\u0e21\u0e48\u0e1e\u0e1a\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c Google Cast \u0e1a\u0e19\u0e40\u0e04\u0e23\u0e37\u0e2d\u0e02\u0e48\u0e32\u0e22" + }, + "step": { + "confirm": { + "description": "\u0e04\u0e38\u0e13\u0e15\u0e49\u0e2d\u0e07\u0e01\u0e32\u0e23\u0e15\u0e31\u0e49\u0e07\u0e04\u0e48\u0e32 Google Cast \u0e2b\u0e23\u0e37\u0e2d\u0e44\u0e21\u0e48?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/uk.json b/homeassistant/components/cast/.translations/uk.json new file mode 100644 index 000000000..783defdca --- /dev/null +++ b/homeassistant/components/cast/.translations/uk.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "confirm": { + "description": "\u041d\u0430\u043b\u0430\u0448\u0442\u0443\u0432\u0430\u0442\u0438 Google Cast?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/__init__.py b/homeassistant/components/cast/__init__.py index 53f5e7040..4dfb58ef3 100644 --- a/homeassistant/components/cast/__init__.py +++ b/homeassistant/components/cast/__init__.py @@ -1,10 +1,8 @@ """Component to embed Google Cast.""" from homeassistant import config_entries -from homeassistant.helpers import config_entry_flow - -DOMAIN = 'cast' -REQUIREMENTS = ['pychromecast==2.1.0'] +from . import home_assistant_cast +from .const import DOMAIN async def async_setup(hass, config): @@ -14,26 +12,20 @@ async def async_setup(hass, config): hass.data[DOMAIN] = conf or {} if conf is not None: - hass.async_create_task(hass.config_entries.flow.async_init( - DOMAIN, context={'source': config_entries.SOURCE_IMPORT})) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT} + ) + ) return True -async def async_setup_entry(hass, entry): +async def async_setup_entry(hass, entry: config_entries.ConfigEntry): """Set up Cast from a config entry.""" - hass.async_create_task(hass.config_entries.async_forward_entry_setup( - entry, 'media_player')) + await home_assistant_cast.async_setup_ha_cast(hass, entry) + + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, "media_player") + ) return True - - -async def _async_has_devices(hass): - """Return if there are devices that can be discovered.""" - from pychromecast.discovery import discover_chromecasts - - return await hass.async_add_executor_job(discover_chromecasts) - - -config_entry_flow.register_discovery_flow( - DOMAIN, 'Google Cast', _async_has_devices, - config_entries.CONN_CLASS_LOCAL_PUSH) diff --git a/homeassistant/components/cast/config_flow.py b/homeassistant/components/cast/config_flow.py new file mode 100644 index 000000000..5c2b6dca9 --- /dev/null +++ b/homeassistant/components/cast/config_flow.py @@ -0,0 +1,18 @@ +"""Config flow for Cast.""" +from pychromecast.discovery import discover_chromecasts + +from homeassistant import config_entries +from homeassistant.helpers import config_entry_flow + +from .const import DOMAIN + + +async def _async_has_devices(hass): + """Return if there are devices that can be discovered.""" + + return await hass.async_add_executor_job(discover_chromecasts) + + +config_entry_flow.register_discovery_flow( + DOMAIN, "Google Cast", _async_has_devices, config_entries.CONN_CLASS_LOCAL_PUSH +) diff --git a/homeassistant/components/cast/const.py b/homeassistant/components/cast/const.py new file mode 100644 index 000000000..c6164484d --- /dev/null +++ b/homeassistant/components/cast/const.py @@ -0,0 +1,26 @@ +"""Consts for Cast integration.""" + +DOMAIN = "cast" +DEFAULT_PORT = 8009 + +# 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" +# Stores an audio group manager. +CAST_MULTIZONE_MANAGER_KEY = "cast_multizone_manager" + +# Dispatcher signal fired with a ChromecastInfo every time we discover a new +# Chromecast or receive it through configuration +SIGNAL_CAST_DISCOVERED = "cast_discovered" + +# Dispatcher signal fired with a ChromecastInfo every time a Chromecast is +# removed +SIGNAL_CAST_REMOVED = "cast_removed" + +# Dispatcher signal fired when a Chromecast should show a Home Assistant Cast view. +SIGNAL_HASS_CAST_SHOW_VIEW = "cast_show_view" diff --git a/homeassistant/components/cast/discovery.py b/homeassistant/components/cast/discovery.py new file mode 100644 index 000000000..54f165889 --- /dev/null +++ b/homeassistant/components/cast/discovery.py @@ -0,0 +1,99 @@ +"""Deal with Cast discovery.""" +import logging +import threading + +import pychromecast + +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import dispatcher_send + +from .const import ( + INTERNAL_DISCOVERY_RUNNING_KEY, + KNOWN_CHROMECAST_INFO_KEY, + SIGNAL_CAST_DISCOVERED, + SIGNAL_CAST_REMOVED, +) +from .helpers import ChromecastInfo, ChromeCastZeroconf + +_LOGGER = logging.getLogger(__name__) + + +def discover_chromecast(hass: HomeAssistant, info: ChromecastInfo): + """Discover a Chromecast.""" + if info in hass.data[KNOWN_CHROMECAST_INFO_KEY]: + _LOGGER.debug("Discovered previous chromecast %s", info) + + # Either discovered completely new chromecast or a "moved" one. + info = info.fill_out_missing_chromecast_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 _remove_chromecast(hass: HomeAssistant, info: ChromecastInfo): + # Removed chromecast + _LOGGER.debug("Removed chromecast %s", info) + + dispatcher_send(hass, SIGNAL_CAST_REMOVED, info) + + +def setup_internal_discovery(hass: HomeAssistant) -> 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 + + def internal_add_callback(name): + """Handle zeroconf discovery of a new chromecast.""" + mdns = listener.services[name] + discover_chromecast( + hass, + ChromecastInfo( + service=name, + host=mdns[0], + port=mdns[1], + uuid=mdns[2], + model_name=mdns[3], + friendly_name=mdns[4], + ), + ) + + def internal_remove_callback(name, mdns): + """Handle zeroconf discovery of a removed chromecast.""" + _remove_chromecast( + hass, + ChromecastInfo( + service=name, + host=mdns[0], + port=mdns[1], + uuid=mdns[2], + model_name=mdns[3], + friendly_name=mdns[4], + ), + ) + + _LOGGER.debug("Starting internal pychromecast discovery.") + listener, browser = pychromecast.start_discovery( + internal_add_callback, internal_remove_callback + ) + ChromeCastZeroconf.set_zeroconf(browser.zc) + + 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) diff --git a/homeassistant/components/cast/helpers.py b/homeassistant/components/cast/helpers.py new file mode 100644 index 000000000..e82f6c9e4 --- /dev/null +++ b/homeassistant/components/cast/helpers.py @@ -0,0 +1,245 @@ +"""Helpers to deal with Cast devices.""" +from typing import Optional, Tuple + +import attr +from pychromecast import dial + +from .const import DEFAULT_PORT + + +@attr.s(slots=True, frozen=True) +class ChromecastInfo: + """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) + service = attr.ib(type=Optional[str], default=None) + uuid = attr.ib( + type=Optional[str], converter=attr.converters.optional(str), default=None + ) # always convert UUID to string if not None + manufacturer = attr.ib(type=str, default="") + model_name = attr.ib(type=str, default="") + friendly_name = attr.ib(type=Optional[str], default=None) + is_dynamic_group = attr.ib(type=Optional[bool], 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.""" + want_dynamic_group = self.is_audio_group + have_dynamic_group = self.is_dynamic_group is not None + have_all_except_dynamic_group = all( + attr.astuple( + self, + filter=attr.filters.exclude( + attr.fields(ChromecastInfo).is_dynamic_group + ), + ) + ) + return have_all_except_dynamic_group and ( + not want_dynamic_group or have_dynamic_group + ) + + @property + def host_port(self) -> Tuple[str, int]: + """Return the host+port tuple.""" + return self.host, self.port + + def fill_out_missing_chromecast_info(self) -> "ChromecastInfo": + """Return a new ChromecastInfo object with missing attributes filled in. + + Uses blocking HTTP. + """ + if self.is_information_complete: + # 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 self + + # Fill out missing information via HTTP dial. + if self.is_audio_group: + is_dynamic_group = False + http_group_status = None + dynamic_groups = [] + if self.uuid: + http_group_status = dial.get_multizone_status( + self.host, + services=[self.service], + zconf=ChromeCastZeroconf.get_zeroconf(), + ) + if http_group_status is not None: + dynamic_groups = [ + str(g.uuid) for g in http_group_status.dynamic_groups + ] + is_dynamic_group = self.uuid in dynamic_groups + + return ChromecastInfo( + service=self.service, + host=self.host, + port=self.port, + uuid=self.uuid, + friendly_name=self.friendly_name, + manufacturer=self.manufacturer, + model_name=self.model_name, + is_dynamic_group=is_dynamic_group, + ) + + http_device_status = dial.get_device_status( + self.host, services=[self.service], zconf=ChromeCastZeroconf.get_zeroconf() + ) + if http_device_status is None: + # HTTP dial didn't give us any new information. + return self + + return ChromecastInfo( + service=self.service, + host=self.host, + port=self.port, + uuid=(self.uuid or http_device_status.uuid), + friendly_name=(self.friendly_name or http_device_status.friendly_name), + manufacturer=(self.manufacturer or http_device_status.manufacturer), + model_name=(self.model_name or http_device_status.model_name), + ) + + def same_dynamic_group(self, other: "ChromecastInfo") -> bool: + """Test chromecast info is same dynamic group.""" + return ( + self.is_audio_group + and other.is_dynamic_group + and self.friendly_name == other.friendly_name + ) + + +class ChromeCastZeroconf: + """Class to hold a zeroconf instance.""" + + __zconf = None + + @classmethod + def set_zeroconf(cls, zconf): + """Set zeroconf.""" + cls.__zconf = zconf + + @classmethod + def get_zeroconf(cls): + """Get zeroconf.""" + return cls.__zconf + + +class CastStatusListener: + """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, mz_mgr): + """Initialize the status listener.""" + self._cast_device = cast_device + self._uuid = chromecast.uuid + self._valid = True + self._mz_mgr = mz_mgr + + chromecast.register_status_listener(self) + chromecast.socket_client.media_controller.register_status_listener(self) + chromecast.register_connection_listener(self) + if cast_device._cast_info.is_audio_group: + self._mz_mgr.add_multizone(chromecast) + else: + self._mz_mgr.register_listener(chromecast.uuid, self) + + def new_cast_status(self, cast_status): + """Handle reception of a new CastStatus.""" + if self._valid: + self._cast_device.new_cast_status(cast_status) + + def new_media_status(self, media_status): + """Handle reception of a new MediaStatus.""" + if self._valid: + self._cast_device.new_media_status(media_status) + + def new_connection_status(self, connection_status): + """Handle reception of a new ConnectionStatus.""" + if self._valid: + self._cast_device.new_connection_status(connection_status) + + @staticmethod + def added_to_multizone(group_uuid): + """Handle the cast added to a group.""" + pass + + def removed_from_multizone(self, group_uuid): + """Handle the cast removed from a group.""" + if self._valid: + self._cast_device.multizone_new_media_status(group_uuid, None) + + def multizone_new_cast_status(self, group_uuid, cast_status): + """Handle reception of a new CastStatus for a group.""" + pass + + def multizone_new_media_status(self, group_uuid, media_status): + """Handle reception of a new MediaStatus for a group.""" + if self._valid: + self._cast_device.multizone_new_media_status(group_uuid, media_status) + + def invalidate(self): + """Invalidate this status listener. + + All following callbacks won't be forwarded. + """ + # pylint: disable=protected-access + if self._cast_device._cast_info.is_audio_group: + self._mz_mgr.remove_multizone(self._uuid) + else: + self._mz_mgr.deregister_listener(self._uuid, self) + self._valid = False + + +class DynamicGroupCastStatusListener: + """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, mz_mgr): + """Initialize the status listener.""" + self._cast_device = cast_device + self._uuid = chromecast.uuid + self._valid = True + self._mz_mgr = mz_mgr + + chromecast.register_status_listener(self) + chromecast.socket_client.media_controller.register_status_listener(self) + chromecast.register_connection_listener(self) + self._mz_mgr.add_multizone(chromecast) + + def new_cast_status(self, cast_status): + """Handle reception of a new CastStatus.""" + pass + + def new_media_status(self, media_status): + """Handle reception of a new MediaStatus.""" + if self._valid: + self._cast_device.new_dynamic_group_media_status(media_status) + + def new_connection_status(self, connection_status): + """Handle reception of a new ConnectionStatus.""" + if self._valid: + self._cast_device.new_dynamic_group_connection_status(connection_status) + + def invalidate(self): + """Invalidate this status listener. + + All following callbacks won't be forwarded. + """ + self._mz_mgr.remove_multizone(self._uuid) + self._valid = False diff --git a/homeassistant/components/cast/home_assistant_cast.py b/homeassistant/components/cast/home_assistant_cast.py new file mode 100644 index 000000000..0b8633e19 --- /dev/null +++ b/homeassistant/components/cast/home_assistant_cast.py @@ -0,0 +1,73 @@ +"""Home Assistant Cast integration for Cast.""" +from typing import Optional + +from pychromecast.controllers.homeassistant import HomeAssistantController +import voluptuous as vol + +from homeassistant import auth, config_entries, core +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.helpers import config_validation as cv, dispatcher + +from .const import DOMAIN, SIGNAL_HASS_CAST_SHOW_VIEW + +SERVICE_SHOW_VIEW = "show_lovelace_view" +ATTR_VIEW_PATH = "view_path" + + +async def async_setup_ha_cast( + hass: core.HomeAssistant, entry: config_entries.ConfigEntry +): + """Set up Home Assistant Cast.""" + user_id: Optional[str] = entry.data.get("user_id") + user: Optional[auth.models.User] = None + + if user_id is not None: + user = await hass.auth.async_get_user(user_id) + + if user is None: + user = await hass.auth.async_create_system_user( + "Home Assistant Cast", [auth.GROUP_ID_ADMIN] + ) + hass.config_entries.async_update_entry( + entry, data={**entry.data, "user_id": user.id} + ) + + if user.refresh_tokens: + refresh_token: auth.models.RefreshToken = list(user.refresh_tokens.values())[0] + else: + refresh_token = await hass.auth.async_create_refresh_token(user) + + async def handle_show_view(call: core.ServiceCall): + """Handle a Show View service call.""" + hass_url = hass.config.api.base_url + + # Home Assistant Cast only works with https urls. If user has no configured + # base url, use their remote url. + if not hass_url.lower().startswith("https://"): + try: + hass_url = hass.components.cloud.async_remote_ui_url() + except hass.components.cloud.CloudNotAvailable: + pass + + controller = HomeAssistantController( + # If you are developing Home Assistant Cast, uncomment and set to your dev app id. + # app_id="5FE44367", + hass_url=hass_url, + client_id=None, + refresh_token=refresh_token.token, + ) + + dispatcher.async_dispatcher_send( + hass, + SIGNAL_HASS_CAST_SHOW_VIEW, + controller, + call.data[ATTR_ENTITY_ID], + call.data[ATTR_VIEW_PATH], + ) + + hass.helpers.service.async_register_admin_service( + DOMAIN, + SERVICE_SHOW_VIEW, + handle_show_view, + vol.Schema({ATTR_ENTITY_ID: cv.entity_id, ATTR_VIEW_PATH: str}), + ) diff --git a/homeassistant/components/cast/manifest.json b/homeassistant/components/cast/manifest.json new file mode 100644 index 000000000..8ad6f8fdb --- /dev/null +++ b/homeassistant/components/cast/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "cast", + "name": "Cast", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/cast", + "requirements": ["pychromecast==4.0.1"], + "dependencies": [], + "after_dependencies": ["cloud"], + "zeroconf": ["_googlecast._tcp.local."], + "codeowners": [] +} diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py new file mode 100644 index 000000000..031741345 --- /dev/null +++ b/homeassistant/components/cast/media_player.py @@ -0,0 +1,961 @@ +"""Provide functionality to interact with Cast devices on the network.""" +import asyncio +import logging +from typing import Optional + +import pychromecast +from pychromecast.controllers.homeassistant import HomeAssistantController +from pychromecast.controllers.multizone import MultizoneManager +from pychromecast.socket_client import ( + CONNECTION_STATUS_CONNECTED, + CONNECTION_STATUS_DISCONNECTED, +) +import voluptuous as vol + +from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerDevice +from homeassistant.components.media_player.const import ( + MEDIA_TYPE_MOVIE, + MEDIA_TYPE_MUSIC, + MEDIA_TYPE_TVSHOW, + SUPPORT_NEXT_TRACK, + SUPPORT_PAUSE, + SUPPORT_PLAY, + SUPPORT_PLAY_MEDIA, + SUPPORT_PREVIOUS_TRACK, + SUPPORT_SEEK, + SUPPORT_STOP, + SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, + SUPPORT_VOLUME_MUTE, + SUPPORT_VOLUME_SET, +) +from homeassistant.const import ( + CONF_HOST, + EVENT_HOMEASSISTANT_STOP, + STATE_IDLE, + STATE_OFF, + STATE_PAUSED, + STATE_PLAYING, +) +from homeassistant.core import callback +from homeassistant.exceptions import PlatformNotReady +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.typing import ConfigType, HomeAssistantType +import homeassistant.util.dt as dt_util +from homeassistant.util.logging import async_create_catching_coro + +from .const import ( + ADDED_CAST_DEVICES_KEY, + CAST_MULTIZONE_MANAGER_KEY, + DEFAULT_PORT, + DOMAIN as CAST_DOMAIN, + KNOWN_CHROMECAST_INFO_KEY, + SIGNAL_CAST_DISCOVERED, + SIGNAL_CAST_REMOVED, + SIGNAL_HASS_CAST_SHOW_VIEW, +) +from .discovery import discover_chromecast, setup_internal_discovery +from .helpers import ( + CastStatusListener, + ChromecastInfo, + ChromeCastZeroconf, + DynamicGroupCastStatusListener, +) + +_LOGGER = logging.getLogger(__name__) + +CONF_IGNORE_CEC = "ignore_cec" +CAST_SPLASH = "https://home-assistant.io/images/cast/splash.png" + +SUPPORT_CAST = ( + SUPPORT_PAUSE + | SUPPORT_PLAY + | SUPPORT_PLAY_MEDIA + | SUPPORT_STOP + | SUPPORT_TURN_OFF + | SUPPORT_TURN_ON + | SUPPORT_VOLUME_MUTE + | SUPPORT_VOLUME_SET +) + + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_HOST): cv.string, + vol.Optional(CONF_IGNORE_CEC, default=[]): vol.All(cv.ensure_list, [cv.string]), + } +) + + +@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. + """ + _LOGGER.debug("_async_create_cast_device: %s", info) + 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 + if info.is_dynamic_group: + # This is a dynamic group, do not add it. + return None + + 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_entities, discovery_info=None +): + """Set up thet Cast platform. + + Deprecated. + """ + _LOGGER.warning( + "Setting configuration for Cast via platform is deprecated. " + "Configure via Cast integration instead." + ) + await _async_setup_platform(hass, config, async_add_entities, discovery_info) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Cast from a config entry.""" + config = hass.data[CAST_DOMAIN].get("media_player", {}) + if not isinstance(config, list): + config = [config] + + # no pending task + done, _ = await asyncio.wait( + [_async_setup_platform(hass, cfg, async_add_entities, None) for cfg in config] + ) + if any([task.exception() for task in done]): + exceptions = [task.exception() for task in done] + for exception in exceptions: + _LOGGER.debug("Failed to setup chromecast", exc_info=exception) + raise PlatformNotReady + + +async def _async_setup_platform( + hass: HomeAssistantType, config: ConfigType, async_add_entities, discovery_info +): + """Set up the cast platform.""" + # 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: + """Handle discovery of a new chromecast.""" + 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_entities([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_executor_job(setup_internal_discovery, hass) + else: + info = await hass.async_add_executor_job(info.fill_out_missing_chromecast_info) + if info.friendly_name is None: + _LOGGER.debug( + "Cannot retrieve detail information for chromecast" + " %s, the device may not be online", + info, + ) + + hass.async_add_executor_job(discover_chromecast, hass, info) + + +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: ChromecastInfo): + """Initialize the cast device.""" + + self._cast_info = cast_info + self.services = None + if cast_info.service: + self.services = set() + self.services.add(cast_info.service) + self._chromecast: Optional[pychromecast.Chromecast] = None + self.cast_status = None + self.media_status = None + self.media_status_received = None + self._dynamic_group_cast_info: ChromecastInfo = None + self._dynamic_group_cast: Optional[pychromecast.Chromecast] = None + self.dynamic_group_media_status = None + self.dynamic_group_media_status_received = None + self.mz_media_status = {} + self.mz_media_status_received = {} + self.mz_mgr = None + self._available = False + self._dynamic_group_available = False + self._status_listener: Optional[CastStatusListener] = None + self._dynamic_group_status_listener: Optional[ + DynamicGroupCastStatusListener + ] = None + self._hass_cast_controller: Optional[HomeAssistantController] = None + + self._add_remove_handler = None + self._del_remove_handler = None + self._cast_view_remove_handler = None + + async def async_added_to_hass(self): + """Create chromecast object when added to hass.""" + self._add_remove_handler = async_dispatcher_connect( + self.hass, SIGNAL_CAST_DISCOVERED, self._async_cast_discovered + ) + self._del_remove_handler = async_dispatcher_connect( + self.hass, SIGNAL_CAST_REMOVED, self._async_cast_removed + ) + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._async_stop) + self.hass.async_create_task( + async_create_catching_coro(self.async_set_cast_info(self._cast_info)) + ) + for info in self.hass.data[KNOWN_CHROMECAST_INFO_KEY]: + if self._cast_info.same_dynamic_group(info): + _LOGGER.debug( + "[%s %s (%s:%s)] Found dynamic group: %s", + self.entity_id, + self._cast_info.friendly_name, + self._cast_info.host, + self._cast_info.port, + info, + ) + self.hass.async_create_task( + async_create_catching_coro(self.async_set_dynamic_group(info)) + ) + break + + self._cast_view_remove_handler = async_dispatcher_connect( + self.hass, SIGNAL_HASS_CAST_SHOW_VIEW, self._handle_signal_show_view + ) + + 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) + if self._add_remove_handler: + self._add_remove_handler() + self._add_remove_handler = None + if self._del_remove_handler: + self._del_remove_handler() + self._del_remove_handler = None + if self._cast_view_remove_handler: + self._cast_view_remove_handler() + self._cast_view_remove_handler = None + + async def async_set_cast_info(self, cast_info): + """Set the cast information and set up the chromecast object.""" + + self._cast_info = cast_info + + if self.services is not None: + if cast_info.service not in self.services: + _LOGGER.debug( + "[%s %s (%s:%s)] Got new service: %s (%s)", + self.entity_id, + self._cast_info.friendly_name, + self._cast_info.host, + self._cast_info.port, + cast_info.service, + self.services, + ) + + self.services.add(cast_info.service) + + if self._chromecast is not None: + # Only setup the chromecast once, added elements to services + # will automatically be picked up. + return + + # pylint: disable=protected-access + if self.services is None: + _LOGGER.debug( + "[%s %s (%s:%s)] Connecting to cast device by host %s", + self.entity_id, + self._cast_info.friendly_name, + self._cast_info.host, + self._cast_info.port, + cast_info, + ) + chromecast = await self.hass.async_add_job( + pychromecast._get_chromecast_from_host, + ( + cast_info.host, + cast_info.port, + cast_info.uuid, + cast_info.model_name, + cast_info.friendly_name, + ), + ) + else: + _LOGGER.debug( + "[%s %s (%s:%s)] Connecting to cast device by service %s", + self.entity_id, + self._cast_info.friendly_name, + self._cast_info.host, + self._cast_info.port, + self.services, + ) + chromecast = await self.hass.async_add_job( + pychromecast._get_chromecast_from_service, + ( + self.services, + ChromeCastZeroconf.get_zeroconf(), + cast_info.uuid, + cast_info.model_name, + cast_info.friendly_name, + ), + ) + self._chromecast = chromecast + + if CAST_MULTIZONE_MANAGER_KEY not in self.hass.data: + self.hass.data[CAST_MULTIZONE_MANAGER_KEY] = MultizoneManager() + + self.mz_mgr = self.hass.data[CAST_MULTIZONE_MANAGER_KEY] + + self._status_listener = CastStatusListener(self, chromecast, self.mz_mgr) + self._available = False + self.cast_status = chromecast.status + self.media_status = chromecast.media_controller.status + self._chromecast.start() + self.async_schedule_update_ha_state() + + async def async_del_cast_info(self, cast_info): + """Remove the service.""" + self.services.discard(cast_info.service) + _LOGGER.debug( + "[%s %s (%s:%s)] Remove service: %s (%s)", + self.entity_id, + self._cast_info.friendly_name, + self._cast_info.host, + self._cast_info.port, + cast_info.service, + self.services, + ) + + async def async_set_dynamic_group(self, cast_info): + """Set the cast information and set up the chromecast object.""" + + _LOGGER.debug( + "[%s %s (%s:%s)] Connecting to dynamic group by host %s", + self.entity_id, + self._cast_info.friendly_name, + self._cast_info.host, + self._cast_info.port, + cast_info, + ) + + await self.async_del_dynamic_group() + self._dynamic_group_cast_info = cast_info + + # pylint: disable=protected-access + chromecast = await self.hass.async_add_executor_job( + pychromecast._get_chromecast_from_host, + ( + cast_info.host, + cast_info.port, + cast_info.uuid, + cast_info.model_name, + cast_info.friendly_name, + ), + ) + + self._dynamic_group_cast = chromecast + + if CAST_MULTIZONE_MANAGER_KEY not in self.hass.data: + self.hass.data[CAST_MULTIZONE_MANAGER_KEY] = MultizoneManager() + + mz_mgr = self.hass.data[CAST_MULTIZONE_MANAGER_KEY] + + self._dynamic_group_status_listener = DynamicGroupCastStatusListener( + self, chromecast, mz_mgr + ) + self._dynamic_group_available = False + self.dynamic_group_media_status = chromecast.media_controller.status + self._dynamic_group_cast.start() + self.async_schedule_update_ha_state() + + async def async_del_dynamic_group(self): + """Remove the dynamic group.""" + cast_info = self._dynamic_group_cast_info + _LOGGER.debug( + "[%s %s (%s:%s)] Remove dynamic group: %s", + self.entity_id, + self._cast_info.friendly_name, + self._cast_info.host, + self._cast_info.port, + cast_info.service if cast_info else None, + ) + + self._dynamic_group_available = False + self._dynamic_group_cast_info = None + if self._dynamic_group_cast is not None: + await self.hass.async_add_executor_job(self._dynamic_group_cast.disconnect) + + self._dynamic_group_invalidate() + + 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( + "[%s %s (%s:%s)] Disconnecting from chromecast socket.", + self.entity_id, + self._cast_info.friendly_name, + self._cast_info.host, + self._cast_info.port, + ) + self._available = False + self.async_schedule_update_ha_state() + + await self.hass.async_add_executor_job(self._chromecast.disconnect) + if self._dynamic_group_cast is not None: + await self.hass.async_add_executor_job(self._dynamic_group_cast.disconnect) + + self._invalidate() + + self.async_schedule_update_ha_state() + + def _invalidate(self): + """Invalidate some attributes.""" + self._chromecast = None + self.cast_status = None + self.media_status = None + self.media_status_received = None + self.mz_media_status = {} + self.mz_media_status_received = {} + self.mz_mgr = None + self._hass_cast_controller = None + if self._status_listener is not None: + self._status_listener.invalidate() + self._status_listener = None + + def _dynamic_group_invalidate(self): + """Invalidate some attributes.""" + self._dynamic_group_cast = None + self.dynamic_group_media_status = None + self.dynamic_group_media_status_received = None + if self._dynamic_group_status_listener is not None: + self._dynamic_group_status_listener.invalidate() + self._dynamic_group_status_listener = None + + # ========== 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.""" + _LOGGER.debug( + "[%s %s (%s:%s)] Received cast device connection status: %s", + self.entity_id, + self._cast_info.friendly_name, + self._cast_info.host, + self._cast_info.port, + connection_status.status, + ) + if connection_status.status == CONNECTION_STATUS_DISCONNECTED: + self._available = False + self._invalidate() + self.schedule_update_ha_state() + return + + 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( + "[%s %s (%s:%s)] Cast device availability changed: %s", + self.entity_id, + self._cast_info.friendly_name, + self._cast_info.host, + self._cast_info.port, + connection_status.status, + ) + info = self._cast_info + if info.friendly_name is None and not info.is_audio_group: + # We couldn't find friendly_name when the cast was added, retry + self._cast_info = info.fill_out_missing_chromecast_info() + self._available = new_available + self.schedule_update_ha_state() + + def new_dynamic_group_media_status(self, media_status): + """Handle updates of the media status.""" + self.dynamic_group_media_status = media_status + self.dynamic_group_media_status_received = dt_util.utcnow() + self.schedule_update_ha_state() + + def new_dynamic_group_connection_status(self, connection_status): + """Handle updates of connection status.""" + _LOGGER.debug( + "[%s %s (%s:%s)] Received dynamic group connection status: %s", + self.entity_id, + self._cast_info.friendly_name, + self._cast_info.host, + self._cast_info.port, + connection_status.status, + ) + if connection_status.status == CONNECTION_STATUS_DISCONNECTED: + self._dynamic_group_available = False + self._dynamic_group_invalidate() + self.schedule_update_ha_state() + return + + new_available = connection_status.status == CONNECTION_STATUS_CONNECTED + if new_available != self._dynamic_group_available: + # Connection status callbacks happen often when disconnected. + # Only update state when availability changed to put less pressure + # on state machine. + _LOGGER.debug( + "[%s %s (%s:%s)] Dynamic group availability changed: %s", + self.entity_id, + self._cast_info.friendly_name, + self._cast_info.host, + self._cast_info.port, + connection_status.status, + ) + self._dynamic_group_available = new_available + self.schedule_update_ha_state() + + def multizone_new_media_status(self, group_uuid, media_status): + """Handle updates of audio group media status.""" + _LOGGER.debug( + "[%s %s (%s:%s)] Multizone %s media status: %s", + self.entity_id, + self._cast_info.friendly_name, + self._cast_info.host, + self._cast_info.port, + group_uuid, + media_status, + ) + self.mz_media_status[group_uuid] = media_status + self.mz_media_status_received[group_uuid] = dt_util.utcnow() + self.schedule_update_ha_state() + + # ========== Service Calls ========== + def _media_controller(self): + """ + Return media status. + + First try from our own cast, then dynamic groups and finally + groups which our cast is a member in. + """ + media_status = self.media_status + media_controller = self._chromecast.media_controller + + if ( + media_status is None or media_status.player_state == "UNKNOWN" + ) and self._dynamic_group_cast is not None: + media_status = self.dynamic_group_media_status + media_controller = self._dynamic_group_cast.media_controller + + if media_status is None or media_status.player_state == "UNKNOWN": + groups = self.mz_media_status + for k, val in groups.items(): + if val and val.player_state != "UNKNOWN": + media_controller = self.mz_mgr.get_multizone_mediacontroller(k) + break + + return media_controller + + def turn_on(self): + """Turn on the cast device.""" + + if not self._chromecast.is_idle: + # Already turned on + return + + if self._chromecast.app_id is not None: + # Quit the previous app before starting splash screen + self._chromecast.quit_app() + + # The only way we can turn the Chromecast is on is by launching an app + self._chromecast.play_media(CAST_SPLASH, pychromecast.STREAM_TYPE_BUFFERED) + + def turn_off(self): + """Turn off the cast device.""" + self._chromecast.quit_app() + + def mute_volume(self, mute): + """Mute the volume.""" + self._chromecast.set_volume_muted(mute) + + def set_volume_level(self, volume): + """Set volume level, range 0..1.""" + self._chromecast.set_volume(volume) + + def media_play(self): + """Send play command.""" + media_controller = self._media_controller() + media_controller.play() + + def media_pause(self): + """Send pause command.""" + media_controller = self._media_controller() + media_controller.pause() + + def media_stop(self): + """Send stop command.""" + media_controller = self._media_controller() + media_controller.stop() + + def media_previous_track(self): + """Send previous track command.""" + media_controller = self._media_controller() + media_controller.queue_prev() + + def media_next_track(self): + """Send next track command.""" + media_controller = self._media_controller() + media_controller.queue_next() + + def media_seek(self, position): + """Seek the media to a specific location.""" + media_controller = self._media_controller() + media_controller.seek(position) + + def play_media(self, media_type, media_id, **kwargs): + """Play media from a URL.""" + # We do not want this to be forwarded to a group / dynamic group + self._chromecast.media_controller.play_media(media_id, media_type) + + # ========== Properties ========== + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def name(self): + """Return the name of the device.""" + return self._cast_info.friendly_name + + @property + def device_info(self): + """Return information about the device.""" + cast_info = self._cast_info + + if cast_info.model_name == "Google Cast Group": + return None + + return { + "name": cast_info.friendly_name, + "identifiers": {(CAST_DOMAIN, cast_info.uuid.replace("-", ""))}, + "model": cast_info.model_name, + "manufacturer": cast_info.manufacturer, + } + + def _media_status(self): + """ + Return media status. + + First try from our own cast, then dynamic groups and finally + groups which our cast is a member in. + """ + media_status = self.media_status + media_status_received = self.media_status_received + + if ( + media_status is None or media_status.player_state == "UNKNOWN" + ) and self._dynamic_group_cast is not None: + media_status = self.dynamic_group_media_status + media_status_received = self.dynamic_group_media_status_received + + if media_status is None or media_status.player_state == "UNKNOWN": + groups = self.mz_media_status + for k, val in groups.items(): + if val and val.player_state != "UNKNOWN": + media_status = val + media_status_received = self.mz_media_status_received[k] + break + + return (media_status, media_status_received) + + @property + def state(self): + """Return the state of the player.""" + media_status, _ = self._media_status() + + if media_status is None: + return None + if media_status.player_is_playing: + return STATE_PLAYING + if media_status.player_is_paused: + return STATE_PAUSED + if media_status.player_is_idle: + return STATE_IDLE + if self._chromecast is not None and self._chromecast.is_idle: + return STATE_OFF + return None + + @property + def available(self): + """Return True if the cast device is connected.""" + return self._available + + @property + def volume_level(self): + """Volume level of the media player (0..1).""" + return self.cast_status.volume_level if self.cast_status else None + + @property + def is_volume_muted(self): + """Boolean if volume is currently muted.""" + return self.cast_status.volume_muted if self.cast_status else None + + @property + def media_content_id(self): + """Content ID of current playing media.""" + media_status, _ = self._media_status() + return media_status.content_id if media_status else None + + @property + def media_content_type(self): + """Content type of current playing media.""" + media_status, _ = self._media_status() + if media_status is None: + return None + if media_status.media_is_tvshow: + return MEDIA_TYPE_TVSHOW + if media_status.media_is_movie: + return MEDIA_TYPE_MOVIE + if media_status.media_is_musictrack: + return MEDIA_TYPE_MUSIC + return None + + @property + def media_duration(self): + """Duration of current playing media in seconds.""" + media_status, _ = self._media_status() + return media_status.duration if media_status else None + + @property + def media_image_url(self): + """Image url of current playing media.""" + media_status, _ = self._media_status() + if media_status is None: + return None + + images = media_status.images + + return images[0].url if images and images[0].url else None + + @property + def media_image_remotely_accessible(self) -> bool: + """If the image url is remotely accessible.""" + return True + + @property + def media_title(self): + """Title of current playing media.""" + media_status, _ = self._media_status() + return media_status.title if media_status else None + + @property + def media_artist(self): + """Artist of current playing media (Music track only).""" + media_status, _ = self._media_status() + return media_status.artist if media_status else None + + @property + def media_album_name(self): + """Album of current playing media (Music track only).""" + media_status, _ = self._media_status() + return media_status.album_name if media_status else None + + @property + def media_album_artist(self): + """Album artist of current playing media (Music track only).""" + media_status, _ = self._media_status() + return media_status.album_artist if media_status else None + + @property + def media_track(self): + """Track number of current playing media (Music track only).""" + media_status, _ = self._media_status() + return media_status.track if media_status else None + + @property + def media_series_title(self): + """Return the title of the series of current playing media.""" + media_status, _ = self._media_status() + return media_status.series_title if media_status else None + + @property + def media_season(self): + """Season of current playing media (TV Show only).""" + media_status, _ = self._media_status() + return media_status.season if media_status else None + + @property + def media_episode(self): + """Episode of current playing media (TV Show only).""" + media_status, _ = self._media_status() + return media_status.episode if media_status else None + + @property + def app_id(self): + """Return the ID of the current running app.""" + return self._chromecast.app_id if self._chromecast else None + + @property + def app_name(self): + """Name of the current running app.""" + return self._chromecast.app_display_name if self._chromecast else None + + @property + def supported_features(self): + """Flag media player features that are supported.""" + support = SUPPORT_CAST + media_status, _ = self._media_status() + + if media_status: + if media_status.supports_queue_next: + support |= SUPPORT_PREVIOUS_TRACK + if media_status.supports_queue_next: + support |= SUPPORT_NEXT_TRACK + if media_status.supports_seek: + support |= SUPPORT_SEEK + + return support + + @property + def media_position(self): + """Position of current playing media in seconds.""" + media_status, _ = self._media_status() + if media_status is None or not ( + media_status.player_is_playing + or media_status.player_is_paused + or media_status.player_is_idle + ): + return None + return media_status.current_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(). + """ + _, media_status_recevied = self._media_status() + return media_status_recevied + + @property + def unique_id(self) -> Optional[str]: + """Return a unique ID.""" + return self._cast_info.uuid + + async def _async_cast_discovered(self, discover: ChromecastInfo): + """Handle discovery of new Chromecast.""" + if self._cast_info.uuid is None: + # We can't handle empty UUIDs + return + + if self._cast_info.same_dynamic_group(discover): + _LOGGER.debug("Discovered matching dynamic group: %s", discover) + await self.async_set_dynamic_group(discover) + return + + if self._cast_info.uuid != discover.uuid: + # Discovered is not our device. + return + + if self.services is None: + _LOGGER.warning( + "[%s %s (%s:%s)] Received update for manually added Cast", + self.entity_id, + self._cast_info.friendly_name, + self._cast_info.host, + self._cast_info.port, + ) + return + + _LOGGER.debug("Discovered chromecast with same UUID: %s", discover) + await self.async_set_cast_info(discover) + + async def _async_cast_removed(self, discover: ChromecastInfo): + """Handle removal of Chromecast.""" + if self._cast_info.uuid is None: + # We can't handle empty UUIDs + return + + if ( + self._dynamic_group_cast_info is not None + and self._dynamic_group_cast_info.uuid == discover.uuid + ): + _LOGGER.debug("Removed matching dynamic group: %s", discover) + await self.async_del_dynamic_group() + return + + if self._cast_info.uuid != discover.uuid: + # Removed is not our device. + return + + _LOGGER.debug("Removed chromecast with same UUID: %s", discover) + await self.async_del_cast_info(discover) + + async def _async_stop(self, event): + """Disconnect socket on Home Assistant stop.""" + await self._async_disconnect() + + def _handle_signal_show_view( + self, controller: HomeAssistantController, entity_id: str, view_path: str + ): + """Handle a show view signal.""" + if entity_id != self.entity_id: + return + + if self._hass_cast_controller is None: + self._hass_cast_controller = controller + self._chromecast.register_handler(controller) + + self._hass_cast_controller.show_lovelace_view(view_path) diff --git a/homeassistant/components/cast/services.yaml b/homeassistant/components/cast/services.yaml new file mode 100644 index 000000000..24bc7b16a --- /dev/null +++ b/homeassistant/components/cast/services.yaml @@ -0,0 +1,9 @@ +show_lovelace_view: + description: Show a Lovelace view on a Chromecast. + fields: + entity_id: + description: Media Player entity to show the Lovelace view on. + example: "media_player.kitchen" + view_path: + description: The path of the Lovelace view to show. + example: downstairs diff --git a/homeassistant/components/cert_expiry/.translations/bg.json b/homeassistant/components/cert_expiry/.translations/bg.json new file mode 100644 index 000000000..a4a36cb04 --- /dev/null +++ b/homeassistant/components/cert_expiry/.translations/bg.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "host_port_exists": "\u0422\u0430\u0437\u0438 \u043a\u043e\u043c\u0431\u0438\u043d\u0430\u0446\u0438\u044f \u043e\u0442 \u0430\u0434\u0440\u0435\u0441 \u0438 \u043f\u043e\u0440\u0442 \u0435 \u0432\u0435\u0447\u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0430" + }, + "error": { + "certificate_error": "\u0421\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u044a\u0442 \u043d\u0435 \u043c\u043e\u0436\u0435 \u0434\u0430 \u0431\u044a\u0434\u0435 \u0432\u0430\u043b\u0438\u0434\u0438\u0440\u0430\u043d", + "certificate_fetch_failed": "\u041d\u0435 \u043c\u043e\u0436\u0435 \u0434\u0430 \u0441\u0435 \u043c\u0438\u0437\u0432\u043b\u0435\u0447\u0435 \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442 \u043e\u0442 \u0442\u0430\u0437\u0438 \u043a\u043e\u043c\u0431\u0438\u043d\u0430\u0446\u0438\u044f \u043e\u0442 \u0430\u0434\u0440\u0435\u0441 \u0438 \u043f\u043e\u0440\u0442", + "connection_timeout": "\u041d\u0435\u0432\u044a\u0437\u043c\u043e\u0436\u043d\u043e\u0441\u0442 \u0437\u0430 \u0441\u0432\u043e\u0435\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u0441 \u0442\u043e\u0437\u0438 \u0430\u0434\u0440\u0435\u0441", + "host_port_exists": "\u0422\u0430\u0437\u0438 \u043a\u043e\u043c\u0431\u0438\u043d\u0430\u0446\u0438\u044f \u043e\u0442 \u0430\u0434\u0440\u0435\u0441 \u0438 \u043f\u043e\u0440\u0442 \u0435 \u0432\u0435\u0447\u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0430", + "resolve_failed": "\u0422\u043e\u0437\u0438 \u0430\u0434\u0440\u0435\u0441 \u043d\u0435 \u043c\u043e\u0436\u0435 \u0434\u0430 \u0431\u044a\u0434\u0435 \u043d\u0430\u043c\u0435\u0440\u0435\u043d", + "wrong_host": "\u0421\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u044a\u0442 \u043d\u0435 \u0441\u044a\u043e\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0430 \u043d\u0430 \u0438\u043c\u0435\u0442\u043e \u043d\u0430 \u0445\u043e\u0441\u0442\u0430" + }, + "step": { + "user": { + "data": { + "host": "\u0410\u0434\u0440\u0435\u0441 \u0432 \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u0430", + "name": "\u0418\u043c\u0435 \u043d\u0430 \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u0430", + "port": "\u041f\u043e\u0440\u0442 \u043d\u0430 \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u0430" + }, + "title": "\u0414\u0435\u0444\u0438\u043d\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u0430 \u0437\u0430 \u0442\u0435\u0441\u0442\u0432\u0430\u043d\u0435" + } + }, + "title": "\u0421\u0440\u043e\u043a \u043d\u0430 \u0432\u0430\u043b\u0438\u0434\u043d\u043e\u0441\u0442 \u043d\u0430 \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u0430" + } +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/.translations/ca.json b/homeassistant/components/cert_expiry/.translations/ca.json new file mode 100644 index 000000000..f1df9a06b --- /dev/null +++ b/homeassistant/components/cert_expiry/.translations/ca.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "host_port_exists": "Aquesta combinaci\u00f3 d'amfitri\u00f3 i port ja est\u00e0 configurada" + }, + "error": { + "certificate_error": "El certificat no ha pogut ser validat", + "certificate_fetch_failed": "No s'ha pogut obtenir el certificat des d'aquesta combinaci\u00f3 d'amfitri\u00f3 i port", + "connection_timeout": "S'ha acabat el temps d'espera durant la connexi\u00f3 amb l'amfitri\u00f3.", + "host_port_exists": "Aquesta combinaci\u00f3 d'amfitri\u00f3 i port ja est\u00e0 configurada", + "resolve_failed": "No s'ha pogut resoldre l'amfitri\u00f3", + "wrong_host": "El certificat no coincideix amb el nom de l'amfitri\u00f3" + }, + "step": { + "user": { + "data": { + "host": "Nom de l'amfitri\u00f3 del certificat", + "name": "Nom del certificat", + "port": "Port del certificat" + }, + "title": "Configuraci\u00f3 del certificat a provar" + } + }, + "title": "Caducitat del certificat" + } +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/.translations/cs.json b/homeassistant/components/cert_expiry/.translations/cs.json new file mode 100644 index 000000000..58a5a281e --- /dev/null +++ b/homeassistant/components/cert_expiry/.translations/cs.json @@ -0,0 +1,8 @@ +{ + "config": { + "error": { + "certificate_error": "Certifik\u00e1t nelze ov\u011b\u0159it", + "wrong_host": "Certifik\u00e1t neodpov\u00edd\u00e1 n\u00e1zvu hostitele" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/.translations/da.json b/homeassistant/components/cert_expiry/.translations/da.json new file mode 100644 index 000000000..c95a56320 --- /dev/null +++ b/homeassistant/components/cert_expiry/.translations/da.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "host_port_exists": "Denne v\u00e6rt- og portkombination er allerede konfigureret" + }, + "error": { + "certificate_error": "Certifikatet kunne ikke valideres", + "certificate_fetch_failed": "Kan ikke hente certifikat fra denne v\u00e6rt- og portkombination", + "connection_timeout": "Timeout ved tilslutning til denne v\u00e6rt", + "host_port_exists": "Denne v\u00e6rt- og portkombination er allerede konfigureret", + "resolve_failed": "V\u00e6rten kunne ikke findes", + "wrong_host": "Certifikatet stemmer ikke overens med v\u00e6rtsnavnet" + }, + "step": { + "user": { + "data": { + "host": "Certifikatets v\u00e6rtsnavn", + "name": "Certifikatets navn", + "port": "Certifikatets port" + }, + "title": "Definer certifikatet, der skal testes" + } + }, + "title": "Certifikat udl\u00f8b" + } +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/.translations/de.json b/homeassistant/components/cert_expiry/.translations/de.json new file mode 100644 index 000000000..344abe130 --- /dev/null +++ b/homeassistant/components/cert_expiry/.translations/de.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "host_port_exists": "Diese Kombination aus Host und Port ist bereits konfiguriert." + }, + "error": { + "certificate_fetch_failed": "Zertifikat kann von dieser Kombination aus Host und Port nicht abgerufen werden", + "connection_timeout": "Zeit\u00fcberschreitung beim Herstellen einer Verbindung mit diesem Host", + "host_port_exists": "Diese Kombination aus Host und Port ist bereits konfiguriert.", + "resolve_failed": "Dieser Host kann nicht aufgel\u00f6st werden" + }, + "step": { + "user": { + "data": { + "host": "Der Hostname des Zertifikats", + "name": "Der Name des Zertifikats", + "port": "Der Port des Zertifikats" + }, + "title": "Definieren Sie das zu testende Zertifikat" + } + }, + "title": "Zertifikatsablauf" + } +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/.translations/en.json b/homeassistant/components/cert_expiry/.translations/en.json new file mode 100644 index 000000000..19e237a6d --- /dev/null +++ b/homeassistant/components/cert_expiry/.translations/en.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "host_port_exists": "This host and port combination is already configured" + }, + "error": { + "certificate_error": "Certificate could not be validated", + "certificate_fetch_failed": "Can not fetch certificate from this host and port combination", + "connection_timeout": "Timeout when connecting to this host", + "host_port_exists": "This host and port combination is already configured", + "resolve_failed": "This host can not be resolved", + "wrong_host": "Certificate does not match hostname" + }, + "step": { + "user": { + "data": { + "host": "The hostname of the certificate", + "name": "The name of the certificate", + "port": "The port of the certificate" + }, + "title": "Define the certificate to test" + } + }, + "title": "Certificate Expiry" + } +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/.translations/es-419.json b/homeassistant/components/cert_expiry/.translations/es-419.json new file mode 100644 index 000000000..392dbf35f --- /dev/null +++ b/homeassistant/components/cert_expiry/.translations/es-419.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Expiraci\u00f3n del certificado" + } +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/.translations/es.json b/homeassistant/components/cert_expiry/.translations/es.json new file mode 100644 index 000000000..4432edac5 --- /dev/null +++ b/homeassistant/components/cert_expiry/.translations/es.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "host_port_exists": "Esta combinaci\u00f3n de host y puerto ya est\u00e1 configurada" + }, + "error": { + "certificate_error": "El certificado no pudo ser validado", + "certificate_fetch_failed": "No se puede obtener el certificado de esta combinaci\u00f3n de host y puerto", + "connection_timeout": "Tiempo de espera agotado al conectar a este host", + "host_port_exists": "Esta combinaci\u00f3n de host y puerto ya est\u00e1 configurada", + "resolve_failed": "Este host no se puede resolver", + "wrong_host": "El certificado no coincide con el nombre de host" + }, + "step": { + "user": { + "data": { + "host": "El nombre de host del certificado", + "name": "El nombre del certificado", + "port": "El puerto del certificado" + }, + "title": "Defina el certificado para probar" + } + }, + "title": "Caducidad del certificado" + } +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/.translations/fr.json b/homeassistant/components/cert_expiry/.translations/fr.json new file mode 100644 index 000000000..9e7df5564 --- /dev/null +++ b/homeassistant/components/cert_expiry/.translations/fr.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "host_port_exists": "Cette combinaison h\u00f4te / port est d\u00e9j\u00e0 configur\u00e9e" + }, + "error": { + "certificate_error": "Le certificat n'a pas pu \u00eatre valid\u00e9", + "certificate_fetch_failed": "Impossible de r\u00e9cup\u00e9rer le certificat de cette combinaison h\u00f4te / port", + "connection_timeout": "D\u00e9lai d'attente lors de la connexion \u00e0 cet h\u00f4te", + "host_port_exists": "Cette combinaison h\u00f4te / port est d\u00e9j\u00e0 configur\u00e9e", + "resolve_failed": "Cet h\u00f4te ne peut pas \u00eatre r\u00e9solu", + "wrong_host": "Le certificat ne correspond pas au nom d'h\u00f4te" + }, + "step": { + "user": { + "data": { + "host": "Le nom d'h\u00f4te du certificat", + "name": "Le nom du certificat", + "port": "Le port du certificat" + }, + "title": "D\u00e9finir le certificat \u00e0 tester" + } + }, + "title": "Expiration du certificat" + } +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/.translations/hu.json b/homeassistant/components/cert_expiry/.translations/hu.json new file mode 100644 index 000000000..584f4c2b7 --- /dev/null +++ b/homeassistant/components/cert_expiry/.translations/hu.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "name": "A tan\u00fas\u00edtv\u00e1ny neve", + "port": "A tan\u00fas\u00edtv\u00e1ny portja" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/.translations/it.json b/homeassistant/components/cert_expiry/.translations/it.json new file mode 100644 index 000000000..d95b9cd84 --- /dev/null +++ b/homeassistant/components/cert_expiry/.translations/it.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "host_port_exists": "Questa combinazione di host e porta \u00e8 gi\u00e0 configurata" + }, + "error": { + "certificate_error": "Il certificato non pu\u00f2 essere convalidato", + "certificate_fetch_failed": "Non \u00e8 possibile recuperare il certificato da questa combinazione di host e porta", + "connection_timeout": "Tempo scaduto collegandosi a questo host", + "host_port_exists": "Questa combinazione di host e porta \u00e8 gi\u00e0 configurata", + "resolve_failed": "Questo host non pu\u00f2 essere risolto", + "wrong_host": "Il certificato non corrisponde al nome host" + }, + "step": { + "user": { + "data": { + "host": "L'hostname del certificato", + "name": "Il nome del certificato", + "port": "La porta del certificato" + }, + "title": "Definire il certificato da testare" + } + }, + "title": "Scadenza certificato" + } +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/.translations/ko.json b/homeassistant/components/cert_expiry/.translations/ko.json new file mode 100644 index 000000000..25c518f86 --- /dev/null +++ b/homeassistant/components/cert_expiry/.translations/ko.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "host_port_exists": "\ud638\uc2a4\ud2b8\uc640 \ud3ec\ud2b8\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "error": { + "certificate_error": "\uc778\uc99d\uc11c\ub97c \ud655\uc778\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", + "certificate_fetch_failed": "\ud574\ub2f9 \ud638\uc2a4\ud2b8\uc640 \ud3ec\ud2b8\uc5d0\uc11c \uc778\uc99d\uc11c\ub97c \uac00\uc838 \uc62c \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", + "connection_timeout": "\ud638\uc2a4\ud2b8 \uc5f0\uacb0 \uc2dc\uac04\uc774 \ucd08\uacfc\ud588\uc2b5\ub2c8\ub2e4", + "host_port_exists": "\ud638\uc2a4\ud2b8\uc640 \ud3ec\ud2b8\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "resolve_failed": "\ud638\uc2a4\ud2b8\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", + "wrong_host": "\uc778\uc99d\uc11c\uac00 \ud638\uc2a4\ud2b8 \uc774\ub984\uacfc \uc77c\uce58\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "host": "\uc778\uc99d\uc11c\uc758 \ud638\uc2a4\ud2b8 \uc774\ub984", + "name": "\uc778\uc99d\uc11c\uc758 \uc774\ub984", + "port": "\uc778\uc99d\uc11c\uc758 \ud3ec\ud2b8" + }, + "title": "\uc778\uc99d\uc11c \uc815\uc758 \ud14c\uc2a4\ud2b8 \ub300\uc0c1" + } + }, + "title": "\uc778\uc99d\uc11c \ub9cc\ub8cc" + } +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/.translations/lb.json b/homeassistant/components/cert_expiry/.translations/lb.json new file mode 100644 index 000000000..14d12967a --- /dev/null +++ b/homeassistant/components/cert_expiry/.translations/lb.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "host_port_exists": "D\u00ebsen Host an Port sinn scho konfigur\u00e9iert" + }, + "error": { + "certificate_error": "Zertifikat konnt net valid\u00e9iert ginn", + "certificate_fetch_failed": "Kann keen Zertifikat vun d\u00ebsen Host a Port recuper\u00e9ieren", + "connection_timeout": "Z\u00e4it Iwwerschreidung beim verbannen.", + "host_port_exists": "D\u00ebsen Host an Port sinn scho konfigur\u00e9iert", + "resolve_failed": "D\u00ebsen Host kann net opgel\u00e9ist ginn", + "wrong_host": "Zertifikat entspr\u00e9cht net den Numm vum Apparat" + }, + "step": { + "user": { + "data": { + "host": "Den Hostnumm vum Zertifikat", + "name": "De Numm vum Zertifikat", + "port": "De Port vum Zertifikat" + }, + "title": "W\u00e9ieen Zertifikat soll getest ginn" + } + }, + "title": "Zertifikat Verfallsdatum" + } +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/.translations/nl.json b/homeassistant/components/cert_expiry/.translations/nl.json new file mode 100644 index 000000000..0544c8c02 --- /dev/null +++ b/homeassistant/components/cert_expiry/.translations/nl.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "host_port_exists": "Deze combinatie van host en poort is al geconfigureerd" + }, + "error": { + "certificate_error": "Certificaat kon niet worden gevalideerd", + "certificate_fetch_failed": "Kan certificaat niet ophalen van deze combinatie van host en poort", + "connection_timeout": "Time-out bij verbinding maken met deze host", + "host_port_exists": "Deze combinatie van host en poort is al geconfigureerd", + "resolve_failed": "Deze host kon niet gevonden worden", + "wrong_host": "Certificaat komt niet overeen met hostnaam" + }, + "step": { + "user": { + "data": { + "host": "De hostnaam van het certificaat", + "name": "De naam van het certificaat", + "port": "De poort van het certificaat" + }, + "title": "Het certificaat defini\u00ebren dat moet worden getest" + } + }, + "title": "Vervaldatum certificaat" + } +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/.translations/no.json b/homeassistant/components/cert_expiry/.translations/no.json new file mode 100644 index 000000000..fc2e98b72 --- /dev/null +++ b/homeassistant/components/cert_expiry/.translations/no.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "host_port_exists": "Denne verts- og portkombinasjonen er allerede konfigurert" + }, + "error": { + "certificate_error": "Sertifikatet kunne ikke valideres", + "certificate_fetch_failed": "Kan ikke hente sertifikat fra denne verts- og portkombinasjonen", + "connection_timeout": "Tidsavbrudd n\u00e5r du kobler til denne verten", + "host_port_exists": "Denne verts- og portkombinasjonen er allerede konfigurert", + "resolve_failed": "Denne verten kan ikke l\u00f8ses", + "wrong_host": "Sertifikatet samsvarer ikke med vertsnavn" + }, + "step": { + "user": { + "data": { + "host": "Sertifikatets vertsnavn", + "name": "Sertifikatets navn", + "port": "Sertifikatets port" + }, + "title": "Definer sertifikatet som skal testes" + } + }, + "title": "Sertifikat utl\u00f8p" + } +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/.translations/pl.json b/homeassistant/components/cert_expiry/.translations/pl.json new file mode 100644 index 000000000..671cbfcd1 --- /dev/null +++ b/homeassistant/components/cert_expiry/.translations/pl.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "host_port_exists": "Ta kombinacja hosta i portu jest ju\u017c skonfigurowana" + }, + "error": { + "certificate_error": "Nie mo\u017cna zweryfikowa\u0107 certyfikatu", + "certificate_fetch_failed": "Nie mo\u017cna pobra\u0107 certyfikatu z tej kombinacji hosta i portu", + "connection_timeout": "Przekroczono limit czasu po\u0142\u0105czenia z hostem.", + "host_port_exists": "Ta kombinacja hosta i portu jest ju\u017c skonfigurowana", + "resolve_failed": "Tego hosta nie mo\u017cna rozwi\u0105za\u0107", + "wrong_host": "Certyfikat nie pasuje do nazwy hosta" + }, + "step": { + "user": { + "data": { + "host": "Nazwa hosta certyfikatu", + "name": "Nazwa certyfikatu", + "port": "Port certyfikatu" + }, + "title": "Zdefiniuj certyfikat do przetestowania" + } + }, + "title": "Wa\u017cno\u015b\u0107 certyfikatu" + } +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/.translations/pt-BR.json b/homeassistant/components/cert_expiry/.translations/pt-BR.json new file mode 100644 index 000000000..06534314e --- /dev/null +++ b/homeassistant/components/cert_expiry/.translations/pt-BR.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "host_port_exists": "Essa combina\u00e7\u00e3o de host e porta j\u00e1 est\u00e1 configurada" + }, + "error": { + "certificate_fetch_failed": "N\u00e3o \u00e9 poss\u00edvel buscar o certificado dessa combina\u00e7\u00e3o de host e porta", + "connection_timeout": "Tempo limite ao conectar-se a este host", + "host_port_exists": "Essa combina\u00e7\u00e3o de host e porta j\u00e1 est\u00e1 configurada", + "resolve_failed": "Este host n\u00e3o pode ser resolvido" + }, + "step": { + "user": { + "data": { + "host": "O nome do host do certificado", + "name": "O nome do certificado", + "port": "A porta do certificado" + }, + "title": "Defina o certificado para testar" + } + }, + "title": "Expira\u00e7\u00e3o do certificado" + } +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/.translations/ru.json b/homeassistant/components/cert_expiry/.translations/ru.json new file mode 100644 index 000000000..8c0f23038 --- /dev/null +++ b/homeassistant/components/cert_expiry/.translations/ru.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "host_port_exists": "\u042d\u0442\u0430 \u043a\u043e\u043c\u0431\u0438\u043d\u0430\u0446\u0438\u044f \u0445\u043e\u0441\u0442\u0430 \u0438 \u043f\u043e\u0440\u0442\u0430 \u0443\u0436\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u0430." + }, + "error": { + "certificate_error": "\u041d\u0435 \u0443\u0434\u0430\u0435\u0442\u0441\u044f \u043f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442.", + "certificate_fetch_failed": "\u041d\u0435 \u0443\u0434\u0430\u0435\u0442\u0441\u044f \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442 \u0441 \u044d\u0442\u043e\u0439 \u043a\u043e\u043c\u0431\u0438\u043d\u0430\u0446\u0438\u0438 \u0445\u043e\u0441\u0442\u0430 \u0438 \u043f\u043e\u0440\u0442\u0430.", + "connection_timeout": "\u0418\u0441\u0442\u0435\u043a\u043b\u043e \u0432\u0440\u0435\u043c\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f \u043a \u0445\u043e\u0441\u0442\u0443.", + "host_port_exists": "\u042d\u0442\u0430 \u043a\u043e\u043c\u0431\u0438\u043d\u0430\u0446\u0438\u044f \u0445\u043e\u0441\u0442\u0430 \u0438 \u043f\u043e\u0440\u0442\u0430 \u0443\u0436\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u0430.", + "resolve_failed": "\u041d\u0435\u0432\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u043f\u0440\u0435\u043e\u0431\u0440\u0430\u0437\u043e\u0432\u0430\u0442\u044c \u0445\u043e\u0441\u0442.", + "wrong_host": "\u0421\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442 \u043d\u0435 \u0441\u043e\u043e\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0443\u0435\u0442 \u0434\u043e\u043c\u0435\u043d\u043d\u043e\u043c\u0443 \u0438\u043c\u0435\u043d\u0438." + }, + "step": { + "user": { + "data": { + "host": "\u0414\u043e\u043c\u0435\u043d\u043d\u043e\u0435 \u0438\u043c\u044f", + "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435", + "port": "\u041f\u043e\u0440\u0442" + }, + "title": "\u0421\u0440\u043e\u043a \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u044f \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u0430" + } + }, + "title": "\u0421\u0440\u043e\u043a \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u044f \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u0430" + } +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/.translations/sl.json b/homeassistant/components/cert_expiry/.translations/sl.json new file mode 100644 index 000000000..d375c626c --- /dev/null +++ b/homeassistant/components/cert_expiry/.translations/sl.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "host_port_exists": "Ta kombinacija gostitelja in vrat je \u017ee konfigurirana" + }, + "error": { + "certificate_error": "Certifikata ni bilo mogo\u010de preveriti", + "certificate_fetch_failed": "Iz te kombinacije gostitelja in vrat ni mogo\u010de pridobiti potrdila", + "connection_timeout": "\u010casovna omejitev za povezavo s tem gostiteljem je potekla", + "host_port_exists": "Ta kombinacija gostitelja in vrat je \u017ee konfigurirana", + "resolve_failed": "Tega gostitelja ni mogo\u010de razre\u0161iti", + "wrong_host": "Potrdilo se ne ujema z imenom gostitelja" + }, + "step": { + "user": { + "data": { + "host": "Ime gostitelja potrdila", + "name": "Ime potrdila", + "port": "Vrata potrdila" + }, + "title": "Dolo\u010dite potrdilo za testiranje" + } + }, + "title": "Veljavnost certifikata" + } +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/.translations/zh-Hans.json b/homeassistant/components/cert_expiry/.translations/zh-Hans.json new file mode 100644 index 000000000..07affc990 --- /dev/null +++ b/homeassistant/components/cert_expiry/.translations/zh-Hans.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "connection_timeout": "\u8fde\u63a5\u5230\u6b64\u4e3b\u673a\u65f6\u7684\u8d85\u65f6" + }, + "step": { + "user": { + "data": { + "host": "\u8bc1\u4e66\u7684\u4e3b\u673a\u540d", + "name": "\u8bc1\u4e66\u7684\u540d\u79f0", + "port": "\u8bc1\u4e66\u7684\u7aef\u53e3" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/.translations/zh-Hant.json b/homeassistant/components/cert_expiry/.translations/zh-Hant.json new file mode 100644 index 000000000..c710deae5 --- /dev/null +++ b/homeassistant/components/cert_expiry/.translations/zh-Hant.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "host_port_exists": "\u6b64\u4e3b\u6a5f\u7aef\u8207\u901a\u8a0a\u57e0\u7d44\u5408\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "certificate_error": "\u8a8d\u8b49\u7121\u6cd5\u78ba\u8a8d", + "certificate_fetch_failed": "\u7121\u6cd5\u81ea\u6b64\u4e3b\u6a5f\u7aef\u8207\u901a\u8a0a\u57e0\u7d44\u5408\u7372\u5f97\u8a8d\u8b49", + "connection_timeout": "\u9023\u7dda\u81f3\u4e3b\u6a5f\u7aef\u903e\u6642", + "host_port_exists": "\u6b64\u4e3b\u6a5f\u7aef\u8207\u901a\u8a0a\u57e0\u7d44\u5408\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "resolve_failed": "\u4e3b\u6a5f\u7aef\u7121\u6cd5\u89e3\u6790", + "wrong_host": "\u8a8d\u8b49\u8207\u4e3b\u6a5f\u540d\u7a31\u4e0d\u7b26\u5408" + }, + "step": { + "user": { + "data": { + "host": "\u8a8d\u8b49\u4e3b\u6a5f\u7aef\u540d\u7a31", + "name": "\u8a8d\u8b49\u540d\u7a31", + "port": "\u8a8d\u8b49\u901a\u8a0a\u57e0" + }, + "title": "\u5b9a\u7fa9\u8a8d\u8b49\u9032\u884c\u6e2c\u8a66" + } + }, + "title": "\u8a8d\u8b49\u5df2\u904e\u671f" + } +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/__init__.py b/homeassistant/components/cert_expiry/__init__.py new file mode 100644 index 000000000..28a79a3e5 --- /dev/null +++ b/homeassistant/components/cert_expiry/__init__.py @@ -0,0 +1,22 @@ +"""The cert_expiry component.""" +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.typing import HomeAssistantType + + +async def async_setup(hass, config): + """Platform setup, do nothing.""" + return True + + +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): + """Load the saved entities.""" + + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, "sensor") + ) + return True + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + return await hass.config_entries.async_forward_entry_unload(entry, "sensor") diff --git a/homeassistant/components/cert_expiry/config_flow.py b/homeassistant/components/cert_expiry/config_flow.py new file mode 100644 index 000000000..14532aea6 --- /dev/null +++ b/homeassistant/components/cert_expiry/config_flow.py @@ -0,0 +1,116 @@ +"""Config flow for the Cert Expiry platform.""" +import logging +import socket +import ssl + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.core import HomeAssistant, callback + +from .const import DEFAULT_NAME, DEFAULT_PORT, DOMAIN +from .helper import get_cert + +_LOGGER = logging.getLogger(__name__) + + +@callback +def certexpiry_entries(hass: HomeAssistant): + """Return the host,port tuples for the domain.""" + return set( + (entry.data[CONF_HOST], entry.data[CONF_PORT]) + for entry in hass.config_entries.async_entries(DOMAIN) + ) + + +class CertexpiryConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + def __init__(self) -> None: + """Initialize the config flow.""" + self._errors = {} + + def _prt_in_configuration_exists(self, user_input) -> bool: + """Return True if host, port combination exists in configuration.""" + host = user_input[CONF_HOST] + port = user_input.get(CONF_PORT, DEFAULT_PORT) + if (host, port) in certexpiry_entries(self.hass): + return True + return False + + async def _test_connection(self, user_input=None): + """Test connection to the server and try to get the certtificate.""" + host = user_input[CONF_HOST] + try: + await self.hass.async_add_executor_job( + get_cert, host, user_input.get(CONF_PORT, DEFAULT_PORT) + ) + return True + except socket.gaierror: + _LOGGER.error("Host cannot be resolved: %s", host) + self._errors[CONF_HOST] = "resolve_failed" + except socket.timeout: + _LOGGER.error("Timed out connecting to %s", host) + self._errors[CONF_HOST] = "connection_timeout" + except ssl.CertificateError as err: + if "doesn't match" in err.args[0]: + _LOGGER.error("Certificate does not match host: %s", host) + self._errors[CONF_HOST] = "wrong_host" + else: + _LOGGER.error("Certificate could not be validated: %s", host) + self._errors[CONF_HOST] = "certificate_error" + except ssl.SSLError: + _LOGGER.error("Certificate could not be validated: %s", host) + self._errors[CONF_HOST] = "certificate_error" + return False + + async def async_step_user(self, user_input=None): + """Step when user intializes a integration.""" + self._errors = {} + if user_input is not None: + # set some defaults in case we need to return to the form + if self._prt_in_configuration_exists(user_input): + self._errors[CONF_HOST] = "host_port_exists" + else: + if await self._test_connection(user_input): + return self.async_create_entry( + title=user_input.get(CONF_NAME, DEFAULT_NAME), + data={ + CONF_HOST: user_input[CONF_HOST], + CONF_PORT: user_input.get(CONF_PORT, DEFAULT_PORT), + }, + ) + else: + user_input = {} + user_input[CONF_NAME] = DEFAULT_NAME + user_input[CONF_HOST] = "" + user_input[CONF_PORT] = DEFAULT_PORT + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required( + CONF_NAME, default=user_input.get(CONF_NAME, DEFAULT_NAME) + ): str, + vol.Required(CONF_HOST, default=user_input[CONF_HOST]): str, + vol.Required( + CONF_PORT, default=user_input.get(CONF_PORT, DEFAULT_PORT) + ): int, + } + ), + errors=self._errors, + ) + + async def async_step_import(self, user_input=None): + """Import a config entry. + + Only host was required in the yaml file all other fields are optional + """ + if self._prt_in_configuration_exists(user_input): + return self.async_abort(reason="host_port_exists") + return await self.async_step_user(user_input) diff --git a/homeassistant/components/cert_expiry/const.py b/homeassistant/components/cert_expiry/const.py new file mode 100644 index 000000000..4129781f2 --- /dev/null +++ b/homeassistant/components/cert_expiry/const.py @@ -0,0 +1,6 @@ +"""Const for Cert Expiry.""" + +DOMAIN = "cert_expiry" +DEFAULT_NAME = "SSL Certificate Expiry" +DEFAULT_PORT = 443 +TIMEOUT = 10.0 diff --git a/homeassistant/components/cert_expiry/helper.py b/homeassistant/components/cert_expiry/helper.py new file mode 100644 index 000000000..cd49588ec --- /dev/null +++ b/homeassistant/components/cert_expiry/helper.py @@ -0,0 +1,16 @@ +"""Helper functions for the Cert Expiry platform.""" +import socket +import ssl + +from .const import TIMEOUT + + +def get_cert(host, port): + """Get the ssl certificate for the host and port combination.""" + ctx = ssl.create_default_context() + address = (host, port) + with socket.create_connection(address, timeout=TIMEOUT) as sock: + with ctx.wrap_socket(sock, server_hostname=address[0]) as ssock: + # pylint disable: https://github.com/PyCQA/pylint/issues/3166 + cert = ssock.getpeercert() # pylint: disable=no-member + return cert diff --git a/homeassistant/components/cert_expiry/manifest.json b/homeassistant/components/cert_expiry/manifest.json new file mode 100644 index 000000000..48816809b --- /dev/null +++ b/homeassistant/components/cert_expiry/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "cert_expiry", + "name": "Cert expiry", + "documentation": "https://www.home-assistant.io/integrations/cert_expiry", + "requirements": [], + "config_flow": true, + "dependencies": [], + "codeowners": [ + "@Cereal2nd", + "@jjlawren" + ] +} diff --git a/homeassistant/components/cert_expiry/sensor.py b/homeassistant/components/cert_expiry/sensor.py new file mode 100644 index 000000000..3a76575df --- /dev/null +++ b/homeassistant/components/cert_expiry/sensor.py @@ -0,0 +1,151 @@ +"""Counter for the days until an HTTPS (TLS) certificate will expire.""" +from datetime import datetime, timedelta +import logging +import socket +import ssl + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PORT, + EVENT_HOMEASSISTANT_START, +) +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +from .const import DEFAULT_NAME, DEFAULT_PORT, DOMAIN +from .helper import get_cert + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(hours=12) + +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, + } +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up certificate expiry sensor.""" + + @callback + def do_import(_): + """Process YAML import after HA is fully started.""" + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=dict(config) + ) + ) + + # Delay to avoid validation during setup in case we're checking our own cert. + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, do_import) + + +async def async_setup_entry(hass, entry, async_add_entities): + """Add cert-expiry entry.""" + async_add_entities( + [SSLCertificate(entry.title, entry.data[CONF_HOST], entry.data[CONF_PORT])], + False, + # Don't update in case we're checking our own cert. + ) + return True + + +class SSLCertificate(Entity): + """Implementation of the certificate expiry sensor.""" + + def __init__(self, sensor_name, server_name, server_port): + """Initialize the sensor.""" + self.server_name = server_name + self.server_port = server_port + self._name = sensor_name + self._state = None + self._available = False + self._valid = False + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def unique_id(self): + """Return a unique id for the sensor.""" + return f"{self.server_name}:{self.server_port}" + + @property + def unit_of_measurement(self): + """Return the unit this state is expressed in.""" + return "days" + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return "mdi:certificate" + + @property + def available(self): + """Return the availability of the sensor.""" + return self._available + + async def async_added_to_hass(self): + """Once the entity is added we should update to get the initial data loaded.""" + + @callback + def do_update(_): + """Run the update method when the start event was fired.""" + self.async_schedule_update_ha_state(True) + + if self.hass.is_running: + self.async_schedule_update_ha_state(True) + else: + # Delay until HA is fully started in case we're checking our own cert. + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, do_update) + + def update(self): + """Fetch the certificate information.""" + try: + cert = get_cert(self.server_name, self.server_port) + except socket.gaierror: + _LOGGER.error("Cannot resolve hostname: %s", self.server_name) + self._available = False + self._valid = False + return + except socket.timeout: + _LOGGER.error("Connection timeout with server: %s", self.server_name) + self._available = False + self._valid = False + return + except (ssl.CertificateError, ssl.SSLError): + self._available = True + self._state = 0 + self._valid = False + return + + ts_seconds = ssl.cert_time_to_seconds(cert["notAfter"]) + timestamp = datetime.fromtimestamp(ts_seconds) + expiry = timestamp - datetime.today() + self._available = True + self._state = expiry.days + self._valid = True + + @property + def device_state_attributes(self): + """Return additional sensor state attributes.""" + attr = {"is_valid": self._valid} + + return attr diff --git a/homeassistant/components/cert_expiry/strings.json b/homeassistant/components/cert_expiry/strings.json new file mode 100644 index 000000000..e5e670d21 --- /dev/null +++ b/homeassistant/components/cert_expiry/strings.json @@ -0,0 +1,25 @@ +{ + "config": { + "title": "Certificate Expiry", + "step": { + "user": { + "title": "Define the certificate to test", + "data": { + "name": "The name of the certificate", + "host": "The hostname of the certificate", + "port": "The port of the certificate" + } + } + }, + "error": { + "host_port_exists": "This host and port combination is already configured", + "resolve_failed": "This host can not be resolved", + "connection_timeout": "Timeout when connecting to this host", + "certificate_error": "Certificate could not be validated", + "wrong_host": "Certificate does not match hostname" + }, + "abort": { + "host_port_exists": "This host and port combination is already configured" + } + } +} diff --git a/homeassistant/components/channels/__init__.py b/homeassistant/components/channels/__init__.py new file mode 100644 index 000000000..70a8c0677 --- /dev/null +++ b/homeassistant/components/channels/__init__.py @@ -0,0 +1 @@ +"""The channels component.""" diff --git a/homeassistant/components/channels/const.py b/homeassistant/components/channels/const.py new file mode 100644 index 000000000..5ae7fdebb --- /dev/null +++ b/homeassistant/components/channels/const.py @@ -0,0 +1,5 @@ +"""Constants for the Channels component.""" +DOMAIN = "channels" +SERVICE_SEEK_FORWARD = "seek_forward" +SERVICE_SEEK_BACKWARD = "seek_backward" +SERVICE_SEEK_BY = "seek_by" diff --git a/homeassistant/components/channels/manifest.json b/homeassistant/components/channels/manifest.json new file mode 100644 index 000000000..c6ef7f8f1 --- /dev/null +++ b/homeassistant/components/channels/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "channels", + "name": "Channels", + "documentation": "https://www.home-assistant.io/integrations/channels", + "requirements": [ + "pychannels==1.0.0" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/channels/media_player.py b/homeassistant/components/channels/media_player.py new file mode 100644 index 000000000..e4acc2f90 --- /dev/null +++ b/homeassistant/components/channels/media_player.py @@ -0,0 +1,315 @@ +"""Support for interfacing with an instance of getchannels.com.""" +import logging + +from pychannels import Channels +import voluptuous as vol + +from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerDevice +from homeassistant.components.media_player.const import ( + MEDIA_TYPE_CHANNEL, + MEDIA_TYPE_EPISODE, + MEDIA_TYPE_MOVIE, + MEDIA_TYPE_TVSHOW, + SUPPORT_NEXT_TRACK, + SUPPORT_PAUSE, + SUPPORT_PLAY, + SUPPORT_PLAY_MEDIA, + SUPPORT_PREVIOUS_TRACK, + SUPPORT_SELECT_SOURCE, + SUPPORT_STOP, + SUPPORT_VOLUME_MUTE, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_HOST, + CONF_NAME, + CONF_PORT, + STATE_IDLE, + STATE_PAUSED, + STATE_PLAYING, +) +import homeassistant.helpers.config_validation as cv + +from .const import DOMAIN, SERVICE_SEEK_BACKWARD, SERVICE_SEEK_BY, SERVICE_SEEK_FORWARD + +_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 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)} +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Channels platform.""" + device = ChannelsPlayer( + config.get(CONF_NAME), config.get(CONF_HOST), config.get(CONF_PORT) + ) + + if DATA_CHANNELS not in hass.data: + hass.data[DATA_CHANNELS] = [] + + add_entities([device], True) + hass.data[DATA_CHANNELS].append(device) + + def service_handler(service): + """Handle service.""" + 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.""" + + def __init__(self, name, host, port): + """Initialize the Channels app.""" + + 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 + if 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/channels/services.yaml b/homeassistant/components/channels/services.yaml new file mode 100644 index 000000000..cbb1dd201 --- /dev/null +++ b/homeassistant/components/channels/services.yaml @@ -0,0 +1,23 @@ +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' + +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' + +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 diff --git a/homeassistant/components/cisco_ios/__init__.py b/homeassistant/components/cisco_ios/__init__.py new file mode 100644 index 000000000..77e7c6621 --- /dev/null +++ b/homeassistant/components/cisco_ios/__init__.py @@ -0,0 +1 @@ +"""The cisco_ios component.""" diff --git a/homeassistant/components/cisco_ios/device_tracker.py b/homeassistant/components/cisco_ios/device_tracker.py new file mode 100644 index 000000000..5a42ef1c8 --- /dev/null +++ b/homeassistant/components/cisco_ios/device_tracker.py @@ -0,0 +1,158 @@ +"""Support for Cisco IOS Routers.""" +import logging +import re + +from pexpect import pxssh +import voluptuous as vol + +from homeassistant.components.device_tracker import ( + DOMAIN, + PLATFORM_SCHEMA, + DeviceScanner, +) +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = vol.All( + PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Optional(CONF_PASSWORD, default=""): cv.string, + vol.Optional(CONF_PORT): cv.port, + } + ) +) + + +def get_scanner(hass, config): + """Validate the configuration and return a Cisco scanner.""" + scanner = CiscoDeviceScanner(config[DOMAIN]) + + return scanner if scanner.success_init else None + + +class CiscoDeviceScanner(DeviceScanner): + """This class queries a wireless router running Cisco IOS firmware.""" + + def __init__(self, config): + """Initialize the scanner.""" + self.host = config[CONF_HOST] + self.username = config[CONF_USERNAME] + self.port = config.get(CONF_PORT) + self.password = config.get(CONF_PASSWORD) + + self.last_results = {} + + self.success_init = self._update_info() + _LOGGER.info("cisco_ios scanner initialized") + + def get_device_name(self, device): + """Get the firmware doesn't save the name of the wireless device.""" + return None + + def scan_devices(self): + """Scan for new devices and return a list with found device IDs.""" + self._update_info() + + return self.last_results + + def _update_info(self): + """ + Ensure the information from the Cisco router is up to date. + + Returns boolean if scanning successful. + """ + string_result = self._get_arp_data() + + if string_result: + self.last_results = [] + last_results = [] + + lines_result = string_result.splitlines() + + # Remove the first two lines, as they contains the arp command + # and the arp table titles e.g. + # show ip arp + # Protocol Address | Age (min) | Hardware Addr | Type | Interface + lines_result = lines_result[2:] + + for line in lines_result: + parts = line.split() + if len(parts) != 6: + continue + + # ['Internet', '10.10.11.1', '-', '0027.d32d.0123', 'ARPA', + # 'GigabitEthernet0'] + age = parts[2] + hw_addr = parts[3] + + if age != "-": + mac = _parse_cisco_mac_address(hw_addr) + age = int(age) + if age < 1: + last_results.append(mac) + + self.last_results = last_results + return True + + return False + + def _get_arp_data(self): + """Open connection to the router and get arp entries.""" + + try: + cisco_ssh = pxssh.pxssh() + cisco_ssh.login( + self.host, + self.username, + self.password, + port=self.port, + auto_prompt_reset=False, + ) + + # Find the hostname + initial_line = cisco_ssh.before.decode("utf-8").splitlines() + router_hostname = initial_line[len(initial_line) - 1] + router_hostname += "#" + # Set the discovered hostname as prompt + regex_expression = ("(?i)^%s" % router_hostname).encode() + cisco_ssh.PROMPT = re.compile(regex_expression, re.MULTILINE) + # Allow full arp table to print at once + cisco_ssh.sendline("terminal length 0") + cisco_ssh.prompt(1) + + cisco_ssh.sendline("show ip arp") + cisco_ssh.prompt(1) + + devices_result = cisco_ssh.before + + return devices_result.decode("utf-8") + except pxssh.ExceptionPxssh as px_e: + _LOGGER.error("pxssh failed on login") + _LOGGER.error(px_e) + + return None + + +def _parse_cisco_mac_address(cisco_hardware_addr): + """ + Parse a Cisco formatted HW address to normal MAC. + + e.g. convert + 001d.ec02.07ab + + to: + 00:1D:EC:02:07:AB + + Takes in cisco_hwaddr: HWAddr String from Cisco ARP table + Returns a regular standard MAC address + """ + cisco_hardware_addr = cisco_hardware_addr.replace(".", "") + blocks = [ + cisco_hardware_addr[x : x + 2] for x in range(0, len(cisco_hardware_addr), 2) + ] + + return ":".join(blocks).upper() diff --git a/homeassistant/components/cisco_ios/manifest.json b/homeassistant/components/cisco_ios/manifest.json new file mode 100644 index 000000000..4a04ffa32 --- /dev/null +++ b/homeassistant/components/cisco_ios/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "cisco_ios", + "name": "Cisco ios", + "documentation": "https://www.home-assistant.io/integrations/cisco_ios", + "requirements": [ + "pexpect==4.6.0" + ], + "dependencies": [], + "codeowners": ["@fbradyirl"] +} diff --git a/homeassistant/components/cisco_mobility_express/__init__.py b/homeassistant/components/cisco_mobility_express/__init__.py new file mode 100644 index 000000000..625a71a5b --- /dev/null +++ b/homeassistant/components/cisco_mobility_express/__init__.py @@ -0,0 +1 @@ +"""Component to embed Cisco Mobility Express.""" diff --git a/homeassistant/components/cisco_mobility_express/device_tracker.py b/homeassistant/components/cisco_mobility_express/device_tracker.py new file mode 100644 index 000000000..702ebdfa6 --- /dev/null +++ b/homeassistant/components/cisco_mobility_express/device_tracker.py @@ -0,0 +1,93 @@ +"""Support for Cisco Mobility Express.""" +import logging + +from ciscomobilityexpress.ciscome import CiscoMobilityExpress +import voluptuous as vol + +from homeassistant.components.device_tracker import ( + DOMAIN, + PLATFORM_SCHEMA, + DeviceScanner, +) +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_SSL = False +DEFAULT_VERIFY_SSL = True + +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_SSL, default=DEFAULT_SSL): cv.boolean, + vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, + } +) + + +def get_scanner(hass, config): + """Validate the configuration and return a Cisco ME scanner.""" + + config = config[DOMAIN] + + controller = CiscoMobilityExpress( + config[CONF_HOST], + config[CONF_USERNAME], + config[CONF_PASSWORD], + config.get(CONF_SSL), + config.get(CONF_VERIFY_SSL), + ) + if not controller.is_logged_in(): + return None + return CiscoMEDeviceScanner(controller) + + +class CiscoMEDeviceScanner(DeviceScanner): + """This class scans for devices associated to a Cisco ME controller.""" + + def __init__(self, controller): + """Initialize the scanner.""" + self.controller = controller + self.last_results = {} + + def scan_devices(self): + """Scan for new devices and return a list with found device IDs.""" + self._update_info() + + return [device.macaddr 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.""" + name = next( + (result.clId for result in self.last_results if result.macaddr == device), + None, + ) + return name + + def get_extra_attributes(self, device): + """ + Get extra attributes of a device. + + Some known extra attributes that may be returned in the device tuple + include SSID, PT (eg 802.11ac), devtype (eg iPhone 7) among others. + """ + device = next( + (result for result in self.last_results if result.macaddr == device), None + ) + return device._asdict() + + def _update_info(self): + """Check the Cisco ME controller for devices.""" + self.last_results = self.controller.get_associated_devices() + _LOGGER.debug( + "Cisco Mobility Express controller returned:" " %s", self.last_results + ) diff --git a/homeassistant/components/cisco_mobility_express/manifest.json b/homeassistant/components/cisco_mobility_express/manifest.json new file mode 100644 index 000000000..b4bc2ff86 --- /dev/null +++ b/homeassistant/components/cisco_mobility_express/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "cisco_mobility_express", + "name": "Cisco mobility express", + "documentation": "https://www.home-assistant.io/integrations/cisco_mobility_express", + "requirements": [ + "ciscomobilityexpress==0.3.3" + ], + "dependencies": [], + "codeowners": ["@fbradyirl"] +} diff --git a/homeassistant/components/cisco_webex_teams/__init__.py b/homeassistant/components/cisco_webex_teams/__init__.py new file mode 100644 index 000000000..0a8714806 --- /dev/null +++ b/homeassistant/components/cisco_webex_teams/__init__.py @@ -0,0 +1 @@ +"""Component to integrate the Cisco Webex Teams cloud.""" diff --git a/homeassistant/components/cisco_webex_teams/manifest.json b/homeassistant/components/cisco_webex_teams/manifest.json new file mode 100644 index 000000000..3560b1e7f --- /dev/null +++ b/homeassistant/components/cisco_webex_teams/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "cisco_webex_teams", + "name": "Cisco webex teams", + "documentation": "https://www.home-assistant.io/integrations/cisco_webex_teams", + "requirements": [ + "webexteamssdk==1.1.1" + ], + "dependencies": [], + "codeowners": ["@fbradyirl"] +} diff --git a/homeassistant/components/cisco_webex_teams/notify.py b/homeassistant/components/cisco_webex_teams/notify.py new file mode 100644 index 000000000..6f80fa138 --- /dev/null +++ b/homeassistant/components/cisco_webex_teams/notify.py @@ -0,0 +1,58 @@ +"""Cisco Webex Teams notify component.""" +import logging + +import voluptuous as vol +from webexteamssdk import ApiError, WebexTeamsAPI, exceptions + +from homeassistant.components.notify import ( + ATTR_TITLE, + PLATFORM_SCHEMA, + BaseNotificationService, +) +from homeassistant.const import CONF_TOKEN +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +CONF_ROOM_ID = "room_id" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + {vol.Required(CONF_TOKEN): cv.string, vol.Required(CONF_ROOM_ID): cv.string} +) + + +def get_service(hass, config, discovery_info=None): + """Get the CiscoWebexTeams notification service.""" + + client = WebexTeamsAPI(access_token=config[CONF_TOKEN]) + try: + # Validate the token & room_id + client.rooms.get(config[CONF_ROOM_ID]) + except exceptions.ApiError as error: + _LOGGER.error(error) + return None + + return CiscoWebexTeamsNotificationService(client, config[CONF_ROOM_ID]) + + +class CiscoWebexTeamsNotificationService(BaseNotificationService): + """The Cisco Webex Teams Notification Service.""" + + def __init__(self, client, room): + """Initialize the service.""" + self.room = room + self.client = client + + def send_message(self, message="", **kwargs): + """Send a message to a user.""" + + title = "" + if kwargs.get(ATTR_TITLE) is not None: + title = "{}{}".format(kwargs.get(ATTR_TITLE), "
") + + try: + self.client.messages.create(roomId=self.room, html=f"{title}{message}") + except ApiError as api_error: + _LOGGER.error( + "Could not send CiscoWebexTeams notification. " "Error: %s", api_error + ) diff --git a/homeassistant/components/ciscospark/__init__.py b/homeassistant/components/ciscospark/__init__.py new file mode 100644 index 000000000..f872a0257 --- /dev/null +++ b/homeassistant/components/ciscospark/__init__.py @@ -0,0 +1 @@ +"""The ciscospark component.""" diff --git a/homeassistant/components/ciscospark/manifest.json b/homeassistant/components/ciscospark/manifest.json new file mode 100644 index 000000000..8058088bf --- /dev/null +++ b/homeassistant/components/ciscospark/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "ciscospark", + "name": "Ciscospark", + "documentation": "https://www.home-assistant.io/integrations/ciscospark", + "requirements": [ + "ciscosparkapi==0.4.2" + ], + "dependencies": [], + "codeowners": ["@fbradyirl"] +} diff --git a/homeassistant/components/ciscospark/notify.py b/homeassistant/components/ciscospark/notify.py new file mode 100644 index 000000000..e765aff05 --- /dev/null +++ b/homeassistant/components/ciscospark/notify.py @@ -0,0 +1,52 @@ +"""Cisco Spark platform for notify component.""" +import logging + +from ciscosparkapi import CiscoSparkAPI, SparkApiError +import voluptuous as vol + +from homeassistant.components.notify import ( + ATTR_TITLE, + PLATFORM_SCHEMA, + BaseNotificationService, +) +from homeassistant.const import CONF_TOKEN +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +CONF_ROOMID = "roomid" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + {vol.Required(CONF_TOKEN): cv.string, vol.Required(CONF_ROOMID): cv.string} +) + + +def get_service(hass, config, discovery_info=None): + """Get the CiscoSpark notification service.""" + return CiscoSparkNotificationService( + config.get(CONF_TOKEN), config.get(CONF_ROOMID) + ) + + +class CiscoSparkNotificationService(BaseNotificationService): + """The Cisco Spark Notification Service.""" + + def __init__(self, token, default_room): + """Initialize the service.""" + + self._default_room = default_room + self._token = token + self._spark = CiscoSparkAPI(access_token=self._token) + + def send_message(self, message="", **kwargs): + """Send a message to a user.""" + + try: + title = "" + if kwargs.get(ATTR_TITLE) is not None: + title = kwargs.get(ATTR_TITLE) + ": " + self._spark.messages.create(roomId=self._default_room, text=title + message) + except SparkApiError as api_error: + _LOGGER.error( + "Could not send CiscoSpark notification. Error: %s", api_error + ) diff --git a/homeassistant/components/citybikes/__init__.py b/homeassistant/components/citybikes/__init__.py new file mode 100644 index 000000000..03ae63eb0 --- /dev/null +++ b/homeassistant/components/citybikes/__init__.py @@ -0,0 +1 @@ +"""The citybikes component.""" diff --git a/homeassistant/components/citybikes/manifest.json b/homeassistant/components/citybikes/manifest.json new file mode 100644 index 000000000..f46c7ba70 --- /dev/null +++ b/homeassistant/components/citybikes/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "citybikes", + "name": "Citybikes", + "documentation": "https://www.home-assistant.io/integrations/citybikes", + "requirements": [], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/citybikes/sensor.py b/homeassistant/components/citybikes/sensor.py new file mode 100644 index 000000000..cb2647487 --- /dev/null +++ b/homeassistant/components/citybikes/sensor.py @@ -0,0 +1,314 @@ +"""Sensor for the CityBikes data.""" +import asyncio +from datetime import timedelta +import logging + +import aiohttp +import async_timeout +import voluptuous as vol + +from homeassistant.components.sensor import ENTITY_ID_FORMAT, PLATFORM_SCHEMA +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_ID, + ATTR_LATITUDE, + ATTR_LOCATION, + ATTR_LONGITUDE, + ATTR_NAME, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_NAME, + CONF_RADIUS, + LENGTH_FEET, + LENGTH_METERS, +) +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity, async_generate_entity_id +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.util import distance, location + +_LOGGER = logging.getLogger(__name__) + +ATTR_EMPTY_SLOTS = "empty_slots" +ATTR_EXTRA = "extra" +ATTR_FREE_BIKES = "free_bikes" +ATTR_NETWORK = "network" +ATTR_NETWORKS_LIST = "networks" +ATTR_STATIONS_LIST = "stations" +ATTR_TIMESTAMP = "timestamp" +ATTR_UID = "uid" + +CONF_NETWORK = "network" +CONF_STATIONS_LIST = "stations" + +DEFAULT_ENDPOINT = "https://api.citybik.es/{uri}" +PLATFORM = "citybikes" + +MONITORED_NETWORKS = "monitored-networks" + +NETWORKS_URI = "v2/networks" + +REQUEST_TIMEOUT = 5 # In seconds; argument to asyncio.timeout + +SCAN_INTERVAL = timedelta(minutes=5) # Timely, and doesn't suffocate the API + +STATIONS_URI = "v2/networks/{uid}?fields=network.stations" + +CITYBIKES_ATTRIBUTION = ( + "Information provided by the CityBikes Project " "(https://citybik.es/#about)" +) + +CITYBIKES_NETWORKS = "citybikes_networks" + +PLATFORM_SCHEMA = vol.All( + cv.has_at_least_one_key(CONF_RADIUS, CONF_STATIONS_LIST), + PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_NAME, default=""): cv.string, + vol.Optional(CONF_NETWORK): cv.string, + vol.Inclusive(CONF_LATITUDE, "coordinates"): cv.latitude, + vol.Inclusive(CONF_LONGITUDE, "coordinates"): cv.longitude, + vol.Optional(CONF_RADIUS, "station_filter"): cv.positive_int, + vol.Optional(CONF_STATIONS_LIST, "station_filter"): vol.All( + cv.ensure_list, vol.Length(min=1), [cv.string] + ), + } + ), +) + +NETWORK_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ID): cv.string, + vol.Required(ATTR_NAME): cv.string, + vol.Required(ATTR_LOCATION): vol.Schema( + { + vol.Required(ATTR_LATITUDE): cv.latitude, + vol.Required(ATTR_LONGITUDE): cv.longitude, + }, + extra=vol.REMOVE_EXTRA, + ), + }, + extra=vol.REMOVE_EXTRA, +) + +NETWORKS_RESPONSE_SCHEMA = vol.Schema( + {vol.Required(ATTR_NETWORKS_LIST): [NETWORK_SCHEMA]} +) + +STATION_SCHEMA = vol.Schema( + { + vol.Required(ATTR_FREE_BIKES): cv.positive_int, + vol.Required(ATTR_EMPTY_SLOTS): vol.Any(cv.positive_int, None), + vol.Required(ATTR_LATITUDE): cv.latitude, + vol.Required(ATTR_LONGITUDE): cv.longitude, + vol.Required(ATTR_ID): cv.string, + vol.Required(ATTR_NAME): cv.string, + vol.Required(ATTR_TIMESTAMP): cv.string, + vol.Optional(ATTR_EXTRA): vol.Schema( + {vol.Optional(ATTR_UID): cv.string}, extra=vol.REMOVE_EXTRA + ), + }, + extra=vol.REMOVE_EXTRA, +) + +STATIONS_RESPONSE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_NETWORK): vol.Schema( + {vol.Required(ATTR_STATIONS_LIST): [STATION_SCHEMA]}, extra=vol.REMOVE_EXTRA + ) + } +) + + +class CityBikesRequestError(Exception): + """Error to indicate a CityBikes API request has failed.""" + + pass + + +async def async_citybikes_request(hass, uri, schema): + """Perform a request to CityBikes API endpoint, and parse the response.""" + try: + session = async_get_clientsession(hass) + + with async_timeout.timeout(REQUEST_TIMEOUT): + req = await session.get(DEFAULT_ENDPOINT.format(uri=uri)) + + json_response = await req.json() + return schema(json_response) + except (asyncio.TimeoutError, aiohttp.ClientError): + _LOGGER.error("Could not connect to CityBikes API endpoint") + except ValueError: + _LOGGER.error("Received non-JSON data from CityBikes API endpoint") + except vol.Invalid as err: + _LOGGER.error( + "Received unexpected JSON from CityBikes" " API endpoint: %s", err + ) + raise CityBikesRequestError + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the CityBikes platform.""" + if PLATFORM not in hass.data: + hass.data[PLATFORM] = {MONITORED_NETWORKS: {}} + + latitude = config.get(CONF_LATITUDE, hass.config.latitude) + longitude = config.get(CONF_LONGITUDE, hass.config.longitude) + network_id = config.get(CONF_NETWORK) + stations_list = set(config.get(CONF_STATIONS_LIST, [])) + radius = config.get(CONF_RADIUS, 0) + name = config[CONF_NAME] + if not hass.config.units.is_metric: + radius = distance.convert(radius, LENGTH_FEET, LENGTH_METERS) + + # Create a single instance of CityBikesNetworks. + networks = hass.data.setdefault(CITYBIKES_NETWORKS, CityBikesNetworks(hass)) + + if not network_id: + network_id = await networks.get_closest_network_id(latitude, longitude) + + if network_id not in hass.data[PLATFORM][MONITORED_NETWORKS]: + network = CityBikesNetwork(hass, network_id) + hass.data[PLATFORM][MONITORED_NETWORKS][network_id] = network + hass.async_create_task(network.async_refresh()) + async_track_time_interval(hass, network.async_refresh, SCAN_INTERVAL) + else: + network = hass.data[PLATFORM][MONITORED_NETWORKS][network_id] + + await network.ready.wait() + + devices = [] + for station in network.stations: + dist = location.distance( + latitude, longitude, station[ATTR_LATITUDE], station[ATTR_LONGITUDE] + ) + station_id = station[ATTR_ID] + station_uid = str(station.get(ATTR_EXTRA, {}).get(ATTR_UID, "")) + + if radius > dist or stations_list.intersection((station_id, station_uid)): + if name: + uid = "_".join([network.network_id, name, station_id]) + else: + uid = "_".join([network.network_id, station_id]) + entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, uid, hass=hass) + devices.append(CityBikesStation(network, station_id, entity_id)) + + async_add_entities(devices, True) + + +class CityBikesNetworks: + """Represent all CityBikes networks.""" + + def __init__(self, hass): + """Initialize the networks instance.""" + self.hass = hass + self.networks = None + self.networks_loading = asyncio.Condition() + + async def get_closest_network_id(self, latitude, longitude): + """Return the id of the network closest to provided location.""" + try: + await self.networks_loading.acquire() + if self.networks is None: + networks = await async_citybikes_request( + self.hass, NETWORKS_URI, NETWORKS_RESPONSE_SCHEMA + ) + self.networks = networks[ATTR_NETWORKS_LIST] + result = None + minimum_dist = None + for network in self.networks: + network_latitude = network[ATTR_LOCATION][ATTR_LATITUDE] + network_longitude = network[ATTR_LOCATION][ATTR_LONGITUDE] + dist = location.distance( + latitude, longitude, network_latitude, network_longitude + ) + if minimum_dist is None or dist < minimum_dist: + minimum_dist = dist + result = network[ATTR_ID] + + return result + except CityBikesRequestError: + raise PlatformNotReady + finally: + self.networks_loading.release() + + +class CityBikesNetwork: + """Thin wrapper around a CityBikes network object.""" + + def __init__(self, hass, network_id): + """Initialize the network object.""" + self.hass = hass + self.network_id = network_id + self.stations = [] + self.ready = asyncio.Event() + + async def async_refresh(self, now=None): + """Refresh the state of the network.""" + try: + network = await async_citybikes_request( + self.hass, + STATIONS_URI.format(uid=self.network_id), + STATIONS_RESPONSE_SCHEMA, + ) + self.stations = network[ATTR_NETWORK][ATTR_STATIONS_LIST] + self.ready.set() + except CityBikesRequestError: + if now is not None: + self.ready.clear() + else: + raise PlatformNotReady + + +class CityBikesStation(Entity): + """CityBikes API Sensor.""" + + def __init__(self, network, station_id, entity_id): + """Initialize the sensor.""" + self._network = network + self._station_id = station_id + self._station_data = {} + self.entity_id = entity_id + + @property + def state(self): + """Return the state of the sensor.""" + return self._station_data.get(ATTR_FREE_BIKES) + + @property + def name(self): + """Return the name of the sensor.""" + return self._station_data.get(ATTR_NAME) + + async def async_update(self): + """Update station state.""" + for station in self._network.stations: + if station[ATTR_ID] == self._station_id: + self._station_data = station + break + + @property + def device_state_attributes(self): + """Return the state attributes.""" + if self._station_data: + return { + ATTR_ATTRIBUTION: CITYBIKES_ATTRIBUTION, + ATTR_UID: self._station_data.get(ATTR_EXTRA, {}).get(ATTR_UID), + ATTR_LATITUDE: self._station_data[ATTR_LATITUDE], + ATTR_LONGITUDE: self._station_data[ATTR_LONGITUDE], + ATTR_EMPTY_SLOTS: self._station_data[ATTR_EMPTY_SLOTS], + ATTR_TIMESTAMP: self._station_data[ATTR_TIMESTAMP], + } + return {ATTR_ATTRIBUTION: CITYBIKES_ATTRIBUTION} + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return "bikes" + + @property + def icon(self): + """Return the icon.""" + return "mdi:bike" diff --git a/homeassistant/components/clementine/__init__.py b/homeassistant/components/clementine/__init__.py new file mode 100644 index 000000000..668ba9373 --- /dev/null +++ b/homeassistant/components/clementine/__init__.py @@ -0,0 +1 @@ +"""The clementine component.""" diff --git a/homeassistant/components/clementine/manifest.json b/homeassistant/components/clementine/manifest.json new file mode 100644 index 000000000..35368dd6c --- /dev/null +++ b/homeassistant/components/clementine/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "clementine", + "name": "Clementine", + "documentation": "https://www.home-assistant.io/integrations/clementine", + "requirements": [ + "python-clementine-remote==1.0.1" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/clementine/media_player.py b/homeassistant/components/clementine/media_player.py new file mode 100644 index 000000000..9e05b8313 --- /dev/null +++ b/homeassistant/components/clementine/media_player.py @@ -0,0 +1,234 @@ +"""Support for Clementine Music Player as media player.""" +from datetime import timedelta +import logging +import time + +from clementineremote import ClementineRemote +import voluptuous as vol + +from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerDevice +from homeassistant.components.media_player.const import ( + MEDIA_TYPE_MUSIC, + SUPPORT_NEXT_TRACK, + SUPPORT_PAUSE, + SUPPORT_PLAY, + SUPPORT_PREVIOUS_TRACK, + SUPPORT_SELECT_SOURCE, + SUPPORT_VOLUME_SET, + SUPPORT_VOLUME_STEP, +) +from homeassistant.const import ( + CONF_ACCESS_TOKEN, + CONF_HOST, + CONF_NAME, + CONF_PORT, + STATE_OFF, + STATE_PAUSED, + STATE_PLAYING, +) +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = "Clementine Remote" +DEFAULT_PORT = 5500 + +SCAN_INTERVAL = timedelta(seconds=5) + +SUPPORT_CLEMENTINE = ( + SUPPORT_PAUSE + | SUPPORT_VOLUME_STEP + | SUPPORT_PREVIOUS_TRACK + | SUPPORT_VOLUME_SET + | SUPPORT_NEXT_TRACK + | SUPPORT_SELECT_SOURCE + | SUPPORT_PLAY +) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_HOST): cv.string, + 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, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Clementine platform.""" + + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) + token = config.get(CONF_ACCESS_TOKEN) + + client = ClementineRemote(host, port, token, reconnect=True) + + add_entities([ClementineDevice(client, config[CONF_NAME])]) + + +class ClementineDevice(MediaPlayerDevice): + """Representation of Clementine Player.""" + + def __init__(self, client, name): + """Initialize the Clementine device.""" + self._client = client + self._name = name + self._muted = False + self._volume = 0.0 + self._track_id = 0 + self._last_track_id = 0 + self._track_name = "" + self._track_artist = "" + self._track_album_name = "" + self._state = None + + def update(self): + """Retrieve the latest data from the Clementine Player.""" + try: + client = self._client + + if client.state == "Playing": + self._state = STATE_PLAYING + elif client.state == "Paused": + self._state = STATE_PAUSED + elif client.state == "Disconnected": + self._state = STATE_OFF + else: + self._state = STATE_PAUSED + + if client.last_update and (time.time() - client.last_update > 40): + self._state = STATE_OFF + + self._volume = float(client.volume) if client.volume else 0.0 + + if client.current_track: + self._track_id = client.current_track["track_id"] + self._track_name = client.current_track["title"] + self._track_artist = client.current_track["track_artist"] + self._track_album_name = client.current_track["track_album"] + + except Exception: + self._state = STATE_OFF + raise + + @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 volume_level(self): + """Volume level of the media player (0..1).""" + return self._volume / 100.0 + + @property + def source(self): + """Return current source name.""" + source_name = "Unknown" + client = self._client + if client.active_playlist_id in client.playlists: + source_name = client.playlists[client.active_playlist_id]["name"] + return source_name + + @property + def source_list(self): + """List of available input sources.""" + source_names = [s["name"] for s in self._client.playlists.values()] + return source_names + + def select_source(self, source): + """Select input source.""" + client = self._client + sources = [s for s in client.playlists.values() if s["name"] == source] + if len(sources) == 1: + client.change_song(sources[0]["id"], 0) + + @property + def media_content_type(self): + """Content type of current playing media.""" + return MEDIA_TYPE_MUSIC + + @property + def media_title(self): + """Title of current playing media.""" + return self._track_name + + @property + def media_artist(self): + """Artist of current playing media, music track only.""" + return self._track_artist + + @property + def media_album_name(self): + """Album name of current playing media, music track only.""" + return self._track_album_name + + @property + def supported_features(self): + """Flag media player features that are supported.""" + return SUPPORT_CLEMENTINE + + @property + def media_image_hash(self): + """Hash value for media image.""" + if self._client.current_track: + return self._client.current_track["track_id"] + + return None + + async def async_get_media_image(self): + """Fetch media image of current playing image.""" + if self._client.current_track: + image = bytes(self._client.current_track["art"]) + return (image, "image/png") + + return None, None + + def volume_up(self): + """Volume up the media player.""" + newvolume = min(self._client.volume + 4, 100) + self._client.set_volume(newvolume) + + def volume_down(self): + """Volume down media player.""" + newvolume = max(self._client.volume - 4, 0) + self._client.set_volume(newvolume) + + def mute_volume(self, mute): + """Send mute command.""" + self._client.set_volume(0) + + def set_volume_level(self, volume): + """Set volume level.""" + self._client.set_volume(int(100 * volume)) + + def media_play_pause(self): + """Simulate play pause media player.""" + if self._state == STATE_PLAYING: + self.media_pause() + else: + self.media_play() + + def media_play(self): + """Send play command.""" + self._state = STATE_PLAYING + self._client.play() + + def media_pause(self): + """Send media pause command to media player.""" + self._state = STATE_PAUSED + self._client.pause() + + def media_next_track(self): + """Send next track command.""" + self._client.next() + + def media_previous_track(self): + """Send the previous track command.""" + self._client.previous() diff --git a/homeassistant/components/clickatell/__init__.py b/homeassistant/components/clickatell/__init__.py new file mode 100644 index 000000000..6c39bc749 --- /dev/null +++ b/homeassistant/components/clickatell/__init__.py @@ -0,0 +1 @@ +"""The clickatell component.""" diff --git a/homeassistant/components/clickatell/manifest.json b/homeassistant/components/clickatell/manifest.json new file mode 100644 index 000000000..a10da6e1c --- /dev/null +++ b/homeassistant/components/clickatell/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "clickatell", + "name": "Clickatell", + "documentation": "https://www.home-assistant.io/integrations/clickatell", + "requirements": [], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/clickatell/notify.py b/homeassistant/components/clickatell/notify.py new file mode 100644 index 000000000..d59a553a4 --- /dev/null +++ b/homeassistant/components/clickatell/notify.py @@ -0,0 +1,41 @@ +"""Clickatell platform for notify component.""" +import logging + +import requests +import voluptuous as vol + +from homeassistant.components.notify import PLATFORM_SCHEMA, BaseNotificationService +from homeassistant.const import CONF_API_KEY, CONF_RECIPIENT +import homeassistant.helpers.config_validation as cv + +_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/clicksend/__init__.py b/homeassistant/components/clicksend/__init__.py new file mode 100644 index 000000000..3037224b9 --- /dev/null +++ b/homeassistant/components/clicksend/__init__.py @@ -0,0 +1 @@ +"""The clicksend component.""" diff --git a/homeassistant/components/clicksend/manifest.json b/homeassistant/components/clicksend/manifest.json new file mode 100644 index 000000000..6a28b3c30 --- /dev/null +++ b/homeassistant/components/clicksend/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "clicksend", + "name": "Clicksend", + "documentation": "https://www.home-assistant.io/integrations/clicksend", + "requirements": [], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/clicksend/notify.py b/homeassistant/components/clicksend/notify.py new file mode 100644 index 000000000..42136e9a0 --- /dev/null +++ b/homeassistant/components/clicksend/notify.py @@ -0,0 +1,105 @@ +"""Clicksend platform for notify component.""" +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_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" +DEFAULT_SENDER = "hass" +TIMEOUT = 5 + +HEADERS = {CONTENT_TYPE: CONTENT_TYPE_JSON} + + +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, default=DEFAULT_SENDER): cv.string, + } + ) + ) +) + + +def get_service(hass, config, discovery_info=None): + """Get the ClickSend notification service.""" + if not _authenticate(config): + _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[CONF_USERNAME] + self.api_key = config[CONF_API_KEY] + self.recipients = config[CONF_RECIPIENT] + self.sender = config[CONF_SENDER] + + def send_message(self, message="", **kwargs): + """Send a message to a user.""" + data = {"messages": []} + for recipient in self.recipients: + data["messages"].append( + { + "source": "hass.notify", + "from": self.sender, + "to": recipient, + "body": message, + } + ) + + api_url = f"{BASE_API_URL}/sms/send" + resp = requests.post( + api_url, + data=json.dumps(data), + headers=HEADERS, + auth=(self.username, self.api_key), + timeout=TIMEOUT, + ) + if resp.status_code == 200: + return + + obj = json.loads(resp.text) + response_msg = obj.get("response_msg") + response_code = obj.get("response_code") + _LOGGER.error( + "Error %s : %s (Code %s)", resp.status_code, response_msg, response_code + ) + + +def _authenticate(config): + """Authenticate with ClickSend.""" + api_url = f"{BASE_API_URL}/account" + resp = requests.get( + api_url, + headers=HEADERS, + auth=(config[CONF_USERNAME], config[CONF_API_KEY]), + timeout=TIMEOUT, + ) + if resp.status_code != 200: + return False + return True diff --git a/homeassistant/components/clicksend_tts/__init__.py b/homeassistant/components/clicksend_tts/__init__.py new file mode 100644 index 000000000..53b593097 --- /dev/null +++ b/homeassistant/components/clicksend_tts/__init__.py @@ -0,0 +1 @@ +"""The clicksend_tts component.""" diff --git a/homeassistant/components/clicksend_tts/manifest.json b/homeassistant/components/clicksend_tts/manifest.json new file mode 100644 index 000000000..8aa3eacf4 --- /dev/null +++ b/homeassistant/components/clicksend_tts/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "clicksend_tts", + "name": "Clicksend tts", + "documentation": "https://www.home-assistant.io/integrations/clicksend_tts", + "requirements": [], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/clicksend_tts/notify.py b/homeassistant/components/clicksend_tts/notify.py new file mode 100644 index 000000000..400e72a7d --- /dev/null +++ b/homeassistant/components/clicksend_tts/notify.py @@ -0,0 +1,113 @@ +"""clicksend_tts platform for notify component.""" +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_RECIPIENT, + 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 = {CONTENT_TYPE: CONTENT_TYPE_JSON} + +CONF_LANGUAGE = "language" +CONF_VOICE = "voice" +CONF_CALLER = "caller" + +DEFAULT_LANGUAGE = "en-us" +DEFAULT_VOICE = "female" +TIMEOUT = 5 + +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, + vol.Optional(CONF_CALLER): cv.string, + } +) + + +def get_service(hass, config, discovery_info=None): + """Get the ClickSend notification service.""" + if not _authenticate(config): + _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) + self.caller = config.get(CONF_CALLER) + if self.caller is None: + self.caller = self.recipient + + def send_message(self, message="", **kwargs): + """Send a voice call to a user.""" + data = { + "messages": [ + { + "source": "hass.notify", + "from": self.caller, + "to": self.recipient, + "body": message, + "lang": self.language, + "voice": self.voice, + } + ] + } + api_url = f"{BASE_API_URL}/voice/send" + resp = requests.post( + api_url, + data=json.dumps(data), + headers=HEADERS, + auth=(self.username, self.api_key), + timeout=TIMEOUT, + ) + + if resp.status_code == 200: + return + obj = json.loads(resp.text) + response_msg = obj["response_msg"] + response_code = obj["response_code"] + _LOGGER.error( + "Error %s : %s (Code %s)", resp.status_code, response_msg, response_code + ) + + +def _authenticate(config): + """Authenticate with ClickSend.""" + api_url = f"{BASE_API_URL}/account" + resp = requests.get( + api_url, + headers=HEADERS, + auth=(config.get(CONF_USERNAME), config.get(CONF_API_KEY)), + timeout=TIMEOUT, + ) + + if resp.status_code != 200: + return False + + return True diff --git a/homeassistant/components/climate/.translations/bg.json b/homeassistant/components/climate/.translations/bg.json new file mode 100644 index 000000000..ac1b05b09 --- /dev/null +++ b/homeassistant/components/climate/.translations/bg.json @@ -0,0 +1,17 @@ +{ + "device_automation": { + "action_type": { + "set_hvac_mode": "\u041f\u0440\u043e\u043c\u044f\u043d\u0430 \u043d\u0430 \u0440\u0435\u0436\u0438\u043c \u043d\u0430 \u041e\u0412\u041a \u043d\u0430 {entity_name}", + "set_preset_mode": "\u041f\u0440\u043e\u043c\u0435\u043d\u0438 \u043f\u0440\u0435\u0434\u0432\u0430\u0440\u0438\u0442\u0435\u043b\u043d\u043e \u0437\u0430\u0434\u0430\u0434\u0435\u043d \u0440\u0435\u0436\u0438\u043c \u043d\u0430 {entity_name}" + }, + "condition_type": { + "is_hvac_mode": "{entity_name} \u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d \u043d\u0430 \u0441\u043f\u0435\u0446\u0438\u0444\u0438\u0447\u0435\u043d \u041e\u0412\u041a \u0440\u0435\u0436\u0438\u043c", + "is_preset_mode": "{entity_name} \u0435 \u0432 \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d \u043f\u0440\u0435\u0434\u0432\u0430\u0440\u0438\u0442\u0435\u043b\u043d\u043e \u0437\u0430\u0434\u0430\u0434\u0435\u043d \u0440\u0435\u0436\u0438\u043c" + }, + "trigger_type": { + "current_humidity_changed": "{entity_name} \u0438\u0437\u043c\u0435\u0440\u0435\u043d\u0430\u0442\u0430 \u0432\u043b\u0430\u0436\u043d\u043e\u0441\u0442 \u0441\u0435 \u043f\u0440\u043e\u043c\u0435\u043d\u0438", + "current_temperature_changed": "{entity_name} \u0438\u0437\u043c\u0435\u0440\u0435\u043d\u0430\u0442\u0430 \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u0430 \u0441\u0435 \u043f\u0440\u043e\u043c\u0435\u043d\u0438", + "hvac_mode_changed": "{entity_name} \u0420\u0435\u0436\u0438\u043c \u043d\u0430 \u041e\u0412\u041a \u0441\u0435 \u043f\u0440\u043e\u043c\u0435\u043d\u0438" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/climate/.translations/ca.json b/homeassistant/components/climate/.translations/ca.json new file mode 100644 index 000000000..bde91c26b --- /dev/null +++ b/homeassistant/components/climate/.translations/ca.json @@ -0,0 +1,17 @@ +{ + "device_automation": { + "action_type": { + "set_hvac_mode": "Canvia el mode HVAC de {entity_name}", + "set_preset_mode": "Canvia la configuraci\u00f3 preestablerta de {entity_name}" + }, + "condition_type": { + "is_hvac_mode": "{entity_name} est\u00e0 configurat/ada en un mode HVAC espec\u00edfic", + "is_preset_mode": "{entity_name} est\u00e0 configurat/ada en un mode preestablert espec\u00edfic" + }, + "trigger_type": { + "current_humidity_changed": "Ha canviat la humitat mesurada per {entity_name}", + "current_temperature_changed": "Ha canviat la temperatura mesurada per {entity_name}", + "hvac_mode_changed": "El mode HVAC de {entity_name} ha canviat" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/climate/.translations/de.json b/homeassistant/components/climate/.translations/de.json new file mode 100644 index 000000000..75ffe328f --- /dev/null +++ b/homeassistant/components/climate/.translations/de.json @@ -0,0 +1,8 @@ +{ + "device_automation": { + "trigger_type": { + "current_humidity_changed": "Gemessene Luftfeuchtigkeit von {entity_name} ge\u00e4ndert", + "current_temperature_changed": "Gemessene Temperatur von {entity_name} ge\u00e4ndert" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/climate/.translations/en.json b/homeassistant/components/climate/.translations/en.json new file mode 100644 index 000000000..2a56426e9 --- /dev/null +++ b/homeassistant/components/climate/.translations/en.json @@ -0,0 +1,17 @@ +{ + "device_automation": { + "action_type": { + "set_hvac_mode": "Change HVAC mode on {entity_name}", + "set_preset_mode": "Change preset on {entity_name}" + }, + "condition_type": { + "is_hvac_mode": "{entity_name} is set to a specific HVAC mode", + "is_preset_mode": "{entity_name} is set to a specific preset mode" + }, + "trigger_type": { + "current_humidity_changed": "{entity_name} measured humidity changed", + "current_temperature_changed": "{entity_name} measured temperature changed", + "hvac_mode_changed": "{entity_name} HVAC mode changed" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/climate/.translations/es.json b/homeassistant/components/climate/.translations/es.json new file mode 100644 index 000000000..e873427e6 --- /dev/null +++ b/homeassistant/components/climate/.translations/es.json @@ -0,0 +1,17 @@ +{ + "device_automation": { + "action_type": { + "set_hvac_mode": "Cambiar el modo HVAC de {entity_name}.", + "set_preset_mode": "Cambiar la configuraci\u00f3n prefijada de {entity_name}" + }, + "condition_type": { + "is_hvac_mode": "{entity_name} est\u00e1 configurado en un modo HVAC espec\u00edfico", + "is_preset_mode": "{entity_name} se establece en un modo predeterminado espec\u00edfico" + }, + "trigger_type": { + "current_humidity_changed": "{entity_name} humedad medida cambi\u00f3", + "current_temperature_changed": "{entity_name} temperatura medida cambi\u00f3", + "hvac_mode_changed": "{entity_name} Modo HVAC cambiado" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/climate/.translations/fr.json b/homeassistant/components/climate/.translations/fr.json new file mode 100644 index 000000000..0358a60f1 --- /dev/null +++ b/homeassistant/components/climate/.translations/fr.json @@ -0,0 +1,17 @@ +{ + "device_automation": { + "action_type": { + "set_hvac_mode": "Changer le mode HVAC sur {entity_name}.", + "set_preset_mode": "Changer les pr\u00e9r\u00e9glages de {entity_name}" + }, + "condition_type": { + "is_hvac_mode": "{entity_name} est d\u00e9fini sur un mode HVAC sp\u00e9cifique", + "is_preset_mode": "{entity_name} est d\u00e9fini sur un mode pr\u00e9d\u00e9fini sp\u00e9cifique" + }, + "trigger_type": { + "current_humidity_changed": "Changement d'humidit\u00e9 mesur\u00e9e pour {entity_name}", + "current_temperature_changed": "Changement de temp\u00e9rature mesur\u00e9e pour {entity_name}", + "hvac_mode_changed": "Mode HVAC chang\u00e9 pour {entity_name}" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/climate/.translations/it.json b/homeassistant/components/climate/.translations/it.json new file mode 100644 index 000000000..25a09b7d6 --- /dev/null +++ b/homeassistant/components/climate/.translations/it.json @@ -0,0 +1,17 @@ +{ + "device_automation": { + "action_type": { + "set_hvac_mode": "Cambia modalit\u00e0 HVAC su {entity_name}", + "set_preset_mode": "Modifica preimpostazione su {entity_name}" + }, + "condition_type": { + "is_hvac_mode": "{entity_name} \u00e8 impostato su una modalit\u00e0 HVAC specifica", + "is_preset_mode": "{entity_name} \u00e8 impostato su una modalit\u00e0 preimpostata specifica" + }, + "trigger_type": { + "current_humidity_changed": "{entity_name} umidit\u00e0 misurata modificata", + "current_temperature_changed": "{entity_name} temperatura misurata cambiata", + "hvac_mode_changed": "{entity_name} modalit\u00e0 HVAC modificata" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/climate/.translations/lb.json b/homeassistant/components/climate/.translations/lb.json new file mode 100644 index 000000000..cfb49f29f --- /dev/null +++ b/homeassistant/components/climate/.translations/lb.json @@ -0,0 +1,17 @@ +{ + "device_automation": { + "action_type": { + "set_hvac_mode": "HVAC Modus \u00e4nnere fir {entity_name}", + "set_preset_mode": "Preset \u00e4nnere fir {entity_name}" + }, + "condition_type": { + "is_hvac_mode": "\n{entity_name} ass op e spezifesche HVAC Modus gesat", + "is_preset_mode": "{entity_name} ass op e spezifesche preset Modus gesat" + }, + "trigger_type": { + "current_humidity_changed": "{entity_name} gemoosse Fiichtegkeet ge\u00e4nnert", + "current_temperature_changed": "{entity_name} gemoossen Temperatur ge\u00e4nnert", + "hvac_mode_changed": "{entity_name} HVAC Modus ge\u00e4nnert" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/climate/.translations/no.json b/homeassistant/components/climate/.translations/no.json new file mode 100644 index 000000000..bc6e97b9a --- /dev/null +++ b/homeassistant/components/climate/.translations/no.json @@ -0,0 +1,17 @@ +{ + "device_automation": { + "action_type": { + "set_hvac_mode": "Endre HVAC-modus p\u00e5 {entity_name}", + "set_preset_mode": "Endre forh\u00e5ndsinnstilling p\u00e5 {entity_name}" + }, + "condition_type": { + "is_hvac_mode": "{entity_name} er satt til en spesifikk HVAC-modus", + "is_preset_mode": "{entity_name} er satt til en spesifikk forh\u00e5ndsinnstilt modus" + }, + "trigger_type": { + "current_humidity_changed": "{entity_name} m\u00e5lt fuktighet er endret", + "current_temperature_changed": "{entity_name} m\u00e5lt temperatur er endret", + "hvac_mode_changed": "{entity_name} HVAC-modus er endret" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/climate/.translations/pl.json b/homeassistant/components/climate/.translations/pl.json new file mode 100644 index 000000000..f2a09eee3 --- /dev/null +++ b/homeassistant/components/climate/.translations/pl.json @@ -0,0 +1,17 @@ +{ + "device_automation": { + "action_type": { + "set_hvac_mode": "zmie\u0144 tryb HVAC na {entity_name}", + "set_preset_mode": "zmie\u0144 ustawienia dla {entity_name}" + }, + "condition_type": { + "is_hvac_mode": "na {entity_name} jest ustawiony okre\u015blony tryb HVAC", + "is_preset_mode": "na {entity_name} jest okre\u015blone ustawienie" + }, + "trigger_type": { + "current_humidity_changed": "zmieni si\u0119 zmierzona wilgotno\u015b\u0107 {entity_name}", + "current_temperature_changed": "zmieni si\u0119 zmierzona temperatura {entity_name}", + "hvac_mode_changed": "zmieni si\u0119 tryb HVAC {entity_name}" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/climate/.translations/ru.json b/homeassistant/components/climate/.translations/ru.json new file mode 100644 index 000000000..6a9c52be2 --- /dev/null +++ b/homeassistant/components/climate/.translations/ru.json @@ -0,0 +1,17 @@ +{ + "device_automation": { + "action_type": { + "set_hvac_mode": "\u0421\u043c\u0435\u043d\u0438\u0442\u044c \u0440\u0435\u0436\u0438\u043c \u0440\u0430\u0431\u043e\u0442\u044b \u043e\u0431\u044a\u0435\u043a\u0442\u0430 \"{entity_name}\"", + "set_preset_mode": "\u0421\u043c\u0435\u043d\u0438\u0442\u044c \u043d\u0430\u0431\u043e\u0440 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a \u043e\u0431\u044a\u0435\u043a\u0442\u0430 \"{entity_name}\"" + }, + "condition_type": { + "is_hvac_mode": "{entity_name} \u043d\u0430\u0445\u043e\u0434\u0438\u0442\u0441\u044f \u0432 \u0437\u0430\u0434\u0430\u043d\u043d\u043e\u043c \u0440\u0435\u0436\u0438\u043c\u0435 \u0440\u0430\u0431\u043e\u0442\u044b", + "is_preset_mode": "{entity_name} \u0432 \u0440\u0435\u0436\u0438\u043c\u0435 \u043f\u0440\u0435\u0434\u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u0435\u043d\u043d\u043e\u0433\u043e \u043d\u0430\u0431\u043e\u0440\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a" + }, + "trigger_type": { + "current_humidity_changed": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0438\u0437\u043c\u0435\u0440\u0435\u043d\u043d\u043e\u0439 \u0432\u043b\u0430\u0436\u043d\u043e\u0441\u0442\u0438", + "current_temperature_changed": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0438\u0437\u043c\u0435\u0440\u0435\u043d\u043d\u043e\u0439 \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u044b", + "hvac_mode_changed": "{entity_name} \u043c\u0435\u043d\u044f\u0435\u0442 \u0440\u0435\u0436\u0438\u043c \u0440\u0430\u0431\u043e\u0442\u044b" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/climate/.translations/sl.json b/homeassistant/components/climate/.translations/sl.json new file mode 100644 index 000000000..ecaf24fed --- /dev/null +++ b/homeassistant/components/climate/.translations/sl.json @@ -0,0 +1,17 @@ +{ + "device_automation": { + "action_type": { + "set_hvac_mode": "Spremeni na\u010din HVAC na {entity_name}", + "set_preset_mode": "Spremenite prednastavitev na {entity_name}" + }, + "condition_type": { + "is_hvac_mode": "{entity_name} je nastavljen na dolo\u010den na\u010din HVAC", + "is_preset_mode": "{entity_name} je nastavljen na dolo\u010den prednastavljeni na\u010din" + }, + "trigger_type": { + "current_humidity_changed": "{entity_name} spremenjena izmerjena vla\u017enost", + "current_temperature_changed": "{entity_name} izmerjena temperaturna sprememba", + "hvac_mode_changed": "{entity_name} HVAC na\u010din spremenjen" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/climate/.translations/zh-Hant.json b/homeassistant/components/climate/.translations/zh-Hant.json new file mode 100644 index 000000000..17e6c9550 --- /dev/null +++ b/homeassistant/components/climate/.translations/zh-Hant.json @@ -0,0 +1,17 @@ +{ + "device_automation": { + "action_type": { + "set_hvac_mode": "\u8b8a\u66f4 {entity_name} HVAC \u6a21\u5f0f", + "set_preset_mode": "\u8b8a\u66f4 {entity_name} \u8a2d\u5b9a\u6a21\u5f0f" + }, + "condition_type": { + "is_hvac_mode": "{entity_name} \u8a2d\u5b9a\u70ba\u6307\u5b9a HVAC \u6a21\u5f0f", + "is_preset_mode": "{entity_name} \u8a2d\u5b9a\u70ba\u6307\u5b9a\u8a2d\u5b9a\u6a21\u5f0f" + }, + "trigger_type": { + "current_humidity_changed": "{entity_name} \u91cf\u6e2c\u6fd5\u5ea6\u5df2\u8b8a\u66f4", + "current_temperature_changed": "{entity_name} \u91cf\u6e2c\u6eab\u5ea6\u5df2\u8b8a\u66f4", + "hvac_mode_changed": "{entity_name} HVAC \u6a21\u5f0f\u5df2\u8b8a\u66f4" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index a3273f67c..e2bf555cc 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -1,304 +1,157 @@ -""" -Provides functionality to interact with climate devices. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/climate/ -""" +"""Provides functionality to interact with climate devices.""" +from abc import abstractmethod from datetime import timedelta -import logging import functools as ft +import logging +from typing import Any, Dict, List, Optional import voluptuous as vol -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, SERVICE_TURN_ON, SERVICE_TURN_OFF, - STATE_ON, STATE_OFF, STATE_UNKNOWN, TEMP_CELSIUS, PRECISION_WHOLE, - PRECISION_TENTHS, ) + ATTR_TEMPERATURE, + PRECISION_TENTHS, + PRECISION_WHOLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + TEMP_CELSIUS, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.config_validation import ( # noqa: F401 + PLATFORM_SCHEMA, + PLATFORM_SCHEMA_BASE, + make_entity_service_schema, +) +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.temperature import display_temp as show_temp +from homeassistant.helpers.typing import ConfigType, HomeAssistantType, ServiceDataType +from homeassistant.util.temperature import convert as convert_temperature + +from .const import ( + ATTR_AUX_HEAT, + ATTR_CURRENT_HUMIDITY, + ATTR_CURRENT_TEMPERATURE, + ATTR_FAN_MODE, + ATTR_FAN_MODES, + ATTR_HUMIDITY, + ATTR_HVAC_ACTION, + ATTR_HVAC_MODE, + ATTR_HVAC_MODES, + ATTR_MAX_HUMIDITY, + ATTR_MAX_TEMP, + ATTR_MIN_HUMIDITY, + ATTR_MIN_TEMP, + ATTR_PRESET_MODE, + ATTR_PRESET_MODES, + ATTR_SWING_MODE, + ATTR_SWING_MODES, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + ATTR_TARGET_TEMP_STEP, + DOMAIN, + HVAC_MODE_COOL, + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_OFF, + HVAC_MODES, + SERVICE_SET_AUX_HEAT, + SERVICE_SET_FAN_MODE, + SERVICE_SET_HUMIDITY, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_PRESET_MODE, + SERVICE_SET_SWING_MODE, + SERVICE_SET_TEMPERATURE, + SUPPORT_AUX_HEAT, + SUPPORT_FAN_MODE, + SUPPORT_PRESET_MODE, + SUPPORT_SWING_MODE, + SUPPORT_TARGET_HUMIDITY, + SUPPORT_TARGET_TEMPERATURE, + SUPPORT_TARGET_TEMPERATURE_RANGE, +) DEFAULT_MIN_TEMP = 7 DEFAULT_MAX_TEMP = 35 -DEFAULT_MIN_HUMITIDY = 30 +DEFAULT_MIN_HUMIDITY = 30 DEFAULT_MAX_HUMIDITY = 99 -DOMAIN = 'climate' - -ENTITY_ID_FORMAT = DOMAIN + '.{}' +ENTITY_ID_FORMAT = DOMAIN + ".{}" SCAN_INTERVAL = timedelta(seconds=60) -SERVICE_SET_AWAY_MODE = 'set_away_mode' -SERVICE_SET_AUX_HEAT = 'set_aux_heat' -SERVICE_SET_TEMPERATURE = 'set_temperature' -SERVICE_SET_FAN_MODE = 'set_fan_mode' -SERVICE_SET_HOLD_MODE = 'set_hold_mode' -SERVICE_SET_OPERATION_MODE = 'set_operation_mode' -SERVICE_SET_SWING_MODE = 'set_swing_mode' -SERVICE_SET_HUMIDITY = 'set_humidity' - -STATE_HEAT = 'heat' -STATE_COOL = 'cool' -STATE_IDLE = 'idle' -STATE_AUTO = 'auto' -STATE_MANUAL = 'manual' -STATE_DRY = 'dry' -STATE_FAN_ONLY = 'fan_only' -STATE_ECO = 'eco' -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' -ATTR_MIN_TEMP = 'min_temp' -ATTR_TARGET_TEMP_HIGH = 'target_temp_high' -ATTR_TARGET_TEMP_LOW = 'target_temp_low' -ATTR_TARGET_TEMP_STEP = 'target_temp_step' -ATTR_AWAY_MODE = 'away_mode' -ATTR_AUX_HEAT = 'aux_heat' -ATTR_FAN_MODE = 'fan_mode' -ATTR_FAN_LIST = 'fan_list' -ATTR_CURRENT_HUMIDITY = 'current_humidity' -ATTR_HUMIDITY = 'humidity' -ATTR_MAX_HUMIDITY = 'max_humidity' -ATTR_MIN_HUMIDITY = 'min_humidity' -ATTR_HOLD_MODE = 'hold_mode' -ATTR_OPERATION_MODE = 'operation_mode' -ATTR_OPERATION_LIST = 'operation_list' -ATTR_SWING_MODE = 'swing_mode' -ATTR_SWING_LIST = 'swing_list' - -CONVERTIBLE_ATTRIBUTE = [ - ATTR_TEMPERATURE, - ATTR_TARGET_TEMP_LOW, - ATTR_TARGET_TEMP_HIGH, -] +CONVERTIBLE_ATTRIBUTE = [ATTR_TEMPERATURE, ATTR_TARGET_TEMP_LOW, ATTR_TARGET_TEMP_HIGH] _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, -}) -SET_AUX_HEAT_SCHEMA = vol.Schema({ - vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, - vol.Required(ATTR_AUX_HEAT): cv.boolean, -}) -SET_TEMPERATURE_SCHEMA = vol.Schema(vol.All( +SET_TEMPERATURE_SCHEMA = vol.All( cv.has_at_least_one_key( - ATTR_TEMPERATURE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW), - { - vol.Exclusive(ATTR_TEMPERATURE, 'temperature'): vol.Coerce(float), - vol.Inclusive(ATTR_TARGET_TEMP_HIGH, 'temperature'): vol.Coerce(float), - vol.Inclusive(ATTR_TARGET_TEMP_LOW, 'temperature'): vol.Coerce(float), - vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, - vol.Optional(ATTR_OPERATION_MODE): cv.string, - } -)) -SET_FAN_MODE_SCHEMA = vol.Schema({ - vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, - vol.Required(ATTR_FAN_MODE): cv.string, -}) -SET_HOLD_MODE_SCHEMA = vol.Schema({ - vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, - vol.Required(ATTR_HOLD_MODE): cv.string, -}) -SET_OPERATION_MODE_SCHEMA = vol.Schema({ - vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, - vol.Required(ATTR_OPERATION_MODE): cv.string, -}) -SET_HUMIDITY_SCHEMA = vol.Schema({ - vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, - vol.Required(ATTR_HUMIDITY): vol.Coerce(float), -}) -SET_SWING_MODE_SCHEMA = vol.Schema({ - vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, - vol.Required(ATTR_SWING_MODE): cv.string, -}) + ATTR_TEMPERATURE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW + ), + make_entity_service_schema( + { + vol.Exclusive(ATTR_TEMPERATURE, "temperature"): vol.Coerce(float), + vol.Inclusive(ATTR_TARGET_TEMP_HIGH, "temperature"): vol.Coerce(float), + vol.Inclusive(ATTR_TARGET_TEMP_LOW, "temperature"): vol.Coerce(float), + vol.Optional(ATTR_HVAC_MODE): vol.In(HVAC_MODES), + } + ), +) -@bind_hass -def set_away_mode(hass, away_mode, entity_id=None): - """Turn all or specified climate devices away mode on.""" - data = { - ATTR_AWAY_MODE: away_mode - } - - if entity_id: - data[ATTR_ENTITY_ID] = entity_id - - hass.services.call(DOMAIN, SERVICE_SET_AWAY_MODE, data) - - -@bind_hass -def set_hold_mode(hass, hold_mode, entity_id=None): - """Set new hold mode.""" - data = { - ATTR_HOLD_MODE: hold_mode - } - - if entity_id: - data[ATTR_ENTITY_ID] = entity_id - - hass.services.call(DOMAIN, SERVICE_SET_HOLD_MODE, data) - - -@bind_hass -def set_aux_heat(hass, aux_heat, entity_id=None): - """Turn all or specified climate devices auxiliary heater on.""" - data = { - ATTR_AUX_HEAT: aux_heat - } - - if entity_id: - data[ATTR_ENTITY_ID] = entity_id - - hass.services.call(DOMAIN, SERVICE_SET_AUX_HEAT, data) - - -@bind_hass -def set_temperature(hass, temperature=None, entity_id=None, - target_temp_high=None, target_temp_low=None, - operation_mode=None): - """Set new target temperature.""" - kwargs = { - key: value for key, value in [ - (ATTR_TEMPERATURE, temperature), - (ATTR_TARGET_TEMP_HIGH, target_temp_high), - (ATTR_TARGET_TEMP_LOW, target_temp_low), - (ATTR_ENTITY_ID, entity_id), - (ATTR_OPERATION_MODE, operation_mode) - ] if value is not None - } - _LOGGER.debug("set_temperature start data=%s", kwargs) - hass.services.call(DOMAIN, SERVICE_SET_TEMPERATURE, kwargs) - - -@bind_hass -def set_humidity(hass, humidity, entity_id=None): - """Set new target humidity.""" - data = {ATTR_HUMIDITY: humidity} - - if entity_id is not None: - data[ATTR_ENTITY_ID] = entity_id - - hass.services.call(DOMAIN, SERVICE_SET_HUMIDITY, data) - - -@bind_hass -def set_fan_mode(hass, fan, entity_id=None): - """Set all or specified climate devices fan mode on.""" - data = {ATTR_FAN_MODE: fan} - - if entity_id: - data[ATTR_ENTITY_ID] = entity_id - - hass.services.call(DOMAIN, SERVICE_SET_FAN_MODE, data) - - -@bind_hass -def set_operation_mode(hass, operation_mode, entity_id=None): - """Set new target operation mode.""" - data = {ATTR_OPERATION_MODE: operation_mode} - - if entity_id is not None: - data[ATTR_ENTITY_ID] = entity_id - - hass.services.call(DOMAIN, SERVICE_SET_OPERATION_MODE, data) - - -@bind_hass -def set_swing_mode(hass, swing_mode, entity_id=None): - """Set new target swing mode.""" - data = {ATTR_SWING_MODE: swing_mode} - - if entity_id is not None: - data[ATTR_ENTITY_ID] = entity_id - - hass.services.call(DOMAIN, SERVICE_SET_SWING_MODE, data) - - -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: """Set up climate devices.""" - component = hass.data[DOMAIN] = \ - EntityComponent(_LOGGER, DOMAIN, hass, SCAN_INTERVAL) + component = hass.data[DOMAIN] = EntityComponent( + _LOGGER, DOMAIN, hass, SCAN_INTERVAL + ) await component.async_setup(config) + component.async_register_entity_service(SERVICE_TURN_ON, {}, "async_turn_on") + component.async_register_entity_service(SERVICE_TURN_OFF, {}, "async_turn_off") component.async_register_entity_service( - SERVICE_SET_AWAY_MODE, SET_AWAY_MODE_SCHEMA, - async_service_away_mode + SERVICE_SET_HVAC_MODE, + {vol.Required(ATTR_HVAC_MODE): vol.In(HVAC_MODES)}, + "async_set_hvac_mode", ) component.async_register_entity_service( - SERVICE_SET_HOLD_MODE, SET_HOLD_MODE_SCHEMA, - 'async_set_hold_mode' + SERVICE_SET_PRESET_MODE, + {vol.Required(ATTR_PRESET_MODE): cv.string}, + "async_set_preset_mode", ) component.async_register_entity_service( - SERVICE_SET_AUX_HEAT, SET_AUX_HEAT_SCHEMA, - async_service_aux_heat + SERVICE_SET_AUX_HEAT, + {vol.Required(ATTR_AUX_HEAT): cv.boolean}, + async_service_aux_heat, ) component.async_register_entity_service( - SERVICE_SET_TEMPERATURE, SET_TEMPERATURE_SCHEMA, - async_service_temperature_set + SERVICE_SET_TEMPERATURE, SET_TEMPERATURE_SCHEMA, async_service_temperature_set, ) component.async_register_entity_service( - SERVICE_SET_HUMIDITY, SET_HUMIDITY_SCHEMA, - 'async_set_humidity' + SERVICE_SET_HUMIDITY, + {vol.Required(ATTR_HUMIDITY): vol.Coerce(float)}, + "async_set_humidity", ) component.async_register_entity_service( - SERVICE_SET_FAN_MODE, SET_FAN_MODE_SCHEMA, - 'async_set_fan_mode' + SERVICE_SET_FAN_MODE, + {vol.Required(ATTR_FAN_MODE): cv.string}, + "async_set_fan_mode", ) component.async_register_entity_service( - SERVICE_SET_OPERATION_MODE, SET_OPERATION_MODE_SCHEMA, - 'async_set_operation_mode' - ) - component.async_register_entity_service( - SERVICE_SET_SWING_MODE, SET_SWING_MODE_SCHEMA, - 'async_set_swing_mode' - ) - component.async_register_entity_service( - SERVICE_TURN_OFF, ON_OFF_SERVICE_SCHEMA, - 'async_turn_off' - ) - component.async_register_entity_service( - SERVICE_TURN_ON, ON_OFF_SERVICE_SCHEMA, - 'async_turn_on' + SERVICE_SET_SWING_MODE, + {vol.Required(ATTR_SWING_MODE): cv.string}, + "async_set_swing_mode", ) return True -async def async_setup_entry(hass, entry): +async def async_setup_entry(hass: HomeAssistantType, entry): """Set up a config entry.""" return await hass.data[DOMAIN].async_setup_entry(entry) -async def async_unload_entry(hass, entry): +async def async_unload_entry(hass: HomeAssistantType, entry): """Unload a config entry.""" return await hass.data[DOMAIN].async_unload_entry(entry) @@ -307,353 +160,340 @@ class ClimateDevice(Entity): """Representation of a climate device.""" @property - def state(self): + def state(self) -> str: """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 + return self.hvac_mode @property - def precision(self): + def precision(self) -> float: """Return the precision of the system.""" if self.hass.config.units.temperature_unit == TEMP_CELSIUS: return PRECISION_TENTHS return PRECISION_WHOLE @property - def state_attributes(self): + def state_attributes(self) -> Dict[str, Any]: """Return the optional state attributes.""" + supported_features = self.supported_features data = { + ATTR_HVAC_MODES: self.hvac_modes, ATTR_CURRENT_TEMPERATURE: show_temp( - self.hass, self.current_temperature, self.temperature_unit, - self.precision), + 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), + 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), + self.hass, self.max_temp, self.temperature_unit, self.precision + ), } - supported_features = self.supported_features - if self.target_temperature_step is not None: + if self.target_temperature_step: data[ATTR_TARGET_TEMP_STEP] = self.target_temperature_step - 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: + data[ATTR_TEMPERATURE] = show_temp( + self.hass, + self.target_temperature, + self.temperature_unit, + self.precision, + ) - if supported_features & SUPPORT_TARGET_TEMPERATURE_LOW: + if supported_features & SUPPORT_TARGET_TEMPERATURE_RANGE: + data[ATTR_TARGET_TEMP_HIGH] = show_temp( + self.hass, + self.target_temperature_high, + self.temperature_unit, + self.precision, + ) data[ATTR_TARGET_TEMP_LOW] = show_temp( - self.hass, self.target_temperature_low, self.temperature_unit, - self.precision) + self.hass, + self.target_temperature_low, + self.temperature_unit, + self.precision, + ) + + if self.current_humidity is not None: + data[ATTR_CURRENT_HUMIDITY] = self.current_humidity if supported_features & SUPPORT_TARGET_HUMIDITY: data[ATTR_HUMIDITY] = self.target_humidity - data[ATTR_CURRENT_HUMIDITY] = self.current_humidity - - 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 + data[ATTR_MIN_HUMIDITY] = self.min_humidity + 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 + data[ATTR_FAN_MODE] = self.fan_mode + data[ATTR_FAN_MODES] = self.fan_modes - if supported_features & SUPPORT_OPERATION_MODE: - data[ATTR_OPERATION_MODE] = self.current_operation - if self.operation_list: - data[ATTR_OPERATION_LIST] = self.operation_list + if self.hvac_action: + data[ATTR_HVAC_ACTION] = self.hvac_action - if supported_features & SUPPORT_HOLD_MODE: - data[ATTR_HOLD_MODE] = self.current_hold_mode + if supported_features & SUPPORT_PRESET_MODE: + data[ATTR_PRESET_MODE] = self.preset_mode + data[ATTR_PRESET_MODES] = self.preset_modes 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 - - 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 + data[ATTR_SWING_MODE] = self.swing_mode + data[ATTR_SWING_MODES] = self.swing_modes 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 + data[ATTR_AUX_HEAT] = STATE_ON if self.is_aux_heat else STATE_OFF return data @property - def temperature_unit(self): + def temperature_unit(self) -> str: """Return the unit of measurement used by the platform.""" - raise NotImplementedError + raise NotImplementedError() @property - def current_humidity(self): + def current_humidity(self) -> Optional[int]: """Return the current humidity.""" return None @property - def target_humidity(self): + def target_humidity(self) -> Optional[int]: """Return the humidity we try to reach.""" return None @property - def current_operation(self): - """Return current operation ie. heat, cool, idle.""" + @abstractmethod + def hvac_mode(self) -> str: + """Return hvac operation ie. heat, cool mode. + + Need to be one of HVAC_MODE_*. + """ + + @property + @abstractmethod + def hvac_modes(self) -> List[str]: + """Return the list of available hvac operation modes. + + Need to be a subset of HVAC_MODES. + """ + + @property + def hvac_action(self) -> Optional[str]: + """Return the current running hvac operation if supported. + + Need to be one of CURRENT_HVAC_*. + """ return None @property - def operation_list(self): - """Return the list of available operation modes.""" - return None - - @property - def current_temperature(self): + def current_temperature(self) -> Optional[float]: """Return the current temperature.""" return None @property - def target_temperature(self): + def target_temperature(self) -> Optional[float]: """Return the temperature we try to reach.""" return None @property - def target_temperature_step(self): + def target_temperature_step(self) -> Optional[float]: """Return the supported step of target temperature.""" return None @property - def target_temperature_high(self): - """Return the highbound target temperature we try to reach.""" - return None + def target_temperature_high(self) -> Optional[float]: + """Return the highbound target temperature we try to reach. + + Requires SUPPORT_TARGET_TEMPERATURE_RANGE. + """ + raise NotImplementedError @property - def target_temperature_low(self): - """Return the lowbound target temperature we try to reach.""" - return None + def target_temperature_low(self) -> Optional[float]: + """Return the lowbound target temperature we try to reach. + + Requires SUPPORT_TARGET_TEMPERATURE_RANGE. + """ + raise NotImplementedError @property - def is_away_mode_on(self): - """Return true if away mode is on.""" - return None + def preset_mode(self) -> Optional[str]: + """Return the current preset mode, e.g., home, away, temp. + + Requires SUPPORT_PRESET_MODE. + """ + raise NotImplementedError @property - def current_hold_mode(self): - """Return the current hold mode, e.g., home, away, temp.""" - return None + def preset_modes(self) -> Optional[List[str]]: + """Return a list of available preset modes. + + Requires SUPPORT_PRESET_MODE. + """ + raise NotImplementedError @property - def is_on(self): - """Return true if on.""" - return None + def is_aux_heat(self) -> Optional[bool]: + """Return true if aux heater. + + Requires SUPPORT_AUX_HEAT. + """ + raise NotImplementedError @property - def is_aux_heat_on(self): - """Return true if aux heater.""" - return None + def fan_mode(self) -> Optional[str]: + """Return the fan setting. + + Requires SUPPORT_FAN_MODE. + """ + raise NotImplementedError @property - def current_fan_mode(self): - """Return the fan setting.""" - return None + def fan_modes(self) -> Optional[List[str]]: + """Return the list of available fan modes. + + Requires SUPPORT_FAN_MODE. + """ + raise NotImplementedError @property - def fan_list(self): - """Return the list of available fan modes.""" - return None + def swing_mode(self) -> Optional[str]: + """Return the swing setting. + + Requires SUPPORT_SWING_MODE. + """ + raise NotImplementedError @property - def current_swing_mode(self): - """Return the fan setting.""" - return None + def swing_modes(self) -> Optional[List[str]]: + """Return the list of available swing modes. - @property - def swing_list(self): - """Return the list of available swing modes.""" - return None + Requires SUPPORT_SWING_MODE. + """ + raise NotImplementedError - def set_temperature(self, **kwargs): + def set_temperature(self, **kwargs) -> None: """Set new target temperature.""" raise NotImplementedError() - def async_set_temperature(self, **kwargs): - """Set new target temperature. + async def async_set_temperature(self, **kwargs) -> None: + """Set new target temperature.""" + await self.hass.async_add_executor_job( + ft.partial(self.set_temperature, **kwargs) + ) - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job( - ft.partial(self.set_temperature, **kwargs)) - - def set_humidity(self, humidity): + def set_humidity(self, humidity: int) -> None: """Set new target humidity.""" raise NotImplementedError() - def async_set_humidity(self, humidity): - """Set new target humidity. + async def async_set_humidity(self, humidity: int) -> None: + """Set new target humidity.""" + await self.hass.async_add_executor_job(self.set_humidity, humidity) - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(self.set_humidity, humidity) - - def set_fan_mode(self, fan_mode): + def set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" raise NotImplementedError() - def async_set_fan_mode(self, fan_mode): - """Set new target fan mode. + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set new target fan mode.""" + await self.hass.async_add_executor_job(self.set_fan_mode, 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_mode) - - def set_operation_mode(self, operation_mode): - """Set new target operation mode.""" + def set_hvac_mode(self, hvac_mode: str) -> None: + """Set new target hvac mode.""" raise NotImplementedError() - def async_set_operation_mode(self, operation_mode): - """Set new target operation mode. + async def async_set_hvac_mode(self, hvac_mode: str) -> None: + """Set new target hvac mode.""" + await self.hass.async_add_executor_job(self.set_hvac_mode, hvac_mode) - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(self.set_operation_mode, operation_mode) - - def set_swing_mode(self, swing_mode): + def set_swing_mode(self, swing_mode: str) -> None: """Set new target swing operation.""" raise NotImplementedError() - def async_set_swing_mode(self, swing_mode): - """Set new target swing operation. + async def async_set_swing_mode(self, swing_mode: str) -> None: + """Set new target swing operation.""" + await self.hass.async_add_executor_job(self.set_swing_mode, swing_mode) - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(self.set_swing_mode, swing_mode) - - def turn_away_mode_on(self): - """Turn away mode on.""" + def set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" raise NotImplementedError() - def async_turn_away_mode_on(self): - """Turn away mode on. + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + await self.hass.async_add_executor_job(self.set_preset_mode, preset_mode) - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(self.turn_away_mode_on) - - def turn_away_mode_off(self): - """Turn away mode off.""" - raise NotImplementedError() - - def async_turn_away_mode_off(self): - """Turn away mode off. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(self.turn_away_mode_off) - - def set_hold_mode(self, hold_mode): - """Set new target hold mode.""" - raise NotImplementedError() - - def async_set_hold_mode(self, hold_mode): - """Set new target hold mode. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(self.set_hold_mode, hold_mode) - - def turn_aux_heat_on(self): + def turn_aux_heat_on(self) -> None: """Turn auxiliary heater on.""" raise NotImplementedError() - def async_turn_aux_heat_on(self): - """Turn auxiliary heater on. + async def async_turn_aux_heat_on(self) -> None: + """Turn auxiliary heater on.""" + await self.hass.async_add_executor_job(self.turn_aux_heat_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): + def turn_aux_heat_off(self) -> None: """Turn auxiliary heater off.""" raise NotImplementedError() - def async_turn_aux_heat_off(self): - """Turn auxiliary heater off. + async def async_turn_aux_heat_off(self) -> None: + """Turn auxiliary heater off.""" + await self.hass.async_add_executor_job(self.turn_aux_heat_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) + async def async_turn_on(self) -> None: + """Turn the entity on.""" + if hasattr(self, "turn_on"): + # pylint: disable=no-member + await self.hass.async_add_executor_job(self.turn_on) + return - def turn_on(self): - """Turn device on.""" - raise NotImplementedError() + # Fake turn on + for mode in (HVAC_MODE_HEAT_COOL, HVAC_MODE_HEAT, HVAC_MODE_COOL): + if mode not in self.hvac_modes: + continue + await self.async_set_hvac_mode(mode) + break - def async_turn_on(self): - """Turn device on. + async def async_turn_off(self) -> None: + """Turn the entity off.""" + if hasattr(self, "turn_off"): + # pylint: disable=no-member + await self.hass.async_add_executor_job(self.turn_off) + return - 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) + # Fake turn off + if HVAC_MODE_OFF in self.hvac_modes: + await self.async_set_hvac_mode(HVAC_MODE_OFF) @property - def supported_features(self): + def supported_features(self) -> int: """Return the list of supported features.""" raise NotImplementedError() @property - def min_temp(self): + def min_temp(self) -> float: """Return the minimum temperature.""" - return convert_temperature(DEFAULT_MIN_TEMP, TEMP_CELSIUS, - self.temperature_unit) + return convert_temperature( + DEFAULT_MIN_TEMP, TEMP_CELSIUS, self.temperature_unit + ) @property - def max_temp(self): + def max_temp(self) -> float: """Return the maximum temperature.""" - return convert_temperature(DEFAULT_MAX_TEMP, TEMP_CELSIUS, - self.temperature_unit) + return convert_temperature( + DEFAULT_MAX_TEMP, TEMP_CELSIUS, self.temperature_unit + ) @property - def min_humidity(self): + def min_humidity(self) -> int: """Return the minimum humidity.""" - return DEFAULT_MIN_HUMITIDY + return DEFAULT_MIN_HUMIDITY @property - def max_humidity(self): + def max_humidity(self) -> int: """Return the maximum humidity.""" return DEFAULT_MAX_HUMIDITY -async def async_service_away_mode(entity, service): - """Handle away mode service.""" - if service.data[ATTR_AWAY_MODE]: - await entity.async_turn_away_mode_on() - else: - await entity.async_turn_away_mode_off() - - -async def async_service_aux_heat(entity, service): +async def async_service_aux_heat( + entity: ClimateDevice, service: ServiceDataType +) -> None: """Handle aux heat service.""" if service.data[ATTR_AUX_HEAT]: await entity.async_turn_aux_heat_on() @@ -661,7 +501,9 @@ async def async_service_aux_heat(entity, service): await entity.async_turn_aux_heat_off() -async def async_service_temperature_set(entity, service): +async def async_service_temperature_set( + entity: ClimateDevice, service: ServiceDataType +) -> None: """Handle set temperature service.""" hass = entity.hass kwargs = {} @@ -669,9 +511,7 @@ async def async_service_temperature_set(entity, service): for value, temp in service.data.items(): if value in CONVERTIBLE_ATTRIBUTE: kwargs[value] = convert_temperature( - temp, - hass.config.units.temperature_unit, - entity.temperature_unit + temp, hass.config.units.temperature_unit, entity.temperature_unit ) else: kwargs[value] = temp diff --git a/homeassistant/components/climate/const.py b/homeassistant/components/climate/const.py new file mode 100644 index 000000000..26cec7efb --- /dev/null +++ b/homeassistant/components/climate/const.py @@ -0,0 +1,130 @@ +"""Provides the constants needed for component.""" + +# All activity disabled / Device is off/standby +HVAC_MODE_OFF = "off" + +# Heating +HVAC_MODE_HEAT = "heat" + +# Cooling +HVAC_MODE_COOL = "cool" + +# The device supports heating/cooling to a range +HVAC_MODE_HEAT_COOL = "heat_cool" + +# The temperature is set based on a schedule, learned behavior, AI or some +# other related mechanism. User is not able to adjust the temperature +HVAC_MODE_AUTO = "auto" + +# Device is in Dry/Humidity mode +HVAC_MODE_DRY = "dry" + +# Only the fan is on, not fan and another mode like cool +HVAC_MODE_FAN_ONLY = "fan_only" + +HVAC_MODES = [ + HVAC_MODE_OFF, + HVAC_MODE_HEAT, + HVAC_MODE_COOL, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_AUTO, + HVAC_MODE_DRY, + HVAC_MODE_FAN_ONLY, +] + +# No preset is active +PRESET_NONE = "none" + +# Device is running an energy-saving mode +PRESET_ECO = "eco" + +# Device is in away mode +PRESET_AWAY = "away" + +# Device turn all valve full up +PRESET_BOOST = "boost" + +# Device is in comfort mode +PRESET_COMFORT = "comfort" + +# Device is in home mode +PRESET_HOME = "home" + +# Device is prepared for sleep +PRESET_SLEEP = "sleep" + +# Device is reacting to activity (e.g. movement sensors) +PRESET_ACTIVITY = "activity" + + +# Possible fan state +FAN_ON = "on" +FAN_OFF = "off" +FAN_AUTO = "auto" +FAN_LOW = "low" +FAN_MEDIUM = "medium" +FAN_HIGH = "high" +FAN_MIDDLE = "middle" +FAN_FOCUS = "focus" +FAN_DIFFUSE = "diffuse" + + +# Possible swing state +SWING_OFF = "off" +SWING_BOTH = "both" +SWING_VERTICAL = "vertical" +SWING_HORIZONTAL = "horizontal" + + +# This are support current states of HVAC +CURRENT_HVAC_OFF = "off" +CURRENT_HVAC_HEAT = "heating" +CURRENT_HVAC_COOL = "cooling" +CURRENT_HVAC_DRY = "drying" +CURRENT_HVAC_IDLE = "idle" +CURRENT_HVAC_FAN = "fan" + + +ATTR_AUX_HEAT = "aux_heat" +ATTR_CURRENT_HUMIDITY = "current_humidity" +ATTR_CURRENT_TEMPERATURE = "current_temperature" +ATTR_FAN_MODES = "fan_modes" +ATTR_FAN_MODE = "fan_mode" +ATTR_PRESET_MODE = "preset_mode" +ATTR_PRESET_MODES = "preset_modes" +ATTR_HUMIDITY = "humidity" +ATTR_MAX_HUMIDITY = "max_humidity" +ATTR_MIN_HUMIDITY = "min_humidity" +ATTR_MAX_TEMP = "max_temp" +ATTR_MIN_TEMP = "min_temp" +ATTR_HVAC_ACTION = "hvac_action" +ATTR_HVAC_MODES = "hvac_modes" +ATTR_HVAC_MODE = "hvac_mode" +ATTR_SWING_MODES = "swing_modes" +ATTR_SWING_MODE = "swing_mode" +ATTR_TARGET_TEMP_HIGH = "target_temp_high" +ATTR_TARGET_TEMP_LOW = "target_temp_low" +ATTR_TARGET_TEMP_STEP = "target_temp_step" + +DEFAULT_MIN_TEMP = 7 +DEFAULT_MAX_TEMP = 35 +DEFAULT_MIN_HUMIDITY = 30 +DEFAULT_MAX_HUMIDITY = 99 + +DOMAIN = "climate" + +SERVICE_SET_AUX_HEAT = "set_aux_heat" +SERVICE_SET_FAN_MODE = "set_fan_mode" +SERVICE_SET_PRESET_MODE = "set_preset_mode" +SERVICE_SET_HUMIDITY = "set_humidity" +SERVICE_SET_HVAC_MODE = "set_hvac_mode" +SERVICE_SET_SWING_MODE = "set_swing_mode" +SERVICE_SET_TEMPERATURE = "set_temperature" + +SUPPORT_TARGET_TEMPERATURE = 1 +SUPPORT_TARGET_TEMPERATURE_RANGE = 2 +SUPPORT_TARGET_HUMIDITY = 4 +SUPPORT_FAN_MODE = 8 +SUPPORT_PRESET_MODE = 16 +SUPPORT_SWING_MODE = 32 +SUPPORT_AUX_HEAT = 64 diff --git a/homeassistant/components/climate/daikin.py b/homeassistant/components/climate/daikin.py deleted file mode 100644 index 6743bf034..000000000 --- a/homeassistant/components/climate/daikin.py +++ /dev/null @@ -1,265 +0,0 @@ -""" -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_entities, 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_entities([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 in ("-", "--"): - 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 deleted file mode 100644 index bc0b9bd52..000000000 --- a/homeassistant/components/climate/demo.py +++ /dev/null @@ -1,251 +0,0 @@ -""" -Demo platform that offers a fake climate device. - -For more details about this platform, please refer to the documentation -https://home-assistant.io/components/demo/ -""" -from homeassistant.components.climate import ( - 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_entities, discovery_info=None): - """Set up the Demo climate devices.""" - add_entities([ - DemoClimate('HeatPump', 68, TEMP_FAHRENHEIT, None, None, 77, - 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, None), - DemoClimate('Ecobee', None, TEMP_CELSIUS, None, 'home', 23, 'Auto Low', - None, None, 'Auto', 'auto', None, 24, 21, None) - ]) - - -class DemoClimate(ClimateDevice): - """Representation of a demo climate device.""" - - 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, - 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 - self._away = away - self._hold = hold - self._current_temperature = current_temperature - self._current_humidity = current_humidity - 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 = ['On Low', 'On High', 'Auto Low', 'Auto High', 'Off'] - self._operation_list = ['heat', 'cool', 'auto', 'off'] - 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): - """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 target_temperature_high(self): - """Return the highbound target temperature we try to reach.""" - return self._target_temperature_high - - @property - def target_temperature_low(self): - """Return the lowbound target temperature we try to reach.""" - return self._target_temperature_low - - @property - def current_humidity(self): - """Return the current humidity.""" - return self._current_humidity - - @property - def target_humidity(self): - """Return the humidity we try to reach.""" - return self._target_humidity - - @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 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 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.""" - return self._current_fan_mode - - @property - def fan_list(self): - """Return the list of available fan modes.""" - return self._fan_list - - def set_temperature(self, **kwargs): - """Set new target temperatures.""" - if kwargs.get(ATTR_TEMPERATURE) is not None: - self._target_temperature = kwargs.get(ATTR_TEMPERATURE) - if kwargs.get(ATTR_TARGET_TEMP_HIGH) is not None and \ - kwargs.get(ATTR_TARGET_TEMP_LOW) is not None: - self._target_temperature_high = kwargs.get(ATTR_TARGET_TEMP_HIGH) - self._target_temperature_low = kwargs.get(ATTR_TARGET_TEMP_LOW) - self.schedule_update_ha_state() - - def set_humidity(self, humidity): - """Set new target temperature.""" - self._target_humidity = humidity - self.schedule_update_ha_state() - - def set_swing_mode(self, swing_mode): - """Set new target temperature.""" - self._current_swing_mode = swing_mode - self.schedule_update_ha_state() - - def set_fan_mode(self, fan_mode): - """Set new target temperature.""" - self._current_fan_mode = fan_mode - self.schedule_update_ha_state() - - def set_operation_mode(self, operation_mode): - """Set new target temperature.""" - self._current_operation = operation_mode - self.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 - - def turn_away_mode_on(self): - """Turn away mode on.""" - self._away = True - self.schedule_update_ha_state() - - def turn_away_mode_off(self): - """Turn away mode off.""" - self._away = False - self.schedule_update_ha_state() - - 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 auxiliary heater on.""" - self._aux = True - self.schedule_update_ha_state() - - def turn_aux_heat_off(self): - """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/device_action.py b/homeassistant/components/climate/device_action.py new file mode 100644 index 000000000..6f7725ac8 --- /dev/null +++ b/homeassistant/components/climate/device_action.py @@ -0,0 +1,114 @@ +"""Provides device automations for Climate.""" +from typing import List, Optional + +import voluptuous as vol + +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_ENTITY_ID, + CONF_TYPE, +) +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import entity_registry +import homeassistant.helpers.config_validation as cv + +from . import DOMAIN, const + +ACTION_TYPES = {"set_hvac_mode", "set_preset_mode"} + +SET_HVAC_MODE_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): "set_hvac_mode", + vol.Required(CONF_ENTITY_ID): cv.entity_domain(DOMAIN), + vol.Required(const.ATTR_HVAC_MODE): vol.In(const.HVAC_MODES), + } +) + +SET_PRESET_MODE_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): "set_preset_mode", + vol.Required(CONF_ENTITY_ID): cv.entity_domain(DOMAIN), + vol.Required(const.ATTR_PRESET_MODE): str, + } +) + +ACTION_SCHEMA = vol.Any(SET_HVAC_MODE_SCHEMA, SET_PRESET_MODE_SCHEMA) + + +async def async_get_actions(hass: HomeAssistant, device_id: str) -> List[dict]: + """List device actions for Climate devices.""" + registry = await entity_registry.async_get_registry(hass) + actions = [] + + # Get all the integrations entities for this device + for entry in entity_registry.async_entries_for_device(registry, device_id): + if entry.domain != DOMAIN: + continue + + state = hass.states.get(entry.entity_id) + + # We need a state or else we can't populate the HVAC and preset modes. + if state is None: + continue + + actions.append( + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "set_hvac_mode", + } + ) + if state.attributes["supported_features"] & const.SUPPORT_PRESET_MODE: + actions.append( + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "set_preset_mode", + } + ) + + return actions + + +async def async_call_action_from_config( + hass: HomeAssistant, config: dict, variables: dict, context: Optional[Context] +) -> None: + """Execute a device action.""" + config = ACTION_SCHEMA(config) + + service_data = {ATTR_ENTITY_ID: config[CONF_ENTITY_ID]} + + if config[CONF_TYPE] == "set_hvac_mode": + service = const.SERVICE_SET_HVAC_MODE + service_data[const.ATTR_HVAC_MODE] = config[const.ATTR_HVAC_MODE] + elif config[CONF_TYPE] == "set_preset_mode": + service = const.SERVICE_SET_PRESET_MODE + service_data[const.ATTR_PRESET_MODE] = config[const.ATTR_PRESET_MODE] + + await hass.services.async_call( + DOMAIN, service, service_data, blocking=True, context=context + ) + + +async def async_get_action_capabilities(hass, config): + """List action capabilities.""" + state = hass.states.get(config[CONF_ENTITY_ID]) + action_type = config[CONF_TYPE] + + fields = {} + + if action_type == "set_hvac_mode": + hvac_modes = state.attributes[const.ATTR_HVAC_MODES] if state else [] + fields[vol.Required(const.ATTR_HVAC_MODE)] = vol.In(hvac_modes) + elif action_type == "set_preset_mode": + if state: + preset_modes = state.attributes.get(const.ATTR_PRESET_MODES, []) + else: + preset_modes = [] + fields[vol.Required(const.ATTR_PRESET_MODE)] = vol.In(preset_modes) + + return {"extra_fields": vol.Schema(fields)} diff --git a/homeassistant/components/climate/device_condition.py b/homeassistant/components/climate/device_condition.py new file mode 100644 index 000000000..cf393a035 --- /dev/null +++ b/homeassistant/components/climate/device_condition.py @@ -0,0 +1,119 @@ +"""Provide the device automations for Climate.""" +from typing import Dict, List + +import voluptuous as vol + +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_CONDITION, + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_ENTITY_ID, + CONF_TYPE, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import condition, config_validation as cv, entity_registry +from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA +from homeassistant.helpers.typing import ConfigType, TemplateVarsType + +from . import DOMAIN, const + +CONDITION_TYPES = {"is_hvac_mode", "is_preset_mode"} + +HVAC_MODE_CONDITION = DEVICE_CONDITION_BASE_SCHEMA.extend( + { + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TYPE): "is_hvac_mode", + vol.Required(const.ATTR_HVAC_MODE): vol.In(const.HVAC_MODES), + } +) + +PRESET_MODE_CONDITION = DEVICE_CONDITION_BASE_SCHEMA.extend( + { + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TYPE): "is_preset_mode", + vol.Required(const.ATTR_PRESET_MODE): str, + } +) + +CONDITION_SCHEMA = vol.Any(HVAC_MODE_CONDITION, PRESET_MODE_CONDITION) + + +async def async_get_conditions( + hass: HomeAssistant, device_id: str +) -> List[Dict[str, str]]: + """List device conditions for Climate devices.""" + registry = await entity_registry.async_get_registry(hass) + conditions = [] + + # Get all the integrations entities for this device + for entry in entity_registry.async_entries_for_device(registry, device_id): + if entry.domain != DOMAIN: + continue + + state = hass.states.get(entry.entity_id) + + conditions.append( + { + CONF_CONDITION: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "is_hvac_mode", + } + ) + + if state and state.attributes["supported_features"] & const.SUPPORT_PRESET_MODE: + conditions.append( + { + CONF_CONDITION: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "is_preset_mode", + } + ) + + return conditions + + +def async_condition_from_config( + config: ConfigType, config_validation: bool +) -> condition.ConditionCheckerType: + """Create a function to test a device condition.""" + if config_validation: + config = CONDITION_SCHEMA(config) + + if config[CONF_TYPE] == "is_hvac_mode": + attribute = const.ATTR_HVAC_MODE + else: + attribute = const.ATTR_PRESET_MODE + + def test_is_state(hass: HomeAssistant, variables: TemplateVarsType) -> bool: + """Test if an entity is a certain state.""" + state = hass.states.get(config[ATTR_ENTITY_ID]) + return state and state.attributes.get(attribute) == config[attribute] + + return test_is_state + + +async def async_get_condition_capabilities(hass, config): + """List condition capabilities.""" + state = hass.states.get(config[CONF_ENTITY_ID]) + condition_type = config[CONF_TYPE] + + fields = {} + + if condition_type == "is_hvac_mode": + hvac_modes = state.attributes[const.ATTR_HVAC_MODES] if state else [] + fields[vol.Required(const.ATTR_HVAC_MODE)] = vol.In(hvac_modes) + + elif condition_type == "is_preset_mode": + if state: + preset_modes = state.attributes.get(const.ATTR_PRESET_MODES, []) + else: + preset_modes = [] + + fields[vol.Required(const.ATTR_PRESET_MODES)] = vol.In(preset_modes) + + return {"extra_fields": vol.Schema(fields)} diff --git a/homeassistant/components/climate/device_trigger.py b/homeassistant/components/climate/device_trigger.py new file mode 100644 index 000000000..4c5dcb0ee --- /dev/null +++ b/homeassistant/components/climate/device_trigger.py @@ -0,0 +1,194 @@ +"""Provides device automations for Climate.""" +from typing import List + +import voluptuous as vol + +from homeassistant.components.automation import ( + AutomationActionType, + numeric_state as numeric_state_automation, + state as state_automation, +) +from homeassistant.components.device_automation import TRIGGER_BASE_SCHEMA +from homeassistant.const import ( + CONF_ABOVE, + CONF_BELOW, + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_ENTITY_ID, + CONF_FOR, + CONF_PLATFORM, + CONF_TYPE, +) +from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers.typing import ConfigType + +from . import DOMAIN, const + +TRIGGER_TYPES = { + "current_temperature_changed", + "current_humidity_changed", + "hvac_mode_changed", +} + +HVAC_MODE_TRIGGER_SCHEMA = TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TYPE): "hvac_mode_changed", + vol.Required(state_automation.CONF_TO): vol.In(const.HVAC_MODES), + } +) + +CURRENT_TRIGGER_SCHEMA = vol.All( + TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TYPE): vol.In( + ["current_temperature_changed", "current_humidity_changed"] + ), + vol.Optional(CONF_BELOW): vol.Any(vol.Coerce(float)), + vol.Optional(CONF_ABOVE): vol.Any(vol.Coerce(float)), + vol.Optional(CONF_FOR): cv.positive_time_period_dict, + } + ), + cv.has_at_least_one_key(CONF_BELOW, CONF_ABOVE), +) + +TRIGGER_SCHEMA = vol.Any(HVAC_MODE_TRIGGER_SCHEMA, CURRENT_TRIGGER_SCHEMA) + + +async def async_get_triggers(hass: HomeAssistant, device_id: str) -> List[dict]: + """List device triggers for Climate devices.""" + registry = await entity_registry.async_get_registry(hass) + triggers = [] + + # Get all the integrations entities for this device + for entry in entity_registry.async_entries_for_device(registry, device_id): + if entry.domain != DOMAIN: + continue + + state = hass.states.get(entry.entity_id) + + # Add triggers for each entity that belongs to this integration + triggers.append( + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "hvac_mode_changed", + } + ) + + if state and const.ATTR_CURRENT_TEMPERATURE in state.attributes: + triggers.append( + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "current_temperature_changed", + } + ) + + if state and const.ATTR_CURRENT_HUMIDITY in state.attributes: + triggers.append( + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "current_humidity_changed", + } + ) + + return triggers + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: AutomationActionType, + automation_info: dict, +) -> CALLBACK_TYPE: + """Attach a trigger.""" + config = TRIGGER_SCHEMA(config) + trigger_type = config[CONF_TYPE] + + if trigger_type == "hvac_mode_changed": + state_config = { + state_automation.CONF_PLATFORM: "state", + state_automation.CONF_ENTITY_ID: config[CONF_ENTITY_ID], + state_automation.CONF_TO: config[state_automation.CONF_TO], + state_automation.CONF_FROM: [ + mode + for mode in const.HVAC_MODES + if mode != config[state_automation.CONF_TO] + ], + } + if CONF_FOR in config: + state_config[CONF_FOR] = config[CONF_FOR] + state_config = state_automation.TRIGGER_SCHEMA(state_config) + return await state_automation.async_attach_trigger( + hass, state_config, action, automation_info, platform_type="device" + ) + + numeric_state_config = { + numeric_state_automation.CONF_PLATFORM: "numeric_state", + numeric_state_automation.CONF_ENTITY_ID: config[CONF_ENTITY_ID], + } + + if trigger_type == "current_temperature_changed": + numeric_state_config[ + numeric_state_automation.CONF_VALUE_TEMPLATE + ] = "{{ state.attributes.current_temperature }}" + else: + numeric_state_config[ + numeric_state_automation.CONF_VALUE_TEMPLATE + ] = "{{ state.attributes.current_humidity }}" + + if CONF_ABOVE in config: + numeric_state_config[CONF_ABOVE] = config[CONF_ABOVE] + if CONF_BELOW in config: + numeric_state_config[CONF_BELOW] = config[CONF_BELOW] + if CONF_FOR in config: + numeric_state_config[CONF_FOR] = config[CONF_FOR] + + numeric_state_config = numeric_state_automation.TRIGGER_SCHEMA(numeric_state_config) + return await numeric_state_automation.async_attach_trigger( + hass, numeric_state_config, action, automation_info, platform_type="device" + ) + + +async def async_get_trigger_capabilities(hass: HomeAssistant, config): + """List trigger capabilities.""" + trigger_type = config[CONF_TYPE] + + if trigger_type == "hvac_action_changed": + return None + + if trigger_type == "hvac_mode_changed": + return { + "extra_fields": vol.Schema( + {vol.Optional(CONF_FOR): cv.positive_time_period_dict} + ) + } + + if trigger_type == "current_temperature_changed": + unit_of_measurement = hass.config.units.temperature_unit + else: + unit_of_measurement = "%" + + return { + "extra_fields": vol.Schema( + { + vol.Optional( + CONF_ABOVE, description={"suffix": unit_of_measurement} + ): vol.Coerce(float), + vol.Optional( + CONF_BELOW, description={"suffix": unit_of_measurement} + ): vol.Coerce(float), + vol.Optional(CONF_FOR): cv.positive_time_period_dict, + } + ) + } diff --git a/homeassistant/components/climate/ecobee.py b/homeassistant/components/climate/ecobee.py deleted file mode 100644 index 46fc5c297..000000000 --- a/homeassistant/components/climate/ecobee.py +++ /dev/null @@ -1,446 +0,0 @@ -""" -Platform for Ecobee Thermostats. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/climate.ecobee/ -""" -import logging - -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, 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_ON, ATTR_TEMPERATURE, TEMP_FAHRENHEIT) -import homeassistant.helpers.config_validation as cv - -_CONFIGURING = {} -_LOGGER = logging.getLogger(__name__) - -ATTR_FAN_MIN_ON_TIME = 'fan_min_on_time' -ATTR_RESUME_ALL = 'resume_all' - -DEFAULT_RESUME_ALL = False -TEMPERATURE_HOLD = 'temp' -VACATION_HOLD = 'vacation' -AWAY_MODE = 'awayMode' - -DEPENDENCIES = ['ecobee'] - -SERVICE_SET_FAN_MIN_ON_TIME = 'ecobee_set_fan_min_on_time' -SERVICE_RESUME_PROGRAM = 'ecobee_resume_program' - -SET_FAN_MIN_ON_TIME_SCHEMA = vol.Schema({ - vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, - vol.Required(ATTR_FAN_MIN_ON_TIME): vol.Coerce(int), -}) - -RESUME_PROGRAM_SCHEMA = vol.Schema({ - vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, - 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_entities, discovery_info=None): - """Set up the Ecobee Thermostat Platform.""" - if discovery_info is None: - return - data = ecobee.NETWORK - hold_temp = discovery_info['hold_temp'] - _LOGGER.info( - "Loading ecobee thermostat component with hold_temp set to %s", - hold_temp) - devices = [Thermostat(data, index, hold_temp) - for index in range(len(data.ecobee.thermostats))] - add_entities(devices) - - def fan_min_on_time_set_service(service): - """Set the minimum fan on time on the target thermostats.""" - entity_id = service.data.get(ATTR_ENTITY_ID) - fan_min_on_time = service.data[ATTR_FAN_MIN_ON_TIME] - - if entity_id: - target_thermostats = [device for device in devices - if device.entity_id in entity_id] - else: - target_thermostats = devices - - for thermostat in target_thermostats: - thermostat.set_fan_min_on_time(str(fan_min_on_time)) - - thermostat.schedule_update_ha_state(True) - - def resume_program_set_service(service): - """Resume the program on the target thermostats.""" - entity_id = service.data.get(ATTR_ENTITY_ID) - resume_all = service.data.get(ATTR_RESUME_ALL) - - if entity_id: - target_thermostats = [device for device in devices - if device.entity_id in entity_id] - else: - target_thermostats = devices - - for thermostat in target_thermostats: - thermostat.resume_program(resume_all) - - thermostat.schedule_update_ha_state(True) - - hass.services.register( - DOMAIN, SERVICE_SET_FAN_MIN_ON_TIME, fan_min_on_time_set_service, - schema=SET_FAN_MIN_ON_TIME_SCHEMA) - - hass.services.register( - DOMAIN, SERVICE_RESUME_PROGRAM, resume_program_set_service, - schema=RESUME_PROGRAM_SCHEMA) - - -class Thermostat(ClimateDevice): - """A thermostat class for Ecobee.""" - - def __init__(self, data, thermostat_index, hold_temp): - """Initialize the thermostat.""" - self.data = data - self.thermostat_index = thermostat_index - self.thermostat = self.data.ecobee.get_thermostat( - self.thermostat_index) - self._name = self.thermostat['name'] - self.hold_temp = hold_temp - self.vacation = None - 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): - """Get the latest state from the thermostat.""" - if self.update_without_throttle: - self.data.update(no_throttle=True) - self.update_without_throttle = False - else: - self.data.update() - - 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.""" - return self.thermostat['name'] - - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return TEMP_FAHRENHEIT - - @property - def current_temperature(self): - """Return the current temperature.""" - 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 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 self.thermostat['runtime']['desiredCool'] / 10.0 - return None - - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - if self.current_operation == STATE_AUTO: - return None - if self.current_operation == STATE_HEAT: - return self.thermostat['runtime']['desiredHeat'] / 10.0 - if self.current_operation == STATE_COOL: - return self.thermostat['runtime']['desiredCool'] / 10.0 - return None - - @property - def fan(self): - """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: - # A temporary hold from away climate is a hold - return 'away' - # A permanent hold from away climate - return AWAY_MODE - if event['holdClimateRef'] != "": - # Any other hold based on climate - return event['holdClimateRef'] - # Any hold not based on a climate is a temp hold - return TEMPERATURE_HOLD - if event['type'].startswith('auto'): - # All auto modes are treated as holds - return event['type'][4:].lower() - if event['type'] == 'vacation': - self.vacation = event['name'] - return VACATION_HOLD - return None - - @property - def current_operation(self): - """Return current operation.""" - if self.operation_mode == 'auxHeatOnly' or \ - self.operation_mode == 'heatPump': - return STATE_HEAT - return self.operation_mode - - @property - def operation_list(self): - """Return the operation modes list.""" - return self._operation_list - - @property - def operation_mode(self): - """Return current operation ie. heat, cool, idle.""" - return self.thermostat['settings']['hvacMode'] - - @property - def mode(self): - """Return current mode, as the user-visible name.""" - cur = self.thermostat['program']['currentClimateRef'] - climates = self.thermostat['program']['climates'] - current = list(filter(lambda x: x['climateRef'] == cur, climates)) - return current[0]['name'] - - @property - def fan_min_on_time(self): - """Return current fan minimum on time.""" - return self.thermostat['settings']['fanMinOnTime'] - - @property - def device_state_attributes(self): - """Return device specific state attributes.""" - # Move these to Thermostat Device and make them global - status = self.thermostat['equipmentStatus'] - operation = None - if status == '': - operation = STATE_IDLE - elif 'Cool' in status: - operation = STATE_COOL - elif 'auxHeat' in status: - operation = STATE_HEAT - elif 'heatPump' in status: - operation = STATE_HEAT - else: - operation = status - - return { - "actual_humidity": self.thermostat['runtime']['actualHumidity'], - "fan": self.fan, - "climate_mode": self.mode, - "operation": operation, - "climate_list": self.climate_list, - "fan_min_on_time": self.fan_min_on_time - } - - @property - def is_away_mode_on(self): - """Return true if away mode is on.""" - return self._current_hold_mode == AWAY_MODE - - @property - def is_aux_heat_on(self): - """Return true if aux heater.""" - return 'auxHeat' in self.thermostat['equipmentStatus'] - - def turn_away_mode_on(self): - """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.""" - 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.).""" - hold = self.current_hold_mode - - if hold == hold_mode: - # no change, so no action required - return - if hold_mode == 'None' or hold_mode is None: - if hold == VACATION_HOLD: - self.data.ecobee.delete_vacation( - self.thermostat_index, self.vacation) - else: - self.data.ecobee.resume_program(self.thermostat_index) - else: - if hold_mode == TEMPERATURE_HOLD: - self.set_temp_hold(self.current_temperature) - else: - self.data.ecobee.set_climate_hold( - self.thermostat_index, hold_mode, self.hold_preference()) - self.update_without_throttle = True - - def set_auto_temp_hold(self, heat_temp, cool_temp): - """Set temperature hold in auto mode.""" - 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, - 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. - - 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 - 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.""" - low_temp = kwargs.get(ATTR_TARGET_TEMP_LOW) - high_temp = kwargs.get(ATTR_TARGET_TEMP_HIGH) - temp = kwargs.get(ATTR_TEMPERATURE) - - if self.current_operation == STATE_AUTO and \ - (low_temp is not None or high_temp is not None): - self.set_auto_temp_hold(low_temp, high_temp) - elif temp is not None: - 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) - self.update_without_throttle = True - - def set_fan_min_on_time(self, fan_min_on_time): - """Set the minimum fan on time.""" - self.data.ecobee.set_fan_min_on_time( - self.thermostat_index, fan_min_on_time) - self.update_without_throttle = True - - def resume_program(self, resume_all): - """Resume the thermostat schedule program.""" - self.data.ecobee.resume_program( - self.thermostat_index, 'true' if resume_all else 'false') - self.update_without_throttle = True - - def hold_preference(self): - """Return user preference setting for hold time.""" - # Values returned from thermostat are 'useEndTime4hour', - # 'useEndTime2hour', 'nextTransition', 'indefinite', 'askMe' - default = self.thermostat['settings']['holdAction'] - if default == 'nextTransition': - return default - # add further conditions if other hold durations should be - # supported; note that this should not include 'indefinite' - # as an indefinite away hold is interpreted as away_mode - return 'nextTransition' - - @property - def climate_list(self): - """Return the list of climates currently available.""" - climates = self.thermostat['program']['climates'] - return list(map((lambda x: x['name']), climates)) diff --git a/homeassistant/components/climate/econet.py b/homeassistant/components/climate/econet.py deleted file mode 100644 index 8be640c37..000000000 --- a/homeassistant/components/climate/econet.py +++ /dev/null @@ -1,222 +0,0 @@ -""" -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.6'] - -_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_entities, 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_entities(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 deleted file mode 100644 index cd410cf3b..000000000 --- a/homeassistant/components/climate/ephember.py +++ /dev/null @@ -1,206 +0,0 @@ -""" -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_OFF, - STATE_AUTO, SUPPORT_AUX_HEAT, SUPPORT_OPERATION_MODE, - 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.2.0'] - -_LOGGER = logging.getLogger(__name__) - -# Return cached results if last scan was less then this time ago -SCAN_INTERVAL = timedelta(seconds=120) - -OPERATION_LIST = [STATE_AUTO, STATE_HEAT, STATE_OFF] - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string -}) - -EPH_TO_HA_STATE = { - 'AUTO': STATE_AUTO, - 'ON': STATE_HEAT, - 'OFF': STATE_OFF -} - -HA_STATE_TO_EPH = {value: key for key, value in EPH_TO_HA_STATE.items()} - - -def setup_platform(hass, config, add_entities, 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_entities([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 | SUPPORT_OPERATION_MODE - - return (SUPPORT_TARGET_TEMPERATURE | - SUPPORT_AUX_HEAT | - SUPPORT_OPERATION_MODE) - - @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 device_state_attributes(self): - """Show Device Attributes.""" - attributes = { - 'currently_active': self._zone['isCurrentlyActive'] - } - return attributes - - @property - def current_operation(self): - """Return current operation ie. heat, cool, idle.""" - mode = self._ember.get_zone_mode(self._zone_name) - return self.map_mode_eph_hass(mode) - - @property - def operation_list(self): - """Return the supported operations.""" - return OPERATION_LIST - - def set_operation_mode(self, operation_mode): - """Set the operation mode.""" - mode = self.map_mode_hass_eph(operation_mode) - if mode is not None: - self._ember.set_mode_by_name(self._zone_name, mode) - else: - _LOGGER.error("Invalid operation mode provided %s", operation_mode) - - @property - def is_on(self): - """Return current state.""" - if self._zone['isCurrentlyActive']: - return True - - return None - - @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) - - @staticmethod - def map_mode_hass_eph(operation_mode): - """Map from home assistant mode to eph mode.""" - from pyephember.pyephember import ZoneMode - return getattr(ZoneMode, HA_STATE_TO_EPH.get(operation_mode), None) - - @staticmethod - def map_mode_eph_hass(operation_mode): - """Map from eph mode to home assistant mode.""" - return EPH_TO_HA_STATE.get(operation_mode.name, STATE_AUTO) diff --git a/homeassistant/components/climate/eq3btsmart.py b/homeassistant/components/climate/eq3btsmart.py deleted file mode 100644 index 904d8222e..000000000 --- a/homeassistant/components/climate/eq3btsmart.py +++ /dev/null @@ -1,195 +0,0 @@ -""" -Support for eQ-3 Bluetooth Smart thermostats. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/climate.eq3btsmart/ -""" -import logging - -import voluptuous as vol - -from homeassistant.components.climate import ( - STATE_ON, STATE_OFF, STATE_AUTO, PLATFORM_SCHEMA, ClimateDevice, - SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE, SUPPORT_AWAY_MODE) -from homeassistant.const import ( - CONF_MAC, CONF_DEVICES, TEMP_CELSIUS, ATTR_TEMPERATURE, PRECISION_HALVES) -import homeassistant.helpers.config_validation as cv - -REQUIREMENTS = ['python-eq3bt==0.1.9', 'construct==2.9.41'] - -_LOGGER = logging.getLogger(__name__) - -STATE_BOOST = 'boost' -STATE_AWAY = 'away' -STATE_MANUAL = 'manual' - -ATTR_STATE_WINDOW_OPEN = 'window_open' -ATTR_STATE_VALVE = 'valve' -ATTR_STATE_LOCKED = 'is_locked' -ATTR_STATE_LOW_BAT = 'low_battery' -ATTR_STATE_AWAY_END = 'away_end' - -DEVICE_SCHEMA = vol.Schema({ - vol.Required(CONF_MAC): cv.string, -}) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_DEVICES): - vol.Schema({cv.string: DEVICE_SCHEMA}), -}) - -SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE | - SUPPORT_AWAY_MODE) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the eQ-3 BLE thermostats.""" - devices = [] - - for name, device_cfg in config[CONF_DEVICES].items(): - mac = device_cfg[CONF_MAC] - devices.append(EQ3BTSmartThermostat(mac, name)) - - add_entities(devices) - - -# pylint: disable=import-error -class EQ3BTSmartThermostat(ClimateDevice): - """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. - 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.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: - """Return if thermostat is available.""" - return self.current_operation is not None - - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def temperature_unit(self): - """Return the unit of measurement that is used.""" - return TEMP_CELSIUS - - @property - def precision(self): - """Return eq3bt's precision 0.5.""" - return PRECISION_HALVES - - @property - def current_temperature(self): - """Can not report temperature, so return target_temperature.""" - return self.target_temperature - - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - return self._thermostat.target_temperature - - def set_temperature(self, **kwargs): - """Set new target temperature.""" - temperature = kwargs.get(ATTR_TEMPERATURE) - if temperature is None: - return - self._target_temperature = temperature - self._thermostat.target_temperature = temperature - - @property - def current_operation(self): - """Return the current operation mode.""" - if self._thermostat.mode < 0: - return None - return self.modes[self._thermostat.mode] - - @property - def operation_list(self): - """Return the list of available operation modes.""" - return [x for x in self.modes.values()] - - 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): - """Away mode off turns to AUTO mode.""" - self.set_operation_mode(STATE_AUTO) - - def turn_away_mode_on(self): - """Set away mode on.""" - self.set_operation_mode(STATE_AWAY) - - @property - def is_away_mode_on(self): - """Return if we are away.""" - return self.current_operation == STATE_AWAY - - @property - def min_temp(self): - """Return the minimum temperature.""" - return self._thermostat.min_temp - - @property - def max_temp(self): - """Return the maximum temperature.""" - return self._thermostat.max_temp - - @property - 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, - } - - return dev_specific - - def update(self): - """Update the data from the thermostat.""" - 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 deleted file mode 100644 index de74d2fac..000000000 --- a/homeassistant/components/climate/flexit.py +++ /dev/null @@ -1,157 +0,0 @@ -""" -Platform for Flexit AC units with CI66 Modbus adapter. - -Example configuration: - -climate: - - platform: flexit - name: Main AC - slave: 21 - -For more details about this platform, please refer to the documentation -https://home-assistant.io/components/climate.flexit/ -""" -import logging -import voluptuous as vol - -from homeassistant.const import ( - CONF_NAME, CONF_SLAVE, TEMP_CELSIUS, - ATTR_TEMPERATURE, DEVICE_DEFAULT_NAME) -from homeassistant.components.climate import ( - ClimateDevice, PLATFORM_SCHEMA, SUPPORT_TARGET_TEMPERATURE, - SUPPORT_FAN_MODE) -from homeassistant.components import modbus -import homeassistant.helpers.config_validation as cv - -REQUIREMENTS = ['pyflexit==0.3'] -DEPENDENCIES = ['modbus'] - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_SLAVE): vol.All(int, vol.Range(min=0, max=32)), - vol.Optional(CONF_NAME, default=DEVICE_DEFAULT_NAME): cv.string -}) - -_LOGGER = logging.getLogger(__name__) - -SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Flexit Platform.""" - modbus_slave = config.get(CONF_SLAVE, None) - name = config.get(CONF_NAME, None) - add_entities([Flexit(modbus_slave, name)], True) - - -class Flexit(ClimateDevice): - """Representation of a Flexit AC unit.""" - - def __init__(self, modbus_slave, name): - """Initialize the unit.""" - from pyflexit import pyflexit - self._name = name - self._slave = modbus_slave - self._target_temperature = None - self._current_temperature = None - self._current_fan_mode = None - self._current_operation = None - self._fan_list = ['Off', 'Low', 'Medium', 'High'] - self._current_operation = None - self._filter_hours = None - self._filter_alarm = None - self._heat_recovery = None - self._heater_enabled = False - self._heating = None - self._cooling = None - 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(): - _LOGGER.warning("Modbus read failed") - - self._target_temperature = self.unit.get_target_temp - self._current_temperature = self.unit.get_temp - self._current_fan_mode =\ - self._fan_list[self.unit.get_fan_speed] - self._filter_hours = self.unit.get_filter_hours - # Mechanical heat recovery, 0-100% - self._heat_recovery = self.unit.get_heat_recovery - # Heater active 0-100% - self._heating = self.unit.get_heating - # Cooling active 0-100% - self._cooling = self.unit.get_cooling - # Filter alarm 0/1 - self._filter_alarm = self.unit.get_filter_alarm - # Heater enabled or not. Does not mean it's necessarily heating - self._heater_enabled = self.unit.get_heater_enabled - # Current operation mode - self._current_operation = self.unit.get_operation - - @property - def device_state_attributes(self): - """Return device specific state attributes.""" - return { - 'filter_hours': self._filter_hours, - 'filter_alarm': self._filter_alarm, - 'heat_recovery': self._heat_recovery, - 'heating': self._heating, - 'heater_enabled': self._heater_enabled, - 'cooling': self._cooling - } - - @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 - - @property - def current_operation(self): - """Return current operation ie. heat, cool, idle.""" - return self._current_operation - - @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 - - 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_temp(self._target_temperature) - - def set_fan_mode(self, fan_mode): - """Set new fan mode.""" - 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 deleted file mode 100644 index 3eedb89a3..000000000 --- a/homeassistant/components/climate/fritzbox.py +++ /dev/null @@ -1,170 +0,0 @@ -""" -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, - STATE_OFF, STATE_ON, 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, STATE_OFF, STATE_ON] - -MIN_TEMPERATURE = 8 -MAX_TEMPERATURE = 28 - -# special temperatures for on/off in Fritz!Box API (modified by pyfritzhome) -ON_API_TEMPERATURE = 127.0 -OFF_API_TEMPERATURE = 126.5 -ON_REPORT_SET_TEMPERATURE = 30.0 -OFF_REPORT_SET_TEMPERATURE = 0.0 - - -def setup_platform(hass, config, add_entities, 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_entities(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.""" - if self._target_temperature in (ON_API_TEMPERATURE, - OFF_API_TEMPERATURE): - return None - 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 == ON_API_TEMPERATURE: - return STATE_ON - if self._target_temperature == OFF_API_TEMPERATURE: - return STATE_OFF - if self._target_temperature == self._comfort_temperature: - return STATE_HEAT - if 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) - elif operation_mode == STATE_OFF: - self.set_temperature(temperature=OFF_REPORT_SET_TEMPERATURE) - elif operation_mode == STATE_ON: - self.set_temperature(temperature=ON_REPORT_SET_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 deleted file mode 100644 index 85879b812..000000000 --- a/homeassistant/components/climate/generic_thermostat.py +++ /dev/null @@ -1,397 +0,0 @@ -""" -Adds support for generic thermostat units. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/climate.generic_thermostat/ -""" -import asyncio -import logging - -import voluptuous as vol - -from homeassistant.core import callback -from homeassistant.core import DOMAIN as HA_DOMAIN -from homeassistant.components.climate import ( - STATE_HEAT, STATE_COOL, STATE_IDLE, STATE_AUTO, ClimateDevice, - ATTR_OPERATION_MODE, ATTR_AWAY_MODE, SUPPORT_OPERATION_MODE, - SUPPORT_AWAY_MODE, SUPPORT_TARGET_TEMPERATURE, PLATFORM_SCHEMA) -from homeassistant.const import ( - STATE_ON, STATE_OFF, ATTR_TEMPERATURE, 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__) - -DEPENDENCIES = ['switch', 'sensor'] - -DEFAULT_TOLERANCE = 0.3 -DEFAULT_NAME = 'Generic Thermostat' - -CONF_HEATER = 'heater' -CONF_SENSOR = 'target_sensor' -CONF_MIN_TEMP = 'min_temp' -CONF_MAX_TEMP = 'max_temp' -CONF_TARGET_TEMP = 'target_temp' -CONF_AC_MODE = 'ac_mode' -CONF_MIN_DUR = 'min_cycle_duration' -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, - vol.Required(CONF_SENSOR): cv.entity_id, - vol.Optional(CONF_AC_MODE): cv.boolean, - vol.Optional(CONF_MAX_TEMP): vol.Coerce(float), - 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_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) -}) - - -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): - """Set up the generic thermostat platform.""" - name = config.get(CONF_NAME) - heater_entity_id = config.get(CONF_HEATER) - sensor_entity_id = config.get(CONF_SENSOR) - min_temp = config.get(CONF_MIN_TEMP) - max_temp = config.get(CONF_MAX_TEMP) - target_temp = config.get(CONF_TARGET_TEMP) - ac_mode = config.get(CONF_AC_MODE) - min_cycle_duration = config.get(CONF_MIN_DUR) - 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_entities([GenericThermostat( - hass, name, heater_entity_id, sensor_entity_id, min_temp, max_temp, - target_temp, ac_mode, min_cycle_duration, cold_tolerance, - hot_tolerance, keep_alive, initial_operation_mode, away_temp)]) - - -class GenericThermostat(ClimateDevice): - """Representation of a Generic Thermostat device.""" - - def __init__(self, hass, name, heater_entity_id, sensor_entity_id, - min_temp, max_temp, target_temp, ac_mode, min_cycle_duration, - 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._cold_tolerance = cold_tolerance - self._hot_tolerance = hot_tolerance - self._keep_alive = keep_alive - 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._temp_lock = asyncio.Lock() - 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) - async_track_state_change( - hass, heater_entity_id, self._async_switch_changed) - - if self._keep_alive: - async_track_time_interval( - hass, self._async_control_heating, self._keep_alive) - - sensor_state = hass.states.get(sensor_entity_id) - 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.""" - return False - - @property - def name(self): - """Return the name of the thermostat.""" - return self._name - - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return self._unit - - @property - def current_temperature(self): - """Return the sensor temperature.""" - return self._cur_temp - - @property - def current_operation(self): - """Return current operation.""" - return self._current_operation - - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - return self._target_temp - - @property - def operation_list(self): - """List of available operation modes.""" - return self._operation_list - - async def async_set_operation_mode(self, operation_mode): - """Set operation mode.""" - if operation_mode == STATE_HEAT: - self._current_operation = STATE_HEAT - self._enabled = True - await self._async_control_heating() - elif operation_mode == STATE_COOL: - self._current_operation = STATE_COOL - self._enabled = True - await self._async_control_heating() - elif operation_mode == STATE_OFF: - self._current_operation = STATE_OFF - self._enabled = False - if self._is_device_active: - await self._async_heater_turn_off() - else: - _LOGGER.error("Unrecognized operation mode: %s", operation_mode) - return - # Ensure we update the current operation after changing the mode - self.schedule_update_ha_state() - - async def async_turn_on(self): - """Turn thermostat on.""" - await self.async_set_operation_mode(self.operation_list[0]) - - async def async_turn_off(self): - """Turn thermostat off.""" - await self.async_set_operation_mode(STATE_OFF) - - async def async_set_temperature(self, **kwargs): - """Set new target temperature.""" - temperature = kwargs.get(ATTR_TEMPERATURE) - if temperature is None: - return - self._target_temp = temperature - await self._async_control_heating() - await self.async_update_ha_state() - - @property - def min_temp(self): - """Return the minimum temperature.""" - if self._min_temp: - return self._min_temp - - # get default temp from super class - return super().min_temp - - @property - def max_temp(self): - """Return the maximum temperature.""" - if self._max_temp: - return self._max_temp - - # Get default temp from super class - return super().max_temp - - async def _async_sensor_changed(self, entity_id, old_state, new_state): - """Handle temperature changes.""" - if new_state is None: - return - - self._async_update_temp(new_state) - await self._async_control_heating() - await self.async_update_ha_state() - - @callback - def _async_switch_changed(self, entity_id, old_state, new_state): - """Handle heater switch state changes.""" - if new_state is None: - return - self.async_schedule_update_ha_state() - - @callback - def _async_update_temp(self, state): - """Update thermostat with latest state from sensor.""" - try: - self._cur_temp = float(state.state) - except ValueError as ex: - _LOGGER.error("Unable to update from sensor: %s", ex) - - async def _async_control_heating(self, time=None): - """Check if we need to turn heating on or off.""" - async with self._temp_lock: - 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. %s, %s", - self._cur_temp, self._target_temp) - - if not self._active or not self._enabled: - return - - if self.min_cycle_duration: - if self._is_device_active: - current_state = STATE_ON - else: - current_state = STATE_OFF - long_enough = condition.state( - self.hass, self.heater_entity_id, current_state, - self.min_cycle_duration) - if not long_enough: - return - - too_cold = \ - self._target_temp - self._cur_temp >= self._cold_tolerance - too_hot = \ - self._cur_temp - self._target_temp >= self._hot_tolerance - if self._is_device_active: - if (self.ac_mode and too_cold) or \ - (not self.ac_mode and too_hot): - _LOGGER.info("Turning off heater %s", - self.heater_entity_id) - await self._async_heater_turn_off() - elif time is not None: - # The time argument is passed only in keep-alive case - await self._async_heater_turn_on() - else: - if (self.ac_mode and too_hot) or \ - (not self.ac_mode and too_cold): - _LOGGER.info("Turning on heater %s", self.heater_entity_id) - await self._async_heater_turn_on() - elif time is not None: - # The time argument is passed only in keep-alive case - await self._async_heater_turn_off() - - @property - def _is_device_active(self): - """If the toggleable device is currently active.""" - 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 - - async def _async_heater_turn_on(self): - """Turn heater toggleable device on.""" - data = {ATTR_ENTITY_ID: self.heater_entity_id} - await self.hass.services.async_call(HA_DOMAIN, SERVICE_TURN_ON, data) - - async def _async_heater_turn_off(self): - """Turn heater toggleable device off.""" - data = {ATTR_ENTITY_ID: self.heater_entity_id} - await 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 - - async def async_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 - await self._async_control_heating() - await self.async_update_ha_state() - - async def async_turn_away_mode_off(self): - """Turn away off.""" - self._is_away = False - self._target_temp = self._saved_target_temp - await self._async_control_heating() - await self.async_update_ha_state() diff --git a/homeassistant/components/climate/heatmiser.py b/homeassistant/components/climate/heatmiser.py deleted file mode 100644 index a03d1567e..000000000 --- a/homeassistant/components/climate/heatmiser.py +++ /dev/null @@ -1,116 +0,0 @@ -""" -Support for the PRT Heatmiser themostats using the V3 protocol. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/climate.heatmiser/ -""" -import logging - -import voluptuous as vol - -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 - -REQUIREMENTS = ['heatmiserV3==0.9.1'] - -_LOGGER = logging.getLogger(__name__) - -CONF_IPADDRESS = 'ipaddress' -CONF_TSTATS = 'tstats' - -TSTATS_SCHEMA = vol.Schema({ - vol.Required(CONF_ID): cv.string, - vol.Required(CONF_NAME): cv.string, -}) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_IPADDRESS): cv.string, - vol.Required(CONF_PORT): cv.port, - vol.Required(CONF_TSTATS, default={}): - vol.Schema({cv.string: TSTATS_SCHEMA}), -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the heatmiser thermostat.""" - from heatmiserV3 import heatmiser, connection - - ipaddress = config.get(CONF_IPADDRESS) - port = str(config.get(CONF_PORT)) - tstats = config.get(CONF_TSTATS) - - serport = connection.connection(ipaddress, port) - serport.open() - - for tstat in tstats.values(): - add_entities([ - HeatmiserV3Thermostat( - heatmiser, tstat.get(CONF_ID), tstat.get(CONF_NAME), serport) - ]) - - -class HeatmiserV3Thermostat(ClimateDevice): - """Representation of a HeatmiserV3 thermostat.""" - - def __init__(self, heatmiser, device, name, serport): - """Initialize the thermostat.""" - self.heatmiser = heatmiser - self.serport = serport - self._current_temperature = None - self._name = name - self._id = device - self.dcb = None - 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.""" - return self._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.""" - if self.dcb is not None: - low = self.dcb.get('floortemplow ') - high = self.dcb.get('floortemphigh') - temp = (high * 256 + low) / 10.0 - self._current_temperature = temp - else: - self._current_temperature = None - 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.""" - temperature = kwargs.get(ATTR_TEMPERATURE) - if temperature is None: - return - self.heatmiser.hmSendAddress( - self._id, - 18, - temperature, - 1, - self.serport) - self._target_temperature = temperature - - def update(self): - """Get the latest data.""" - self.dcb = self.heatmiser.hmReadAddress(self._id, 'prt', self.serport) diff --git a/homeassistant/components/climate/hive.py b/homeassistant/components/climate/hive.py deleted file mode 100644 index 37289d45c..000000000 --- a/homeassistant/components/climate/hive.py +++ /dev/null @@ -1,191 +0,0 @@ -""" -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_entities, discovery_info=None): - """Set up Hive climate devices.""" - if discovery_info is None: - return - session = hass.data.get(DATA_HIVE) - - add_entities([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/homekit_controller.py b/homeassistant/components/climate/homekit_controller.py deleted file mode 100644 index f720fb602..000000000 --- a/homeassistant/components/climate/homekit_controller.py +++ /dev/null @@ -1,130 +0,0 @@ -""" -Support for Homekit climate devices. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/climate.homekit_controller/ -""" -import logging - -from homeassistant.components.homekit_controller import ( - HomeKitEntity, KNOWN_ACCESSORIES) -from homeassistant.components.climate import ( - ClimateDevice, STATE_HEAT, STATE_COOL, STATE_IDLE, - SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE) -from homeassistant.const import TEMP_CELSIUS, STATE_OFF, ATTR_TEMPERATURE - -DEPENDENCIES = ['homekit_controller'] - -_LOGGER = logging.getLogger(__name__) - -# Map of Homekit operation modes to hass modes -MODE_HOMEKIT_TO_HASS = { - 0: STATE_OFF, - 1: STATE_HEAT, - 2: STATE_COOL, -} - -# Map of hass operation modes to homekit modes -MODE_HASS_TO_HOMEKIT = {v: k for k, v in MODE_HOMEKIT_TO_HASS.items()} - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up Homekit climate.""" - if discovery_info is not None: - accessory = hass.data[KNOWN_ACCESSORIES][discovery_info['serial']] - add_entities([HomeKitClimateDevice(accessory, discovery_info)], True) - - -class HomeKitClimateDevice(HomeKitEntity, ClimateDevice): - """Representation of a Homekit climate device.""" - - def __init__(self, *args): - """Initialise the device.""" - super().__init__(*args) - self._state = None - self._current_mode = None - self._valid_modes = [] - self._current_temp = None - self._target_temp = None - - def update_characteristics(self, characteristics): - """Synchronise device state with Home Assistant.""" - # pylint: disable=import-error - from homekit import CharacteristicsTypes as ctypes - - for characteristic in characteristics: - ctype = characteristic['type'] - if ctype == ctypes.HEATING_COOLING_CURRENT: - self._state = MODE_HOMEKIT_TO_HASS.get( - characteristic['value']) - if ctype == ctypes.HEATING_COOLING_TARGET: - self._chars['target_mode'] = characteristic['iid'] - self._features |= SUPPORT_OPERATION_MODE - self._current_mode = MODE_HOMEKIT_TO_HASS.get( - characteristic['value']) - self._valid_modes = [MODE_HOMEKIT_TO_HASS.get( - mode) for mode in characteristic['valid-values']] - elif ctype == ctypes.TEMPERATURE_CURRENT: - self._current_temp = characteristic['value'] - elif ctype == ctypes.TEMPERATURE_TARGET: - self._chars['target_temp'] = characteristic['iid'] - self._features |= SUPPORT_TARGET_TEMPERATURE - self._target_temp = characteristic['value'] - - def set_temperature(self, **kwargs): - """Set new target temperature.""" - temp = kwargs.get(ATTR_TEMPERATURE) - - characteristics = [{'aid': self._aid, - 'iid': self._chars['target_temp'], - 'value': temp}] - self.put_characteristics(characteristics) - - def set_operation_mode(self, operation_mode): - """Set new target operation mode.""" - characteristics = [{'aid': self._aid, - 'iid': self._chars['target_mode'], - 'value': MODE_HASS_TO_HOMEKIT[operation_mode]}] - self.put_characteristics(characteristics) - - @property - def state(self): - """Return the current state.""" - # If the device reports its operating mode as off, it sometimes doesn't - # report a new state. - if self._current_mode == STATE_OFF: - return STATE_OFF - - if self._state == STATE_OFF and self._current_mode != STATE_OFF: - return STATE_IDLE - return self._state - - @property - def current_temperature(self): - """Return the current temperature.""" - return self._current_temp - - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - return self._target_temp - - @property - def current_operation(self): - """Return current operation ie. heat, cool, idle.""" - return self._current_mode - - @property - def operation_list(self): - """Return the list of available operation modes.""" - return self._valid_modes - - @property - def supported_features(self): - """Return the list of supported features.""" - return self._features - - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return TEMP_CELSIUS diff --git a/homeassistant/components/climate/homematic.py b/homeassistant/components/climate/homematic.py deleted file mode 100644 index 5b741a87b..000000000 --- a/homeassistant/components/climate/homematic.py +++ /dev/null @@ -1,167 +0,0 @@ -""" -Support for Homematic thermostats. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/climate.homematic/ -""" -import logging - -from homeassistant.components.climate import ( - STATE_AUTO, SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE, - ClimateDevice) -from homeassistant.components.homematic import ( - ATTR_DISCOVER_DEVICES, HM_ATTRIBUTE_SUPPORT, HMDevice) -from homeassistant.const import ATTR_TEMPERATURE, STATE_UNKNOWN, TEMP_CELSIUS - -DEPENDENCIES = ['homematic'] - -_LOGGER = logging.getLogger(__name__) - -STATE_MANUAL = 'manual' -STATE_BOOST = 'boost' -STATE_COMFORT = 'comfort' -STATE_LOWERING = 'lowering' - -HM_STATE_MAP = { - 'AUTO_MODE': STATE_AUTO, - 'MANU_MODE': STATE_MANUAL, - 'BOOST_MODE': STATE_BOOST, - 'COMFORT_MODE': STATE_COMFORT, - 'LOWERING_MODE': STATE_LOWERING -} - -HM_TEMP_MAP = [ - 'ACTUAL_TEMPERATURE', - 'TEMPERATURE', -] - -HM_HUMI_MAP = [ - 'ACTUAL_HUMIDITY', - 'HUMIDITY', -] - -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_entities, discovery_info=None): - """Set up the Homematic thermostat platform.""" - if discovery_info is None: - return - - devices = [] - for conf in discovery_info[ATTR_DISCOVER_DEVICES]: - new_device = HMThermostat(conf) - devices.append(new_device) - - add_entities(devices) - - -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.""" - return TEMP_CELSIUS - - @property - def current_operation(self): - """Return current operation ie. heat, cool, idle.""" - if HM_CONTROL_MODE not in self._data: - return None - - 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 - if 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): - """Return the list of available operation modes.""" - op_list = [] - - for mode in self._hmdevice.ACTIONNODE: - if mode in HM_STATE_MAP: - op_list.append(HM_STATE_MAP.get(mode)) - - return op_list - - @property - def current_humidity(self): - """Return the current humidity.""" - for node in HM_HUMI_MAP: - if node in self._data: - return self._data[node] - - @property - def current_temperature(self): - """Return the current temperature.""" - for node in HM_TEMP_MAP: - if node in self._data: - return self._data[node] - - @property - def target_temperature(self): - """Return the target temperature.""" - return self._data.get(self._state) - - def set_temperature(self, **kwargs): - """Set new target temperature.""" - temperature = kwargs.get(ATTR_TEMPERATURE) - if temperature is None: - return None - - self._hmdevice.writeNodeData(self._state, float(temperature)) - - def set_operation_mode(self, operation_mode): - """Set new target operation mode.""" - for mode, state in HM_STATE_MAP.items(): - 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 4.5 - - @property - def max_temp(self): - """Return the maximum temperature - 30.5 means on.""" - 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 or \ - HM_IP_CONTROL_MODE in self._hmdevice.ATTRIBUTENODE: - self._data[HM_CONTROL_MODE] = STATE_UNKNOWN - - for node in self._hmdevice.SENSORNODE.keys(): - self._data[node] = STATE_UNKNOWN diff --git a/homeassistant/components/climate/homematicip_cloud.py b/homeassistant/components/climate/homematicip_cloud.py deleted file mode 100644 index 966cd95ad..000000000 --- a/homeassistant/components/climate/homematicip_cloud.py +++ /dev/null @@ -1,102 +0,0 @@ -""" -Support for HomematicIP Cloud climate devices. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/climate.homematicip_cloud/ -""" -import logging - -from homeassistant.components.climate import ( - ATTR_TEMPERATURE, STATE_AUTO, STATE_MANUAL, SUPPORT_TARGET_TEMPERATURE, - ClimateDevice) -from homeassistant.components.homematicip_cloud import ( - HMIPC_HAPID, HomematicipGenericDevice) -from homeassistant.components.homematicip_cloud import DOMAIN as HMIPC_DOMAIN -from homeassistant.const import TEMP_CELSIUS - -_LOGGER = logging.getLogger(__name__) - -STATE_BOOST = 'Boost' - -HA_STATE_TO_HMIP = { - STATE_AUTO: 'AUTOMATIC', - STATE_MANUAL: 'MANUAL', -} - -HMIP_STATE_TO_HA = {value: key for key, value in HA_STATE_TO_HMIP.items()} - - -async def async_setup_platform( - hass, config, async_add_entities, discovery_info=None): - """Set up the HomematicIP Cloud climate devices.""" - pass - - -async def async_setup_entry(hass, config_entry, async_add_entities): - """Set up the HomematicIP climate from a config entry.""" - from homematicip.group import HeatingGroup - - home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home - devices = [] - for device in home.groups: - if isinstance(device, HeatingGroup): - devices.append(HomematicipHeatingGroup(home, device)) - - if devices: - async_add_entities(devices) - - -class HomematicipHeatingGroup(HomematicipGenericDevice, ClimateDevice): - """Representation of a HomematicIP heating group.""" - - def __init__(self, home, device): - """Initialize heating group.""" - device.modelType = 'Group-Heating' - super().__init__(home, device) - - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return TEMP_CELSIUS - - @property - def supported_features(self): - """Return the list of supported features.""" - return SUPPORT_TARGET_TEMPERATURE - - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - return self._device.setPointTemperature - - @property - def current_temperature(self): - """Return the current temperature.""" - return self._device.actualTemperature - - @property - def current_humidity(self): - """Return the current humidity.""" - return self._device.humidity - - @property - def current_operation(self): - """Return current operation ie. automatic or manual.""" - return HMIP_STATE_TO_HA.get(self._device.controlMode) - - @property - def min_temp(self): - """Return the minimum temperature.""" - return self._device.minTemperature - - @property - def max_temp(self): - """Return the maximum temperature.""" - return self._device.maxTemperature - - async def async_set_temperature(self, **kwargs): - """Set new target temperature.""" - temperature = kwargs.get(ATTR_TEMPERATURE) - if temperature is None: - return - await self._device.set_point_temperature(temperature) diff --git a/homeassistant/components/climate/honeywell.py b/homeassistant/components/climate/honeywell.py deleted file mode 100644 index 6d54695fa..000000000 --- a/homeassistant/components/climate/honeywell.py +++ /dev/null @@ -1,428 +0,0 @@ -""" -Support for Honeywell Round Connected and Honeywell Evohome thermostats. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/climate.honeywell/ -""" -import logging -import socket -import datetime - -import requests -import voluptuous as vol - -import homeassistant.helpers.config_validation as cv -from homeassistant.components.climate import ( - ClimateDevice, PLATFORM_SCHEMA, ATTR_FAN_MODE, ATTR_FAN_LIST, - ATTR_OPERATION_MODE, ATTR_OPERATION_LIST, SUPPORT_TARGET_TEMPERATURE, - SUPPORT_AWAY_MODE, SUPPORT_OPERATION_MODE) -from homeassistant.const import ( - CONF_PASSWORD, CONF_USERNAME, TEMP_CELSIUS, TEMP_FAHRENHEIT, - ATTR_TEMPERATURE, CONF_REGION) - -REQUIREMENTS = ['evohomeclient==0.2.5', 'somecomfort==0.5.2'] - -_LOGGER = logging.getLogger(__name__) - -ATTR_FAN = 'fan' -ATTR_SYSTEM_MODE = 'system_mode' -ATTR_CURRENT_OPERATION = 'equipment_output_status' - -CONF_AWAY_TEMPERATURE = 'away_temperature' -CONF_COOL_AWAY_TEMPERATURE = 'away_cool_temperature' -CONF_HEAT_AWAY_TEMPERATURE = 'away_heat_temperature' - -DEFAULT_AWAY_TEMPERATURE = 16 -DEFAULT_COOL_AWAY_TEMPERATURE = 30 -DEFAULT_HEAT_AWAY_TEMPERATURE = 16 -DEFAULT_REGION = 'eu' -REGIONS = ['eu', 'us'] - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_AWAY_TEMPERATURE, - default=DEFAULT_AWAY_TEMPERATURE): vol.Coerce(float), - vol.Optional(CONF_COOL_AWAY_TEMPERATURE, - default=DEFAULT_COOL_AWAY_TEMPERATURE): vol.Coerce(float), - vol.Optional(CONF_HEAT_AWAY_TEMPERATURE, - default=DEFAULT_HEAT_AWAY_TEMPERATURE): vol.Coerce(float), - vol.Optional(CONF_REGION, default=DEFAULT_REGION): vol.In(REGIONS), -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Honeywell thermostat.""" - username = config.get(CONF_USERNAME) - password = config.get(CONF_PASSWORD) - region = config.get(CONF_REGION) - - if region == 'us': - return _setup_us(username, password, config, add_entities) - - return _setup_round(username, password, config, add_entities) - - -def _setup_round(username, password, config, add_entities): - """Set up the rounding function.""" - from evohomeclient import EvohomeClient - - away_temp = config.get(CONF_AWAY_TEMPERATURE) - evo_api = EvohomeClient(username, password) - - try: - zones = evo_api.temperatures(force_refresh=True) - for i, zone in enumerate(zones): - add_entities( - [RoundThermostat(evo_api, zone['id'], i == 0, away_temp)], - True - ) - except socket.error: - _LOGGER.error( - "Connection error logging into the honeywell evohome web service") - return False - return True - - -# config will be used later -def _setup_us(username, password, config, add_entities): - """Set up the user.""" - import somecomfort - - try: - client = somecomfort.SomeComfort(username, password) - except somecomfort.AuthError: - _LOGGER.error("Failed to login to honeywell account %s", username) - return False - except somecomfort.SomeComfortError as ex: - _LOGGER.error("Failed to initialize honeywell client: %s", str(ex)) - return False - - dev_id = config.get('thermostat') - loc_id = config.get('location') - cool_away_temp = config.get(CONF_COOL_AWAY_TEMPERATURE) - heat_away_temp = config.get(CONF_HEAT_AWAY_TEMPERATURE) - - add_entities([HoneywellUSThermostat(client, device, cool_away_temp, - heat_away_temp, username, password) - for location in client.locations_by_id.values() - for device in location.devices_by_id.values() - if ((not loc_id or location.locationid == loc_id) and - (not dev_id or device.deviceid == dev_id))]) - return True - - -class RoundThermostat(ClimateDevice): - """Representation of a Honeywell Round Connected thermostat.""" - - def __init__(self, client, zone_id, master, away_temp): - """Initialize the thermostat.""" - self.client = client - self._current_temperature = None - self._target_temperature = None - self._name = 'round connected' - self._id = zone_id - self._master = master - self._is_dhw = False - 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.""" - 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.""" - if self._is_dhw: - return None - return self._target_temperature - - def set_temperature(self, **kwargs): - """Set new target temperature.""" - temperature = kwargs.get(ATTR_TEMPERATURE) - if temperature is None: - return - self.client.set_temperature(self._name, temperature) - - @property - def current_operation(self) -> str: - """Get the current operation of the system.""" - return getattr(self.client, ATTR_SYSTEM_MODE, None) - - @property - def is_away_mode_on(self): - """Return true if away mode is on.""" - return self._away - - def set_operation_mode(self, operation_mode: str) -> None: - """Set the HVAC mode for the thermostat.""" - if hasattr(self.client, ATTR_SYSTEM_MODE): - self.client.system_mode = operation_mode - - def turn_away_mode_on(self): - """Turn away on. - - Honeywell does have a proprietary away mode, but it doesn't really work - the way it should. For example: If you set a temperature manually - it doesn't get overwritten when away mode is switched on. - """ - self._away = True - self.client.set_temperature(self._name, self._away_temp) - - def turn_away_mode_off(self): - """Turn away off.""" - self._away = False - self.client.cancel_temp_override(self._name) - - def update(self): - """Get the latest date.""" - try: - # Only refresh if this is the "master" device, - # others will pick up the cache - for val in self.client.temperatures(force_refresh=self._master): - if val['id'] == self._id: - data = val - - except KeyError: - _LOGGER.error("Update failed from Honeywell server") - self.client.user_data = None - return - - except StopIteration: - _LOGGER.error("Did not receive any temperature data from the " - "evohomeclient API") - return - - self._current_temperature = data['temp'] - self._target_temperature = data['setpoint'] - if data['thermostat'] == 'DOMESTIC_HOT_WATER': - self._name = 'Hot Water' - self._is_dhw = True - else: - self._name = data['name'] - self._is_dhw = False - - # The underlying library doesn't expose the thermostat's mode - # but we can pull it out of the big dictionary of information. - device = self.client.devices[self._id] - self.client.system_mode = device[ - 'thermostat']['changeableValues']['mode'] - - -class HoneywellUSThermostat(ClimateDevice): - """Representation of a Honeywell US Thermostat.""" - - def __init__(self, client, device, cool_away_temp, - heat_away_temp, username, password): - """Initialize the thermostat.""" - self._client = client - self._device = device - self._cool_away_temp = cool_away_temp - self._heat_away_temp = heat_away_temp - self._away = False - 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.""" - return self._device.fan_running - - @property - def name(self): - """Return the name of the honeywell, if any.""" - return self._device.name - - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return (TEMP_CELSIUS if self._device.temperature_unit == 'C' - else TEMP_FAHRENHEIT) - - @property - def current_temperature(self): - """Return the current temperature.""" - return self._device.current_temperature - - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - if self._device.system_mode == 'cool': - return self._device.setpoint_cool - return self._device.setpoint_heat - - @property - def current_operation(self) -> str: - """Return current operation ie. heat, cool, idle.""" - oper = getattr(self._device, ATTR_CURRENT_OPERATION, None) - if oper == "off": - oper = "idle" - return oper - - def set_temperature(self, **kwargs): - """Set target temperature.""" - temperature = kwargs.get(ATTR_TEMPERATURE) - if temperature is None: - return - import somecomfort - try: - # Get current mode - mode = self._device.system_mode - # Set hold if this is not the case - if getattr(self._device, "hold_{}".format(mode)) is False: - # Get next period key - next_period_key = '{}NextPeriod'.format(mode.capitalize()) - # Get next period raw value - next_period = self._device.raw_ui_data.get(next_period_key) - # Get next period time - hour, minute = divmod(next_period * 15, 60) - # Set hold time - setattr(self._device, - "hold_{}".format(mode), - datetime.time(hour, minute)) - # Set temperature - setattr(self._device, - "setpoint_{}".format(mode), - temperature) - except somecomfort.SomeComfortError: - _LOGGER.error("Temperature %.1f out of range", temperature) - - @property - def device_state_attributes(self): - """Return the device specific state attributes.""" - import somecomfort - data = { - ATTR_FAN: (self.is_fan_on and 'running' or 'idle'), - ATTR_FAN_MODE: self._device.fan_mode, - ATTR_OPERATION_MODE: self._device.system_mode, - } - data[ATTR_FAN_LIST] = somecomfort.FAN_MODES - data[ATTR_OPERATION_LIST] = somecomfort.SYSTEM_MODES - return data - - @property - def is_away_mode_on(self): - """Return true if away mode is on.""" - return self._away - - def turn_away_mode_on(self): - """Turn away on. - - Somecomfort does have a proprietary away mode, but it doesn't really - work the way it should. For example: If you set a temperature manually - it doesn't get overwritten when away mode is switched on. - """ - self._away = True - import somecomfort - try: - # Get current mode - mode = self._device.system_mode - except somecomfort.SomeComfortError: - _LOGGER.error('Can not get system mode') - return - try: - - # Set permanent hold - setattr(self._device, - "hold_{}".format(mode), - True) - # Set temperature - setattr(self._device, - "setpoint_{}".format(mode), - getattr(self, "_{}_away_temp".format(mode))) - except somecomfort.SomeComfortError: - _LOGGER.error('Temperature %.1f out of range', - getattr(self, "_{}_away_temp".format(mode))) - - def turn_away_mode_off(self): - """Turn away off.""" - self._away = False - import somecomfort - try: - # Disabling all hold modes - self._device.hold_cool = False - self._device.hold_heat = False - except somecomfort.SomeComfortError: - _LOGGER.error('Can not stop hold mode') - - def set_operation_mode(self, operation_mode: str) -> None: - """Set the system mode (Cool, Heat, etc).""" - if hasattr(self._device, ATTR_SYSTEM_MODE): - self._device.system_mode = operation_mode - - def update(self): - """Update the state.""" - import somecomfort - retries = 3 - while retries > 0: - try: - self._device.refresh() - break - except (somecomfort.client.APIRateLimited, OSError, - requests.exceptions.ReadTimeout) as exp: - retries -= 1 - if retries == 0: - raise exp - if not self._retry(): - raise exp - _LOGGER.error( - "SomeComfort update failed, Retrying - Error: %s", exp) - - def _retry(self): - """Recreate a new somecomfort client. - - When we got an error, the best way to be sure that the next query - will succeed, is to recreate a new somecomfort client. - """ - import somecomfort - try: - self._client = somecomfort.SomeComfort( - self._username, self._password) - except somecomfort.AuthError: - _LOGGER.error("Failed to login to honeywell account %s", - self._username) - return False - except somecomfort.SomeComfortError as ex: - _LOGGER.error("Failed to initialize honeywell client: %s", - str(ex)) - return False - - devices = [device - for location in self._client.locations_by_id.values() - for device in location.devices_by_id.values() - if device.name == self._device.name] - - if len(devices) != 1: - _LOGGER.error("Failed to find device %s", self._device.name) - return False - - self._device = devices[0] - return True diff --git a/homeassistant/components/climate/knx.py b/homeassistant/components/climate/knx.py deleted file mode 100644 index 4eada3566..000000000 --- a/homeassistant/components/climate/knx.py +++ /dev/null @@ -1,212 +0,0 @@ -""" -Support for KNX/IP climate devices. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/climate.knx/ -""" - -import voluptuous as vol - -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_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' -CONF_OPERATION_MODE_STATE_ADDRESS = 'operation_mode_state_address' -CONF_CONTROLLER_STATUS_ADDRESS = 'controller_status_address' -CONF_CONTROLLER_STATUS_STATE_ADDRESS = 'controller_status_state_address' -CONF_OPERATION_MODE_FROST_PROTECTION_ADDRESS = \ - 'operation_mode_frost_protection_address' -CONF_OPERATION_MODE_NIGHT_ADDRESS = 'operation_mode_night_address' -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_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, - vol.Optional(CONF_CONTROLLER_STATUS_STATE_ADDRESS): cv.string, - vol.Optional(CONF_OPERATION_MODE_FROST_PROTECTION_ADDRESS): cv.string, - vol.Optional(CONF_OPERATION_MODE_NIGHT_ADDRESS): cv.string, - vol.Optional(CONF_OPERATION_MODE_COMFORT_ADDRESS): cv.string, -}) - - -async def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): - """Set up climate(s) for KNX platform.""" - if discovery_info is not None: - async_add_entities_discovery(hass, discovery_info, async_add_entities) - else: - async_add_entities_config(hass, config, async_add_entities) - - -@callback -def async_add_entities_discovery(hass, discovery_info, async_add_entities): - """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)) - async_add_entities(entities) - - -@callback -def async_add_entities_config(hass, config, async_add_entities): - """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_target_temperature=config.get( - CONF_TARGET_TEMPERATURE_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( - CONF_CONTROLLER_STATUS_ADDRESS), - group_address_controller_status_state=config.get( - CONF_CONTROLLER_STATUS_STATE_ADDRESS), - group_address_operation_mode_protection=config.get( - CONF_OPERATION_MODE_FROST_PROTECTION_ADDRESS), - group_address_operation_mode_night=config.get( - CONF_OPERATION_MODE_NIGHT_ADDRESS), - group_address_operation_mode_comfort=config.get( - CONF_OPERATION_MODE_COMFORT_ADDRESS)) - hass.data[DATA_KNX].xknx.devices.add(climate) - async_add_entities([KNXClimate(hass, climate)]) - - -class KNXClimate(ClimateDevice): - """Representation of a KNX climate device.""" - - def __init__(self, hass, device): - """Initialize of a KNX climate device.""" - self.device = device - self.hass = hass - self.async_register_callbacks() - - @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.""" - async def after_update_callback(device): - """Call after device was updated.""" - await self.async_update_ha_state() - self.device.register_device_updated_cb(after_update_callback) - - @property - 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.""" - return False - - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return TEMP_CELSIUS - - @property - def current_temperature(self): - """Return the current 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.""" - 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 - - async def async_set_temperature(self, **kwargs): - """Set new target temperature.""" - temperature = kwargs.get(ATTR_TEMPERATURE) - if temperature is None: - return - await self.device.set_target_temperature(temperature) - await self.async_update_ha_state() - - @property - def current_operation(self): - """Return current operation ie. heat, cool, idle.""" - if self.device.supports_operation_mode: - return self.device.operation_mode.value - return None - - @property - def operation_list(self): - """Return the list of available operation modes.""" - return [operation_mode.value for - operation_mode in - self.device.get_supported_operation_modes()] - - 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) - await self.device.set_operation_mode(knx_operation_mode) diff --git a/homeassistant/components/climate/manifest.json b/homeassistant/components/climate/manifest.json new file mode 100644 index 000000000..5933eaf90 --- /dev/null +++ b/homeassistant/components/climate/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "climate", + "name": "Climate", + "documentation": "https://www.home-assistant.io/integrations/climate", + "requirements": [], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/climate/maxcube.py b/homeassistant/components/climate/maxcube.py deleted file mode 100644 index 328cdabde..000000000 --- a/homeassistant/components/climate/maxcube.py +++ /dev/null @@ -1,196 +0,0 @@ -""" -Support for MAX! Thermostats via MAX! Cube. - -For more details about this platform, please refer to the documentation -https://home-assistant.io/components/maxcube/ -""" -import socket -import logging - -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__) - -STATE_MANUAL = 'manual' -STATE_BOOST = 'boost' -STATE_VACATION = 'vacation' - -SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Iterate through all MAX! Devices and add thermostats.""" - devices = [] - for handler in hass.data[DATA_KEY].values(): - cube = handler.cube - for device in cube.devices: - name = '{} {}'.format( - cube.room_by_id(device.room_id).name, device.name) - - if cube.is_thermostat(device) or cube.is_wallthermostat(device): - devices.append( - MaxCubeClimate(handler, name, device.rf_address)) - - if devices: - add_entities(devices) - - -class MaxCubeClimate(ClimateDevice): - """MAX! Cube ClimateDevice.""" - - def __init__(self, handler, name, rf_address): - """Initialize MAX! Cube ClimateDevice.""" - self._name = name - self._operation_list = [STATE_AUTO, STATE_MANUAL, STATE_BOOST, - STATE_VACATION] - self._rf_address = rf_address - self._cubehandle = handler - - @property - def supported_features(self): - """Return the list of supported features.""" - return SUPPORT_FLAGS - - @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 min_temp(self): - """Return the minimum temperature.""" - device = self._cubehandle.cube.device_by_rf(self._rf_address) - return self.map_temperature_max_hass(device.min_temperature) - - @property - def max_temp(self): - """Return the maximum temperature.""" - device = self._cubehandle.cube.device_by_rf(self._rf_address) - return self.map_temperature_max_hass(device.max_temperature) - - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return TEMP_CELSIUS - - @property - def current_temperature(self): - """Return the current temperature.""" - device = self._cubehandle.cube.device_by_rf(self._rf_address) - - # Map and return current temperature - return self.map_temperature_max_hass(device.actual_temperature) - - @property - def current_operation(self): - """Return current operation (auto, manual, boost, vacation).""" - device = self._cubehandle.cube.device_by_rf(self._rf_address) - return self.map_mode_max_hass(device.mode) - - @property - def operation_list(self): - """Return the list of available operation modes.""" - return self._operation_list - - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - device = self._cubehandle.cube.device_by_rf(self._rf_address) - return self.map_temperature_max_hass(device.target_temperature) - - def set_temperature(self, **kwargs): - """Set new target temperatures.""" - if kwargs.get(ATTR_TEMPERATURE) is None: - return False - - target_temperature = kwargs.get(ATTR_TEMPERATURE) - device = self._cubehandle.cube.device_by_rf(self._rf_address) - - cube = self._cubehandle.cube - - with self._cubehandle.mutex: - try: - cube.set_target_temperature(device, target_temperature) - except (socket.timeout, socket.error): - _LOGGER.error("Setting target temperature failed") - return False - - def set_operation_mode(self, operation_mode): - """Set new operation mode.""" - device = self._cubehandle.cube.device_by_rf(self._rf_address) - mode = self.map_mode_hass_max(operation_mode) - - if mode is None: - return False - - with self._cubehandle.mutex: - try: - self._cubehandle.cube.set_mode(device, mode) - except (socket.timeout, socket.error): - _LOGGER.error("Setting operation mode failed") - return False - - def update(self): - """Get latest data from MAX! Cube.""" - self._cubehandle.update() - - @staticmethod - def map_temperature_max_hass(temperature): - """Map Temperature from MAX! to HASS.""" - if temperature is None: - return 0.0 - - return temperature - - @staticmethod - def map_mode_hass_max(operation_mode): - """Map Home Assistant Operation Modes to MAX! Operation Modes.""" - from maxcube.device import \ - MAX_DEVICE_MODE_AUTOMATIC, \ - MAX_DEVICE_MODE_MANUAL, \ - MAX_DEVICE_MODE_VACATION, \ - MAX_DEVICE_MODE_BOOST - - if operation_mode == STATE_AUTO: - mode = MAX_DEVICE_MODE_AUTOMATIC - elif operation_mode == STATE_MANUAL: - mode = MAX_DEVICE_MODE_MANUAL - elif operation_mode == STATE_VACATION: - mode = MAX_DEVICE_MODE_VACATION - elif operation_mode == STATE_BOOST: - mode = MAX_DEVICE_MODE_BOOST - else: - mode = None - - return mode - - @staticmethod - def map_mode_max_hass(mode): - """Map MAX! Operation Modes to Home Assistant Operation Modes.""" - from maxcube.device import \ - MAX_DEVICE_MODE_AUTOMATIC, \ - MAX_DEVICE_MODE_MANUAL, \ - MAX_DEVICE_MODE_VACATION, \ - MAX_DEVICE_MODE_BOOST - - if mode == MAX_DEVICE_MODE_AUTOMATIC: - operation_mode = STATE_AUTO - elif mode == MAX_DEVICE_MODE_MANUAL: - operation_mode = STATE_MANUAL - elif mode == MAX_DEVICE_MODE_VACATION: - operation_mode = STATE_VACATION - elif mode == MAX_DEVICE_MODE_BOOST: - operation_mode = STATE_BOOST - else: - operation_mode = None - - return operation_mode diff --git a/homeassistant/components/climate/melissa.py b/homeassistant/components/climate/melissa.py deleted file mode 100644 index c8e67c148..000000000 --- a/homeassistant/components/climate/melissa.py +++ /dev/null @@ -1,250 +0,0 @@ -""" -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_entities, 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_entities(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): - """Send 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 - if state == self._api.STATE_OFF: - return STATE_OFF - if 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 - if mode == self._api.MODE_COOL: - return STATE_COOL - if mode == self._api.MODE_DRY: - return STATE_DRY - if 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 - if fan == self._api.FAN_LOW: - return SPEED_LOW - if fan == self._api.FAN_MEDIUM: - return SPEED_MEDIUM - if 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 - if mode == STATE_COOL: - return self._api.MODE_COOL - if mode == STATE_DRY: - return self._api.MODE_DRY - if mode == STATE_FAN_ONLY: - return self._api.MODE_FAN - _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 - if fan == SPEED_LOW: - return self._api.FAN_LOW - if fan == SPEED_MEDIUM: - return self._api.FAN_MEDIUM - if fan == SPEED_HIGH: - return self._api.FAN_HIGH - _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 deleted file mode 100644 index 1c5c03e45..000000000 --- a/homeassistant/components/climate/modbus.py +++ /dev/null @@ -1,148 +0,0 @@ -""" -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) - -from homeassistant.components import 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_entities, 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_entities([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 deleted file mode 100644 index 9e227e002..000000000 --- a/homeassistant/components/climate/mqtt.py +++ /dev/null @@ -1,647 +0,0 @@ -""" -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 -from homeassistant.components import 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, DEFAULT_MIN_TEMP, DEFAULT_MAX_TEMP) -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' - -CONF_MIN_TEMP = 'min_temp' -CONF_MAX_TEMP = 'max_temp' - -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, - - vol.Optional(CONF_MIN_TEMP, default=DEFAULT_MIN_TEMP): vol.Coerce(float), - vol.Optional(CONF_MAX_TEMP, default=DEFAULT_MAX_TEMP): vol.Coerce(float) - -}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) - - -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): - """Set up the MQTT climate devices.""" - if discovery_info is not None: - config = PLATFORM_SCHEMA(discovery_info) - - 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_entities([ - 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), - config.get(CONF_MIN_TEMP), - config.get(CONF_MAX_TEMP)) - ]) - - -class MqttClimate(MqttAvailability, ClimateDevice): - """Representation of an MQTT 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, - min_temp, max_temp): - """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 - self._min_temp = min_temp - self._max_temp = max_temp - - @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 - - @property - def min_temp(self): - """Return the minimum temperature.""" - return self._min_temp - - @property - def max_temp(self): - """Return the maximum temperature.""" - return self._max_temp diff --git a/homeassistant/components/climate/mysensors.py b/homeassistant/components/climate/mysensors.py deleted file mode 100644 index 66c634d8c..000000000 --- a/homeassistant/components/climate/mysensors.py +++ /dev/null @@ -1,182 +0,0 @@ -""" -MySensors platform that offers a Climate (MySensors-HVAC) component. - -For more details about this platform, please refer to the documentation -https://home-assistant.io/components/climate.mysensors/ -""" -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, 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 = { - STATE_AUTO: 'AutoChangeOver', - STATE_COOL: 'CoolOn', - STATE_HEAT: 'HeatOn', - STATE_OFF: 'Off', -} -DICT_MYS_TO_HA = { - 'AutoChangeOver': STATE_AUTO, - 'CoolOn': STATE_COOL, - 'HeatOn': STATE_HEAT, - 'Off': STATE_OFF, -} - -FAN_LIST = ['Auto', 'Min', 'Normal', 'Max'] -OPERATION_LIST = [STATE_OFF, STATE_AUTO, STATE_COOL, STATE_HEAT] - - -async def async_setup_platform( - hass, config, async_add_entities, discovery_info=None): - """Set up the mysensors climate.""" - mysensors.setup_mysensors_platform( - hass, DOMAIN, discovery_info, MySensorsHVAC, - async_add_entities=async_add_entities) - - -class MySensorsHVAC(mysensors.device.MySensorsEntity, ClimateDevice): - """Representation of a MySensors HVAC.""" - - @property - def supported_features(self): - """Return the list of supported features.""" - features = SUPPORT_OPERATION_MODE - set_req = self.gateway.const.SetReq - if set_req.V_HVAC_SPEED in self._values: - features = features | SUPPORT_FAN_MODE - if (set_req.V_HVAC_SETPOINT_COOL in self._values and - set_req.V_HVAC_SETPOINT_HEAT in self._values): - features = ( - features | SUPPORT_TARGET_TEMPERATURE_HIGH | - SUPPORT_TARGET_TEMPERATURE_LOW) - else: - features = features | SUPPORT_TARGET_TEMPERATURE - return features - - @property - def assumed_state(self): - """Return True if unable to access real state of entity.""" - return self.gateway.optimistic - - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return TEMP_CELSIUS if self.gateway.metric else TEMP_FAHRENHEIT - - @property - def current_temperature(self): - """Return the current temperature.""" - value = self._values.get(self.gateway.const.SetReq.V_TEMP) - - if value is not None: - value = float(value) - - return value - - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - set_req = self.gateway.const.SetReq - if set_req.V_HVAC_SETPOINT_COOL in self._values and \ - set_req.V_HVAC_SETPOINT_HEAT in self._values: - return None - temp = self._values.get(set_req.V_HVAC_SETPOINT_COOL) - if temp is None: - temp = self._values.get(set_req.V_HVAC_SETPOINT_HEAT) - return float(temp) if temp is not None else None - - @property - def target_temperature_high(self): - """Return the highbound target temperature we try to reach.""" - set_req = self.gateway.const.SetReq - if set_req.V_HVAC_SETPOINT_HEAT in self._values: - temp = self._values.get(set_req.V_HVAC_SETPOINT_COOL) - return float(temp) if temp is not None else None - - @property - def target_temperature_low(self): - """Return the lowbound target temperature we try to reach.""" - set_req = self.gateway.const.SetReq - if set_req.V_HVAC_SETPOINT_COOL in self._values: - temp = self._values.get(set_req.V_HVAC_SETPOINT_HEAT) - return float(temp) if temp is not None else None - - @property - def current_operation(self): - """Return current operation ie. heat, cool, idle.""" - return self._values.get(self.value_type) - - @property - def operation_list(self): - """List of available operation modes.""" - return OPERATION_LIST - - @property - def current_fan_mode(self): - """Return the fan setting.""" - return self._values.get(self.gateway.const.SetReq.V_HVAC_SPEED) - - @property - def fan_list(self): - """List of available fan modes.""" - return FAN_LIST - - async def async_set_temperature(self, **kwargs): - """Set new target temperature.""" - set_req = self.gateway.const.SetReq - temp = kwargs.get(ATTR_TEMPERATURE) - low = kwargs.get(ATTR_TARGET_TEMP_LOW) - high = kwargs.get(ATTR_TARGET_TEMP_HIGH) - heat = self._values.get(set_req.V_HVAC_SETPOINT_HEAT) - cool = self._values.get(set_req.V_HVAC_SETPOINT_COOL) - updates = [] - if temp is not None: - if heat is not None: - # Set HEAT Target temperature - value_type = set_req.V_HVAC_SETPOINT_HEAT - elif cool is not None: - # Set COOL Target temperature - value_type = set_req.V_HVAC_SETPOINT_COOL - if heat is not None or cool is not None: - updates = [(value_type, temp)] - elif all(val is not None for val in (low, high, heat, cool)): - updates = [ - (set_req.V_HVAC_SETPOINT_HEAT, low), - (set_req.V_HVAC_SETPOINT_COOL, high)] - for value_type, value in updates: - 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 - self._values[value_type] = value - self.async_schedule_update_ha_state() - - 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_mode) - if self.gateway.optimistic: - # Optimistically assume that device has changed state - self._values[set_req.V_HVAC_SPEED] = fan_mode - self.async_schedule_update_ha_state() - - 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 - self._values[self.value_type] = operation_mode - self.async_schedule_update_ha_state() - - async def async_update(self): - """Update the controller with the latest value from a sensor.""" - 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 deleted file mode 100644 index bc63512fc..000000000 --- a/homeassistant/components/climate/nest.py +++ /dev/null @@ -1,301 +0,0 @@ -""" -Support for Nest thermostats. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/climate.nest/ -""" -import logging - -import voluptuous as vol - -from homeassistant.components.nest import ( - DATA_NEST, SIGNAL_NEST_UPDATE, DOMAIN as NEST_DOMAIN) -from homeassistant.components.climate import ( - STATE_AUTO, STATE_COOL, STATE_HEAT, STATE_ECO, ClimateDevice, - PLATFORM_SCHEMA, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, - 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) -from homeassistant.helpers.dispatcher import async_dispatcher_connect - -DEPENDENCIES = ['nest'] -_LOGGER = logging.getLogger(__name__) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_SCAN_INTERVAL): - vol.All(vol.Coerce(int), vol.Range(min=1)), -}) - -NEST_MODE_HEAT_COOL = 'heat-cool' - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Nest thermostat. - - No longer in use. - """ - - -async def async_setup_entry(hass, entry, async_add_entities): - """Set up the Nest climate device based on a config entry.""" - temp_unit = hass.config.units.temperature_unit - - thermostats = await hass.async_add_job(hass.data[DATA_NEST].thermostats) - - all_devices = [NestThermostat(structure, device, temp_unit) - for structure, device in thermostats] - - async_add_entities(all_devices, True) - - -class NestThermostat(ClimateDevice): - """Representation of a Nest thermostat.""" - - def __init__(self, structure, device, temp_unit): - """Initialize the thermostat.""" - self._unit = temp_unit - self.structure = structure - 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] - - # Add supported nest thermostat features - if self.device.can_heat: - self._operation_list.append(STATE_HEAT) - - if self.device.can_cool: - self._operation_list.append(STATE_COOL) - - 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 - self._location = None - self._name = None - self._humidity = None - self._target_temperature = None - self._temperature = None - self._temperature_scale = None - self._mode = None - self._fan = None - self._eco_temperature = None - self._is_locked = None - self._locked_temperature = None - self._min_temperature = None - self._max_temperature = None - - @property - def should_poll(self): - """Do not need poll thanks using Nest streaming API.""" - return False - - async def async_added_to_hass(self): - """Register update signal handler.""" - async def async_update_state(): - """Update device state.""" - await self.async_update_ha_state(True) - - async_dispatcher_connect(self.hass, SIGNAL_NEST_UPDATE, - async_update_state) - - @property - def supported_features(self): - """Return the list of supported features.""" - return self._support_flags - - @property - def unique_id(self): - """Return unique ID for this device.""" - return self.device.serial - - @property - def device_info(self): - """Return information about the device.""" - return { - 'identifiers': { - (NEST_DOMAIN, self.device.device_id), - }, - 'name': self.device.name_long, - 'manufacturer': 'Nest Labs', - 'model': "Thermostat", - 'sw_version': self.device.software_version, - } - - @property - def name(self): - """Return the name of the nest, if any.""" - return self._name - - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return self._temperature_scale - - @property - def current_temperature(self): - """Return the current temperature.""" - return self._temperature - - @property - 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 - if 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 != NEST_MODE_HEAT_COOL and \ - self._mode != STATE_ECO and \ - not self.is_away_mode_on: - return self._target_temperature - return None - - @property - def target_temperature_low(self): - """Return the lower bound temperature we try to reach.""" - if (self.is_away_mode_on or self._mode == STATE_ECO) and \ - self._eco_temperature[0]: - # eco_temperature is always a low, high tuple - return self._eco_temperature[0] - if self._mode == NEST_MODE_HEAT_COOL: - return self._target_temperature[0] - return None - - @property - def target_temperature_high(self): - """Return the upper bound temperature we try to reach.""" - if (self.is_away_mode_on or self._mode == STATE_ECO) and \ - self._eco_temperature[1]: - # eco_temperature is always a low, high tuple - return self._eco_temperature[1] - if self._mode == NEST_MODE_HEAT_COOL: - return self._target_temperature[1] - return None - - @property - def is_away_mode_on(self): - """Return if away mode is on.""" - return self._away - - def set_temperature(self, **kwargs): - """Set new target temperature.""" - import nest - temp = None - target_temp_low = kwargs.get(ATTR_TARGET_TEMP_LOW) - target_temp_high = kwargs.get(ATTR_TARGET_TEMP_HIGH) - if self._mode == NEST_MODE_HEAT_COOL: - if target_temp_low is not None and target_temp_high is not None: - temp = (target_temp_low, target_temp_high) - _LOGGER.debug("Nest set_temperature-output-value=%s", temp) - else: - temp = kwargs.get(ATTR_TEMPERATURE) - _LOGGER.debug("Nest set_temperature-output-value=%s", temp) - try: - if temp is not None: - self.device.target = temp - except nest.nest.APIError as api_error: - _LOGGER.error("An error occurred while setting temperature: %s", - api_error) - # restore target temperature - self.schedule_update_ha_state(True) - - def set_operation_mode(self, operation_mode): - """Set operation mode.""" - if operation_mode in [STATE_HEAT, STATE_COOL, STATE_OFF, STATE_ECO]: - device_mode = operation_mode - elif operation_mode == STATE_AUTO: - device_mode = NEST_MODE_HEAT_COOL - else: - device_mode = STATE_OFF - _LOGGER.error( - "An error occurred while setting device mode. " - "Invalid operation mode: %s", operation_mode) - self.device.mode = device_mode - - @property - def operation_list(self): - """List of available operation modes.""" - return self._operation_list - - def turn_away_mode_on(self): - """Turn away on.""" - self.structure.away = True - - def turn_away_mode_off(self): - """Turn away off.""" - self.structure.away = False - - @property - def current_fan_mode(self): - """Return whether the fan is on.""" - if self._has_fan: - # Return whether the fan is on - return STATE_ON if self._fan else STATE_AUTO - # No Fan available so disable slider - return None - - @property - def fan_list(self): - """List of available fan modes.""" - if self._has_fan: - return self._fan_list - return None - - def set_fan_mode(self, fan_mode): - """Turn fan on/off.""" - if self._has_fan: - self.device.fan = fan_mode.lower() - - @property - def min_temp(self): - """Identify min_temp in Nest API or defaults if not available.""" - return self._min_temperature - - @property - def max_temp(self): - """Identify max_temp in Nest API or defaults if not available.""" - return self._max_temperature - - def update(self): - """Cache value from Python-nest.""" - self._location = self.device.where - self._name = self.device.name - self._humidity = self.device.humidity - self._temperature = self.device.temperature - self._mode = self.device.mode - self._target_temperature = self.device.target - self._fan = self.device.fan - self._away = self.structure.away == 'away' - self._eco_temperature = self.device.eco_temperature - self._locked_temperature = self.device.locked_temperature - self._min_temperature = self.device.min_temperature - self._max_temperature = self.device.max_temperature - self._is_locked = self.device.is_locked - if self.device.temperature_scale == 'C': - self._temperature_scale = TEMP_CELSIUS - else: - self._temperature_scale = TEMP_FAHRENHEIT diff --git a/homeassistant/components/climate/netatmo.py b/homeassistant/components/climate/netatmo.py deleted file mode 100644 index 8849ada5c..000000000 --- a/homeassistant/components/climate/netatmo.py +++ /dev/null @@ -1,175 +0,0 @@ -""" -Support for Netatmo Smart Thermostat. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/climate.netatmo/ -""" -import logging -from datetime import timedelta -import voluptuous as vol - -from homeassistant.const import TEMP_CELSIUS, ATTR_TEMPERATURE -from homeassistant.components.climate import ( - STATE_HEAT, STATE_IDLE, ClimateDevice, PLATFORM_SCHEMA, - SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE, SUPPORT_AWAY_MODE) -from homeassistant.util import Throttle -import homeassistant.helpers.config_validation as cv - -DEPENDENCIES = ['netatmo'] - -_LOGGER = logging.getLogger(__name__) - -CONF_RELAY = 'relay' -CONF_THERMOSTAT = 'thermostat' - -DEFAULT_AWAY_TEMPERATURE = 14 -# # 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 -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=300) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_RELAY): cv.string, - vol.Optional(CONF_THERMOSTAT, default=[]): - vol.All(cv.ensure_list, [cv.string]), -}) - -SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE | - SUPPORT_AWAY_MODE) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the NetAtmo Thermostat.""" - netatmo = hass.components.netatmo - device = config.get(CONF_RELAY) - - import pyatmo - try: - data = ThermostatData(netatmo.NETATMO_AUTH, device) - for module_name in data.get_module_names(): - if CONF_THERMOSTAT in config: - if config[CONF_THERMOSTAT] != [] and \ - module_name not in config[CONF_THERMOSTAT]: - continue - add_entities([NetatmoThermostat(data, module_name)], True) - except pyatmo.NoDevice: - return None - - -class NetatmoThermostat(ClimateDevice): - """Representation a Netatmo thermostat.""" - - def __init__(self, data, module_name, away_temp=None): - """Initialize the sensor.""" - self._data = data - self._state = None - self._name = module_name - 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 temperature_unit(self): - """Return the unit of measurement.""" - return TEMP_CELSIUS - - @property - def current_temperature(self): - """Return the current temperature.""" - return self._data.current_temperature - - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - return self._target_temperature - - @property - def current_operation(self): - """Return the current state of the thermostat.""" - state = self._data.thermostatdata.relay_cmd - if state == 0: - return STATE_IDLE - if state == 100: - return STATE_HEAT - - @property - def is_away_mode_on(self): - """Return true if away mode is on.""" - return self._away - - def turn_away_mode_on(self): - """Turn away on.""" - mode = "away" - temp = None - self._data.thermostatdata.setthermpoint(mode, temp, endTimeOffset=None) - self._away = True - - def turn_away_mode_off(self): - """Turn away off.""" - mode = "program" - temp = None - self._data.thermostatdata.setthermpoint(mode, temp, endTimeOffset=None) - self._away = False - - def set_temperature(self, **kwargs): - """Set new target temperature for 2 hours.""" - temperature = kwargs.get(ATTR_TEMPERATURE) - if temperature is None: - return - mode = "manual" - self._data.thermostatdata.setthermpoint( - mode, temperature, DEFAULT_TIME_OFFSET) - self._target_temperature = temperature - self._away = False - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): - """Get the latest data from NetAtmo API and updates the states.""" - self._data.update() - self._target_temperature = self._data.thermostatdata.setpoint_temp - self._away = self._data.setpoint_mode == 'away' - - -class ThermostatData: - """Get the latest data from Netatmo.""" - - def __init__(self, auth, device=None): - """Initialize the data object.""" - self.auth = auth - self.thermostatdata = None - self.module_names = [] - self.device = device - self.current_temperature = None - self.target_temperature = None - self.setpoint_mode = None - - def get_module_names(self): - """Return all module available on the API as a list.""" - self.update() - if not self.device: - for device in self.thermostatdata.modules: - for module in self.thermostatdata.modules[device].values(): - self.module_names.append(module['module_name']) - else: - for module in self.thermostatdata.modules[self.device].values(): - self.module_names.append(module['module_name']) - return self.module_names - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): - """Call the NetAtmo API to update the data.""" - import pyatmo - self.thermostatdata = pyatmo.ThermostatData(self.auth) - self.target_temperature = self.thermostatdata.setpoint_temp - self.setpoint_mode = self.thermostatdata.setpoint_mode - self.current_temperature = self.thermostatdata.temp diff --git a/homeassistant/components/climate/nuheat.py b/homeassistant/components/climate/nuheat.py deleted file mode 100644 index d0bfe5add..000000000 --- a/homeassistant/components/climate/nuheat.py +++ /dev/null @@ -1,227 +0,0 @@ -""" -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_entities, 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_entities(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 deleted file mode 100644 index e00624233..000000000 --- a/homeassistant/components/climate/oem.py +++ /dev/null @@ -1,147 +0,0 @@ -""" -OpenEnergyMonitor Thermostat Support. - -This provides a climate component for the ESP8266 based thermostat sold by -OpenEnergyMonitor. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/climate.oem/ -""" -import logging - -import requests -import voluptuous as vol - -# 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, - 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 - -REQUIREMENTS = ['oemthermostat==1.1'] - -_LOGGER = logging.getLogger(__name__) - -CONF_AWAY_TEMP = 'away_temp' - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_NAME, default="Thermostat"): cv.string, - vol.Optional(CONF_PORT, default=80): cv.port, - vol.Inclusive(CONF_USERNAME, 'authentication'): cv.string, - vol.Inclusive(CONF_PASSWORD, 'authentication'): cv.string, - vol.Optional(CONF_AWAY_TEMP, default=14): vol.Coerce(float) -}) - -SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_AWAY_MODE - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the oemthermostat platform.""" - from oemthermostat import Thermostat - - name = config.get(CONF_NAME) - host = config.get(CONF_HOST) - port = config.get(CONF_PORT) - username = config.get(CONF_USERNAME) - password = config.get(CONF_PASSWORD) - away_temp = config.get(CONF_AWAY_TEMP) - - try: - therm = Thermostat( - host, port=port, username=username, password=password) - except (ValueError, AssertionError, requests.RequestException): - return False - - add_entities((ThermostatDevice(hass, therm, name, away_temp), ), True) - - -class ThermostatDevice(ClimateDevice): - """Interface class for the oemthermostat module.""" - - def __init__(self, hass, thermostat, name, away_temp): - """Initialize the device.""" - self._name = name - self.hass = hass - - # Away mode stuff - self._away = False - self._away_temp = away_temp - self._prev_temp = thermostat.setpoint - - self.thermostat = thermostat - # Set the thermostat mode to manual - self.thermostat.mode = 2 - - # set up internal state varS - self._state = None - 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.""" - 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. heat, cool, idle.""" - if self._state: - return STATE_HEAT - return STATE_IDLE - - @property - def current_temperature(self): - """Return the current temperature.""" - return self._temperature - - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - return self._setpoint - - def set_temperature(self, **kwargs): - """Set the temperature.""" - # If we are setting the temp, then we don't want away mode anymore. - self.turn_away_mode_off() - - temp = kwargs.get(ATTR_TEMPERATURE) - self.thermostat.setpoint = temp - - @property - def is_away_mode_on(self): - """Return true if away mode is on.""" - return self._away - - def turn_away_mode_on(self): - """Turn away mode on.""" - if not self._away: - self._prev_temp = self._setpoint - - self.thermostat.setpoint = self._away_temp - self._away = True - - def turn_away_mode_off(self): - """Turn away mode off.""" - if self._away: - self.thermostat.setpoint = self._prev_temp - - self._away = False - - def update(self): - """Update local state.""" - self._setpoint = self.thermostat.setpoint - self._temperature = self.thermostat.temperature - self._state = self.thermostat.state diff --git a/homeassistant/components/climate/opentherm_gw.py b/homeassistant/components/climate/opentherm_gw.py deleted file mode 100644 index c1f7afa61..000000000 --- a/homeassistant/components/climate/opentherm_gw.py +++ /dev/null @@ -1,189 +0,0 @@ -""" -Support for OpenTherm Gateway devices. - -For more details about this component, please refer to the documentation at -http://home-assistant.io/components/climate.opentherm_gw/ -""" -import logging - -import voluptuous as vol - -from homeassistant.components.climate import (ClimateDevice, PLATFORM_SCHEMA, - STATE_IDLE, STATE_HEAT, - STATE_COOL, - SUPPORT_TARGET_TEMPERATURE) -from homeassistant.const import (ATTR_TEMPERATURE, CONF_DEVICE, CONF_NAME, - PRECISION_HALVES, PRECISION_TENTHS, - TEMP_CELSIUS, PRECISION_WHOLE) -import homeassistant.helpers.config_validation as cv - -REQUIREMENTS = ['pyotgw==0.1b0'] - -CONF_FLOOR_TEMP = "floor_temperature" -CONF_PRECISION = 'precision' - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_DEVICE): cv.string, - vol.Optional(CONF_NAME, default="OpenTherm Gateway"): cv.string, - vol.Optional(CONF_PRECISION): vol.In([PRECISION_TENTHS, PRECISION_HALVES, - PRECISION_WHOLE]), - vol.Optional(CONF_FLOOR_TEMP, default=False): cv.boolean, -}) - -SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE) -_LOGGER = logging.getLogger(__name__) - - -async def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): - """Set up the opentherm_gw device.""" - gateway = OpenThermGateway(config) - async_add_entities([gateway]) - - -class OpenThermGateway(ClimateDevice): - """Representation of a climate device.""" - - def __init__(self, config): - """Initialize the sensor.""" - import pyotgw - self.pyotgw = pyotgw - self.gateway = self.pyotgw.pyotgw() - self._device = config[CONF_DEVICE] - self.friendly_name = config.get(CONF_NAME) - self.floor_temp = config.get(CONF_FLOOR_TEMP) - self.temp_precision = config.get(CONF_PRECISION) - self._current_operation = STATE_IDLE - self._current_temperature = 0.0 - self._target_temperature = 0.0 - self._away_mode_a = None - self._away_mode_b = None - self._away_state_a = False - self._away_state_b = False - - async def async_added_to_hass(self): - """Connect to the OpenTherm Gateway device.""" - await self.gateway.connect(self.hass.loop, self._device) - self.gateway.subscribe(self.receive_report) - _LOGGER.debug("Connected to %s on %s", self.friendly_name, - self._device) - - async def receive_report(self, status): - """Receive and handle a new report from the Gateway.""" - _LOGGER.debug("Received report: %s", status) - ch_active = status.get(self.pyotgw.DATA_SLAVE_CH_ACTIVE) - cooling_active = status.get(self.pyotgw.DATA_SLAVE_COOLING_ACTIVE) - if ch_active: - self._current_operation = STATE_HEAT - elif cooling_active: - self._current_operation = STATE_COOL - else: - self._current_operation = STATE_IDLE - self._current_temperature = status.get(self.pyotgw.DATA_ROOM_TEMP) - - temp = status.get(self.pyotgw.DATA_ROOM_SETPOINT_OVRD) - if temp is None: - temp = status.get(self.pyotgw.DATA_ROOM_SETPOINT) - self._target_temperature = temp - - # GPIO mode 5: 0 == Away - # GPIO mode 6: 1 == Away - gpio_a_state = status.get(self.pyotgw.OTGW_GPIO_A) - if gpio_a_state == 5: - self._away_mode_a = 0 - elif gpio_a_state == 6: - self._away_mode_a = 1 - else: - self._away_mode_a = None - gpio_b_state = status.get(self.pyotgw.OTGW_GPIO_B) - if gpio_b_state == 5: - self._away_mode_b = 0 - elif gpio_b_state == 6: - self._away_mode_b = 1 - else: - self._away_mode_b = None - if self._away_mode_a is not None: - self._away_state_a = (status.get(self.pyotgw.OTGW_GPIO_A_STATE) == - self._away_mode_a) - if self._away_mode_b is not None: - self._away_state_b = (status.get(self.pyotgw.OTGW_GPIO_B_STATE) == - self._away_mode_b) - self.async_schedule_update_ha_state() - - @property - def name(self): - """Return the friendly name.""" - return self.friendly_name - - @property - def precision(self): - """Return the precision of the system.""" - if self.temp_precision is not None: - return self.temp_precision - if self.hass.config.units.temperature_unit == TEMP_CELSIUS: - return PRECISION_HALVES - return PRECISION_WHOLE - - @property - def should_poll(self): - """Disable polling for this entity.""" - return False - - @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 ie. heat, cool, idle.""" - return self._current_operation - - @property - def current_temperature(self): - """Return the current temperature.""" - if self.floor_temp is True: - if self.temp_precision == PRECISION_HALVES: - return int(2 * self._current_temperature) / 2 - if self.temp_precision == PRECISION_TENTHS: - return int(10 * self._current_temperature) / 10 - return int(self._current_temperature) - return self._current_temperature - - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - return self._target_temperature - - @property - def target_temperature_step(self): - """Return the supported step of target temperature.""" - return self.temp_precision - - @property - def is_away_mode_on(self): - """Return true if away mode is on.""" - return self._away_state_a or self._away_state_b - - async def async_set_temperature(self, **kwargs): - """Set new target temperature.""" - if ATTR_TEMPERATURE in kwargs: - temp = float(kwargs[ATTR_TEMPERATURE]) - self._target_temperature = await self.gateway.set_target_temp( - temp) - self.async_schedule_update_ha_state() - - @property - def supported_features(self): - """Return the list of supported features.""" - return SUPPORT_FLAGS - - @property - def min_temp(self): - """Return the minimum temperature.""" - return 1 - - @property - def max_temp(self): - """Return the maximum temperature.""" - return 30 diff --git a/homeassistant/components/climate/proliphix.py b/homeassistant/components/climate/proliphix.py deleted file mode 100644 index 76160a28c..000000000 --- a/homeassistant/components/climate/proliphix.py +++ /dev/null @@ -1,115 +0,0 @@ -""" -Support for Proliphix NT10e Thermostats. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/climate.proliphix/ -""" -import voluptuous as vol - -from homeassistant.components.climate import ( - PRECISION_TENTHS, STATE_COOL, STATE_HEAT, STATE_IDLE, - 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 - -REQUIREMENTS = ['proliphix==0.4.1'] - -ATTR_FAN = 'fan' - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Proliphix thermostats.""" - username = config.get(CONF_USERNAME) - password = config.get(CONF_PASSWORD) - host = config.get(CONF_HOST) - - import proliphix - - pdp = proliphix.PDP(host, username, password) - - add_entities([ProliphixThermostat(pdp)]) - - -class ProliphixThermostat(ClimateDevice): - """Representation a Proliphix thermostat.""" - - def __init__(self, pdp): - """Initialize the thermostat.""" - self._pdp = 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.""" - return True - - def update(self): - """Update the data from the thermostat.""" - self._pdp.update() - - @property - def name(self): - """Return the name of the thermostat.""" - return self._name - - @property - def precision(self): - """Return the precision of the system. - - Proliphix temperature values are passed back and forth in the - API as tenths of degrees F (i.e. 690 for 69 degrees). - """ - return PRECISION_TENTHS - - @property - def device_state_attributes(self): - """Return the device specific state attributes.""" - return { - ATTR_FAN: self._pdp.fan_state - } - - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return TEMP_FAHRENHEIT - - @property - def current_temperature(self): - """Return the current temperature.""" - return self._pdp.cur_temp - - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - return self._pdp.setback - - @property - def current_operation(self): - """Return the current state of the thermostat.""" - state = self._pdp.hvac_state - if state in (1, 2): - return STATE_IDLE - if state == 3: - return STATE_HEAT - if state == 6: - return STATE_COOL - - def set_temperature(self, **kwargs): - """Set new target temperature.""" - temperature = kwargs.get(ATTR_TEMPERATURE) - if temperature is None: - return - self._pdp.setback = temperature diff --git a/homeassistant/components/climate/radiotherm.py b/homeassistant/components/climate/radiotherm.py deleted file mode 100644 index 14cd2a0f0..000000000 --- a/homeassistant/components/climate/radiotherm.py +++ /dev/null @@ -1,339 +0,0 @@ -""" -Support for Radio Thermostat wifi-enabled home thermostats. - -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_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.4.1'] - -_LOGGER = logging.getLogger(__name__) - -ATTR_FAN = 'fan' -ATTR_MODE = 'mode' - -CONF_HOLD_TEMP = 'hold_temp' -CONF_AWAY_TEMPERATURE_HEAT = 'away_temperature_heat' -CONF_AWAY_TEMPERATURE_COOL = 'away_temperature_cool' - -DEFAULT_AWAY_TEMPERATURE_HEAT = 60 -DEFAULT_AWAY_TEMPERATURE_COOL = 85 - -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.All(vol.Coerce(float), round_temp), - vol.Optional(CONF_AWAY_TEMPERATURE_COOL, - 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_entities, discovery_info=None): - """Set up the Radio Thermostat.""" - import radiotherm - - hosts = [] - if CONF_HOST in config: - hosts = config[CONF_HOST] - else: - hosts.append(radiotherm.discover.discover_address()) - - if hosts is None: - _LOGGER.error("No Radiotherm Thermostats detected") - return False - - hold_temp = config.get(CONF_HOLD_TEMP) - away_temps = [ - config.get(CONF_AWAY_TEMPERATURE_HEAT), - config.get(CONF_AWAY_TEMPERATURE_COOL) - ] - tstats = [] - - for host in hosts: - try: - tstat = radiotherm.get_thermostat(host) - tstats.append(RadioThermostat(tstat, hold_temp, away_temps)) - except OSError: - _LOGGER.exception("Unable to connect to Radio Thermostat: %s", - host) - - add_entities(tstats, True) - - -class RadioThermostat(ClimateDevice): - """Representation of a Radio Thermostat.""" - - def __init__(self, device, hold_temp, away_temps): - """Initialize the thermostat.""" - self.device = device - 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 - - # 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): - """Return the name of the Radio Thermostat.""" - return self._name - - @property - 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.""" - return { - ATTR_FAN: self._fstate, - ATTR_MODE: self._tstate, - } - - @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.""" - return self._current_temperature - - @property - def current_operation(self): - """Return the current operation. head, cool idle.""" - return self._current_operation - - @property - def operation_list(self): - """Return the operation modes list.""" - return OPERATION_LIST - - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - return self._target_temperature - - @property - def is_away_mode_on(self): - """Return true if away mode is on.""" - return self._away - - def update(self): - """Update and validate the data from the thermostat.""" - # 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('%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._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 - - def set_temperature(self, **kwargs): - """Set new target temperature.""" - temperature = kwargs.get(ATTR_TEMPERATURE) - if temperature is None: - return - - temperature = round_temp(temperature) - - if self._current_operation == STATE_COOL: - self.device.t_cool = temperature - elif self._current_operation == STATE_HEAT: - self.device.t_heat = temperature - elif self._current_operation == STATE_AUTO: - 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(), - 'hour': now.hour, - 'minute': now.minute - } - - def set_operation_mode(self, operation_mode): - """Set operation mode (auto, cool, heat, off).""" - if operation_mode in (STATE_OFF, 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 = self._target_temperature - elif operation_mode == STATE_HEAT: - self.device.t_heat = self._target_temperature - - def turn_away_mode_on(self): - """Turn away on. - - The RTCOA app simulates away mode by using a hold. - """ - away_temp = None - if not self._away: - self._prev_temp = self._target_temperature - if self._current_operation == STATE_HEAT: - away_temp = self._away_temps[0] - elif self._current_operation == STATE_COOL: - away_temp = self._away_temps[1] - - self._away = True - self.set_temperature(temperature=away_temp, hold_changed=True) - - def turn_away_mode_off(self): - """Turn away off.""" - self._away = False - self.set_temperature(temperature=self._prev_temp, hold_changed=True) diff --git a/homeassistant/components/climate/reproduce_state.py b/homeassistant/components/climate/reproduce_state.py new file mode 100644 index 000000000..82ca4f4e8 --- /dev/null +++ b/homeassistant/components/climate/reproduce_state.py @@ -0,0 +1,77 @@ +"""Module that groups code required to handle state restore for component.""" +import asyncio +from typing import Iterable, Optional + +from homeassistant.const import ATTR_TEMPERATURE +from homeassistant.core import Context, State +from homeassistant.helpers.typing import HomeAssistantType + +from .const import ( + ATTR_AUX_HEAT, + ATTR_HUMIDITY, + ATTR_HVAC_MODE, + ATTR_PRESET_MODE, + ATTR_SWING_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + DOMAIN, + HVAC_MODES, + SERVICE_SET_AUX_HEAT, + SERVICE_SET_HUMIDITY, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_PRESET_MODE, + SERVICE_SET_SWING_MODE, + SERVICE_SET_TEMPERATURE, +) + + +async def _async_reproduce_states( + hass: HomeAssistantType, state: State, context: Optional[Context] = None +) -> None: + """Reproduce component states.""" + + async def call_service(service: str, keys: Iterable, data=None): + """Call service with set of attributes given.""" + data = data or {} + data["entity_id"] = state.entity_id + for key in keys: + if key in state.attributes: + data[key] = state.attributes[key] + + await hass.services.async_call( + DOMAIN, service, data, blocking=True, context=context + ) + + if state.state in HVAC_MODES: + await call_service(SERVICE_SET_HVAC_MODE, [], {ATTR_HVAC_MODE: state.state}) + + if ATTR_AUX_HEAT in state.attributes: + await call_service(SERVICE_SET_AUX_HEAT, [ATTR_AUX_HEAT]) + + if ( + (ATTR_TEMPERATURE in state.attributes) + or (ATTR_TARGET_TEMP_HIGH in state.attributes) + or (ATTR_TARGET_TEMP_LOW in state.attributes) + ): + await call_service( + SERVICE_SET_TEMPERATURE, + [ATTR_TEMPERATURE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW], + ) + + if ATTR_PRESET_MODE in state.attributes: + await call_service(SERVICE_SET_PRESET_MODE, [ATTR_PRESET_MODE]) + + if ATTR_SWING_MODE in state.attributes: + await call_service(SERVICE_SET_SWING_MODE, [ATTR_SWING_MODE]) + + if ATTR_HUMIDITY in state.attributes: + await call_service(SERVICE_SET_HUMIDITY, [ATTR_HUMIDITY]) + + +async def async_reproduce_states( + hass: HomeAssistantType, states: Iterable[State], context: Optional[Context] = None +) -> None: + """Reproduce component states.""" + await asyncio.gather( + *(_async_reproduce_states(hass, state, context) for state in states) + ) diff --git a/homeassistant/components/climate/sensibo.py b/homeassistant/components/climate/sensibo.py deleted file mode 100644 index ef33ee849..000000000 --- a/homeassistant/components/climate/sensibo.py +++ /dev/null @@ -1,353 +0,0 @@ -""" -Support for Sensibo wifi-enabled home thermostats. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/climate.sensibo/ -""" - -import asyncio -import logging - -import aiohttp -import async_timeout -import voluptuous as vol - -from homeassistant.const import ( - 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, 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.3'] - -_LOGGER = logging.getLogger(__name__) - -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}', '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_entities, - discovery_info=None): - """Set up Sensibo devices.""" - import pysensibo - - client = pysensibo.SensiboClient( - config[CONF_API_KEY], session=async_get_clientsession(hass), - timeout=TIMEOUT) - devices = [] - try: - for dev in ( - yield from client.async_get_devices(_INITIAL_FETCH_FIELDS)): - if config[CONF_ID] == ALL or dev['id'] in config[CONF_ID]: - devices.append(SensiboClimate( - client, dev, hass.config.units.temperature_unit)) - except (aiohttp.client_exceptions.ClientConnectorError, - asyncio.TimeoutError): - _LOGGER.exception('Failed to connect to Sensibo servers.') - raise PlatformNotReady - - if devices: - async_add_entities(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 of a Sensibo device.""" - - def __init__(self, client, data, units): - """Build SensiboClimate. - - client: aiohttp session. - data: initially-fetched data. - """ - self._client = client - self._id = data['id'] - self._external_state = None - self._units = units - 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'] - self._ac_states = data['acState'] - self._status = data['connectionStatus']['isAlive'] - capabilities = data['remoteCapabilities'] - self._operations = sorted(capabilities['modes'].keys()) - self._current_capabilities = capabilities[ - 'modes'][self.current_operation] - 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._units - 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): - """Return the state attributes.""" - return {ATTR_CURRENT_HUMIDITY: self.current_humidity, - 'battery': self.current_battery} - - @property - def temperature_unit(self): - """Return the unit of measurement which this thermostat uses.""" - return self._temperature_unit - - @property - def available(self): - """Return True if entity is available.""" - return self._status - - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - return self._ac_states.get('targetTemperature') - - @property - def target_temperature_step(self): - """Return the supported step of target temperature.""" - if self.temperature_unit == self.hass.config.units.temperature_unit: - # We are working in same units as the a/c unit. Use whole degrees - # like the API supports. - return 1 - # Unit conversion is going on. No point to stick to specific steps. - return None - - @property - def current_operation(self): - """Return current operation ie. heat, cool, idle.""" - return self._ac_states['mode'] - - @property - def current_humidity(self): - """Return the current humidity.""" - return self._measurements['humidity'] - - @property - def current_battery(self): - """Return the current battery voltage.""" - return self._measurements.get('batteryVoltage') - - @property - def current_temperature(self): - """Return the current temperature.""" - # This field is not affected by temperatureUnit. - # It is always in C - return convert_temperature( - self._measurements['temperature'], - TEMP_CELSIUS, - self.temperature_unit) - - @property - def operation_list(self): - """List of available operation modes.""" - return self._operations - - @property - def current_fan_mode(self): - """Return the fan setting.""" - return self._ac_states.get('fanLevel') - - @property - def fan_list(self): - """List of available fan modes.""" - return self._current_capabilities.get('fanLevels') - - @property - def current_swing_mode(self): - """Return the fan setting.""" - return self._ac_states.get('swing') - - @property - def swing_list(self): - """List of available swing modes.""" - return self._current_capabilities.get('swing') - - @property - def name(self): - """Return the name of the entity.""" - return self._name - - @property - 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] \ - if self._temperatures_list else super().min_temp - - @property - def max_temp(self): - """Return the maximum temperature.""" - 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): - """Set new target temperature.""" - temperature = kwargs.get(ATTR_TEMPERATURE) - if temperature is None: - return - temperature = int(temperature) - if temperature not in self._temperatures_list: - # Requested temperature is not supported. - if temperature == self.target_temperature: - return - index = self._temperatures_list.index(self.target_temperature) - if temperature > self.target_temperature and index < len( - self._temperatures_list) - 1: - temperature = self._temperatures_list[index + 1] - elif temperature < self.target_temperature and index > 0: - temperature = self._temperatures_list[index - 1] - else: - return - - with async_timeout.timeout(TIMEOUT): - yield from self._client.async_set_ac_state_property( - self._id, 'targetTemperature', temperature, self._ac_states) - - @asyncio.coroutine - 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_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._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._ac_states) - - @asyncio.coroutine - 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._ac_states) - - @asyncio.coroutine - 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._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): - """Retrieve latest state.""" - try: - with async_timeout.timeout(TIMEOUT): - data = yield from self._client.async_get_device( - self._id, _FETCH_FIELDS) - self._do_update(data) - except aiohttp.client_exceptions.ClientError: - _LOGGER.warning('Failed to connect to Sensibo servers.') diff --git a/homeassistant/components/climate/services.yaml b/homeassistant/components/climate/services.yaml index fbb21962c..34e89d573 100644 --- a/homeassistant/components/climate/services.yaml +++ b/homeassistant/components/climate/services.yaml @@ -9,24 +9,17 @@ set_aux_heat: aux_heat: description: New value of axillary heater. example: true -set_away_mode: - description: Turn away mode on/off for climate device. + +set_preset_mode: + description: Set preset mode for climate device. fields: entity_id: description: Name(s) of entities to change. example: 'climate.kitchen' - away_mode: - description: New value of away mode. - example: true -set_hold_mode: - description: Turn hold mode for climate device. - fields: - entity_id: - description: Name(s) of entities to change. - example: 'climate.kitchen' - hold_mode: - description: New value of hold mode + preset_mode: + description: New value of preset mode example: 'away' + set_temperature: description: Set target temperature of climate device. fields: @@ -42,9 +35,10 @@ set_temperature: target_temp_low: 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' + hvac_mode: + description: HVAC operation mode to set temperature to. + example: 'heat' + set_humidity: description: Set target humidity of climate device. fields: @@ -54,6 +48,7 @@ set_humidity: humidity: description: New target humidity for climate device. example: 60 + set_fan_mode: description: Set fan operation for climate device. fields: @@ -63,15 +58,17 @@ set_fan_mode: fan_mode: description: New value of fan mode. example: On Low -set_operation_mode: - description: Set operation mode for climate device. + +set_hvac_mode: + description: Set HVAC operation mode for climate device. fields: entity_id: description: Name(s) of entities to change. example: 'climate.nest' - operation_mode: + hvac_mode: description: New value of operation mode. - example: Heat + example: heat + set_swing_mode: description: Set swing operation for climate device. fields: @@ -80,7 +77,6 @@ set_swing_mode: example: 'climate.nest' swing_mode: description: New value of swing mode. - example: turn_on: description: Turn climate device on. @@ -95,60 +91,3 @@ turn_off: entity_id: 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. - example: 5 - -ecobee_resume_program: - description: Resume the programmed schedule. - fields: - entity_id: - 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/spider.py b/homeassistant/components/climate/spider.py deleted file mode 100644 index a9d966bd4..000000000 --- a/homeassistant/components/climate/spider.py +++ /dev/null @@ -1,127 +0,0 @@ -""" -Support for Spider thermostats. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/climate.spider/ -""" - -import logging - -from homeassistant.components.climate import ( - ATTR_TEMPERATURE, STATE_COOL, STATE_HEAT, STATE_IDLE, - SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE, ClimateDevice) -from homeassistant.components.spider import DOMAIN as SPIDER_DOMAIN -from homeassistant.const import TEMP_CELSIUS - -DEPENDENCIES = ['spider'] - -OPERATION_LIST = [ - STATE_HEAT, - STATE_COOL, -] - -HA_STATE_TO_SPIDER = { - STATE_COOL: 'Cool', - STATE_HEAT: 'Heat', - STATE_IDLE: 'Idle' -} - -SPIDER_STATE_TO_HA = {value: key for key, value in HA_STATE_TO_SPIDER.items()} - -_LOGGER = logging.getLogger(__name__) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Spider thermostat.""" - if discovery_info is None: - return - - devices = [SpiderThermostat(hass.data[SPIDER_DOMAIN]['controller'], device) - for device in hass.data[SPIDER_DOMAIN]['thermostats']] - add_entities(devices, True) - - -class SpiderThermostat(ClimateDevice): - """Representation of a thermostat.""" - - def __init__(self, api, thermostat): - """Initialize the thermostat.""" - self.api = api - self.thermostat = thermostat - - @property - def supported_features(self): - """Return the list of supported features.""" - supports = SUPPORT_TARGET_TEMPERATURE - - if self.thermostat.has_operation_mode: - supports = supports | SUPPORT_OPERATION_MODE - - return supports - - @property - def unique_id(self): - """Return the id of the thermostat, if any.""" - return self.thermostat.id - - @property - def name(self): - """Return the name of the thermostat, if any.""" - return self.thermostat.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.thermostat.current_temperature - - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - return self.thermostat.target_temperature - - @property - def target_temperature_step(self): - """Return the supported step of target temperature.""" - return self.thermostat.temperature_steps - - @property - def min_temp(self): - """Return the minimum temperature.""" - return self.thermostat.minimum_temperature - - @property - def max_temp(self): - """Return the maximum temperature.""" - return self.thermostat.maximum_temperature - - @property - def current_operation(self): - """Return current operation ie. heat, cool, idle.""" - return SPIDER_STATE_TO_HA[self.thermostat.operation_mode] - - @property - def operation_list(self): - """Return the list of available operation modes.""" - return OPERATION_LIST - - def set_temperature(self, **kwargs): - """Set new target temperature.""" - temperature = kwargs.get(ATTR_TEMPERATURE) - if temperature is None: - return - - self.thermostat.set_temperature(temperature) - - def set_operation_mode(self, operation_mode): - """Set new target operation mode.""" - self.thermostat.set_operation_mode( - HA_STATE_TO_SPIDER.get(operation_mode)) - - def update(self): - """Get the latest data.""" - self.thermostat = self.api.get_thermostat(self.unique_id) diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json new file mode 100644 index 000000000..ff071aed0 --- /dev/null +++ b/homeassistant/components/climate/strings.json @@ -0,0 +1,17 @@ +{ + "device_automation": { + "condition_type": { + "is_hvac_mode": "{entity_name} is set to a specific HVAC mode", + "is_preset_mode": "{entity_name} is set to a specific preset mode" + }, + "trigger_type": { + "current_temperature_changed": "{entity_name} measured temperature changed", + "current_humidity_changed": "{entity_name} measured humidity changed", + "hvac_mode_changed": "{entity_name} HVAC mode changed" + }, + "action_type": { + "set_hvac_mode": "Change HVAC mode on {entity_name}", + "set_preset_mode": "Change preset on {entity_name}" + } + } +} diff --git a/homeassistant/components/climate/tado.py b/homeassistant/components/climate/tado.py deleted file mode 100644 index 1e52c1636..000000000 --- a/homeassistant/components/climate/tado.py +++ /dev/null @@ -1,356 +0,0 @@ -""" -Tado component to create a climate device for each zone. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/climate.tado/ -""" -import logging - -from homeassistant.const import (PRECISION_TENTHS, TEMP_CELSIUS) -from homeassistant.components.climate import ( - ClimateDevice, SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE) -from homeassistant.util.temperature import convert as convert_temperature -from homeassistant.const import ATTR_TEMPERATURE -from homeassistant.components.tado import DATA_TADO - -_LOGGER = logging.getLogger(__name__) - -CONST_MODE_SMART_SCHEDULE = 'SMART_SCHEDULE' # Default mytado mode -CONST_MODE_OFF = 'OFF' # Switch off heating in a zone - -# When we change the temperature setting, we need an overlay mode -# wait until tado changes the mode automatic -CONST_OVERLAY_TADO_MODE = 'TADO_MODE' -# the user has change the temperature or mode manually -CONST_OVERLAY_MANUAL = 'MANUAL' -# the temperature will be reset after a timespan -CONST_OVERLAY_TIMER = 'TIMER' - -CONST_MODE_FAN_HIGH = 'HIGH' -CONST_MODE_FAN_MIDDLE = 'MIDDLE' -CONST_MODE_FAN_LOW = 'LOW' - -FAN_MODES_LIST = { - CONST_MODE_FAN_HIGH: 'High', - CONST_MODE_FAN_MIDDLE: 'Middle', - CONST_MODE_FAN_LOW: 'Low', - CONST_MODE_OFF: 'Off', -} - -OPERATION_LIST = { - CONST_OVERLAY_MANUAL: 'Manual', - CONST_OVERLAY_TIMER: 'Timer', - CONST_OVERLAY_TADO_MODE: 'Tado mode', - CONST_MODE_SMART_SCHEDULE: 'Smart schedule', - CONST_MODE_OFF: 'Off', -} - -SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Tado climate platform.""" - tado = hass.data[DATA_TADO] - - try: - zones = tado.get_zones() - except RuntimeError: - _LOGGER.error("Unable to get zone info from mytado") - return - - climate_devices = [] - for zone in zones: - device = create_climate_device( - tado, hass, zone, zone['name'], zone['id']) - if not device: - continue - climate_devices.append(device) - - if climate_devices: - add_entities(climate_devices, True) - - -def create_climate_device(tado, hass, zone, name, zone_id): - """Create a Tado climate device.""" - capabilities = tado.get_capabilities(zone_id) - - unit = TEMP_CELSIUS - ac_mode = capabilities['type'] == 'AIR_CONDITIONING' - - if ac_mode: - temperatures = capabilities['HEAT']['temperatures'] - 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']) - - data_id = 'zone {} {}'.format(name, zone_id) - device = TadoClimate(tado, - name, zone_id, data_id, - hass.config.units.temperature(min_temp, unit), - hass.config.units.temperature(max_temp, unit), - ac_mode) - - tado.add_sensor(data_id, { - 'id': zone_id, - 'zone': zone, - 'name': name, - 'climate': device - }) - - return device - - -class TadoClimate(ClimateDevice): - """Representation of a tado climate device.""" - - def __init__(self, store, zone_name, zone_id, data_id, - min_temp, max_temp, ac_mode, - tolerance=0.3): - """Initialize of Tado climate device.""" - self._store = store - self._data_id = data_id - - self.zone_name = zone_name - self.zone_id = zone_id - - self.ac_mode = ac_mode - - self._active = False - self._device_is_active = False - - self._unit = TEMP_CELSIUS - self._cur_temp = None - self._cur_humidity = None - self._is_away = False - self._min_temp = min_temp - self._max_temp = max_temp - self._target_temp = None - self._tolerance = tolerance - self._cooling = False - - self._current_fan = CONST_MODE_OFF - 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.""" - return self.zone_name - - @property - def current_humidity(self): - """Return the current humidity.""" - return self._cur_humidity - - @property - def current_temperature(self): - """Return the sensor temperature.""" - return self._cur_temp - - @property - def current_operation(self): - """Return current readable operation mode.""" - if self._cooling: - return "Cooling" - return OPERATION_LIST.get(self._current_operation) - - @property - def operation_list(self): - """Return the list of available operation modes (readable).""" - return list(OPERATION_LIST.values()) - - @property - def current_fan_mode(self): - """Return the fan setting.""" - if self.ac_mode: - return FAN_MODES_LIST.get(self._current_fan) - return None - - @property - def fan_list(self): - """List of available fan modes.""" - if self.ac_mode: - return list(FAN_MODES_LIST.values()) - return None - - @property - def temperature_unit(self): - """Return the unit of measurement used by the platform.""" - return self._unit - - @property - 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.""" - return self._target_temp - - def set_temperature(self, **kwargs): - """Set new target temperature.""" - temperature = kwargs.get(ATTR_TEMPERATURE) - if temperature is None: - return - - self._current_operation = CONST_OVERLAY_TADO_MODE - self._overlay_mode = None - 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 - - for mode, readable in OPERATION_LIST.items(): - if readable == readable_operation_mode: - operation_mode = mode - break - - self._current_operation = operation_mode - self._overlay_mode = None - self._control_heating() - - @property - def min_temp(self): - """Return the minimum temperature.""" - return convert_temperature(self._min_temp, self._unit, - self.hass.config.units.temperature_unit) - - @property - def max_temp(self): - """Return the maximum temperature.""" - return convert_temperature(self._max_temp, self._unit, - self.hass.config.units.temperature_unit) - - def update(self): - """Update the state of this climate device.""" - self._store.update() - - data = self._store.get_data(self._data_id) - - if data is None: - _LOGGER.debug("Received no data for zone %s", self.zone_name) - return - - if 'sensorDataPoints' in data: - sensor_data = data['sensorDataPoints'] - - unit = TEMP_CELSIUS - - if 'insideTemperature' in sensor_data: - temperature = float( - sensor_data['insideTemperature']['celsius']) - self._cur_temp = self.hass.config.units.temperature( - temperature, unit) - - if 'humidity' in sensor_data: - humidity = float( - sensor_data['humidity']['percentage']) - self._cur_humidity = humidity - - # temperature setting will not exist when device is off - if 'temperature' in data['setting'] and \ - data['setting']['temperature'] is not None: - setting = float( - data['setting']['temperature']['celsius']) - self._target_temp = self.hass.config.units.temperature( - setting, unit) - - if 'tadoMode' in data: - mode = data['tadoMode'] - self._is_away = mode == 'AWAY' - - if 'setting' in data: - power = data['setting']['power'] - if power == 'OFF': - self._current_operation = CONST_MODE_OFF - self._current_fan = CONST_MODE_OFF - # There is no overlay, the mode will always be - # "SMART_SCHEDULE" - self._overlay_mode = CONST_MODE_SMART_SCHEDULE - self._device_is_active = False - else: - self._device_is_active = True - - overlay = False - overlay_data = None - termination = CONST_MODE_SMART_SCHEDULE - cooling = False - fan_speed = CONST_MODE_OFF - - if 'overlay' in data: - overlay_data = data['overlay'] - overlay = overlay_data is not None - - if overlay: - termination = overlay_data['termination']['type'] - - if 'setting' in overlay_data: - setting_data = overlay_data['setting'] - setting = setting_data is not None - - if setting: - if 'mode' in setting_data: - cooling = setting_data['mode'] == 'COOL' - - if 'fanSpeed' in setting_data: - fan_speed = setting_data['fanSpeed'] - - if self._device_is_active: - # 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 - - self._cooling = cooling - self._current_fan = fan_speed - - def _control_heating(self): - """Send new target temperature to mytado.""" - if not self._active and None not in ( - self._cur_temp, self._target_temp): - self._active = True - _LOGGER.info("Obtained current and target temperature. " - "Tado thermostat active") - - if not self._active or self._current_operation == self._overlay_mode: - return - - if self._current_operation == CONST_MODE_SMART_SCHEDULE: - _LOGGER.info("Switching mytado.com to SCHEDULE (default) " - "for zone %s", self.zone_name) - self._store.reset_zone_overlay(self.zone_id) - self._overlay_mode = self._current_operation - return - - if self._current_operation == CONST_MODE_OFF: - _LOGGER.info("Switching mytado.com to OFF for zone %s", - self.zone_name) - self._store.set_zone_overlay(self.zone_id, CONST_OVERLAY_MANUAL) - self._overlay_mode = self._current_operation - return - - _LOGGER.info("Switching mytado.com to %s mode for zone %s", - self._current_operation, self.zone_name) - self._store.set_zone_overlay( - self.zone_id, self._current_operation, self._target_temp) - - self._overlay_mode = self._current_operation diff --git a/homeassistant/components/climate/tesla.py b/homeassistant/components/climate/tesla.py deleted file mode 100644 index ef5f2227c..000000000 --- a/homeassistant/components/climate/tesla.py +++ /dev/null @@ -1,100 +0,0 @@ -""" -Support for Tesla HVAC system. - -For more details about this platform, please refer to the documentation -https://home-assistant.io/components/climate.tesla/ -""" -import logging - -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 ( - ATTR_TEMPERATURE, STATE_OFF, STATE_ON, TEMP_CELSIUS, TEMP_FAHRENHEIT) - -_LOGGER = logging.getLogger(__name__) - -DEPENDENCIES = ['tesla'] - -OPERATION_LIST = [STATE_ON, STATE_OFF] - -SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Tesla climate platform.""" - devices = [TeslaThermostat(device, hass.data[TESLA_DOMAIN]['controller']) - for device in hass.data[TESLA_DOMAIN]['devices']['climate']] - add_entities(devices, True) - - -class TeslaThermostat(TeslaDevice, ClimateDevice): - """Representation of a Tesla climate.""" - - def __init__(self, tesla_device, controller): - """Initialize the Tesla device.""" - super().__init__(tesla_device, controller) - self.entity_id = ENTITY_ID_FORMAT.format(self.tesla_id) - self._target_temperature = None - self._temperature = None - - @property - def supported_features(self): - """Return the list of supported features.""" - return SUPPORT_FLAGS - - @property - def current_operation(self): - """Return current operation ie. On or Off.""" - mode = self.tesla_device.is_hvac_enabled() - if mode: - return OPERATION_LIST[0] # On - return OPERATION_LIST[1] # Off - - @property - def operation_list(self): - """List of available operation modes.""" - return OPERATION_LIST - - def update(self): - """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() - self._temperature = self.tesla_device.get_current_temp() - - @property - def temperature_unit(self): - """Return the unit of measurement.""" - tesla_temp_units = self.tesla_device.measurement - - if tesla_temp_units == 'F': - return TEMP_FAHRENHEIT - return TEMP_CELSIUS - - @property - def current_temperature(self): - """Return the current temperature.""" - return self._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 temperatures.""" - _LOGGER.debug("Setting temperature for: %s", self._name) - temperature = kwargs.get(ATTR_TEMPERATURE) - if temperature: - self.tesla_device.set_temperature(temperature) - - def set_operation_mode(self, operation_mode): - """Set HVAC mode (auto, cool, heat, off).""" - _LOGGER.debug("Setting mode for: %s", self._name) - if operation_mode == OPERATION_LIST[1]: # off - self.tesla_device.set_status(False) - elif operation_mode == OPERATION_LIST[0]: # heat - self.tesla_device.set_status(True) diff --git a/homeassistant/components/climate/toon.py b/homeassistant/components/climate/toon.py deleted file mode 100644 index e759e922e..000000000 --- a/homeassistant/components/climate/toon.py +++ /dev/null @@ -1,97 +0,0 @@ -""" -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_entities, discovery_info=None): - """Set up the Toon climate device.""" - add_entities([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 deleted file mode 100644 index 641f6e9a1..000000000 --- a/homeassistant/components/climate/touchline.py +++ /dev/null @@ -1,90 +0,0 @@ -""" -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_entities, 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_entities(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/tuya.py b/homeassistant/components/climate/tuya.py deleted file mode 100644 index 2da46fee1..000000000 --- a/homeassistant/components/climate/tuya.py +++ /dev/null @@ -1,173 +0,0 @@ -""" -Support for the Tuya climate devices. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/climate.tuya/ -""" - -from homeassistant.components.climate import ( - ATTR_TEMPERATURE, ENTITY_ID_FORMAT, STATE_AUTO, STATE_COOL, STATE_ECO, - STATE_ELECTRIC, STATE_FAN_ONLY, STATE_GAS, STATE_HEAT, STATE_HEAT_PUMP, - STATE_HIGH_DEMAND, STATE_PERFORMANCE, SUPPORT_FAN_MODE, SUPPORT_ON_OFF, - SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE, ClimateDevice) -from homeassistant.components.fan import SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH -from homeassistant.components.tuya import DATA_TUYA, TuyaDevice - -from homeassistant.const import ( - PRECISION_WHOLE, TEMP_CELSIUS, TEMP_FAHRENHEIT) - -DEPENDENCIES = ['tuya'] -DEVICE_TYPE = 'climate' - -HA_STATE_TO_TUYA = { - STATE_AUTO: 'auto', - STATE_COOL: 'cold', - STATE_ECO: 'eco', - STATE_ELECTRIC: 'electric', - STATE_FAN_ONLY: 'wind', - STATE_GAS: 'gas', - STATE_HEAT: 'hot', - STATE_HEAT_PUMP: 'heat_pump', - STATE_HIGH_DEMAND: 'high_demand', - STATE_PERFORMANCE: 'performance', -} - -TUYA_STATE_TO_HA = {value: key for key, value in HA_STATE_TO_TUYA.items()} - -FAN_MODES = {SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH} - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up Tuya Climate devices.""" - if discovery_info is None: - return - tuya = hass.data[DATA_TUYA] - dev_ids = discovery_info.get('dev_ids') - devices = [] - for dev_id in dev_ids: - device = tuya.get_device_by_id(dev_id) - if device is None: - continue - devices.append(TuyaClimateDevice(device)) - add_entities(devices) - - -class TuyaClimateDevice(TuyaDevice, ClimateDevice): - """Tuya climate devices,include air conditioner,heater.""" - - def __init__(self, tuya): - """Init climate device.""" - super().__init__(tuya) - self.entity_id = ENTITY_ID_FORMAT.format(tuya.object_id()) - self.operations = [] - - async def async_added_to_hass(self): - """Create operation list when add to hass.""" - await super().async_added_to_hass() - modes = self.tuya.operation_list() - if modes is None: - return - for mode in modes: - if mode in TUYA_STATE_TO_HA: - self.operations.append(TUYA_STATE_TO_HA[mode]) - - @property - def is_on(self): - """Return true if climate is on.""" - return self.tuya.state() - - @property - def precision(self): - """Return the precision of the system.""" - return PRECISION_WHOLE - - @property - def temperature_unit(self): - """Return the unit of measurement used by the platform.""" - unit = self.tuya.temperature_unit() - if unit == 'CELSIUS': - return TEMP_CELSIUS - if unit == 'FAHRENHEIT': - return TEMP_FAHRENHEIT - return TEMP_CELSIUS - - @property - def current_operation(self): - """Return current operation ie. heat, cool, idle.""" - mode = self.tuya.current_operation() - if mode is None: - return None - return TUYA_STATE_TO_HA.get(mode) - - @property - def operation_list(self): - """Return the list of available operation modes.""" - return self.operations - - @property - def current_temperature(self): - """Return the current temperature.""" - return self.tuya.current_temperature() - - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - return self.tuya.target_temperature() - - @property - def target_temperature_step(self): - """Return the supported step of target temperature.""" - return self.tuya.target_temperature_step() - - @property - def current_fan_mode(self): - """Return the fan setting.""" - return self.tuya.current_fan_mode() - - @property - def fan_list(self): - """Return the list of available fan modes.""" - return self.tuya.fan_list() - - def set_temperature(self, **kwargs): - """Set new target temperature.""" - if ATTR_TEMPERATURE in kwargs: - self.tuya.set_temperature(kwargs[ATTR_TEMPERATURE]) - - def set_fan_mode(self, fan_mode): - """Set new target fan mode.""" - self.tuya.set_fan_mode(fan_mode) - - def set_operation_mode(self, operation_mode): - """Set new target operation mode.""" - self.tuya.set_operation_mode(HA_STATE_TO_TUYA.get(operation_mode)) - - def turn_on(self): - """Turn device on.""" - self.tuya.turn_on() - - def turn_off(self): - """Turn device off.""" - self.tuya.turn_off() - - @property - def supported_features(self): - """Return the list of supported features.""" - supports = SUPPORT_ON_OFF - if self.tuya.support_target_temperature(): - supports = supports | SUPPORT_TARGET_TEMPERATURE - if self.tuya.support_mode(): - supports = supports | SUPPORT_OPERATION_MODE - if self.tuya.support_wind_speed(): - supports = supports | SUPPORT_FAN_MODE - return supports - - @property - def min_temp(self): - """Return the minimum temperature.""" - return self.tuya.min_temp() - - @property - def max_temp(self): - """Return the maximum temperature.""" - return self.tuya.max_temp() diff --git a/homeassistant/components/climate/venstar.py b/homeassistant/components/climate/venstar.py deleted file mode 100644 index 16c0b2061..000000000 --- a/homeassistant/components/climate/venstar.py +++ /dev/null @@ -1,314 +0,0 @@ -""" -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_entities, 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_entities([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 - if self._client.mode == self._client.MODE_COOL: - return STATE_COOL - if 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 - if 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 deleted file mode 100644 index e97bd6cd8..000000000 --- a/homeassistant/components/climate/vera.py +++ /dev/null @@ -1,152 +0,0 @@ -""" -Support for Vera thermostats. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/switch.vera/ -""" -import logging - -from homeassistant.util import convert -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, - ATTR_TEMPERATURE) - -from homeassistant.components.vera import ( - VERA_CONTROLLER, VERA_DEVICES, VeraDevice) - -DEPENDENCIES = ['vera'] - -_LOGGER = logging.getLogger(__name__) - -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_entities_callback, discovery_info=None): - """Set up of Vera thermostats.""" - add_entities_callback( - [VeraThermostat(device, hass.data[VERA_CONTROLLER]) for - device in hass.data[VERA_DEVICES]['climate']], True) - - -class VeraThermostat(VeraDevice, ClimateDevice): - """Representation of a Vera Thermostat.""" - - def __init__(self, vera_device, controller): - """Initialize the Vera device.""" - 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.""" - mode = self.vera_device.get_hvac_mode() - if mode == 'HeatOn': - return OPERATION_LIST[0] # heat - if mode == 'CoolOn': - return OPERATION_LIST[1] # cool - if mode == 'AutoChangeOver': - return OPERATION_LIST[2] # auto - if mode == 'Off': - return OPERATION_LIST[3] # off - return 'Off' - - @property - def operation_list(self): - """Return the list of available operation modes.""" - return OPERATION_LIST - - @property - def current_fan_mode(self): - """Return the fan setting.""" - mode = self.vera_device.get_fan_mode() - if mode == "ContinuousOn": - return FAN_OPERATION_LIST[0] # on - if mode == "Auto": - return FAN_OPERATION_LIST[1] # auto - if mode == "PeriodicOn": - return FAN_OPERATION_LIST[2] # cycle - return "Auto" - - @property - def fan_list(self): - """Return a list of available fan modes.""" - return FAN_OPERATION_LIST - - def set_fan_mode(self, fan_mode): - """Set new target temperature.""" - if fan_mode == FAN_OPERATION_LIST[0]: - self.vera_device.fan_on() - elif fan_mode == FAN_OPERATION_LIST[1]: - self.vera_device.fan_auto() - elif fan_mode == FAN_OPERATION_LIST[2]: - return self.vera_device.fan_cycle() - - @property - def current_power_w(self): - """Return the current power usage in W.""" - power = self.vera_device.power - if power: - return convert(power, float, 0.0) - - @property - def temperature_unit(self): - """Return the unit of measurement.""" - vera_temp_units = ( - self.vera_device.vera_controller.temperature_units) - - if vera_temp_units == 'F': - return TEMP_FAHRENHEIT - - return TEMP_CELSIUS - - @property - def current_temperature(self): - """Return the current temperature.""" - return self.vera_device.get_current_temperature() - - @property - def operation(self): - """Return current operation ie. heat, cool, idle.""" - return self.vera_device.get_hvac_state() - - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - return self.vera_device.get_current_goal_temperature() - - def set_temperature(self, **kwargs): - """Set new target temperatures.""" - if kwargs.get(ATTR_TEMPERATURE) is not None: - self.vera_device.set_temperature(kwargs.get(ATTR_TEMPERATURE)) - - def set_operation_mode(self, operation_mode): - """Set HVAC mode (auto, cool, heat, off).""" - if operation_mode == OPERATION_LIST[3]: # off - self.vera_device.turn_off() - elif operation_mode == OPERATION_LIST[2]: # auto - self.vera_device.turn_auto_on() - elif operation_mode == OPERATION_LIST[1]: # cool - self.vera_device.turn_cool_on() - elif operation_mode == OPERATION_LIST[0]: # heat - self.vera_device.turn_heat_on() - - def turn_fan_on(self): - """Turn fan on.""" - self.vera_device.fan_on() - - def turn_fan_off(self): - """Turn fan off.""" - self.vera_device.fan_auto() diff --git a/homeassistant/components/climate/wink.py b/homeassistant/components/climate/wink.py deleted file mode 100644 index 3013a1553..000000000 --- a/homeassistant/components/climate/wink.py +++ /dev/null @@ -1,598 +0,0 @@ -""" -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.climate import ( - 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 ( - 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'] - -SPEED_LOW = 'low' -SPEED_MEDIUM = 'medium' -SPEED_HIGH = 'high' - -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_entities, discovery_info=None): - """Set up the Wink climate devices.""" - import pywink - for climate in pywink.get_thermostats(): - _id = climate.object_id() + climate.name() - if _id not in hass.data[DOMAIN]['unique_ids']: - add_entities([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_entities([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_entities([WinkWaterHeater(water_heater, hass)]) - - -class WinkThermostat(WinkDevice, ClimateDevice): - """Representation of a Wink thermostat.""" - - @property - def supported_features(self): - """Return the list of supported features.""" - return SUPPORT_FLAGS_THERMOSTAT - - @asyncio.coroutine - def async_added_to_hass(self): - """Call when entity is added to hass.""" - self.hass.data[DOMAIN]['entities']['climate'].append(self) - - @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 = {} - 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] = 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] = show_temp( - self.hass, self.target_temperature_low, self.temperature_unit, - PRECISION_TENTHS) - - if self.external_temperature is not None: - 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 - - if self.occupied is not None: - data[ATTR_OCCUPIED] = self.occupied - - if self.eco_target is not None: - data[ATTR_ECO_TARGET] = self.eco_target - - if self.heat_on is not None: - data[ATTR_HEAT_ON] = self.heat_on - - if self.cool_on is not None: - data[ATTR_COOL_ON] = self.cool_on - - current_humidity = self.current_humidity - if current_humidity is not None: - data[ATTR_CURRENT_HUMIDITY] = current_humidity - - return data - - @property - def current_temperature(self): - """Return the current temperature.""" - return self.wink.current_temperature() - - @property - def current_humidity(self): - """Return the current humidity.""" - if self.wink.current_humidity() is not None: - # The API states humidity will be a float 0-1 - # the only example API response with humidity listed show an int - # This will address both possibilities - if self.wink.current_humidity() < 1: - return self.wink.current_humidity() * 100 - return self.wink.current_humidity() - return None - - @property - def external_temperature(self): - """Return the current external temperature.""" - return self.wink.current_external_temperature() - - @property - def smart_temperature(self): - """Return the current average temp of all remote sensor.""" - return self.wink.current_smart_temperature() - - @property - def eco_target(self): - """Return status of eco target (Is the thermostat in eco mode).""" - return self.wink.eco_target() - - @property - 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 - else: - 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 - def target_humidity(self): - """Return the humidity we try to reach.""" - target_hum = None - if self.wink.current_humidifier_mode() == 'on': - if self.wink.current_humidifier_set_point() is not None: - target_hum = self.wink.current_humidifier_set_point() * 100 - elif self.wink.current_dehumidifier_mode() == 'on': - if self.wink.current_dehumidifier_set_point() is not None: - target_hum = self.wink.current_dehumidifier_set_point() * 100 - else: - target_hum = None - return target_hum - - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - if self.current_operation != STATE_AUTO and not self.is_away_mode_on: - if self.current_operation == STATE_COOL: - return self.wink.current_max_set_point() - if self.current_operation == STATE_HEAT: - return self.wink.current_min_set_point() - return None - - @property - def target_temperature_low(self): - """Return the lower bound temperature we try to reach.""" - if self.current_operation == STATE_AUTO: - return self.wink.current_min_set_point() - return None - - @property - def target_temperature_high(self): - """Return the higher bound temperature we try to reach.""" - if self.current_operation == STATE_AUTO: - return self.wink.current_max_set_point() - return None - - @property - def is_away_mode_on(self): - """Return if away mode is on.""" - return self.wink.away() - - @property - def is_aux_heat_on(self): - """Return true if aux heater.""" - if 'aux' not in self.wink.hvac_modes(): - return None - - if self.wink.current_hvac_mode() == 'aux': - return True - return False - - def set_temperature(self, **kwargs): - """Set new target temperature.""" - target_temp = kwargs.get(ATTR_TEMPERATURE) - target_temp_low = kwargs.get(ATTR_TARGET_TEMP_LOW) - target_temp_high = kwargs.get(ATTR_TARGET_TEMP_HIGH) - if target_temp is not None: - if self.current_operation == STATE_COOL: - target_temp_high = target_temp - if self.current_operation == STATE_HEAT: - target_temp_low = target_temp - if target_temp_low is not None: - target_temp_low = target_temp_low - if target_temp_high is not None: - target_temp_high = target_temp_high - self.wink.set_temperature(target_temp_low, target_temp_high) - - def set_operation_mode(self, operation_mode): - """Set operation mode.""" - 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() - 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): - """Turn away on.""" - self.wink.set_away_mode() - - def turn_away_mode_off(self): - """Turn away off.""" - self.wink.set_away_mode(False) - - @property - def current_fan_mode(self): - """Return whether the fan is on.""" - if self.wink.current_fan_mode() == 'on': - return STATE_ON - if self.wink.current_fan_mode() == 'auto': - return STATE_AUTO - # No Fan available so disable slider - return None - - @property - def fan_list(self): - """List of available fan modes.""" - if self.wink.has_fan(): - return self.wink.fan_modes() - return None - - def set_fan_mode(self, fan_mode): - """Turn fan on/off.""" - self.wink.set_fan_mode(fan_mode.lower()) - - def turn_aux_heat_on(self): - """Turn auxiliary heater on.""" - self.wink.set_operation_mode('aux') - - def turn_aux_heat_off(self): - """Turn auxiliary heater off.""" - self.set_operation_mode(STATE_HEAT) - - @property - def min_temp(self): - """Return the minimum temperature.""" - minimum = 7 # Default minimum - min_min = self.wink.min_min_set_point() - min_max = self.wink.min_max_set_point() - if self.current_operation == STATE_HEAT: - if min_min: - return_value = min_min - else: - return_value = minimum - elif self.current_operation == STATE_COOL: - if min_max: - return_value = min_max - else: - return_value = minimum - elif self.current_operation == STATE_AUTO: - if min_min and min_max: - return_value = min(min_min, min_max) - else: - return_value = minimum - else: - return_value = minimum - return return_value - - @property - def max_temp(self): - """Return the maximum temperature.""" - maximum = 35 # Default maximum - max_min = self.wink.max_min_set_point() - max_max = self.wink.max_max_set_point() - if self.current_operation == STATE_HEAT: - if max_min: - return_value = max_min - else: - return_value = maximum - elif self.current_operation == STATE_COOL: - if max_max: - return_value = max_max - else: - return_value = maximum - elif self.current_operation == STATE_AUTO: - if max_min and max_max: - return_value = min(max_min, max_max) - else: - return_value = maximum - else: - return_value = maximum - return return_value - - -class WinkAC(WinkDevice, ClimateDevice): - """Representation of a Wink air conditioner.""" - - @property - def supported_features(self): - """Return the list of supported features.""" - return SUPPORT_FLAGS_AC - - @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 = {} - 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] = 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] = 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 - - @property - def current_temperature(self): - """Return the current temperature.""" - return self.wink.current_temperature() - - @property - def current_operation(self): - """Return current operation ie. auto_eco, cool_only, fan_only.""" - if not self.wink.is_on(): - current_op = STATE_OFF - else: - 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 - def operation_list(self): - """List of available operation modes.""" - op_list = ['off'] - modes = self.wink.modes() - 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): - """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) - 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 current_fan_mode(self): - """ - 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.33: - return SPEED_LOW - if speed <= 0.66: - return SPEED_MEDIUM - 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, 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/zhong_hong.py b/homeassistant/components/climate/zhong_hong.py deleted file mode 100644 index 46d590a94..000000000 --- a/homeassistant/components/climate/zhong_hong.py +++ /dev/null @@ -1,217 +0,0 @@ -""" -Support for ZhongHong HVAC Controller. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/climate.zhong_hong/ -""" -import logging - -import voluptuous as vol - -from homeassistant.components.climate import ( - ATTR_OPERATION_MODE, PLATFORM_SCHEMA, STATE_COOL, STATE_DRY, - STATE_FAN_ONLY, STATE_HEAT, SUPPORT_FAN_MODE, SUPPORT_ON_OFF, - SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE, ClimateDevice) -from homeassistant.const import (ATTR_TEMPERATURE, CONF_HOST, CONF_PORT, - EVENT_HOMEASSISTANT_STOP, TEMP_CELSIUS) -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import (async_dispatcher_connect, - async_dispatcher_send) - -_LOGGER = logging.getLogger(__name__) - -CONF_GATEWAY_ADDRRESS = 'gateway_address' - -REQUIREMENTS = ['zhong_hong_hvac==1.0.9'] -SIGNAL_DEVICE_ADDED = 'zhong_hong_device_added' -SIGNAL_ZHONG_HONG_HUB_START = 'zhong_hong_hub_start' - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_HOST): - cv.string, - vol.Optional(CONF_PORT, default=9999): - vol.Coerce(int), - vol.Optional(CONF_GATEWAY_ADDRRESS, default=1): - vol.Coerce(int), -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the ZhongHong HVAC platform.""" - from zhong_hong_hvac.hub import ZhongHongGateway - host = config.get(CONF_HOST) - port = config.get(CONF_PORT) - gw_addr = config.get(CONF_GATEWAY_ADDRRESS) - hub = ZhongHongGateway(host, port, gw_addr) - devices = [ - ZhongHongClimate(hub, addr_out, addr_in) - for (addr_out, addr_in) in hub.discovery_ac() - ] - - _LOGGER.debug("We got %s zhong_hong climate devices", len(devices)) - - hub_is_initialized = False - - async def startup(): - """Start hub socket after all climate entity is setted up.""" - nonlocal hub_is_initialized - if not all([device.is_initialized for device in devices]): - return - - if hub_is_initialized: - return - - _LOGGER.debug("zhong_hong hub start listen event") - await hass.async_add_job(hub.start_listen) - await hass.async_add_job(hub.query_all_status) - hub_is_initialized = True - - async_dispatcher_connect(hass, SIGNAL_DEVICE_ADDED, startup) - - # add devices after SIGNAL_DEVICE_SETTED_UP event is listend - add_entities(devices) - - def stop_listen(event): - """Stop ZhongHongHub socket.""" - hub.stop_listen() - - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_listen) - - -class ZhongHongClimate(ClimateDevice): - """Representation of a ZhongHong controller support HVAC.""" - - def __init__(self, hub, addr_out, addr_in): - """Set up the ZhongHong climate devices.""" - from zhong_hong_hvac.hvac import HVAC - self._device = HVAC(hub, addr_out, addr_in) - self._hub = hub - self._current_operation = None - self._current_temperature = None - self._target_temperature = None - self._current_fan_mode = None - self._is_on = None - self.is_initialized = False - - async def async_added_to_hass(self): - """Register callbacks.""" - self._device.register_update_callback(self._after_update) - self.is_initialized = True - async_dispatcher_send(self.hass, SIGNAL_DEVICE_ADDED) - - def _after_update(self, climate): - """Handle state update.""" - _LOGGER.debug("async update ha state") - if self._device.current_operation: - self._current_operation = self._device.current_operation.lower() - if self._device.current_temperature: - self._current_temperature = self._device.current_temperature - if self._device.current_fan_mode: - self._current_fan_mode = self._device.current_fan_mode - if self._device.target_temperature: - self._target_temperature = self._device.target_temperature - self._is_on = self._device.is_on - self.schedule_update_ha_state() - - @property - def should_poll(self): - """Return the polling state.""" - return False - - @property - def name(self): - """Return the name of the thermostat, if any.""" - return self.unique_id - - @property - def unique_id(self): - """Return the unique ID of the HVAC.""" - return "zhong_hong_hvac_{}_{}".format(self._device.addr_out, - self._device.addr_in) - - @property - def supported_features(self): - """Return the list of supported features.""" - return (SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE - | SUPPORT_OPERATION_MODE | SUPPORT_ON_OFF) - - @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 ie. heat, cool, idle.""" - return self._current_operation - - @property - def operation_list(self): - """Return the list of available operation modes.""" - return [STATE_COOL, STATE_HEAT, STATE_DRY, STATE_FAN_ONLY] - - @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 target_temperature_step(self): - """Return the supported step of target temperature.""" - return 1 - - @property - def is_on(self): - """Return true if on.""" - return self._device.is_on - - @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._device.fan_list - - @property - def min_temp(self): - """Return the minimum temperature.""" - return self._device.min_temp - - @property - def max_temp(self): - """Return the maximum temperature.""" - return self._device.max_temp - - def turn_on(self): - """Turn on ac.""" - return self._device.turn_on() - - def turn_off(self): - """Turn off ac.""" - return self._device.turn_off() - - def set_temperature(self, **kwargs): - """Set new target temperature.""" - temperature = kwargs.get(ATTR_TEMPERATURE) - if temperature is not None: - self._device.set_temperature(temperature) - - operation_mode = kwargs.get(ATTR_OPERATION_MODE) - if operation_mode is not None: - self.set_operation_mode(operation_mode) - - def set_operation_mode(self, operation_mode): - """Set new target operation mode.""" - self._device.set_operation_mode(operation_mode.upper()) - - def set_fan_mode(self, fan_mode): - """Set new target fan mode.""" - self._device.set_fan_mode(fan_mode) diff --git a/homeassistant/components/climate/zwave.py b/homeassistant/components/climate/zwave.py deleted file mode 100644 index 77b5e1116..000000000 --- a/homeassistant/components/climate/zwave.py +++ /dev/null @@ -1,247 +0,0 @@ -""" -Support for Z-Wave climate devices. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/climate.zwave/ -""" -# Because we do not compile openzwave on CI -import logging -from homeassistant.components.climate import ( - DOMAIN, ClimateDevice, STATE_AUTO, STATE_COOL, STATE_HEAT, - SUPPORT_TARGET_TEMPERATURE, SUPPORT_FAN_MODE, - SUPPORT_OPERATION_MODE, SUPPORT_SWING_MODE) -from homeassistant.components.zwave import ( # noqa pylint: disable=unused-import - ZWaveDeviceEntity, async_setup_platform) -from homeassistant.const import ( - STATE_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_TEMPERATURE) - -_LOGGER = logging.getLogger(__name__) - -CONF_NAME = 'name' -DEFAULT_NAME = 'Z-Wave Climate' - -REMOTEC = 0x5254 -REMOTEC_ZXT_120 = 0x8377 -REMOTEC_ZXT_120_THERMOSTAT = (REMOTEC, REMOTEC_ZXT_120) -ATTR_OPERATING_STATE = 'operating_state' -ATTR_FAN_STATE = 'fan_state' - -WORKAROUND_ZXT_120 = 'zxt_120' - -DEVICE_MAPPINGS = { - REMOTEC_ZXT_120_THERMOSTAT: WORKAROUND_ZXT_120 -} - -STATE_MAPPINGS = { - 'Off': STATE_OFF, - 'Heat': STATE_HEAT, - 'Heat Mode': STATE_HEAT, - 'Heat (Default)': STATE_HEAT, - 'Cool': STATE_COOL, - 'Auto': STATE_AUTO, -} - - -def get_device(hass, values, **kwargs): - """Create Z-Wave entity device.""" - temp_unit = hass.config.units.temperature_unit - return ZWaveClimate(values, temp_unit) - - -class ZWaveClimate(ZWaveDeviceEntity, ClimateDevice): - """Representation of a Z-Wave Climate device.""" - - def __init__(self, values, temp_unit): - """Initialize the Z-Wave climate device.""" - ZWaveDeviceEntity.__init__(self, values, DOMAIN) - self._target_temperature = None - self._current_temperature = None - self._current_operation = None - self._operation_list = None - self._operation_mapping = None - self._operating_state = None - self._current_fan_mode = None - self._fan_list = None - self._fan_state = None - self._current_swing_mode = None - self._swing_list = None - self._unit = temp_unit - _LOGGER.debug("temp_unit is %s", self._unit) - self._zxt_120 = None - # Make sure that we have values for the key before converting to int - if (self.node.manufacturer_id.strip() and - self.node.product_id.strip()): - specific_sensor_key = ( - int(self.node.manufacturer_id, 16), - int(self.node.product_id, 16)) - if specific_sensor_key in DEVICE_MAPPINGS: - if DEVICE_MAPPINGS[specific_sensor_key] == WORKAROUND_ZXT_120: - _LOGGER.debug( - "Remotec ZXT-120 Zwave Thermostat workaround") - 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 - if self.values.mode: - self._operation_list = [] - self._operation_mapping = {} - operation_list = self.values.mode.data_items - if operation_list: - for mode in operation_list: - ha_mode = STATE_MAPPINGS.get(mode) - if ha_mode and ha_mode not in self._operation_mapping: - self._operation_mapping[ha_mode] = mode - self._operation_list.append(ha_mode) - continue - self._operation_list.append(mode) - current_mode = self.values.mode.data - self._current_operation = next( - (key for key, value in self._operation_mapping.items() - if value == current_mode), current_mode) - _LOGGER.debug("self._operation_list=%s", self._operation_list) - _LOGGER.debug("self._current_operation=%s", self._current_operation) - - # Current Temp - if self.values.temperature: - self._current_temperature = self.values.temperature.data - device_unit = self.values.temperature.units - if device_unit is not None: - self._unit = device_unit - - # Fan Mode - if self.values.fan_mode: - self._current_fan_mode = self.values.fan_mode.data - fan_list = self.values.fan_mode.data_items - if fan_list: - self._fan_list = list(fan_list) - _LOGGER.debug("self._fan_list=%s", self._fan_list) - _LOGGER.debug("self._current_fan_mode=%s", - self._current_fan_mode) - # Swing mode - if self._zxt_120 == 1: - if self.values.zxt_120_swing_mode: - self._current_swing_mode = self.values.zxt_120_swing_mode.data - swing_list = self.values.zxt_120_swing_mode.data_items - if swing_list: - self._swing_list = list(swing_list) - _LOGGER.debug("self._swing_list=%s", self._swing_list) - _LOGGER.debug("self._current_swing_mode=%s", - self._current_swing_mode) - # Set point - if self.values.primary.data == 0: - _LOGGER.debug("Setpoint is 0, setting default to " - "current_temperature=%s", - self._current_temperature) - if self._current_temperature is not None: - self._target_temperature = ( - round((float(self._current_temperature)), 1)) - else: - self._target_temperature = round( - (float(self.values.primary.data)), 1) - - # Operating state - if self.values.operating_state: - self._operating_state = self.values.operating_state.data - - # Fan operating state - if self.values.fan_state: - self._fan_state = self.values.fan_state.data - - @property - def current_fan_mode(self): - """Return the fan speed set.""" - return self._current_fan_mode - - @property - def fan_list(self): - """Return a list of available fan modes.""" - return self._fan_list - - @property - def current_swing_mode(self): - """Return the swing mode set.""" - return self._current_swing_mode - - @property - def swing_list(self): - """Return a list of available swing modes.""" - return self._swing_list - - @property - def temperature_unit(self): - """Return the unit of measurement.""" - if self._unit == 'C': - return TEMP_CELSIUS - if self._unit == 'F': - return TEMP_FAHRENHEIT - return self._unit - - @property - def current_temperature(self): - """Return the current temperature.""" - return self._current_temperature - - @property - def current_operation(self): - """Return the current operation mode.""" - return self._current_operation - - @property - def operation_list(self): - """Return a list of available operation modes.""" - return self._operation_list - - @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: - temperature = kwargs.get(ATTR_TEMPERATURE) - else: - return - - self.values.primary.data = temperature - - def set_fan_mode(self, fan_mode): - """Set new target fan mode.""" - if self.values.fan_mode: - self.values.fan_mode.data = fan_mode - - def set_operation_mode(self, operation_mode): - """Set new target operation mode.""" - if self.values.mode: - self.values.mode.data = self._operation_mapping.get( - operation_mode, operation_mode) - - def set_swing_mode(self, swing_mode): - """Set new target swing mode.""" - if self._zxt_120 == 1: - if self.values.zxt_120_swing_mode: - self.values.zxt_120_swing_mode.data = swing_mode - - @property - def device_state_attributes(self): - """Return the device specific state attributes.""" - data = super().device_state_attributes - if self._operating_state: - data[ATTR_OPERATING_STATE] = self._operating_state - if self._fan_state: - data[ATTR_FAN_STATE] = self._fan_state - return data diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 8c1a9751c..6d9b70051 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -1,309 +1,250 @@ -""" -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 +"""Component to integrate the Home Assistant cloud.""" import logging -import os -import aiohttp -import async_timeout +from hass_nabucasa import Cloud import voluptuous as vol -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.alexa import const as alexa_const from homeassistant.components.google_assistant import const as ga_c +from homeassistant.const import ( + CONF_MODE, + CONF_NAME, + CONF_REGION, + EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP, +) +from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv, entityfilter +from homeassistant.loader import bind_hass +from homeassistant.util.aiohttp import MockRequest -from . import http_api, iot -from .const import CONFIG_DIR, DOMAIN, SERVERS - -REQUIREMENTS = ['warrant==0.6.1'] +from . import account_link, http_api +from .client import CloudClient +from .const import ( + CONF_ACCOUNT_LINK_URL, + CONF_ACME_DIRECTORY_SERVER, + CONF_ALEXA, + CONF_ALEXA_ACCESS_TOKEN_URL, + CONF_ALIASES, + CONF_CLOUDHOOK_CREATE_URL, + CONF_COGNITO_CLIENT_ID, + CONF_ENTITY_CONFIG, + CONF_FILTER, + CONF_GOOGLE_ACTIONS, + CONF_GOOGLE_ACTIONS_REPORT_STATE_URL, + CONF_GOOGLE_ACTIONS_SYNC_URL, + CONF_RELAYER, + CONF_REMOTE_API_URL, + CONF_SUBSCRIPTION_INFO_URL, + CONF_USER_POOL_ID, + CONF_VOICE_API_URL, + DOMAIN, + MODE_DEV, + MODE_PROD, +) +from .prefs import CloudPreferences _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 = MODE_PROD -DEFAULT_MODE = 'production' -DEPENDENCIES = ['http'] - -MODE_DEV = 'development' - -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] + 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) +SERVICE_REMOTE_CONNECT = "remote_connect" +SERVICE_REMOTE_DISCONNECT = "remote_disconnect" -@asyncio.coroutine -def async_setup(hass, config): +ALEXA_ENTITY_SCHEMA = vol.Schema( + { + vol.Optional(alexa_const.CONF_DESCRIPTION): cv.string, + vol.Optional(alexa_const.CONF_DISPLAY_CATEGORIES): cv.string, + vol.Optional(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=dict): 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}} +) + +# pylint: disable=no-value-for-parameter +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Optional(CONF_MODE, default=DEFAULT_MODE): vol.In( + [MODE_DEV, MODE_PROD] + ), + 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): vol.Url(), + vol.Optional(CONF_SUBSCRIPTION_INFO_URL): vol.Url(), + vol.Optional(CONF_CLOUDHOOK_CREATE_URL): vol.Url(), + vol.Optional(CONF_REMOTE_API_URL): vol.Url(), + vol.Optional(CONF_ACME_DIRECTORY_SERVER): vol.Url(), + vol.Optional(CONF_ALEXA): ALEXA_SCHEMA, + vol.Optional(CONF_GOOGLE_ACTIONS): GACTIONS_SCHEMA, + vol.Optional(CONF_ALEXA_ACCESS_TOKEN_URL): vol.Url(), + vol.Optional(CONF_GOOGLE_ACTIONS_REPORT_STATE_URL): vol.Url(), + vol.Optional(CONF_ACCOUNT_LINK_URL): vol.Url(), + vol.Optional(CONF_VOICE_API_URL): vol.Url(), + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +class CloudNotAvailable(HomeAssistantError): + """Raised when an action requires the cloud but it's not available.""" + + +@bind_hass +@callback +def async_is_logged_in(hass) -> bool: + """Test if user is logged in.""" + return DOMAIN in hass.data and hass.data[DOMAIN].is_logged_in + + +@bind_hass +@callback +def async_active_subscription(hass) -> bool: + """Test if user has an active subscription.""" + return async_is_logged_in(hass) and not hass.data[DOMAIN].subscription_expired + + +@bind_hass +async def async_create_cloudhook(hass, webhook_id: str) -> str: + """Create a cloudhook.""" + if not async_is_logged_in(hass): + raise CloudNotAvailable + + hook = await hass.data[DOMAIN].cloudhooks.async_create(webhook_id, True) + return hook["cloudhook_url"] + + +@bind_hass +async def async_delete_cloudhook(hass, webhook_id: str) -> None: + """Delete a cloudhook.""" + if DOMAIN not in hass.data: + raise CloudNotAvailable + + await hass.data[DOMAIN].cloudhooks.async_delete(webhook_id) + + +@bind_hass +@callback +def async_remote_ui_url(hass) -> str: + """Get the remote UI URL.""" + if not async_is_logged_in(hass): + raise CloudNotAvailable + + if not hass.data[DOMAIN].remote.instance_domain: + raise CloudNotAvailable + + return "https://" + hass.data[DOMAIN].remote.instance_domain + + +def is_cloudhook_request(request): + """Test if a request came from a cloudhook. + + Async friendly. + """ + return isinstance(request, MockRequest) + + +async def async_setup(hass, config): """Initialize the Home Assistant cloud.""" + # Process configs if DOMAIN in config: kwargs = dict(config[DOMAIN]) else: kwargs = {CONF_MODE: DEFAULT_MODE} + # Alexa/Google custom config alexa_conf = kwargs.pop(CONF_ALEXA, None) or ALEXA_SCHEMA({}) + google_conf = kwargs.pop(CONF_GOOGLE_ACTIONS, None) or GACTIONS_SCHEMA({}) - if CONF_GOOGLE_ACTIONS not in kwargs: - kwargs[CONF_GOOGLE_ACTIONS] = GACTIONS_SCHEMA({}) + # Cloud settings + prefs = CloudPreferences(hass) + await prefs.async_initialize() - kwargs[CONF_ALEXA] = alexa_sh.Config( - should_expose=alexa_conf[CONF_FILTER], - entity_config=alexa_conf.get(CONF_ENTITY_CONFIG), + # Initialize Cloud + websession = hass.helpers.aiohttp_client.async_get_clientsession() + client = CloudClient(hass, prefs, websession, alexa_conf, google_conf) + cloud = hass.data[DOMAIN] = Cloud(client, **kwargs) + + async def _startup(event): + """Startup event.""" + await cloud.start() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _startup) + + async def _shutdown(event): + """Shutdown event.""" + await cloud.stop() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown) + + async def _service_handler(service): + """Handle service for cloud.""" + if service.service == SERVICE_REMOTE_CONNECT: + await cloud.remote.connect() + await prefs.async_update(remote_enabled=True) + elif service.service == SERVICE_REMOTE_DISCONNECT: + await cloud.remote.disconnect() + await prefs.async_update(remote_enabled=False) + + hass.helpers.service.async_register_admin_service( + DOMAIN, SERVICE_REMOTE_CONNECT, _service_handler + ) + hass.helpers.service.async_register_admin_service( + DOMAIN, SERVICE_REMOTE_DISCONNECT, _service_handler ) - 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 + loaded = False + async def _on_connect(): + """Discover RemoteUI binary sensor.""" + nonlocal loaded -class Cloud: - """Store the configuration of the cloud connection.""" + # Prevent multiple discovery + if loaded: + return + loaded = True - 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), + hass.async_create_task( + hass.helpers.discovery.async_load_platform( + "binary_sensor", DOMAIN, {}, config ) + ) + hass.async_create_task( + hass.helpers.discovery.async_load_platform("stt", DOMAIN, {}, config) + ) + hass.async_create_task( + hass.helpers.discovery.async_load_platform("tts", DOMAIN, {}, config) + ) - return self._gactions_config + cloud.iot.register_on_connect(_on_connect) - def path(self, *parts): - """Get config path inside cloud dir. + await http_api.async_setup(hass) - Async friendly. - """ - return self.hass.config.path(CONFIG_DIR, *parts) + account_link.async_setup(hass) - @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 + return True diff --git a/homeassistant/components/cloud/account_link.py b/homeassistant/components/cloud/account_link.py new file mode 100644 index 000000000..1d0de2691 --- /dev/null +++ b/homeassistant/components/cloud/account_link.py @@ -0,0 +1,144 @@ +"""Account linking via the cloud.""" +import asyncio +import logging +from typing import Any + +from hass_nabucasa import account_link + +from homeassistant.const import MAJOR_VERSION, MINOR_VERSION, PATCH_VERSION +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_entry_oauth2_flow, event + +from .const import DOMAIN + +DATA_SERVICES = "cloud_account_link_services" +CACHE_TIMEOUT = 3600 +_LOGGER = logging.getLogger(__name__) + + +@callback +def async_setup(hass: HomeAssistant): + """Set up cloud account link.""" + config_entry_oauth2_flow.async_add_implementation_provider( + hass, DOMAIN, async_provide_implementation + ) + + +async def async_provide_implementation(hass: HomeAssistant, domain: str): + """Provide an implementation for a domain.""" + services = await _get_services(hass) + + for service in services: + if service["service"] == domain and _is_older(service["min_version"]): + return CloudOAuth2Implementation(hass, domain) + + return + + +@callback +def _is_older(version: str) -> bool: + """Test if a version is older than the current HA version.""" + version_parts = version.split(".") + + if len(version_parts) != 3: + return False + + try: + version_parts = [int(val) for val in version_parts] + except ValueError: + return False + + patch_number_str = "" + + for char in PATCH_VERSION: + if char.isnumeric(): + patch_number_str += char + else: + break + + try: + patch_number = int(patch_number_str) + except ValueError: + patch_number = 0 + + cur_version_parts = [MAJOR_VERSION, MINOR_VERSION, patch_number] + + return version_parts <= cur_version_parts + + +async def _get_services(hass): + """Get the available services.""" + services = hass.data.get(DATA_SERVICES) + + if services is not None: + return services + + services = await account_link.async_fetch_available_services(hass.data[DOMAIN]) + + hass.data[DATA_SERVICES] = services + + @callback + def clear_services(_now): + """Clear services cache.""" + hass.data.pop(DATA_SERVICES, None) + + event.async_call_later(hass, CACHE_TIMEOUT, clear_services) + + return services + + +class CloudOAuth2Implementation(config_entry_oauth2_flow.AbstractOAuth2Implementation): + """Cloud implementation of the OAuth2 flow.""" + + def __init__(self, hass: HomeAssistant, service: str): + """Initialize cloud OAuth2 implementation.""" + self.hass = hass + self.service = service + + @property + def name(self) -> str: + """Name of the implementation.""" + return "Home Assistant Cloud" + + @property + def domain(self) -> str: + """Domain that is providing the implementation.""" + return DOMAIN + + async def async_generate_authorize_url(self, flow_id: str) -> str: + """Generate a url for the user to authorize.""" + helper = account_link.AuthorizeAccountHelper( + self.hass.data[DOMAIN], self.service + ) + authorize_url = await helper.async_get_authorize_url() + + async def await_tokens(): + """Wait for tokens and pass them on when received.""" + try: + tokens = await helper.async_get_tokens() + + except asyncio.TimeoutError: + _LOGGER.info("Timeout fetching tokens for flow %s", flow_id) + except account_link.AccountLinkException as err: + _LOGGER.info( + "Failed to fetch tokens for flow %s: %s", flow_id, err.code + ) + else: + await self.hass.config_entries.flow.async_configure( + flow_id=flow_id, user_input=tokens + ) + + self.hass.async_create_task(await_tokens()) + + return authorize_url + + async def async_resolve_external_data(self, external_data: Any) -> dict: + """Resolve external data to tokens.""" + # We already passed in tokens + return external_data + + async def _async_refresh_token(self, token: dict) -> dict: + """Refresh a token.""" + return await account_link.async_fetch_access_token( + self.hass.data[DOMAIN], self.service, token["refresh_token"] + ) diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py new file mode 100644 index 000000000..45e1fab11 --- /dev/null +++ b/homeassistant/components/cloud/alexa_config.py @@ -0,0 +1,281 @@ +"""Alexa configuration for Home Assistant Cloud.""" +import asyncio +from datetime import timedelta +import logging + +import aiohttp +import async_timeout +from hass_nabucasa import cloud_api + +from homeassistant.components.alexa import ( + config as alexa_config, + entities as alexa_entities, + errors as alexa_errors, + state_report as alexa_state_report, +) +from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES +from homeassistant.core import callback +from homeassistant.helpers import entity_registry +from homeassistant.helpers.event import async_call_later +from homeassistant.util.dt import utcnow + +from .const import ( + CONF_ENTITY_CONFIG, + CONF_FILTER, + DEFAULT_SHOULD_EXPOSE, + PREF_SHOULD_EXPOSE, + RequireRelink, +) + +_LOGGER = logging.getLogger(__name__) + +# Time to wait when entity preferences have changed before syncing it to +# the cloud. +SYNC_DELAY = 1 + + +class AlexaConfig(alexa_config.AbstractConfig): + """Alexa Configuration.""" + + def __init__(self, hass, config, prefs, cloud): + """Initialize the Alexa config.""" + super().__init__(hass) + self._config = config + self._prefs = prefs + self._cloud = cloud + self._token = None + self._token_valid = None + self._cur_entity_prefs = prefs.alexa_entity_configs + self._alexa_sync_unsub = None + self._endpoint = None + + prefs.async_listen_updates(self._async_prefs_updated) + hass.bus.async_listen( + entity_registry.EVENT_ENTITY_REGISTRY_UPDATED, + self._handle_entity_registry_updated, + ) + + @property + def enabled(self): + """Return if Alexa is enabled.""" + return self._prefs.alexa_enabled + + @property + def supports_auth(self): + """Return if config supports auth.""" + return True + + @property + def should_report_state(self): + """Return if states should be proactively reported.""" + return self._prefs.alexa_report_state + + @property + def endpoint(self): + """Endpoint for report state.""" + if self._endpoint is None: + raise ValueError("No endpoint available. Fetch access token first") + + return self._endpoint + + @property + def entity_config(self): + """Return entity config.""" + return self._config.get(CONF_ENTITY_CONFIG) or {} + + def should_expose(self, entity_id): + """If an entity should be exposed.""" + if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + return False + + if not self._config[CONF_FILTER].empty_filter: + return self._config[CONF_FILTER](entity_id) + + entity_configs = self._prefs.alexa_entity_configs + entity_config = entity_configs.get(entity_id, {}) + return entity_config.get(PREF_SHOULD_EXPOSE, DEFAULT_SHOULD_EXPOSE) + + @callback + def async_invalidate_access_token(self): + """Invalidate access token.""" + self._token_valid = None + + async def async_get_access_token(self): + """Get an access token.""" + if self._token_valid is not None and self._token_valid > utcnow(): + return self._token + + resp = await cloud_api.async_alexa_access_token(self._cloud) + body = await resp.json() + + if resp.status == 400: + if body["reason"] in ("RefreshTokenNotFound", "UnknownRegion"): + if self.should_report_state: + await self._prefs.async_update(alexa_report_state=False) + self.hass.components.persistent_notification.async_create( + "There was an error reporting state to Alexa ({}). " + "Please re-link your Alexa skill via the Alexa app to " + "continue using it.".format(body["reason"]), + "Alexa state reporting disabled", + "cloud_alexa_report", + ) + raise RequireRelink + + raise alexa_errors.NoTokenAvailable + + self._token = body["access_token"] + self._endpoint = body["event_endpoint"] + self._token_valid = utcnow() + timedelta(seconds=body["expires_in"]) + return self._token + + async def _async_prefs_updated(self, prefs): + """Handle updated preferences.""" + if self.should_report_state != self.is_reporting_states: + if self.should_report_state: + await self.async_enable_proactive_mode() + else: + await self.async_disable_proactive_mode() + + # State reporting is reported as a property on entities. + # So when we change it, we need to sync all entities. + await self.async_sync_entities() + return + + # If entity prefs are the same or we have filter in config.yaml, + # don't sync. + if ( + self._cur_entity_prefs is prefs.alexa_entity_configs + or not self._config[CONF_FILTER].empty_filter + ): + return + + if self._alexa_sync_unsub: + self._alexa_sync_unsub() + + self._alexa_sync_unsub = async_call_later( + self.hass, SYNC_DELAY, self._sync_prefs + ) + + async def _sync_prefs(self, _now): + """Sync the updated preferences to Alexa.""" + self._alexa_sync_unsub = None + old_prefs = self._cur_entity_prefs + new_prefs = self._prefs.alexa_entity_configs + + seen = set() + to_update = [] + to_remove = [] + + for entity_id, info in old_prefs.items(): + seen.add(entity_id) + old_expose = info.get(PREF_SHOULD_EXPOSE) + + if entity_id in new_prefs: + new_expose = new_prefs[entity_id].get(PREF_SHOULD_EXPOSE) + else: + new_expose = None + + if old_expose == new_expose: + continue + + if new_expose: + to_update.append(entity_id) + else: + to_remove.append(entity_id) + + # Now all the ones that are in new prefs but never were in old prefs + for entity_id, info in new_prefs.items(): + if entity_id in seen: + continue + + new_expose = info.get(PREF_SHOULD_EXPOSE) + + if new_expose is None: + continue + + # Only test if we should expose. It can never be a remove action, + # as it didn't exist in old prefs object. + if new_expose: + to_update.append(entity_id) + + # We only set the prefs when update is successful, that way we will + # retry when next change comes in. + if await self._sync_helper(to_update, to_remove): + self._cur_entity_prefs = new_prefs + + async def async_sync_entities(self): + """Sync all entities to Alexa.""" + # Remove any pending sync + if self._alexa_sync_unsub: + self._alexa_sync_unsub() + self._alexa_sync_unsub = None + + to_update = [] + to_remove = [] + + for entity in alexa_entities.async_get_entities(self.hass, self): + if self.should_expose(entity.entity_id): + to_update.append(entity.entity_id) + else: + to_remove.append(entity.entity_id) + + return await self._sync_helper(to_update, to_remove) + + async def _sync_helper(self, to_update, to_remove) -> bool: + """Sync entities to Alexa. + + Return boolean if it was successful. + """ + if not to_update and not to_remove: + return True + + # Make sure it's valid. + await self.async_get_access_token() + + tasks = [] + + if to_update: + tasks.append( + alexa_state_report.async_send_add_or_update_message( + self.hass, self, to_update + ) + ) + + if to_remove: + tasks.append( + alexa_state_report.async_send_delete_message(self.hass, self, to_remove) + ) + + try: + with async_timeout.timeout(10): + await asyncio.wait(tasks, return_when=asyncio.ALL_COMPLETED) + + return True + + except asyncio.TimeoutError: + _LOGGER.warning("Timeout trying to sync entitites to Alexa") + return False + + except aiohttp.ClientError as err: + _LOGGER.warning("Error trying to sync entities to Alexa: %s", err) + return False + + async def _handle_entity_registry_updated(self, event): + """Handle when entity registry updated.""" + if not self.enabled or not self._cloud.is_logged_in: + return + + action = event.data["action"] + entity_id = event.data["entity_id"] + to_update = [] + to_remove = [] + + if action == "create" and self.should_expose(entity_id): + to_update.append(entity_id) + elif action == "remove" and self.should_expose(entity_id): + to_remove.append(entity_id) + + try: + await self._sync_helper(to_update, to_remove) + except alexa_errors.NoTokenAvailable: + pass diff --git a/homeassistant/components/cloud/auth_api.py b/homeassistant/components/cloud/auth_api.py deleted file mode 100644 index dcf756748..000000000 --- a/homeassistant/components/cloud/auth_api.py +++ /dev/null @@ -1,155 +0,0 @@ -"""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/binary_sensor.py b/homeassistant/components/cloud/binary_sensor.py new file mode 100644 index 000000000..056105f80 --- /dev/null +++ b/homeassistant/components/cloud/binary_sensor.py @@ -0,0 +1,75 @@ +"""Support for Home Assistant Cloud binary sensors.""" +import asyncio + +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import DISPATCHER_REMOTE_UPDATE, DOMAIN + +WAIT_UNTIL_CHANGE = 3 + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the cloud binary sensors.""" + if discovery_info is None: + return + cloud = hass.data[DOMAIN] + + async_add_entities([CloudRemoteBinary(cloud)]) + + +class CloudRemoteBinary(BinarySensorDevice): + """Representation of an Cloud Remote UI Connection binary sensor.""" + + def __init__(self, cloud): + """Initialize the binary sensor.""" + self.cloud = cloud + self._unsub_dispatcher = None + + @property + def name(self) -> str: + """Return the name of the binary sensor, if any.""" + return "Remote UI" + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return "cloud-remote-ui-connectivity" + + @property + def is_on(self) -> bool: + """Return true if the binary sensor is on.""" + return self.cloud.remote.is_connected + + @property + def device_class(self) -> str: + """Return the class of this device, from component DEVICE_CLASSES.""" + return "connectivity" + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self.cloud.remote.certificate is not None + + @property + def should_poll(self) -> bool: + """Return True if entity has to be polled for state.""" + return False + + async def async_added_to_hass(self): + """Register update dispatcher.""" + + async def async_state_update(data): + """Update callback.""" + await asyncio.sleep(WAIT_UNTIL_CHANGE) + self.async_schedule_update_ha_state() + + self._unsub_dispatcher = async_dispatcher_connect( + self.hass, DISPATCHER_REMOTE_UPDATE, async_state_update + ) + + async def async_will_remove_from_hass(self): + """Register update dispatcher.""" + if self._unsub_dispatcher is not None: + self._unsub_dispatcher() + self._unsub_dispatcher = None diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py new file mode 100644 index 000000000..24947ed79 --- /dev/null +++ b/homeassistant/components/cloud/client.py @@ -0,0 +1,201 @@ +"""Interface implementation for cloud client.""" +import asyncio +import logging +from pathlib import Path +from typing import Any, Dict + +import aiohttp +from hass_nabucasa.client import CloudClient as Interface + +from homeassistant.components.alexa import ( + errors as alexa_errors, + smart_home as alexa_sh, +) +from homeassistant.components.google_assistant import smart_home as ga +from homeassistant.core import Context, callback +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.util.aiohttp import MockRequest + +from . import alexa_config, google_config, utils +from .const import DISPATCHER_REMOTE_UPDATE +from .prefs import CloudPreferences + +_LOGGER = logging.getLogger(__name__) + + +class CloudClient(Interface): + """Interface class for Home Assistant Cloud.""" + + def __init__( + self, + hass: HomeAssistantType, + prefs: CloudPreferences, + websession: aiohttp.ClientSession, + alexa_user_config: Dict[str, Any], + google_user_config: Dict[str, Any], + ): + """Initialize client interface to Cloud.""" + self._hass = hass + self._prefs = prefs + self._websession = websession + self.google_user_config = google_user_config + self.alexa_user_config = alexa_user_config + self._alexa_config = None + self._google_config = None + + @property + def base_path(self) -> Path: + """Return path to base dir.""" + return Path(self._hass.config.config_dir) + + @property + def prefs(self) -> CloudPreferences: + """Return Cloud preferences.""" + return self._prefs + + @property + def loop(self) -> asyncio.BaseEventLoop: + """Return client loop.""" + return self._hass.loop + + @property + def websession(self) -> aiohttp.ClientSession: + """Return client session for aiohttp.""" + return self._websession + + @property + def aiohttp_runner(self) -> aiohttp.web.AppRunner: + """Return client webinterface aiohttp application.""" + return self._hass.http.runner + + @property + def cloudhooks(self) -> Dict[str, Dict[str, str]]: + """Return list of cloudhooks.""" + return self._prefs.cloudhooks + + @property + def remote_autostart(self) -> bool: + """Return true if we want start a remote connection.""" + return self._prefs.remote_enabled + + @property + def alexa_config(self) -> alexa_config.AlexaConfig: + """Return Alexa config.""" + if self._alexa_config is None: + assert self.cloud is not None + self._alexa_config = alexa_config.AlexaConfig( + self._hass, self.alexa_user_config, self._prefs, self.cloud + ) + + return self._alexa_config + + async def get_google_config(self) -> google_config.CloudGoogleConfig: + """Return Google config.""" + if not self._google_config: + assert self.cloud is not None + + cloud_user = await self._prefs.get_cloud_user() + + self._google_config = google_config.CloudGoogleConfig( + self._hass, self.google_user_config, cloud_user, self._prefs, self.cloud + ) + await self._google_config.async_initialize() + + return self._google_config + + async def logged_in(self) -> None: + """When user logs in.""" + await self.prefs.async_set_username(self.cloud.username) + + if self.alexa_config.enabled and self.alexa_config.should_report_state: + try: + await self.alexa_config.async_enable_proactive_mode() + except alexa_errors.NoTokenAvailable: + pass + + if self._prefs.google_enabled: + gconf = await self.get_google_config() + + gconf.async_enable_local_sdk() + + if gconf.should_report_state: + gconf.async_enable_report_state() + + async def cleanups(self) -> None: + """Cleanup some stuff after logout.""" + await self.prefs.async_set_username(None) + + self._google_config = None + + @callback + def user_message(self, identifier: str, title: str, message: str) -> None: + """Create a message for user to UI.""" + self._hass.components.persistent_notification.async_create( + message, title, identifier + ) + + @callback + def dispatcher_message(self, identifier: str, data: Any = None) -> None: + """Match cloud notification to dispatcher.""" + if identifier.startswith("remote_"): + async_dispatcher_send(self._hass, DISPATCHER_REMOTE_UPDATE, data) + + async def async_alexa_message(self, payload: Dict[Any, Any]) -> Dict[Any, Any]: + """Process cloud alexa message to client.""" + cloud_user = await self._prefs.get_cloud_user() + return await alexa_sh.async_handle_message( + self._hass, + self.alexa_config, + payload, + context=Context(user_id=cloud_user), + enabled=self._prefs.alexa_enabled, + ) + + async def async_google_message(self, payload: Dict[Any, Any]) -> Dict[Any, Any]: + """Process cloud google message to client.""" + if not self._prefs.google_enabled: + return ga.turned_off_response(payload) + + gconf = await self.get_google_config() + + return await ga.async_handle_message( + self._hass, gconf, gconf.cloud_user, payload + ) + + async def async_webhook_message(self, payload: Dict[Any, Any]) -> Dict[Any, Any]: + """Process cloud webhook message to client.""" + cloudhook_id = payload["cloudhook_id"] + + found = None + for cloudhook in self._prefs.cloudhooks.values(): + if cloudhook["cloudhook_id"] == cloudhook_id: + found = cloudhook + break + + if found is None: + return {"status": 200} + + request = MockRequest( + content=payload["body"].encode("utf-8"), + headers=payload["headers"], + method=payload["method"], + query_string=payload["query"], + ) + + response = await self._hass.components.webhook.async_handle_webhook( + found["webhook_id"], request + ) + + response_dict = utils.aiohttp_serialize_response(response) + body = response_dict.get("body") + + return { + "body": body, + "status": response_dict["status"], + "headers": {"Content-Type": response.content_type}, + } + + async def async_cloudhooks_update(self, data: Dict[str, Dict[str, str]]) -> None: + """Update local list of cloudhooks.""" + await self._prefs.async_update(cloudhooks=data) diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 82128206d..406263c85 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -1,26 +1,59 @@ """Constants for the cloud component.""" -DOMAIN = 'cloud' -CONFIG_DIR = '.cloud' +DOMAIN = "cloud" REQUEST_TIMEOUT = 10 -SERVERS = { - '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'), - } -} +PREF_ENABLE_ALEXA = "alexa_enabled" +PREF_ENABLE_GOOGLE = "google_enabled" +PREF_ENABLE_REMOTE = "remote_enabled" +PREF_GOOGLE_SECURE_DEVICES_PIN = "google_secure_devices_pin" +PREF_CLOUDHOOKS = "cloudhooks" +PREF_CLOUD_USER = "cloud_user" +PREF_GOOGLE_ENTITY_CONFIGS = "google_entity_configs" +PREF_GOOGLE_REPORT_STATE = "google_report_state" +PREF_ALEXA_ENTITY_CONFIGS = "alexa_entity_configs" +PREF_ALEXA_REPORT_STATE = "alexa_report_state" +PREF_OVERRIDE_NAME = "override_name" +PREF_DISABLE_2FA = "disable_2fa" +PREF_ALIASES = "aliases" +PREF_SHOULD_EXPOSE = "should_expose" +PREF_GOOGLE_LOCAL_WEBHOOK_ID = "google_local_webhook_id" +PREF_USERNAME = "username" +DEFAULT_SHOULD_EXPOSE = True +DEFAULT_DISABLE_2FA = False +DEFAULT_ALEXA_REPORT_STATE = False +DEFAULT_GOOGLE_REPORT_STATE = False -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. -""" +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" +CONF_SUBSCRIPTION_INFO_URL = "subscription_info_url" +CONF_CLOUDHOOK_CREATE_URL = "cloudhook_create_url" +CONF_REMOTE_API_URL = "remote_api_url" +CONF_ACME_DIRECTORY_SERVER = "acme_directory_server" +CONF_ALEXA_ACCESS_TOKEN_URL = "alexa_access_token_url" +CONF_GOOGLE_ACTIONS_REPORT_STATE_URL = "google_actions_report_state_url" +CONF_ACCOUNT_LINK_URL = "account_link_url" +CONF_VOICE_API_URL = "voice_api_url" -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. -""" +MODE_DEV = "development" +MODE_PROD = "production" + +DISPATCHER_REMOTE_UPDATE = "cloud_remote_update" + + +class InvalidTrustedNetworks(Exception): + """Raised when invalid trusted networks config.""" + + +class InvalidTrustedProxies(Exception): + """Raised when invalid trusted proxies config.""" + + +class RequireRelink(Exception): + """The skill needs to be relinked.""" diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py new file mode 100644 index 000000000..1ff87bf95 --- /dev/null +++ b/homeassistant/components/cloud/google_config.py @@ -0,0 +1,164 @@ +"""Google config for Cloud.""" +import asyncio +import logging + +import async_timeout +from hass_nabucasa.google_report_state import ErrorResponse + +from homeassistant.components.google_assistant.helpers import AbstractConfig +from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES +from homeassistant.helpers import entity_registry + +from .const import ( + CONF_ENTITY_CONFIG, + DEFAULT_DISABLE_2FA, + DEFAULT_SHOULD_EXPOSE, + PREF_DISABLE_2FA, + PREF_SHOULD_EXPOSE, +) + +_LOGGER = logging.getLogger(__name__) + + +class CloudGoogleConfig(AbstractConfig): + """HA Cloud Configuration for Google Assistant.""" + + def __init__(self, hass, config, cloud_user, prefs, cloud): + """Initialize the Google config.""" + super().__init__(hass) + self._config = config + self._user = cloud_user + self._prefs = prefs + self._cloud = cloud + self._cur_entity_prefs = self._prefs.google_entity_configs + self._sync_entities_lock = asyncio.Lock() + + prefs.async_listen_updates(self._async_prefs_updated) + hass.bus.async_listen( + entity_registry.EVENT_ENTITY_REGISTRY_UPDATED, + self._handle_entity_registry_updated, + ) + + @property + def enabled(self): + """Return if Google is enabled.""" + return self._cloud.is_logged_in and self._prefs.google_enabled + + @property + def entity_config(self): + """Return entity config.""" + return self._config.get(CONF_ENTITY_CONFIG) or {} + + @property + def secure_devices_pin(self): + """Return entity config.""" + return self._prefs.google_secure_devices_pin + + @property + def should_report_state(self): + """Return if states should be proactively reported.""" + return self._cloud.is_logged_in and self._prefs.google_report_state + + @property + def local_sdk_webhook_id(self): + """Return the local SDK webhook. + + Return None to disable the local SDK. + """ + return self._prefs.google_local_webhook_id + + @property + def local_sdk_user_id(self): + """Return the user ID to be used for actions received via the local SDK.""" + return self._user + + @property + def cloud_user(self): + """Return Cloud User account.""" + return self._user + + def should_expose(self, state): + """If a state object should be exposed.""" + return self._should_expose_entity_id(state.entity_id) + + def _should_expose_entity_id(self, entity_id): + """If an entity ID should be exposed.""" + if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + return False + + if not self._config["filter"].empty_filter: + return self._config["filter"](entity_id) + + entity_configs = self._prefs.google_entity_configs + entity_config = entity_configs.get(entity_id, {}) + return entity_config.get(PREF_SHOULD_EXPOSE, DEFAULT_SHOULD_EXPOSE) + + def should_2fa(self, state): + """If an entity should be checked for 2FA.""" + entity_configs = self._prefs.google_entity_configs + entity_config = entity_configs.get(state.entity_id, {}) + return not entity_config.get(PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA) + + async def async_report_state(self, message, agent_user_id: str): + """Send a state report to Google.""" + try: + await self._cloud.google_report_state.async_send_message(message) + except ErrorResponse as err: + _LOGGER.warning("Error reporting state - %s: %s", err.code, err.message) + + async def _async_request_sync_devices(self, agent_user_id: str): + """Trigger a sync with Google.""" + if self._sync_entities_lock.locked(): + return 200 + + websession = self.hass.helpers.aiohttp_client.async_get_clientsession() + + async with self._sync_entities_lock: + with async_timeout.timeout(10): + await self._cloud.auth.async_check_token() + + _LOGGER.debug("Requesting sync") + + with async_timeout.timeout(30): + req = await websession.post( + self._cloud.google_actions_sync_url, + headers={"authorization": self._cloud.id_token}, + ) + _LOGGER.debug("Finished requesting syncing: %s", req.status) + return req.status + + async def _async_prefs_updated(self, prefs): + """Handle updated preferences.""" + if self.should_report_state != self.is_reporting_state: + if self.should_report_state: + self.async_enable_report_state() + else: + self.async_disable_report_state() + + # State reporting is reported as a property on entities. + # So when we change it, we need to sync all entities. + await self.async_sync_entities_all() + + # If entity prefs are the same or we have filter in config.yaml, + # don't sync. + elif ( + self._cur_entity_prefs is not prefs.google_entity_configs + and self._config["filter"].empty_filter + ): + self.async_schedule_google_sync_all() + + if self.enabled and not self.is_local_sdk_active: + self.async_enable_local_sdk() + elif not self.enabled and self.is_local_sdk_active: + self.async_disable_local_sdk() + + async def _handle_entity_registry_updated(self, event): + """Handle when entity registry updated.""" + if not self.enabled or not self._cloud.is_logged_in: + return + + entity_id = event.data["entity_id"] + + # Schedule a sync if a change was made to an entity that Google knows about + if self._should_expose_entity_id(entity_id): + await self.async_sync_entities_all() diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index a4b3b59f3..c68f24172 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -3,41 +3,125 @@ import asyncio from functools import wraps import logging +import aiohttp import async_timeout +import attr +from hass_nabucasa import Cloud, auth, thingtalk +from hass_nabucasa.const import STATE_DISCONNECTED import voluptuous as vol +from homeassistant.components import websocket_api +from homeassistant.components.alexa import ( + entities as alexa_entities, + errors as alexa_errors, +) +from homeassistant.components.google_assistant import helpers as google_helpers from homeassistant.components.http import HomeAssistantView -from homeassistant.components.http.data_validator import ( - RequestDataValidator) +from homeassistant.components.http.data_validator import RequestDataValidator +from homeassistant.components.websocket_api import const as ws_const +from homeassistant.core import callback -from . import auth_api -from .const import DOMAIN, REQUEST_TIMEOUT +from .const import ( + DOMAIN, + PREF_ALEXA_REPORT_STATE, + PREF_ENABLE_ALEXA, + PREF_ENABLE_GOOGLE, + PREF_GOOGLE_REPORT_STATE, + PREF_GOOGLE_SECURE_DEVICES_PIN, + REQUEST_TIMEOUT, + InvalidTrustedNetworks, + InvalidTrustedProxies, + RequireRelink, +) _LOGGER = logging.getLogger(__name__) +WS_TYPE_STATUS = "cloud/status" +SCHEMA_WS_STATUS = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( + {vol.Required("type"): WS_TYPE_STATUS} +) + + +WS_TYPE_SUBSCRIPTION = "cloud/subscription" +SCHEMA_WS_SUBSCRIPTION = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( + {vol.Required("type"): WS_TYPE_SUBSCRIPTION} +) + + +WS_TYPE_HOOK_CREATE = "cloud/cloudhook/create" +SCHEMA_WS_HOOK_CREATE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( + {vol.Required("type"): WS_TYPE_HOOK_CREATE, vol.Required("webhook_id"): str} +) + + +WS_TYPE_HOOK_DELETE = "cloud/cloudhook/delete" +SCHEMA_WS_HOOK_DELETE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( + {vol.Required("type"): WS_TYPE_HOOK_DELETE, vol.Required("webhook_id"): str} +) + + +_CLOUD_ERRORS = { + InvalidTrustedNetworks: ( + 500, + "Remote UI not compatible with 127.0.0.1/::1 as a trusted network.", + ), + InvalidTrustedProxies: ( + 500, + "Remote UI not compatible with 127.0.0.1/::1 as trusted proxies.", + ), +} + + async def async_setup(hass): """Initialize the HTTP API.""" + async_register_command = hass.components.websocket_api.async_register_command + async_register_command(WS_TYPE_STATUS, websocket_cloud_status, SCHEMA_WS_STATUS) + async_register_command( + WS_TYPE_SUBSCRIPTION, websocket_subscription, SCHEMA_WS_SUBSCRIPTION + ) + async_register_command(websocket_update_prefs) + async_register_command( + WS_TYPE_HOOK_CREATE, websocket_hook_create, SCHEMA_WS_HOOK_CREATE + ) + async_register_command( + WS_TYPE_HOOK_DELETE, websocket_hook_delete, SCHEMA_WS_HOOK_DELETE + ) + async_register_command(websocket_remote_connect) + async_register_command(websocket_remote_disconnect) + + async_register_command(google_assistant_list) + async_register_command(google_assistant_update) + + async_register_command(alexa_list) + async_register_command(alexa_update) + async_register_command(alexa_sync) + + async_register_command(thingtalk_convert) + 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) - -_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.') -} + _CLOUD_ERRORS.update( + { + auth.UserNotFound: (400, "User does not exist."), + auth.UserNotConfirmed: (400, "Email not confirmed."), + auth.UserExists: (400, "An account with the given email already exists."), + auth.Unauthenticated: (401, "Authentication failed."), + auth.PasswordChangeRequired: (400, "Password change required."), + asyncio.TimeoutError: (502, "Unable to reach the Home Assistant cloud."), + aiohttp.ClientError: (500, "Error making internal request"), + } + ) def _handle_cloud_errors(handler): - """Handle auth errors.""" + """Webview decorator to handle auth errors.""" + @wraps(handler) async def error_handler(view, request, *args, **kwargs): """Handle exceptions that raise from the wrapped request handler.""" @@ -45,176 +129,480 @@ def _handle_cloud_errors(handler): 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__) + except Exception as err: # pylint: disable=broad-except + status, msg = _process_cloud_exception(err, request.path) + return view.json_message( + msg, status_code=status, message_code=err.__class__.__name__.lower() + ) return error_handler +def _ws_handle_cloud_errors(handler): + """Websocket decorator to handle auth errors.""" + + @wraps(handler) + async def error_handler(hass, connection, msg): + """Handle exceptions that raise from the wrapped handler.""" + try: + return await handler(hass, connection, msg) + + except Exception as err: # pylint: disable=broad-except + err_status, err_msg = _process_cloud_exception(err, msg["type"]) + connection.send_error(msg["id"], err_status, err_msg) + + return error_handler + + +def _process_cloud_exception(exc, where): + """Process a cloud exception.""" + err_info = _CLOUD_ERRORS.get(exc.__class__) + if err_info is None: + _LOGGER.exception("Unexpected error processing request for %s", where) + err_info = (502, f"Unexpected error: {exc}") + return err_info + + class GoogleActionsSyncView(HomeAssistantView): """Trigger a Google Actions Smart Home Sync.""" - url = '/api/cloud/google_actions/sync' - name = 'api:cloud:google_actions/sync' + url = "/api/cloud/google_actions/sync" + name = "api:cloud:google_actions/sync" @_handle_cloud_errors async def post(self, request): """Trigger a Google Actions sync.""" - hass = request.app['hass'] - cloud = hass.data[DOMAIN] - websession = hass.helpers.aiohttp_client.async_get_clientsession() - - with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): - await hass.async_add_job(auth_api.check_token, cloud) - - with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): - req = await websession.post( - cloud.google_actions_sync_url, headers={ - 'authorization': cloud.id_token - }) - - return self.json({}, status_code=req.status) + hass = request.app["hass"] + cloud: Cloud = hass.data[DOMAIN] + gconf = await cloud.client.get_google_config() + status = await gconf.async_sync_entities(gconf.cloud_user) + return self.json({}, status_code=status) class CloudLoginView(HomeAssistantView): """Login to Home Assistant cloud.""" - url = '/api/cloud/login' - name = 'api:cloud:login' + url = "/api/cloud/login" + name = "api:cloud:login" @_handle_cloud_errors - @RequestDataValidator(vol.Schema({ - vol.Required('email'): str, - vol.Required('password'): str, - })) + @RequestDataValidator( + vol.Schema({vol.Required("email"): str, vol.Required("password"): str}) + ) async def post(self, request, data): """Handle login request.""" - hass = request.app['hass'] + 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)) + await cloud.login(data["email"], data["password"]) + return self.json({"success": True}) class CloudLogoutView(HomeAssistantView): """Log out of the Home Assistant cloud.""" - url = '/api/cloud/logout' - name = 'api:cloud:logout' + url = "/api/cloud/logout" + name = "api:cloud:logout" @_handle_cloud_errors async def post(self, request): """Handle logout request.""" - hass = request.app['hass'] + hass = request.app["hass"] cloud = hass.data[DOMAIN] - with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + with async_timeout.timeout(REQUEST_TIMEOUT): await cloud.logout() - return self.json_message('ok') - - -class CloudAccountView(HomeAssistantView): - """View to retrieve account info.""" - - url = '/api/cloud/account' - name = 'api:cloud:account' - - async def get(self, request): - """Get account info.""" - hass = request.app['hass'] - cloud = hass.data[DOMAIN] - - if not cloud.is_logged_in: - return self.json_message('Not logged in', 400) - - return self.json(_account_data(cloud)) + return self.json_message("ok") class CloudRegisterView(HomeAssistantView): """Register on the Home Assistant cloud.""" - url = '/api/cloud/register' - name = 'api:cloud:register' + 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)), - })) + @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'] + hass = request.app["hass"] cloud = hass.data[DOMAIN] - with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + with async_timeout.timeout(REQUEST_TIMEOUT): await hass.async_add_job( - auth_api.register, cloud, data['email'], data['password']) + cloud.auth.register, data["email"], data["password"] + ) - return self.json_message('ok') + return self.json_message("ok") class CloudResendConfirmView(HomeAssistantView): """Resend email confirmation code.""" - url = '/api/cloud/resend_confirm' - name = 'api:cloud:resend_confirm' + url = "/api/cloud/resend_confirm" + name = "api:cloud:resend_confirm" @_handle_cloud_errors - @RequestDataValidator(vol.Schema({ - vol.Required('email'): str, - })) + @RequestDataValidator(vol.Schema({vol.Required("email"): str})) async def post(self, request, data): """Handle resending confirm email code request.""" - hass = request.app['hass'] + 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']) + with async_timeout.timeout(REQUEST_TIMEOUT): + await hass.async_add_job(cloud.auth.resend_email_confirm, data["email"]) - return self.json_message('ok') + return self.json_message("ok") class CloudForgotPasswordView(HomeAssistantView): """View to start Forgot Password flow..""" - url = '/api/cloud/forgot_password' - name = 'api:cloud:forgot_password' + url = "/api/cloud/forgot_password" + name = "api:cloud:forgot_password" @_handle_cloud_errors - @RequestDataValidator(vol.Schema({ - vol.Required('email'): str, - })) + @RequestDataValidator(vol.Schema({vol.Required("email"): str})) async def post(self, request, data): """Handle forgot password request.""" - hass = request.app['hass'] + 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']) + with async_timeout.timeout(REQUEST_TIMEOUT): + await hass.async_add_job(cloud.auth.forgot_password, data["email"]) - return self.json_message('ok') + return self.json_message("ok") + + +@callback +def websocket_cloud_status(hass, connection, msg): + """Handle request for account info. + + Async friendly. + """ + cloud = hass.data[DOMAIN] + connection.send_message( + websocket_api.result_message(msg["id"], _account_data(cloud)) + ) + + +def _require_cloud_login(handler): + """Websocket decorator that requires cloud to be logged in.""" + + @wraps(handler) + def with_cloud_auth(hass, connection, msg): + """Require to be logged into the cloud.""" + cloud = hass.data[DOMAIN] + if not cloud.is_logged_in: + connection.send_message( + websocket_api.error_message( + msg["id"], "not_logged_in", "You need to be logged in to the cloud." + ) + ) + return + + handler(hass, connection, msg) + + return with_cloud_auth + + +@_require_cloud_login +@websocket_api.async_response +async def websocket_subscription(hass, connection, msg): + """Handle request for account info.""" + + cloud = hass.data[DOMAIN] + + with async_timeout.timeout(REQUEST_TIMEOUT): + response = await cloud.fetch_subscription_info() + + if response.status != 200: + connection.send_message( + websocket_api.error_message( + msg["id"], "request_failed", "Failed to request subscription" + ) + ) + + data = await response.json() + + # Check if a user is subscribed but local info is outdated + # In that case, let's refresh and reconnect + if data.get("provider") and not cloud.is_connected: + _LOGGER.debug("Found disconnected account with valid subscriotion, connecting") + await hass.async_add_executor_job(cloud.auth.renew_access_token) + + # Cancel reconnect in progress + if cloud.iot.state != STATE_DISCONNECTED: + await cloud.iot.disconnect() + + hass.async_create_task(cloud.iot.connect()) + + connection.send_message(websocket_api.result_message(msg["id"], data)) + + +@_require_cloud_login +@websocket_api.async_response +@websocket_api.websocket_command( + { + vol.Required("type"): "cloud/update_prefs", + vol.Optional(PREF_ENABLE_GOOGLE): bool, + vol.Optional(PREF_ENABLE_ALEXA): bool, + vol.Optional(PREF_ALEXA_REPORT_STATE): bool, + vol.Optional(PREF_GOOGLE_REPORT_STATE): bool, + vol.Optional(PREF_GOOGLE_SECURE_DEVICES_PIN): vol.Any(None, str), + } +) +async def websocket_update_prefs(hass, connection, msg): + """Handle request for account info.""" + cloud = hass.data[DOMAIN] + + changes = dict(msg) + changes.pop("id") + changes.pop("type") + + # If we turn alexa linking on, validate that we can fetch access token + if changes.get(PREF_ALEXA_REPORT_STATE): + try: + with async_timeout.timeout(10): + await cloud.client.alexa_config.async_get_access_token() + except asyncio.TimeoutError: + connection.send_error( + msg["id"], "alexa_timeout", "Timeout validating Alexa access token." + ) + return + except (alexa_errors.NoTokenAvailable, RequireRelink): + connection.send_error( + msg["id"], + "alexa_relink", + "Please go to the Alexa app and re-link the Home Assistant " + "skill and then try to enable state reporting.", + ) + return + + await cloud.client.prefs.async_update(**changes) + + connection.send_message(websocket_api.result_message(msg["id"])) + + +@_require_cloud_login +@websocket_api.async_response +@_ws_handle_cloud_errors +async def websocket_hook_create(hass, connection, msg): + """Handle request for account info.""" + cloud = hass.data[DOMAIN] + hook = await cloud.cloudhooks.async_create(msg["webhook_id"], False) + connection.send_message(websocket_api.result_message(msg["id"], hook)) + + +@_require_cloud_login +@websocket_api.async_response +@_ws_handle_cloud_errors +async def websocket_hook_delete(hass, connection, msg): + """Handle request for account info.""" + cloud = hass.data[DOMAIN] + await cloud.cloudhooks.async_delete(msg["webhook_id"]) + connection.send_message(websocket_api.result_message(msg["id"])) def _account_data(cloud): """Generate the auth data JSON response.""" + + if not cloud.is_logged_in: + return {"logged_in": False, "cloud": STATE_DISCONNECTED} + claims = cloud.claims + client = cloud.client + remote = cloud.remote + + # Load remote certificate + if remote.certificate: + certificate = attr.asdict(remote.certificate) + else: + certificate = None return { - 'email': claims['email'], - 'sub_exp': claims['custom:sub-exp'], - 'cloud': cloud.iot.state, + "logged_in": True, + "email": claims["email"], + "cloud": cloud.iot.state, + "prefs": client.prefs.as_dict(), + "google_entities": client.google_user_config["filter"].config, + "alexa_entities": client.alexa_user_config["filter"].config, + "remote_domain": remote.instance_domain, + "remote_connected": remote.is_connected, + "remote_certificate": certificate, } + + +@websocket_api.require_admin +@_require_cloud_login +@websocket_api.async_response +@_ws_handle_cloud_errors +@websocket_api.websocket_command({"type": "cloud/remote/connect"}) +async def websocket_remote_connect(hass, connection, msg): + """Handle request for connect remote.""" + cloud = hass.data[DOMAIN] + await cloud.client.prefs.async_update(remote_enabled=True) + await cloud.remote.connect() + connection.send_result(msg["id"], _account_data(cloud)) + + +@websocket_api.require_admin +@_require_cloud_login +@websocket_api.async_response +@_ws_handle_cloud_errors +@websocket_api.websocket_command({"type": "cloud/remote/disconnect"}) +async def websocket_remote_disconnect(hass, connection, msg): + """Handle request for disconnect remote.""" + cloud = hass.data[DOMAIN] + await cloud.client.prefs.async_update(remote_enabled=False) + await cloud.remote.disconnect() + connection.send_result(msg["id"], _account_data(cloud)) + + +@websocket_api.require_admin +@_require_cloud_login +@websocket_api.async_response +@_ws_handle_cloud_errors +@websocket_api.websocket_command({"type": "cloud/google_assistant/entities"}) +async def google_assistant_list(hass, connection, msg): + """List all google assistant entities.""" + cloud = hass.data[DOMAIN] + gconf = await cloud.client.get_google_config() + entities = google_helpers.async_get_entities(hass, gconf) + + result = [] + + for entity in entities: + result.append( + { + "entity_id": entity.entity_id, + "traits": [trait.name for trait in entity.traits()], + "might_2fa": entity.might_2fa(), + } + ) + + connection.send_result(msg["id"], result) + + +@websocket_api.require_admin +@_require_cloud_login +@websocket_api.async_response +@_ws_handle_cloud_errors +@websocket_api.websocket_command( + { + "type": "cloud/google_assistant/entities/update", + "entity_id": str, + vol.Optional("should_expose"): bool, + vol.Optional("override_name"): str, + vol.Optional("aliases"): [str], + vol.Optional("disable_2fa"): bool, + } +) +async def google_assistant_update(hass, connection, msg): + """Update google assistant config.""" + cloud = hass.data[DOMAIN] + changes = dict(msg) + changes.pop("type") + changes.pop("id") + + await cloud.client.prefs.async_update_google_entity_config(**changes) + + connection.send_result( + msg["id"], cloud.client.prefs.google_entity_configs.get(msg["entity_id"]) + ) + + +@websocket_api.require_admin +@_require_cloud_login +@websocket_api.async_response +@_ws_handle_cloud_errors +@websocket_api.websocket_command({"type": "cloud/alexa/entities"}) +async def alexa_list(hass, connection, msg): + """List all alexa entities.""" + cloud = hass.data[DOMAIN] + entities = alexa_entities.async_get_entities(hass, cloud.client.alexa_config) + + result = [] + + for entity in entities: + result.append( + { + "entity_id": entity.entity_id, + "display_categories": entity.default_display_categories(), + "interfaces": [ifc.name() for ifc in entity.interfaces()], + } + ) + + connection.send_result(msg["id"], result) + + +@websocket_api.require_admin +@_require_cloud_login +@websocket_api.async_response +@_ws_handle_cloud_errors +@websocket_api.websocket_command( + { + "type": "cloud/alexa/entities/update", + "entity_id": str, + vol.Optional("should_expose"): bool, + } +) +async def alexa_update(hass, connection, msg): + """Update alexa entity config.""" + cloud = hass.data[DOMAIN] + changes = dict(msg) + changes.pop("type") + changes.pop("id") + + await cloud.client.prefs.async_update_alexa_entity_config(**changes) + + connection.send_result( + msg["id"], cloud.client.prefs.alexa_entity_configs.get(msg["entity_id"]) + ) + + +@websocket_api.require_admin +@_require_cloud_login +@websocket_api.async_response +@websocket_api.websocket_command({"type": "cloud/alexa/sync"}) +async def alexa_sync(hass, connection, msg): + """Sync with Alexa.""" + cloud = hass.data[DOMAIN] + + with async_timeout.timeout(10): + try: + success = await cloud.client.alexa_config.async_sync_entities() + except alexa_errors.NoTokenAvailable: + connection.send_error( + msg["id"], + "alexa_relink", + "Please go to the Alexa app and re-link the Home Assistant " "skill.", + ) + return + + if success: + connection.send_result(msg["id"]) + else: + connection.send_error(msg["id"], ws_const.ERR_UNKNOWN_ERROR, "Unknown error") + + +@websocket_api.async_response +@websocket_api.websocket_command({"type": "cloud/thingtalk/convert", "query": str}) +async def thingtalk_convert(hass, connection, msg): + """Convert a query.""" + cloud = hass.data[DOMAIN] + + with async_timeout.timeout(10): + try: + connection.send_result( + msg["id"], await thingtalk.async_convert(cloud, msg["query"]) + ) + except thingtalk.ThingTalkConversionError as err: + connection.send_error(msg["id"], ws_const.ERR_UNKNOWN_ERROR, str(err)) diff --git a/homeassistant/components/cloud/iot.py b/homeassistant/components/cloud/iot.py deleted file mode 100644 index f4ce7bb3d..000000000 --- a/homeassistant/components/cloud/iot.py +++ /dev/null @@ -1,255 +0,0 @@ -"""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.status == 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) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json new file mode 100644 index 000000000..accc4a0c0 --- /dev/null +++ b/homeassistant/components/cloud/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "cloud", + "name": "Cloud", + "documentation": "https://www.home-assistant.io/integrations/cloud", + "requirements": ["hass-nabucasa==0.30"], + "dependencies": ["http", "webhook"], + "after_dependencies": ["alexa", "google_assistant"], + "codeowners": ["@home-assistant/cloud"] +} diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py new file mode 100644 index 000000000..a7d1b59fd --- /dev/null +++ b/homeassistant/components/cloud/prefs.py @@ -0,0 +1,335 @@ +"""Preference management for cloud.""" +from ipaddress import ip_address +from typing import Optional + +from homeassistant.auth.const import GROUP_ID_ADMIN +from homeassistant.auth.models import User +from homeassistant.core import callback +from homeassistant.util.logging import async_create_catching_coro + +from .const import ( + DEFAULT_ALEXA_REPORT_STATE, + DEFAULT_GOOGLE_REPORT_STATE, + DOMAIN, + PREF_ALEXA_ENTITY_CONFIGS, + PREF_ALEXA_REPORT_STATE, + PREF_ALIASES, + PREF_CLOUD_USER, + PREF_CLOUDHOOKS, + PREF_DISABLE_2FA, + PREF_ENABLE_ALEXA, + PREF_ENABLE_GOOGLE, + PREF_ENABLE_REMOTE, + PREF_GOOGLE_ENTITY_CONFIGS, + PREF_GOOGLE_LOCAL_WEBHOOK_ID, + PREF_GOOGLE_REPORT_STATE, + PREF_GOOGLE_SECURE_DEVICES_PIN, + PREF_OVERRIDE_NAME, + PREF_SHOULD_EXPOSE, + PREF_USERNAME, + InvalidTrustedNetworks, + InvalidTrustedProxies, +) + +STORAGE_KEY = DOMAIN +STORAGE_VERSION = 1 +_UNDEF = object() + + +class CloudPreferences: + """Handle cloud preferences.""" + + def __init__(self, hass): + """Initialize cloud prefs.""" + self._hass = hass + self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + self._prefs = None + self._listeners = [] + + async def async_initialize(self): + """Finish initializing the preferences.""" + prefs = await self._store.async_load() + + if prefs is None: + prefs = self._empty_config("") + + self._prefs = prefs + + if PREF_GOOGLE_LOCAL_WEBHOOK_ID not in self._prefs: + await self._save_prefs( + { + **self._prefs, + PREF_GOOGLE_LOCAL_WEBHOOK_ID: self._hass.components.webhook.async_generate_id(), + } + ) + + @callback + def async_listen_updates(self, listener): + """Listen for updates to the preferences.""" + self._listeners.append(listener) + + async def async_update( + self, + *, + google_enabled=_UNDEF, + alexa_enabled=_UNDEF, + remote_enabled=_UNDEF, + google_secure_devices_pin=_UNDEF, + cloudhooks=_UNDEF, + cloud_user=_UNDEF, + google_entity_configs=_UNDEF, + alexa_entity_configs=_UNDEF, + alexa_report_state=_UNDEF, + google_report_state=_UNDEF, + ): + """Update user preferences.""" + prefs = {**self._prefs} + + for key, value in ( + (PREF_ENABLE_GOOGLE, google_enabled), + (PREF_ENABLE_ALEXA, alexa_enabled), + (PREF_ENABLE_REMOTE, remote_enabled), + (PREF_GOOGLE_SECURE_DEVICES_PIN, google_secure_devices_pin), + (PREF_CLOUDHOOKS, cloudhooks), + (PREF_CLOUD_USER, cloud_user), + (PREF_GOOGLE_ENTITY_CONFIGS, google_entity_configs), + (PREF_ALEXA_ENTITY_CONFIGS, alexa_entity_configs), + (PREF_ALEXA_REPORT_STATE, alexa_report_state), + (PREF_GOOGLE_REPORT_STATE, google_report_state), + ): + if value is not _UNDEF: + prefs[key] = value + + if remote_enabled is True and self._has_local_trusted_network: + prefs[PREF_ENABLE_REMOTE] = False + raise InvalidTrustedNetworks + + if remote_enabled is True and self._has_local_trusted_proxies: + prefs[PREF_ENABLE_REMOTE] = False + raise InvalidTrustedProxies + + await self._save_prefs(prefs) + + async def async_update_google_entity_config( + self, + *, + entity_id, + override_name=_UNDEF, + disable_2fa=_UNDEF, + aliases=_UNDEF, + should_expose=_UNDEF, + ): + """Update config for a Google entity.""" + entities = self.google_entity_configs + entity = entities.get(entity_id, {}) + + changes = {} + for key, value in ( + (PREF_OVERRIDE_NAME, override_name), + (PREF_DISABLE_2FA, disable_2fa), + (PREF_ALIASES, aliases), + (PREF_SHOULD_EXPOSE, should_expose), + ): + if value is not _UNDEF: + changes[key] = value + + if not changes: + return + + updated_entity = {**entity, **changes} + + updated_entities = {**entities, entity_id: updated_entity} + await self.async_update(google_entity_configs=updated_entities) + + async def async_update_alexa_entity_config( + self, *, entity_id, should_expose=_UNDEF + ): + """Update config for an Alexa entity.""" + entities = self.alexa_entity_configs + entity = entities.get(entity_id, {}) + + changes = {} + for key, value in ((PREF_SHOULD_EXPOSE, should_expose),): + if value is not _UNDEF: + changes[key] = value + + if not changes: + return + + updated_entity = {**entity, **changes} + + updated_entities = {**entities, entity_id: updated_entity} + await self.async_update(alexa_entity_configs=updated_entities) + + async def async_set_username(self, username): + """Set the username that is logged in.""" + # Logging out. + if username is None: + user = await self._load_cloud_user() + + if user is not None: + await self._hass.auth.async_remove_user(user) + await self._save_prefs({**self._prefs, PREF_CLOUD_USER: None}) + return + + cur_username = self._prefs.get(PREF_USERNAME) + + if cur_username == username: + return + + if cur_username is None: + await self._save_prefs({**self._prefs, PREF_USERNAME: username}) + else: + await self._save_prefs(self._empty_config(username)) + + def as_dict(self): + """Return dictionary version.""" + return { + PREF_ENABLE_ALEXA: self.alexa_enabled, + PREF_ENABLE_GOOGLE: self.google_enabled, + PREF_ENABLE_REMOTE: self.remote_enabled, + PREF_GOOGLE_SECURE_DEVICES_PIN: self.google_secure_devices_pin, + PREF_GOOGLE_ENTITY_CONFIGS: self.google_entity_configs, + PREF_ALEXA_ENTITY_CONFIGS: self.alexa_entity_configs, + PREF_ALEXA_REPORT_STATE: self.alexa_report_state, + PREF_GOOGLE_REPORT_STATE: self.google_report_state, + PREF_CLOUDHOOKS: self.cloudhooks, + } + + @property + def remote_enabled(self): + """Return if remote is enabled on start.""" + enabled = self._prefs.get(PREF_ENABLE_REMOTE, False) + + if not enabled: + return False + + if self._has_local_trusted_network or self._has_local_trusted_proxies: + return False + + return True + + @property + def alexa_enabled(self): + """Return if Alexa is enabled.""" + return self._prefs[PREF_ENABLE_ALEXA] + + @property + def alexa_report_state(self): + """Return if Alexa report state is enabled.""" + return self._prefs.get(PREF_ALEXA_REPORT_STATE, DEFAULT_ALEXA_REPORT_STATE) + + @property + def google_enabled(self): + """Return if Google is enabled.""" + return self._prefs[PREF_ENABLE_GOOGLE] + + @property + def google_report_state(self): + """Return if Google report state is enabled.""" + return self._prefs.get(PREF_GOOGLE_REPORT_STATE, DEFAULT_GOOGLE_REPORT_STATE) + + @property + def google_secure_devices_pin(self): + """Return if Google is allowed to unlock locks.""" + return self._prefs.get(PREF_GOOGLE_SECURE_DEVICES_PIN) + + @property + def google_entity_configs(self): + """Return Google Entity configurations.""" + return self._prefs.get(PREF_GOOGLE_ENTITY_CONFIGS, {}) + + @property + def google_local_webhook_id(self): + """Return Google webhook ID to receive local messages.""" + return self._prefs[PREF_GOOGLE_LOCAL_WEBHOOK_ID] + + @property + def alexa_entity_configs(self): + """Return Alexa Entity configurations.""" + return self._prefs.get(PREF_ALEXA_ENTITY_CONFIGS, {}) + + @property + def cloudhooks(self): + """Return the published cloud webhooks.""" + return self._prefs.get(PREF_CLOUDHOOKS, {}) + + async def get_cloud_user(self) -> str: + """Return ID from Home Assistant Cloud system user.""" + user = await self._load_cloud_user() + + if user: + return user.id + + user = await self._hass.auth.async_create_system_user( + "Home Assistant Cloud", [GROUP_ID_ADMIN] + ) + await self.async_update(cloud_user=user.id) + return user.id + + async def _load_cloud_user(self) -> Optional[User]: + """Load cloud user if available.""" + user_id = self._prefs.get(PREF_CLOUD_USER) + + if user_id is None: + return None + + # Fetch the user. It can happen that the user no longer exists if + # an image was restored without restoring the cloud prefs. + return await self._hass.auth.async_get_user(user_id) + + @property + def _has_local_trusted_network(self) -> bool: + """Return if we allow localhost to bypass auth.""" + local4 = ip_address("127.0.0.1") + local6 = ip_address("::1") + + for prv in self._hass.auth.auth_providers: + if prv.type != "trusted_networks": + continue + + for network in prv.trusted_networks: + if local4 in network or local6 in network: + return True + + return False + + @property + def _has_local_trusted_proxies(self) -> bool: + """Return if we allow localhost to be a proxy and use its data.""" + if not hasattr(self._hass, "http"): + return False + + local4 = ip_address("127.0.0.1") + local6 = ip_address("::1") + + if any( + local4 in nwk or local6 in nwk for nwk in self._hass.http.trusted_proxies + ): + return True + + return False + + async def _save_prefs(self, prefs): + """Save preferences to disk.""" + self._prefs = prefs + await self._store.async_save(self._prefs) + + for listener in self._listeners: + self._hass.async_create_task(async_create_catching_coro(listener(self))) + + @callback + def _empty_config(self, username): + """Return an empty config.""" + return { + PREF_ENABLE_ALEXA: True, + PREF_ENABLE_GOOGLE: True, + PREF_ENABLE_REMOTE: False, + PREF_GOOGLE_SECURE_DEVICES_PIN: None, + PREF_GOOGLE_ENTITY_CONFIGS: {}, + PREF_ALEXA_ENTITY_CONFIGS: {}, + PREF_CLOUDHOOKS: {}, + PREF_CLOUD_USER: None, + PREF_USERNAME: username, + PREF_GOOGLE_LOCAL_WEBHOOK_ID: self._hass.components.webhook.async_generate_id(), + } diff --git a/homeassistant/components/cloud/services.yaml b/homeassistant/components/cloud/services.yaml new file mode 100644 index 000000000..20c25225c --- /dev/null +++ b/homeassistant/components/cloud/services.yaml @@ -0,0 +1,7 @@ +# Describes the format for available cloud services + +remote_connect: + description: Make instance UI available outside over NabuCasa cloud. + +remote_disconnect: + description: Disconnect UI from NabuCasa cloud. diff --git a/homeassistant/components/cloud/stt.py b/homeassistant/components/cloud/stt.py new file mode 100644 index 000000000..acca36afa --- /dev/null +++ b/homeassistant/components/cloud/stt.py @@ -0,0 +1,106 @@ +"""Support for the cloud for speech to text service.""" +from typing import List + +from aiohttp import StreamReader +from hass_nabucasa import Cloud +from hass_nabucasa.voice import VoiceError + +from homeassistant.components.stt import Provider, SpeechMetadata, SpeechResult +from homeassistant.components.stt.const import ( + AudioBitRates, + AudioChannels, + AudioCodecs, + AudioFormats, + AudioSampleRates, + SpeechResultState, +) + +from .const import DOMAIN + +SUPPORT_LANGUAGES = [ + "da-DK", + "de-DE", + "en-AU", + "en-CA", + "en-GB", + "en-US", + "es-ES", + "fi-FI", + "fr-CA", + "fr-FR", + "it-IT", + "ja-JP", + "nl-NL", + "pl-PL", + "pt-PT", + "ru-RU", + "sv-SE", + "th-TH", + "zh-CN", + "zh-HK", +] + + +async def async_get_engine(hass, config, discovery_info=None): + """Set up Cloud speech component.""" + cloud: Cloud = hass.data[DOMAIN] + + return CloudProvider(cloud) + + +class CloudProvider(Provider): + """NabuCasa speech API provider.""" + + def __init__(self, cloud: Cloud) -> None: + """Hass NabuCasa Speech to text.""" + self.cloud = cloud + + @property + def supported_languages(self) -> List[str]: + """Return a list of supported languages.""" + return SUPPORT_LANGUAGES + + @property + def supported_formats(self) -> List[AudioFormats]: + """Return a list of supported formats.""" + return [AudioFormats.WAV, AudioFormats.OGG] + + @property + def supported_codecs(self) -> List[AudioCodecs]: + """Return a list of supported codecs.""" + return [AudioCodecs.PCM, AudioCodecs.OPUS] + + @property + def supported_bit_rates(self) -> List[AudioBitRates]: + """Return a list of supported bitrates.""" + return [AudioBitRates.BITRATE_16] + + @property + def supported_sample_rates(self) -> List[AudioSampleRates]: + """Return a list of supported samplerates.""" + return [AudioSampleRates.SAMPLERATE_16000] + + @property + def supported_channels(self) -> List[AudioChannels]: + """Return a list of supported channels.""" + return [AudioChannels.CHANNEL_MONO] + + async def async_process_audio_stream( + self, metadata: SpeechMetadata, stream: StreamReader + ) -> SpeechResult: + """Process an audio stream to STT service.""" + content = f"audio/{metadata.format!s}; codecs=audio/{metadata.codec!s}; samplerate=16000" + + # Process STT + try: + result = await self.cloud.voice.process_stt( + stream, content, metadata.language + ) + except VoiceError: + return SpeechResult(None, SpeechResultState.ERROR) + + # Return Speech as Text + return SpeechResult( + result.text, + SpeechResultState.SUCCESS if result.success else SpeechResultState.ERROR, + ) diff --git a/homeassistant/components/cloud/tts.py b/homeassistant/components/cloud/tts.py new file mode 100644 index 000000000..ea769c6a0 --- /dev/null +++ b/homeassistant/components/cloud/tts.py @@ -0,0 +1,81 @@ +"""Support for the cloud for text to speech service.""" + +from hass_nabucasa import Cloud +from hass_nabucasa.voice import VoiceError +import voluptuous as vol + +from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider + +from .const import DOMAIN + +CONF_GENDER = "gender" + +SUPPORT_LANGUAGES = ["en-US", "de-DE", "es-ES"] +SUPPORT_GENDER = ["male", "female"] + +DEFAULT_LANG = "en-US" +DEFAULT_GENDER = "female" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_LANG, default=DEFAULT_LANG): vol.In(SUPPORT_LANGUAGES), + vol.Optional(CONF_GENDER, default=DEFAULT_GENDER): vol.In(SUPPORT_GENDER), + } +) + + +async def async_get_engine(hass, config, discovery_info=None): + """Set up Cloud speech component.""" + cloud: Cloud = hass.data[DOMAIN] + + if discovery_info is not None: + language = DEFAULT_LANG + gender = DEFAULT_GENDER + else: + language = config[CONF_LANG] + gender = config[CONF_GENDER] + + return CloudProvider(cloud, language, gender) + + +class CloudProvider(Provider): + """NabuCasa Cloud speech API provider.""" + + def __init__(self, cloud: Cloud, language: str, gender: str): + """Initialize cloud provider.""" + self.cloud = cloud + self.name = "Cloud" + self._language = language + self._gender = gender + + @property + def default_language(self): + """Return the default language.""" + return self._language + + @property + def supported_languages(self): + """Return list of supported languages.""" + return SUPPORT_LANGUAGES + + @property + def supported_options(self): + """Return list of supported options like voice, emotion.""" + return [CONF_GENDER] + + @property + def default_options(self): + """Return a dict include default options.""" + return {CONF_GENDER: self._gender} + + async def async_get_tts_audio(self, message, language, options=None): + """Load TTS from NabuCasa Cloud.""" + # Process TTS + try: + data = await self.cloud.voice.process_tts( + message, language, gender=options[CONF_GENDER] + ) + except VoiceError: + return (None, None) + + return ("mp3", data) diff --git a/homeassistant/components/cloud/utils.py b/homeassistant/components/cloud/utils.py new file mode 100644 index 000000000..36599b42a --- /dev/null +++ b/homeassistant/components/cloud/utils.py @@ -0,0 +1,21 @@ +"""Helper functions for cloud components.""" +from typing import Any, Dict + +from aiohttp import payload, web + + +def aiohttp_serialize_response(response: web.Response) -> Dict[str, Any]: + """Serialize an aiohttp response to a dictionary.""" + body = response.body + + if body is None: + pass + elif isinstance(body, payload.StringPayload): + # pylint: disable=protected-access + body = body._value.decode(body.encoding) + elif isinstance(body, bytes): + body = body.decode(response.charset or "utf-8") + else: + raise ValueError("Unknown payload encoding") + + return {"status": response.status, "body": body, "headers": dict(response.headers)} diff --git a/homeassistant/components/cloudflare.py b/homeassistant/components/cloudflare.py deleted file mode 100644 index ae400ca63..000000000 --- a/homeassistant/components/cloudflare.py +++ /dev/null @@ -1,77 +0,0 @@ -""" -Update the IP addresses of your Cloudflare DNS records. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/cloudflare/ -""" -from datetime import timedelta -import logging - -import voluptuous as vol - -from homeassistant.const import CONF_API_KEY, CONF_EMAIL, CONF_ZONE -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.event import track_time_interval - -REQUIREMENTS = ['pycfdns==0.0.1'] - -_LOGGER = logging.getLogger(__name__) - -CONF_RECORDS = 'records' - -DOMAIN = 'cloudflare' - -INTERVAL = timedelta(minutes=60) - -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - vol.Required(CONF_EMAIL): cv.string, - vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_ZONE): cv.string, - vol.Required(CONF_RECORDS): vol.All(cv.ensure_list, [cv.string]), - }) -}, extra=vol.ALLOW_EXTRA) - - -def setup(hass, config): - """Set up the Cloudflare component.""" - from pycfdns import CloudflareUpdater - - cfupdate = CloudflareUpdater() - email = config[DOMAIN][CONF_EMAIL] - key = config[DOMAIN][CONF_API_KEY] - zone = config[DOMAIN][CONF_ZONE] - records = config[DOMAIN][CONF_RECORDS] - - def update_records_interval(now): - """Set up recurring update.""" - _update_cloudflare(cfupdate, email, key, zone, records) - - def update_records_service(now): - """Set up service for manual trigger.""" - _update_cloudflare(cfupdate, email, key, zone, records) - - track_time_interval(hass, update_records_interval, INTERVAL) - hass.services.register( - DOMAIN, 'update_records', update_records_service) - return True - - -def _update_cloudflare(cfupdate, email, key, zone, records): - """Update DNS records for a given zone.""" - _LOGGER.debug("Starting update for zone %s", zone) - - headers = cfupdate.set_header(email, key) - _LOGGER.debug("Header data defined as: %s", headers) - - zoneid = cfupdate.get_zoneID(headers, zone) - _LOGGER.debug("Zone ID is set to: %s", zoneid) - - update_records = cfupdate.get_recordInfo(headers, zoneid, zone, records) - _LOGGER.debug("Records: %s", update_records) - - result = cfupdate.update_records(headers, zoneid, update_records) - _LOGGER.debug("Update for zone %s is complete", zone) - - if result is not True: - _LOGGER.warning(result) diff --git a/homeassistant/components/cloudflare/__init__.py b/homeassistant/components/cloudflare/__init__.py new file mode 100644 index 000000000..265621b62 --- /dev/null +++ b/homeassistant/components/cloudflare/__init__.py @@ -0,0 +1,74 @@ +"""Update the IP addresses of your Cloudflare DNS records.""" +from datetime import timedelta +import logging + +from pycfdns import CloudflareUpdater +import voluptuous as vol + +from homeassistant.const import CONF_API_KEY, CONF_EMAIL, CONF_ZONE +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import track_time_interval + +_LOGGER = logging.getLogger(__name__) + +CONF_RECORDS = "records" + +DOMAIN = "cloudflare" + +INTERVAL = timedelta(minutes=60) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_EMAIL): cv.string, + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_ZONE): cv.string, + vol.Required(CONF_RECORDS): vol.All(cv.ensure_list, [cv.string]), + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +def setup(hass, config): + """Set up the Cloudflare component.""" + + cfupdate = CloudflareUpdater() + email = config[DOMAIN][CONF_EMAIL] + key = config[DOMAIN][CONF_API_KEY] + zone = config[DOMAIN][CONF_ZONE] + records = config[DOMAIN][CONF_RECORDS] + + def update_records_interval(now): + """Set up recurring update.""" + _update_cloudflare(cfupdate, email, key, zone, records) + + def update_records_service(now): + """Set up service for manual trigger.""" + _update_cloudflare(cfupdate, email, key, zone, records) + + track_time_interval(hass, update_records_interval, INTERVAL) + hass.services.register(DOMAIN, "update_records", update_records_service) + return True + + +def _update_cloudflare(cfupdate, email, key, zone, records): + """Update DNS records for a given zone.""" + _LOGGER.debug("Starting update for zone %s", zone) + + headers = cfupdate.set_header(email, key) + _LOGGER.debug("Header data defined as: %s", headers) + + zoneid = cfupdate.get_zoneID(headers, zone) + _LOGGER.debug("Zone ID is set to: %s", zoneid) + + update_records = cfupdate.get_recordInfo(headers, zoneid, zone, records) + _LOGGER.debug("Records: %s", update_records) + + result = cfupdate.update_records(headers, zoneid, update_records) + _LOGGER.debug("Update for zone %s is complete", zone) + + if result is not True: + _LOGGER.warning(result) diff --git a/homeassistant/components/cloudflare/manifest.json b/homeassistant/components/cloudflare/manifest.json new file mode 100644 index 000000000..78bc6de99 --- /dev/null +++ b/homeassistant/components/cloudflare/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "cloudflare", + "name": "Cloudflare", + "documentation": "https://www.home-assistant.io/integrations/cloudflare", + "requirements": [ + "pycfdns==0.0.1" + ], + "dependencies": [], + "codeowners": [ + "@ludeeus" + ] +} diff --git a/homeassistant/components/cloudflare/services.yaml b/homeassistant/components/cloudflare/services.yaml new file mode 100644 index 000000000..23ffdd14d --- /dev/null +++ b/homeassistant/components/cloudflare/services.yaml @@ -0,0 +1,2 @@ +update_records: + description: Manually trigger update to Cloudflare records. diff --git a/homeassistant/components/cmus/__init__.py b/homeassistant/components/cmus/__init__.py new file mode 100644 index 000000000..f46f661ec --- /dev/null +++ b/homeassistant/components/cmus/__init__.py @@ -0,0 +1 @@ +"""The cmus component.""" diff --git a/homeassistant/components/cmus/manifest.json b/homeassistant/components/cmus/manifest.json new file mode 100644 index 000000000..fe5b8e155 --- /dev/null +++ b/homeassistant/components/cmus/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "cmus", + "name": "Cmus", + "documentation": "https://www.home-assistant.io/integrations/cmus", + "requirements": [ + "pycmus==0.1.1" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/cmus/media_player.py b/homeassistant/components/cmus/media_player.py new file mode 100644 index 000000000..3daf0bac8 --- /dev/null +++ b/homeassistant/components/cmus/media_player.py @@ -0,0 +1,239 @@ +"""Support for interacting with and controlling the cmus music player.""" +import logging + +from pycmus import exceptions, remote +import voluptuous as vol + +from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerDevice +from homeassistant.components.media_player.const import ( + MEDIA_TYPE_MUSIC, + MEDIA_TYPE_PLAYLIST, + SUPPORT_NEXT_TRACK, + SUPPORT_PAUSE, + SUPPORT_PLAY, + SUPPORT_PLAY_MEDIA, + SUPPORT_PREVIOUS_TRACK, + SUPPORT_SEEK, + SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, + SUPPORT_VOLUME_SET, +) +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + STATE_OFF, + STATE_PAUSED, + STATE_PLAYING, +) +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = "cmus" +DEFAULT_PORT = 3000 + +SUPPORT_CMUS = ( + SUPPORT_PAUSE + | SUPPORT_VOLUME_SET + | SUPPORT_TURN_OFF + | SUPPORT_TURN_ON + | SUPPORT_PREVIOUS_TRACK + | SUPPORT_NEXT_TRACK + | SUPPORT_PLAY_MEDIA + | SUPPORT_SEEK + | SUPPORT_PLAY +) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Inclusive(CONF_HOST, "remote"): cv.string, + vol.Inclusive(CONF_PASSWORD, "remote"): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + } +) + + +def setup_platform(hass, config, add_entities, discover_info=None): + """Set up the CMUS platform.""" + + host = config.get(CONF_HOST) + password = config.get(CONF_PASSWORD) + port = config.get(CONF_PORT) + name = config.get(CONF_NAME) + + try: + cmus_remote = CmusDevice(host, password, port, name) + except exceptions.InvalidPassword: + _LOGGER.error("The provided password was rejected by cmus") + return False + add_entities([cmus_remote], True) + + +class CmusDevice(MediaPlayerDevice): + """Representation of a running cmus.""" + + # pylint: disable=no-member + def __init__(self, server, password, port, name): + """Initialize the CMUS device.""" + + if server: + self.cmus = remote.PyCmus(server=server, password=password, port=port) + auto_name = f"cmus-{server}" + else: + self.cmus = remote.PyCmus() + auto_name = "cmus-local" + self._name = name or auto_name + self.status = {} + + def update(self): + """Get the latest data and update the state.""" + status = self.cmus.get_status_dict() + if not status: + _LOGGER.warning("Received no status from cmus") + else: + self.status = status + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def state(self): + """Return the media state.""" + if self.status.get("status") == "playing": + return STATE_PLAYING + if self.status.get("status") == "paused": + return STATE_PAUSED + return STATE_OFF + + @property + def media_content_id(self): + """Content ID of current playing media.""" + return self.status.get("file") + + @property + def content_type(self): + """Content type of the current playing media.""" + return MEDIA_TYPE_MUSIC + + @property + def media_duration(self): + """Duration of current playing media in seconds.""" + return self.status.get("duration") + + @property + def media_title(self): + """Title of current playing media.""" + return self.status["tag"].get("title") + + @property + def media_artist(self): + """Artist of current playing media, music track only.""" + return self.status["tag"].get("artist") + + @property + def media_track(self): + """Track number of current playing media, music track only.""" + return self.status["tag"].get("tracknumber") + + @property + def media_album_name(self): + """Album name of current playing media, music track only.""" + return self.status["tag"].get("album") + + @property + def media_album_artist(self): + """Album artist of current playing media, music track only.""" + return self.status["tag"].get("albumartist") + + @property + def volume_level(self): + """Return the volume level.""" + left = self.status["set"].get("vol_left")[0] + right = self.status["set"].get("vol_right")[0] + if left != right: + volume = float(left + right) / 2 + else: + volume = left + return int(volume) / 100 + + @property + def supported_features(self): + """Flag media player features that are supported.""" + return SUPPORT_CMUS + + def turn_off(self): + """Service to send the CMUS the command to stop playing.""" + self.cmus.player_stop() + + def turn_on(self): + """Service to send the CMUS the command to start playing.""" + self.cmus.player_play() + + def set_volume_level(self, volume): + """Set volume level, range 0..1.""" + self.cmus.set_volume(int(volume * 100)) + + def volume_up(self): + """Set the volume up.""" + left = self.status["set"].get("vol_left") + right = self.status["set"].get("vol_right") + if left != right: + current_volume = float(left + right) / 2 + else: + current_volume = left + + if current_volume <= 100: + self.cmus.set_volume(int(current_volume) + 5) + + def volume_down(self): + """Set the volume down.""" + left = self.status["set"].get("vol_left") + right = self.status["set"].get("vol_right") + if left != right: + current_volume = float(left + right) / 2 + else: + current_volume = left + + if current_volume <= 100: + self.cmus.set_volume(int(current_volume) - 5) + + def play_media(self, media_type, media_id, **kwargs): + """Send the play command.""" + if media_type in [MEDIA_TYPE_MUSIC, MEDIA_TYPE_PLAYLIST]: + self.cmus.player_play_file(media_id) + else: + _LOGGER.error( + "Invalid media type %s. Only %s and %s are supported", + media_type, + MEDIA_TYPE_MUSIC, + MEDIA_TYPE_PLAYLIST, + ) + + def media_pause(self): + """Send the pause command.""" + self.cmus.player_pause() + + def media_next_track(self): + """Send next track command.""" + self.cmus.player_next() + + def media_previous_track(self): + """Send next track command.""" + self.cmus.player_prev() + + def media_seek(self, position): + """Send seek command.""" + self.cmus.seek(position) + + def media_play(self): + """Send the play command.""" + self.cmus.player_play() + + def media_stop(self): + """Send the stop command.""" + self.cmus.stop() diff --git a/homeassistant/components/co2signal/__init__.py b/homeassistant/components/co2signal/__init__.py new file mode 100644 index 000000000..a9c6422b4 --- /dev/null +++ b/homeassistant/components/co2signal/__init__.py @@ -0,0 +1 @@ +"""The co2signal component.""" diff --git a/homeassistant/components/co2signal/manifest.json b/homeassistant/components/co2signal/manifest.json new file mode 100644 index 000000000..f07813b3d --- /dev/null +++ b/homeassistant/components/co2signal/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "co2signal", + "name": "Co2signal", + "documentation": "https://www.home-assistant.io/integrations/co2signal", + "requirements": [ + "co2signal==0.4.2" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/co2signal/sensor.py b/homeassistant/components/co2signal/sensor.py new file mode 100644 index 000000000..7160d140b --- /dev/null +++ b/homeassistant/components/co2signal/sensor.py @@ -0,0 +1,113 @@ +"""Support for the CO2signal platform.""" +import logging + +import CO2Signal +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + ATTR_ATTRIBUTION, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_TOKEN, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +CONF_COUNTRY_CODE = "country_code" + +_LOGGER = logging.getLogger(__name__) + +ATTRIBUTION = "Data provided by CO2signal" + +MSG_LOCATION = ( + "Please use either coordinates or the country code. " + "For the coordinates, " + "you need to use both latitude and longitude." +) +CO2_INTENSITY_UNIT = "CO2eq/kWh" +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_TOKEN): cv.string, + vol.Inclusive(CONF_LATITUDE, "coords", msg=MSG_LOCATION): cv.latitude, + vol.Inclusive(CONF_LONGITUDE, "coords", msg=MSG_LOCATION): cv.longitude, + vol.Optional(CONF_COUNTRY_CODE): cv.string, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the CO2signal sensor.""" + token = config[CONF_TOKEN] + lat = config.get(CONF_LATITUDE, hass.config.latitude) + lon = config.get(CONF_LONGITUDE, hass.config.longitude) + country_code = config.get(CONF_COUNTRY_CODE) + + _LOGGER.debug("Setting up the sensor using the %s", country_code) + + devs = [] + + devs.append(CO2Sensor(token, country_code, lat, lon)) + add_entities(devs, True) + + +class CO2Sensor(Entity): + """Implementation of the CO2Signal sensor.""" + + def __init__(self, token, country_code, lat, lon): + """Initialize the sensor.""" + self._token = token + self._country_code = country_code + self._latitude = lat + self._longitude = lon + self._data = None + + if country_code is not None: + device_name = country_code + else: + device_name = "{lat}/{lon}".format( + lat=round(self._latitude, 2), lon=round(self._longitude, 2) + ) + + self._friendly_name = f"CO2 intensity - {device_name}" + + @property + def name(self): + """Return the name of the sensor.""" + return self._friendly_name + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return "mdi:periodic-table-co2" + + @property + def state(self): + """Return the state of the device.""" + return self._data + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return CO2_INTENSITY_UNIT + + @property + def device_state_attributes(self): + """Return the state attributes of the last update.""" + return {ATTR_ATTRIBUTION: ATTRIBUTION} + + def update(self): + """Get the latest data and updates the states.""" + + _LOGGER.debug("Update data for %s", self._friendly_name) + + if self._country_code is not None: + self._data = CO2Signal.get_latest_carbon_intensity( + self._token, country_code=self._country_code + ) + else: + self._data = CO2Signal.get_latest_carbon_intensity( + self._token, latitude=self._latitude, longitude=self._longitude + ) + + self._data = round(self._data, 2) diff --git a/homeassistant/components/coinbase.py b/homeassistant/components/coinbase.py deleted file mode 100644 index 154320b4a..000000000 --- a/homeassistant/components/coinbase.py +++ /dev/null @@ -1,90 +0,0 @@ -""" -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: - """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/coinbase/__init__.py b/homeassistant/components/coinbase/__init__.py new file mode 100644 index 000000000..67869e6b8 --- /dev/null +++ b/homeassistant/components/coinbase/__init__.py @@ -0,0 +1,98 @@ +"""Support for Coinbase.""" +from datetime import timedelta +import logging + +from coinbase.wallet.client import Client +from coinbase.wallet.error import AuthenticationError +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 + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "coinbase" + +CONF_API_SECRET = "api_secret" +CONF_ACCOUNT_CURRENCIES = "account_balance_currencies" +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_ACCOUNT_CURRENCIES): vol.All( + cv.ensure_list, [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) + account_currencies = config[DOMAIN].get(CONF_ACCOUNT_CURRENCIES) + 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: + if account_currencies is None or account.currency in account_currencies: + 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: + """Get the latest data and update the states.""" + + def __init__(self, api_key, api_secret): + """Init the coinbase data object.""" + + self.client = Client(api_key, api_secret) + self.update() + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data from coinbase.""" + + 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/coinbase/manifest.json b/homeassistant/components/coinbase/manifest.json new file mode 100644 index 000000000..2da323f08 --- /dev/null +++ b/homeassistant/components/coinbase/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "coinbase", + "name": "Coinbase", + "documentation": "https://www.home-assistant.io/integrations/coinbase", + "requirements": [ + "coinbase==2.1.0" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/coinbase/sensor.py b/homeassistant/components/coinbase/sensor.py new file mode 100644 index 000000000..4a3e85d5e --- /dev/null +++ b/homeassistant/components/coinbase/sensor.py @@ -0,0 +1,133 @@ +"""Support for Coinbase sensors.""" +from homeassistant.const import ATTR_ATTRIBUTION +from homeassistant.helpers.entity import Entity + +ATTR_NATIVE_BALANCE = "Balance in native currency" + +CURRENCY_ICONS = { + "BTC": "mdi:currency-btc", + "ETH": "mdi:currency-eth", + "EUR": "mdi:currency-eur", + "LTC": "mdi:litecoin", + "USD": "mdi:currency-usd", +} + +DEFAULT_COIN_ICON = "mdi:coin" + +ATTRIBUTION = "Data provided by coinbase.com" + +DATA_COINBASE = "coinbase_cache" + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Coinbase sensors.""" + if discovery_info is None: + return + if "account" in discovery_info: + account = discovery_info["account"] + sensor = AccountSensor( + hass.data[DATA_COINBASE], account["name"], account["balance"]["currency"] + ) + if "exchange_currency" in discovery_info: + sensor = ExchangeRateSensor( + hass.data[DATA_COINBASE], + discovery_info["exchange_currency"], + discovery_info["native_currency"], + ) + + add_entities([sensor], True) + + +class AccountSensor(Entity): + """Representation of a Coinbase.com sensor.""" + + def __init__(self, coinbase_data, name, currency): + """Initialize the sensor.""" + self._coinbase_data = coinbase_data + self._name = f"Coinbase {name}" + self._state = None + self._unit_of_measurement = currency + self._native_balance = None + self._native_currency = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement this sensor expresses itself in.""" + return self._unit_of_measurement + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return CURRENCY_ICONS.get(self._unit_of_measurement, DEFAULT_COIN_ICON) + + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + return { + ATTR_ATTRIBUTION: ATTRIBUTION, + ATTR_NATIVE_BALANCE: "{} {}".format( + self._native_balance, self._native_currency + ), + } + + def update(self): + """Get the latest state of the sensor.""" + self._coinbase_data.update() + for account in self._coinbase_data.accounts["data"]: + if self._name == "Coinbase {}".format(account["name"]): + self._state = account["balance"]["amount"] + self._native_balance = account["native_balance"]["amount"] + self._native_currency = account["native_balance"]["currency"] + + +class ExchangeRateSensor(Entity): + """Representation of a Coinbase.com sensor.""" + + def __init__(self, coinbase_data, exchange_currency, native_currency): + """Initialize the sensor.""" + self._coinbase_data = coinbase_data + self.currency = exchange_currency + self._name = f"{exchange_currency} Exchange Rate" + self._state = None + self._unit_of_measurement = native_currency + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement this sensor expresses itself in.""" + return self._unit_of_measurement + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return CURRENCY_ICONS.get(self.currency, DEFAULT_COIN_ICON) + + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + return {ATTR_ATTRIBUTION: ATTRIBUTION} + + def update(self): + """Get the latest state of the sensor.""" + self._coinbase_data.update() + rate = self._coinbase_data.exchange_rates.rates[self.currency] + self._state = round(1 / float(rate), 2) diff --git a/homeassistant/components/coinmarketcap/__init__.py b/homeassistant/components/coinmarketcap/__init__.py new file mode 100644 index 000000000..0cdb5a16a --- /dev/null +++ b/homeassistant/components/coinmarketcap/__init__.py @@ -0,0 +1 @@ +"""The coinmarketcap component.""" diff --git a/homeassistant/components/coinmarketcap/manifest.json b/homeassistant/components/coinmarketcap/manifest.json new file mode 100644 index 000000000..ec9aec6c6 --- /dev/null +++ b/homeassistant/components/coinmarketcap/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "coinmarketcap", + "name": "Coinmarketcap", + "documentation": "https://www.home-assistant.io/integrations/coinmarketcap", + "requirements": [ + "coinmarketcap==5.0.3" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/coinmarketcap/sensor.py b/homeassistant/components/coinmarketcap/sensor.py new file mode 100644 index 000000000..ca166aa79 --- /dev/null +++ b/homeassistant/components/coinmarketcap/sensor.py @@ -0,0 +1,164 @@ +"""Details about crypto currencies from CoinMarketCap.""" +from datetime import timedelta +import logging +from urllib.error import HTTPError + +from coinmarketcap import Market +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ATTR_ATTRIBUTION, CONF_DISPLAY_CURRENCY +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +ATTR_VOLUME_24H = "volume_24h" +ATTR_AVAILABLE_SUPPLY = "available_supply" +ATTR_CIRCULATING_SUPPLY = "circulating_supply" +ATTR_MARKET_CAP = "market_cap" +ATTR_PERCENT_CHANGE_24H = "percent_change_24h" +ATTR_PERCENT_CHANGE_7D = "percent_change_7d" +ATTR_PERCENT_CHANGE_1H = "percent_change_1h" +ATTR_PRICE = "price" +ATTR_RANK = "rank" +ATTR_SYMBOL = "symbol" +ATTR_TOTAL_SUPPLY = "total_supply" + +ATTRIBUTION = "Data provided by CoinMarketCap" + +CONF_CURRENCY_ID = "currency_id" +CONF_DISPLAY_CURRENCY_DECIMALS = "display_currency_decimals" + +DEFAULT_CURRENCY_ID = 1 +DEFAULT_DISPLAY_CURRENCY = "USD" +DEFAULT_DISPLAY_CURRENCY_DECIMALS = 2 + +ICON = "mdi:currency-usd" + +SCAN_INTERVAL = timedelta(minutes=15) + + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_CURRENCY_ID, default=DEFAULT_CURRENCY_ID): cv.positive_int, + vol.Optional( + CONF_DISPLAY_CURRENCY, default=DEFAULT_DISPLAY_CURRENCY + ): cv.string, + vol.Optional( + CONF_DISPLAY_CURRENCY_DECIMALS, default=DEFAULT_DISPLAY_CURRENCY_DECIMALS + ): vol.All(vol.Coerce(int), vol.Range(min=1)), + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the CoinMarketCap sensor.""" + currency_id = config.get(CONF_CURRENCY_ID) + display_currency = config.get(CONF_DISPLAY_CURRENCY).upper() + display_currency_decimals = config.get(CONF_DISPLAY_CURRENCY_DECIMALS) + + try: + CoinMarketCapData(currency_id, display_currency).update() + except HTTPError: + _LOGGER.warning( + "Currency ID %s or display currency %s " + "is not available. Using 1 (bitcoin) " + "and USD.", + currency_id, + display_currency, + ) + currency_id = DEFAULT_CURRENCY_ID + display_currency = DEFAULT_DISPLAY_CURRENCY + + add_entities( + [ + CoinMarketCapSensor( + CoinMarketCapData(currency_id, display_currency), + display_currency_decimals, + ) + ], + True, + ) + + +class CoinMarketCapSensor(Entity): + """Representation of a CoinMarketCap sensor.""" + + def __init__(self, data, display_currency_decimals): + """Initialize the sensor.""" + self.data = data + self.display_currency_decimals = display_currency_decimals + self._ticker = None + self._unit_of_measurement = self.data.display_currency + + @property + def name(self): + """Return the name of the sensor.""" + return self._ticker.get("name") + + @property + def state(self): + """Return the state of the sensor.""" + return round( + float( + self._ticker.get("quotes").get(self.data.display_currency).get("price") + ), + self.display_currency_decimals, + ) + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self._unit_of_measurement + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return ICON + + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + return { + ATTR_VOLUME_24H: self._ticker.get("quotes") + .get(self.data.display_currency) + .get("volume_24h"), + ATTR_ATTRIBUTION: ATTRIBUTION, + ATTR_CIRCULATING_SUPPLY: self._ticker.get("circulating_supply"), + ATTR_MARKET_CAP: self._ticker.get("quotes") + .get(self.data.display_currency) + .get("market_cap"), + ATTR_PERCENT_CHANGE_24H: self._ticker.get("quotes") + .get(self.data.display_currency) + .get("percent_change_24h"), + ATTR_PERCENT_CHANGE_7D: self._ticker.get("quotes") + .get(self.data.display_currency) + .get("percent_change_7d"), + ATTR_PERCENT_CHANGE_1H: self._ticker.get("quotes") + .get(self.data.display_currency) + .get("percent_change_1h"), + ATTR_RANK: self._ticker.get("rank"), + ATTR_SYMBOL: self._ticker.get("symbol"), + ATTR_TOTAL_SUPPLY: self._ticker.get("total_supply"), + } + + def update(self): + """Get the latest data and updates the states.""" + self.data.update() + self._ticker = self.data.ticker.get("data") + + +class CoinMarketCapData: + """Get the latest data and update the states.""" + + def __init__(self, currency_id, display_currency): + """Initialize the data object.""" + self.currency_id = currency_id + self.display_currency = display_currency + self.ticker = None + + def update(self): + """Get the latest data from coinmarketcap.com.""" + + self.ticker = Market().ticker(self.currency_id, convert=self.display_currency) diff --git a/homeassistant/components/comed_hourly_pricing/__init__.py b/homeassistant/components/comed_hourly_pricing/__init__.py new file mode 100644 index 000000000..db6c2100e --- /dev/null +++ b/homeassistant/components/comed_hourly_pricing/__init__.py @@ -0,0 +1 @@ +"""The comed_hourly_pricing component.""" diff --git a/homeassistant/components/comed_hourly_pricing/manifest.json b/homeassistant/components/comed_hourly_pricing/manifest.json new file mode 100644 index 000000000..89fbb84e8 --- /dev/null +++ b/homeassistant/components/comed_hourly_pricing/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "comed_hourly_pricing", + "name": "Comed hourly pricing", + "documentation": "https://www.home-assistant.io/integrations/comed_hourly_pricing", + "requirements": [], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/comed_hourly_pricing/sensor.py b/homeassistant/components/comed_hourly_pricing/sensor.py new file mode 100644 index 000000000..90830d522 --- /dev/null +++ b/homeassistant/components/comed_hourly_pricing/sensor.py @@ -0,0 +1,127 @@ +"""Support for ComEd Hourly Pricing data.""" +import asyncio +from datetime import timedelta +import json +import logging + +import aiohttp +import async_timeout +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME, CONF_OFFSET +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) +_RESOURCE = "https://hourlypricing.comed.com/api" + +SCAN_INTERVAL = timedelta(minutes=5) + +ATTRIBUTION = "Data provided by ComEd Hourly Pricing service" + +CONF_CURRENT_HOUR_AVERAGE = "current_hour_average" +CONF_FIVE_MINUTE = "five_minute" +CONF_MONITORED_FEEDS = "monitored_feeds" +CONF_SENSOR_TYPE = "type" + +SENSOR_TYPES = { + CONF_FIVE_MINUTE: ["ComEd 5 Minute Price", "c"], + CONF_CURRENT_HOUR_AVERAGE: ["ComEd Current Hour Average Price", "c"], +} + +TYPES_SCHEMA = vol.In(SENSOR_TYPES) + +SENSORS_SCHEMA = vol.Schema( + { + vol.Required(CONF_SENSOR_TYPE): TYPES_SCHEMA, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_OFFSET, default=0.0): vol.Coerce(float), + } +) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + {vol.Required(CONF_MONITORED_FEEDS): [SENSORS_SCHEMA]} +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the ComEd Hourly Pricing sensor.""" + websession = async_get_clientsession(hass) + dev = [] + + for variable in config[CONF_MONITORED_FEEDS]: + dev.append( + ComedHourlyPricingSensor( + hass.loop, + websession, + variable[CONF_SENSOR_TYPE], + variable[CONF_OFFSET], + variable.get(CONF_NAME), + ) + ) + + async_add_entities(dev, True) + + +class ComedHourlyPricingSensor(Entity): + """Implementation of a ComEd Hourly Pricing sensor.""" + + def __init__(self, loop, websession, sensor_type, offset, name): + """Initialize the sensor.""" + self.loop = loop + self.websession = websession + if name: + self._name = name + else: + self._name = SENSOR_TYPES[sensor_type][0] + self.type = sensor_type + self.offset = offset + self._state = None + self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._unit_of_measurement + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return {ATTR_ATTRIBUTION: ATTRIBUTION} + + async def async_update(self): + """Get the ComEd Hourly Pricing data from the web service.""" + try: + if self.type == CONF_FIVE_MINUTE or self.type == CONF_CURRENT_HOUR_AVERAGE: + url_string = _RESOURCE + if self.type == CONF_FIVE_MINUTE: + url_string += "?type=5minutefeed" + else: + url_string += "?type=currenthouraverage" + + with async_timeout.timeout(60): + response = await self.websession.get(url_string) + # The API responds with MIME type 'text/html' + text = await response.text() + data = json.loads(text) + self._state = round(float(data[0]["price"]) + self.offset, 2) + + else: + self._state = None + + except (asyncio.TimeoutError, aiohttp.ClientError) as err: + _LOGGER.error("Could not get data from ComEd API: %s", err) + except (ValueError, KeyError): + _LOGGER.warning("Could not update status for %s", self.name) diff --git a/homeassistant/components/comfoconnect.py b/homeassistant/components/comfoconnect.py deleted file mode 100644 index 69d88274f..000000000 --- a/homeassistant/components/comfoconnect.py +++ /dev/null @@ -1,134 +0,0 @@ -""" -Support to control a Zehnder ComfoAir Q350/450/600 ventilation unit. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/comfoconnect/ -""" -import logging - -import voluptuous as vol - -from homeassistant.const import ( - 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'] - -_LOGGER = logging.getLogger(__name__) - -DOMAIN = 'comfoconnect' - -SIGNAL_COMFOCONNECT_UPDATE_RECEIVED = 'comfoconnect_update_received' - -ATTR_CURRENT_TEMPERATURE = 'current_temperature' -ATTR_CURRENT_HUMIDITY = 'current_humidity' -ATTR_OUTSIDE_TEMPERATURE = 'outside_temperature' -ATTR_OUTSIDE_HUMIDITY = 'outside_humidity' -ATTR_AIR_FLOW_SUPPLY = 'air_flow_supply' -ATTR_AIR_FLOW_EXHAUST = 'air_flow_exhaust' - -CONF_USER_AGENT = 'user_agent' - -DEFAULT_NAME = 'ComfoAirQ' -DEFAULT_PIN = 0 -DEFAULT_TOKEN = '00000000000000000000000000000001' -DEFAULT_USER_AGENT = 'Home Assistant' - -DEVICE = None - -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_TOKEN, default=DEFAULT_TOKEN): - vol.Length(min=32, max=32, msg='invalid token'), - vol.Optional(CONF_USER_AGENT, default=DEFAULT_USER_AGENT): cv.string, - vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.positive_int, - }), -}, extra=vol.ALLOW_EXTRA) - - -def setup(hass, config): - """Set up the ComfoConnect bridge.""" - from pycomfoconnect import (Bridge) - - conf = config[DOMAIN] - host = conf.get(CONF_HOST) - name = conf.get(CONF_NAME) - token = conf.get(CONF_TOKEN) - user_agent = conf.get(CONF_USER_AGENT) - pin = conf.get(CONF_PIN) - - # Run discovery on the configured ip - bridges = Bridge.discover(host) - if not bridges: - _LOGGER.error("Could not connect to ComfoConnect bridge on %s", host) - return False - bridge = bridges[0] - _LOGGER.info("Bridge found: %s (%s)", bridge.uuid.hex(), bridge.host) - - # Setup ComfoConnect Bridge - ccb = ComfoConnectBridge(hass, bridge, name, token, user_agent, pin) - hass.data[DOMAIN] = ccb - - # Start connection with bridge - ccb.connect() - - # Schedule disconnect on shutdown - def _shutdown(_event): - ccb.disconnect() - - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown) - - # Load platforms - discovery.load_platform(hass, 'fan', DOMAIN, {}, config) - - return True - - -class ComfoConnectBridge: - """Representation of a ComfoConnect bridge.""" - - def __init__(self, hass, bridge, name, token, friendly_name, pin): - """Initialize the ComfoConnect bridge.""" - from pycomfoconnect import (ComfoConnect) - - self.data = {} - self.name = name - self.hass = hass - - self.comfoconnect = ComfoConnect( - bridge=bridge, local_uuid=bytes.fromhex(token), - local_devicename=friendly_name, pin=pin) - self.comfoconnect.callback_sensor = self.sensor_callback - - def connect(self): - """Connect with the bridge.""" - _LOGGER.debug("Connecting with bridge") - self.comfoconnect.connect(True) - - def disconnect(self): - """Disconnect from the bridge.""" - _LOGGER.debug("Disconnecting from bridge") - self.comfoconnect.disconnect() - - def sensor_callback(self, var, value): - """Call function for sensor updates.""" - _LOGGER.debug("Got value from bridge: %d = %d", var, value) - - from pycomfoconnect import ( - SENSOR_TEMPERATURE_EXTRACT, SENSOR_TEMPERATURE_OUTDOOR) - - if var in [SENSOR_TEMPERATURE_EXTRACT, SENSOR_TEMPERATURE_OUTDOOR]: - self.data[var] = value / 10 - else: - self.data[var] = value - - # Notify listeners that we have received an update - dispatcher_send(self.hass, SIGNAL_COMFOCONNECT_UPDATE_RECEIVED, var) - - def subscribe_sensor(self, sensor_id): - """Subscribe for the specified sensor.""" - self.comfoconnect.register_sensor(sensor_id) diff --git a/homeassistant/components/comfoconnect/__init__.py b/homeassistant/components/comfoconnect/__init__.py new file mode 100644 index 000000000..f1fd67cc4 --- /dev/null +++ b/homeassistant/components/comfoconnect/__init__.py @@ -0,0 +1,121 @@ +"""Support to control a Zehnder ComfoAir Q350/450/600 ventilation unit.""" +import logging + +from pycomfoconnect import Bridge, ComfoConnect +import voluptuous as vol + +from homeassistant.const import ( + 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 + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "comfoconnect" + +SIGNAL_COMFOCONNECT_UPDATE_RECEIVED = "comfoconnect_update_received_{}" + +CONF_USER_AGENT = "user_agent" + +DEFAULT_NAME = "ComfoAirQ" +DEFAULT_PIN = 0 +DEFAULT_TOKEN = "00000000000000000000000000000001" +DEFAULT_USER_AGENT = "Home Assistant" + +DEVICE = None + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_TOKEN, default=DEFAULT_TOKEN): vol.Length( + min=32, max=32, msg="invalid token" + ), + vol.Optional(CONF_USER_AGENT, default=DEFAULT_USER_AGENT): cv.string, + vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.positive_int, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +def setup(hass, config): + """Set up the ComfoConnect bridge.""" + + conf = config[DOMAIN] + host = conf.get(CONF_HOST) + name = conf.get(CONF_NAME) + token = conf.get(CONF_TOKEN) + user_agent = conf.get(CONF_USER_AGENT) + pin = conf.get(CONF_PIN) + + # Run discovery on the configured ip + bridges = Bridge.discover(host) + if not bridges: + _LOGGER.error("Could not connect to ComfoConnect bridge on %s", host) + return False + bridge = bridges[0] + _LOGGER.info("Bridge found: %s (%s)", bridge.uuid.hex(), bridge.host) + + # Setup ComfoConnect Bridge + ccb = ComfoConnectBridge(hass, bridge, name, token, user_agent, pin) + hass.data[DOMAIN] = ccb + + # Start connection with bridge + ccb.connect() + + # Schedule disconnect on shutdown + def _shutdown(_event): + ccb.disconnect() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown) + + # Load platforms + discovery.load_platform(hass, "fan", DOMAIN, {}, config) + + return True + + +class ComfoConnectBridge: + """Representation of a ComfoConnect bridge.""" + + def __init__(self, hass, bridge, name, token, friendly_name, pin): + """Initialize the ComfoConnect bridge.""" + self.data = {} + self.name = name + self.hass = hass + self.unique_id = bridge.uuid.hex() + + self.comfoconnect = ComfoConnect( + bridge=bridge, + local_uuid=bytes.fromhex(token), + local_devicename=friendly_name, + pin=pin, + ) + self.comfoconnect.callback_sensor = self.sensor_callback + + def connect(self): + """Connect with the bridge.""" + _LOGGER.debug("Connecting with bridge") + self.comfoconnect.connect(True) + + def disconnect(self): + """Disconnect from the bridge.""" + _LOGGER.debug("Disconnecting from bridge") + self.comfoconnect.disconnect() + + def sensor_callback(self, var, value): + """Notify listeners that we have received an update.""" + _LOGGER.debug("Received update for %s: %s", var, value) + dispatcher_send( + self.hass, SIGNAL_COMFOCONNECT_UPDATE_RECEIVED.format(var), value + ) diff --git a/homeassistant/components/comfoconnect/fan.py b/homeassistant/components/comfoconnect/fan.py new file mode 100644 index 000000000..432b25ac6 --- /dev/null +++ b/homeassistant/components/comfoconnect/fan.py @@ -0,0 +1,127 @@ +"""Platform to control a Zehnder ComfoAir Q350/450/600 ventilation unit.""" +import logging + +from pycomfoconnect import ( + CMD_FAN_MODE_AWAY, + CMD_FAN_MODE_HIGH, + CMD_FAN_MODE_LOW, + CMD_FAN_MODE_MEDIUM, + SENSOR_FAN_SPEED_MODE, +) + +from homeassistant.components.fan import ( + SPEED_HIGH, + SPEED_LOW, + SPEED_MEDIUM, + SPEED_OFF, + SUPPORT_SET_SPEED, + FanEntity, +) +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from . import DOMAIN, SIGNAL_COMFOCONNECT_UPDATE_RECEIVED, ComfoConnectBridge + +_LOGGER = logging.getLogger(__name__) + +SPEED_MAPPING = {0: SPEED_OFF, 1: SPEED_LOW, 2: SPEED_MEDIUM, 3: SPEED_HIGH} + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the ComfoConnect fan platform.""" + ccb = hass.data[DOMAIN] + + add_entities([ComfoConnectFan(ccb.name, ccb)], True) + + +class ComfoConnectFan(FanEntity): + """Representation of the ComfoConnect fan platform.""" + + def __init__(self, name, ccb: ComfoConnectBridge) -> None: + """Initialize the ComfoConnect fan.""" + self._ccb = ccb + self._name = name + + async def async_added_to_hass(self): + """Register for sensor updates.""" + _LOGGER.debug("Registering for fan speed") + async_dispatcher_connect( + self.hass, + SIGNAL_COMFOCONNECT_UPDATE_RECEIVED.format(SENSOR_FAN_SPEED_MODE), + self._handle_update, + ) + await self.hass.async_add_executor_job( + self._ccb.comfoconnect.register_sensor, SENSOR_FAN_SPEED_MODE + ) + + def _handle_update(self, value): + """Handle update callbacks.""" + _LOGGER.debug( + "Handle update for fan speed (%d): %s", SENSOR_FAN_SPEED_MODE, value + ) + self._ccb.data[SENSOR_FAN_SPEED_MODE] = value + self.schedule_update_ha_state() + + @property + def should_poll(self) -> bool: + """Do not poll.""" + return False + + @property + def unique_id(self): + """Return a unique_id for this entity.""" + return self._ccb.unique_id + + @property + def name(self): + """Return the name of the fan.""" + return self._name + + @property + def icon(self): + """Return the icon to use in the frontend.""" + return "mdi:air-conditioner" + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return SUPPORT_SET_SPEED + + @property + def speed(self): + """Return the current fan mode.""" + try: + speed = self._ccb.data[SENSOR_FAN_SPEED_MODE] + return SPEED_MAPPING[speed] + except KeyError: + return None + + @property + 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: + """Turn on the fan.""" + if speed is None: + speed = SPEED_LOW + self.set_speed(speed) + + def turn_off(self, **kwargs) -> None: + """Turn off the fan (to away).""" + self.set_speed(SPEED_OFF) + + def set_speed(self, speed: str): + """Set fan speed.""" + _LOGGER.debug("Changing fan speed to %s", speed) + + if speed == SPEED_OFF: + self._ccb.comfoconnect.cmd_rmi_request(CMD_FAN_MODE_AWAY) + elif speed == SPEED_LOW: + self._ccb.comfoconnect.cmd_rmi_request(CMD_FAN_MODE_LOW) + elif speed == SPEED_MEDIUM: + self._ccb.comfoconnect.cmd_rmi_request(CMD_FAN_MODE_MEDIUM) + elif speed == SPEED_HIGH: + self._ccb.comfoconnect.cmd_rmi_request(CMD_FAN_MODE_HIGH) + + # Update current mode + self.schedule_update_ha_state() diff --git a/homeassistant/components/comfoconnect/manifest.json b/homeassistant/components/comfoconnect/manifest.json new file mode 100644 index 000000000..091b7f7bc --- /dev/null +++ b/homeassistant/components/comfoconnect/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "comfoconnect", + "name": "Comfoconnect", + "documentation": "https://www.home-assistant.io/integrations/comfoconnect", + "requirements": [ + "pycomfoconnect==0.3" + ], + "dependencies": [], + "codeowners": ["@michaelarnauts"] +} diff --git a/homeassistant/components/comfoconnect/sensor.py b/homeassistant/components/comfoconnect/sensor.py new file mode 100644 index 000000000..3e3507ea4 --- /dev/null +++ b/homeassistant/components/comfoconnect/sensor.py @@ -0,0 +1,292 @@ +"""Platform to control a Zehnder ComfoAir Q350/450/600 ventilation unit.""" +import logging + +from pycomfoconnect import ( + SENSOR_BYPASS_STATE, + SENSOR_DAYS_TO_REPLACE_FILTER, + SENSOR_FAN_EXHAUST_DUTY, + SENSOR_FAN_EXHAUST_FLOW, + SENSOR_FAN_EXHAUST_SPEED, + SENSOR_FAN_SUPPLY_DUTY, + SENSOR_FAN_SUPPLY_FLOW, + SENSOR_FAN_SUPPLY_SPEED, + SENSOR_HUMIDITY_EXHAUST, + SENSOR_HUMIDITY_EXTRACT, + SENSOR_HUMIDITY_OUTDOOR, + SENSOR_HUMIDITY_SUPPLY, + SENSOR_POWER_CURRENT, + SENSOR_TEMPERATURE_EXHAUST, + SENSOR_TEMPERATURE_EXTRACT, + SENSOR_TEMPERATURE_OUTDOOR, + SENSOR_TEMPERATURE_SUPPLY, +) +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + CONF_RESOURCES, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_TEMPERATURE, + POWER_WATT, + TEMP_CELSIUS, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from . import DOMAIN, SIGNAL_COMFOCONNECT_UPDATE_RECEIVED, ComfoConnectBridge + +ATTR_AIR_FLOW_EXHAUST = "air_flow_exhaust" +ATTR_AIR_FLOW_SUPPLY = "air_flow_supply" +ATTR_BYPASS_STATE = "bypass_state" +ATTR_CURRENT_HUMIDITY = "current_humidity" +ATTR_CURRENT_TEMPERATURE = "current_temperature" +ATTR_DAYS_TO_REPLACE_FILTER = "days_to_replace_filter" +ATTR_EXHAUST_FAN_DUTY = "exhaust_fan_duty" +ATTR_EXHAUST_FAN_SPEED = "exhaust_fan_speed" +ATTR_EXHAUST_HUMIDITY = "exhaust_humidity" +ATTR_EXHAUST_TEMPERATURE = "exhaust_temperature" +ATTR_OUTSIDE_HUMIDITY = "outside_humidity" +ATTR_OUTSIDE_TEMPERATURE = "outside_temperature" +ATTR_POWER_CURRENT = "power_usage" +ATTR_SUPPLY_FAN_DUTY = "supply_fan_duty" +ATTR_SUPPLY_FAN_SPEED = "supply_fan_speed" +ATTR_SUPPLY_HUMIDITY = "supply_humidity" +ATTR_SUPPLY_TEMPERATURE = "supply_temperature" + +_LOGGER = logging.getLogger(__name__) + +ATTR_ICON = "icon" +ATTR_ID = "id" +ATTR_LABEL = "label" +ATTR_MULTIPLIER = "multiplier" +ATTR_UNIT = "unit" + +SENSOR_TYPES = { + ATTR_CURRENT_TEMPERATURE: { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_LABEL: "Inside Temperature", + ATTR_UNIT: TEMP_CELSIUS, + ATTR_ICON: "mdi:thermometer", + ATTR_ID: SENSOR_TEMPERATURE_EXTRACT, + ATTR_MULTIPLIER: 0.1, + }, + ATTR_CURRENT_HUMIDITY: { + ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, + ATTR_LABEL: "Inside Humidity", + ATTR_UNIT: "%", + ATTR_ICON: "mdi:water-percent", + ATTR_ID: SENSOR_HUMIDITY_EXTRACT, + }, + ATTR_OUTSIDE_TEMPERATURE: { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_LABEL: "Outside Temperature", + ATTR_UNIT: TEMP_CELSIUS, + ATTR_ICON: "mdi:thermometer", + ATTR_ID: SENSOR_TEMPERATURE_OUTDOOR, + ATTR_MULTIPLIER: 0.1, + }, + ATTR_OUTSIDE_HUMIDITY: { + ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, + ATTR_LABEL: "Outside Humidity", + ATTR_UNIT: "%", + ATTR_ICON: "mdi:water-percent", + ATTR_ID: SENSOR_HUMIDITY_OUTDOOR, + }, + ATTR_SUPPLY_TEMPERATURE: { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_LABEL: "Supply Temperature", + ATTR_UNIT: TEMP_CELSIUS, + ATTR_ICON: "mdi:thermometer", + ATTR_ID: SENSOR_TEMPERATURE_SUPPLY, + ATTR_MULTIPLIER: 0.1, + }, + ATTR_SUPPLY_HUMIDITY: { + ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, + ATTR_LABEL: "Supply Humidity", + ATTR_UNIT: "%", + ATTR_ICON: "mdi:water-percent", + ATTR_ID: SENSOR_HUMIDITY_SUPPLY, + }, + ATTR_SUPPLY_FAN_SPEED: { + ATTR_DEVICE_CLASS: None, + ATTR_LABEL: "Supply Fan Speed", + ATTR_UNIT: "rpm", + ATTR_ICON: "mdi:fan", + ATTR_ID: SENSOR_FAN_SUPPLY_SPEED, + }, + ATTR_SUPPLY_FAN_DUTY: { + ATTR_DEVICE_CLASS: None, + ATTR_LABEL: "Supply Fan Duty", + ATTR_UNIT: "%", + ATTR_ICON: "mdi:fan", + ATTR_ID: SENSOR_FAN_SUPPLY_DUTY, + }, + ATTR_EXHAUST_FAN_SPEED: { + ATTR_DEVICE_CLASS: None, + ATTR_LABEL: "Exhaust Fan Speed", + ATTR_UNIT: "rpm", + ATTR_ICON: "mdi:fan", + ATTR_ID: SENSOR_FAN_EXHAUST_SPEED, + }, + ATTR_EXHAUST_FAN_DUTY: { + ATTR_DEVICE_CLASS: None, + ATTR_LABEL: "Exhaust Fan Duty", + ATTR_UNIT: "%", + ATTR_ICON: "mdi:fan", + ATTR_ID: SENSOR_FAN_EXHAUST_DUTY, + }, + ATTR_EXHAUST_TEMPERATURE: { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_LABEL: "Exhaust Temperature", + ATTR_UNIT: TEMP_CELSIUS, + ATTR_ICON: "mdi:thermometer", + ATTR_ID: SENSOR_TEMPERATURE_EXHAUST, + ATTR_MULTIPLIER: 0.1, + }, + ATTR_EXHAUST_HUMIDITY: { + ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, + ATTR_LABEL: "Exhaust Humidity", + ATTR_UNIT: "%", + ATTR_ICON: "mdi:water-percent", + ATTR_ID: SENSOR_HUMIDITY_EXHAUST, + }, + ATTR_AIR_FLOW_SUPPLY: { + ATTR_DEVICE_CLASS: None, + ATTR_LABEL: "Supply airflow", + ATTR_UNIT: "m³/h", + ATTR_ICON: "mdi:fan", + ATTR_ID: SENSOR_FAN_SUPPLY_FLOW, + }, + ATTR_AIR_FLOW_EXHAUST: { + ATTR_DEVICE_CLASS: None, + ATTR_LABEL: "Exhaust airflow", + ATTR_UNIT: "m³/h", + ATTR_ICON: "mdi:fan", + ATTR_ID: SENSOR_FAN_EXHAUST_FLOW, + }, + ATTR_BYPASS_STATE: { + ATTR_DEVICE_CLASS: None, + ATTR_LABEL: "Bypass State", + ATTR_UNIT: "%", + ATTR_ICON: "mdi:camera-iris", + ATTR_ID: SENSOR_BYPASS_STATE, + }, + ATTR_DAYS_TO_REPLACE_FILTER: { + ATTR_DEVICE_CLASS: None, + ATTR_LABEL: "Days to replace filter", + ATTR_UNIT: "days", + ATTR_ICON: "mdi:calendar", + ATTR_ID: SENSOR_DAYS_TO_REPLACE_FILTER, + }, + ATTR_POWER_CURRENT: { + ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, + ATTR_LABEL: "Power usage", + ATTR_UNIT: POWER_WATT, + ATTR_ICON: "mdi:flash", + ATTR_ID: SENSOR_POWER_CURRENT, + }, +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_RESOURCES, default=[]): vol.All( + cv.ensure_list, [vol.In(SENSOR_TYPES)] + ), + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the ComfoConnect fan platform.""" + ccb = hass.data[DOMAIN] + + sensors = [] + for resource in config[CONF_RESOURCES]: + sensors.append( + ComfoConnectSensor( + name=f"{ccb.name} {SENSOR_TYPES[resource][ATTR_LABEL]}", + ccb=ccb, + sensor_type=resource, + ) + ) + + add_entities(sensors, True) + + +class ComfoConnectSensor(Entity): + """Representation of a ComfoConnect sensor.""" + + def __init__(self, name, ccb: ComfoConnectBridge, sensor_type) -> None: + """Initialize the ComfoConnect sensor.""" + self._ccb = ccb + self._sensor_type = sensor_type + self._sensor_id = SENSOR_TYPES[self._sensor_type][ATTR_ID] + self._name = name + + async def async_added_to_hass(self): + """Register for sensor updates.""" + _LOGGER.debug( + "Registering for sensor %s (%d)", self._sensor_type, self._sensor_id, + ) + async_dispatcher_connect( + self.hass, + SIGNAL_COMFOCONNECT_UPDATE_RECEIVED.format(self._sensor_id), + self._handle_update, + ) + await self.hass.async_add_executor_job( + self._ccb.comfoconnect.register_sensor, self._sensor_id + ) + + def _handle_update(self, value): + """Handle update callbacks.""" + _LOGGER.debug( + "Handle update for sensor %s (%d): %s", + self._sensor_type, + self._sensor_id, + value, + ) + self._ccb.data[self._sensor_id] = round( + value * SENSOR_TYPES[self._sensor_type].get(ATTR_MULTIPLIER, 1), 2 + ) + self.schedule_update_ha_state() + + @property + def state(self): + """Return the state of the entity.""" + try: + return self._ccb.data[self._sensor_id] + except KeyError: + return None + + @property + def should_poll(self) -> bool: + """Do not poll.""" + return False + + @property + def unique_id(self): + """Return a unique_id for this entity.""" + return f"{self._ccb.unique_id}-{self._sensor_type}" + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def icon(self): + """Return the icon to use in the frontend.""" + return SENSOR_TYPES[self._sensor_type][ATTR_ICON] + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity.""" + return SENSOR_TYPES[self._sensor_type][ATTR_UNIT] + + @property + def device_class(self): + """Return the device_class.""" + return SENSOR_TYPES[self._sensor_type][ATTR_DEVICE_CLASS] diff --git a/homeassistant/components/command_line/__init__.py b/homeassistant/components/command_line/__init__.py new file mode 100644 index 000000000..fe0640d3e --- /dev/null +++ b/homeassistant/components/command_line/__init__.py @@ -0,0 +1 @@ +"""The command_line component.""" diff --git a/homeassistant/components/command_line/binary_sensor.py b/homeassistant/components/command_line/binary_sensor.py new file mode 100644 index 000000000..eaa371be1 --- /dev/null +++ b/homeassistant/components/command_line/binary_sensor.py @@ -0,0 +1,112 @@ +"""Support for custom shell commands to retrieve values.""" +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASSES_SCHEMA, + PLATFORM_SCHEMA, + BinarySensorDevice, +) +from homeassistant.const import ( + CONF_COMMAND, + CONF_DEVICE_CLASS, + CONF_NAME, + CONF_PAYLOAD_OFF, + CONF_PAYLOAD_ON, + CONF_VALUE_TEMPLATE, +) +import homeassistant.helpers.config_validation as cv + +from .sensor import CommandSensorData + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = "Binary Command Sensor" +DEFAULT_PAYLOAD_ON = "ON" +DEFAULT_PAYLOAD_OFF = "OFF" + +SCAN_INTERVAL = timedelta(seconds=60) + +CONF_COMMAND_TIMEOUT = "command_timeout" +DEFAULT_TIMEOUT = 15 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_COMMAND): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + 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_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Command line Binary Sensor.""" + name = config.get(CONF_NAME) + command = config.get(CONF_COMMAND) + payload_off = config.get(CONF_PAYLOAD_OFF) + payload_on = config.get(CONF_PAYLOAD_ON) + device_class = config.get(CONF_DEVICE_CLASS) + value_template = config.get(CONF_VALUE_TEMPLATE) + command_timeout = config.get(CONF_COMMAND_TIMEOUT) + if value_template is not None: + value_template.hass = hass + data = CommandSensorData(hass, command, command_timeout) + + add_entities( + [ + CommandBinarySensor( + hass, data, name, device_class, payload_on, payload_off, value_template + ) + ], + True, + ) + + +class CommandBinarySensor(BinarySensorDevice): + """Representation of a command line binary sensor.""" + + def __init__( + self, hass, data, name, device_class, payload_on, payload_off, value_template + ): + """Initialize the Command line binary sensor.""" + self._hass = hass + self.data = data + self._name = name + self._device_class = device_class + self._state = False + self._payload_on = payload_on + self._payload_off = payload_off + self._value_template = value_template + + @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 + + def update(self): + """Get the latest data and updates the state.""" + self.data.update() + value = self.data.value + + if self._value_template is not None: + value = self._value_template.render_with_possible_json_value(value, False) + if value == self._payload_on: + self._state = True + elif value == self._payload_off: + self._state = False diff --git a/homeassistant/components/command_line/cover.py b/homeassistant/components/command_line/cover.py new file mode 100644 index 000000000..1d996614c --- /dev/null +++ b/homeassistant/components/command_line/cover.py @@ -0,0 +1,161 @@ +"""Support for command line covers.""" +import logging +import subprocess + +import voluptuous as vol + +from homeassistant.components.cover import PLATFORM_SCHEMA, CoverDevice +from homeassistant.const import ( + CONF_COMMAND_CLOSE, + CONF_COMMAND_OPEN, + CONF_COMMAND_STATE, + CONF_COMMAND_STOP, + CONF_COVERS, + CONF_FRIENDLY_NAME, + CONF_VALUE_TEMPLATE, +) +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +COVER_SCHEMA = vol.Schema( + { + vol.Optional(CONF_COMMAND_CLOSE, default="true"): cv.string, + vol.Optional(CONF_COMMAND_OPEN, default="true"): cv.string, + vol.Optional(CONF_COMMAND_STATE): cv.string, + vol.Optional(CONF_COMMAND_STOP, default="true"): cv.string, + vol.Optional(CONF_FRIENDLY_NAME): cv.string, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + } +) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + {vol.Required(CONF_COVERS): cv.schema_with_slug_keys(COVER_SCHEMA)} +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up cover controlled by shell commands.""" + devices = config.get(CONF_COVERS, {}) + covers = [] + + for device_name, device_config in devices.items(): + value_template = device_config.get(CONF_VALUE_TEMPLATE) + if value_template is not None: + value_template.hass = hass + + covers.append( + CommandCover( + hass, + device_config.get(CONF_FRIENDLY_NAME, device_name), + device_config.get(CONF_COMMAND_OPEN), + device_config.get(CONF_COMMAND_CLOSE), + device_config.get(CONF_COMMAND_STOP), + device_config.get(CONF_COMMAND_STATE), + value_template, + ) + ) + + if not covers: + _LOGGER.error("No covers added") + return False + + add_entities(covers) + + +class CommandCover(CoverDevice): + """Representation a command line cover.""" + + def __init__( + self, + hass, + name, + command_open, + command_close, + command_stop, + command_state, + value_template, + ): + """Initialize the cover.""" + self._hass = hass + self._name = name + self._state = None + self._command_open = command_open + self._command_close = command_close + self._command_stop = command_stop + self._command_state = command_state + self._value_template = value_template + + @staticmethod + def _move_cover(command): + """Execute the actual commands.""" + _LOGGER.info("Running command: %s", command) + + success = subprocess.call(command, shell=True) == 0 + + if not success: + _LOGGER.error("Command failed: %s", command) + + return success + + @staticmethod + def _query_state_value(command): + """Execute state command for return value.""" + _LOGGER.info("Running state command: %s", command) + + try: + return_value = subprocess.check_output(command, shell=True) + return return_value.strip().decode("utf-8") + except subprocess.CalledProcessError: + _LOGGER.error("Command failed: %s", command) + + @property + def should_poll(self): + """Only poll if we have state command.""" + return self._command_state is not None + + @property + def name(self): + """Return the name of the cover.""" + return self._name + + @property + def is_closed(self): + """Return if the cover is closed.""" + if self.current_cover_position is not None: + return self.current_cover_position == 0 + + @property + def current_cover_position(self): + """Return current position of cover. + + None is unknown, 0 is closed, 100 is fully open. + """ + return self._state + + def _query_state(self): + """Query for the state.""" + if not self._command_state: + _LOGGER.error("No state command specified") + return + return self._query_state_value(self._command_state) + + def update(self): + """Update device state.""" + if self._command_state: + payload = str(self._query_state()) + if self._value_template: + payload = self._value_template.render_with_possible_json_value(payload) + self._state = int(payload) + + def open_cover(self, **kwargs): + """Open the cover.""" + self._move_cover(self._command_open) + + def close_cover(self, **kwargs): + """Close the cover.""" + self._move_cover(self._command_close) + + def stop_cover(self, **kwargs): + """Stop the cover.""" + self._move_cover(self._command_stop) diff --git a/homeassistant/components/command_line/manifest.json b/homeassistant/components/command_line/manifest.json new file mode 100644 index 000000000..4d7dfc899 --- /dev/null +++ b/homeassistant/components/command_line/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "command_line", + "name": "Command line", + "documentation": "https://www.home-assistant.io/integrations/command_line", + "requirements": [], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/command_line/notify.py b/homeassistant/components/command_line/notify.py new file mode 100644 index 000000000..21653171f --- /dev/null +++ b/homeassistant/components/command_line/notify.py @@ -0,0 +1,42 @@ +"""Support for command line notification services.""" +import logging +import subprocess + +import voluptuous as vol + +from homeassistant.components.notify import PLATFORM_SCHEMA, BaseNotificationService +from homeassistant.const import CONF_COMMAND, CONF_NAME +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + {vol.Required(CONF_COMMAND): cv.string, vol.Optional(CONF_NAME): cv.string} +) + + +def get_service(hass, config, discovery_info=None): + """Get the Command Line notification service.""" + command = config[CONF_COMMAND] + + return CommandLineNotificationService(command) + + +class CommandLineNotificationService(BaseNotificationService): + """Implement the notification service for the Command Line service.""" + + def __init__(self, command): + """Initialize the service.""" + self.command = command + + def send_message(self, message="", **kwargs): + """Send a message to a command line.""" + try: + proc = subprocess.Popen( + self.command, universal_newlines=True, stdin=subprocess.PIPE, shell=True + ) + proc.communicate(input=message) + if proc.returncode != 0: + _LOGGER.error("Command failed: %s", self.command) + except subprocess.SubprocessError: + _LOGGER.error("Error trying to exec Command: %s", self.command) diff --git a/homeassistant/components/command_line/sensor.py b/homeassistant/components/command_line/sensor.py new file mode 100644 index 000000000..85ba78ecd --- /dev/null +++ b/homeassistant/components/command_line/sensor.py @@ -0,0 +1,185 @@ +"""Allows to configure custom shell commands to turn a value for a sensor.""" +import collections +from datetime import timedelta +import json +import logging +import shlex +import subprocess + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_COMMAND, + CONF_NAME, + CONF_UNIT_OF_MEASUREMENT, + CONF_VALUE_TEMPLATE, + STATE_UNKNOWN, +) +from homeassistant.exceptions import TemplateError +from homeassistant.helpers import template +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +CONF_COMMAND_TIMEOUT = "command_timeout" +CONF_JSON_ATTRIBUTES = "json_attributes" + +DEFAULT_NAME = "Command Sensor" +DEFAULT_TIMEOUT = 15 + +SCAN_INTERVAL = timedelta(seconds=60) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_COMMAND): cv.string, + vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + vol.Optional(CONF_JSON_ATTRIBUTES): cv.ensure_list_csv, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Command Sensor.""" + name = config.get(CONF_NAME) + command = config.get(CONF_COMMAND) + unit = config.get(CONF_UNIT_OF_MEASUREMENT) + value_template = config.get(CONF_VALUE_TEMPLATE) + command_timeout = config.get(CONF_COMMAND_TIMEOUT) + if value_template is not None: + value_template.hass = hass + json_attributes = config.get(CONF_JSON_ATTRIBUTES) + data = CommandSensorData(hass, command, command_timeout) + + add_entities( + [CommandSensor(hass, data, name, unit, value_template, json_attributes)], True + ) + + +class CommandSensor(Entity): + """Representation of a sensor that is using shell commands.""" + + def __init__( + self, hass, data, name, unit_of_measurement, value_template, json_attributes + ): + """Initialize the sensor.""" + self._hass = hass + self.data = data + self._attributes = None + self._json_attributes = json_attributes + self._name = name + self._state = None + self._unit_of_measurement = unit_of_measurement + self._value_template = value_template + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self._unit_of_measurement + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._attributes + + def update(self): + """Get the latest data and updates the state.""" + self.data.update() + value = self.data.value + + if self._json_attributes: + self._attributes = {} + if value: + try: + json_dict = json.loads(value) + if isinstance(json_dict, collections.Mapping): + self._attributes = { + k: json_dict[k] + for k in self._json_attributes + if k in json_dict + } + else: + _LOGGER.warning("JSON result was not a dictionary") + except ValueError: + _LOGGER.warning("Unable to parse output as JSON: %s", value) + else: + _LOGGER.warning("Empty reply found when expecting JSON data") + + if value is None: + value = STATE_UNKNOWN + elif self._value_template is not None: + self._state = self._value_template.render_with_possible_json_value( + value, STATE_UNKNOWN + ) + else: + self._state = value + + +class CommandSensorData: + """The class for handling the data retrieval.""" + + def __init__(self, hass, command, command_timeout): + """Initialize the data object.""" + self.value = None + self.hass = hass + self.command = command + self.timeout = command_timeout + + def update(self): + """Get the latest data with a shell command.""" + command = self.command + cache = {} + + if command in cache: + prog, args, args_compiled = cache[command] + elif " " not in command: + prog = command + args = None + args_compiled = None + cache[command] = (prog, args, args_compiled) + else: + prog, args = command.split(" ", 1) + args_compiled = template.Template(args, self.hass) + cache[command] = (prog, args, args_compiled) + + if args_compiled: + try: + args_to_render = {"arguments": args} + rendered_args = args_compiled.render(args_to_render) + except TemplateError as ex: + _LOGGER.exception("Error rendering command template: %s", ex) + return + else: + rendered_args = None + + if rendered_args == args: + # No template used. default behavior + shell = True + else: + # Template used. Construct the string used in the shell + command = str(" ".join([prog] + shlex.split(rendered_args))) + shell = True + try: + _LOGGER.debug("Running command: %s", command) + return_value = subprocess.check_output( + command, shell=shell, timeout=self.timeout + ) + self.value = return_value.strip().decode("utf-8") + except subprocess.CalledProcessError: + _LOGGER.error("Command failed: %s", command) + except subprocess.TimeoutExpired: + _LOGGER.error("Timeout for command: %s", command) diff --git a/homeassistant/components/command_line/switch.py b/homeassistant/components/command_line/switch.py new file mode 100644 index 000000000..62dcbe2f1 --- /dev/null +++ b/homeassistant/components/command_line/switch.py @@ -0,0 +1,168 @@ +"""Support for custom shell commands to turn a switch on/off.""" +import logging +import subprocess + +import voluptuous as vol + +from homeassistant.components.switch import ( + ENTITY_ID_FORMAT, + PLATFORM_SCHEMA, + SwitchDevice, +) +from homeassistant.const import ( + CONF_COMMAND_OFF, + CONF_COMMAND_ON, + CONF_COMMAND_STATE, + CONF_FRIENDLY_NAME, + CONF_SWITCHES, + CONF_VALUE_TEMPLATE, +) +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +SWITCH_SCHEMA = vol.Schema( + { + vol.Optional(CONF_COMMAND_OFF, default="true"): cv.string, + vol.Optional(CONF_COMMAND_ON, default="true"): cv.string, + vol.Optional(CONF_COMMAND_STATE): cv.string, + vol.Optional(CONF_FRIENDLY_NAME): cv.string, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + } +) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + {vol.Required(CONF_SWITCHES): cv.schema_with_slug_keys(SWITCH_SCHEMA)} +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Find and return switches controlled by shell commands.""" + devices = config.get(CONF_SWITCHES, {}) + switches = [] + + for object_id, device_config in devices.items(): + value_template = device_config.get(CONF_VALUE_TEMPLATE) + + if value_template is not None: + value_template.hass = hass + + switches.append( + CommandSwitch( + hass, + object_id, + device_config.get(CONF_FRIENDLY_NAME, object_id), + device_config.get(CONF_COMMAND_ON), + device_config.get(CONF_COMMAND_OFF), + device_config.get(CONF_COMMAND_STATE), + value_template, + ) + ) + + if not switches: + _LOGGER.error("No switches added") + return False + + add_entities(switches) + + +class CommandSwitch(SwitchDevice): + """Representation a switch that can be toggled using shell commands.""" + + def __init__( + self, + hass, + object_id, + friendly_name, + command_on, + command_off, + command_state, + value_template, + ): + """Initialize the switch.""" + self._hass = hass + self.entity_id = ENTITY_ID_FORMAT.format(object_id) + self._name = friendly_name + self._state = False + self._command_on = command_on + self._command_off = command_off + self._command_state = command_state + self._value_template = value_template + + @staticmethod + def _switch(command): + """Execute the actual commands.""" + _LOGGER.info("Running command: %s", command) + + success = subprocess.call(command, shell=True) == 0 + + if not success: + _LOGGER.error("Command failed: %s", command) + + return success + + @staticmethod + def _query_state_value(command): + """Execute state command for return value.""" + _LOGGER.info("Running state command: %s", command) + + try: + return_value = subprocess.check_output(command, shell=True) + return return_value.strip().decode("utf-8") + except subprocess.CalledProcessError: + _LOGGER.error("Command failed: %s", command) + + @staticmethod + def _query_state_code(command): + """Execute state command for return code.""" + _LOGGER.info("Running state command: %s", command) + return subprocess.call(command, shell=True) == 0 + + @property + def should_poll(self): + """Only poll if we have state command.""" + return self._command_state is not None + + @property + def name(self): + """Return the name of the switch.""" + return self._name + + @property + def is_on(self): + """Return true if device is on.""" + return self._state + + @property + def assumed_state(self): + """Return true if we do optimistic updates.""" + return self._command_state is None + + def _query_state(self): + """Query for state.""" + if not self._command_state: + _LOGGER.error("No state command specified") + return + if self._value_template: + return CommandSwitch._query_state_value(self._command_state) + return CommandSwitch._query_state_code(self._command_state) + + def update(self): + """Update device state.""" + if self._command_state: + payload = str(self._query_state()) + if self._value_template: + payload = self._value_template.render_with_possible_json_value(payload) + self._state = payload.lower() == "true" + + def turn_on(self, **kwargs): + """Turn the device on.""" + if CommandSwitch._switch(self._command_on) and not self._command_state: + self._state = True + self.schedule_update_ha_state() + + def turn_off(self, **kwargs): + """Turn the device off.""" + if CommandSwitch._switch(self._command_off) and not self._command_state: + self._state = False + self.schedule_update_ha_state() diff --git a/homeassistant/components/concord232/__init__.py b/homeassistant/components/concord232/__init__.py new file mode 100644 index 000000000..aec6c38ed --- /dev/null +++ b/homeassistant/components/concord232/__init__.py @@ -0,0 +1 @@ +"""The concord232 component.""" diff --git a/homeassistant/components/concord232/alarm_control_panel.py b/homeassistant/components/concord232/alarm_control_panel.py new file mode 100644 index 000000000..81a54a182 --- /dev/null +++ b/homeassistant/components/concord232/alarm_control_panel.py @@ -0,0 +1,150 @@ +"""Support for Concord232 alarm control panels.""" +import datetime +import logging + +from concord232 import client as concord232_client +import requests +import voluptuous as vol + +import homeassistant.components.alarm_control_panel as alarm +from homeassistant.components.alarm_control_panel import PLATFORM_SCHEMA +from homeassistant.components.alarm_control_panel.const import ( + SUPPORT_ALARM_ARM_AWAY, + SUPPORT_ALARM_ARM_HOME, +) +from homeassistant.const import ( + CONF_CODE, + CONF_HOST, + CONF_MODE, + CONF_NAME, + CONF_PORT, + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_DISARMED, +) +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_HOST = "localhost" +DEFAULT_NAME = "CONCORD232" +DEFAULT_PORT = 5007 +DEFAULT_MODE = "audible" + +SCAN_INTERVAL = datetime.timedelta(seconds=10) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_CODE): cv.string, + vol.Optional(CONF_MODE, default=DEFAULT_MODE): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Concord232 alarm control panel platform.""" + name = config.get(CONF_NAME) + code = config.get(CONF_CODE) + mode = config.get(CONF_MODE) + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) + + url = f"http://{host}:{port}" + + try: + add_entities([Concord232Alarm(url, name, code, mode)], True) + except requests.exceptions.ConnectionError as ex: + _LOGGER.error("Unable to connect to Concord232: %s", str(ex)) + + +class Concord232Alarm(alarm.AlarmControlPanel): + """Representation of the Concord232-based alarm panel.""" + + def __init__(self, url, name, code, mode): + """Initialize the Concord232 alarm panel.""" + + self._state = None + self._name = name + self._code = code + self._mode = mode + self._url = url + self._alarm = concord232_client.Client(self._url) + self._alarm.partitions = self._alarm.list_partitions() + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def code_format(self): + """Return the characters if code is defined.""" + return alarm.FORMAT_NUMBER + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @property + def supported_features(self) -> int: + """Return the list of supported features.""" + return SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY + + def update(self): + """Update values from API.""" + try: + part = self._alarm.list_partitions()[0] + except requests.exceptions.ConnectionError as ex: + _LOGGER.error( + "Unable to connect to %(host)s: %(reason)s", + dict(host=self._url, reason=ex), + ) + return + except IndexError: + _LOGGER.error("Concord232 reports no partitions") + return + + if part["arming_level"] == "Off": + self._state = STATE_ALARM_DISARMED + elif "Home" in part["arming_level"]: + self._state = STATE_ALARM_ARMED_HOME + else: + self._state = STATE_ALARM_ARMED_AWAY + + def alarm_disarm(self, code=None): + """Send disarm command.""" + if not self._validate_code(code, STATE_ALARM_DISARMED): + return + self._alarm.disarm(code) + + def alarm_arm_home(self, code=None): + """Send arm home command.""" + if not self._validate_code(code, STATE_ALARM_ARMED_HOME): + return + if self._mode == "silent": + self._alarm.arm("stay", "silent") + else: + self._alarm.arm("stay") + + def alarm_arm_away(self, code=None): + """Send arm away command.""" + if not self._validate_code(code, STATE_ALARM_ARMED_AWAY): + return + self._alarm.arm("away") + + def _validate_code(self, code, state): + """Validate given 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 diff --git a/homeassistant/components/concord232/binary_sensor.py b/homeassistant/components/concord232/binary_sensor.py new file mode 100644 index 000000000..2d119e2cf --- /dev/null +++ b/homeassistant/components/concord232/binary_sensor.py @@ -0,0 +1,142 @@ +"""Support for exposing Concord232 elements as sensors.""" +import datetime +import logging + +from concord232 import client as concord232_client +import requests +import voluptuous as vol + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASSES, + PLATFORM_SCHEMA, + BinarySensorDevice, +) +from homeassistant.const import CONF_HOST, CONF_PORT +import homeassistant.helpers.config_validation as cv +import homeassistant.util.dt as dt_util + +_LOGGER = logging.getLogger(__name__) + +CONF_EXCLUDE_ZONES = "exclude_zones" +CONF_ZONE_TYPES = "zone_types" + +DEFAULT_HOST = "localhost" +DEFAULT_NAME = "Alarm" +DEFAULT_PORT = "5007" +DEFAULT_SSL = False + +SCAN_INTERVAL = datetime.timedelta(seconds=10) + +ZONE_TYPES_SCHEMA = vol.Schema({cv.positive_int: vol.In(DEVICE_CLASSES)}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_EXCLUDE_ZONES, default=[]): vol.All( + cv.ensure_list, [cv.positive_int] + ), + vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_ZONE_TYPES, default={}): ZONE_TYPES_SCHEMA, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Concord232 binary sensor platform.""" + + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) + exclude = config.get(CONF_EXCLUDE_ZONES) + zone_types = config.get(CONF_ZONE_TYPES) + sensors = [] + + try: + _LOGGER.debug("Initializing client") + client = concord232_client.Client(f"http://{host}:{port}") + client.zones = client.list_zones() + client.last_zone_update = dt_util.utcnow() + + except requests.exceptions.ConnectionError as ex: + _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: + sensors.append( + Concord232ZoneSensor( + hass, + client, + zone, + zone_types.get(zone["number"], get_opening_type(zone)), + ) + ) + + add_entities(sensors, True) + + +def get_opening_type(zone): + """Return the result of the type guessing from name.""" + if "MOTION" in zone["name"]: + return "motion" + if "KEY" in zone["name"]: + return "safety" + if "SMOKE" in zone["name"]: + return "smoke" + if "WATER" in zone["name"]: + return "water" + return "opening" + + +class Concord232ZoneSensor(BinarySensorDevice): + """Representation of a Concord232 zone as a sensor.""" + + def __init__(self, hass, client, zone, zone_type): + """Initialize the Concord232 binary sensor.""" + self._hass = hass + self._client = client + self._zone = zone + self._number = zone["number"] + self._zone_type = zone_type + + @property + def device_class(self): + """Return the class of this sensor, from DEVICE_CLASSES.""" + return self._zone_type + + @property + def should_poll(self): + """No polling needed.""" + return True + + @property + def name(self): + """Return the name of the binary sensor.""" + return self._zone["name"] + + @property + 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") + + def update(self): + """Get updated stats from API.""" + last_update = dt_util.utcnow() - self._client.last_zone_update + _LOGGER.debug("Zone: %s ", self._zone) + if last_update > datetime.timedelta(seconds=1): + self._client.zones = self._client.list_zones() + self._client.last_zone_update = dt_util.utcnow() + _LOGGER.debug("Updated from zone: %s", self._zone["name"]) + + if hasattr(self._client, "zones"): + self._zone = next( + (x for x in self._client.zones if x["number"] == self._number), None + ) diff --git a/homeassistant/components/concord232/manifest.json b/homeassistant/components/concord232/manifest.json new file mode 100644 index 000000000..f5ff021b6 --- /dev/null +++ b/homeassistant/components/concord232/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "concord232", + "name": "Concord232", + "documentation": "https://www.home-assistant.io/integrations/concord232", + "requirements": [ + "concord232==0.15" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py index df0e2f13a..5873cdc32 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -1,41 +1,44 @@ """Component to configure Home Assistant via an API.""" import asyncio +import importlib import os import voluptuous as vol -from homeassistant.core import callback -from homeassistant.const import EVENT_COMPONENT_LOADED, CONF_ID -from homeassistant.setup import ( - async_prepare_setup_platform, ATTR_COMPONENT) from homeassistant.components.http import HomeAssistantView -from homeassistant.util.yaml import load_yaml, dump +from homeassistant.const import CONF_ID, EVENT_COMPONENT_LOADED +from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.setup import ATTR_COMPONENT +from homeassistant.util.yaml import dump, load_yaml -DOMAIN = 'config' -DEPENDENCIES = ['http'] +DOMAIN = "config" SECTIONS = ( - 'automation', - 'config_entries', - 'core', - 'customize', - 'device_registry', - 'entity_registry', - 'group', - 'hassbian', - 'script', + "area_registry", + "auth", + "auth_provider_homeassistant", + "automation", + "config_entries", + "core", + "customize", + "device_registry", + "entity_registry", + "group", + "script", + "scene", ) -ON_DEMAND = ('zwave',) +ON_DEMAND = ("zwave",) async def async_setup(hass, config): """Set up the config component.""" - await hass.components.frontend.async_register_built_in_panel( - 'config', 'config', 'hass:settings') + hass.components.frontend.async_register_built_in_panel( + "config", "config", "hass:settings", require_admin=True + ) async def setup_panel(panel_name): """Set up a panel.""" - panel = await async_prepare_setup_platform( - hass, config, DOMAIN, panel_name) + panel = importlib.import_module(f".{panel_name}", __name__) if not panel: return @@ -43,31 +46,26 @@ async def async_setup(hass, config): success = await panel.async_setup(hass) if success: - key = '{}.{}'.format(DOMAIN, panel_name) + key = f"{DOMAIN}.{panel_name}" hass.bus.async_fire(EVENT_COMPONENT_LOADED, {ATTR_COMPONENT: key}) - hass.config.components.add(key) @callback def component_loaded(event): """Respond to components being loaded.""" panel_name = event.data.get(ATTR_COMPONENT) if panel_name in ON_DEMAND: - hass.async_add_job(setup_panel(panel_name)) + hass.async_create_task(setup_panel(panel_name)) hass.bus.async_listen(EVENT_COMPONENT_LOADED, component_loaded) tasks = [setup_panel(panel_name) for panel_name in SECTIONS] - if hass.auth.active: - tasks.append(setup_panel('auth')) - tasks.append(setup_panel('auth_provider_homeassistant')) - 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) + await asyncio.wait(tasks) return True @@ -75,15 +73,25 @@ async def async_setup(hass, config): class BaseEditConfigView(HomeAssistantView): """Configure a Group endpoint.""" - def __init__(self, component, config_type, path, key_schema, data_schema, - *, post_write_hook=None): + def __init__( + self, + component, + config_type, + path, + key_schema, + data_schema, + *, + post_write_hook=None, + data_validator=None, + ): """Initialize a config view.""" - self.url = '/api/config/%s/%s/{config_key}' % (component, config_type) - self.name = 'api:config:%s:%s' % (component, config_type) + self.url = f"/api/config/{component}/{config_type}/{{config_key}}" + self.name = f"api:config:{component}:{config_type}" self.path = path self.key_schema = key_schema self.data_schema = data_schema self.post_write_hook = post_write_hook + self.data_validator = data_validator def _empty_config(self): """Empty config if file not found.""" @@ -97,14 +105,18 @@ class BaseEditConfigView(HomeAssistantView): """Set value.""" raise NotImplementedError + def _delete_value(self, hass, data, config_key): + """Delete value.""" + raise NotImplementedError + async def get(self, request, config_key): """Fetch device specific config.""" - hass = request.app['hass'] + hass = request.app["hass"] current = await self.read_config(hass) value = self._get_value(hass, current, config_key) if value is None: - return self.json_message('Resource not found', 404) + return self.json_message("Resource not found", 404) return self.json(value) @@ -113,39 +125,58 @@ class BaseEditConfigView(HomeAssistantView): try: data = await request.json() except ValueError: - return self.json_message('Invalid JSON specified', 400) + return self.json_message("Invalid JSON specified", 400) try: self.key_schema(config_key) except vol.Invalid as err: - return self.json_message('Key malformed: {}'.format(err), 400) + return self.json_message(f"Key malformed: {err}", 400) + + hass = request.app["hass"] try: # We just validate, we don't store that data because # we don't want to store the defaults. - self.data_schema(data) - except vol.Invalid as err: - return self.json_message('Message malformed: {}'.format(err), 400) + if self.data_validator: + await self.data_validator(hass, data) + else: + self.data_schema(data) + except (vol.Invalid, HomeAssistantError) as err: + return self.json_message(f"Message malformed: {err}", 400) - hass = request.app['hass'] path = hass.config.path(self.path) current = await self.read_config(hass) self._write_value(hass, current, config_key, data) - await hass.async_add_job(_write, path, current) + await hass.async_add_executor_job(_write, path, current) if self.post_write_hook is not None: - hass.async_add_job(self.post_write_hook(hass)) + hass.async_create_task(self.post_write_hook(hass)) - return self.json({ - 'result': 'ok', - }) + return self.json({"result": "ok"}) + + async def delete(self, request, config_key): + """Remove an entry.""" + hass = request.app["hass"] + current = await self.read_config(hass) + value = self._get_value(hass, current, config_key) + path = hass.config.path(self.path) + + if value is None: + return self.json_message("Resource not found", 404) + + self._delete_value(hass, current, config_key) + await hass.async_add_executor_job(_write, path, current) + + if self.post_write_hook is not None: + hass.async_create_task(self.post_write_hook(hass)) + + return self.json({"result": "ok"}) async def read_config(self, hass): """Read the config.""" - current = await hass.async_add_job( - _read, hass.config.path(self.path)) + current = await hass.async_add_job(_read, hass.config.path(self.path)) if not current: current = self._empty_config() return current @@ -166,6 +197,10 @@ class EditKeyBasedConfigView(BaseEditConfigView): """Set value.""" data.setdefault(config_key, {}).update(new_value) + def _delete_value(self, hass, data, config_key): + """Delete value.""" + return data.pop(config_key) + class EditIdBasedConfigView(BaseEditConfigView): """Configure key based config entries.""" @@ -176,8 +211,7 @@ class EditIdBasedConfigView(BaseEditConfigView): def _get_value(self, hass, data, config_key): """Get value.""" - return next( - (val for val in data if val.get(CONF_ID) == config_key), None) + return next((val for val in data if val.get(CONF_ID) == config_key), None) def _write_value(self, hass, data, config_key, new_value): """Set value.""" @@ -189,6 +223,13 @@ class EditIdBasedConfigView(BaseEditConfigView): value.update(new_value) + def _delete_value(self, hass, data, config_key): + """Delete value.""" + index = next( + idx for idx, val in enumerate(data) if val.get(CONF_ID) == config_key + ) + data.pop(index) + def _read(path): """Read YAML helper.""" @@ -203,5 +244,5 @@ def _write(path, data): # Do it before opening file. If dump causes error it will now not # truncate the file. data = dump(data) - with open(path, 'w', encoding='utf-8') as outfile: + with open(path, "w", encoding="utf-8") as outfile: outfile.write(data) diff --git a/homeassistant/components/config/area_registry.py b/homeassistant/components/config/area_registry.py new file mode 100644 index 000000000..81daf3533 --- /dev/null +++ b/homeassistant/components/config/area_registry.py @@ -0,0 +1,125 @@ +"""HTTP views to interact with the area registry.""" +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.components.websocket_api.decorators import ( + async_response, + require_admin, +) +from homeassistant.core import callback +from homeassistant.helpers.area_registry import async_get_registry + +WS_TYPE_LIST = "config/area_registry/list" +SCHEMA_WS_LIST = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( + {vol.Required("type"): WS_TYPE_LIST} +) + +WS_TYPE_CREATE = "config/area_registry/create" +SCHEMA_WS_CREATE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( + {vol.Required("type"): WS_TYPE_CREATE, vol.Required("name"): str} +) + +WS_TYPE_DELETE = "config/area_registry/delete" +SCHEMA_WS_DELETE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( + {vol.Required("type"): WS_TYPE_DELETE, vol.Required("area_id"): str} +) + +WS_TYPE_UPDATE = "config/area_registry/update" +SCHEMA_WS_UPDATE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( + { + vol.Required("type"): WS_TYPE_UPDATE, + vol.Required("area_id"): str, + vol.Required("name"): str, + } +) + + +async def async_setup(hass): + """Enable the Area Registry views.""" + hass.components.websocket_api.async_register_command( + WS_TYPE_LIST, websocket_list_areas, SCHEMA_WS_LIST + ) + hass.components.websocket_api.async_register_command( + WS_TYPE_CREATE, websocket_create_area, SCHEMA_WS_CREATE + ) + hass.components.websocket_api.async_register_command( + WS_TYPE_DELETE, websocket_delete_area, SCHEMA_WS_DELETE + ) + hass.components.websocket_api.async_register_command( + WS_TYPE_UPDATE, websocket_update_area, SCHEMA_WS_UPDATE + ) + return True + + +@async_response +async def websocket_list_areas(hass, connection, msg): + """Handle list areas command.""" + registry = await async_get_registry(hass) + connection.send_message( + websocket_api.result_message( + msg["id"], + [ + {"name": entry.name, "area_id": entry.id} + for entry in registry.async_list_areas() + ], + ) + ) + + +@require_admin +@async_response +async def websocket_create_area(hass, connection, msg): + """Create area command.""" + registry = await async_get_registry(hass) + try: + entry = registry.async_create(msg["name"]) + except ValueError as err: + connection.send_message( + websocket_api.error_message(msg["id"], "invalid_info", str(err)) + ) + else: + connection.send_message( + websocket_api.result_message(msg["id"], _entry_dict(entry)) + ) + + +@require_admin +@async_response +async def websocket_delete_area(hass, connection, msg): + """Delete area command.""" + registry = await async_get_registry(hass) + + try: + await registry.async_delete(msg["area_id"]) + except KeyError: + connection.send_message( + websocket_api.error_message( + msg["id"], "invalid_info", "Area ID doesn't exist" + ) + ) + else: + connection.send_message(websocket_api.result_message(msg["id"], "success")) + + +@require_admin +@async_response +async def websocket_update_area(hass, connection, msg): + """Handle update area websocket command.""" + registry = await async_get_registry(hass) + + try: + entry = registry.async_update(msg["area_id"], msg["name"]) + except ValueError as err: + connection.send_message( + websocket_api.error_message(msg["id"], "invalid_info", str(err)) + ) + else: + connection.send_message( + websocket_api.result_message(msg["id"], _entry_dict(entry)) + ) + + +@callback +def _entry_dict(entry): + """Convert entry to API format.""" + return {"area_id": entry.id, "name": entry.name} diff --git a/homeassistant/components/config/auth.py b/homeassistant/components/config/auth.py index 6f00b03de..361367ffb 100644 --- a/homeassistant/components/config/auth.py +++ b/homeassistant/components/config/auth.py @@ -1,113 +1,134 @@ """Offer API to configure Home Assistant auth.""" import voluptuous as vol -from homeassistant.core import callback from homeassistant.components import websocket_api +WS_TYPE_LIST = "config/auth/list" +SCHEMA_WS_LIST = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( + {vol.Required("type"): WS_TYPE_LIST} +) -WS_TYPE_LIST = 'config/auth/list' -SCHEMA_WS_LIST = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required('type'): WS_TYPE_LIST, -}) +WS_TYPE_DELETE = "config/auth/delete" +SCHEMA_WS_DELETE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( + {vol.Required("type"): WS_TYPE_DELETE, vol.Required("user_id"): str} +) -WS_TYPE_DELETE = 'config/auth/delete' -SCHEMA_WS_DELETE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required('type'): WS_TYPE_DELETE, - vol.Required('user_id'): str, -}) - -WS_TYPE_CREATE = 'config/auth/create' -SCHEMA_WS_CREATE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required('type'): WS_TYPE_CREATE, - vol.Required('name'): str, -}) +WS_TYPE_CREATE = "config/auth/create" +SCHEMA_WS_CREATE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( + {vol.Required("type"): WS_TYPE_CREATE, vol.Required("name"): str} +) async def async_setup(hass): """Enable the Home Assistant views.""" hass.components.websocket_api.async_register_command( - WS_TYPE_LIST, websocket_list, - SCHEMA_WS_LIST + WS_TYPE_LIST, websocket_list, SCHEMA_WS_LIST ) hass.components.websocket_api.async_register_command( - WS_TYPE_DELETE, websocket_delete, - SCHEMA_WS_DELETE + WS_TYPE_DELETE, websocket_delete, SCHEMA_WS_DELETE ) hass.components.websocket_api.async_register_command( - WS_TYPE_CREATE, websocket_create, - SCHEMA_WS_CREATE + WS_TYPE_CREATE, websocket_create, SCHEMA_WS_CREATE ) + hass.components.websocket_api.async_register_command(websocket_update) return True -@callback -@websocket_api.require_owner -def websocket_list(hass, connection, msg): +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_list(hass, connection, msg): """Return a list of users.""" - async def send_users(): - """Send users.""" - result = [_user_info(u) for u in await hass.auth.async_get_users()] + result = [_user_info(u) for u in await hass.auth.async_get_users()] - connection.send_message_outside( - websocket_api.result_message(msg['id'], result)) - - hass.async_add_job(send_users()) + connection.send_message(websocket_api.result_message(msg["id"], result)) -@callback -@websocket_api.require_owner -def websocket_delete(hass, connection, msg): +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_delete(hass, connection, msg): """Delete a user.""" - async def delete_user(): - """Delete user.""" - if msg['user_id'] == connection.request.get('hass_user').id: - connection.send_message_outside(websocket_api.error_message( - msg['id'], 'no_delete_self', - 'Unable to delete your own account')) - return + if msg["user_id"] == connection.user.id: + connection.send_message( + websocket_api.error_message( + msg["id"], "no_delete_self", "Unable to delete your own account" + ) + ) + return - user = await hass.auth.async_get_user(msg['user_id']) + user = await hass.auth.async_get_user(msg["user_id"]) - if not user: - connection.send_message_outside(websocket_api.error_message( - msg['id'], 'not_found', 'User not found')) - return + if not user: + connection.send_message( + websocket_api.error_message(msg["id"], "not_found", "User not found") + ) + return - await hass.auth.async_remove_user(user) + await hass.auth.async_remove_user(user) - connection.send_message_outside( - websocket_api.result_message(msg['id'])) - - hass.async_add_job(delete_user()) + connection.send_message(websocket_api.result_message(msg["id"])) -@callback -@websocket_api.require_owner -def websocket_create(hass, connection, msg): +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_create(hass, connection, msg): """Create a user.""" - async def create_user(): - """Create a user.""" - user = await hass.auth.async_create_user(msg['name']) + user = await hass.auth.async_create_user(msg["name"]) - connection.send_message_outside( - websocket_api.result_message(msg['id'], { - 'user': _user_info(user) - })) + connection.send_message( + websocket_api.result_message(msg["id"], {"user": _user_info(user)}) + ) - hass.async_add_job(create_user()) + +@websocket_api.require_admin +@websocket_api.async_response +@websocket_api.websocket_command( + { + vol.Required("type"): "config/auth/update", + vol.Required("user_id"): str, + vol.Optional("name"): str, + vol.Optional("group_ids"): [str], + } +) +async def websocket_update(hass, connection, msg): + """Update a user.""" + user = await hass.auth.async_get_user(msg.pop("user_id")) + + if not user: + connection.send_message( + websocket_api.error_message( + msg["id"], websocket_api.const.ERR_NOT_FOUND, "User not found" + ) + ) + return + + if user.system_generated: + connection.send_message( + websocket_api.error_message( + msg["id"], + "cannot_modify_system_generated", + "Unable to update system generated users.", + ) + ) + return + + msg.pop("type") + msg_id = msg.pop("id") + + await hass.auth.async_update_user(user, **msg) + + connection.send_message( + websocket_api.result_message(msg_id, {"user": _user_info(user)}) + ) def _user_info(user): """Format a user.""" return { - 'id': user.id, - 'name': user.name, - 'is_owner': user.is_owner, - 'is_active': user.is_active, - 'system_generated': user.system_generated, - 'credentials': [ - { - 'type': c.auth_provider_type, - } for c in user.credentials - ] + "id": user.id, + "name": user.name, + "is_owner": user.is_owner, + "is_active": user.is_active, + "system_generated": user.system_generated, + "group_ids": [group.id for group in user.groups], + "credentials": [{"type": c.auth_provider_type} for c in user.credentials], } diff --git a/homeassistant/components/config/auth_provider_homeassistant.py b/homeassistant/components/config/auth_provider_homeassistant.py index 960e8f5e7..dec7fb24d 100644 --- a/homeassistant/components/config/auth_provider_homeassistant.py +++ b/homeassistant/components/config/auth_provider_homeassistant.py @@ -2,45 +2,43 @@ import voluptuous as vol from homeassistant.auth.providers import homeassistant as auth_ha -from homeassistant.core import callback from homeassistant.components import websocket_api +WS_TYPE_CREATE = "config/auth_provider/homeassistant/create" +SCHEMA_WS_CREATE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( + { + vol.Required("type"): WS_TYPE_CREATE, + vol.Required("user_id"): str, + vol.Required("username"): str, + vol.Required("password"): str, + } +) -WS_TYPE_CREATE = 'config/auth_provider/homeassistant/create' -SCHEMA_WS_CREATE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required('type'): WS_TYPE_CREATE, - vol.Required('user_id'): str, - vol.Required('username'): str, - vol.Required('password'): str, -}) +WS_TYPE_DELETE = "config/auth_provider/homeassistant/delete" +SCHEMA_WS_DELETE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( + {vol.Required("type"): WS_TYPE_DELETE, vol.Required("username"): str} +) -WS_TYPE_DELETE = 'config/auth_provider/homeassistant/delete' -SCHEMA_WS_DELETE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required('type'): WS_TYPE_DELETE, - vol.Required('username'): str, -}) - -WS_TYPE_CHANGE_PASSWORD = 'config/auth_provider/homeassistant/change_password' -SCHEMA_WS_CHANGE_PASSWORD = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required('type'): WS_TYPE_CHANGE_PASSWORD, - vol.Required('current_password'): str, - vol.Required('new_password'): str -}) +WS_TYPE_CHANGE_PASSWORD = "config/auth_provider/homeassistant/change_password" +SCHEMA_WS_CHANGE_PASSWORD = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( + { + vol.Required("type"): WS_TYPE_CHANGE_PASSWORD, + vol.Required("current_password"): str, + vol.Required("new_password"): str, + } +) async def async_setup(hass): """Enable the Home Assistant views.""" hass.components.websocket_api.async_register_command( - WS_TYPE_CREATE, websocket_create, - SCHEMA_WS_CREATE + WS_TYPE_CREATE, websocket_create, SCHEMA_WS_CREATE ) hass.components.websocket_api.async_register_command( - WS_TYPE_DELETE, websocket_delete, - SCHEMA_WS_DELETE + WS_TYPE_DELETE, websocket_delete, SCHEMA_WS_DELETE ) hass.components.websocket_api.async_register_command( - WS_TYPE_CHANGE_PASSWORD, websocket_change_password, - SCHEMA_WS_CHANGE_PASSWORD + WS_TYPE_CHANGE_PASSWORD, websocket_change_password, SCHEMA_WS_CHANGE_PASSWORD ) return True @@ -48,127 +46,131 @@ async def async_setup(hass): def _get_provider(hass): """Get homeassistant auth provider.""" for prv in hass.auth.auth_providers: - if prv.type == 'homeassistant': + if prv.type == "homeassistant": return prv - raise RuntimeError('Provider not found') + raise RuntimeError("Provider not found") -@callback -@websocket_api.require_owner -def websocket_create(hass, connection, msg): +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_create(hass, connection, msg): """Create credentials and attach to a user.""" - async def create_creds(): - """Create credentials.""" - provider = _get_provider(hass) - await provider.async_initialize() + provider = _get_provider(hass) + await provider.async_initialize() - user = await hass.auth.async_get_user(msg['user_id']) + user = await hass.auth.async_get_user(msg["user_id"]) - if user is None: - connection.send_message_outside(websocket_api.error_message( - msg['id'], 'not_found', 'User not found')) - return + if user is None: + connection.send_message( + websocket_api.error_message(msg["id"], "not_found", "User not found") + ) + return - if user.system_generated: - connection.send_message_outside(websocket_api.error_message( - msg['id'], 'system_generated', - 'Cannot add credentials to a system generated user.')) - return - - try: - await hass.async_add_executor_job( - provider.data.add_auth, msg['username'], msg['password']) - except auth_ha.InvalidUser: - connection.send_message_outside(websocket_api.error_message( - msg['id'], 'username_exists', 'Username already exists')) - return - - credentials = await provider.async_get_or_create_credentials({ - 'username': msg['username'] - }) - await hass.auth.async_link_user(user, credentials) - - await provider.data.async_save() - connection.to_write.put_nowait(websocket_api.result_message(msg['id'])) - - hass.async_add_job(create_creds()) - - -@callback -@websocket_api.require_owner -def websocket_delete(hass, connection, msg): - """Delete username and related credential.""" - async def delete_creds(): - """Delete user credentials.""" - provider = _get_provider(hass) - await provider.async_initialize() - - credentials = await provider.async_get_or_create_credentials({ - 'username': msg['username'] - }) - - # if not new, an existing credential exists. - # Removing the credential will also remove the auth. - if not credentials.is_new: - await hass.auth.async_remove_credentials(credentials) - - connection.to_write.put_nowait( - websocket_api.result_message(msg['id'])) - return - - try: - provider.data.async_remove_auth(msg['username']) - await provider.data.async_save() - except auth_ha.InvalidUser: - connection.to_write.put_nowait(websocket_api.error_message( - msg['id'], 'auth_not_found', 'Given username was not found.')) - return - - connection.to_write.put_nowait( - websocket_api.result_message(msg['id'])) - - hass.async_add_job(delete_creds()) - - -@callback -def websocket_change_password(hass, connection, msg): - """Change user password.""" - async def change_password(): - """Change user password.""" - user = connection.request.get('hass_user') - if user is None: - connection.send_message_outside(websocket_api.error_message( - msg['id'], 'user_not_found', 'User not found')) - return - - provider = _get_provider(hass) - await provider.async_initialize() - - username = None - for credential in user.credentials: - if credential.auth_provider_type == provider.type: - username = credential.data['username'] - break - - if username is None: - connection.send_message_outside(websocket_api.error_message( - msg['id'], 'credentials_not_found', 'Credentials not found')) - return - - try: - await provider.async_validate_login( - username, msg['current_password']) - except auth_ha.InvalidAuth: - connection.send_message_outside(websocket_api.error_message( - msg['id'], 'invalid_password', 'Invalid password')) - return + if user.system_generated: + connection.send_message( + websocket_api.error_message( + msg["id"], + "system_generated", + "Cannot add credentials to a system generated user.", + ) + ) + return + try: await hass.async_add_executor_job( - provider.data.change_password, username, msg['new_password']) + provider.data.add_auth, msg["username"], msg["password"] + ) + except auth_ha.InvalidUser: + connection.send_message( + websocket_api.error_message( + msg["id"], "username_exists", "Username already exists" + ) + ) + return + + credentials = await provider.async_get_or_create_credentials( + {"username": msg["username"]} + ) + await hass.auth.async_link_user(user, credentials) + + await provider.data.async_save() + connection.send_message(websocket_api.result_message(msg["id"])) + + +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_delete(hass, connection, msg): + """Delete username and related credential.""" + provider = _get_provider(hass) + await provider.async_initialize() + + credentials = await provider.async_get_or_create_credentials( + {"username": msg["username"]} + ) + + # if not new, an existing credential exists. + # Removing the credential will also remove the auth. + if not credentials.is_new: + await hass.auth.async_remove_credentials(credentials) + + connection.send_message(websocket_api.result_message(msg["id"])) + return + + try: + provider.data.async_remove_auth(msg["username"]) await provider.data.async_save() + except auth_ha.InvalidUser: + connection.send_message( + websocket_api.error_message( + msg["id"], "auth_not_found", "Given username was not found." + ) + ) + return - connection.send_message_outside( - websocket_api.result_message(msg['id'])) + connection.send_message(websocket_api.result_message(msg["id"])) - hass.async_add_job(change_password()) + +@websocket_api.async_response +async def websocket_change_password(hass, connection, msg): + """Change user password.""" + user = connection.user + if user is None: + connection.send_message( + websocket_api.error_message(msg["id"], "user_not_found", "User not found") + ) + return + + provider = _get_provider(hass) + await provider.async_initialize() + + username = None + for credential in user.credentials: + if credential.auth_provider_type == provider.type: + username = credential.data["username"] + break + + if username is None: + connection.send_message( + websocket_api.error_message( + msg["id"], "credentials_not_found", "Credentials not found" + ) + ) + return + + try: + await provider.async_validate_login(username, msg["current_password"]) + except auth_ha.InvalidAuth: + connection.send_message( + websocket_api.error_message( + msg["id"], "invalid_password", "Invalid password" + ) + ) + return + + await hass.async_add_executor_job( + provider.data.change_password, username, msg["new_password"] + ) + await provider.data.async_save() + + connection.send_message(websocket_api.result_message(msg["id"])) diff --git a/homeassistant/components/config/automation.py b/homeassistant/components/config/automation.py index 223159eb4..d7bb1ef98 100644 --- a/homeassistant/components/config/automation.py +++ b/homeassistant/components/config/automation.py @@ -1,25 +1,34 @@ """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) +from homeassistant.components.automation import DOMAIN, PLATFORM_SCHEMA +from homeassistant.components.automation.config import async_validate_config_item +from homeassistant.config import AUTOMATION_CONFIG_PATH +from homeassistant.const import CONF_ID, SERVICE_RELOAD import homeassistant.helpers.config_validation as cv - -CONFIG_PATH = 'automations.yaml' +from . import EditIdBasedConfigView -@asyncio.coroutine -def async_setup(hass): +async def async_setup(hass): """Set up the Automation config API.""" - hass.http.register_view(EditAutomationConfigView( - DOMAIN, 'config', CONFIG_PATH, cv.string, - PLATFORM_SCHEMA, post_write_hook=async_reload - )) + + async def hook(hass): + """post_write_hook for Config View that reloads automations.""" + await hass.services.async_call(DOMAIN, SERVICE_RELOAD) + + hass.http.register_view( + EditAutomationConfigView( + DOMAIN, + "config", + AUTOMATION_CONFIG_PATH, + cv.string, + PLATFORM_SCHEMA, + post_write_hook=hook, + data_validator=async_validate_config_item, + ) + ) return True @@ -45,7 +54,7 @@ class EditAutomationConfigView(EditIdBasedConfigView): # Iterate through some keys that we want to have ordered in the output updated_value = OrderedDict() - for key in ('id', 'alias', 'trigger', 'condition', 'action'): + for key in ("id", "alias", "description", "trigger", "condition", "action"): if key in cur_value: updated_value[key] = cur_value[key] if key in new_value: diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index 73b2767be..dbf0ee8f2 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -1,39 +1,55 @@ """Http views to control the config manager.""" -import asyncio +import aiohttp.web_exceptions +import voluptuous as vol +import voluptuous_serialize from homeassistant import config_entries, data_entry_flow +from homeassistant.auth.permissions.const import CAT_CONFIG_ENTRIES +from homeassistant.components import websocket_api from homeassistant.components.http import HomeAssistantView +from homeassistant.exceptions import Unauthorized from homeassistant.helpers.data_entry_flow import ( - FlowManagerIndexView, FlowManagerResourceView) + FlowManagerIndexView, + FlowManagerResourceView, +) +from homeassistant.loader import async_get_config_flows -@asyncio.coroutine -def async_setup(hass): +async 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(ConfigManagerFlowIndexView(hass.config_entries.flow)) + hass.http.register_view(ConfigManagerFlowResourceView(hass.config_entries.flow)) hass.http.register_view(ConfigManagerAvailableFlowView) + + hass.http.register_view( + OptionManagerFlowIndexView(hass.config_entries.options.flow) + ) + hass.http.register_view( + OptionManagerFlowResourceView(hass.config_entries.options.flow) + ) + + hass.components.websocket_api.async_register_command(config_entries_progress) + hass.components.websocket_api.async_register_command(system_options_list) + hass.components.websocket_api.async_register_command(system_options_update) + hass.components.websocket_api.async_register_command(ignore_config_flow) + return True def _prepare_json(result): """Convert result for JSON.""" - if result['type'] != data_entry_flow.RESULT_TYPE_FORM: + if result["type"] != data_entry_flow.RESULT_TYPE_FORM: return result - import voluptuous_serialize - data = result.copy() - schema = data['data_schema'] + schema = data["data_schema"] if schema is None: - data['data_schema'] = [] + data["data_schema"] = [] else: - data['data_schema'] = voluptuous_serialize.convert(schema) + data["data_schema"] = voluptuous_serialize.convert(schema) return data @@ -41,38 +57,56 @@ def _prepare_json(result): class ConfigManagerEntryIndexView(HomeAssistantView): """View to get available config entries.""" - url = '/api/config/config_entries/entry' - name = 'api:config:config_entries:entry' + 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, - 'connection_class': entry.connection_class, - } for entry in hass.config_entries.async_entries()]) + async def get(self, request): + """List available config entries.""" + hass = request.app["hass"] + + results = [] + + for entry in hass.config_entries.async_entries(): + handler = config_entries.HANDLERS.get(entry.domain) + supports_options = ( + # Guard in case handler is no longer registered (custom compnoent etc) + handler is not None + # pylint: disable=comparison-with-callable + and handler.async_get_options_flow + != config_entries.ConfigFlow.async_get_options_flow + ) + results.append( + { + "entry_id": entry.entry_id, + "domain": entry.domain, + "title": entry.title, + "source": entry.source, + "state": entry.state, + "connection_class": entry.connection_class, + "supports_options": supports_options, + } + ) + + return self.json(results) 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' + url = "/api/config/config_entries/entry/{entry_id}" + name = "api:config:config_entries:entry:resource" - @asyncio.coroutine - def delete(self, request, entry_id): + async def delete(self, request, entry_id): """Delete a config entry.""" - hass = request.app['hass'] + if not request["hass_user"].is_admin: + raise Unauthorized(config_entry_id=entry_id, permission="remove") + + hass = request.app["hass"] try: - result = yield from hass.config_entries.async_remove(entry_id) + result = await hass.config_entries.async_remove(entry_id) except config_entries.UnknownEntry: - return self.json_message('Invalid entry specified', 404) + return self.json_message("Invalid entry specified", 404) return self.json(result) @@ -80,37 +114,208 @@ class ConfigManagerEntryResourceView(HomeAssistantView): class ConfigManagerFlowIndexView(FlowManagerIndexView): """View to create config flows.""" - url = '/api/config/config_entries/flow' - name = 'api:config:config_entries:flow' + 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. + async def get(self, request): + """Not implemented.""" + raise aiohttp.web_exceptions.HTTPMethodNotAllowed("GET", ["POST"]) - Example of a non-user initiated flow is a discovered Hue hub that - requires user interaction to finish setup. - """ - hass = request.app['hass'] + # pylint: disable=arguments-differ + async def post(self, request): + """Handle a POST request.""" + if not request["hass_user"].is_admin: + raise Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission="add") - return self.json([ - flw for flw in hass.config_entries.flow.async_progress() - if flw['context']['source'] != config_entries.SOURCE_USER]) + # pylint: disable=no-value-for-parameter + return await super().post(request) + + def _prepare_result_json(self, result): + """Convert result to JSON.""" + if result["type"] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY: + return super()._prepare_result_json(result) + + data = result.copy() + data["result"] = data["result"].entry_id + data.pop("data") + return data 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' + url = "/api/config/config_entries/flow/{flow_id}" + name = "api:config:config_entries:flow:resource" + + async def get(self, request, flow_id): + """Get the current state of a data_entry_flow.""" + if not request["hass_user"].is_admin: + raise Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission="add") + + return await super().get(request, flow_id) + + # pylint: disable=arguments-differ + async def post(self, request, flow_id): + """Handle a POST request.""" + if not request["hass_user"].is_admin: + raise Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission="add") + + # pylint: disable=no-value-for-parameter + return await super().post(request, flow_id) + + def _prepare_result_json(self, result): + """Convert result to JSON.""" + if result["type"] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY: + return super()._prepare_result_json(result) + + data = result.copy() + data["result"] = data["result"].entry_id + data.pop("data") + return data class ConfigManagerAvailableFlowView(HomeAssistantView): """View to query available flows.""" - url = '/api/config/config_entries/flow_handlers' - name = 'api:config:config_entries:flow_handlers' + url = "/api/config/config_entries/flow_handlers" + name = "api:config:config_entries:flow_handlers" - @asyncio.coroutine - def get(self, request): + async def get(self, request): """List available flow handlers.""" - return self.json(config_entries.FLOWS) + hass = request.app["hass"] + return self.json(await async_get_config_flows(hass)) + + +class OptionManagerFlowIndexView(FlowManagerIndexView): + """View to create option flows.""" + + url = "/api/config/config_entries/options/flow" + name = "api:config:config_entries:option:flow" + + # pylint: disable=arguments-differ + async def post(self, request): + """Handle a POST request. + + handler in request is entry_id. + """ + if not request["hass_user"].is_admin: + raise Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission="edit") + + # pylint: disable=no-value-for-parameter + return await super().post(request) + + +class OptionManagerFlowResourceView(FlowManagerResourceView): + """View to interact with the option flow manager.""" + + url = "/api/config/config_entries/options/flow/{flow_id}" + name = "api:config:config_entries:options:flow:resource" + + async def get(self, request, flow_id): + """Get the current state of a data_entry_flow.""" + if not request["hass_user"].is_admin: + raise Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission="edit") + + return await super().get(request, flow_id) + + # pylint: disable=arguments-differ + async def post(self, request, flow_id): + """Handle a POST request.""" + if not request["hass_user"].is_admin: + raise Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission="edit") + + # pylint: disable=no-value-for-parameter + return await super().post(request, flow_id) + + +@websocket_api.require_admin +@websocket_api.websocket_command({"type": "config_entries/flow/progress"}) +def config_entries_progress(hass, connection, msg): + """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. + """ + connection.send_result( + msg["id"], + [ + flw + for flw in hass.config_entries.flow.async_progress() + if flw["context"]["source"] != config_entries.SOURCE_USER + ], + ) + + +@websocket_api.require_admin +@websocket_api.async_response +@websocket_api.websocket_command( + {"type": "config_entries/system_options/list", "entry_id": str} +) +async def system_options_list(hass, connection, msg): + """List all system options for a config entry.""" + entry_id = msg["entry_id"] + entry = hass.config_entries.async_get_entry(entry_id) + + if entry: + connection.send_result(msg["id"], entry.system_options.as_dict()) + + +@websocket_api.require_admin +@websocket_api.async_response +@websocket_api.websocket_command( + { + "type": "config_entries/system_options/update", + "entry_id": str, + vol.Optional("disable_new_entities"): bool, + } +) +async def system_options_update(hass, connection, msg): + """Update config entry system options.""" + changes = dict(msg) + changes.pop("id") + changes.pop("type") + entry_id = changes.pop("entry_id") + entry = hass.config_entries.async_get_entry(entry_id) + + if entry is None: + connection.send_error( + msg["id"], websocket_api.const.ERR_NOT_FOUND, "Config entry not found" + ) + return + + hass.config_entries.async_update_entry(entry, system_options=changes) + connection.send_result(msg["id"], entry.system_options.as_dict()) + + +@websocket_api.require_admin +@websocket_api.async_response +@websocket_api.websocket_command({"type": "config_entries/ignore_flow", "flow_id": str}) +async def ignore_config_flow(hass, connection, msg): + """Ignore a config flow.""" + flow = next( + ( + flw + for flw in hass.config_entries.flow.async_progress() + if flw["flow_id"] == msg["flow_id"] + ), + None, + ) + + if flow is None: + connection.send_error( + msg["id"], websocket_api.const.ERR_NOT_FOUND, "Config entry not found" + ) + return + + if "unique_id" not in flow["context"]: + connection.send_error( + msg["id"], "no_unique_id", "Specified flow has no unique ID." + ) + return + + await hass.config_entries.flow.async_init( + flow["handler"], + context={"source": config_entries.SOURCE_IGNORE}, + data={"unique_id": flow["context"]["unique_id"]}, + ) + connection.send_result(msg["id"]) diff --git a/homeassistant/components/config/core.py b/homeassistant/components/config/core.py index 4ff530ad2..e9ceb7eac 100644 --- a/homeassistant/components/config/core.py +++ b/homeassistant/components/config/core.py @@ -1,31 +1,90 @@ """Component to interact with Hassbian tools.""" -import asyncio +import voluptuous as vol + +from homeassistant.components import websocket_api from homeassistant.components.http import HomeAssistantView from homeassistant.config import async_check_ha_config_file +from homeassistant.const import CONF_UNIT_SYSTEM_IMPERIAL, CONF_UNIT_SYSTEM_METRIC +from homeassistant.helpers import config_validation as cv +from homeassistant.util import location -@asyncio.coroutine -def async_setup(hass): +async def async_setup(hass): """Set up the Hassbian config.""" hass.http.register_view(CheckConfigView) + websocket_api.async_register_command(hass, websocket_update_config) + websocket_api.async_register_command(hass, websocket_detect_config) return True class CheckConfigView(HomeAssistantView): """Hassbian packages endpoint.""" - url = '/api/config/core/check_config' - name = 'api:config:core:check_config' + url = "/api/config/core/check_config" + name = "api:config:core:check_config" - @asyncio.coroutine - def post(self, request): + async def post(self, request): """Validate configuration and return results.""" - errors = yield from async_check_ha_config_file(request.app['hass']) + errors = await async_check_ha_config_file(request.app["hass"]) - state = 'invalid' if errors else 'valid' + state = "invalid" if errors else "valid" - return self.json({ - "result": state, - "errors": errors, - }) + return self.json({"result": state, "errors": errors}) + + +@websocket_api.require_admin +@websocket_api.async_response +@websocket_api.websocket_command( + { + "type": "config/core/update", + vol.Optional("latitude"): cv.latitude, + vol.Optional("longitude"): cv.longitude, + vol.Optional("elevation"): int, + vol.Optional("unit_system"): cv.unit_system, + vol.Optional("location_name"): str, + vol.Optional("time_zone"): cv.time_zone, + } +) +async def websocket_update_config(hass, connection, msg): + """Handle update core config command.""" + data = dict(msg) + data.pop("id") + data.pop("type") + + try: + await hass.config.async_update(**data) + connection.send_result(msg["id"]) + except ValueError as err: + connection.send_error(msg["id"], "invalid_info", str(err)) + + +@websocket_api.require_admin +@websocket_api.async_response +@websocket_api.websocket_command({"type": "config/core/detect"}) +async def websocket_detect_config(hass, connection, msg): + """Detect core config.""" + session = hass.helpers.aiohttp_client.async_get_clientsession() + location_info = await location.async_detect_location_info(session) + + info = {} + + if location_info is None: + connection.send_result(msg["id"], info) + return + + if location_info.use_metric: + info["unit_system"] = CONF_UNIT_SYSTEM_METRIC + else: + info["unit_system"] = CONF_UNIT_SYSTEM_IMPERIAL + + if location_info.latitude: + info["latitude"] = location_info.latitude + + if location_info.longitude: + info["longitude"] = location_info.longitude + + if location_info.time_zone: + info["time_zone"] = location_info.time_zone + + connection.send_result(msg["id"], info) diff --git a/homeassistant/components/config/customize.py b/homeassistant/components/config/customize.py index d25992ecc..ed75a8a04 100644 --- a/homeassistant/components/config/customize.py +++ b/homeassistant/components/config/customize.py @@ -1,22 +1,26 @@ """Provide configuration end points for Customize.""" -import asyncio - -from homeassistant.components.config import EditKeyBasedConfigView -from homeassistant.components import async_reload_core_config +from homeassistant.components.homeassistant import SERVICE_RELOAD_CORE_CONFIG from homeassistant.config import DATA_CUSTOMIZE - +from homeassistant.core import DOMAIN import homeassistant.helpers.config_validation as cv -CONFIG_PATH = 'customize.yaml' +from . import EditKeyBasedConfigView + +CONFIG_PATH = "customize.yaml" -@asyncio.coroutine -def async_setup(hass): +async def async_setup(hass): """Set up the Customize config API.""" - hass.http.register_view(CustomizeConfigView( - 'customize', 'config', CONFIG_PATH, cv.entity_id, dict, - post_write_hook=async_reload_core_config - )) + + async def hook(hass): + """post_write_hook for Config View that reloads groups.""" + await hass.services.async_call(DOMAIN, SERVICE_RELOAD_CORE_CONFIG) + + hass.http.register_view( + CustomizeConfigView( + "customize", "config", CONFIG_PATH, cv.entity_id, dict, post_write_hook=hook + ) + ) return True @@ -27,7 +31,7 @@ class CustomizeConfigView(EditKeyBasedConfigView): def _get_value(self, hass, data, config_key): """Get value.""" customize = hass.data.get(DATA_CUSTOMIZE, {}).get(config_key) or {} - return {'global': customize, 'local': data.get(config_key, {})} + return {"global": customize, "local": data.get(config_key, {})} def _write_value(self, hass, data, config_key, new_value): """Set value.""" diff --git a/homeassistant/components/config/device_registry.py b/homeassistant/components/config/device_registry.py index 88aa5727a..08f53f948 100644 --- a/homeassistant/components/config/device_registry.py +++ b/homeassistant/components/config/device_registry.py @@ -1,47 +1,78 @@ """HTTP views to interact with the device registry.""" import voluptuous as vol +from homeassistant.components import websocket_api +from homeassistant.components.websocket_api.decorators import ( + async_response, + require_admin, +) from homeassistant.core import callback from homeassistant.helpers.device_registry import async_get_registry -from homeassistant.components import websocket_api -DEPENDENCIES = ['websocket_api'] +WS_TYPE_LIST = "config/device_registry/list" +SCHEMA_WS_LIST = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( + {vol.Required("type"): WS_TYPE_LIST} +) -WS_TYPE_LIST = 'config/device_registry/list' -SCHEMA_WS_LIST = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required('type'): WS_TYPE_LIST, -}) +WS_TYPE_UPDATE = "config/device_registry/update" +SCHEMA_WS_UPDATE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( + { + vol.Required("type"): WS_TYPE_UPDATE, + vol.Required("device_id"): str, + vol.Optional("area_id"): vol.Any(str, None), + vol.Optional("name_by_user"): vol.Any(str, None), + } +) async def async_setup(hass): - """Enable the Entity Registry views.""" + """Enable the Device Registry views.""" hass.components.websocket_api.async_register_command( - WS_TYPE_LIST, websocket_list_devices, - SCHEMA_WS_LIST + WS_TYPE_LIST, websocket_list_devices, SCHEMA_WS_LIST + ) + hass.components.websocket_api.async_register_command( + WS_TYPE_UPDATE, websocket_update_device, SCHEMA_WS_UPDATE ) return True +@async_response +async def websocket_list_devices(hass, connection, msg): + """Handle list devices command.""" + registry = await async_get_registry(hass) + connection.send_message( + websocket_api.result_message( + msg["id"], [_entry_dict(entry) for entry in registry.devices.values()] + ) + ) + + +@require_admin +@async_response +async def websocket_update_device(hass, connection, msg): + """Handle update area websocket command.""" + registry = await async_get_registry(hass) + + msg.pop("type") + msg_id = msg.pop("id") + + entry = registry.async_update_device(**msg) + + connection.send_message(websocket_api.result_message(msg_id, _entry_dict(entry))) + + @callback -def websocket_list_devices(hass, connection, msg): - """Handle list devices command. - - Async friendly. - """ - async def retrieve_entities(): - """Get devices from registry.""" - registry = await async_get_registry(hass) - connection.send_message_outside(websocket_api.result_message( - msg['id'], [{ - 'config_entries': list(entry.config_entries), - 'connections': list(entry.connections), - 'manufacturer': entry.manufacturer, - 'model': entry.model, - 'name': entry.name, - 'sw_version': entry.sw_version, - 'id': entry.id, - 'hub_device_id': entry.hub_device_id, - } for entry in registry.devices.values()] - )) - - hass.async_add_job(retrieve_entities()) +def _entry_dict(entry): + """Convert entry to API format.""" + return { + "config_entries": list(entry.config_entries), + "connections": list(entry.connections), + "manufacturer": entry.manufacturer, + "model": entry.model, + "name": entry.name, + "sw_version": entry.sw_version, + "id": entry.id, + "via_device_id": entry.via_device_id, + "area_id": entry.area_id, + "name_by_user": entry.name_by_user, + } diff --git a/homeassistant/components/config/entity_registry.py b/homeassistant/components/config/entity_registry.py index 0f9abf167..458a9dd3e 100644 --- a/homeassistant/components/config/entity_registry.py +++ b/homeassistant/components/config/entity_registry.py @@ -1,140 +1,155 @@ """HTTP views to interact with the entity registry.""" import voluptuous as vol -from homeassistant.core import callback -from homeassistant.helpers.entity_registry import async_get_registry from homeassistant.components import websocket_api +from homeassistant.components.websocket_api.const import ERR_NOT_FOUND +from homeassistant.components.websocket_api.decorators import ( + async_response, + require_admin, +) +from homeassistant.core import callback from homeassistant.helpers import config_validation as cv - -DEPENDENCIES = ['websocket_api'] - -WS_TYPE_LIST = 'config/entity_registry/list' -SCHEMA_WS_LIST = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required('type'): WS_TYPE_LIST, -}) - -WS_TYPE_GET = 'config/entity_registry/get' -SCHEMA_WS_GET = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required('type'): WS_TYPE_GET, - vol.Required('entity_id'): cv.entity_id -}) - -WS_TYPE_UPDATE = 'config/entity_registry/update' -SCHEMA_WS_UPDATE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required('type'): WS_TYPE_UPDATE, - vol.Required('entity_id'): cv.entity_id, - # If passed in, we update value. Passing None will remove old value. - vol.Optional('name'): vol.Any(str, None), - vol.Optional('new_entity_id'): str, -}) +from homeassistant.helpers.entity_registry import async_get_registry async def async_setup(hass): """Enable the Entity Registry views.""" - hass.components.websocket_api.async_register_command( - WS_TYPE_LIST, websocket_list_entities, - SCHEMA_WS_LIST - ) - hass.components.websocket_api.async_register_command( - WS_TYPE_GET, websocket_get_entity, - SCHEMA_WS_GET - ) - hass.components.websocket_api.async_register_command( - WS_TYPE_UPDATE, websocket_update_entity, - SCHEMA_WS_UPDATE - ) + hass.components.websocket_api.async_register_command(websocket_list_entities) + hass.components.websocket_api.async_register_command(websocket_get_entity) + hass.components.websocket_api.async_register_command(websocket_update_entity) + hass.components.websocket_api.async_register_command(websocket_remove_entity) return True -@callback -def websocket_list_entities(hass, connection, msg): +@async_response +@websocket_api.websocket_command({vol.Required("type"): "config/entity_registry/list"}) +async def websocket_list_entities(hass, connection, msg): """Handle list registry entries command. Async friendly. """ - async def retrieve_entities(): - """Get entities from registry.""" - registry = await async_get_registry(hass) - connection.send_message_outside(websocket_api.result_message( - msg['id'], [{ - 'config_entry_id': entry.config_entry_id, - 'device_id': entry.device_id, - 'disabled_by': entry.disabled_by, - 'entity_id': entry.entity_id, - 'name': entry.name, - 'platform': entry.platform, - } for entry in registry.entities.values()] - )) - - hass.async_add_job(retrieve_entities()) + registry = await async_get_registry(hass) + connection.send_message( + websocket_api.result_message( + msg["id"], [_entry_dict(entry) for entry in registry.entities.values()] + ) + ) -@callback -def websocket_get_entity(hass, connection, msg): +@async_response +@websocket_api.websocket_command( + { + vol.Required("type"): "config/entity_registry/get", + vol.Required("entity_id"): cv.entity_id, + } +) +async def websocket_get_entity(hass, connection, msg): """Handle get entity registry entry command. Async friendly. """ - async def retrieve_entity(): - """Get entity from registry.""" - registry = await async_get_registry(hass) - entry = registry.entities.get(msg['entity_id']) + registry = await async_get_registry(hass) + entry = registry.entities.get(msg["entity_id"]) - if entry is None: - connection.send_message_outside(websocket_api.error_message( - msg['id'], websocket_api.ERR_NOT_FOUND, 'Entity not found')) - return + if entry is None: + connection.send_message( + websocket_api.error_message(msg["id"], ERR_NOT_FOUND, "Entity not found") + ) + return - connection.send_message_outside(websocket_api.result_message( - msg['id'], _entry_dict(entry) - )) - - hass.async_add_job(retrieve_entity()) + connection.send_message(websocket_api.result_message(msg["id"], _entry_dict(entry))) -@callback -def websocket_update_entity(hass, connection, msg): - """Handle get camera thumbnail websocket command. +@require_admin +@async_response +@websocket_api.websocket_command( + { + vol.Required("type"): "config/entity_registry/update", + vol.Required("entity_id"): cv.entity_id, + # If passed in, we update value. Passing None will remove old value. + vol.Optional("name"): vol.Any(str, None), + vol.Optional("new_entity_id"): str, + # We only allow setting disabled_by user via API. + vol.Optional("disabled_by"): vol.Any("user", None), + } +) +async def websocket_update_entity(hass, connection, msg): + """Handle update entity websocket command. Async friendly. """ - async def update_entity(): - """Get entity from registry.""" - registry = await async_get_registry(hass) + registry = await async_get_registry(hass) - if msg['entity_id'] not in registry.entities: - connection.send_message_outside(websocket_api.error_message( - msg['id'], websocket_api.ERR_NOT_FOUND, 'Entity not found')) + if msg["entity_id"] not in registry.entities: + connection.send_message( + websocket_api.error_message(msg["id"], ERR_NOT_FOUND, "Entity not found") + ) + return + + changes = {} + + if "name" in msg: + changes["name"] = msg["name"] + + if "disabled_by" in msg: + changes["disabled_by"] = msg["disabled_by"] + + if "new_entity_id" in msg and msg["new_entity_id"] != msg["entity_id"]: + changes["new_entity_id"] = msg["new_entity_id"] + if hass.states.get(msg["new_entity_id"]) is not None: + connection.send_message( + websocket_api.error_message( + msg["id"], "invalid_info", "Entity is already registered" + ) + ) return - changes = {} + try: + if changes: + entry = registry.async_update_entity(msg["entity_id"], **changes) + except ValueError as err: + connection.send_message( + websocket_api.error_message(msg["id"], "invalid_info", str(err)) + ) + else: + connection.send_message( + websocket_api.result_message(msg["id"], _entry_dict(entry)) + ) - if 'name' in msg: - changes['name'] = msg['name'] - if 'new_entity_id' in msg: - changes['new_entity_id'] = msg['new_entity_id'] +@require_admin +@async_response +@websocket_api.websocket_command( + { + vol.Required("type"): "config/entity_registry/remove", + vol.Required("entity_id"): cv.entity_id, + } +) +async def websocket_remove_entity(hass, connection, msg): + """Handle remove entity websocket command. - try: - if changes: - entry = registry.async_update_entity( - msg['entity_id'], **changes) - except ValueError as err: - connection.send_message_outside(websocket_api.error_message( - msg['id'], 'invalid_info', str(err) - )) - else: - connection.send_message_outside(websocket_api.result_message( - msg['id'], _entry_dict(entry) - )) + Async friendly. + """ + registry = await async_get_registry(hass) - hass.async_create_task(update_entity()) + if msg["entity_id"] not in registry.entities: + connection.send_message( + websocket_api.error_message(msg["id"], ERR_NOT_FOUND, "Entity not found") + ) + return + + registry.async_remove(msg["entity_id"]) + connection.send_message(websocket_api.result_message(msg["id"])) @callback def _entry_dict(entry): """Convert entry to API format.""" return { - 'entity_id': entry.entity_id, - 'name': entry.name + "config_entry_id": entry.config_entry_id, + "device_id": entry.device_id, + "disabled_by": entry.disabled_by, + "entity_id": entry.entity_id, + "name": entry.name, + "platform": entry.platform, } diff --git a/homeassistant/components/config/group.py b/homeassistant/components/config/group.py index 8b327faa9..d95891af6 100644 --- a/homeassistant/components/config/group.py +++ b/homeassistant/components/config/group.py @@ -1,24 +1,27 @@ """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 DOMAIN, GROUP_SCHEMA +from homeassistant.config import GROUP_CONFIG_PATH +from homeassistant.const import SERVICE_RELOAD import homeassistant.helpers.config_validation as cv - -CONFIG_PATH = 'groups.yaml' +from . import EditKeyBasedConfigView -@asyncio.coroutine -def async_setup(hass): +async 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, - post_write_hook=hook - )) + async def hook(hass): + """post_write_hook for Config View that reloads groups.""" + await hass.services.async_call(DOMAIN, SERVICE_RELOAD) + + hass.http.register_view( + EditKeyBasedConfigView( + "group", + "config", + GROUP_CONFIG_PATH, + cv.slug, + GROUP_SCHEMA, + post_write_hook=hook, + ) + ) return True diff --git a/homeassistant/components/config/hassbian.py b/homeassistant/components/config/hassbian.py deleted file mode 100644 index 8de5f62d9..000000000 --- a/homeassistant/components/config/hassbian.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Component to interact with Hassbian tools.""" -import asyncio -import json -import os - -from homeassistant.components.http import HomeAssistantView - - -_TEST_OUTPUT = """ -{ - "suites":{ - "libcec":{ - "state":"Uninstalled", - "description":"Installs the libcec package for controlling CEC devices from this Pi" - }, - "mosquitto":{ - "state":"failed", - "description":"Installs the Mosquitto package for setting up a local MQTT server" - }, - "openzwave":{ - "state":"Uninstalled", - "description":"Installs the Open Z-wave package for setting up your zwave network" - }, - "samba":{ - "state":"installing", - "description":"Installs the samba package for sharing the hassbian configuration files over the Pi's network." - } - } -} -""" # noqa - - -@asyncio.coroutine -def async_setup(hass): - """Set up the Hassbian config.""" - # Test if is Hassbian - test_mode = 'FORCE_HASSBIAN' in os.environ - is_hassbian = test_mode - - if not is_hassbian: - return False - - hass.http.register_view(HassbianSuitesView(test_mode)) - hass.http.register_view(HassbianSuiteInstallView(test_mode)) - - return True - - -@asyncio.coroutine -def hassbian_status(hass, test_mode=False): - """Query for the Hassbian status.""" - # Fetch real output when not in test mode - if test_mode: - return json.loads(_TEST_OUTPUT) - - raise Exception('Real mode not implemented yet.') - - -class HassbianSuitesView(HomeAssistantView): - """Hassbian packages endpoint.""" - - url = '/api/config/hassbian/suites' - name = 'api:config:hassbian:suites' - - def __init__(self, test_mode): - """Initialize suites view.""" - self._test_mode = test_mode - - @asyncio.coroutine - def get(self, request): - """Request suite status.""" - inp = yield from hassbian_status(request.app['hass'], self._test_mode) - - return self.json(inp['suites']) - - -class HassbianSuiteInstallView(HomeAssistantView): - """Hassbian packages endpoint.""" - - url = '/api/config/hassbian/suites/{suite}/install' - name = 'api:config:hassbian:suite' - - def __init__(self, test_mode): - """Initialize suite view.""" - self._test_mode = test_mode - - @asyncio.coroutine - def post(self, request, suite): - """Request suite status.""" - # do real install if not in test mode - return self.json({"status": "ok"}) diff --git a/homeassistant/components/config/manifest.json b/homeassistant/components/config/manifest.json new file mode 100644 index 000000000..c6e99b43c --- /dev/null +++ b/homeassistant/components/config/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "config", + "name": "Config", + "documentation": "https://www.home-assistant.io/integrations/config", + "requirements": [], + "dependencies": [ + "http" + ], + "codeowners": [ + "@home-assistant/core" + ] +} diff --git a/homeassistant/components/config/scene.py b/homeassistant/components/config/scene.py new file mode 100644 index 000000000..79a30177e --- /dev/null +++ b/homeassistant/components/config/scene.py @@ -0,0 +1,65 @@ +"""Provide configuration end points for Scenes.""" +from collections import OrderedDict +import uuid + +from homeassistant.components.scene import DOMAIN, PLATFORM_SCHEMA +from homeassistant.config import SCENE_CONFIG_PATH +from homeassistant.const import CONF_ID, SERVICE_RELOAD +import homeassistant.helpers.config_validation as cv + +from . import EditIdBasedConfigView + + +async def async_setup(hass): + """Set up the Scene config API.""" + + async def hook(hass): + """post_write_hook for Config View that reloads scenes.""" + await hass.services.async_call(DOMAIN, SERVICE_RELOAD) + + hass.http.register_view( + EditSceneConfigView( + DOMAIN, + "config", + SCENE_CONFIG_PATH, + cv.string, + PLATFORM_SCHEMA, + post_write_hook=hook, + ) + ) + return True + + +class EditSceneConfigView(EditIdBasedConfigView): + """Edit scene 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 scenes 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 = dict() + 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", "name", "entities"): + 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/script.py b/homeassistant/components/config/script.py index 345c8e4a8..032774de4 100644 --- a/homeassistant/components/config/script.py +++ b/homeassistant/components/config/script.py @@ -1,19 +1,27 @@ """Provide configuration end points for scripts.""" -import asyncio - -from homeassistant.components.config import EditKeyBasedConfigView -from homeassistant.components.script import SCRIPT_ENTRY_SCHEMA, async_reload +from homeassistant.components.script import DOMAIN, SCRIPT_ENTRY_SCHEMA +from homeassistant.config import SCRIPT_CONFIG_PATH +from homeassistant.const import SERVICE_RELOAD import homeassistant.helpers.config_validation as cv - -CONFIG_PATH = 'scripts.yaml' +from . import EditKeyBasedConfigView -@asyncio.coroutine -def async_setup(hass): +async def async_setup(hass): """Set up the script config API.""" - hass.http.register_view(EditKeyBasedConfigView( - 'script', 'config', CONFIG_PATH, cv.slug, SCRIPT_ENTRY_SCHEMA, - post_write_hook=async_reload - )) + + async def hook(hass): + """post_write_hook for Config View that reloads scripts.""" + await hass.services.async_call(DOMAIN, SERVICE_RELOAD) + + hass.http.register_view( + EditKeyBasedConfigView( + "script", + "config", + SCRIPT_CONFIG_PATH, + cv.slug, + SCRIPT_ENTRY_SCHEMA, + post_write_hook=hook, + ) + ) return True diff --git a/homeassistant/components/config/services.yaml b/homeassistant/components/config/services.yaml new file mode 100644 index 000000000..e69de29bb diff --git a/homeassistant/components/config/zwave.py b/homeassistant/components/config/zwave.py index fcdab8350..eaed84fe2 100644 --- a/homeassistant/components/config/zwave.py +++ b/homeassistant/components/config/zwave.py @@ -1,28 +1,33 @@ """Provide configuration end points for Z-Wave.""" -import asyncio +from collections import deque import logging -from collections import deque from aiohttp.web import Response -import homeassistant.core as ha -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 +from homeassistant.components.zwave import DEVICE_CONFIG_SCHEMA_ENTRY, const +from homeassistant.const import HTTP_NOT_FOUND, HTTP_OK +import homeassistant.core as ha import homeassistant.helpers.config_validation as cv +from . import EditKeyBasedConfigView + _LOGGER = logging.getLogger(__name__) -CONFIG_PATH = 'zwave_device_config.yaml' -OZW_LOG_FILENAME = 'OZW_Log.txt' +CONFIG_PATH = "zwave_device_config.yaml" +OZW_LOG_FILENAME = "OZW_Log.txt" -@asyncio.coroutine -def async_setup(hass): +async def async_setup(hass): """Set up the Z-Wave config API.""" - hass.http.register_view(EditKeyBasedConfigView( - 'zwave', 'device_config', CONFIG_PATH, cv.entity_id, - DEVICE_CONFIG_SCHEMA_ENTRY - )) + hass.http.register_view( + EditKeyBasedConfigView( + "zwave", + "device_config", + CONFIG_PATH, + cv.entity_id, + DEVICE_CONFIG_SCHEMA_ENTRY, + ) + ) hass.http.register_view(ZWaveNodeValueView) hass.http.register_view(ZWaveNodeGroupView) hass.http.register_view(ZWaveNodeConfigView) @@ -40,24 +45,23 @@ class ZWaveLogView(HomeAssistantView): url = "/api/zwave/ozwlog" name = "api:zwave:ozwlog" -# pylint: disable=no-self-use - @asyncio.coroutine - def get(self, request): + # pylint: disable=no-self-use + async def get(self, request): """Retrieve the lines from ZWave log.""" try: - lines = int(request.query.get('lines', 0)) + lines = int(request.query.get("lines", 0)) except ValueError: - return Response(text='Invalid datetime', status=400) + return Response(text="Invalid datetime", status=400) - hass = request.app['hass'] - response = yield from hass.async_add_job(self._get_log, hass, lines) + hass = request.app["hass"] + response = await hass.async_add_job(self._get_log, hass, lines) - return Response(text='\n'.join(response)) + 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: + with open(logfilepath, "r") as logfile: data = (line.rstrip() for line in logfile) if lines == 0: loglines = list(data) @@ -75,15 +79,13 @@ class ZWaveConfigWriteView(HomeAssistantView): @ha.callback def post(self, request): """Save cache configuration to zwcfg_xxxxx.xml.""" - hass = request.app['hass'] + 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) + 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) + return self.json_message("Z-Wave configuration saved to file.", HTTP_OK) class ZWaveNodeValueView(HomeAssistantView): @@ -96,7 +98,7 @@ class ZWaveNodeValueView(HomeAssistantView): def get(self, request, node_id): """Retrieve groups of node.""" nodeid = int(node_id) - hass = request.app['hass'] + hass = request.app["hass"] values_list = hass.data[const.DATA_ENTITY_VALUES] values_data = {} @@ -107,10 +109,10 @@ class ZWaveNodeValueView(HomeAssistantView): continue values_data[entity_values.primary.value_id] = { - 'label': entity_values.primary.label, - 'index': entity_values.primary.index, - 'instance': entity_values.primary.instance, - 'poll_intensity': entity_values.primary.poll_intensity, + "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) @@ -125,19 +127,20 @@ class ZWaveNodeGroupView(HomeAssistantView): def get(self, request, node_id): """Retrieve groups of node.""" nodeid = int(node_id) - hass = request.app['hass'] + hass = request.app["hass"] network = hass.data.get(const.DATA_NETWORK) node = network.nodes.get(nodeid) if node is None: - return self.json_message('Node not found', HTTP_NOT_FOUND) + return self.json_message("Node not found", HTTP_NOT_FOUND) groupdata = node.groups groups = {} for key, value in groupdata.items(): - groups[key] = {'associations': value.associations, - 'association_instances': - value.associations_instances, - 'label': value.label, - 'max_associations': value.max_associations} + groups[key] = { + "associations": value.associations, + "association_instances": value.associations_instances, + "label": value.label, + "max_associations": value.max_associations, + } return self.json(groups) @@ -151,22 +154,24 @@ class ZWaveNodeConfigView(HomeAssistantView): def get(self, request, node_id): """Retrieve configurations of node.""" nodeid = int(node_id) - hass = request.app['hass'] + hass = request.app["hass"] network = hass.data.get(const.DATA_NETWORK) node = network.nodes.get(nodeid) if node is None: - return self.json_message('Node not found', HTTP_NOT_FOUND) + return self.json_message("Node not found", HTTP_NOT_FOUND) config = {} - for value in ( - node.get_values(class_id=const.COMMAND_CLASS_CONFIGURATION) - .values()): - config[value.index] = {'label': value.label, - 'type': value.type, - 'help': value.help, - 'data_items': value.data_items, - 'data': value.data, - 'max': value.max, - 'min': value.min} + for value in node.get_values( + class_id=const.COMMAND_CLASS_CONFIGURATION + ).values(): + config[value.index] = { + "label": value.label, + "type": value.type, + "help": value.help, + "data_items": value.data_items, + "data": value.data, + "max": value.max, + "min": value.min, + } return self.json(config) @@ -180,22 +185,22 @@ class ZWaveUserCodeView(HomeAssistantView): def get(self, request, node_id): """Retrieve usercodes of node.""" nodeid = int(node_id) - hass = request.app['hass'] + hass = request.app["hass"] network = hass.data.get(const.DATA_NETWORK) node = network.nodes.get(nodeid) if node is None: - return self.json_message('Node not found', HTTP_NOT_FOUND) + return self.json_message("Node not found", HTTP_NOT_FOUND) usercodes = {} if not node.has_command_class(const.COMMAND_CLASS_USER_CODE): return self.json(usercodes) - for value in ( - node.get_values(class_id=const.COMMAND_CLASS_USER_CODE) - .values()): + for value in node.get_values(class_id=const.COMMAND_CLASS_USER_CODE).values(): if value.genre != const.GENRE_USER: continue - usercodes[value.index] = {'code': value.data, - 'label': value.label, - 'length': len(value.data)} + usercodes[value.index] = { + "code": value.data, + "label": value.label, + "length": len(value.data), + } return self.json(usercodes) @@ -208,22 +213,23 @@ class ZWaveProtectionView(HomeAssistantView): async def get(self, request, node_id): """Retrieve the protection commandclass options of node.""" nodeid = int(node_id) - hass = request.app['hass'] + hass = request.app["hass"] network = hass.data.get(const.DATA_NETWORK) def _fetch_protection(): """Get protection data.""" node = network.nodes.get(nodeid) if node is None: - return self.json_message('Node not found', HTTP_NOT_FOUND) + return self.json_message("Node not found", HTTP_NOT_FOUND) protection_options = {} if not node.has_command_class(const.COMMAND_CLASS_PROTECTION): return self.json(protection_options) protections = node.get_protections() protection_options = { - 'value_id': '{0:d}'.format(list(protections)[0]), - 'selected': node.get_protection_item(list(protections)[0]), - 'options': node.get_protection_items(list(protections)[0])} + "value_id": "{0:d}".format(list(protections)[0]), + "selected": node.get_protection_item(list(protections)[0]), + "options": node.get_protection_items(list(protections)[0]), + } return self.json(protection_options) return await hass.async_add_executor_job(_fetch_protection) @@ -231,7 +237,7 @@ class ZWaveProtectionView(HomeAssistantView): async def post(self, request, node_id): """Change the selected option in protection commandclass.""" nodeid = int(node_id) - hass = request.app['hass'] + hass = request.app["hass"] network = hass.data.get(const.DATA_NETWORK) protection_data = await request.json() @@ -241,15 +247,14 @@ class ZWaveProtectionView(HomeAssistantView): selection = protection_data["selection"] value_id = int(protection_data[const.ATTR_VALUE_ID]) if node is None: - return self.json_message('Node not found', HTTP_NOT_FOUND) + return self.json_message("Node not found", HTTP_NOT_FOUND) if not node.has_command_class(const.COMMAND_CLASS_PROTECTION): return self.json_message( - 'No protection commandclass on this node', HTTP_NOT_FOUND) + "No protection commandclass on this node", HTTP_NOT_FOUND + ) state = node.set_protection(value_id, selection) if not state: - return self.json_message( - 'Protection setting did not complete', 202) - return self.json_message( - 'Protection setting succsessfully set', HTTP_OK) + return self.json_message("Protection setting did not complete", 202) + return self.json_message("Protection setting succsessfully set", HTTP_OK) return await hass.async_add_executor_job(_set_protection) diff --git a/homeassistant/components/configurator.py b/homeassistant/components/configurator.py deleted file mode 100644 index 56fb7b424..000000000 --- a/homeassistant/components/configurator.py +++ /dev/null @@ -1,233 +0,0 @@ -""" -Support to allow pieces of code to request configuration from the user. - -Initiate a request by calling the `request_config` method with a callback. -This will return a request id that has to be used for future calls. -A callback has to be provided to `request_config` which will be called when -the user has submitted configuration information. -""" -import asyncio -import functools as ft -import logging - -from homeassistant.core import callback as async_callback -from homeassistant.const import EVENT_TIME_CHANGED, ATTR_FRIENDLY_NAME, \ - 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 - -_LOGGER = logging.getLogger(__name__) -_KEY_INSTANCE = 'configurator' - -DATA_REQUESTS = 'configurator_requests' - -ATTR_CONFIGURE_ID = 'configure_id' -ATTR_DESCRIPTION = 'description' -ATTR_DESCRIPTION_IMAGE = 'description_image' -ATTR_ERRORS = 'errors' -ATTR_FIELDS = 'fields' -ATTR_LINK_NAME = 'link_name' -ATTR_LINK_URL = 'link_url' -ATTR_SUBMIT_CAPTION = 'submit_caption' - -DOMAIN = 'configurator' - -ENTITY_ID_FORMAT = DOMAIN + '.{}' - -SERVICE_CONFIGURE = 'configure' -STATE_CONFIGURE = 'configure' -STATE_CONFIGURED = 'configured' - - -@bind_hass -@async_callback -def async_request_config( - hass, name, callback=None, description=None, description_image=None, - submit_caption=None, fields=None, link_name=None, link_url=None, - entity_picture=None): - """Create a new request for configuration. - - 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, submit_caption, fields, entity_picture) - - if DATA_REQUESTS not in hass.data: - hass.data[DATA_REQUESTS] = {} - - hass.data[DATA_REQUESTS][request_id] = instance - - return request_id - - -@bind_hass -def request_config(hass, *args, **kwargs): - """Create a new request for configuration. - - Will return an ID to be used for sequent calls. - """ - return run_callback_threadsafe( - hass.loop, ft.partial(async_request_config, hass, *args, **kwargs) - ).result() - - -@bind_hass -@async_callback -def async_notify_errors(hass, request_id, error): - """Add errors to a config request.""" - try: - hass.data[DATA_REQUESTS][request_id].async_notify_errors( - request_id, error) - except KeyError: - # If request_id does not exist - pass - - -@bind_hass -def notify_errors(hass, request_id, error): - """Add errors to a config request.""" - return run_callback_threadsafe( - hass.loop, async_notify_errors, hass, request_id, error - ).result() - - -@bind_hass -@async_callback -def async_request_done(hass, request_id): - """Mark a configuration request as done.""" - try: - hass.data[DATA_REQUESTS].pop(request_id).async_request_done(request_id) - except KeyError: - # If request_id does not exist - pass - - -@bind_hass -def request_done(hass, request_id): - """Mark a configuration request as done.""" - return run_callback_threadsafe( - hass.loop, async_request_done, hass, request_id - ).result() - - -@asyncio.coroutine -def async_setup(hass, config): - """Set up the configurator component.""" - return True - - -class Configurator: - """The class to keep track of current configuration requests.""" - - def __init__(self, hass): - """Initialize the configurator.""" - self.hass = hass - self._cur_id = 0 - self._requests = {} - hass.services.async_register( - DOMAIN, SERVICE_CONFIGURE, self.async_handle_service_call) - - @async_callback - def async_request_config( - 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) - - if fields is None: - fields = [] - - request_id = self._generate_unique_id() - - self._requests[request_id] = (entity_id, fields, callback) - - data = { - ATTR_CONFIGURE_ID: request_id, - ATTR_FIELDS: fields, - ATTR_FRIENDLY_NAME: name, - ATTR_ENTITY_PICTURE: entity_picture, - } - - data.update({ - key: value for key, value in [ - (ATTR_DESCRIPTION, description), - (ATTR_SUBMIT_CAPTION, submit_caption), - ] if value is not None - }) - - self.hass.states.async_set(entity_id, STATE_CONFIGURE, data) - - return request_id - - @async_callback - def async_notify_errors(self, request_id, error): - """Update the state with errors.""" - if not self._validate_request_id(request_id): - return - - entity_id = self._requests[request_id][0] - - state = self.hass.states.get(entity_id) - - new_data = dict(state.attributes) - new_data[ATTR_ERRORS] = error - - self.hass.states.async_set(entity_id, STATE_CONFIGURE, new_data) - - @async_callback - def async_request_done(self, request_id): - """Remove the configuration request.""" - if not self._validate_request_id(request_id): - return - - entity_id = self._requests.pop(request_id)[0] - - # If we remove the state right away, it will not be included with - # the result fo the service call (current design limitation). - # Instead, we will set it to configured to give as feedback but delete - # it shortly after so that it is deleted when the client updates. - self.hass.states.async_set(entity_id, STATE_CONFIGURED) - - def deferred_remove(event): - """Remove the request state.""" - self.hass.states.async_remove(entity_id) - - self.hass.bus.async_listen_once(EVENT_TIME_CHANGED, deferred_remove) - - @asyncio.coroutine - def async_handle_service_call(self, call): - """Handle a configure service call.""" - request_id = call.data.get(ATTR_CONFIGURE_ID) - - if not self._validate_request_id(request_id): - return - - # pylint: disable=unused-variable - entity_id, fields, callback = self._requests[request_id] - - # field validation goes here? - if callback: - yield from self.hass.async_add_job(callback, - call.data.get(ATTR_FIELDS, {})) - - def _generate_unique_id(self): - """Generate a unique configurator ID.""" - self._cur_id += 1 - return "{}-{}".format(id(self), self._cur_id) - - def _validate_request_id(self, request_id): - """Validate that the request belongs to this instance.""" - return request_id in self._requests diff --git a/homeassistant/components/configurator/__init__.py b/homeassistant/components/configurator/__init__.py new file mode 100644 index 000000000..78333d963 --- /dev/null +++ b/homeassistant/components/configurator/__init__.py @@ -0,0 +1,244 @@ +""" +Support to allow pieces of code to request configuration from the user. + +Initiate a request by calling the `request_config` method with a callback. +This will return a request id that has to be used for future calls. +A callback has to be provided to `request_config` which will be called when +the user has submitted configuration information. +""" +import functools as ft +import logging + +from homeassistant.const import ( + ATTR_ENTITY_PICTURE, + ATTR_FRIENDLY_NAME, + EVENT_TIME_CHANGED, +) +from homeassistant.core import callback as async_callback +from homeassistant.helpers.entity import async_generate_entity_id +from homeassistant.loader import bind_hass +from homeassistant.util.async_ import run_callback_threadsafe + +_LOGGER = logging.getLogger(__name__) +_KEY_INSTANCE = "configurator" + +DATA_REQUESTS = "configurator_requests" + +ATTR_CONFIGURE_ID = "configure_id" +ATTR_DESCRIPTION = "description" +ATTR_DESCRIPTION_IMAGE = "description_image" +ATTR_ERRORS = "errors" +ATTR_FIELDS = "fields" +ATTR_LINK_NAME = "link_name" +ATTR_LINK_URL = "link_url" +ATTR_SUBMIT_CAPTION = "submit_caption" + +DOMAIN = "configurator" + +ENTITY_ID_FORMAT = DOMAIN + ".{}" + +SERVICE_CONFIGURE = "configure" +STATE_CONFIGURE = "configure" +STATE_CONFIGURED = "configured" + + +@bind_hass +@async_callback +def async_request_config( + hass, + name, + callback=None, + description=None, + description_image=None, + submit_caption=None, + fields=None, + link_name=None, + link_url=None, + entity_picture=None, +): + """Create a new request for configuration. + + Will return an ID to be used for sequent calls. + """ + if link_name is not None and link_url is not None: + description += f"\n\n[{link_name}]({link_url})" + + if description_image is not None: + description += f"\n\n![Description image]({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, submit_caption, fields, entity_picture + ) + + if DATA_REQUESTS not in hass.data: + hass.data[DATA_REQUESTS] = {} + + hass.data[DATA_REQUESTS][request_id] = instance + + return request_id + + +@bind_hass +def request_config(hass, *args, **kwargs): + """Create a new request for configuration. + + Will return an ID to be used for sequent calls. + """ + return run_callback_threadsafe( + hass.loop, ft.partial(async_request_config, hass, *args, **kwargs) + ).result() + + +@bind_hass +@async_callback +def async_notify_errors(hass, request_id, error): + """Add errors to a config request.""" + try: + hass.data[DATA_REQUESTS][request_id].async_notify_errors(request_id, error) + except KeyError: + # If request_id does not exist + pass + + +@bind_hass +def notify_errors(hass, request_id, error): + """Add errors to a config request.""" + return run_callback_threadsafe( + hass.loop, async_notify_errors, hass, request_id, error + ).result() + + +@bind_hass +@async_callback +def async_request_done(hass, request_id): + """Mark a configuration request as done.""" + try: + hass.data[DATA_REQUESTS].pop(request_id).async_request_done(request_id) + except KeyError: + # If request_id does not exist + pass + + +@bind_hass +def request_done(hass, request_id): + """Mark a configuration request as done.""" + return run_callback_threadsafe( + hass.loop, async_request_done, hass, request_id + ).result() + + +async def async_setup(hass, config): + """Set up the configurator component.""" + return True + + +class Configurator: + """The class to keep track of current configuration requests.""" + + def __init__(self, hass): + """Initialize the configurator.""" + self.hass = hass + self._cur_id = 0 + self._requests = {} + hass.services.async_register( + DOMAIN, SERVICE_CONFIGURE, self.async_handle_service_call + ) + + @async_callback + def async_request_config( + 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) + + if fields is None: + fields = [] + + request_id = self._generate_unique_id() + + self._requests[request_id] = (entity_id, fields, callback) + + data = { + ATTR_CONFIGURE_ID: request_id, + ATTR_FIELDS: fields, + ATTR_FRIENDLY_NAME: name, + ATTR_ENTITY_PICTURE: entity_picture, + } + + data.update( + { + key: value + for key, value in [ + (ATTR_DESCRIPTION, description), + (ATTR_SUBMIT_CAPTION, submit_caption), + ] + if value is not None + } + ) + + self.hass.states.async_set(entity_id, STATE_CONFIGURE, data) + + return request_id + + @async_callback + def async_notify_errors(self, request_id, error): + """Update the state with errors.""" + if not self._validate_request_id(request_id): + return + + entity_id = self._requests[request_id][0] + + state = self.hass.states.get(entity_id) + + new_data = dict(state.attributes) + new_data[ATTR_ERRORS] = error + + self.hass.states.async_set(entity_id, STATE_CONFIGURE, new_data) + + @async_callback + def async_request_done(self, request_id): + """Remove the configuration request.""" + if not self._validate_request_id(request_id): + return + + entity_id = self._requests.pop(request_id)[0] + + # If we remove the state right away, it will not be included with + # the result fo the service call (current design limitation). + # Instead, we will set it to configured to give as feedback but delete + # it shortly after so that it is deleted when the client updates. + self.hass.states.async_set(entity_id, STATE_CONFIGURED) + + def deferred_remove(event): + """Remove the request state.""" + self.hass.states.async_remove(entity_id) + + self.hass.bus.async_listen_once(EVENT_TIME_CHANGED, deferred_remove) + + async def async_handle_service_call(self, call): + """Handle a configure service call.""" + request_id = call.data.get(ATTR_CONFIGURE_ID) + + if not self._validate_request_id(request_id): + return + + # pylint: disable=unused-variable + entity_id, fields, callback = self._requests[request_id] + + # field validation goes here? + if callback: + await self.hass.async_add_job(callback, call.data.get(ATTR_FIELDS, {})) + + def _generate_unique_id(self): + """Generate a unique configurator ID.""" + self._cur_id += 1 + return "{}-{}".format(id(self), self._cur_id) + + def _validate_request_id(self, request_id): + """Validate that the request belongs to this instance.""" + return request_id in self._requests diff --git a/homeassistant/components/configurator/manifest.json b/homeassistant/components/configurator/manifest.json new file mode 100644 index 000000000..10c067d4a --- /dev/null +++ b/homeassistant/components/configurator/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "configurator", + "name": "Configurator", + "documentation": "https://www.home-assistant.io/integrations/configurator", + "requirements": [], + "dependencies": [], + "codeowners": [ + "@home-assistant/core" + ] +} diff --git a/homeassistant/components/configurator/services.yaml b/homeassistant/components/configurator/services.yaml new file mode 100644 index 000000000..e69de29bb diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index d8d386f5c..158a36598 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -1,191 +1,165 @@ -""" -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/ -""" +"""Support for functionality to have conversations with Home Assistant.""" import logging import re import voluptuous as vol from homeassistant import core -from homeassistant.components import http -from homeassistant.components.conversation.util import create_matcher -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.components import http, websocket_api +from homeassistant.components.http.data_validator import RequestDataValidator +from homeassistant.helpers import config_validation as cv, intent from homeassistant.loader import bind_hass -from homeassistant.setup import (ATTR_COMPONENT) + +from .agent import AbstractConversationAgent +from .default_agent import DefaultAgent, async_register _LOGGER = logging.getLogger(__name__) -ATTR_TEXT = 'text' +ATTR_TEXT = "text" -DEPENDENCIES = ['http'] -DOMAIN = 'conversation' +DOMAIN = "conversation" -REGEX_TURN_COMMAND = re.compile(r'turn (?P(?: |\w)+) (?P\w+)') -REGEX_TYPE = type(re.compile('')) +REGEX_TYPE = type(re.compile("")) +DATA_AGENT = "conversation_agent" +DATA_CONFIG = "conversation_config" -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 = 'process' +SERVICE_PROCESS_SCHEMA = vol.Schema({vol.Required(ATTR_TEXT): cv.string}) -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, +) -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) +async_register = bind_hass(async_register) # pylint: disable=invalid-name @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)) +def async_set_agent(hass: core.HomeAssistant, agent: AbstractConversationAgent): + """Set the agent to handle the conversations.""" + hass.data[DATA_AGENT] = agent async def async_setup(hass, config): """Register the process service.""" - config = config.get(DOMAIN, {}) - intents = hass.data.get(DOMAIN) + hass.data[DATA_CONFIG] = config - 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): + async def handle_service(service): """Parse text into commands.""" text = service.data[ATTR_TEXT] - _LOGGER.debug('Processing: <%s>', text) + _LOGGER.debug("Processing: <%s>", text) + agent = await _get_agent(hass) try: - await _process(hass, text) + await agent.async_process(text, service.context) except intent.IntentHandleError as err: - _LOGGER.error('Error processing %s: %s', text, 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) + DOMAIN, SERVICE_PROCESS, handle_service, schema=SERVICE_PROCESS_SCHEMA + ) + hass.http.register_view(ConversationProcessView()) + hass.components.websocket_api.async_register_command(websocket_process) + hass.components.websocket_api.async_register_command(websocket_get_agent_info) + hass.components.websocket_api.async_register_command(websocket_set_onboarding) return True -async def _process(hass, text): - """Process a line of text.""" - intents = hass.data.get(DOMAIN, {}) +@websocket_api.async_response +@websocket_api.websocket_command( + {"type": "conversation/process", "text": str, vol.Optional("conversation_id"): str} +) +async def websocket_process(hass, connection, msg): + """Process text.""" + connection.send_result( + msg["id"], + await _async_converse( + hass, msg["text"], msg.get("conversation_id"), connection.context(msg) + ), + ) - for intent_type, matchers in intents.items(): - for matcher in matchers: - match = matcher.match(text) - if not match: - continue +@websocket_api.async_response +@websocket_api.websocket_command({"type": "conversation/agent/info"}) +async def websocket_get_agent_info(hass, connection, msg): + """Do we need onboarding.""" + agent = await _get_agent(hass) - response = await hass.helpers.intent.async_handle( - DOMAIN, intent_type, - {key: {'value': value} for key, value - in match.groupdict().items()}, text) - return response + connection.send_result( + msg["id"], + { + "onboarding": await agent.async_get_onboarding(), + "attribution": agent.attribution, + }, + ) + + +@websocket_api.async_response +@websocket_api.websocket_command({"type": "conversation/onboarding/set", "shown": bool}) +async def websocket_set_onboarding(hass, connection, msg): + """Set onboarding status.""" + agent = await _get_agent(hass) + + success = await agent.async_set_onboarding(msg["shown"]) + + if success: + connection.send_result(msg["id"]) + else: + connection.send_error(msg["id"]) class ConversationProcessView(http.HomeAssistantView): - """View to retrieve shopping list content.""" + """View to process text.""" - url = '/api/conversation/process' + url = "/api/conversation/process" name = "api:conversation:process" - @RequestDataValidator(vol.Schema({ - vol.Required('text'): str, - })) + @RequestDataValidator( + vol.Schema({vol.Required("text"): str, vol.Optional("conversation_id"): str}) + ) async def post(self, request, data): """Send a request for processing.""" - hass = request.app['hass'] + 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") + intent_result = await _async_converse( + hass, data["text"], data.get("conversation_id"), self.context(request) + ) return self.json(intent_result) + + +async def _get_agent(hass: core.HomeAssistant) -> AbstractConversationAgent: + """Get the active conversation agent.""" + agent = hass.data.get(DATA_AGENT) + if agent is None: + agent = hass.data[DATA_AGENT] = DefaultAgent(hass) + await agent.async_initialize(hass.data.get(DATA_CONFIG)) + return agent + + +async def _async_converse( + hass: core.HomeAssistant, text: str, conversation_id: str, context: core.Context +) -> intent.IntentResponse: + """Process text and get intent.""" + agent = await _get_agent(hass) + try: + intent_result = await agent.async_process(text, context, conversation_id) + 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 intent_result diff --git a/homeassistant/components/conversation/agent.py b/homeassistant/components/conversation/agent.py new file mode 100644 index 000000000..c9c2ab46c --- /dev/null +++ b/homeassistant/components/conversation/agent.py @@ -0,0 +1,29 @@ +"""Agent foundation for conversation integration.""" +from abc import ABC, abstractmethod +from typing import Optional + +from homeassistant.core import Context +from homeassistant.helpers import intent + + +class AbstractConversationAgent(ABC): + """Abstract conversation agent.""" + + @property + def attribution(self): + """Return the attribution.""" + return None + + async def async_get_onboarding(self): + """Get onboard data.""" + return None + + async def async_set_onboarding(self, shown): + """Set onboard data.""" + return True + + @abstractmethod + async def async_process( + self, text: str, context: Context, conversation_id: Optional[str] = None + ) -> intent.IntentResponse: + """Process a sentence.""" diff --git a/homeassistant/components/conversation/const.py b/homeassistant/components/conversation/const.py new file mode 100644 index 000000000..04bfa3730 --- /dev/null +++ b/homeassistant/components/conversation/const.py @@ -0,0 +1,3 @@ +"""Const for conversation integration.""" + +DOMAIN = "conversation" diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py new file mode 100644 index 000000000..2f09cba2e --- /dev/null +++ b/homeassistant/components/conversation/default_agent.py @@ -0,0 +1,137 @@ +"""Standard conversastion implementation for Home Assistant.""" +import logging +import re +from typing import Optional + +from homeassistant import core, setup +from homeassistant.components.cover.intent import INTENT_CLOSE_COVER, INTENT_OPEN_COVER +from homeassistant.components.shopping_list.intent import ( + INTENT_ADD_ITEM, + INTENT_LAST_ITEMS, +) +from homeassistant.const import EVENT_COMPONENT_LOADED +from homeassistant.core import callback +from homeassistant.helpers import intent +from homeassistant.setup import ATTR_COMPONENT + +from .agent import AbstractConversationAgent +from .const import DOMAIN +from .util import create_matcher + +_LOGGER = logging.getLogger(__name__) + +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]"], + }, + "shopping_list": { + INTENT_ADD_ITEM: ["Add [the] [a] [an] {item} to my shopping list"], + INTENT_LAST_ITEMS: ["What is on my shopping list"], + }, +} + + +@core.callback +def async_register(hass, intent_type, utterances): + """Register utterances and any custom intents for the default agent. + + Registrations don't require conversations to be loaded. They will become + active once the conversation component is loaded. + """ + intents = hass.data.setdefault(DOMAIN, {}) + conf = intents.setdefault(intent_type, []) + + for utterance in utterances: + if isinstance(utterance, REGEX_TYPE): + conf.append(utterance) + else: + conf.append(create_matcher(utterance)) + + +class DefaultAgent(AbstractConversationAgent): + """Default agent for conversation agent.""" + + def __init__(self, hass: core.HomeAssistant): + """Initialize the default agent.""" + self.hass = hass + + async def async_initialize(self, config): + """Initialize the default agent.""" + if "intent" not in self.hass.config.components: + await setup.async_setup_component(self.hass, "intent", {}) + + config = config.get(DOMAIN, {}) + intents = self.hass.data.setdefault(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) + + # 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( + self.hass, + intent.INTENT_TURN_ON, + ["Turn [the] [a] {name}[s] on", "Turn on [the] [a] [an] {name}[s]"], + ) + async_register( + self.hass, + intent.INTENT_TURN_OFF, + ["Turn [the] [a] [an] {name}[s] off", "Turn off [the] [a] [an] {name}[s]"], + ) + async_register( + self.hass, + intent.INTENT_TOGGLE, + ["Toggle [the] [a] [an] {name}[s]", "[the] [a] [an] {name}[s] toggle"], + ) + + @callback + def component_loaded(event): + """Handle a new component loaded.""" + self.register_utterances(event.data[ATTR_COMPONENT]) + + self.hass.bus.async_listen(EVENT_COMPONENT_LOADED, component_loaded) + + # Check already loaded components. + for component in self.hass.config.components: + self.register_utterances(component) + + @callback + def register_utterances(self, component): + """Register utterances for a component.""" + if component not in UTTERANCES: + return + for intent_type, sentences in UTTERANCES[component].items(): + async_register(self.hass, intent_type, sentences) + + async def async_process( + self, text: str, context: core.Context, conversation_id: Optional[str] = None + ) -> intent.IntentResponse: + """Process a sentence.""" + intents = self.hass.data[DOMAIN] + + for intent_type, matchers in intents.items(): + for matcher in matchers: + match = matcher.match(text) + + if not match: + continue + + return await intent.async_handle( + self.hass, + DOMAIN, + intent_type, + {key: {"value": value} for key, value in match.groupdict().items()}, + text, + context, + ) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json new file mode 100644 index 000000000..0d6d67cf2 --- /dev/null +++ b/homeassistant/components/conversation/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "conversation", + "name": "Conversation", + "documentation": "https://www.home-assistant.io/integrations/conversation", + "requirements": [], + "dependencies": [ + "http" + ], + "codeowners": [ + "@home-assistant/core" + ] +} diff --git a/homeassistant/components/conversation/util.py b/homeassistant/components/conversation/util.py index 60d861afd..4904cb9f9 100644 --- a/homeassistant/components/conversation/util.py +++ b/homeassistant/components/conversation/util.py @@ -6,13 +6,13 @@ 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) + parts = re.split(r"({\w+}|\[[\w\s]+\] *)", utterance) # Pattern to extract name from GROUP part. Matches {name} - group_matcher = re.compile(r'{(\w+)}') + group_matcher = re.compile(r"{(\w+)}") # Pattern to extract text from OPTIONAL part. Matches [the color] - optional_matcher = re.compile(r'\[([\w ]+)\] *') + optional_matcher = re.compile(r"\[([\w ]+)\] *") - pattern = ['^'] + pattern = ["^"] for part in parts: group_match = group_matcher.match(part) optional_match = optional_matcher.match(part) @@ -24,12 +24,11 @@ def create_matcher(utterance): # Group part if group_match is not None: - pattern.append( - r'(?P<{}>[\w ]+?)\s*'.format(group_match.groups()[0])) + 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(r"(?:{} *)?".format(optional_match.groups()[0])) - pattern.append('$') - return re.compile(''.join(pattern), re.I) + pattern.append("$") + return re.compile("".join(pattern), re.I) diff --git a/homeassistant/components/coolmaster/.translations/bg.json b/homeassistant/components/coolmaster/.translations/bg.json new file mode 100644 index 000000000..9e484f5d3 --- /dev/null +++ b/homeassistant/components/coolmaster/.translations/bg.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "connection_error": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u0441 CoolMasterNet. \u041c\u043e\u043b\u044f, \u043f\u0440\u043e\u0432\u0435\u0440\u0435\u0442\u0435 \u0430\u0434\u0440\u0435\u0441\u0430.", + "no_units": "\u041d\u0435 \u0431\u044f\u0445\u0430 \u043d\u0430\u043c\u0435\u0440\u0435\u043d\u0438 \u043a\u043b\u0438\u043c\u0430\u0442\u0438\u0447\u043d\u0438/\u0432\u0435\u043d\u0442\u0438\u043b\u0430\u0446\u0438\u043e\u043d\u043d\u0438 \u0441\u0438\u0441\u0442\u0435\u043c\u0438 \u043d\u0430 \u0437\u0430\u0434\u0430\u0434\u0435\u043d\u0438\u044f CoolMasterNet \u0430\u0434\u0440\u0435\u0441." + }, + "step": { + "user": { + "data": { + "cool": "\u041f\u043e\u0434\u0434\u0440\u044a\u0436\u043a\u0430 \u043d\u0430 \u0440\u0435\u0436\u0438\u043c \u043e\u0445\u043b\u0430\u0436\u0434\u0430\u043d\u0435", + "dry": "\u041f\u043e\u0434\u0434\u0440\u044a\u0436\u043a\u0430 \u043d\u0430 \u0440\u0435\u0436\u0438\u043c \u0438\u0437\u0441\u0443\u0448\u0430\u0432\u0430\u043d\u0435", + "fan_only": "\u041f\u043e\u0434\u0434\u0440\u044a\u0436\u043a\u0430 \u043d\u0430 \u0440\u0435\u0436\u0438\u043c \u0432\u0435\u043d\u0442\u0438\u043b\u0430\u0442\u043e\u0440", + "heat": "\u041f\u043e\u0434\u0434\u0440\u044a\u0436\u043a\u0430 \u043d\u0430 \u0440\u0435\u0436\u0438\u043c \u043e\u0442\u043e\u043f\u043b\u0435\u043d\u0438\u0435", + "heat_cool": "\u041f\u043e\u0434\u0434\u0440\u044a\u0436\u043a\u0430 \u043d\u0430 \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u043d \u0440\u0435\u0436\u0438\u043c \u043e\u0442\u043e\u043f\u043b\u0435\u043d\u0438\u0435/\u043e\u0445\u043b\u0430\u0436\u0434\u0430\u043d\u0435", + "host": "\u0410\u0434\u0440\u0435\u0441", + "off": "\u041c\u043e\u0436\u0435 \u0434\u0430 \u0431\u044a\u0434\u0435 \u0438\u0437\u043a\u043b\u044e\u0447\u0435\u043d" + }, + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 \u0441\u0432\u043e\u0438\u0442\u0435 \u0434\u0430\u043d\u043d\u0438 \u0437\u0430 \u0432\u0440\u044a\u0437\u043a\u0430 \u0441 CoolMasterNet." + } + }, + "title": "CoolMasterNet" + } +} \ No newline at end of file diff --git a/homeassistant/components/coolmaster/.translations/ca.json b/homeassistant/components/coolmaster/.translations/ca.json new file mode 100644 index 000000000..65816e696 --- /dev/null +++ b/homeassistant/components/coolmaster/.translations/ca.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "connection_error": "No s'ha pogut connectar amb la inst\u00e0ncia de CoolMasterNet. Comprova l'amfitri\u00f3.", + "no_units": "No s'ha pogut trobar cap unitat d'HVAC a l'amfitri\u00f3 de CoolMasterNet." + }, + "step": { + "user": { + "data": { + "cool": "Suporta mode refredar", + "dry": "Suporta mode assecar", + "fan_only": "Suporta nom\u00e9s mode ventiladoci\u00f3", + "heat": "Suporta mode escalfar", + "heat_cool": "Suporta mode escalfar/refredar autom\u00e0tic", + "host": "Amfitri\u00f3", + "off": "Es pot apagar" + }, + "title": "Configuraci\u00f3 de la connexi\u00f3 amb CoolMasterNet." + } + }, + "title": "CoolMasterNet" + } +} \ No newline at end of file diff --git a/homeassistant/components/coolmaster/.translations/cs.json b/homeassistant/components/coolmaster/.translations/cs.json new file mode 100644 index 000000000..f1e18f8fc --- /dev/null +++ b/homeassistant/components/coolmaster/.translations/cs.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "connection_error": "Nepoda\u0159ilo se p\u0159ipojit k instanci CoolMasterNet. Zkontrolujte pros\u00edm sv\u00e9ho hostitele.", + "no_units": "V hostiteli CoolMasterNet nelze naj\u00edt \u017e\u00e1dn\u00e9 jednotky HVAC." + }, + "step": { + "user": { + "data": { + "cool": "Podpora re\u017eimu chlazen\u00ed", + "dry": "Podpora re\u017eimu vysou\u0161en\u00ed", + "fan_only": "Podpora re\u017eimu pouze ventil\u00e1tor", + "heat": "Podpora re\u017eimu topen\u00ed", + "heat_cool": "Podpora automatick\u00e9ho oh\u0159\u00edv\u00e1n\u00ed/chlazen\u00ed", + "host": "Hostitel", + "off": "Lze vypnout" + }, + "title": "Nastavte podrobnosti p\u0159ipojen\u00ed CoolMasterNet." + } + }, + "title": "CoolMasterNet" + } +} \ No newline at end of file diff --git a/homeassistant/components/coolmaster/.translations/da.json b/homeassistant/components/coolmaster/.translations/da.json new file mode 100644 index 000000000..8f50a0eb6 --- /dev/null +++ b/homeassistant/components/coolmaster/.translations/da.json @@ -0,0 +1,15 @@ +{ + "config": { + "step": { + "user": { + "data": { + "heat_cool": "Underst\u00f8t automatisk varm/k\u00f8l tilstand", + "host": "V\u00e6rt", + "off": "Kan slukkes" + }, + "title": "Ops\u00e6t dine CoolMasterNet-forbindelsesdetaljer." + } + }, + "title": "CoolMasterNet" + } +} \ No newline at end of file diff --git a/homeassistant/components/coolmaster/.translations/de.json b/homeassistant/components/coolmaster/.translations/de.json new file mode 100644 index 000000000..66c6911cf --- /dev/null +++ b/homeassistant/components/coolmaster/.translations/de.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "off": "Kann ausgeschaltet werden" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coolmaster/.translations/en.json b/homeassistant/components/coolmaster/.translations/en.json new file mode 100644 index 000000000..6c30efc59 --- /dev/null +++ b/homeassistant/components/coolmaster/.translations/en.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "connection_error": "Failed to connect to CoolMasterNet instance. Please check your host.", + "no_units": "Could not find any HVAC units in CoolMasterNet host." + }, + "step": { + "user": { + "data": { + "cool": "Support cool mode", + "dry": "Support dry mode", + "fan_only": "Support fan only mode", + "heat": "Support heat mode", + "heat_cool": "Support automatic heat/cool mode", + "host": "Host", + "off": "Can be turned off" + }, + "title": "Setup your CoolMasterNet connection details." + } + }, + "title": "CoolMasterNet" + } +} \ No newline at end of file diff --git a/homeassistant/components/coolmaster/.translations/es.json b/homeassistant/components/coolmaster/.translations/es.json new file mode 100644 index 000000000..aedd81bac --- /dev/null +++ b/homeassistant/components/coolmaster/.translations/es.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "connection_error": "Error al conectarse a la instancia de CoolMasterNet. Por favor revise su anfitri\u00f3n.", + "no_units": "No se ha encontrado ninguna unidad HVAC en el host CoolMasterNet." + }, + "step": { + "user": { + "data": { + "cool": "Soporta el modo de enfriamiento", + "dry": "Soporta el modo seco", + "fan_only": "Soporta modo solo ventilador", + "heat": "Soporta modo calor", + "heat_cool": "Soporta el modo autom\u00e1tico de calor/fr\u00edo", + "host": "Host", + "off": "Se puede apagar" + }, + "title": "Configure los detalles de su conexi\u00f3n a CoolMasterNet." + } + }, + "title": "CoolMasterNet" + } +} \ No newline at end of file diff --git a/homeassistant/components/coolmaster/.translations/fr.json b/homeassistant/components/coolmaster/.translations/fr.json new file mode 100644 index 000000000..97b1753dd --- /dev/null +++ b/homeassistant/components/coolmaster/.translations/fr.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "connection_error": "\u00c9chec de la connexion \u00e0 l'instance CoolMasterNet. S'il vous pla\u00eet v\u00e9rifier votre h\u00f4te.", + "no_units": "Impossible de trouver des unit\u00e9s HVAC dans l'h\u00f4te CoolMasterNet." + }, + "step": { + "user": { + "data": { + "cool": "Prise en charge du mode refroidissement", + "dry": "Prise en charge du mode d\u00e9shumidification", + "fan_only": "Prise en charge du mode ventilateur uniquement", + "heat": "Prise en charge du mode chauffage", + "heat_cool": "Prise en charge du mode chauffage / refroidissement automatique", + "host": "H\u00f4te", + "off": "Peut \u00eatre \u00e9teint" + }, + "title": "Configurez les d\u00e9tails de votre connexion CoolMasterNet." + } + }, + "title": "CoolMasterNet" + } +} \ No newline at end of file diff --git a/homeassistant/components/coolmaster/.translations/it.json b/homeassistant/components/coolmaster/.translations/it.json new file mode 100644 index 000000000..b543a10d3 --- /dev/null +++ b/homeassistant/components/coolmaster/.translations/it.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "connection_error": "Impossibile connettersi all'istanza CoolMasterNet. Controlla il tuo host.", + "no_units": "Impossibile trovare alcuna unit\u00e0 HVAC nell'host CoolMasterNet." + }, + "step": { + "user": { + "data": { + "cool": "Supporta la modalit\u00e0 fresco", + "dry": "Supporta la modalit\u00e0 asciutto", + "fan_only": "Supporta la modalit\u00e0 solo ventilatore", + "heat": "Supporta la modalit\u00e0 di riscaldamento", + "heat_cool": "Supporta la modalit\u00e0 di riscaldamento/raffreddamento automatica", + "host": "Host", + "off": "Pu\u00f2 essere spento" + }, + "title": "Impostare i dettagli della connessione CoolMasterNet." + } + }, + "title": "CoolMasterNet" + } +} \ No newline at end of file diff --git a/homeassistant/components/coolmaster/.translations/ko.json b/homeassistant/components/coolmaster/.translations/ko.json new file mode 100644 index 000000000..ff6ddf0ac --- /dev/null +++ b/homeassistant/components/coolmaster/.translations/ko.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "connection_error": "CoolMasterNet \uc778\uc2a4\ud134\uc2a4\uc5d0 \uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ud638\uc2a4\ud2b8\ub97c \ud655\uc778\ud574\uc8fc\uc138\uc694.", + "no_units": "CoolMasterNet \ud638\uc2a4\ud2b8\uc5d0\uc11c HVAC \uae30\uae30\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4." + }, + "step": { + "user": { + "data": { + "cool": "\ub0c9\ubc29 \ubaa8\ub4dc \uc9c0\uc6d0", + "dry": "\uc81c\uc2b5 \ubaa8\ub4dc \uc9c0\uc6d0", + "fan_only": "\uc1a1\ud48d \ubaa8\ub4dc \uc9c0\uc6d0", + "heat": "\ub09c\ubc29 \ubaa8\ub4dc \uc9c0\uc6d0", + "heat_cool": "\uc790\ub3d9 \ub0c9/\ub09c\ubc29 \ubaa8\ub4dc \uc9c0\uc6d0", + "host": "\ud638\uc2a4\ud2b8", + "off": "\uc804\uc6d0\uc744 \ub04c \uc218 \uc788\uc2b4" + }, + "title": "CoolMasterNet \uc5f0\uacb0 \uc0c1\uc138\uc815\ubcf4\ub97c \uc124\uc815\ud574\uc8fc\uc138\uc694." + } + }, + "title": "CoolMasterNet" + } +} \ No newline at end of file diff --git a/homeassistant/components/coolmaster/.translations/lb.json b/homeassistant/components/coolmaster/.translations/lb.json new file mode 100644 index 000000000..ed54abac0 --- /dev/null +++ b/homeassistant/components/coolmaster/.translations/lb.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "connection_error": "Feeler beim verbanne mat der CoolMasterNet Instanz. Iwwerpr\u00e9ift w.e.g. \u00e4ren Apparat.", + "no_units": "Konnt keng HVAC Eenheeten am CoolMasterNet Apparat fannen." + }, + "step": { + "user": { + "data": { + "cool": "\u00cbnnerst\u00ebtzt KillModus", + "dry": "\u00cbnnerst\u00ebtzt Dr\u00e9che Modus", + "fan_only": "\u00cbnnerst\u00ebtzt n\u00ebmmen Ventilatiouns Modus", + "heat": "\u00cbnnerst\u00ebtzt H\u00ebtzt Modus", + "heat_cool": "\u00cbnnerst\u00ebtzt automateschen H\u00ebtzt/Kill Modus", + "host": "Apparat", + "off": "Kann ausgeschalt ginn" + }, + "title": "CoolMasterNet Verbindungs Detailer ariichten" + } + }, + "title": "CoolMasterNet" + } +} \ No newline at end of file diff --git a/homeassistant/components/coolmaster/.translations/nl.json b/homeassistant/components/coolmaster/.translations/nl.json new file mode 100644 index 000000000..02b65cdff --- /dev/null +++ b/homeassistant/components/coolmaster/.translations/nl.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "connection_error": "Kan geen verbinding maken met CoolMasterNet-instantie. Controleer uw host" + }, + "step": { + "user": { + "data": { + "off": "Kan uitgeschakeld worden" + }, + "title": "Stel uw CoolMasterNet-verbindingsgegevens in." + } + }, + "title": "CoolMasterNet" + } +} \ No newline at end of file diff --git a/homeassistant/components/coolmaster/.translations/no.json b/homeassistant/components/coolmaster/.translations/no.json new file mode 100644 index 000000000..90c40aaa6 --- /dev/null +++ b/homeassistant/components/coolmaster/.translations/no.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "connection_error": "Kunne ikke koble til CoolMasterNet-forekomsten. Sjekk verten din.", + "no_units": "Kunne ikke finne noen HVAC-enheter i CoolMasterNet vert." + }, + "step": { + "user": { + "data": { + "cool": "St\u00f8tte kj\u00f8lemodus", + "dry": "St\u00f8tt t\u00f8rr modus", + "fan_only": "St\u00f8tt kun modus for vifte", + "heat": "St\u00f8tt varmemodus", + "heat_cool": "St\u00f8tter automatisk varme/kj\u00f8l-modus", + "host": "Vert", + "off": "Kan sl\u00e5s av" + }, + "title": "Konfigurer informasjonen om CoolMasterNet-tilkoblingen." + } + }, + "title": "CoolMasterNet" + } +} \ No newline at end of file diff --git a/homeassistant/components/coolmaster/.translations/pl.json b/homeassistant/components/coolmaster/.translations/pl.json new file mode 100644 index 000000000..118c4bc42 --- /dev/null +++ b/homeassistant/components/coolmaster/.translations/pl.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "connection_error": "Nie mo\u017cna po\u0142\u0105czy\u0107 z CoolMasterNet. Sprawd\u017a adres hosta.", + "no_units": "Nie mo\u017cna znale\u017a\u0107 urz\u0105dze\u0144 HVAC na ho\u015bcie CoolMasterNet." + }, + "step": { + "user": { + "data": { + "cool": "Obs\u0142uga trybu ch\u0142odzenia", + "dry": "Obs\u0142uga trybu osuszania", + "fan_only": "Obs\u0142uga trybu \"tylko wentylator\"", + "heat": "Obs\u0142uga trybu grzania", + "heat_cool": "Obs\u0142uga automatycznego trybu grzanie/ch\u0142odzenie", + "host": "Host", + "off": "Mo\u017ce by\u0107 wy\u0142\u0105czone" + }, + "title": "Skonfiguruj szczeg\u00f3\u0142y po\u0142\u0105czenia CoolMasterNet." + } + }, + "title": "CoolMasterNet" + } +} \ No newline at end of file diff --git a/homeassistant/components/coolmaster/.translations/pt-BR.json b/homeassistant/components/coolmaster/.translations/pt-BR.json new file mode 100644 index 000000000..bb8213418 --- /dev/null +++ b/homeassistant/components/coolmaster/.translations/pt-BR.json @@ -0,0 +1,17 @@ +{ + "config": { + "step": { + "user": { + "data": { + "cool": "Suporta o modo de resfriamento", + "dry": "Suporta o modo seco", + "fan_only": "Suporte apenas o modo ventilador", + "heat": "Suporta o modo de aquecimento", + "heat_cool": "Suporta o modo de aquecimento/resfriamento autom\u00e1tico", + "host": "Host", + "off": "Pode ser desligado" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coolmaster/.translations/ru.json b/homeassistant/components/coolmaster/.translations/ru.json new file mode 100644 index 000000000..4c2f74440 --- /dev/null +++ b/homeassistant/components/coolmaster/.translations/ru.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "connection_error": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f, \u043f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u0430\u0434\u0440\u0435\u0441 \u0445\u043e\u0441\u0442\u0430.", + "no_units": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043d\u0430\u0439\u0442\u0438 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u043e\u0442\u043e\u043f\u043b\u0435\u043d\u0438\u044f, \u0432\u0435\u043d\u0442\u0438\u043b\u044f\u0446\u0438\u0438 \u0438 \u043a\u043e\u043d\u0434\u0438\u0446\u0438\u043e\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f." + }, + "step": { + "user": { + "data": { + "cool": "\u0420\u0435\u0436\u0438\u043c \u043e\u0445\u043b\u0430\u0436\u0434\u0435\u043d\u0438\u044f", + "dry": "\u0420\u0435\u0436\u0438\u043c \u043e\u0441\u0443\u0448\u0435\u043d\u0438\u044f", + "fan_only": "\u0420\u0435\u0436\u0438\u043c \u0432\u0435\u043d\u0442\u0438\u043b\u044f\u0446\u0438\u0438", + "heat": "\u0420\u0435\u0436\u0438\u043c \u043e\u0431\u043e\u0433\u0440\u0435\u0432\u0430", + "heat_cool": "\u0410\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u0438\u0439 \u0440\u0435\u0436\u0438\u043c", + "host": "\u0425\u043e\u0441\u0442", + "off": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u044c \u043e\u0442\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435" + }, + "title": "CoolMasterNet" + } + }, + "title": "CoolMasterNet" + } +} \ No newline at end of file diff --git a/homeassistant/components/coolmaster/.translations/sl.json b/homeassistant/components/coolmaster/.translations/sl.json new file mode 100644 index 000000000..a59b5215e --- /dev/null +++ b/homeassistant/components/coolmaster/.translations/sl.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "connection_error": "Povezava s CoolMasterNet ni uspela. Preverite svojega gostitelja.", + "no_units": "V gostitelju CoolMasterNet ni bilo mogo\u010de najti nobenih enot HVAC." + }, + "step": { + "user": { + "data": { + "cool": "Podpira na\u010din hlajenja", + "dry": "Podpira na\u010din su\u0161enja", + "fan_only": "Podpira samo na\u010din ventilacije", + "heat": "Podpira na\u010din ogrevanja", + "heat_cool": "Podpira samodejni na\u010din ogrevanja / hlajenja", + "host": "Gostitelj", + "off": "Lahko se izklopi" + }, + "title": "Nastavite svoje podatke CoolMasterNet." + } + }, + "title": "CoolMasterNet" + } +} \ No newline at end of file diff --git a/homeassistant/components/coolmaster/.translations/zh-Hant.json b/homeassistant/components/coolmaster/.translations/zh-Hant.json new file mode 100644 index 000000000..bc61e82b9 --- /dev/null +++ b/homeassistant/components/coolmaster/.translations/zh-Hant.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "connection_error": "\u9023\u7dda\u81f3 CoolMasterNet \u5931\u6557\uff0c\u8acb\u6aa2\u67e5\u4e3b\u6a5f\u7aef\u3002", + "no_units": "\u7121\u6cd5\u65bc CoolMasterNet \u4e3b\u6a5f\u627e\u5230\u4efb\u4f55 HVAC \u8a2d\u5099\u3002" + }, + "step": { + "user": { + "data": { + "cool": "\u652f\u63f4\u5236\u51b7\u6a21\u5f0f", + "dry": "\u652f\u63f4\u9664\u6fd5\u6a21\u5f0f", + "fan_only": "\u652f\u63f4\u50c5\u9001\u98a8\u6a21\u5f0f", + "heat": "\u652f\u63f4\u4fdd\u6696\u6a21\u5f0f", + "heat_cool": "\u652f\u63f4\u81ea\u52d5\u4fdd\u6696/\u5236\u51b7\u6a21\u5f0f", + "host": "\u4e3b\u6a5f\u7aef", + "off": "\u53ef\u4ee5\u95dc\u9589" + }, + "title": "\u8a2d\u5b9a CoolMasterNet \u9023\u7dda\u8cc7\u8a0a\u3002" + } + }, + "title": "CoolMasterNet" + } +} \ No newline at end of file diff --git a/homeassistant/components/coolmaster/__init__.py b/homeassistant/components/coolmaster/__init__.py new file mode 100644 index 000000000..c666c39cf --- /dev/null +++ b/homeassistant/components/coolmaster/__init__.py @@ -0,0 +1,20 @@ +"""The Coolmaster integration.""" + + +async def async_setup(hass, config): + """Set up Coolmaster components.""" + return True + + +async def async_setup_entry(hass, entry): + """Set up Coolmaster from a config entry.""" + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, "climate") + ) + + return True + + +async def async_unload_entry(hass, entry): + """Unload a Coolmaster config entry.""" + return await hass.config_entries.async_forward_entry_unload(entry, "climate") diff --git a/homeassistant/components/coolmaster/climate.py b/homeassistant/components/coolmaster/climate.py new file mode 100644 index 000000000..a52431dd8 --- /dev/null +++ b/homeassistant/components/coolmaster/climate.py @@ -0,0 +1,188 @@ +"""CoolMasterNet platform to control of CoolMasteNet Climate Devices.""" + +import logging + +from pycoolmasternet import CoolMasterNet + +from homeassistant.components.climate import ClimateDevice +from homeassistant.components.climate.const import ( + HVAC_MODE_COOL, + HVAC_MODE_DRY, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_OFF, + SUPPORT_FAN_MODE, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.const import ( + ATTR_TEMPERATURE, + CONF_HOST, + CONF_PORT, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) + +from .const import CONF_SUPPORTED_MODES, DOMAIN + +SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE + +CM_TO_HA_STATE = { + "heat": HVAC_MODE_HEAT, + "cool": HVAC_MODE_COOL, + "auto": HVAC_MODE_HEAT_COOL, + "dry": HVAC_MODE_DRY, + "fan": HVAC_MODE_FAN_ONLY, +} + +HA_STATE_TO_CM = {value: key for key, value in CM_TO_HA_STATE.items()} + +FAN_MODES = ["low", "med", "high", "auto"] + +_LOGGER = logging.getLogger(__name__) + + +def _build_entity(device, supported_modes): + _LOGGER.debug("Found device %s", device.uid) + return CoolmasterClimate(device, supported_modes) + + +async def async_setup_entry(hass, config_entry, async_add_devices): + """Set up the CoolMasterNet climate platform.""" + supported_modes = config_entry.data.get(CONF_SUPPORTED_MODES) + host = config_entry.data[CONF_HOST] + port = config_entry.data[CONF_PORT] + cool = CoolMasterNet(host, port=port) + devices = await hass.async_add_executor_job(cool.devices) + + all_devices = [_build_entity(device, supported_modes) for device in devices] + + async_add_devices(all_devices, True) + + +class CoolmasterClimate(ClimateDevice): + """Representation of a coolmaster climate device.""" + + def __init__(self, device, supported_modes): + """Initialize the climate device.""" + self._device = device + self._uid = device.uid + self._hvac_modes = supported_modes + self._hvac_mode = None + self._target_temperature = None + self._current_temperature = None + self._current_fan_mode = None + self._current_operation = None + self._on = None + self._unit = None + + def update(self): + """Pull state from CoolMasterNet.""" + status = self._device.status + self._target_temperature = status["thermostat"] + self._current_temperature = status["temperature"] + self._current_fan_mode = status["fan_speed"] + self._on = status["is_on"] + + device_mode = status["mode"] + if self._on: + self._hvac_mode = CM_TO_HA_STATE[device_mode] + else: + self._hvac_mode = HVAC_MODE_OFF + + if status["unit"] == "celsius": + self._unit = TEMP_CELSIUS + else: + self._unit = TEMP_FAHRENHEIT + + @property + def device_info(self): + """Return device info for this device.""" + return { + "identifiers": {(DOMAIN, self.unique_id)}, + "name": self.name, + "manufacturer": "CoolAutomation", + "model": "CoolMasterNet", + } + + @property + def unique_id(self): + """Return unique ID for this device.""" + return self._uid + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS + + @property + def name(self): + """Return the name of the climate device.""" + return self.unique_id + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return self._unit + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._current_temperature + + @property + def target_temperature(self): + """Return the temperature we are trying to reach.""" + return self._target_temperature + + @property + def hvac_mode(self): + """Return hvac target hvac state.""" + return self._hvac_mode + + @property + def hvac_modes(self): + """Return the list of available operation modes.""" + return self._hvac_modes + + @property + def fan_mode(self): + """Return the fan setting.""" + return self._current_fan_mode + + @property + def fan_modes(self): + """Return the list of available fan modes.""" + return FAN_MODES + + def set_temperature(self, **kwargs): + """Set new target temperatures.""" + temp = kwargs.get(ATTR_TEMPERATURE) + if temp is not None: + _LOGGER.debug("Setting temp of %s to %s", self.unique_id, str(temp)) + self._device.set_thermostat(str(temp)) + + def set_fan_mode(self, fan_mode): + """Set new fan mode.""" + _LOGGER.debug("Setting fan mode of %s to %s", self.unique_id, fan_mode) + self._device.set_fan_speed(fan_mode) + + def set_hvac_mode(self, hvac_mode): + """Set new operation mode.""" + _LOGGER.debug("Setting operation mode of %s to %s", self.unique_id, hvac_mode) + + if hvac_mode == HVAC_MODE_OFF: + self.turn_off() + else: + self._device.set_mode(HA_STATE_TO_CM[hvac_mode]) + self.turn_on() + + def turn_on(self): + """Turn on.""" + _LOGGER.debug("Turning %s on", self.unique_id) + self._device.turn_on() + + def turn_off(self): + """Turn off.""" + _LOGGER.debug("Turning %s off", self.unique_id) + self._device.turn_off() diff --git a/homeassistant/components/coolmaster/config_flow.py b/homeassistant/components/coolmaster/config_flow.py new file mode 100644 index 000000000..e9cef5626 --- /dev/null +++ b/homeassistant/components/coolmaster/config_flow.py @@ -0,0 +1,63 @@ +"""Config flow to configure Coolmaster.""" + +from pycoolmasternet import CoolMasterNet +import voluptuous as vol + +from homeassistant import config_entries, core +from homeassistant.const import CONF_HOST, CONF_PORT + +# pylint: disable=unused-import +from .const import AVAILABLE_MODES, CONF_SUPPORTED_MODES, DEFAULT_PORT, DOMAIN + +MODES_SCHEMA = {vol.Required(mode, default=True): bool for mode in AVAILABLE_MODES} + +DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str, **MODES_SCHEMA}) + + +async def _validate_connection(hass: core.HomeAssistant, host): + cool = CoolMasterNet(host, port=DEFAULT_PORT) + devices = await hass.async_add_executor_job(cool.devices) + return bool(devices) + + +class CoolmasterConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a Coolmaster config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + def _async_get_entry(self, data): + supported_modes = [ + key for (key, value) in data.items() if key in AVAILABLE_MODES and value + ] + return self.async_create_entry( + title=data[CONF_HOST], + data={ + CONF_HOST: data[CONF_HOST], + CONF_PORT: DEFAULT_PORT, + CONF_SUPPORTED_MODES: supported_modes, + }, + ) + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + if user_input is None: + return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA) + + errors = {} + + host = user_input[CONF_HOST] + + try: + result = await _validate_connection(self.hass, host) + if not result: + errors["base"] = "no_units" + except (ConnectionRefusedError, TimeoutError): + errors["base"] = "connection_error" + + if errors: + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + return self._async_get_entry(user_input) diff --git a/homeassistant/components/coolmaster/const.py b/homeassistant/components/coolmaster/const.py new file mode 100644 index 000000000..d4cfea738 --- /dev/null +++ b/homeassistant/components/coolmaster/const.py @@ -0,0 +1,25 @@ +"""Constants for the Coolmaster integration.""" + +from homeassistant.components.climate.const import ( + HVAC_MODE_COOL, + HVAC_MODE_DRY, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_OFF, +) + +DOMAIN = "coolmaster" + +DEFAULT_PORT = 10102 + +CONF_SUPPORTED_MODES = "supported_modes" + +AVAILABLE_MODES = [ + HVAC_MODE_OFF, + HVAC_MODE_HEAT, + HVAC_MODE_COOL, + HVAC_MODE_DRY, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_FAN_ONLY, +] diff --git a/homeassistant/components/coolmaster/manifest.json b/homeassistant/components/coolmaster/manifest.json new file mode 100644 index 000000000..124a1e4a5 --- /dev/null +++ b/homeassistant/components/coolmaster/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "coolmaster", + "name": "Coolmaster", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/coolmaster", + "requirements": [ + "pycoolmasternet==0.0.4" + ], + "dependencies": [], + "codeowners": [ + "@OnFreund" + ] +} diff --git a/homeassistant/components/coolmaster/strings.json b/homeassistant/components/coolmaster/strings.json new file mode 100644 index 000000000..d309f8c9c --- /dev/null +++ b/homeassistant/components/coolmaster/strings.json @@ -0,0 +1,23 @@ +{ + "config": { + "title": "CoolMasterNet", + "step": { + "user": { + "title": "Setup your CoolMasterNet connection details.", + "data": { + "host": "Host", + "off": "Can be turned off", + "heat": "Support heat mode", + "cool": "Support cool mode", + "heat_cool": "Support automatic heat/cool mode", + "dry": "Support dry mode", + "fan_only": "Support fan only mode" + } + } + }, + "error": { + "connection_error": "Failed to connect to CoolMasterNet instance. Please check your host.", + "no_units": "Could not find any HVAC units in CoolMasterNet host." + } + } +} diff --git a/homeassistant/components/counter/__init__.py b/homeassistant/components/counter/__init__.py index d720819a0..98329bc41 100644 --- a/homeassistant/components/counter/__init__.py +++ b/homeassistant/components/counter/__init__.py @@ -1,96 +1,62 @@ -""" -Component to count within automations. - -For more details about this component, please refer to the documentation -at https://home-assistant.io/components/counter/ -""" +"""Component to count within automations.""" import logging import voluptuous as vol -from homeassistant.const import ATTR_ENTITY_ID, CONF_ICON, CONF_NAME -from homeassistant.core import callback +from homeassistant.const import CONF_ICON, CONF_MAXIMUM, CONF_MINIMUM, CONF_NAME 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.loader import bind_hass +from homeassistant.helpers.restore_state import RestoreEntity _LOGGER = logging.getLogger(__name__) -ATTR_INITIAL = 'initial' -ATTR_STEP = 'step' +ATTR_INITIAL = "initial" +ATTR_STEP = "step" +ATTR_MINIMUM = "minimum" +ATTR_MAXIMUM = "maximum" +VALUE = "value" -CONF_INITIAL = 'initial' -CONF_STEP = 'step' +CONF_INITIAL = "initial" +CONF_RESTORE = "restore" +CONF_STEP = "step" DEFAULT_INITIAL = 0 DEFAULT_STEP = 1 -DOMAIN = 'counter' +DOMAIN = "counter" -ENTITY_ID_FORMAT = DOMAIN + '.{}' +ENTITY_ID_FORMAT = DOMAIN + ".{}" -SERVICE_DECREMENT = 'decrement' -SERVICE_INCREMENT = 'increment' -SERVICE_RESET = 'reset' - -SERVICE_SCHEMA = vol.Schema({ - vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, -}) - -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - cv.slug: vol.Any({ - vol.Optional(CONF_ICON): cv.icon, - vol.Optional(CONF_INITIAL, default=DEFAULT_INITIAL): - cv.positive_int, - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_STEP, default=DEFAULT_STEP): cv.positive_int, - }, None) - }) -}, extra=vol.ALLOW_EXTRA) +SERVICE_DECREMENT = "decrement" +SERVICE_INCREMENT = "increment" +SERVICE_RESET = "reset" +SERVICE_CONFIGURE = "configure" -@bind_hass -def increment(hass, entity_id): - """Increment a counter.""" - hass.add_job(async_increment, hass, entity_id) - - -@callback -@bind_hass -def async_increment(hass, entity_id): - """Increment a counter.""" - hass.async_add_job(hass.services.async_call( - DOMAIN, SERVICE_INCREMENT, {ATTR_ENTITY_ID: entity_id})) - - -@bind_hass -def decrement(hass, entity_id): - """Decrement a counter.""" - hass.add_job(async_decrement, hass, entity_id) - - -@callback -@bind_hass -def async_decrement(hass, entity_id): - """Decrement a counter.""" - hass.async_add_job(hass.services.async_call( - DOMAIN, SERVICE_DECREMENT, {ATTR_ENTITY_ID: entity_id})) - - -@bind_hass -def reset(hass, entity_id): - """Reset a counter.""" - hass.add_job(async_reset, hass, entity_id) - - -@callback -@bind_hass -def async_reset(hass, entity_id): - """Reset a counter.""" - hass.async_add_job(hass.services.async_call( - DOMAIN, SERVICE_RESET, {ATTR_ENTITY_ID: entity_id})) +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: cv.schema_with_slug_keys( + vol.Any( + { + vol.Optional(CONF_ICON): cv.icon, + vol.Optional( + CONF_INITIAL, default=DEFAULT_INITIAL + ): cv.positive_int, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_MAXIMUM, default=None): vol.Any( + None, vol.Coerce(int) + ), + vol.Optional(CONF_MINIMUM, default=None): vol.Any( + None, vol.Coerce(int) + ), + vol.Optional(CONF_RESTORE, default=True): cv.boolean, + vol.Optional(CONF_STEP, default=DEFAULT_STEP): cv.positive_int, + }, + None, + ) + ) + }, + extra=vol.ALLOW_EXTRA, +) async def async_setup(hass, config): @@ -105,37 +71,50 @@ async def async_setup(hass, config): name = cfg.get(CONF_NAME) initial = cfg.get(CONF_INITIAL) + restore = cfg.get(CONF_RESTORE) step = cfg.get(CONF_STEP) icon = cfg.get(CONF_ICON) + minimum = cfg.get(CONF_MINIMUM) + maximum = cfg.get(CONF_MAXIMUM) - entities.append(Counter(object_id, name, initial, step, icon)) + entities.append( + Counter(object_id, name, initial, minimum, maximum, restore, step, icon) + ) if not entities: return False + component.async_register_entity_service(SERVICE_INCREMENT, {}, "async_increment") + component.async_register_entity_service(SERVICE_DECREMENT, {}, "async_decrement") + component.async_register_entity_service(SERVICE_RESET, {}, "async_reset") component.async_register_entity_service( - SERVICE_INCREMENT, SERVICE_SCHEMA, - 'async_increment') - component.async_register_entity_service( - SERVICE_DECREMENT, SERVICE_SCHEMA, - 'async_decrement') - component.async_register_entity_service( - SERVICE_RESET, SERVICE_SCHEMA, - 'async_reset') + SERVICE_CONFIGURE, + { + vol.Optional(ATTR_MINIMUM): vol.Any(None, vol.Coerce(int)), + vol.Optional(ATTR_MAXIMUM): vol.Any(None, vol.Coerce(int)), + vol.Optional(ATTR_STEP): cv.positive_int, + vol.Optional(ATTR_INITIAL): cv.positive_int, + vol.Optional(VALUE): cv.positive_int, + }, + "async_configure", + ) await component.async_add_entities(entities) return True -class Counter(Entity): +class Counter(RestoreEntity): """Representation of a counter.""" - def __init__(self, object_id, name, initial, step, icon): + def __init__(self, object_id, name, initial, minimum, maximum, restore, step, icon): """Initialize a counter.""" self.entity_id = ENTITY_ID_FORMAT.format(object_id) self._name = name + self._restore = restore self._step = step self._state = self._initial = initial + self._min = minimum + self._max = maximum self._icon = icon @property @@ -161,31 +140,63 @@ class Counter(Entity): @property def state_attributes(self): """Return the state attributes.""" - return { - ATTR_INITIAL: self._initial, - ATTR_STEP: self._step, - } + ret = {ATTR_INITIAL: self._initial, ATTR_STEP: self._step} + if self._min is not None: + ret[CONF_MINIMUM] = self._min + if self._max is not None: + ret[CONF_MAXIMUM] = self._max + return ret + + def compute_next_state(self, state): + """Keep the state within the range of min/max values.""" + if self._min is not None: + state = max(self._min, state) + if self._max is not None: + state = min(self._max, state) + + return state async def async_added_to_hass(self): """Call when entity about to be added to Home Assistant.""" - # If not None, we got an initial value. - if self._state is not None: - return - - state = await async_get_last_state(self.hass, self.entity_id) - self._state = state and state.state == state + await super().async_added_to_hass() + # __init__ will set self._state to self._initial, only override + # if needed. + if self._restore: + state = await self.async_get_last_state() + if state is not None: + self._state = self.compute_next_state(int(state.state)) + self._initial = state.attributes.get(ATTR_INITIAL) + self._max = state.attributes.get(ATTR_MAXIMUM) + self._min = state.attributes.get(ATTR_MINIMUM) + self._step = state.attributes.get(ATTR_STEP) async def async_decrement(self): """Decrement the counter.""" - self._state -= self._step + self._state = self.compute_next_state(self._state - self._step) await self.async_update_ha_state() async def async_increment(self): """Increment a counter.""" - self._state += self._step + self._state = self.compute_next_state(self._state + self._step) await self.async_update_ha_state() async def async_reset(self): """Reset a counter.""" - self._state = self._initial + self._state = self.compute_next_state(self._initial) + await self.async_update_ha_state() + + async def async_configure(self, **kwargs): + """Change the counter's settings with a service.""" + if CONF_MINIMUM in kwargs: + self._min = kwargs[CONF_MINIMUM] + if CONF_MAXIMUM in kwargs: + self._max = kwargs[CONF_MAXIMUM] + if CONF_STEP in kwargs: + self._step = kwargs[CONF_STEP] + if CONF_INITIAL in kwargs: + self._initial = kwargs[CONF_INITIAL] + if VALUE in kwargs: + self._state = kwargs[VALUE] + + self._state = self.compute_next_state(self._state) await self.async_update_ha_state() diff --git a/homeassistant/components/counter/manifest.json b/homeassistant/components/counter/manifest.json new file mode 100644 index 000000000..3fd533054 --- /dev/null +++ b/homeassistant/components/counter/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "counter", + "name": "Counter", + "documentation": "https://www.home-assistant.io/integrations/counter", + "requirements": [], + "dependencies": [], + "codeowners": [ + "@fabaff" + ] +} diff --git a/homeassistant/components/counter/reproduce_state.py b/homeassistant/components/counter/reproduce_state.py new file mode 100644 index 000000000..b37fcea71 --- /dev/null +++ b/homeassistant/components/counter/reproduce_state.py @@ -0,0 +1,71 @@ +"""Reproduce an Counter state.""" +import asyncio +import logging +from typing import Iterable, Optional + +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import Context, State +from homeassistant.helpers.typing import HomeAssistantType + +from . import ( + ATTR_INITIAL, + ATTR_MAXIMUM, + ATTR_MINIMUM, + ATTR_STEP, + DOMAIN, + SERVICE_CONFIGURE, + VALUE, +) + +_LOGGER = logging.getLogger(__name__) + + +async def _async_reproduce_state( + hass: HomeAssistantType, state: State, context: Optional[Context] = None +) -> None: + """Reproduce a single state.""" + cur_state = hass.states.get(state.entity_id) + + if cur_state is None: + _LOGGER.warning("Unable to find entity %s", state.entity_id) + return + + if not state.state.isdigit(): + _LOGGER.warning( + "Invalid state specified for %s: %s", state.entity_id, state.state + ) + return + + # Return if we are already at the right state. + if ( + cur_state.state == state.state + and cur_state.attributes.get(ATTR_INITIAL) == state.attributes.get(ATTR_INITIAL) + and cur_state.attributes.get(ATTR_MAXIMUM) == state.attributes.get(ATTR_MAXIMUM) + and cur_state.attributes.get(ATTR_MINIMUM) == state.attributes.get(ATTR_MINIMUM) + and cur_state.attributes.get(ATTR_STEP) == state.attributes.get(ATTR_STEP) + ): + return + + service_data = {ATTR_ENTITY_ID: state.entity_id, VALUE: state.state} + service = SERVICE_CONFIGURE + if ATTR_INITIAL in state.attributes: + service_data[ATTR_INITIAL] = state.attributes[ATTR_INITIAL] + if ATTR_MAXIMUM in state.attributes: + service_data[ATTR_MAXIMUM] = state.attributes[ATTR_MAXIMUM] + if ATTR_MINIMUM in state.attributes: + service_data[ATTR_MINIMUM] = state.attributes[ATTR_MINIMUM] + if ATTR_STEP in state.attributes: + service_data[ATTR_STEP] = state.attributes[ATTR_STEP] + + await hass.services.async_call( + DOMAIN, service, service_data, context=context, blocking=True + ) + + +async def async_reproduce_states( + hass: HomeAssistantType, states: Iterable[State], context: Optional[Context] = None +) -> None: + """Reproduce Counter states.""" + await asyncio.gather( + *(_async_reproduce_state(hass, state, context) for state in states) + ) diff --git a/homeassistant/components/counter/services.yaml b/homeassistant/components/counter/services.yaml index ef76f9b9e..449ae6841 100644 --- a/homeassistant/components/counter/services.yaml +++ b/homeassistant/components/counter/services.yaml @@ -17,4 +17,25 @@ reset: fields: entity_id: description: Entity id of the counter to reset. - example: 'counter.count0' \ No newline at end of file + example: 'counter.count0' +configure: + description: Change counter parameters + fields: + entity_id: + description: Entity id of the counter to change. + example: 'counter.count0' + minimum: + description: New minimum value for the counter or None to remove minimum + example: 0 + maximum: + description: New maximum value for the counter or None to remove maximum + example: 100 + step: + description: New value for step + example: 2 + initial: + description: New value for initial + example: 6 + value: + description: New state value + example: 3 diff --git a/homeassistant/components/cover/.translations/bg.json b/homeassistant/components/cover/.translations/bg.json new file mode 100644 index 000000000..4651fb4ae --- /dev/null +++ b/homeassistant/components/cover/.translations/bg.json @@ -0,0 +1,20 @@ +{ + "device_automation": { + "condition_type": { + "is_closed": "{entity_name} \u0435 \u0437\u0430\u0442\u0432\u043e\u0440\u0435\u043d", + "is_closing": "{entity_name} \u0441\u0435 \u0437\u0430\u0442\u0432\u0430\u0440\u044f", + "is_open": "{entity_name} \u0435 \u043e\u0442\u0432\u043e\u0440\u0435\u043d", + "is_opening": "{entity_name} \u0441\u0435 \u043e\u0442\u0432\u0430\u0440\u044f", + "is_position": "\u0422\u0435\u043a\u0443\u0449\u0430\u0442\u0430 \u043f\u043e\u0437\u0438\u0446\u0438\u044f \u043d\u0430 {entity_name} \u0435", + "is_tilt_position": "\u0422\u0435\u043a\u0443\u0449\u0430\u0442\u0430 \u043f\u043e\u0437\u0438\u0446\u0438\u044f \u043d\u0430 \u043d\u0430\u043a\u043b\u043e\u043d\u0430 \u043d\u0430 {entity_name} \u0435" + }, + "trigger_type": { + "closed": "{entity_name} \u0437\u0430\u0442\u0432\u043e\u0440\u0435\u043d", + "closing": "{entity_name} \u0441\u0435 \u0437\u0430\u0442\u0432\u0430\u0440\u044f", + "opened": "{entity_name} \u0435 \u043e\u0442\u0432\u043e\u0440\u0435\u043d", + "opening": "{entity_name} \u0441\u0435 \u043e\u0442\u0432\u0430\u0440\u044f", + "position": "{entity_name} \u043f\u0440\u043e\u043c\u0435\u043d\u0438 \u043f\u043e\u0437\u0438\u0446\u0438\u044f\u0442\u0430 \u0441\u0438", + "tilt_position": "{entity_name} \u043f\u0440\u043e\u043c\u0435\u043d\u0438 \u043d\u0430\u043a\u043b\u043e\u043d\u0430 \u0441\u0438" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cover/.translations/ca.json b/homeassistant/components/cover/.translations/ca.json new file mode 100644 index 000000000..b2c2371db --- /dev/null +++ b/homeassistant/components/cover/.translations/ca.json @@ -0,0 +1,20 @@ +{ + "device_automation": { + "condition_type": { + "is_closed": "{entity_name} est\u00e0 tancat/da", + "is_closing": "{entity_name} est\u00e0 tancant-se", + "is_open": "{entity_name} est\u00e0 obert/a", + "is_opening": "{entity_name} s'est\u00e0 obrint", + "is_position": "La posici\u00f3 de {entity_name} \u00e9s", + "is_tilt_position": "La posici\u00f3 d'inclinaci\u00f3 de {entity_name} \u00e9s" + }, + "trigger_type": { + "closed": "{entity_name} tancat/da", + "closing": "{entity_name} tancant-se", + "opened": "{entity_name} s'ha obert", + "opening": "{entity_name} obrint-se", + "position": "Canvia la posici\u00f3 de {entity_name}", + "tilt_position": "Canvia la inclinaci\u00f3 de {entity_name}" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cover/.translations/cs.json b/homeassistant/components/cover/.translations/cs.json new file mode 100644 index 000000000..bed9bc976 --- /dev/null +++ b/homeassistant/components/cover/.translations/cs.json @@ -0,0 +1,12 @@ +{ + "device_automation": { + "condition_type": { + "is_closed": "{entity_name} je zav\u0159eno", + "is_closing": "{entity_name} se zav\u00edr\u00e1", + "is_open": "{entity_name} je otev\u0159eno", + "is_opening": "{entity_name} se otev\u00edr\u00e1", + "is_position": "pozice {entity_name} je", + "is_tilt_position": "pozice naklon\u011bn\u00ed {entity_name} je" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cover/.translations/da.json b/homeassistant/components/cover/.translations/da.json new file mode 100644 index 000000000..e603723b5 --- /dev/null +++ b/homeassistant/components/cover/.translations/da.json @@ -0,0 +1,10 @@ +{ + "device_automation": { + "condition_type": { + "is_closed": "{entity_name} er lukket", + "is_closing": "{entity_name} lukker", + "is_open": "{entity_name} er \u00e5ben", + "is_opening": "{entity_name} \u00e5bnes" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cover/.translations/de.json b/homeassistant/components/cover/.translations/de.json new file mode 100644 index 000000000..ba692f15e --- /dev/null +++ b/homeassistant/components/cover/.translations/de.json @@ -0,0 +1,16 @@ +{ + "device_automation": { + "condition_type": { + "is_closed": "{entity_name} ist geschlossen", + "is_closing": "{entity_name} wird geschlossen", + "is_open": "{entity_name} ist offen", + "is_opening": "{entity_name} wird ge\u00f6ffnet" + }, + "trigger_type": { + "closed": "{entity_name} geschlossen", + "closing": "{entity_name} wird geschlossen", + "opened": "{entity_name} ge\u00f6ffnet", + "opening": "{entity_name} wird ge\u00f6ffnet" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cover/.translations/en.json b/homeassistant/components/cover/.translations/en.json new file mode 100644 index 000000000..27710f794 --- /dev/null +++ b/homeassistant/components/cover/.translations/en.json @@ -0,0 +1,20 @@ +{ + "device_automation": { + "condition_type": { + "is_closed": "{entity_name} is closed", + "is_closing": "{entity_name} is closing", + "is_open": "{entity_name} is open", + "is_opening": "{entity_name} is opening", + "is_position": "Current {entity_name} position is", + "is_tilt_position": "Current {entity_name} tilt position is" + }, + "trigger_type": { + "closed": "{entity_name} closed", + "closing": "{entity_name} closing", + "opened": "{entity_name} opened", + "opening": "{entity_name} opening", + "position": "{entity_name} position changes", + "tilt_position": "{entity_name} tilt position changes" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cover/.translations/es.json b/homeassistant/components/cover/.translations/es.json new file mode 100644 index 000000000..490583b54 --- /dev/null +++ b/homeassistant/components/cover/.translations/es.json @@ -0,0 +1,20 @@ +{ + "device_automation": { + "condition_type": { + "is_closed": "{entity_name} est\u00e1 cerrado", + "is_closing": "{entity_name} se est\u00e1 cerrando", + "is_open": "{entity_name} est\u00e1 abierto", + "is_opening": "{entity_name} se est\u00e1 abriendo", + "is_position": "La posici\u00f3n actual de {entity_name} es", + "is_tilt_position": "La posici\u00f3n de inclinaci\u00f3n actual de {entity_name} es" + }, + "trigger_type": { + "closed": "{entity_name} cerrado", + "closing": "{entity_name} cerrando", + "opened": "abierto {entity_name}", + "opening": "abriendo {entity_name}", + "position": "Posici\u00f3n cambiada de {entity_name}", + "tilt_position": "Cambia la posici\u00f3n de inclinaci\u00f3n de {entity_name}" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cover/.translations/fr.json b/homeassistant/components/cover/.translations/fr.json new file mode 100644 index 000000000..3aa877637 --- /dev/null +++ b/homeassistant/components/cover/.translations/fr.json @@ -0,0 +1,20 @@ +{ + "device_automation": { + "condition_type": { + "is_closed": "{entity_name} est ferm\u00e9", + "is_closing": "{entity_name} se ferme", + "is_open": "{entity_name} est ouvert", + "is_opening": "{entity_name} est en train de s'ouvrir", + "is_position": "La position de {entity_name} est", + "is_tilt_position": "La position d'inclinaison de {entity_name} est" + }, + "trigger_type": { + "closed": "{entity_name} ferm\u00e9", + "closing": "{entity_name} fermeture", + "opened": "{entity_name} ouvert", + "opening": "{entity_name} ouverture", + "position": "{entity_name} changement de position", + "tilt_position": "{entity_name} changement d'inclinaison" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cover/.translations/hu.json b/homeassistant/components/cover/.translations/hu.json new file mode 100644 index 000000000..d460c5310 --- /dev/null +++ b/homeassistant/components/cover/.translations/hu.json @@ -0,0 +1,20 @@ +{ + "device_automation": { + "condition_type": { + "is_closed": "{entity_name} z\u00e1rva van", + "is_closing": "{entity_name} z\u00e1r\u00f3dik", + "is_open": "{entity_name} nyitva van", + "is_opening": "{entity_name} ny\u00edlik", + "is_position": "{entity_name} jelenlegi poz\u00edci\u00f3ja", + "is_tilt_position": "{entity_name} jelenlegi d\u00f6nt\u00e9si poz\u00edci\u00f3ja" + }, + "trigger_type": { + "closed": "{entity_name} bez\u00e1r\u00f3dott", + "closing": "{entity_name} z\u00e1r\u00f3dik", + "opened": "{entity_name} kiny\u00edlt", + "opening": "{entity_name} ny\u00edlik", + "position": "{entity_name} poz\u00edci\u00f3ja v\u00e1ltozik", + "tilt_position": "{entity_name} d\u00f6nt\u00e9si poz\u00edci\u00f3ja v\u00e1ltozik" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cover/.translations/it.json b/homeassistant/components/cover/.translations/it.json new file mode 100644 index 000000000..bc9413d4a --- /dev/null +++ b/homeassistant/components/cover/.translations/it.json @@ -0,0 +1,20 @@ +{ + "device_automation": { + "condition_type": { + "is_closed": "{entity_name} \u00e8 chiuso", + "is_closing": "{entity_name} si sta chiudendo", + "is_open": "{entity_name} \u00e8 aperto", + "is_opening": "{entity_name} si sta aprendo", + "is_position": "La posizione attuale di {entity_name} \u00e8", + "is_tilt_position": "La posizione d'inclinazione attuale di {entity_name} \u00e8" + }, + "trigger_type": { + "closed": "{entity_name} chiuso", + "closing": "{entity_name} in chiusura", + "opened": "{entity_name} aperto", + "opening": "{entity_name} in apertura", + "position": "{entity_name} cambiamenti della posizione", + "tilt_position": "{entity_name} cambiamenti della posizione d'inclinazione" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cover/.translations/ko.json b/homeassistant/components/cover/.translations/ko.json new file mode 100644 index 000000000..6a59bb9f6 --- /dev/null +++ b/homeassistant/components/cover/.translations/ko.json @@ -0,0 +1,20 @@ +{ + "device_automation": { + "condition_type": { + "is_closed": "{entity_name} \uc774(\uac00) \ub2eb\ud614\uc2b5\ub2c8\ub2e4", + "is_closing": "{entity_name} \uc774(\uac00) \ub2eb\ud799\ub2c8\ub2e4", + "is_open": "{entity_name} \uc774(\uac00) \uc5f4\ub838\uc2b5\ub2c8\ub2e4", + "is_opening": "{entity_name} \uc774(\uac00) \uc5f4\ub9bd\ub2c8\ub2e4", + "is_position": "\ud604\uc7ac {entity_name} \uac1c\ud3d0 \uc704\uce58", + "is_tilt_position": "\ud604\uc7ac {entity_name} \uac1c\ud3d0 \uae30\uc6b8\uae30" + }, + "trigger_type": { + "closed": "{entity_name} \uc774(\uac00) \ub2eb\ud798", + "closing": "{entity_name} \uc774(\uac00) \ub2eb\ud788\ub294 \uc911", + "opened": "{entity_name} \uc774(\uac00) \uc5f4\ub9bc", + "opening": "{entity_name} \uc774(\uac00) \uc5f4\ub9ac\ub294 \uc911", + "position": "{entity_name} \uac1c\ud3d0 \uc704\uce58 \ubcc0\ud654", + "tilt_position": "{entity_name} \uac1c\ud3d0 \uae30\uc6b8\uae30 \ubcc0\ud654" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cover/.translations/lb.json b/homeassistant/components/cover/.translations/lb.json new file mode 100644 index 000000000..b2645f3e0 --- /dev/null +++ b/homeassistant/components/cover/.translations/lb.json @@ -0,0 +1,20 @@ +{ + "device_automation": { + "condition_type": { + "is_closed": "{entity_name} ass zou", + "is_closing": "{entity_name} g\u00ebtt zougemaach", + "is_open": "{entity_name} ass op", + "is_opening": "{entity_name} g\u00ebtt opgemaach", + "is_position": "{entity_name} positioun ass", + "is_tilt_position": "{entity_name} kipp positioun ass" + }, + "trigger_type": { + "closed": "{entity_name} gouf zougemaach", + "closing": "{entity_name} mecht zou", + "opened": "{entity_name} gouf opgemaach", + "opening": "{entity_name} mecht op", + "position": "{entity_name} positioun \u00e4nnert", + "tilt_position": "{entity_name} kipp positioun ge\u00e4nnert" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cover/.translations/nl.json b/homeassistant/components/cover/.translations/nl.json new file mode 100644 index 000000000..472583687 --- /dev/null +++ b/homeassistant/components/cover/.translations/nl.json @@ -0,0 +1,20 @@ +{ + "device_automation": { + "condition_type": { + "is_closed": "{entity_name} is gesloten", + "is_closing": "{entity_name} wordt gesloten", + "is_open": "{entity_name} is open", + "is_opening": "{entity_name} wordt geopend", + "is_position": "Huidige {entity_name} positie is", + "is_tilt_position": "Huidige {entity_name} kantel positie is" + }, + "trigger_type": { + "closed": "{entity_name} gesloten", + "closing": "{entity_name} wordt gesloten", + "opened": "{entity_name} geopend", + "opening": "{entity_name} wordt geopend", + "position": "{entity_name} positiewijzigingen", + "tilt_position": "{entity_name} kantel positiewijzigingen" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cover/.translations/no.json b/homeassistant/components/cover/.translations/no.json new file mode 100644 index 000000000..cc045e436 --- /dev/null +++ b/homeassistant/components/cover/.translations/no.json @@ -0,0 +1,20 @@ +{ + "device_automation": { + "condition_type": { + "is_closed": "{entity_name} er stengt", + "is_closing": "{entity_name} stenges", + "is_open": "{entity_name} er \u00e5pen", + "is_opening": "{entity_name} \u00e5pnes", + "is_position": "{entity_name}-posisjonen er", + "is_tilt_position": "{entity_name} vippeposisjon er" + }, + "trigger_type": { + "closed": "{entity_name} lukket", + "closing": "{entity_name} lukkes", + "opened": "{entity_name} \u00e5pnet", + "opening": "{entity_name} \u00e5pning", + "position": "{entity_name} posisjon endringer", + "tilt_position": "{entity_name} endringer i vippeposisjon" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cover/.translations/pl.json b/homeassistant/components/cover/.translations/pl.json new file mode 100644 index 000000000..718c4b86f --- /dev/null +++ b/homeassistant/components/cover/.translations/pl.json @@ -0,0 +1,20 @@ +{ + "device_automation": { + "condition_type": { + "is_closed": "pokrywa {entity_name} jest zamkni\u0119ta", + "is_closing": "{entity_name} si\u0119 zamyka", + "is_open": "pokrywa {entity_name} jest otwarta", + "is_opening": "{entity_name} si\u0119 otwiera", + "is_position": "pozycja pokrywy {entity_name} to", + "is_tilt_position": "pochylenie pokrywy {entity_name} to" + }, + "trigger_type": { + "closed": "nast\u0105pi zamkni\u0119cie {entity_name}", + "closing": "{entity_name} si\u0119 zamyka", + "opened": "nast\u0105pi otwarcie {entity_name}", + "opening": "{entity_name} si\u0119 otwiera", + "position": "zmieni si\u0119 pozycja pokrywy {entity_name}", + "tilt_position": "zmieni si\u0119 pochylenie pokrywy {entity_name}" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cover/.translations/pt.json b/homeassistant/components/cover/.translations/pt.json new file mode 100644 index 000000000..6234d2685 --- /dev/null +++ b/homeassistant/components/cover/.translations/pt.json @@ -0,0 +1,18 @@ +{ + "device_automation": { + "condition_type": { + "is_closed": "{entity_name} est\u00e1 fechada", + "is_closing": "{entity_name} est\u00e1 a fechar", + "is_open": "{entity_name} est\u00e1 aberta", + "is_opening": "{entity_name} est\u00e1 a abrir", + "is_position": "A posi\u00e7\u00e3o atual de {entity_name} \u00e9", + "is_tilt_position": "A inclina\u00e7\u00e3o actual de {entity_name} \u00e9" + }, + "trigger_type": { + "closed": "{entity_name} fechou", + "closing": "{entity_name} est\u00e1 a fechar", + "opened": "{entity_name} abriu", + "opening": "{entity_name} est\u00e1 a abrir" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cover/.translations/ru.json b/homeassistant/components/cover/.translations/ru.json new file mode 100644 index 000000000..ebe81486c --- /dev/null +++ b/homeassistant/components/cover/.translations/ru.json @@ -0,0 +1,20 @@ +{ + "device_automation": { + "condition_type": { + "is_closed": "{entity_name} \u0432 \u0437\u0430\u043a\u0440\u044b\u0442\u043e\u043c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0438", + "is_closing": "{entity_name} \u0437\u0430\u043a\u0440\u044b\u0432\u0430\u0435\u0442\u0441\u044f", + "is_open": "{entity_name} \u0432 \u043e\u0442\u043a\u0440\u044b\u0442\u043e\u043c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0438", + "is_opening": "{entity_name} \u043e\u0442\u043a\u0440\u044b\u0432\u0430\u0435\u0442\u0441\u044f", + "is_position": "{entity_name} \u043d\u0430\u0445\u043e\u0434\u0438\u0442\u0441\u044f \u0432 \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0438", + "is_tilt_position": "{entity_name} \u0432 \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0438 \u043d\u0430\u043a\u043b\u043e\u043d\u0430" + }, + "trigger_type": { + "closed": "{entity_name} \u0437\u0430\u043a\u0440\u044b\u0442\u043e", + "closing": "{entity_name} \u0437\u0430\u043a\u0440\u044b\u0432\u0430\u0435\u0442\u0441\u044f", + "opened": "{entity_name} \u043e\u0442\u043a\u0440\u044b\u0442\u043e", + "opening": "{entity_name} \u043e\u0442\u043a\u0440\u044b\u0432\u0430\u0435\u0442\u0441\u044f", + "position": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435", + "tilt_position": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u043d\u0430\u043a\u043b\u043e\u043d" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cover/.translations/sl.json b/homeassistant/components/cover/.translations/sl.json new file mode 100644 index 000000000..cd3570d39 --- /dev/null +++ b/homeassistant/components/cover/.translations/sl.json @@ -0,0 +1,20 @@ +{ + "device_automation": { + "condition_type": { + "is_closed": "{entity_name} je/so zaprt/a", + "is_closing": "{entity_name} se zapira/jo", + "is_open": "{entity_name} je odprt/a/o", + "is_opening": "{entity_name} se odpira/jo", + "is_position": "Trenutna pozicija {entity_name} je", + "is_tilt_position": "Trenutni polo\u017eaj nagiba {entity_name} je" + }, + "trigger_type": { + "closed": "{entity_name} se je/so se zaprla", + "closing": "{entity_name} se zapira/jo", + "opened": "{entity_name} se/so je odprla", + "opening": "{entity_name} se odpira/jo", + "position": "{entity_name} spremembe polo\u017eaja", + "tilt_position": "{entity_name} spremembe nagiba" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cover/.translations/zh-Hant.json b/homeassistant/components/cover/.translations/zh-Hant.json new file mode 100644 index 000000000..f2880a72e --- /dev/null +++ b/homeassistant/components/cover/.translations/zh-Hant.json @@ -0,0 +1,20 @@ +{ + "device_automation": { + "condition_type": { + "is_closed": "{entity_name} \u5df2\u95dc\u9589", + "is_closing": "{entity_name} \u6b63\u5728\u95dc\u9589", + "is_open": "{entity_name} \u5df2\u958b\u555f", + "is_opening": "{entity_name} \u6b63\u5728\u958b\u555f", + "is_position": "\u76ee\u524d {entity_name} \u4f4d\u7f6e\u70ba", + "is_tilt_position": "\u76ee\u524d {entity_name} \u6a19\u984c\u4f4d\u7f6e\u70ba" + }, + "trigger_type": { + "closed": "{entity_name} \u5df2\u95dc\u9589", + "closing": "{entity_name} \u6b63\u5728\u95dc\u9589", + "opened": "{entity_name} \u5df2\u958b\u555f", + "opening": "{entity_name} \u6b63\u5728\u958b\u555f", + "position": "{entity_name} \u4f4d\u7f6e\u8b8a\u66f4", + "tilt_position": "{entity_name} \u6a19\u984c\u4f4d\u7f6e\u8b8a\u66f4" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index 05c5e46e4..3c842067c 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -1,44 +1,69 @@ -""" -Support for Cover devices. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/cover/ -""" +"""Support for Cover devices.""" from datetime import timedelta import functools as ft import logging +from typing import Any import voluptuous as vol -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, - SERVICE_STOP_COVER_TILT, SERVICE_SET_COVER_TILT_POSITION, STATE_OPEN, - STATE_CLOSED, STATE_UNKNOWN, STATE_OPENING, STATE_CLOSING, ATTR_ENTITY_ID) + SERVICE_CLOSE_COVER, + SERVICE_CLOSE_COVER_TILT, + SERVICE_OPEN_COVER, + SERVICE_OPEN_COVER_TILT, + SERVICE_SET_COVER_POSITION, + SERVICE_SET_COVER_TILT_POSITION, + SERVICE_STOP_COVER, + SERVICE_STOP_COVER_TILT, + SERVICE_TOGGLE, + SERVICE_TOGGLE_COVER_TILT, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, +) +from homeassistant.helpers.config_validation import ( # noqa: F401 + PLATFORM_SCHEMA, + PLATFORM_SCHEMA_BASE, +) +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.loader import bind_hass + +# mypy: allow-untyped-calls, allow-untyped-defs, no-check-untyped-defs _LOGGER = logging.getLogger(__name__) -DOMAIN = 'cover' -DEPENDENCIES = ['group'] +DOMAIN = "cover" SCAN_INTERVAL = timedelta(seconds=15) -GROUP_NAME_ALL_COVERS = 'all covers' -ENTITY_ID_ALL_COVERS = group.ENTITY_ID_FORMAT.format('all_covers') +GROUP_NAME_ALL_COVERS = "all covers" +ENTITY_ID_ALL_COVERS = group.ENTITY_ID_FORMAT.format("all_covers") -ENTITY_ID_FORMAT = DOMAIN + '.{}' +ENTITY_ID_FORMAT = DOMAIN + ".{}" +# Refer to the cover dev docs for device class descriptions +DEVICE_CLASS_AWNING = "awning" +DEVICE_CLASS_BLIND = "blind" +DEVICE_CLASS_CURTAIN = "curtain" +DEVICE_CLASS_DAMPER = "damper" +DEVICE_CLASS_DOOR = "door" +DEVICE_CLASS_GARAGE = "garage" +DEVICE_CLASS_SHADE = "shade" +DEVICE_CLASS_SHUTTER = "shutter" +DEVICE_CLASS_WINDOW = "window" DEVICE_CLASSES = [ - 'window', # Window control - 'garage', # Garage door control + DEVICE_CLASS_AWNING, + DEVICE_CLASS_BLIND, + DEVICE_CLASS_CURTAIN, + DEVICE_CLASS_DAMPER, + DEVICE_CLASS_DOOR, + DEVICE_CLASS_GARAGE, + DEVICE_CLASS_SHADE, + DEVICE_CLASS_SHUTTER, + DEVICE_CLASS_WINDOW, ] - DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.In(DEVICE_CLASSES)) SUPPORT_OPEN = 1 @@ -50,27 +75,10 @@ SUPPORT_CLOSE_TILT = 32 SUPPORT_STOP_TILT = 64 SUPPORT_SET_TILT_POSITION = 128 -ATTR_CURRENT_POSITION = 'current_position' -ATTR_CURRENT_TILT_POSITION = 'current_tilt_position' -ATTR_POSITION = 'position' -ATTR_TILT_POSITION = 'tilt_position' - -INTENT_OPEN_COVER = 'HassOpenCover' -INTENT_CLOSE_COVER = 'HassCloseCover' - -COVER_SERVICE_SCHEMA = vol.Schema({ - vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, -}) - -COVER_SET_COVER_POSITION_SCHEMA = COVER_SERVICE_SCHEMA.extend({ - vol.Required(ATTR_POSITION): - vol.All(vol.Coerce(int), vol.Range(min=0, max=100)), -}) - -COVER_SET_COVER_TILT_POSITION_SCHEMA = COVER_SERVICE_SCHEMA.extend({ - vol.Required(ATTR_TILT_POSITION): - vol.All(vol.Coerce(int), vol.Range(min=0, max=100)), -}) +ATTR_CURRENT_POSITION = "current_position" +ATTR_CURRENT_TILT_POSITION = "current_tilt_position" +ATTR_POSITION = "position" +ATTR_TILT_POSITION = "tilt_position" @bind_hass @@ -80,123 +88,75 @@ def is_closed(hass, entity_id=None): return hass.states.is_state(entity_id, STATE_CLOSED) -@bind_hass -def open_cover(hass, entity_id=None): - """Open all or specified cover.""" - data = {ATTR_ENTITY_ID: entity_id} if entity_id else None - hass.services.call(DOMAIN, SERVICE_OPEN_COVER, data) - - -@bind_hass -def close_cover(hass, entity_id=None): - """Close all or specified cover.""" - data = {ATTR_ENTITY_ID: entity_id} if entity_id else None - hass.services.call(DOMAIN, SERVICE_CLOSE_COVER, data) - - -@bind_hass -def set_cover_position(hass, position, entity_id=None): - """Move to specific position all or specified cover.""" - data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} - data[ATTR_POSITION] = position - hass.services.call(DOMAIN, SERVICE_SET_COVER_POSITION, data) - - -@bind_hass -def stop_cover(hass, entity_id=None): - """Stop all or specified cover.""" - data = {ATTR_ENTITY_ID: entity_id} if entity_id else None - hass.services.call(DOMAIN, SERVICE_STOP_COVER, data) - - -@bind_hass -def open_cover_tilt(hass, entity_id=None): - """Open all or specified cover tilt.""" - data = {ATTR_ENTITY_ID: entity_id} if entity_id else None - hass.services.call(DOMAIN, SERVICE_OPEN_COVER_TILT, data) - - -@bind_hass -def close_cover_tilt(hass, entity_id=None): - """Close all or specified cover tilt.""" - data = {ATTR_ENTITY_ID: entity_id} if entity_id else None - hass.services.call(DOMAIN, SERVICE_CLOSE_COVER_TILT, data) - - -@bind_hass -def set_cover_tilt_position(hass, tilt_position, entity_id=None): - """Move to specific tilt position all or specified cover.""" - data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} - data[ATTR_TILT_POSITION] = tilt_position - hass.services.call(DOMAIN, SERVICE_SET_COVER_TILT_POSITION, data) - - -@bind_hass -def stop_cover_tilt(hass, entity_id=None): - """Stop all or specified cover tilt.""" - data = {ATTR_ENTITY_ID: entity_id} if entity_id else None - hass.services.call(DOMAIN, SERVICE_STOP_COVER_TILT, data) - - async def async_setup(hass, config): """Track states and offer events for covers.""" - component = EntityComponent( - _LOGGER, DOMAIN, hass, SCAN_INTERVAL, GROUP_NAME_ALL_COVERS) + component = hass.data[DOMAIN] = EntityComponent( + _LOGGER, DOMAIN, hass, SCAN_INTERVAL, GROUP_NAME_ALL_COVERS + ) await component.async_setup(config) + component.async_register_entity_service(SERVICE_OPEN_COVER, {}, "async_open_cover") + component.async_register_entity_service( - SERVICE_OPEN_COVER, COVER_SERVICE_SCHEMA, - 'async_open_cover' + SERVICE_CLOSE_COVER, {}, "async_close_cover" ) component.async_register_entity_service( - SERVICE_CLOSE_COVER, COVER_SERVICE_SCHEMA, - 'async_close_cover' + SERVICE_SET_COVER_POSITION, + { + vol.Required(ATTR_POSITION): vol.All( + vol.Coerce(int), vol.Range(min=0, max=100) + ) + }, + "async_set_cover_position", + ) + + component.async_register_entity_service(SERVICE_STOP_COVER, {}, "async_stop_cover") + + component.async_register_entity_service(SERVICE_TOGGLE, {}, "async_toggle") + + component.async_register_entity_service( + SERVICE_OPEN_COVER_TILT, {}, "async_open_cover_tilt" ) component.async_register_entity_service( - SERVICE_SET_COVER_POSITION, COVER_SET_COVER_POSITION_SCHEMA, - 'async_set_cover_position' + SERVICE_CLOSE_COVER_TILT, {}, "async_close_cover_tilt" ) component.async_register_entity_service( - SERVICE_STOP_COVER, COVER_SERVICE_SCHEMA, - 'async_stop_cover' + SERVICE_STOP_COVER_TILT, {}, "async_stop_cover_tilt" ) component.async_register_entity_service( - SERVICE_OPEN_COVER_TILT, COVER_SERVICE_SCHEMA, - 'async_open_cover_tilt' + SERVICE_SET_COVER_TILT_POSITION, + { + vol.Required(ATTR_TILT_POSITION): vol.All( + vol.Coerce(int), vol.Range(min=0, max=100) + ) + }, + "async_set_cover_tilt_position", ) component.async_register_entity_service( - SERVICE_CLOSE_COVER_TILT, COVER_SERVICE_SCHEMA, - 'async_close_cover_tilt' + SERVICE_TOGGLE_COVER_TILT, {}, "async_toggle_tilt" ) - component.async_register_entity_service( - SERVICE_STOP_COVER_TILT, COVER_SERVICE_SCHEMA, - 'async_stop_cover_tilt' - ) - - component.async_register_entity_service( - SERVICE_SET_COVER_TILT_POSITION, COVER_SET_COVER_TILT_POSITION_SCHEMA, - 'async_set_cover_tilt_position' - ) - - 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 +async def async_setup_entry(hass, entry): + """Set up 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 CoverDevice(Entity): - """Representation a cover.""" + """Representation of a cover.""" @property def current_cover_position(self): @@ -225,7 +185,7 @@ class CoverDevice(Entity): closed = self.is_closed if closed is None: - return STATE_UNKNOWN + return None return STATE_CLOSED if closed else STATE_OPEN @@ -254,8 +214,11 @@ class CoverDevice(Entity): if self.current_cover_tilt_position is not None: supported_features |= ( - SUPPORT_OPEN_TILT | SUPPORT_CLOSE_TILT | SUPPORT_STOP_TILT | - SUPPORT_SET_TILT_POSITION) + SUPPORT_OPEN_TILT + | SUPPORT_CLOSE_TILT + | SUPPORT_STOP_TILT + | SUPPORT_SET_TILT_POSITION + ) return supported_features @@ -274,7 +237,7 @@ class CoverDevice(Entity): """Return if the cover is closed or not.""" raise NotImplementedError() - def open_cover(self, **kwargs): + def open_cover(self, **kwargs: Any) -> None: """Open the cover.""" raise NotImplementedError() @@ -285,7 +248,7 @@ class CoverDevice(Entity): """ return self.hass.async_add_job(ft.partial(self.open_cover, **kwargs)) - def close_cover(self, **kwargs): + def close_cover(self, **kwargs: Any) -> None: """Close cover.""" raise NotImplementedError() @@ -296,6 +259,22 @@ class CoverDevice(Entity): """ return self.hass.async_add_job(ft.partial(self.close_cover, **kwargs)) + def toggle(self, **kwargs: Any) -> None: + """Toggle the entity.""" + if self.is_closed: + self.open_cover(**kwargs) + else: + self.close_cover(**kwargs) + + def async_toggle(self, **kwargs): + """Toggle the entity. + + This method must be run in the event loop and returns a coroutine. + """ + if self.is_closed: + return self.async_open_cover(**kwargs) + return self.async_close_cover(**kwargs) + def set_cover_position(self, **kwargs): """Move the cover to a specific position.""" pass @@ -305,8 +284,7 @@ class CoverDevice(Entity): This method must be run in the event loop and returns a coroutine. """ - return self.hass.async_add_job( - ft.partial(self.set_cover_position, **kwargs)) + return self.hass.async_add_job(ft.partial(self.set_cover_position, **kwargs)) def stop_cover(self, **kwargs): """Stop the cover.""" @@ -319,7 +297,7 @@ class CoverDevice(Entity): """ return self.hass.async_add_job(ft.partial(self.stop_cover, **kwargs)) - def open_cover_tilt(self, **kwargs): + def open_cover_tilt(self, **kwargs: Any) -> None: """Open the cover tilt.""" pass @@ -328,10 +306,9 @@ class CoverDevice(Entity): This method must be run in the event loop and returns a coroutine. """ - return self.hass.async_add_job( - ft.partial(self.open_cover_tilt, **kwargs)) + return self.hass.async_add_job(ft.partial(self.open_cover_tilt, **kwargs)) - def close_cover_tilt(self, **kwargs): + def close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover tilt.""" pass @@ -340,8 +317,7 @@ class CoverDevice(Entity): This method must be run in the event loop and returns a coroutine. """ - return self.hass.async_add_job( - ft.partial(self.close_cover_tilt, **kwargs)) + return self.hass.async_add_job(ft.partial(self.close_cover_tilt, **kwargs)) def set_cover_tilt_position(self, **kwargs): """Move the cover tilt to a specific position.""" @@ -353,7 +329,8 @@ class CoverDevice(Entity): This method must be run in the event loop and returns a coroutine. """ return self.hass.async_add_job( - ft.partial(self.set_cover_tilt_position, **kwargs)) + ft.partial(self.set_cover_tilt_position, **kwargs) + ) def stop_cover_tilt(self, **kwargs): """Stop the cover.""" @@ -364,5 +341,20 @@ class CoverDevice(Entity): This method must be run in the event loop and returns a coroutine. """ - return self.hass.async_add_job( - ft.partial(self.stop_cover_tilt, **kwargs)) + return self.hass.async_add_job(ft.partial(self.stop_cover_tilt, **kwargs)) + + def toggle_tilt(self, **kwargs: Any) -> None: + """Toggle the entity.""" + if self.current_cover_tilt_position == 0: + self.open_cover_tilt(**kwargs) + else: + self.close_cover_tilt(**kwargs) + + def async_toggle_tilt(self, **kwargs): + """Toggle the entity. + + This method must be run in the event loop and returns a coroutine. + """ + if self.current_cover_tilt_position == 0: + return self.async_open_cover_tilt(**kwargs) + return self.async_close_cover_tilt(**kwargs) diff --git a/homeassistant/components/cover/abode.py b/homeassistant/components/cover/abode.py deleted file mode 100644 index 3ba3fb118..000000000 --- a/homeassistant/components/cover/abode.py +++ /dev/null @@ -1,50 +0,0 @@ -""" -This component provides HA cover support for Abode Security System. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/cover.abode/ -""" -import logging - -from homeassistant.components.abode import AbodeDevice, DOMAIN as ABODE_DOMAIN -from homeassistant.components.cover import CoverDevice - - -DEPENDENCIES = ['abode'] - -_LOGGER = logging.getLogger(__name__) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up Abode cover devices.""" - import abodepy.helpers.constants as CONST - - data = hass.data[ABODE_DOMAIN] - - devices = [] - for device in data.abode.get_devices(generic_type=CONST.TYPE_COVER): - if data.is_excluded(device): - continue - - devices.append(AbodeCover(data, device)) - - data.devices.extend(devices) - - add_entities(devices) - - -class AbodeCover(AbodeDevice, CoverDevice): - """Representation of an Abode cover.""" - - @property - def is_closed(self): - """Return true if cover is closed, else False.""" - return not self._device.is_open - - def close_cover(self, **kwargs): - """Issue close command to cover.""" - self._device.close_cover() - - def open_cover(self, **kwargs): - """Issue open command to cover.""" - self._device.open_cover() diff --git a/homeassistant/components/cover/aladdin_connect.py b/homeassistant/components/cover/aladdin_connect.py deleted file mode 100644 index 4627ba777..000000000 --- a/homeassistant/components/cover/aladdin_connect.py +++ /dev/null @@ -1,120 +0,0 @@ -""" -Platform for the Aladdin Connect cover component. - -For more details about this platform, please refer to the documentation -https://home-assistant.io/components/cover.aladdin_connect/ -""" -import logging - -import voluptuous as vol - -from homeassistant.components.cover import (CoverDevice, PLATFORM_SCHEMA, - SUPPORT_OPEN, SUPPORT_CLOSE) -from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD, STATE_CLOSED, - STATE_OPENING, STATE_CLOSING, STATE_OPEN) -import homeassistant.helpers.config_validation as cv - -REQUIREMENTS = ['aladdin_connect==0.3'] - -_LOGGER = logging.getLogger(__name__) - -NOTIFICATION_ID = 'aladdin_notification' -NOTIFICATION_TITLE = 'Aladdin Connect Cover Setup' - -STATES_MAP = { - 'open': STATE_OPEN, - 'opening': STATE_OPENING, - 'closed': STATE_CLOSED, - 'closing': STATE_CLOSING -} - -SUPPORTED_FEATURES = SUPPORT_OPEN | SUPPORT_CLOSE - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Aladdin Connect platform.""" - from aladdin_connect import AladdinConnectClient - - username = config.get(CONF_USERNAME) - password = config.get(CONF_PASSWORD) - acc = AladdinConnectClient(username, password) - - try: - if not acc.login(): - raise ValueError("Username or Password is incorrect") - add_entities(AladdinDevice(acc, door) for door in acc.get_doors()) - 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 AladdinDevice(CoverDevice): - """Representation of Aladdin Connect cover.""" - - def __init__(self, acc, device): - """Initialize the cover.""" - self._acc = acc - self._device_id = device['device_id'] - self._number = device['door_number'] - self._name = device['name'] - self._status = STATES_MAP.get(device['status']) - - @property - def device_class(self): - """Define this cover as a garage door.""" - return 'garage' - - @property - def supported_features(self): - """Flag supported features.""" - return SUPPORTED_FEATURES - - @property - def unique_id(self): - """Return a unique ID.""" - return '{}-{}'.format(self._device_id, self._number) - - @property - def name(self): - """Return the name of the garage door.""" - return self._name - - @property - def is_opening(self): - """Return if the cover is opening or not.""" - return self._status == STATE_OPENING - - @property - def is_closing(self): - """Return if the cover is closing or not.""" - return self._status == STATE_CLOSING - - @property - def is_closed(self): - """Return None if status is unknown, True if closed, else False.""" - if self._status is None: - return None - return self._status == STATE_CLOSED - - def close_cover(self, **kwargs): - """Issue close command to cover.""" - self._acc.close_door(self._device_id, self._number) - - def open_cover(self, **kwargs): - """Issue open command to cover.""" - self._acc.open_door(self._device_id, self._number) - - def update(self): - """Update status of cover.""" - acc_status = self._acc.get_door_status(self._device_id, self._number) - self._status = STATES_MAP.get(acc_status) diff --git a/homeassistant/components/cover/brunt.py b/homeassistant/components/cover/brunt.py deleted file mode 100644 index 746f3840a..000000000 --- a/homeassistant/components/cover/brunt.py +++ /dev/null @@ -1,182 +0,0 @@ -""" -Support for Brunt Blind Engine covers. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/cover.brunt -""" - -import logging - -import voluptuous as vol - -from homeassistant.const import ( - ATTR_ATTRIBUTION, CONF_PASSWORD, CONF_USERNAME) -from homeassistant.components.cover import ( - ATTR_POSITION, CoverDevice, - PLATFORM_SCHEMA, SUPPORT_CLOSE, - SUPPORT_OPEN, SUPPORT_SET_POSITION -) -import homeassistant.helpers.config_validation as cv - -REQUIREMENTS = ['brunt==0.1.3'] - -_LOGGER = logging.getLogger(__name__) - -COVER_FEATURES = SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_SET_POSITION -DEVICE_CLASS = 'window' - -ATTR_REQUEST_POSITION = 'request_position' -NOTIFICATION_ID = 'brunt_notification' -NOTIFICATION_TITLE = 'Brunt Cover Setup' -ATTRIBUTION = 'Based on an unofficial Brunt SDK.' - -CLOSED_POSITION = 0 -OPEN_POSITION = 100 - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the brunt platform.""" - # pylint: disable=no-name-in-module - from brunt import BruntAPI - username = config[CONF_USERNAME] - password = config[CONF_PASSWORD] - - bapi = BruntAPI(username=username, password=password) - try: - things = bapi.getThings()['things'] - if not things: - _LOGGER.error("No things present in account.") - else: - add_entities([BruntDevice( - bapi, thing['NAME'], - thing['thingUri']) for thing in things], True) - 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 BruntDevice(CoverDevice): - """ - Representation of a Brunt cover device. - - Contains the common logic for all Brunt devices. - """ - - def __init__(self, bapi, name, thing_uri): - """Init the Brunt device.""" - self._bapi = bapi - self._name = name - self._thing_uri = thing_uri - - self._state = {} - self._available = None - - @property - def name(self): - """Return the name of the device as reported by tellcore.""" - return self._name - - @property - def available(self): - """Could the device be accessed during the last update call.""" - return self._available - - @property - def current_cover_position(self): - """ - Return current position of cover. - - None is unknown, 0 is closed, 100 is fully open. - """ - pos = self._state.get('currentPosition') - return int(pos) if pos else None - - @property - def request_cover_position(self): - """ - Return request position of cover. - - The request position is the position of the last request - to Brunt, at times there is a diff of 1 to current - None is unknown, 0 is closed, 100 is fully open. - """ - pos = self._state.get('requestPosition') - return int(pos) if pos else None - - @property - def move_state(self): - """ - Return current moving state of cover. - - None is unknown, 0 when stopped, 1 when opening, 2 when closing - """ - mov = self._state.get('moveState') - return int(mov) if mov else None - - @property - def is_opening(self): - """Return if the cover is opening or not.""" - return self.move_state == 1 - - @property - def is_closing(self): - """Return if the cover is closing or not.""" - return self.move_state == 2 - - @property - def device_state_attributes(self): - """Return the detailed device state attributes.""" - return { - ATTR_ATTRIBUTION: ATTRIBUTION, - ATTR_REQUEST_POSITION: self.request_cover_position - } - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return DEVICE_CLASS - - @property - def supported_features(self): - """Flag supported features.""" - return COVER_FEATURES - - @property - def is_closed(self): - """Return true if cover is closed, else False.""" - return self.current_cover_position == CLOSED_POSITION - - def update(self): - """Poll the current state of the device.""" - try: - self._state = self._bapi.getState( - thingUri=self._thing_uri).get('thing') - self._available = True - except (TypeError, KeyError, NameError, ValueError) as ex: - _LOGGER.error("%s", ex) - self._available = False - - def open_cover(self, **kwargs): - """Set the cover to the open position.""" - self._bapi.changeRequestPosition( - OPEN_POSITION, thingUri=self._thing_uri) - - def close_cover(self, **kwargs): - """Set the cover to the closed position.""" - self._bapi.changeRequestPosition( - CLOSED_POSITION, thingUri=self._thing_uri) - - def set_cover_position(self, **kwargs): - """Set the cover to a specific position.""" - self._bapi.changeRequestPosition( - kwargs[ATTR_POSITION], thingUri=self._thing_uri) diff --git a/homeassistant/components/cover/command_line.py b/homeassistant/components/cover/command_line.py deleted file mode 100644 index bebf78b1d..000000000 --- a/homeassistant/components/cover/command_line.py +++ /dev/null @@ -1,151 +0,0 @@ -""" -Support for command line covers. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/cover.command_line/ -""" -import logging -import subprocess - -import voluptuous as vol - -from homeassistant.components.cover import (CoverDevice, PLATFORM_SCHEMA) -from homeassistant.const import ( - CONF_COMMAND_CLOSE, CONF_COMMAND_OPEN, CONF_COMMAND_STATE, - CONF_COMMAND_STOP, CONF_COVERS, CONF_VALUE_TEMPLATE, CONF_FRIENDLY_NAME) -import homeassistant.helpers.config_validation as cv - -_LOGGER = logging.getLogger(__name__) - -COVER_SCHEMA = vol.Schema({ - vol.Optional(CONF_COMMAND_CLOSE, default='true'): cv.string, - vol.Optional(CONF_COMMAND_OPEN, default='true'): cv.string, - vol.Optional(CONF_COMMAND_STATE): cv.string, - vol.Optional(CONF_COMMAND_STOP, default='true'): cv.string, - vol.Optional(CONF_FRIENDLY_NAME): cv.string, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, -}) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_COVERS): vol.Schema({cv.slug: COVER_SCHEMA}), -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up cover controlled by shell commands.""" - devices = config.get(CONF_COVERS, {}) - covers = [] - - for device_name, device_config in devices.items(): - value_template = device_config.get(CONF_VALUE_TEMPLATE) - if value_template is not None: - value_template.hass = hass - - covers.append( - CommandCover( - hass, - device_config.get(CONF_FRIENDLY_NAME, device_name), - device_config.get(CONF_COMMAND_OPEN), - device_config.get(CONF_COMMAND_CLOSE), - device_config.get(CONF_COMMAND_STOP), - device_config.get(CONF_COMMAND_STATE), - value_template, - ) - ) - - if not covers: - _LOGGER.error("No covers added") - return False - - add_entities(covers) - - -class CommandCover(CoverDevice): - """Representation a command line cover.""" - - def __init__(self, hass, name, command_open, command_close, command_stop, - command_state, value_template): - """Initialize the cover.""" - self._hass = hass - self._name = name - self._state = None - self._command_open = command_open - self._command_close = command_close - self._command_stop = command_stop - self._command_state = command_state - self._value_template = value_template - - @staticmethod - def _move_cover(command): - """Execute the actual commands.""" - _LOGGER.info("Running command: %s", command) - - success = (subprocess.call(command, shell=True) == 0) - - if not success: - _LOGGER.error("Command failed: %s", command) - - return success - - @staticmethod - def _query_state_value(command): - """Execute state command for return value.""" - _LOGGER.info("Running state command: %s", command) - - try: - return_value = subprocess.check_output(command, shell=True) - return return_value.strip().decode('utf-8') - except subprocess.CalledProcessError: - _LOGGER.error("Command failed: %s", command) - - @property - def should_poll(self): - """Only poll if we have state command.""" - return self._command_state is not None - - @property - def name(self): - """Return the name of the cover.""" - return self._name - - @property - def is_closed(self): - """Return if the cover is closed.""" - if self.current_cover_position is not None: - return self.current_cover_position == 0 - - @property - def current_cover_position(self): - """Return current position of cover. - - None is unknown, 0 is closed, 100 is fully open. - """ - return self._state - - def _query_state(self): - """Query for the state.""" - if not self._command_state: - _LOGGER.error("No state command specified") - return - return self._query_state_value(self._command_state) - - def update(self): - """Update device state.""" - if self._command_state: - payload = str(self._query_state()) - if self._value_template: - payload = self._value_template.render_with_possible_json_value( - payload) - self._state = int(payload) - - def open_cover(self, **kwargs): - """Open the cover.""" - self._move_cover(self._command_open) - - def close_cover(self, **kwargs): - """Close the cover.""" - self._move_cover(self._command_close) - - def stop_cover(self, **kwargs): - """Stop the cover.""" - self._move_cover(self._command_stop) diff --git a/homeassistant/components/cover/demo.py b/homeassistant/components/cover/demo.py deleted file mode 100644 index 21add0a6c..000000000 --- a/homeassistant/components/cover/demo.py +++ /dev/null @@ -1,217 +0,0 @@ -""" -Demo platform for the cover component. - -For more details about this platform, please refer to the documentation -https://home-assistant.io/components/demo/ -""" -from homeassistant.components.cover import ( - CoverDevice, SUPPORT_OPEN, SUPPORT_CLOSE, ATTR_POSITION, - ATTR_TILT_POSITION) -from homeassistant.helpers.event import track_utc_time_change - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Demo covers.""" - add_entities([ - DemoCover(hass, 'Kitchen Window'), - DemoCover(hass, 'Hall Window', 10), - DemoCover(hass, 'Living Room Window', 70, 50), - DemoCover(hass, 'Garage Door', device_class='garage', - supported_features=(SUPPORT_OPEN | SUPPORT_CLOSE)), - ]) - - -class DemoCover(CoverDevice): - """Representation of a demo cover.""" - - def __init__(self, hass, name, position=None, tilt_position=None, - device_class=None, supported_features=None): - """Initialize the cover.""" - self.hass = hass - self._name = name - self._position = position - self._device_class = device_class - self._supported_features = supported_features - self._set_position = None - self._set_tilt_position = None - self._tilt_position = tilt_position - self._requested_closing = True - self._requested_closing_tilt = True - self._unsub_listener_cover = None - self._unsub_listener_cover_tilt = None - self._is_opening = False - self._is_closing = False - if position is None: - self._closed = True - else: - self._closed = self.current_cover_position <= 0 - - @property - def name(self): - """Return the name of the cover.""" - return self._name - - @property - def should_poll(self): - """No polling needed for a demo cover.""" - return False - - @property - def current_cover_position(self): - """Return the current position of the cover.""" - return self._position - - @property - def current_cover_tilt_position(self): - """Return the current tilt position of the cover.""" - return self._tilt_position - - @property - def is_closed(self): - """Return if the cover is closed.""" - return self._closed - - @property - def is_closing(self): - """Return if the cover is closing.""" - return self._is_closing - - @property - def is_opening(self): - """Return if the cover is opening.""" - return self._is_opening - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return self._device_class - - @property - def supported_features(self): - """Flag supported features.""" - if self._supported_features is not None: - return self._supported_features - return super().supported_features - - def close_cover(self, **kwargs): - """Close the cover.""" - if self._position == 0: - return - if self._position is None: - self._closed = True - self.schedule_update_ha_state() - return - - self._is_closing = True - self._listen_cover() - self._requested_closing = True - self.schedule_update_ha_state() - - def close_cover_tilt(self, **kwargs): - """Close the cover tilt.""" - if self._tilt_position in (0, None): - return - - self._listen_cover_tilt() - self._requested_closing_tilt = True - - def open_cover(self, **kwargs): - """Open the cover.""" - if self._position == 100: - return - if self._position is None: - self._closed = False - self.schedule_update_ha_state() - return - - self._is_opening = True - self._listen_cover() - self._requested_closing = False - self.schedule_update_ha_state() - - def open_cover_tilt(self, **kwargs): - """Open the cover tilt.""" - if self._tilt_position in (100, None): - return - - self._listen_cover_tilt() - self._requested_closing_tilt = False - - 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 - - self._listen_cover() - self._requested_closing = position < self._position - - 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 - - self._listen_cover_tilt() - self._requested_closing_tilt = tilt_position < self._tilt_position - - def stop_cover(self, **kwargs): - """Stop the cover.""" - self._is_closing = False - self._is_opening = False - if self._position is None: - return - if self._unsub_listener_cover is not None: - self._unsub_listener_cover() - self._unsub_listener_cover = None - self._set_position = None - - def stop_cover_tilt(self, **kwargs): - """Stop the cover tilt.""" - if self._tilt_position is None: - return - - if self._unsub_listener_cover_tilt is not None: - self._unsub_listener_cover_tilt() - self._unsub_listener_cover_tilt = None - self._set_tilt_position = None - - def _listen_cover(self): - """Listen for changes in cover.""" - if self._unsub_listener_cover is None: - self._unsub_listener_cover = track_utc_time_change( - self.hass, self._time_changed_cover) - - def _time_changed_cover(self, now): - """Track time changes.""" - if self._requested_closing: - self._position -= 10 - else: - self._position += 10 - - if self._position in (100, 0, self._set_position): - self.stop_cover() - - self._closed = self.current_cover_position <= 0 - - self.schedule_update_ha_state() - - def _listen_cover_tilt(self): - """Listen for changes in cover tilt.""" - if self._unsub_listener_cover_tilt is None: - self._unsub_listener_cover_tilt = track_utc_time_change( - self.hass, self._time_changed_cover_tilt) - - def _time_changed_cover_tilt(self, now): - """Track time changes.""" - if self._requested_closing_tilt: - self._tilt_position -= 10 - else: - self._tilt_position += 10 - - if self._tilt_position in (100, 0, self._set_tilt_position): - self.stop_cover_tilt() - - self.schedule_update_ha_state() diff --git a/homeassistant/components/cover/device_condition.py b/homeassistant/components/cover/device_condition.py new file mode 100644 index 000000000..ec6da84e5 --- /dev/null +++ b/homeassistant/components/cover/device_condition.py @@ -0,0 +1,207 @@ +"""Provides device automations for Cover.""" +from typing import Any, Dict, List + +import voluptuous as vol + +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + CONF_ABOVE, + CONF_BELOW, + CONF_CONDITION, + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_ENTITY_ID, + CONF_TYPE, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import ( + condition, + config_validation as cv, + entity_registry, + template, +) +from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA +from homeassistant.helpers.typing import ConfigType, TemplateVarsType + +from . import ( + DOMAIN, + SUPPORT_CLOSE, + SUPPORT_OPEN, + SUPPORT_SET_POSITION, + SUPPORT_SET_TILT_POSITION, +) + +POSITION_CONDITION_TYPES = {"is_position", "is_tilt_position"} +STATE_CONDITION_TYPES = {"is_open", "is_closed", "is_opening", "is_closing"} + +POSITION_CONDITION_SCHEMA = vol.All( + DEVICE_CONDITION_BASE_SCHEMA.extend( + { + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TYPE): vol.In(POSITION_CONDITION_TYPES), + vol.Optional(CONF_ABOVE): vol.All( + vol.Coerce(int), vol.Range(min=0, max=100) + ), + vol.Optional(CONF_BELOW): vol.All( + vol.Coerce(int), vol.Range(min=0, max=100) + ), + } + ), + cv.has_at_least_one_key(CONF_BELOW, CONF_ABOVE), +) + +STATE_CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend( + { + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TYPE): vol.In(STATE_CONDITION_TYPES), + } +) + +CONDITION_SCHEMA = vol.Any(POSITION_CONDITION_SCHEMA, STATE_CONDITION_SCHEMA) + + +async def async_get_conditions(hass: HomeAssistant, device_id: str) -> List[dict]: + """List device conditions for Cover devices.""" + registry = await entity_registry.async_get_registry(hass) + conditions: List[Dict[str, Any]] = [] + + # Get all the integrations entities for this device + for entry in entity_registry.async_entries_for_device(registry, device_id): + if entry.domain != DOMAIN: + continue + + state = hass.states.get(entry.entity_id) + if not state or ATTR_SUPPORTED_FEATURES not in state.attributes: + continue + + supported_features = state.attributes[ATTR_SUPPORTED_FEATURES] + supports_open_close = supported_features & (SUPPORT_OPEN | SUPPORT_CLOSE) + + # Add conditions for each entity that belongs to this integration + if supports_open_close: + conditions.append( + { + CONF_CONDITION: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "is_open", + } + ) + conditions.append( + { + CONF_CONDITION: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "is_closed", + } + ) + conditions.append( + { + CONF_CONDITION: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "is_opening", + } + ) + conditions.append( + { + CONF_CONDITION: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "is_closing", + } + ) + if supported_features & SUPPORT_SET_POSITION: + conditions.append( + { + CONF_CONDITION: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "is_position", + } + ) + if supported_features & SUPPORT_SET_TILT_POSITION: + conditions.append( + { + CONF_CONDITION: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "is_tilt_position", + } + ) + + return conditions + + +async def async_get_condition_capabilities(hass: HomeAssistant, config: dict) -> dict: + """List condition capabilities.""" + if config[CONF_TYPE] not in ["is_position", "is_tilt_position"]: + return {} + + return { + "extra_fields": vol.Schema( + { + vol.Optional(CONF_ABOVE, default=0): vol.All( + vol.Coerce(int), vol.Range(min=0, max=100) + ), + vol.Optional(CONF_BELOW, default=100): vol.All( + vol.Coerce(int), vol.Range(min=0, max=100) + ), + } + ) + } + + +def async_condition_from_config( + config: ConfigType, config_validation: bool +) -> condition.ConditionCheckerType: + """Create a function to test a device condition.""" + if config_validation: + config = CONDITION_SCHEMA(config) + + if config[CONF_TYPE] in STATE_CONDITION_TYPES: + if config[CONF_TYPE] == "is_open": + state = STATE_OPEN + elif config[CONF_TYPE] == "is_closed": + state = STATE_CLOSED + elif config[CONF_TYPE] == "is_opening": + state = STATE_OPENING + elif config[CONF_TYPE] == "is_closing": + state = STATE_CLOSING + + def test_is_state(hass: HomeAssistant, variables: TemplateVarsType) -> bool: + """Test if an entity is a certain state.""" + return condition.state(hass, config[ATTR_ENTITY_ID], state) + + return test_is_state + + if config[CONF_TYPE] == "is_position": + position = "current_position" + if config[CONF_TYPE] == "is_tilt_position": + position = "current_tilt_position" + min_pos = config.get(CONF_ABOVE, None) + max_pos = config.get(CONF_BELOW, None) + value_template = template.Template( # type: ignore + f"{{{{ state.attributes.{position} }}}}" + ) + + def template_if(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool: + """Validate template based if-condition.""" + value_template.hass = hass + + return condition.async_numeric_state( + hass, config[ATTR_ENTITY_ID], max_pos, min_pos, value_template + ) + + return template_if diff --git a/homeassistant/components/cover/device_trigger.py b/homeassistant/components/cover/device_trigger.py new file mode 100644 index 000000000..988427003 --- /dev/null +++ b/homeassistant/components/cover/device_trigger.py @@ -0,0 +1,212 @@ +"""Provides device automations for Cover.""" +from typing import List + +import voluptuous as vol + +from homeassistant.components.automation import ( + AutomationActionType, + numeric_state as numeric_state_automation, + state as state_automation, +) +from homeassistant.components.device_automation import TRIGGER_BASE_SCHEMA +from homeassistant.const import ( + ATTR_SUPPORTED_FEATURES, + CONF_ABOVE, + CONF_BELOW, + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_ENTITY_ID, + CONF_PLATFORM, + CONF_TYPE, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, +) +from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers.typing import ConfigType + +from . import ( + DOMAIN, + SUPPORT_CLOSE, + SUPPORT_OPEN, + SUPPORT_SET_POSITION, + SUPPORT_SET_TILT_POSITION, +) + +POSITION_TRIGGER_TYPES = {"position", "tilt_position"} +STATE_TRIGGER_TYPES = {"opened", "closed", "opening", "closing"} + +POSITION_TRIGGER_SCHEMA = vol.All( + TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TYPE): vol.In(POSITION_TRIGGER_TYPES), + vol.Optional(CONF_ABOVE): vol.All( + vol.Coerce(int), vol.Range(min=0, max=100) + ), + vol.Optional(CONF_BELOW): vol.All( + vol.Coerce(int), vol.Range(min=0, max=100) + ), + } + ), + cv.has_at_least_one_key(CONF_BELOW, CONF_ABOVE), +) + +STATE_TRIGGER_SCHEMA = TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TYPE): vol.In(STATE_TRIGGER_TYPES), + } +) + +TRIGGER_SCHEMA = vol.Any(POSITION_TRIGGER_SCHEMA, STATE_TRIGGER_SCHEMA) + + +async def async_get_triggers(hass: HomeAssistant, device_id: str) -> List[dict]: + """List device triggers for Cover devices.""" + registry = await entity_registry.async_get_registry(hass) + triggers = [] + + # Get all the integrations entities for this device + for entry in entity_registry.async_entries_for_device(registry, device_id): + if entry.domain != DOMAIN: + continue + + state = hass.states.get(entry.entity_id) + if not state or ATTR_SUPPORTED_FEATURES not in state.attributes: + continue + + supported_features = state.attributes[ATTR_SUPPORTED_FEATURES] + supports_open_close = supported_features & (SUPPORT_OPEN | SUPPORT_CLOSE) + + # Add triggers for each entity that belongs to this integration + if supports_open_close: + triggers.append( + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "opened", + } + ) + triggers.append( + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "closed", + } + ) + triggers.append( + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "opening", + } + ) + triggers.append( + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "closing", + } + ) + if supported_features & SUPPORT_SET_POSITION: + triggers.append( + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "position", + } + ) + if supported_features & SUPPORT_SET_TILT_POSITION: + triggers.append( + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "tilt_position", + } + ) + + return triggers + + +async def async_get_trigger_capabilities(hass: HomeAssistant, config: dict) -> dict: + """List trigger capabilities.""" + if config[CONF_TYPE] not in ["position", "tilt_position"]: + return {} + + return { + "extra_fields": vol.Schema( + { + vol.Optional(CONF_ABOVE, default=0): vol.All( + vol.Coerce(int), vol.Range(min=0, max=100) + ), + vol.Optional(CONF_BELOW, default=100): vol.All( + vol.Coerce(int), vol.Range(min=0, max=100) + ), + } + ) + } + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: AutomationActionType, + automation_info: dict, +) -> CALLBACK_TYPE: + """Attach a trigger.""" + config = TRIGGER_SCHEMA(config) + + if config[CONF_TYPE] in STATE_TRIGGER_TYPES: + if config[CONF_TYPE] == "opened": + to_state = STATE_OPEN + elif config[CONF_TYPE] == "closed": + to_state = STATE_CLOSED + elif config[CONF_TYPE] == "opening": + to_state = STATE_OPENING + elif config[CONF_TYPE] == "closing": + to_state = STATE_CLOSING + + state_config = { + state_automation.CONF_PLATFORM: "state", + CONF_ENTITY_ID: config[CONF_ENTITY_ID], + state_automation.CONF_TO: to_state, + } + state_config = state_automation.TRIGGER_SCHEMA(state_config) + return await state_automation.async_attach_trigger( + hass, state_config, action, automation_info, platform_type="device" + ) + + if config[CONF_TYPE] == "position": + position = "current_position" + if config[CONF_TYPE] == "tilt_position": + position = "current_tilt_position" + min_pos = config.get(CONF_ABOVE, -1) + max_pos = config.get(CONF_BELOW, 101) + value_template = f"{{{{ state.attributes.{position} }}}}" + + numeric_state_config = { + numeric_state_automation.CONF_PLATFORM: "numeric_state", + numeric_state_automation.CONF_ENTITY_ID: config[CONF_ENTITY_ID], + numeric_state_automation.CONF_BELOW: max_pos, + numeric_state_automation.CONF_ABOVE: min_pos, + numeric_state_automation.CONF_VALUE_TEMPLATE: value_template, + } + numeric_state_config = numeric_state_automation.TRIGGER_SCHEMA(numeric_state_config) + return await numeric_state_automation.async_attach_trigger( + hass, numeric_state_config, action, automation_info, platform_type="device" + ) diff --git a/homeassistant/components/cover/garadget.py b/homeassistant/components/cover/garadget.py deleted file mode 100644 index 7a04aa4c7..000000000 --- a/homeassistant/components/cover/garadget.py +++ /dev/null @@ -1,267 +0,0 @@ -""" -Platform for the Garadget cover component. - -For more details about this platform, please refer to the documentation -https://home-assistant.io/components/garadget/ -""" -import logging - -import requests -import voluptuous as vol - -import homeassistant.helpers.config_validation as cv -from homeassistant.components.cover import CoverDevice, PLATFORM_SCHEMA -from homeassistant.helpers.event import track_utc_time_change -from homeassistant.const import ( - CONF_DEVICE, CONF_USERNAME, CONF_PASSWORD, CONF_ACCESS_TOKEN, CONF_NAME, - STATE_UNKNOWN, STATE_CLOSED, STATE_OPEN, CONF_COVERS) - -_LOGGER = logging.getLogger(__name__) - -ATTR_AVAILABLE = 'available' -ATTR_SENSOR_STRENGTH = 'sensor_reflection_rate' -ATTR_SIGNAL_STRENGTH = 'wifi_signal_strength' -ATTR_TIME_IN_STATE = 'time_in_state' - -DEFAULT_NAME = 'Garadget' - -STATE_CLOSING = 'closing' -STATE_OFFLINE = 'offline' -STATE_OPENING = 'opening' -STATE_STOPPED = 'stopped' - -STATES_MAP = { - 'open': STATE_OPEN, - 'opening': STATE_OPENING, - 'closed': STATE_CLOSED, - 'closing': STATE_CLOSING, - 'stopped': STATE_STOPPED -} - -COVER_SCHEMA = vol.Schema({ - vol.Optional(CONF_ACCESS_TOKEN): cv.string, - vol.Optional(CONF_DEVICE): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PASSWORD): cv.string, - vol.Optional(CONF_USERNAME): cv.string, -}) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_COVERS): vol.Schema({cv.slug: COVER_SCHEMA}), -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Garadget covers.""" - covers = [] - devices = config.get(CONF_COVERS) - - for device_id, device_config in devices.items(): - args = { - 'name': device_config.get(CONF_NAME), - 'device_id': device_config.get(CONF_DEVICE, device_id), - 'username': device_config.get(CONF_USERNAME), - 'password': device_config.get(CONF_PASSWORD), - 'access_token': device_config.get(CONF_ACCESS_TOKEN) - } - - covers.append(GaradgetCover(hass, args)) - - add_entities(covers) - - -class GaradgetCover(CoverDevice): - """Representation of a Garadget cover.""" - - def __init__(self, hass, args): - """Initialize the cover.""" - self.particle_url = 'https://api.particle.io' - self.hass = hass - self._name = args['name'] - self.device_id = args['device_id'] - self.access_token = args['access_token'] - self.obtained_token = False - self._username = args['username'] - self._password = args['password'] - self._state = STATE_UNKNOWN - self.time_in_state = None - self.signal = None - self.sensor = None - self._unsub_listener_cover = None - self._available = True - - if self.access_token is None: - self.access_token = self.get_token() - self._obtained_token = True - - try: - if self._name is None: - doorconfig = self._get_variable('doorConfig') - if doorconfig['nme'] is not None: - self._name = doorconfig['nme'] - self.update() - except requests.exceptions.ConnectionError as ex: - _LOGGER.error( - "Unable to connect to server: %(reason)s", dict(reason=ex)) - self._state = STATE_OFFLINE - self._available = False - self._name = DEFAULT_NAME - except KeyError as ex: - _LOGGER.warning("Garadget device %(device)s seems to be offline", - dict(device=self.device_id)) - self._name = DEFAULT_NAME - self._state = STATE_OFFLINE - self._available = False - - def __del__(self): - """Try to remove token.""" - if self._obtained_token is True: - if self.access_token is not None: - self.remove_token() - - @property - def name(self): - """Return the name of the cover.""" - return self._name - - @property - def should_poll(self): - """No polling needed for a demo cover.""" - return True - - @property - def available(self): - """Return True if entity is available.""" - return self._available - - @property - def device_state_attributes(self): - """Return the device state attributes.""" - data = {} - - if self.signal is not None: - data[ATTR_SIGNAL_STRENGTH] = self.signal - - if self.time_in_state is not None: - data[ATTR_TIME_IN_STATE] = self.time_in_state - - if self.sensor is not None: - data[ATTR_SENSOR_STRENGTH] = self.sensor - - if self.access_token is not None: - data[CONF_ACCESS_TOKEN] = self.access_token - - return data - - @property - def is_closed(self): - """Return if the cover is closed.""" - if self._state == STATE_UNKNOWN: - return None - return self._state == STATE_CLOSED - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return 'garage' - - def get_token(self): - """Get new token for usage during this session.""" - args = { - 'grant_type': 'password', - 'username': self._username, - 'password': self._password - } - url = '{}/oauth/token'.format(self.particle_url) - ret = requests.post( - url, auth=('particle', 'particle'), data=args, timeout=10) - - try: - return ret.json()['access_token'] - except KeyError: - _LOGGER.error("Unable to retrieve access token") - - def remove_token(self): - """Remove authorization token from API.""" - url = '{}/v1/access_tokens/{}'.format( - self.particle_url, self.access_token) - ret = requests.delete( - url, auth=(self._username, self._password), timeout=10) - return ret.text - - def _start_watcher(self, command): - """Start watcher.""" - _LOGGER.debug("Starting Watcher for command: %s ", command) - if self._unsub_listener_cover is None: - self._unsub_listener_cover = track_utc_time_change( - self.hass, self._check_state) - - def _check_state(self, now): - """Check the state of the service during an operation.""" - self.schedule_update_ha_state(True) - - 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, **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, **kwargs): - """Stop the door where it is.""" - if self._state not in ['stopped']: - ret = self._put_command('setState', 'stop') - self._start_watcher('stop') - return ret['return_value'] == 1 - - def update(self): - """Get updated status from API.""" - try: - status = self._get_variable('doorStatus') - _LOGGER.debug("Current Status: %s", status['status']) - self._state = STATES_MAP.get(status['status'], STATE_UNKNOWN) - self.time_in_state = status['time'] - self.signal = status['signal'] - self.sensor = status['sensor'] - self._available = True - except requests.exceptions.ConnectionError as ex: - _LOGGER.error( - "Unable to connect to server: %(reason)s", dict(reason=ex)) - self._state = STATE_OFFLINE - except KeyError as ex: - _LOGGER.warning("Garadget device %(device)s seems to be offline", - dict(device=self.device_id)) - self._state = STATE_OFFLINE - - if self._state not in [STATE_CLOSING, STATE_OPENING]: - if self._unsub_listener_cover is not None: - self._unsub_listener_cover() - self._unsub_listener_cover = None - - def _get_variable(self, var): - """Get latest status.""" - url = '{}/v1/devices/{}/{}?access_token={}'.format( - self.particle_url, self.device_id, var, self.access_token) - ret = requests.get(url, timeout=10) - result = {} - for pairs in ret.json()['result'].split('|'): - key = pairs.split('=') - result[key[0]] = key[1] - return result - - def _put_command(self, func, arg=None): - """Send commands to API.""" - params = {'access_token': self.access_token} - if arg: - params['command'] = arg - url = '{}/v1/devices/{}/{}'.format( - self.particle_url, self.device_id, func) - ret = requests.post(url, data=params, timeout=10) - return ret.json() diff --git a/homeassistant/components/cover/gogogate2.py b/homeassistant/components/cover/gogogate2.py deleted file mode 100644 index accc4f9ec..000000000 --- a/homeassistant/components/cover/gogogate2.py +++ /dev/null @@ -1,117 +0,0 @@ -""" -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_entities, 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_entities(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 deleted file mode 100644 index 0424c9007..000000000 --- a/homeassistant/components/cover/group.py +++ /dev/null @@ -1,271 +0,0 @@ -""" -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_entities, - discovery_info=None): - """Set up the Group Cover platform.""" - async_add_entities( - [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 deleted file mode 100644 index 935743212..000000000 --- a/homeassistant/components/cover/homematic.py +++ /dev/null @@ -1,110 +0,0 @@ -""" -The HomeMatic cover platform. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/cover.homematic/ -""" -import logging - -from homeassistant.components.cover import ( - ATTR_POSITION, ATTR_TILT_POSITION, CoverDevice) -from homeassistant.components.homematic import ATTR_DISCOVER_DEVICES, HMDevice -from homeassistant.const import STATE_UNKNOWN - -_LOGGER = logging.getLogger(__name__) - -DEPENDENCIES = ['homematic'] - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the platform.""" - if discovery_info is None: - return - - devices = [] - for conf in discovery_info[ATTR_DISCOVER_DEVICES]: - new_device = HMCover(conf) - devices.append(new_device) - - add_entities(devices) - - -class HMCover(HMDevice, CoverDevice): - """Representation a HomeMatic Cover.""" - - @property - def current_cover_position(self): - """ - Return current position of cover. - - None is unknown, 0 is closed, 100 is fully open. - """ - return int(self._hm_get_state() * 100) - - def set_cover_position(self, **kwargs): - """Move the cover to a specific position.""" - if ATTR_POSITION in kwargs: - position = float(kwargs[ATTR_POSITION]) - position = min(100, max(0, position)) - level = position / 100.0 - self._hmdevice.set_level(level, self._channel) - - @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 - - def open_cover(self, **kwargs): - """Open the cover.""" - self._hmdevice.move_up(self._channel) - - def close_cover(self, **kwargs): - """Close the cover.""" - self._hmdevice.move_down(self._channel) - - def stop_cover(self, **kwargs): - """Stop the device if in motion.""" - self._hmdevice.stop(self._channel) - - def _init_data_struct(self): - """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/insteon.py b/homeassistant/components/cover/insteon.py deleted file mode 100644 index f0cf93c13..000000000 --- a/homeassistant/components/cover/insteon.py +++ /dev/null @@ -1,73 +0,0 @@ -""" -Support for Insteon covers via PowerLinc Modem. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/cover.insteon/ -""" -import logging -import math - -from homeassistant.components.insteon import InsteonEntity -from homeassistant.components.cover import (CoverDevice, ATTR_POSITION, - SUPPORT_OPEN, SUPPORT_CLOSE, - SUPPORT_SET_POSITION) - -_LOGGER = logging.getLogger(__name__) - -DEPENDENCIES = ['insteon'] -SUPPORTED_FEATURES = SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_SET_POSITION - - -async def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): - """Set up the Insteon platform.""" - if not discovery_info: - return - - insteon_modem = hass.data['insteon'].get('modem') - - address = discovery_info['address'] - device = insteon_modem.devices[address] - state_key = discovery_info['state_key'] - - _LOGGER.debug('Adding device %s entity %s to Cover platform', - device.address.hex, device.states[state_key].name) - - new_entity = InsteonCoverDevice(device, state_key) - - async_add_entities([new_entity]) - - -class InsteonCoverDevice(InsteonEntity, CoverDevice): - """A Class for an Insteon device.""" - - @property - def current_cover_position(self): - """Return the current cover position.""" - return int(math.ceil(self._insteon_device_state.value*100/255)) - - @property - def supported_features(self): - """Return the supported features for this entity.""" - return SUPPORTED_FEATURES - - @property - def is_closed(self): - """Return the boolean response if the node is on.""" - return bool(self.current_cover_position) - - async def async_open_cover(self, **kwargs): - """Open device.""" - self._insteon_device_state.open() - - async def async_close_cover(self, **kwargs): - """Close device.""" - self._insteon_device_state.close() - - async def async_set_cover_position(self, **kwargs): - """Set the cover position.""" - position = int(kwargs[ATTR_POSITION]*255/100) - if position == 0: - self._insteon_device_state.close() - else: - self._insteon_device_state.set_position(position) diff --git a/homeassistant/components/cover/intent.py b/homeassistant/components/cover/intent.py new file mode 100644 index 000000000..36402025b --- /dev/null +++ b/homeassistant/components/cover/intent.py @@ -0,0 +1,22 @@ +"""Intents for the cover integration.""" +from homeassistant.core import HomeAssistant +from homeassistant.helpers import intent + +from . import DOMAIN, SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER + +INTENT_OPEN_COVER = "HassOpenCover" +INTENT_CLOSE_COVER = "HassCloseCover" + + +async def async_setup_intents(hass: HomeAssistant) -> None: + """Set up the cover intents.""" + 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 {}" + ) + ) diff --git a/homeassistant/components/cover/isy994.py b/homeassistant/components/cover/isy994.py deleted file mode 100644 index 428c1f326..000000000 --- a/homeassistant/components/cover/isy994.py +++ /dev/null @@ -1,94 +0,0 @@ -""" -Support for ISY994 covers. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/cover.isy994/ -""" -import logging -from typing import Callable - -from homeassistant.components.cover import CoverDevice, DOMAIN -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__) - -VALUE_TO_STATE = { - 0: STATE_CLOSED, - 101: STATE_UNKNOWN, - 102: 'stopped', - 103: STATE_CLOSING, - 104: STATE_OPENING -} - - -def setup_platform(hass, config: ConfigType, - add_entities: Callable[[list], None], discovery_info=None): - """Set up the ISY994 cover platform.""" - devices = [] - for node in hass.data[ISY994_NODES][DOMAIN]: - devices.append(ISYCoverDevice(node)) - - for name, status, actions in hass.data[ISY994_PROGRAMS][DOMAIN]: - devices.append(ISYCoverProgram(name, status, actions)) - - add_entities(devices) - - -class ISYCoverDevice(ISYDevice, CoverDevice): - """Representation of an ISY994 cover device.""" - - @property - def current_cover_position(self) -> int: - """Return the current cover position.""" - return sorted((0, self.value, 100))[1] - - @property - def is_closed(self) -> bool: - """Get whether the ISY994 cover device is closed.""" - return self.state == STATE_CLOSED - - @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: - """Send the open cover command to the ISY994 cover device.""" - if not self._node.on(val=100): - _LOGGER.error("Unable to open the cover") - - def close_cover(self, **kwargs) -> None: - """Send the close cover command to the ISY994 cover device.""" - if not self._node.off(): - _LOGGER.error("Unable to close the cover") - - -class ISYCoverProgram(ISYCoverDevice): - """Representation of an ISY994 cover program.""" - - def __init__(self, name: str, node: object, actions: object) -> None: - """Initialize the ISY994 cover program.""" - super().__init__(node) - self._name = name - self._actions = actions - - @property - def state(self) -> str: - """Get the state of the ISY994 cover program.""" - return STATE_CLOSED if bool(self.value) else STATE_OPEN - - def open_cover(self, **kwargs) -> None: - """Send the open cover command to the ISY994 cover program.""" - if not self._actions.runThen(): - _LOGGER.error("Unable to open the cover") - - def close_cover(self, **kwargs) -> None: - """Send the close cover command to the ISY994 cover program.""" - if not self._actions.runElse(): - _LOGGER.error("Unable to close the cover") diff --git a/homeassistant/components/cover/knx.py b/homeassistant/components/cover/knx.py deleted file mode 100644 index 43a87fab3..000000000 --- a/homeassistant/components/cover/knx.py +++ /dev/null @@ -1,203 +0,0 @@ -""" -Support for KNX/IP covers. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/cover.knx/ -""" - -import voluptuous as vol - -from homeassistant.components.cover import ( - 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' -CONF_POSITION_ADDRESS = 'position_address' -CONF_POSITION_STATE_ADDRESS = 'position_state_address' -CONF_ANGLE_ADDRESS = 'angle_address' -CONF_ANGLE_STATE_ADDRESS = 'angle_state_address' -CONF_TRAVELLING_TIME_DOWN = 'travelling_time_down' -CONF_TRAVELLING_TIME_UP = 'travelling_time_up' -CONF_INVERT_POSITION = 'invert_position' -CONF_INVERT_ANGLE = 'invert_angle' - -DEFAULT_TRAVEL_TIME = 25 -DEFAULT_NAME = 'KNX Cover' -DEPENDENCIES = ['knx'] - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_MOVE_LONG_ADDRESS): cv.string, - vol.Optional(CONF_MOVE_SHORT_ADDRESS): cv.string, - vol.Optional(CONF_POSITION_ADDRESS): cv.string, - vol.Optional(CONF_POSITION_STATE_ADDRESS): cv.string, - vol.Optional(CONF_ANGLE_ADDRESS): cv.string, - vol.Optional(CONF_ANGLE_STATE_ADDRESS): cv.string, - vol.Optional(CONF_TRAVELLING_TIME_DOWN, default=DEFAULT_TRAVEL_TIME): - cv.positive_int, - vol.Optional(CONF_TRAVELLING_TIME_UP, default=DEFAULT_TRAVEL_TIME): - cv.positive_int, - vol.Optional(CONF_INVERT_POSITION, default=False): cv.boolean, - vol.Optional(CONF_INVERT_ANGLE, default=False): cv.boolean, -}) - - -async def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): - """Set up cover(s) for KNX platform.""" - if discovery_info is not None: - async_add_entities_discovery(hass, discovery_info, async_add_entities) - else: - async_add_entities_config(hass, config, async_add_entities) - - -@callback -def async_add_entities_discovery(hass, discovery_info, async_add_entities): - """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)) - async_add_entities(entities) - - -@callback -def async_add_entities_config(hass, config, async_add_entities): - """Set up cover for KNX platform configured within platform.""" - import xknx - cover = xknx.devices.Cover( - hass.data[DATA_KNX].xknx, - name=config.get(CONF_NAME), - group_address_long=config.get(CONF_MOVE_LONG_ADDRESS), - group_address_short=config.get(CONF_MOVE_SHORT_ADDRESS), - group_address_position_state=config.get( - CONF_POSITION_STATE_ADDRESS), - group_address_angle=config.get(CONF_ANGLE_ADDRESS), - 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), - invert_position=config.get(CONF_INVERT_POSITION), - invert_angle=config.get(CONF_INVERT_ANGLE)) - - hass.data[DATA_KNX].xknx.devices.add(cover) - async_add_entities([KNXCover(hass, cover)]) - - -class KNXCover(CoverDevice): - """Representation of a KNX cover.""" - - def __init__(self, hass, device): - """Initialize the cover.""" - self.device = device - self.hass = hass - self.async_register_callbacks() - - self._unsubscribe_auto_updater = None - - @callback - def async_register_callbacks(self): - """Register callbacks to update hass after device was changed.""" - async def after_update_callback(device): - """Call after device was updated.""" - await self.async_update_ha_state() - self.device.register_device_updated_cb(after_update_callback) - - @property - 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.""" - return False - - @property - def supported_features(self): - """Flag supported features.""" - supported_features = SUPPORT_OPEN | SUPPORT_CLOSE | \ - SUPPORT_SET_POSITION | SUPPORT_STOP - if self.device.supports_angle: - supported_features |= SUPPORT_SET_TILT_POSITION - return supported_features - - @property - def current_cover_position(self): - """Return the current position of the cover.""" - return self.device.current_position() - - @property - def is_closed(self): - """Return if the cover is closed.""" - return self.device.is_closed() - - async def async_close_cover(self, **kwargs): - """Close the cover.""" - if not self.device.is_closed(): - await self.device.set_down() - self.start_auto_updater() - - async def async_open_cover(self, **kwargs): - """Open the cover.""" - if not self.device.is_open(): - await self.device.set_up() - self.start_auto_updater() - - async def async_set_cover_position(self, **kwargs): - """Move the cover to a specific position.""" - if ATTR_POSITION in kwargs: - position = kwargs[ATTR_POSITION] - await self.device.set_position(position) - self.start_auto_updater() - - async def async_stop_cover(self, **kwargs): - """Stop the cover.""" - await self.device.stop() - self.stop_auto_updater() - - @property - def current_cover_tilt_position(self): - """Return current tilt position of cover.""" - if not self.device.supports_angle: - return None - return self.device.current_angle() - - async def async_set_cover_tilt_position(self, **kwargs): - """Move the cover tilt to a specific position.""" - if ATTR_TILT_POSITION in kwargs: - 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.""" - if self._unsubscribe_auto_updater is None: - self._unsubscribe_auto_updater = async_track_utc_time_change( - self.hass, self.auto_updater_hook) - - def stop_auto_updater(self): - """Stop the autoupdater.""" - if self._unsubscribe_auto_updater is not None: - self._unsubscribe_auto_updater() - self._unsubscribe_auto_updater = None - - @callback - def auto_updater_hook(self, now): - """Call for the autoupdater.""" - 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()) diff --git a/homeassistant/components/cover/lutron.py b/homeassistant/components/cover/lutron.py deleted file mode 100644 index 7ea7abf88..000000000 --- a/homeassistant/components/cover/lutron.py +++ /dev/null @@ -1,75 +0,0 @@ -""" -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'] - - -def setup_platform(hass, config, add_entities, 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_entities(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 deleted file mode 100644 index 37b7c1be4..000000000 --- a/homeassistant/components/cover/lutron_caseta.py +++ /dev/null @@ -1,68 +0,0 @@ -""" -Support for Lutron Caseta shades. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/cover.lutron_caseta/ -""" -import logging - -from homeassistant.components.cover import ( - CoverDevice, SUPPORT_OPEN, SUPPORT_CLOSE, SUPPORT_SET_POSITION, - ATTR_POSITION, DOMAIN) -from homeassistant.components.lutron_caseta import ( - LUTRON_CASETA_SMARTBRIDGE, LutronCasetaDevice) - -_LOGGER = logging.getLogger(__name__) - -DEPENDENCIES = ['lutron_caseta'] - - -async def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): - """Set up the Lutron Caseta shades as a cover device.""" - devs = [] - bridge = hass.data[LUTRON_CASETA_SMARTBRIDGE] - cover_devices = bridge.get_devices_by_domain(DOMAIN) - for cover_device in cover_devices: - dev = LutronCasetaCover(cover_device, bridge) - devs.append(dev) - - async_add_entities(devs, True) - - -class LutronCasetaCover(LutronCasetaDevice, 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._state['current_state'] < 1 - - @property - def current_cover_position(self): - """Return the current position of cover.""" - return self._state['current_state'] - - async def async_close_cover(self, **kwargs): - """Close the cover.""" - self._smartbridge.set_value(self._device_id, 0) - - async def async_open_cover(self, **kwargs): - """Open the cover.""" - self._smartbridge.set_value(self._device_id, 100) - - async 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) - - async 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/manifest.json b/homeassistant/components/cover/manifest.json new file mode 100644 index 000000000..1d82dcb5b --- /dev/null +++ b/homeassistant/components/cover/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "cover", + "name": "Cover", + "documentation": "https://www.home-assistant.io/integrations/cover", + "requirements": [], + "dependencies": [ + "group" + ], + "codeowners": [ + "@home-assistant/core" + ] +} diff --git a/homeassistant/components/cover/mqtt.py b/homeassistant/components/cover/mqtt.py deleted file mode 100644 index 977353cb3..000000000 --- a/homeassistant/components/cover/mqtt.py +++ /dev/null @@ -1,389 +0,0 @@ -""" -Support for MQTT cover devices. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/cover.mqtt/ -""" -import logging - -import voluptuous as vol - -from homeassistant.core import callback -from homeassistant.components import mqtt -from homeassistant.components.cover import ( - CoverDevice, ATTR_TILT_POSITION, SUPPORT_OPEN_TILT, - SUPPORT_CLOSE_TILT, SUPPORT_STOP_TILT, SUPPORT_SET_TILT_POSITION, - SUPPORT_OPEN, SUPPORT_CLOSE, SUPPORT_STOP, SUPPORT_SET_POSITION, - ATTR_POSITION) -from homeassistant.exceptions import TemplateError -from homeassistant.const import ( - CONF_NAME, CONF_VALUE_TEMPLATE, CONF_OPTIMISTIC, STATE_OPEN, - STATE_CLOSED, STATE_UNKNOWN) -from homeassistant.components.mqtt import ( - CONF_AVAILABILITY_TOPIC, CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, - CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, CONF_RETAIN, - valid_publish_topic, valid_subscribe_topic, MqttAvailability) -import homeassistant.helpers.config_validation as cv - -_LOGGER = logging.getLogger(__name__) - -DEPENDENCIES = ['mqtt'] - -CONF_TILT_COMMAND_TOPIC = 'tilt_command_topic' -CONF_TILT_STATUS_TOPIC = 'tilt_status_topic' -CONF_POSITION_TOPIC = 'set_position_topic' -CONF_SET_POSITION_TEMPLATE = 'set_position_template' - -CONF_PAYLOAD_OPEN = 'payload_open' -CONF_PAYLOAD_CLOSE = 'payload_close' -CONF_PAYLOAD_STOP = 'payload_stop' -CONF_STATE_OPEN = 'state_open' -CONF_STATE_CLOSED = 'state_closed' -CONF_TILT_CLOSED_POSITION = 'tilt_closed_value' -CONF_TILT_OPEN_POSITION = 'tilt_opened_value' -CONF_TILT_MIN = 'tilt_min' -CONF_TILT_MAX = 'tilt_max' -CONF_TILT_STATE_OPTIMISTIC = 'tilt_optimistic' -CONF_TILT_INVERT_STATE = 'tilt_invert_state' - -DEFAULT_NAME = 'MQTT Cover' -DEFAULT_PAYLOAD_OPEN = 'OPEN' -DEFAULT_PAYLOAD_CLOSE = 'CLOSE' -DEFAULT_PAYLOAD_STOP = 'STOP' -DEFAULT_OPTIMISTIC = False -DEFAULT_RETAIN = False -DEFAULT_TILT_CLOSED_POSITION = 0 -DEFAULT_TILT_OPEN_POSITION = 100 -DEFAULT_TILT_MIN = 0 -DEFAULT_TILT_MAX = 100 -DEFAULT_TILT_OPTIMISTIC = False -DEFAULT_TILT_INVERT_STATE = False - -OPEN_CLOSE_FEATURES = (SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_STOP) -TILT_FEATURES = (SUPPORT_OPEN_TILT | SUPPORT_CLOSE_TILT | SUPPORT_STOP_TILT | - SUPPORT_SET_TILT_POSITION) - -PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ - 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, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PAYLOAD_OPEN, default=DEFAULT_PAYLOAD_OPEN): cv.string, - vol.Optional(CONF_PAYLOAD_CLOSE, default=DEFAULT_PAYLOAD_CLOSE): cv.string, - vol.Optional(CONF_PAYLOAD_STOP, default=DEFAULT_PAYLOAD_STOP): cv.string, - 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): 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, - default=DEFAULT_TILT_OPEN_POSITION): int, - vol.Optional(CONF_TILT_MIN, default=DEFAULT_TILT_MIN): int, - vol.Optional(CONF_TILT_MAX, default=DEFAULT_TILT_MAX): int, - vol.Optional(CONF_TILT_STATE_OPTIMISTIC, - 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) - - -async def async_setup_platform(hass, config, async_add_entities, - 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 - set_position_template = config.get(CONF_SET_POSITION_TEMPLATE) - if set_position_template is not None: - set_position_template.hass = hass - - async_add_entities([MqttCover( - 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), - config.get(CONF_RETAIN), - config.get(CONF_STATE_OPEN), - config.get(CONF_STATE_CLOSED), - 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), - config.get(CONF_TILT_CLOSED_POSITION), - config.get(CONF_TILT_MIN), - config.get(CONF_TILT_MAX), - config.get(CONF_TILT_STATE_OPTIMISTIC), - config.get(CONF_TILT_INVERT_STATE), - config.get(CONF_POSITION_TOPIC), - set_position_template, - )]) - - -class MqttCover(MqttAvailability, CoverDevice): - """Representation of a cover that can be controlled using MQTT.""" - - 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 - self._state_topic = state_topic - self._command_topic = command_topic - self._tilt_command_topic = tilt_command_topic - self._tilt_status_topic = tilt_status_topic - self._qos = qos - self._payload_open = payload_open - self._payload_close = payload_close - self._payload_stop = payload_stop - self._state_open = state_open - self._state_closed = state_closed - self._retain = retain - self._tilt_open_position = tilt_open_position - self._tilt_closed_position = tilt_closed_position - self._optimistic = optimistic or state_topic is None - self._template = value_template - self._tilt_value = None - self._tilt_min = tilt_min - self._tilt_max = tilt_max - self._tilt_optimistic = tilt_optimistic - self._tilt_invert = tilt_invert - self._position_topic = position_topic - self._set_position_template = set_position_template - - async def async_added_to_hass(self): - """Subscribe MQTT events.""" - await super().async_added_to_hass() - - @callback - def tilt_updated(topic, payload, qos): - """Handle tilt updates.""" - if (payload.isnumeric() and - self._tilt_min <= int(payload) <= self._tilt_max): - - level = self.find_percentage_in_range(float(payload)) - self._tilt_value = level - self.async_schedule_update_ha_state() - - @callback - 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) - - if payload == self._state_open: - self._state = False - elif payload == self._state_closed: - self._state = True - elif payload.isnumeric() and 0 <= int(payload) <= 100: - if int(payload) > 0: - self._state = False - else: - self._state = True - self._position = int(payload) - else: - _LOGGER.warning( - "Payload is not True, False, or integer (0-100): %s", - payload) - return - - self.async_schedule_update_ha_state() - - if self._state_topic is None: - # Force into optimistic mode. - self._optimistic = True - else: - await mqtt.async_subscribe( - self.hass, self._state_topic, - state_message_received, self._qos) - - if self._tilt_status_topic is None: - self._tilt_optimistic = True - else: - self._tilt_optimistic = False - self._tilt_value = STATE_UNKNOWN - await mqtt.async_subscribe( - self.hass, self._tilt_status_topic, tilt_updated, self._qos) - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def assumed_state(self): - """Return true if we do optimistic updates.""" - return self._optimistic - - @property - def name(self): - """Return the name of the cover.""" - return self._name - - @property - def is_closed(self): - """Return if the cover is closed.""" - return self._state - - @property - def current_cover_position(self): - """Return current position of cover. - - None is unknown, 0 is closed, 100 is fully open. - """ - return self._position - - @property - def current_cover_tilt_position(self): - """Return current position of cover tilt.""" - return self._tilt_value - - @property - def supported_features(self): - """Flag supported features.""" - supported_features = 0 - if self._command_topic is not None: - supported_features = OPEN_CLOSE_FEATURES - - if self._position_topic is not None: - supported_features |= SUPPORT_SET_POSITION - - if self._tilt_command_topic is not None: - supported_features |= TILT_FEATURES - - return supported_features - - async def async_open_cover(self, **kwargs): - """Move the cover up. - - This method is a coroutine. - """ - mqtt.async_publish( - self.hass, self._command_topic, self._payload_open, self._qos, - self._retain) - if self._optimistic: - # Optimistically assume that cover has changed state. - self._state = False - self.async_schedule_update_ha_state() - - async def async_close_cover(self, **kwargs): - """Move the cover down. - - This method is a coroutine. - """ - mqtt.async_publish( - self.hass, self._command_topic, self._payload_close, self._qos, - self._retain) - if self._optimistic: - # Optimistically assume that cover has changed state. - self._state = True - self.async_schedule_update_ha_state() - - async def async_stop_cover(self, **kwargs): - """Stop the device. - - This method is a coroutine. - """ - mqtt.async_publish( - self.hass, self._command_topic, self._payload_stop, self._qos, - self._retain) - - async def async_open_cover_tilt(self, **kwargs): - """Tilt the cover open.""" - mqtt.async_publish(self.hass, self._tilt_command_topic, - self._tilt_open_position, self._qos, - self._retain) - if self._tilt_optimistic: - self._tilt_value = self._tilt_open_position - self.async_schedule_update_ha_state() - - async def async_close_cover_tilt(self, **kwargs): - """Tilt the cover closed.""" - mqtt.async_publish(self.hass, self._tilt_command_topic, - self._tilt_closed_position, self._qos, - self._retain) - if self._tilt_optimistic: - self._tilt_value = self._tilt_closed_position - self.async_schedule_update_ha_state() - - async def async_set_cover_tilt_position(self, **kwargs): - """Move the cover tilt to a specific position.""" - if ATTR_TILT_POSITION not in kwargs: - return - - position = float(kwargs[ATTR_TILT_POSITION]) - - # The position needs to be between min and max - level = self.find_in_range_from_percent(position) - - mqtt.async_publish(self.hass, self._tilt_command_topic, - level, self._qos, self._retain) - - async def async_set_cover_position(self, **kwargs): - """Move the cover to a specific position.""" - if ATTR_POSITION in kwargs: - position = kwargs[ATTR_POSITION] - if self._set_position_template is not None: - try: - position = self._set_position_template.async_render( - **kwargs) - except TemplateError as ex: - _LOGGER.error(ex) - self._state = None - - mqtt.async_publish(self.hass, self._position_topic, - position, self._qos, self._retain) - - def find_percentage_in_range(self, position): - """Find the 0-100% value within the specified range.""" - # the range of motion as defined by the min max values - tilt_range = self._tilt_max - self._tilt_min - # offset to be zero based - offset_position = position - self._tilt_min - # the percentage value within the range - position_percentage = float(offset_position) / tilt_range * 100.0 - if self._tilt_invert: - return 100 - position_percentage - return position_percentage - - def find_in_range_from_percent(self, percentage): - """ - Find the adjusted value for 0-100% within the specified range. - - if the range is 80-180 and the percentage is 90 - this method would determine the value to send on the topic - by offsetting the max and min, getting the percentage value and - returning the offset - """ - offset = self._tilt_min - tilt_range = self._tilt_max - self._tilt_min - - position = round(tilt_range * (percentage / 100.0)) - position += offset - - if self._tilt_invert: - position = self._tilt_max - position + offset - return position diff --git a/homeassistant/components/cover/myq.py b/homeassistant/components/cover/myq.py deleted file mode 100644 index 78b6f891f..000000000 --- a/homeassistant/components/cover/myq.py +++ /dev/null @@ -1,131 +0,0 @@ -""" -Support for MyQ-Enabled Garage Doors. - -For more details about this platform, please refer to the documentation -https://home-assistant.io/components/cover.myq/ -""" -import logging - -import voluptuous as vol - -from homeassistant.components.cover import ( - CoverDevice, SUPPORT_CLOSE, SUPPORT_OPEN) -from homeassistant.const import ( - CONF_PASSWORD, CONF_TYPE, CONF_USERNAME, STATE_CLOSED, STATE_OPEN, - STATE_CLOSING, STATE_OPENING) -import homeassistant.helpers.config_validation as cv - -REQUIREMENTS = ['pymyq==0.0.15'] - -_LOGGER = logging.getLogger(__name__) - -DEFAULT_NAME = 'myq' - -MYQ_TO_HASS = { - 'closed': STATE_CLOSED, - 'open': STATE_OPEN, - 'closing': STATE_CLOSING, - 'opening': STATE_OPENING -} - -NOTIFICATION_ID = 'myq_notification' -NOTIFICATION_TITLE = 'MyQ Cover Setup' - -COVER_SCHEMA = vol.Schema({ - vol.Required(CONF_TYPE): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the MyQ component.""" - from pymyq import MyQAPI as pymyq - - username = config.get(CONF_USERNAME) - password = config.get(CONF_PASSWORD) - brand = config.get(CONF_TYPE) - myq = pymyq(username, password, brand) - - try: - if not myq.is_supported_brand(): - raise ValueError("Unsupported type. See documentation") - - if not myq.is_login_valid(): - raise ValueError("Username or Password is incorrect") - - add_entities(MyQDevice(myq, door) for door in myq.get_garage_doors()) - return True - - 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) - return False - - -class MyQDevice(CoverDevice): - """Representation of a MyQ cover.""" - - def __init__(self, myq, device): - """Initialize with API object, device id.""" - self.myq = myq - self.device_id = device['deviceid'] - self._name = device['name'] - self._status = STATE_CLOSED - - @property - def device_class(self): - """Define this cover as a garage door.""" - return 'garage' - - @property - def should_poll(self): - """Poll for state.""" - return True - - @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 MYQ_TO_HASS[self._status] == STATE_CLOSED - - @property - def is_closing(self): - """Return if the cover is closing or not.""" - return MYQ_TO_HASS[self._status] == STATE_CLOSING - - @property - def is_opening(self): - """Return if the cover is opening or not.""" - return MYQ_TO_HASS[self._status] == STATE_OPENING - - def close_cover(self, **kwargs): - """Issue close command to cover.""" - self.myq.close_device(self.device_id) - - def open_cover(self, **kwargs): - """Issue open command to cover.""" - self.myq.open_device(self.device_id) - - @property - def supported_features(self): - """Flag supported features.""" - return SUPPORT_OPEN | SUPPORT_CLOSE - - @property - def unique_id(self): - """Return a unique, HASS-friendly identifier for this entity.""" - return self.device_id - - def update(self): - """Update status of cover.""" - self._status = self.myq.get_status(self.device_id) diff --git a/homeassistant/components/cover/mysensors.py b/homeassistant/components/cover/mysensors.py deleted file mode 100644 index 60ff7aeef..000000000 --- a/homeassistant/components/cover/mysensors.py +++ /dev/null @@ -1,86 +0,0 @@ -""" -Support for MySensors covers. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/cover.mysensors/ -""" -from homeassistant.components import mysensors -from homeassistant.components.cover import ATTR_POSITION, DOMAIN, CoverDevice -from homeassistant.const import STATE_OFF, STATE_ON - - -async def async_setup_platform( - hass, config, async_add_entities, discovery_info=None): - """Set up the mysensors platform for covers.""" - mysensors.setup_mysensors_platform( - hass, DOMAIN, discovery_info, MySensorsCover, - async_add_entities=async_add_entities) - - -class MySensorsCover(mysensors.device.MySensorsEntity, CoverDevice): - """Representation of the value of a MySensors Cover child node.""" - - @property - def assumed_state(self): - """Return True if unable to access real state of entity.""" - return self.gateway.optimistic - - @property - def is_closed(self): - """Return True if cover is closed.""" - set_req = self.gateway.const.SetReq - if set_req.V_DIMMER in self._values: - return self._values.get(set_req.V_DIMMER) == 0 - return self._values.get(set_req.V_LIGHT) == STATE_OFF - - @property - def current_cover_position(self): - """Return current position of cover. - - None is unknown, 0 is closed, 100 is fully open. - """ - set_req = self.gateway.const.SetReq - return self._values.get(set_req.V_DIMMER) - - async def async_open_cover(self, **kwargs): - """Move the cover up.""" - set_req = self.gateway.const.SetReq - self.gateway.set_child_value( - self.node_id, self.child_id, set_req.V_UP, 1) - if self.gateway.optimistic: - # Optimistically assume that cover has changed state. - if set_req.V_DIMMER in self._values: - self._values[set_req.V_DIMMER] = 100 - else: - self._values[set_req.V_LIGHT] = STATE_ON - self.async_schedule_update_ha_state() - - async def async_close_cover(self, **kwargs): - """Move the cover down.""" - set_req = self.gateway.const.SetReq - self.gateway.set_child_value( - self.node_id, self.child_id, set_req.V_DOWN, 1) - if self.gateway.optimistic: - # Optimistically assume that cover has changed state. - if set_req.V_DIMMER in self._values: - self._values[set_req.V_DIMMER] = 0 - else: - self._values[set_req.V_LIGHT] = STATE_OFF - self.async_schedule_update_ha_state() - - 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 - self.gateway.set_child_value( - self.node_id, self.child_id, set_req.V_DIMMER, position) - if self.gateway.optimistic: - # Optimistically assume that cover has changed state. - self._values[set_req.V_DIMMER] = position - self.async_schedule_update_ha_state() - - async def async_stop_cover(self, **kwargs): - """Stop the device.""" - set_req = self.gateway.const.SetReq - self.gateway.set_child_value( - self.node_id, self.child_id, set_req.V_STOP, 1) diff --git a/homeassistant/components/cover/opengarage.py b/homeassistant/components/cover/opengarage.py deleted file mode 100644 index 19a87c5bf..000000000 --- a/homeassistant/components/cover/opengarage.py +++ /dev/null @@ -1,191 +0,0 @@ -""" -Platform for the opengarage.io cover component. - -For more details about this platform, please refer to the documentation -https://home-assistant.io/components/cover.opengarage/ -""" -import logging - -import requests -import voluptuous as vol - -from homeassistant.components.cover import ( - CoverDevice, PLATFORM_SCHEMA, SUPPORT_OPEN, SUPPORT_CLOSE) -from homeassistant.const import ( - CONF_DEVICE, CONF_NAME, STATE_UNKNOWN, STATE_CLOSED, STATE_OPEN, - CONF_COVERS, CONF_HOST, CONF_PORT) -import homeassistant.helpers.config_validation as cv - -_LOGGER = logging.getLogger(__name__) - -ATTR_DISTANCE_SENSOR = 'distance_sensor' -ATTR_DOOR_STATE = 'door_state' -ATTR_SIGNAL_STRENGTH = 'wifi_signal' - -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' - -STATES_MAP = { - 0: STATE_CLOSED, - 1: STATE_OPEN, -} - -COVER_SCHEMA = vol.Schema({ - 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, -}) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_COVERS): vol.Schema({cv.slug: COVER_SCHEMA}), -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the OpenGarage covers.""" - covers = [] - devices = config.get(CONF_COVERS) - - for device_id, device_config in devices.items(): - args = { - CONF_NAME: device_config.get(CONF_NAME), - CONF_HOST: device_config.get(CONF_HOST), - CONF_PORT: device_config.get(CONF_PORT), - CONF_DEVICE_ID: device_config.get(CONF_DEVICE, device_id), - CONF_DEVICE_KEY: device_config.get(CONF_DEVICE_KEY) - } - - covers.append(OpenGarageCover(hass, args)) - - add_entities(covers, True) - - -class OpenGarageCover(CoverDevice): - """Representation of a OpenGarage cover.""" - - def __init__(self, hass, args): - """Initialize the cover.""" - self.opengarage_url = 'http://{}:{}'.format( - args[CONF_HOST], args[CONF_PORT]) - self.hass = hass - self._name = args[CONF_NAME] - self.device_id = args['device_id'] - self._device_key = args[CONF_DEVICE_KEY] - self._state = None - self._state_before_move = None - self.dist = None - self.signal = None - self._available = True - - @property - def name(self): - """Return the name of the cover.""" - return self._name - - @property - def available(self): - """Return True if entity is available.""" - return self._available - - @property - def device_state_attributes(self): - """Return the device state attributes.""" - data = {} - - if self.signal is not None: - data[ATTR_SIGNAL_STRENGTH] = self.signal - - if self.dist is not None: - data[ATTR_DISTANCE_SENSOR] = self.dist - - if self._state is not None: - data[ATTR_DOOR_STATE] = self._state - - return data - - @property - def is_closed(self): - """Return if the cover is closed.""" - if self._state in [STATE_UNKNOWN, STATE_OFFLINE]: - return None - return self._state in [STATE_CLOSED, STATE_OPENING] - - 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, **kwargs): - """Open the cover.""" - if self._state not in [STATE_OPEN, STATE_OPENING]: - self._state_before_move = self._state - self._state = STATE_OPENING - self._push_button() - - def update(self): - """Get updated status from API.""" - try: - status = self._get_status() - if self._name is None: - 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: - self._state = state - self._state_before_move = None - else: - self._state = state - - _LOGGER.debug("%s status: %s", self._name, self._state) - self.signal = status.get('rssi') - self.dist = status.get('dist') - self._available = True - except requests.exceptions.RequestException as ex: - _LOGGER.error("Unable to connect to OpenGarage device: %(reason)s", - dict(reason=ex)) - self._state = STATE_OFFLINE - - def _get_status(self): - """Get latest status.""" - url = '{}/jc'.format(self.opengarage_url) - ret = requests.get(url, timeout=10) - return ret.json() - - def _push_button(self): - """Send commands to API.""" - url = '{}/cc?dkey={}&click=1'.format( - 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", - self._name) - self._state = self._state_before_move - self._state_before_move = None - 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 - self._state_before_move = None - - @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 diff --git a/homeassistant/components/cover/reproduce_state.py b/homeassistant/components/cover/reproduce_state.py new file mode 100644 index 000000000..64ea410ce --- /dev/null +++ b/homeassistant/components/cover/reproduce_state.py @@ -0,0 +1,117 @@ +"""Reproduce an Cover state.""" +import asyncio +import logging +from typing import Iterable, Optional + +from homeassistant.components.cover import ( + ATTR_CURRENT_POSITION, + ATTR_CURRENT_TILT_POSITION, + ATTR_POSITION, + ATTR_TILT_POSITION, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_CLOSE_COVER, + SERVICE_CLOSE_COVER_TILT, + SERVICE_OPEN_COVER, + SERVICE_OPEN_COVER_TILT, + SERVICE_SET_COVER_POSITION, + SERVICE_SET_COVER_TILT_POSITION, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, +) +from homeassistant.core import Context, State +from homeassistant.helpers.typing import HomeAssistantType + +from . import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +VALID_STATES = {STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_OPENING} + + +async def _async_reproduce_state( + hass: HomeAssistantType, state: State, context: Optional[Context] = None +) -> None: + """Reproduce a single state.""" + cur_state = hass.states.get(state.entity_id) + + if cur_state is None: + _LOGGER.warning("Unable to find entity %s", state.entity_id) + return + + if state.state not in VALID_STATES: + _LOGGER.warning( + "Invalid state specified for %s: %s", state.entity_id, state.state + ) + return + + # Return if we are already at the right state. + if ( + cur_state.state == state.state + and cur_state.attributes.get(ATTR_CURRENT_POSITION) + == state.attributes.get(ATTR_CURRENT_POSITION) + and cur_state.attributes.get(ATTR_CURRENT_TILT_POSITION) + == state.attributes.get(ATTR_CURRENT_TILT_POSITION) + ): + return + + service_data = {ATTR_ENTITY_ID: state.entity_id} + service_data_tilting = {ATTR_ENTITY_ID: state.entity_id} + + if cur_state.state != state.state or cur_state.attributes.get( + ATTR_CURRENT_POSITION + ) != state.attributes.get(ATTR_CURRENT_POSITION): + # Open/Close + if state.state == STATE_CLOSED or state.state == STATE_CLOSING: + service = SERVICE_CLOSE_COVER + elif state.state == STATE_OPEN or state.state == STATE_OPENING: + if ( + ATTR_CURRENT_POSITION in cur_state.attributes + and ATTR_CURRENT_POSITION in state.attributes + ): + service = SERVICE_SET_COVER_POSITION + service_data[ATTR_POSITION] = state.attributes[ATTR_CURRENT_POSITION] + else: + service = SERVICE_OPEN_COVER + + await hass.services.async_call( + DOMAIN, service, service_data, context=context, blocking=True + ) + + if ( + ATTR_CURRENT_TILT_POSITION in state.attributes + and ATTR_CURRENT_TILT_POSITION in cur_state.attributes + and cur_state.attributes.get(ATTR_CURRENT_TILT_POSITION) + != state.attributes.get(ATTR_CURRENT_TILT_POSITION) + ): + # Tilt position + if state.attributes.get(ATTR_CURRENT_TILT_POSITION) == 100: + service_tilting = SERVICE_OPEN_COVER_TILT + elif state.attributes.get(ATTR_CURRENT_TILT_POSITION) == 0: + service_tilting = SERVICE_CLOSE_COVER_TILT + else: + service_tilting = SERVICE_SET_COVER_TILT_POSITION + service_data_tilting[ATTR_TILT_POSITION] = state.attributes[ + ATTR_CURRENT_TILT_POSITION + ] + + await hass.services.async_call( + DOMAIN, + service_tilting, + service_data_tilting, + context=context, + blocking=True, + ) + + +async def async_reproduce_states( + hass: HomeAssistantType, states: Iterable[State], context: Optional[Context] = None +) -> None: + """Reproduce Cover states.""" + # Reproduce states in parallel. + await asyncio.gather( + *(_async_reproduce_state(hass, state, context) for state in states) + ) diff --git a/homeassistant/components/cover/rflink.py b/homeassistant/components/cover/rflink.py deleted file mode 100644 index 41a4c2af0..000000000 --- a/homeassistant/components/cover/rflink.py +++ /dev/null @@ -1,125 +0,0 @@ -""" -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 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 - - -async def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): - """Set up the Rflink cover platform.""" - async_add_entities(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', 'up']: - self._state = True - elif command in ['off', 'alloff', 'down']: - 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 not self._state - - @property - def assumed_state(self): - """Return True because covers can be stopped midway.""" - return True - - 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 deleted file mode 100644 index d486b6019..000000000 --- a/homeassistant/components/cover/rfxtrx.py +++ /dev/null @@ -1,80 +0,0 @@ -""" -Support for RFXtrx cover components. - -For more details about this platform, please refer to the documentation -https://home-assistant.io/components/cover.rfxtrx/ -""" -import voluptuous as vol - -from homeassistant.components import rfxtrx -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 = 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_entities, discovery_info=None): - """Set up the RFXtrx cover.""" - import RFXtrx as rfxtrxmod - - covers = rfxtrx.get_devices_from_config(config, RfxtrxCover) - add_entities(covers) - - def cover_update(event): - """Handle cover updates from the RFXtrx gateway.""" - if not isinstance(event.device, rfxtrxmod.LightingDevice) or \ - event.device.known_to_be_dimmable or \ - not event.device.known_to_be_rollershutter: - return - - new_device = rfxtrx.get_new_device(event, config, RfxtrxCover) - if new_device: - add_entities([new_device]) - - rfxtrx.apply_received_command(event) - - # Subscribe to main RFXtrx events - if cover_update not in rfxtrx.RECEIVED_EVT_SUBSCRIBERS: - rfxtrx.RECEIVED_EVT_SUBSCRIBERS.append(cover_update) - - -class RfxtrxCover(rfxtrx.RfxtrxDevice, CoverDevice): - """Representation of a RFXtrx cover.""" - - @property - def should_poll(self): - """Return the polling state. No polling available in RFXtrx cover.""" - return False - - @property - def is_closed(self): - """Return if the cover is closed.""" - return None - - def open_cover(self, **kwargs): - """Move the cover up.""" - self._send_command("roll_up") - - def close_cover(self, **kwargs): - """Move the cover down.""" - self._send_command("roll_down") - - def stop_cover(self, **kwargs): - """Stop the cover.""" - self._send_command("stop_roll") diff --git a/homeassistant/components/cover/rpi_gpio.py b/homeassistant/components/cover/rpi_gpio.py deleted file mode 100644 index 828f5e8e0..000000000 --- a/homeassistant/components/cover/rpi_gpio.py +++ /dev/null @@ -1,119 +0,0 @@ -""" -Support for controlling a Raspberry Pi cover. - -Instructions for building the controller can be found here -https://github.com/andrewshilliday/garage-door-controller - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/cover.rpi_gpio/ -""" -import logging -from time import sleep - -import voluptuous as vol - -from homeassistant.components.cover import CoverDevice, PLATFORM_SCHEMA -from homeassistant.const import CONF_NAME -from homeassistant.components import rpi_gpio -import homeassistant.helpers.config_validation as cv - -_LOGGER = logging.getLogger(__name__) - -CONF_COVERS = 'covers' -CONF_RELAY_PIN = 'relay_pin' -CONF_RELAY_TIME = 'relay_time' -CONF_STATE_PIN = 'state_pin' -CONF_STATE_PULL_MODE = 'state_pull_mode' -CONF_INVERT_STATE = 'invert_state' -CONF_INVERT_RELAY = 'invert_relay' - -DEFAULT_RELAY_TIME = .2 -DEFAULT_STATE_PULL_MODE = 'UP' -DEFAULT_INVERT_STATE = False -DEFAULT_INVERT_RELAY = False -DEPENDENCIES = ['rpi_gpio'] - -_COVERS_SCHEMA = vol.All( - cv.ensure_list, - [ - vol.Schema({ - CONF_NAME: cv.string, - CONF_RELAY_PIN: cv.positive_int, - CONF_STATE_PIN: cv.positive_int, - }) - ] -) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_COVERS): _COVERS_SCHEMA, - vol.Optional(CONF_STATE_PULL_MODE, default=DEFAULT_STATE_PULL_MODE): - cv.string, - vol.Optional(CONF_RELAY_TIME, default=DEFAULT_RELAY_TIME): cv.positive_int, - vol.Optional(CONF_INVERT_STATE, default=DEFAULT_INVERT_STATE): cv.boolean, - vol.Optional(CONF_INVERT_RELAY, default=DEFAULT_INVERT_RELAY): cv.boolean, -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the RPi cover platform.""" - relay_time = config.get(CONF_RELAY_TIME) - state_pull_mode = config.get(CONF_STATE_PULL_MODE) - invert_state = config.get(CONF_INVERT_STATE) - invert_relay = config.get(CONF_INVERT_RELAY) - covers = [] - covers_conf = config.get(CONF_COVERS) - - for cover in covers_conf: - covers.append(RPiGPIOCover( - cover[CONF_NAME], cover[CONF_RELAY_PIN], cover[CONF_STATE_PIN], - state_pull_mode, relay_time, invert_state, invert_relay)) - add_entities(covers) - - -class RPiGPIOCover(CoverDevice): - """Representation of a Raspberry GPIO cover.""" - - def __init__(self, name, relay_pin, state_pin, state_pull_mode, - relay_time, invert_state, invert_relay): - """Initialize the cover.""" - self._name = name - self._state = False - self._relay_pin = relay_pin - self._state_pin = state_pin - self._state_pull_mode = state_pull_mode - self._relay_time = relay_time - self._invert_state = invert_state - 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, 0 if self._invert_relay else 1) - - @property - def name(self): - """Return the name of the cover if any.""" - return self._name - - def update(self): - """Update the state of the cover.""" - self._state = rpi_gpio.read_input(self._state_pin) - - @property - def is_closed(self): - """Return true if cover is closed.""" - return self._state != self._invert_state - - def _trigger(self): - """Trigger the cover.""" - 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, 0 if self._invert_relay else 1) - - def close_cover(self, **kwargs): - """Close the cover.""" - if not self.is_closed: - self._trigger() - - def open_cover(self, **kwargs): - """Open the cover.""" - if self.is_closed: - self._trigger() diff --git a/homeassistant/components/cover/ryobi_gdo.py b/homeassistant/components/cover/ryobi_gdo.py deleted file mode 100644 index fec91f843..000000000 --- a/homeassistant/components/cover/ryobi_gdo.py +++ /dev/null @@ -1,103 +0,0 @@ -""" -Ryobi platform for the cover component. - -For more details about this platform, please refer to the documentation -https://home-assistant.io/components/cover.ryobi_gdo/ -""" -import logging -import voluptuous as vol - -import homeassistant.helpers.config_validation as cv - -from homeassistant.components.cover import ( - CoverDevice, PLATFORM_SCHEMA, SUPPORT_OPEN, SUPPORT_CLOSE) -from homeassistant.const import ( - CONF_USERNAME, CONF_PASSWORD, STATE_UNKNOWN, STATE_CLOSED) - -REQUIREMENTS = ['py_ryobi_gdo==0.0.10'] - -_LOGGER = logging.getLogger(__name__) - -CONF_DEVICE_ID = 'device_id' - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_USERNAME): cv.string, -}) - -SUPPORTED_FEATURES = (SUPPORT_OPEN | SUPPORT_CLOSE) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Ryobi covers.""" - from py_ryobi_gdo import RyobiGDO as ryobi_door - covers = [] - - username = config.get(CONF_USERNAME) - password = config.get(CONF_PASSWORD) - devices = config.get(CONF_DEVICE_ID) - - for device_id in devices: - my_door = ryobi_door(username, password, device_id) - _LOGGER.debug("Getting the API key") - if my_door.get_api_key() is False: - _LOGGER.error("Wrong credentials, no API key retrieved") - return - _LOGGER.debug("Checking if the device ID is present") - if my_door.check_device_id() is False: - _LOGGER.error("%s not in your device list", device_id) - return - _LOGGER.debug("Adding device %s to covers", device_id) - covers.append(RyobiCover(hass, my_door)) - if covers: - _LOGGER.debug("Adding covers") - add_entities(covers, True) - - -class RyobiCover(CoverDevice): - """Representation of a ryobi cover.""" - - def __init__(self, hass, ryobi_door): - """Initialize the cover.""" - self.ryobi_door = ryobi_door - self._name = 'ryobi_gdo_{}'.format(ryobi_door.get_device_id()) - self._door_state = None - - @property - def name(self): - """Return the name of the cover.""" - return self._name - - @property - def is_closed(self): - """Return if the cover is closed.""" - if self._door_state == STATE_UNKNOWN: - return False - return self._door_state == STATE_CLOSED - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return 'garage' - - @property - def supported_features(self): - """Flag supported features.""" - return SUPPORTED_FEATURES - - def close_cover(self, **kwargs): - """Close the cover.""" - _LOGGER.debug("Closing garage door") - self.ryobi_door.close_device() - - def open_cover(self, **kwargs): - """Open the cover.""" - _LOGGER.debug("Opening garage door") - self.ryobi_door.open_device() - - def update(self): - """Update status from the door.""" - _LOGGER.debug("Updating RyobiGDO status") - self.ryobi_door.update() - self._door_state = self.ryobi_door.get_door_status() diff --git a/homeassistant/components/cover/scsgate.py b/homeassistant/components/cover/scsgate.py deleted file mode 100644 index a6f09c723..000000000 --- a/homeassistant/components/cover/scsgate.py +++ /dev/null @@ -1,100 +0,0 @@ -""" -Allow to configure a SCSGate cover. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/cover.scsgate/ -""" -import logging - -import voluptuous as vol - -from homeassistant.components import scsgate -from homeassistant.components.cover import (CoverDevice, PLATFORM_SCHEMA) -from homeassistant.const import (CONF_DEVICES, CONF_NAME) -import homeassistant.helpers.config_validation as cv - -_LOGGER = logging.getLogger(__name__) - -DEPENDENCIES = ['scsgate'] - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_DEVICES): vol.Schema({cv.slug: scsgate.SCSGATE_SCHEMA}), -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the SCSGate cover.""" - devices = config.get(CONF_DEVICES) - covers = [] - logger = logging.getLogger(__name__) - - if devices: - for _, entity_info in devices.items(): - if entity_info[scsgate.CONF_SCS_ID] in scsgate.SCSGATE.devices: - continue - - name = entity_info[CONF_NAME] - scs_id = entity_info[scsgate.CONF_SCS_ID] - - logger.info("Adding %s scsgate.cover", name) - - cover = SCSGateCover(name=name, scs_id=scs_id, logger=logger) - scsgate.SCSGATE.add_device(cover) - covers.append(cover) - - add_entities(covers) - - -class SCSGateCover(CoverDevice): - """Representation of SCSGate cover.""" - - def __init__(self, scs_id, name, logger): - """Initialize the cover.""" - self._scs_id = scs_id - self._name = name - self._logger = logger - - @property - def scs_id(self): - """Return the SCSGate ID.""" - return self._scs_id - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def name(self): - """Return the name of the cover.""" - return self._name - - @property - def is_closed(self): - """Return if the cover is closed.""" - return None - - def open_cover(self, **kwargs): - """Move the cover.""" - from scsgate.tasks import RaiseRollerShutterTask - - scsgate.SCSGATE.append_task( - RaiseRollerShutterTask(target=self._scs_id)) - - def close_cover(self, **kwargs): - """Move the cover down.""" - from scsgate.tasks import LowerRollerShutterTask - - scsgate.SCSGATE.append_task( - LowerRollerShutterTask(target=self._scs_id)) - - def stop_cover(self, **kwargs): - """Stop the cover.""" - from scsgate.tasks import HaltRollerShutterTask - - scsgate.SCSGATE.append_task(HaltRollerShutterTask(target=self._scs_id)) - - def process_event(self, message): - """Handle a SCSGate message related with this cover.""" - self._logger.debug("Cover %s, got message %s", - self._scs_id, message.toggled) diff --git a/homeassistant/components/cover/services.yaml b/homeassistant/components/cover/services.yaml index 79f00180a..64534e409 100644 --- a/homeassistant/components/cover/services.yaml +++ b/homeassistant/components/cover/services.yaml @@ -14,6 +14,13 @@ close_cover: description: Name(s) of cover(s) to close. example: 'cover.living_room' +toggle: + description: Toggles a cover open/closed. + fields: + entity_id: + description: Name(s) of cover(s) to toggle. + example: 'cover.garage_door' + set_cover_position: description: Move to specific position all or specified cover. fields: @@ -36,21 +43,28 @@ open_cover_tilt: fields: entity_id: description: Name(s) of cover(s) tilt to open. - example: 'cover.living_room' + example: 'cover.living_room_blinds' 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' + example: 'cover.living_room_blinds' + +toggle_cover_tilt: + description: Toggles a cover tilt open/closed. + fields: + entity_id: + description: Name(s) of cover(s) to toggle tilt. + example: 'cover.living_room_blinds' 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' + example: 'cover.living_room_blinds' tilt_position: description: Tilt position of the cover (0 to 100). example: 30 @@ -60,4 +74,4 @@ stop_cover_tilt: fields: entity_id: description: Name(s) of cover(s) to stop. - example: 'cover.living_room' + example: 'cover.living_room_blinds' diff --git a/homeassistant/components/cover/strings.json b/homeassistant/components/cover/strings.json new file mode 100644 index 000000000..36492cc5e --- /dev/null +++ b/homeassistant/components/cover/strings.json @@ -0,0 +1,20 @@ +{ + "device_automation": { + "condition_type": { + "is_open": "{entity_name} is open", + "is_closed": "{entity_name} is closed", + "is_opening": "{entity_name} is opening", + "is_closing": "{entity_name} is closing", + "is_position": "Current {entity_name} position is", + "is_tilt_position": "Current {entity_name} tilt position is" + }, + "trigger_type": { + "opened": "{entity_name} opened", + "closed": "{entity_name} closed", + "opening": "{entity_name} opening", + "closing": "{entity_name} closing", + "position": "{entity_name} position changes", + "tilt_position": "{entity_name} tilt position changes" + } + } +} diff --git a/homeassistant/components/cover/tahoma.py b/homeassistant/components/cover/tahoma.py deleted file mode 100644 index baf32073c..000000000 --- a/homeassistant/components/cover/tahoma.py +++ /dev/null @@ -1,218 +0,0 @@ -""" -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/ -""" -from datetime import timedelta -import logging - -from homeassistant.util.dt import utcnow -from homeassistant.components.cover import CoverDevice, ATTR_POSITION -from homeassistant.components.tahoma import ( - DOMAIN as TAHOMA_DOMAIN, TahomaDevice) - -DEPENDENCIES = ['tahoma'] - -_LOGGER = logging.getLogger(__name__) - -ATTR_MEM_POS = 'memorized_position' -ATTR_RSSI_LEVEL = 'rssi_level' -ATTR_LOCK_START_TS = 'lock_start_ts' -ATTR_LOCK_END_TS = 'lock_end_ts' -ATTR_LOCK_LEVEL = 'lock_level' -ATTR_LOCK_ORIG = 'lock_originator' - - -def setup_platform(hass, config, add_entities, 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_entities(devices, True) - - -class TahomaCover(TahomaDevice, CoverDevice): - """Representation a Tahoma Cover.""" - - def __init__(self, tahoma_device, controller): - """Initialize the device.""" - super().__init__(tahoma_device, controller) - - self._closure = 0 - # 100 equals open - self._position = 100 - self._closed = False - self._rssi_level = None - self._icon = None - # Can be 0 and bigger - self._lock_timer = 0 - self._lock_start_ts = None - self._lock_end_ts = None - # Can be 'comfortLevel1', 'comfortLevel2', 'comfortLevel3', - # 'comfortLevel4', 'environmentProtection', 'humanProtection', - # 'userLevel1', 'userLevel2' - self._lock_level = None - # Can be 'LSC', 'SAAC', 'SFC', 'UPS', 'externalGateway', 'localUser', - # 'myself', 'rain', 'security', 'temperature', 'timer', 'user', 'wind' - self._lock_originator = None - - def update(self): - """Update method.""" - self.controller.get_states([self.tahoma_device]) - - # For vertical covers - self._closure = self.tahoma_device.active_states.get( - 'core:ClosureState') - # For horizontal covers - if self._closure is None: - self._closure = self.tahoma_device.active_states.get( - 'core:DeploymentState') - - # For all, if available - if 'core:PriorityLockTimerState' in self.tahoma_device.active_states: - old_lock_timer = self._lock_timer - self._lock_timer = \ - self.tahoma_device.active_states['core:PriorityLockTimerState'] - # Derive timestamps from _lock_timer, only if not already set or - # something has changed - if self._lock_timer > 0: - _LOGGER.debug("Update %s, lock_timer: %d", self._name, - self._lock_timer) - if self._lock_start_ts is None: - self._lock_start_ts = utcnow() - if self._lock_end_ts is None or \ - old_lock_timer != self._lock_timer: - self._lock_end_ts = utcnow() +\ - timedelta(seconds=self._lock_timer) - else: - self._lock_start_ts = None - self._lock_end_ts = None - else: - self._lock_timer = 0 - self._lock_start_ts = None - self._lock_end_ts = None - - self._lock_level = self.tahoma_device.active_states.get( - 'io:PriorityLockLevelState') - - self._lock_originator = self.tahoma_device.active_states.get( - 'io:PriorityLockOriginatorState') - - self._rssi_level = self.tahoma_device.active_states.get( - 'core:RSSILevelState') - - # Define which icon to use - if self._lock_timer > 0: - if self._lock_originator == 'wind': - self._icon = 'mdi:weather-windy' - else: - self._icon = 'mdi:lock-alert' - else: - self._icon = None - - # Define current position. - # _position: 0 is closed, 100 is fully open. - # 'core:ClosureState': 100 is closed, 0 is fully open. - if self._closure is not None: - self._position = 100 - self._closure - if self._position <= 5: - self._position = 0 - if self._position >= 95: - self._position = 100 - self._closed = self._position == 0 - else: - self._position = None - if 'core:OpenClosedState' in self.tahoma_device.active_states: - self._closed = \ - self.tahoma_device.active_states['core:OpenClosedState']\ - == 'closed' - else: - self._closed = False - - _LOGGER.debug("Update %s, position: %d", self._name, self._position) - - @property - def current_cover_position(self): - """Return current position of cover.""" - return self._position - - 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.""" - return self._closed - - @property - def device_class(self): - """Return the class of the device.""" - if self.tahoma_device.type == 'io:WindowOpenerVeluxIOComponent': - return 'window' - return None - - @property - def device_state_attributes(self): - """Return the device state attributes.""" - attr = {} - super_attr = super().device_state_attributes - if super_attr is not None: - attr.update(super_attr) - - if 'core:Memorized1PositionState' in self.tahoma_device.active_states: - attr[ATTR_MEM_POS] = self.tahoma_device.active_states[ - 'core:Memorized1PositionState'] - if self._rssi_level is not None: - attr[ATTR_RSSI_LEVEL] = self._rssi_level - if self._lock_start_ts is not None: - attr[ATTR_LOCK_START_TS] = self._lock_start_ts.isoformat() - if self._lock_end_ts is not None: - attr[ATTR_LOCK_END_TS] = self._lock_end_ts.isoformat() - if self._lock_level is not None: - attr[ATTR_LOCK_LEVEL] = self._lock_level - if self._lock_originator is not None: - attr[ATTR_LOCK_ORIG] = self._lock_originator - return attr - - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return self._icon - - def open_cover(self, **kwargs): - """Open the cover.""" - if self.tahoma_device.type == 'io:HorizontalAwningIOComponent': - self.apply_action('close') - else: - self.apply_action('open') - - def close_cover(self, **kwargs): - """Close the cover.""" - if self.tahoma_device.type == 'io:HorizontalAwningIOComponent': - self.apply_action('open') - else: - 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', - 'rts:VenetianBlindRTSComponent', - 'rts:DualCurtainRTSComponent', - 'rts:ExteriorVenetianBlindRTSComponent', - 'rts:BlindRTSComponent'): - self.apply_action('my') - elif self.tahoma_device.type in \ - ('io:HorizontalAwningIOComponent', - 'io:RollerShutterGenericIOComponent', - 'io:VerticalExteriorAwningIOComponent'): - self.apply_action('stop') - else: - self.apply_action('stopIdentify') diff --git a/homeassistant/components/cover/tellduslive.py b/homeassistant/components/cover/tellduslive.py deleted file mode 100644 index 9d292d9e8..000000000 --- a/homeassistant/components/cover/tellduslive.py +++ /dev/null @@ -1,46 +0,0 @@ -""" -Support for Tellstick covers using Tellstick Net. - -This platform uses the Telldus Live online service. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/cover.tellduslive/ -""" -import logging - -from homeassistant.components.cover import CoverDevice -from homeassistant.components.tellduslive import TelldusLiveEntity - -_LOGGER = logging.getLogger(__name__) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Telldus Live covers.""" - if discovery_info is None: - return - - add_entities(TelldusLiveCover(hass, cover) for cover in discovery_info) - - -class TelldusLiveCover(TelldusLiveEntity, CoverDevice): - """Representation of a cover.""" - - @property - def is_closed(self): - """Return the current position of the cover.""" - return self.device.is_down - - def close_cover(self, **kwargs): - """Close the cover.""" - self.device.down() - self.changed() - - def open_cover(self, **kwargs): - """Open the cover.""" - self.device.up() - self.changed() - - def stop_cover(self, **kwargs): - """Stop the cover.""" - self.device.stop() - self.changed() diff --git a/homeassistant/components/cover/tellstick.py b/homeassistant/components/cover/tellstick.py deleted file mode 100644 index 88608ac42..000000000 --- a/homeassistant/components/cover/tellstick.py +++ /dev/null @@ -1,65 +0,0 @@ -""" -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_entities, 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_entities([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 deleted file mode 100644 index e02cdc323..000000000 --- a/homeassistant/components/cover/template.py +++ /dev/null @@ -1,406 +0,0 @@ -""" -Support for covers which integrate with other components. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/cover.template/ -""" -import logging - -import voluptuous as vol - -from homeassistant.core import callback -from homeassistant.components.cover import ( - ENTITY_ID_FORMAT, CoverDevice, PLATFORM_SCHEMA, - SUPPORT_OPEN_TILT, SUPPORT_CLOSE_TILT, SUPPORT_STOP_TILT, - SUPPORT_SET_TILT_POSITION, SUPPORT_OPEN, SUPPORT_CLOSE, SUPPORT_STOP, - SUPPORT_SET_POSITION, ATTR_POSITION, ATTR_TILT_POSITION) -from homeassistant.const import ( - CONF_FRIENDLY_NAME, CONF_ENTITY_ID, - EVENT_HOMEASSISTANT_START, MATCH_ALL, - CONF_VALUE_TEMPLATE, CONF_ICON_TEMPLATE, - 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.script import Script - -_LOGGER = logging.getLogger(__name__) -_VALID_STATES = [STATE_OPEN, STATE_CLOSED, 'true', 'false'] - -CONF_COVERS = 'covers' - -CONF_POSITION_TEMPLATE = 'position_template' -CONF_TILT_TEMPLATE = 'tilt_template' -OPEN_ACTION = 'open_cover' -CLOSE_ACTION = 'close_cover' -STOP_ACTION = 'stop_cover' -POSITION_ACTION = 'set_cover_position' -TILT_ACTION = 'set_cover_tilt_position' -CONF_TILT_OPTIMISTIC = 'tilt_optimistic' - -CONF_VALUE_OR_POSITION_TEMPLATE = 'value_or_position' -CONF_OPEN_OR_CLOSE = 'open_or_close' - -TILT_FEATURES = (SUPPORT_OPEN_TILT | SUPPORT_CLOSE_TILT | SUPPORT_STOP_TILT | - SUPPORT_SET_TILT_POSITION) - -COVER_SCHEMA = vol.Schema({ - vol.Inclusive(OPEN_ACTION, CONF_OPEN_OR_CLOSE): cv.SCRIPT_SCHEMA, - vol.Inclusive(CLOSE_ACTION, CONF_OPEN_OR_CLOSE): cv.SCRIPT_SCHEMA, - vol.Optional(STOP_ACTION): cv.SCRIPT_SCHEMA, - vol.Exclusive(CONF_POSITION_TEMPLATE, - CONF_VALUE_OR_POSITION_TEMPLATE): cv.template, - vol.Exclusive(CONF_VALUE_TEMPLATE, - CONF_VALUE_OR_POSITION_TEMPLATE): cv.template, - 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): cv.string, - vol.Optional(CONF_ENTITY_ID): cv.entity_ids -}) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_COVERS): vol.Schema({cv.slug: COVER_SCHEMA}), -}) - - -async def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): - """Set up the Template cover.""" - covers = [] - - for device, device_config in config[CONF_COVERS].items(): - friendly_name = device_config.get(CONF_FRIENDLY_NAME, device) - state_template = device_config.get(CONF_VALUE_TEMPLATE) - 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) - position_action = device_config.get(POSITION_ACTION) - tilt_action = device_config.get(TILT_ACTION) - optimistic = device_config.get(CONF_OPTIMISTIC) - tilt_optimistic = device_config.get(CONF_TILT_OPTIMISTIC) - - if position_action is None and open_action is None: - _LOGGER.error('Must specify at least one of %s' or '%s', - OPEN_ACTION, POSITION_ACTION) - continue - template_entity_ids = set() - if state_template is not None: - temp_ids = state_template.extract_entities() - if str(temp_ids) != MATCH_ALL: - template_entity_ids |= set(temp_ids) - - if position_template is not None: - temp_ids = position_template.extract_entities() - if str(temp_ids) != MATCH_ALL: - template_entity_ids |= set(temp_ids) - - if tilt_template is not None: - temp_ids = tilt_template.extract_entities() - 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 - - entity_ids = device_config.get(CONF_ENTITY_ID, template_entity_ids) - - covers.append( - CoverTemplate( - hass, - device, friendly_name, state_template, - position_template, tilt_template, icon_template, - entity_picture_template, open_action, close_action, - stop_action, position_action, tilt_action, - optimistic, tilt_optimistic, entity_ids - ) - ) - if not covers: - _LOGGER.error("No covers added") - return False - - async_add_entities(covers) - return True - - -class CoverTemplate(CoverDevice): - """Representation of a Template cover.""" - - def __init__(self, hass, device_id, friendly_name, state_template, - position_template, tilt_template, icon_template, - 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 - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, device_id, hass=hass) - self._name = friendly_name - self._template = 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) - self._close_script = None - if close_action is not None: - self._close_script = Script(hass, close_action) - self._stop_script = None - if stop_action is not None: - self._stop_script = Script(hass, stop_action) - self._position_script = None - if position_action is not None: - self._position_script = Script(hass, position_action) - self._tilt_script = None - if tilt_action is not None: - self._tilt_script = Script(hass, tilt_action) - self._optimistic = (optimistic or - (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 - - if self._template is not None: - self._template.hass = self.hass - if self._position_template is not None: - self._position_template.hass = self.hass - if self._tilt_template is not None: - 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 - - async def async_added_to_hass(self): - """Register callbacks.""" - @callback - def template_cover_state_listener(entity, old_state, new_state): - """Handle target device state changes.""" - self.async_schedule_update_ha_state(True) - - @callback - def template_cover_startup(event): - """Update template on startup.""" - async_track_state_change( - self.hass, self._entities, template_cover_state_listener) - - self.async_schedule_update_ha_state(True) - - self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_START, template_cover_startup) - - @property - def name(self): - """Return the name of the cover.""" - return self._name - - @property - def is_closed(self): - """Return if the cover is closed.""" - return self._position == 0 - - @property - def current_cover_position(self): - """Return current position of cover. - - None is unknown, 0 is closed, 100 is fully open. - """ - if self._position_template or self._position_script: - return self._position - return None - - @property - def current_cover_tilt_position(self): - """Return current position of cover tilt. - - None is unknown, 0 is closed, 100 is fully open. - """ - return self._tilt_value - - @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 supported_features(self): - """Flag supported features.""" - supported_features = SUPPORT_OPEN | SUPPORT_CLOSE - - if self._stop_script is not None: - supported_features |= SUPPORT_STOP - - if self._position_script is not None: - supported_features |= SUPPORT_SET_POSITION - - if self.current_cover_tilt_position is not None: - supported_features |= TILT_FEATURES - - return supported_features - - @property - def should_poll(self): - """Return the polling state.""" - return False - - async def async_open_cover(self, **kwargs): - """Move the cover up.""" - if self._open_script: - await self._open_script.async_run() - elif self._position_script: - await self._position_script.async_run({"position": 100}) - if self._optimistic: - self._position = 100 - self.async_schedule_update_ha_state() - - async def async_close_cover(self, **kwargs): - """Move the cover down.""" - if self._close_script: - await self._close_script.async_run() - elif self._position_script: - await self._position_script.async_run({"position": 0}) - if self._optimistic: - self._position = 0 - self.async_schedule_update_ha_state() - - async def async_stop_cover(self, **kwargs): - """Fire the stop action.""" - if self._stop_script: - await self._stop_script.async_run() - - async def async_set_cover_position(self, **kwargs): - """Set cover position.""" - self._position = kwargs[ATTR_POSITION] - await self._position_script.async_run( - {"position": self._position}) - if self._optimistic: - self.async_schedule_update_ha_state() - - async def async_open_cover_tilt(self, **kwargs): - """Tilt the cover open.""" - self._tilt_value = 100 - await self._tilt_script.async_run({"tilt": self._tilt_value}) - if self._tilt_optimistic: - self.async_schedule_update_ha_state() - - async def async_close_cover_tilt(self, **kwargs): - """Tilt the cover closed.""" - self._tilt_value = 0 - await self._tilt_script.async_run( - {"tilt": self._tilt_value}) - if self._tilt_optimistic: - self.async_schedule_update_ha_state() - - async def async_set_cover_tilt_position(self, **kwargs): - """Move the cover tilt to a specific position.""" - self._tilt_value = kwargs[ATTR_TILT_POSITION] - await self._tilt_script.async_run({"tilt": self._tilt_value}) - if self._tilt_optimistic: - self.async_schedule_update_ha_state() - - async def async_update(self): - """Update the state from the template.""" - if self._template is not None: - try: - state = self._template.async_render().lower() - if state in _VALID_STATES: - if state in ('true', STATE_OPEN): - self._position = 100 - else: - self._position = 0 - else: - _LOGGER.error( - 'Received invalid cover is_on state: %s. Expected: %s', - state, ', '.join(_VALID_STATES)) - self._position = None - except TemplateError as ex: - _LOGGER.error(ex) - self._position = None - if self._position_template is not None: - try: - state = float(self._position_template.async_render()) - if state < 0 or state > 100: - self._position = None - _LOGGER.error("Cover position value must be" - " between 0 and 100." - " Value was: %.2f", state) - else: - self._position = state - except TemplateError as ex: - _LOGGER.error(ex) - self._position = None - except ValueError as ex: - _LOGGER.error(ex) - self._position = None - if self._tilt_template is not None: - try: - state = float(self._tilt_template.async_render()) - if state < 0 or state > 100: - self._tilt_value = None - _LOGGER.error("Tilt value must be between 0 and 100." - " Value was: %.2f", state) - else: - self._tilt_value = state - except TemplateError as ex: - _LOGGER.error(ex) - self._tilt_value = None - except ValueError as ex: - _LOGGER.error(ex) - self._tilt_value = 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/cover/tuya.py b/homeassistant/components/cover/tuya.py deleted file mode 100644 index 6ab858160..000000000 --- a/homeassistant/components/cover/tuya.py +++ /dev/null @@ -1,60 +0,0 @@ -""" -Support for Tuya cover. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/cover.tuya/ -""" -from homeassistant.components.cover import ( - CoverDevice, ENTITY_ID_FORMAT, SUPPORT_OPEN, SUPPORT_CLOSE, SUPPORT_STOP) -from homeassistant.components.tuya import DATA_TUYA, TuyaDevice - -DEPENDENCIES = ['tuya'] - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up Tuya cover devices.""" - if discovery_info is None: - return - tuya = hass.data[DATA_TUYA] - dev_ids = discovery_info.get('dev_ids') - devices = [] - for dev_id in dev_ids: - device = tuya.get_device_by_id(dev_id) - if device is None: - continue - devices.append(TuyaCover(device)) - add_entities(devices) - - -class TuyaCover(TuyaDevice, CoverDevice): - """Tuya cover devices.""" - - def __init__(self, tuya): - """Init tuya cover device.""" - super().__init__(tuya) - self.entity_id = ENTITY_ID_FORMAT.format(tuya.object_id()) - - @property - def supported_features(self): - """Flag supported features.""" - supported_features = SUPPORT_OPEN | SUPPORT_CLOSE - if self.tuya.support_stop(): - supported_features |= SUPPORT_STOP - return supported_features - - @property - def is_closed(self): - """Return if the cover is closed or not.""" - return None - - def open_cover(self, **kwargs): - """Open the cover.""" - self.tuya.open_cover() - - def close_cover(self, **kwargs): - """Close cover.""" - self.tuya.close_cover() - - def stop_cover(self, **kwargs): - """Stop the cover.""" - self.tuya.stop_cover() diff --git a/homeassistant/components/cover/velbus.py b/homeassistant/components/cover/velbus.py deleted file mode 100644 index a85017788..000000000 --- a/homeassistant/components/cover/velbus.py +++ /dev/null @@ -1,158 +0,0 @@ -""" -Support for Velbus covers. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/cover.velbus/ -""" -import logging -import time - -import voluptuous as vol - -from homeassistant.components.cover import ( - CoverDevice, PLATFORM_SCHEMA, SUPPORT_OPEN, SUPPORT_CLOSE, - SUPPORT_STOP) -from homeassistant.components.velbus import DOMAIN -from homeassistant.const import (CONF_COVERS, CONF_NAME) -import homeassistant.helpers.config_validation as cv - -_LOGGER = logging.getLogger(__name__) - -COVER_SCHEMA = vol.Schema({ - vol.Required('module'): cv.positive_int, - vol.Required('open_channel'): cv.positive_int, - vol.Required('close_channel'): cv.positive_int, - vol.Required(CONF_NAME): cv.string -}) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_COVERS): vol.Schema({cv.slug: COVER_SCHEMA}), -}) - -DEPENDENCIES = ['velbus'] - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up cover controlled by Velbus.""" - devices = config.get(CONF_COVERS, {}) - covers = [] - - velbus = hass.data[DOMAIN] - for device_name, device_config in devices.items(): - covers.append( - VelbusCover( - velbus, - device_config.get(CONF_NAME, device_name), - device_config.get('module'), - device_config.get('open_channel'), - device_config.get('close_channel') - ) - ) - - if not covers: - _LOGGER.error("No covers added") - return False - - add_entities(covers) - - -class VelbusCover(CoverDevice): - """Representation a Velbus cover.""" - - def __init__(self, velbus, name, module, open_channel, close_channel): - """Initialize the cover.""" - self._velbus = velbus - self._name = name - self._close_channel_state = None - self._open_channel_state = None - self._module = module - self._open_channel = open_channel - self._close_channel = close_channel - - async def async_added_to_hass(self): - """Add listener for Velbus messages on bus.""" - def _init_velbus(): - """Initialize Velbus on startup.""" - self._velbus.subscribe(self._on_message) - self.get_status() - - await self.hass.async_add_job(_init_velbus) - - def _on_message(self, message): - import velbus - if isinstance(message, velbus.RelayStatusMessage): - if message.address == self._module: - if message.channel == self._close_channel: - self._close_channel_state = message.is_on() - self.schedule_update_ha_state() - if message.channel == self._open_channel: - self._open_channel_state = message.is_on() - self.schedule_update_ha_state() - - @property - def supported_features(self): - """Flag supported features.""" - return SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_STOP - - @property - def should_poll(self): - """Disable polling.""" - return False - - @property - def name(self): - """Return the name of the cover.""" - return self._name - - @property - def is_closed(self): - """Return if the cover is closed.""" - return self._close_channel_state - - @property - def current_cover_position(self): - """Return current position of cover. - - None is unknown. - """ - return None - - def _relay_off(self, channel): - import velbus - message = velbus.SwitchRelayOffMessage() - message.set_defaults(self._module) - message.relay_channels = [channel] - self._velbus.send(message) - - def _relay_on(self, channel): - import velbus - message = velbus.SwitchRelayOnMessage() - message.set_defaults(self._module) - message.relay_channels = [channel] - self._velbus.send(message) - - def open_cover(self, **kwargs): - """Open the cover.""" - self._relay_off(self._close_channel) - time.sleep(0.3) - self._relay_on(self._open_channel) - - def close_cover(self, **kwargs): - """Close the cover.""" - self._relay_off(self._open_channel) - time.sleep(0.3) - self._relay_on(self._close_channel) - - def stop_cover(self, **kwargs): - """Stop the cover.""" - self._relay_off(self._open_channel) - time.sleep(0.3) - self._relay_off(self._close_channel) - - def get_status(self): - """Retrieve current status.""" - import velbus - message = velbus.ModuleStatusRequestMessage() - message.set_defaults(self._module) - message.channels = [self._open_channel, self._close_channel] - self._velbus.send(message) diff --git a/homeassistant/components/cover/vera.py b/homeassistant/components/cover/vera.py deleted file mode 100644 index 279e4a430..000000000 --- a/homeassistant/components/cover/vera.py +++ /dev/null @@ -1,72 +0,0 @@ -""" -Support for Vera cover - curtains, rollershutters etc. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/cover.vera/ -""" -import logging - -from homeassistant.components.cover import CoverDevice, ENTITY_ID_FORMAT, \ - ATTR_POSITION -from homeassistant.components.vera import ( - VERA_CONTROLLER, VERA_DEVICES, VeraDevice) - -DEPENDENCIES = ['vera'] - -_LOGGER = logging.getLogger(__name__) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Vera covers.""" - add_entities( - [VeraCover(device, hass.data[VERA_CONTROLLER]) for - device in hass.data[VERA_DEVICES]['cover']], True) - - -class VeraCover(VeraDevice, CoverDevice): - """Representation a Vera Cover.""" - - def __init__(self, vera_device, controller): - """Initialize the Vera device.""" - VeraDevice.__init__(self, vera_device, controller) - self.entity_id = ENTITY_ID_FORMAT.format(self.vera_id) - - @property - def current_cover_position(self): - """ - Return current position of cover. - - 0 is closed, 100 is fully open. - """ - position = self.vera_device.get_level() - if position <= 5: - return 0 - if position >= 95: - return 100 - return position - - def set_cover_position(self, **kwargs): - """Move the cover to a specific position.""" - self.vera_device.set_level(kwargs.get(ATTR_POSITION)) - self.schedule_update_ha_state() - - @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 - - def open_cover(self, **kwargs): - """Open the cover.""" - self.vera_device.open() - self.schedule_update_ha_state() - - def close_cover(self, **kwargs): - """Close the cover.""" - self.vera_device.close() - self.schedule_update_ha_state() - - def stop_cover(self, **kwargs): - """Stop the cover.""" - self.vera_device.stop() - self.schedule_update_ha_state() diff --git a/homeassistant/components/cover/wink.py b/homeassistant/components/cover/wink.py deleted file mode 100644 index 857283b9b..000000000 --- a/homeassistant/components/cover/wink.py +++ /dev/null @@ -1,63 +0,0 @@ -""" -Support for Wink Covers. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/cover.wink/ -""" -from homeassistant.components.cover import CoverDevice, STATE_UNKNOWN, \ - ATTR_POSITION -from homeassistant.components.wink import WinkDevice, DOMAIN - -DEPENDENCIES = ['wink'] - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Wink cover platform.""" - import pywink - - for shade in pywink.get_shades(): - _id = shade.object_id() + shade.name() - if _id not in hass.data[DOMAIN]['unique_ids']: - add_entities([WinkCoverDevice(shade, hass)]) - for shade in pywink.get_shade_groups(): - _id = shade.object_id() + shade.name() - if _id not in hass.data[DOMAIN]['unique_ids']: - add_entities([WinkCoverDevice(shade, hass)]) - for door in pywink.get_garage_doors(): - _id = door.object_id() + door.name() - if _id not in hass.data[DOMAIN]['unique_ids']: - add_entities([WinkCoverDevice(door, hass)]) - - -class WinkCoverDevice(WinkDevice, CoverDevice): - """Representation of a Wink cover device.""" - - async def async_added_to_hass(self): - """Call when entity is added to hass.""" - self.hass.data[DOMAIN]['entities']['cover'].append(self) - - def close_cover(self, **kwargs): - """Close the cover.""" - self.wink.set_state(0) - - def open_cover(self, **kwargs): - """Open the cover.""" - self.wink.set_state(1) - - 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 cover shutter.""" - if self.wink.state() is not None: - return int(self.wink.state()*100) - return STATE_UNKNOWN - - @property - def is_closed(self): - """Return if the cover is closed.""" - state = self.wink.state() - return bool(state == 0) diff --git a/homeassistant/components/cover/xiaomi_aqara.py b/homeassistant/components/cover/xiaomi_aqara.py deleted file mode 100644 index 3ed0a70b1..000000000 --- a/homeassistant/components/cover/xiaomi_aqara.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Support for Xiaomi curtain.""" -import logging - -from homeassistant.components.cover import CoverDevice, ATTR_POSITION -from homeassistant.components.xiaomi_aqara import (PY_XIAOMI_GATEWAY, - XiaomiDevice) - -_LOGGER = logging.getLogger(__name__) - -ATTR_CURTAIN_LEVEL = 'curtain_level' - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Perform the setup for Xiaomi devices.""" - devices = [] - for (_, gateway) in hass.data[PY_XIAOMI_GATEWAY].gateways.items(): - for device in gateway.devices['cover']: - model = device['model'] - if model == 'curtain': - devices.append(XiaomiGenericCover(device, "Curtain", - {'status': 'status', - 'pos': 'curtain_level'}, - gateway)) - add_entities(devices) - - -class XiaomiGenericCover(XiaomiDevice, CoverDevice): - """Representation of a XiaomiGenericCover.""" - - def __init__(self, device, name, data_key, xiaomi_hub): - """Initialize the XiaomiGenericCover.""" - self._data_key = data_key - self._pos = 0 - XiaomiDevice.__init__(self, device, name, xiaomi_hub) - - @property - def current_cover_position(self): - """Return the current position of the cover.""" - return self._pos - - @property - def is_closed(self): - """Return if the cover is closed.""" - return self.current_cover_position <= 0 - - def close_cover(self, **kwargs): - """Close the cover.""" - self._write_to_hub(self._sid, **{self._data_key['status']: 'close'}) - - def open_cover(self, **kwargs): - """Open the cover.""" - self._write_to_hub(self._sid, **{self._data_key['status']: 'open'}) - - def stop_cover(self, **kwargs): - """Stop the cover.""" - self._write_to_hub(self._sid, **{self._data_key['status']: 'stop'}) - - 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, raw_data): - """Parse data sent by gateway.""" - if ATTR_CURTAIN_LEVEL in data: - self._pos = int(data[ATTR_CURTAIN_LEVEL]) - return True - return False diff --git a/homeassistant/components/cover/zwave.py b/homeassistant/components/cover/zwave.py deleted file mode 100644 index 258087702..000000000 --- a/homeassistant/components/cover/zwave.py +++ /dev/null @@ -1,173 +0,0 @@ -""" -Support for Z-Wave cover components. - -For more details about this platform, please refer to the documentation -https://home-assistant.io/components/cover.zwave/ -""" -# Because we do not compile openzwave on CI -# pylint: disable=import-error -import logging -from homeassistant.components.cover import ( - DOMAIN, SUPPORT_OPEN, SUPPORT_CLOSE, ATTR_POSITION) -from homeassistant.components import zwave -from homeassistant.components.zwave import ( # noqa pylint: disable=unused-import - ZWaveDeviceEntity, async_setup_platform, workaround) -from homeassistant.components.cover import CoverDevice - -_LOGGER = logging.getLogger(__name__) - -SUPPORT_GARAGE = SUPPORT_OPEN | SUPPORT_CLOSE - - -def get_device(hass, values, node_config, **kwargs): - """Create Z-Wave entity device.""" - invert_buttons = node_config.get(zwave.CONF_INVERT_OPENCLOSE_BUTTONS) - if (values.primary.command_class == - zwave.const.COMMAND_CLASS_SWITCH_MULTILEVEL - and values.primary.index == 0): - return ZwaveRollershutter(hass, values, invert_buttons) - if values.primary.command_class == zwave.const.COMMAND_CLASS_SWITCH_BINARY: - return ZwaveGarageDoorSwitch(values) - if values.primary.command_class == \ - zwave.const.COMMAND_CLASS_BARRIER_OPERATOR: - return ZwaveGarageDoorBarrier(values) - return None - - -class ZwaveRollershutter(zwave.ZWaveDeviceEntity, CoverDevice): - """Representation of an Z-Wave cover.""" - - def __init__(self, hass, values, invert_buttons): - """Initialize the Z-Wave rollershutter.""" - ZWaveDeviceEntity.__init__(self, values, DOMAIN) - self._network = hass.data[zwave.const.DATA_NETWORK] - self._open_id = None - self._close_id = None - self._current_position = None - self._invert_buttons = invert_buttons - - self._workaround = workaround.get_device_mapping(values.primary) - if self._workaround: - _LOGGER.debug("Using workaround %s", self._workaround) - self.update_properties() - - def update_properties(self): - """Handle data changes for node values.""" - # Position value - self._current_position = self.values.primary.data - - if self.values.open and self.values.close and \ - self._open_id is None and self._close_id is None: - if self._invert_buttons: - self._open_id = self.values.close.value_id - self._close_id = self.values.open.value_id - else: - self._open_id = self.values.open.value_id - self._close_id = self.values.close.value_id - - @property - def is_closed(self): - """Return if the cover is closed.""" - if self.current_cover_position is None: - return None - if self.current_cover_position > 0: - return False - return True - - @property - def current_cover_position(self): - """Return the current position of Zwave roller shutter.""" - if self._workaround == workaround.WORKAROUND_NO_POSITION: - return None - if self._current_position is not None: - if self._current_position <= 5: - return 0 - if self._current_position >= 95: - return 100 - return self._current_position - - def open_cover(self, **kwargs): - """Move the roller shutter up.""" - self._network.manager.pressButton(self._open_id) - - def close_cover(self, **kwargs): - """Move the roller shutter down.""" - self._network.manager.pressButton(self._close_id) - - def set_cover_position(self, **kwargs): - """Move the roller shutter to a specific position.""" - self.node.set_dimmer(self.values.primary.value_id, - kwargs.get(ATTR_POSITION)) - - def stop_cover(self, **kwargs): - """Stop the roller shutter.""" - self._network.manager.releaseButton(self._open_id) - - -class ZwaveGarageDoorBase(zwave.ZWaveDeviceEntity, CoverDevice): - """Base class for a Zwave garage door device.""" - - def __init__(self, values): - """Initialize the zwave garage door.""" - ZWaveDeviceEntity.__init__(self, values, DOMAIN) - self._state = None - self.update_properties() - - def update_properties(self): - """Handle data changes for node values.""" - self._state = self.values.primary.data - _LOGGER.debug("self._state=%s", self._state) - - @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_GARAGE - - -class ZwaveGarageDoorSwitch(ZwaveGarageDoorBase): - """Representation of a switch based Zwave garage door device.""" - - @property - def is_closed(self): - """Return the current position of Zwave garage door.""" - return not self._state - - def close_cover(self, **kwargs): - """Close the garage door.""" - self.values.primary.data = False - - def open_cover(self, **kwargs): - """Open the garage door.""" - self.values.primary.data = True - - -class ZwaveGarageDoorBarrier(ZwaveGarageDoorBase): - """Representation of a barrier operator Zwave garage door device.""" - - @property - def is_opening(self): - """Return true if cover is in an opening state.""" - return self._state == "Opening" - - @property - def is_closing(self): - """Return true if cover is in a closing state.""" - return self._state == "Closing" - - @property - def is_closed(self): - """Return the current position of Zwave garage door.""" - return self._state == "Closed" - - def close_cover(self, **kwargs): - """Close the garage door.""" - self.values.primary.data = "Closed" - - def open_cover(self, **kwargs): - """Open the garage door.""" - self.values.primary.data = "Opened" diff --git a/homeassistant/components/cppm_tracker/__init__.py b/homeassistant/components/cppm_tracker/__init__.py new file mode 100644 index 000000000..cb6aa8788 --- /dev/null +++ b/homeassistant/components/cppm_tracker/__init__.py @@ -0,0 +1 @@ +"""Add support for ClearPass Policy Manager.""" diff --git a/homeassistant/components/cppm_tracker/device_tracker.py b/homeassistant/components/cppm_tracker/device_tracker.py new file mode 100644 index 000000000..1bb723091 --- /dev/null +++ b/homeassistant/components/cppm_tracker/device_tracker.py @@ -0,0 +1,80 @@ +"""Support for ClearPass Policy Manager.""" +from datetime import timedelta +import logging + +from clearpasspy import ClearPass +import voluptuous as vol + +from homeassistant.components.device_tracker import ( + DOMAIN, + PLATFORM_SCHEMA, + DeviceScanner, +) +from homeassistant.const import CONF_API_KEY, CONF_HOST +import homeassistant.helpers.config_validation as cv + +SCAN_INTERVAL = timedelta(seconds=120) + +CLIENT_ID = "client_id" + +GRANT_TYPE = "client_credentials" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CLIENT_ID): cv.string, + vol.Required(CONF_API_KEY): cv.string, + } +) + +_LOGGER = logging.getLogger(__name__) + + +def get_scanner(hass, config): + """Initialize Scanner.""" + + data = { + "server": config[DOMAIN][CONF_HOST], + "grant_type": GRANT_TYPE, + "secret": config[DOMAIN][CONF_API_KEY], + "client": config[DOMAIN][CLIENT_ID], + } + cppm = ClearPass(data) + if cppm.access_token is None: + return None + _LOGGER.debug("Successfully received Access Token") + return CPPMDeviceScanner(cppm) + + +class CPPMDeviceScanner(DeviceScanner): + """Initialize class.""" + + def __init__(self, cppm): + """Initialize class.""" + self._cppm = cppm + self.results = None + + def scan_devices(self): + """Initialize scanner.""" + self.get_cppm_data() + return [device["mac"] for device in self.results] + + def get_device_name(self, device): + """Retrieve device name.""" + name = next( + (result["name"] for result in self.results if result["mac"] == device), None + ) + return name + + def get_cppm_data(self): + """Retrieve data from Aruba Clearpass and return parsed result.""" + endpoints = self._cppm.get_endpoints(100)["_embedded"]["items"] + devices = [] + for item in endpoints: + if self._cppm.online_status(item["mac_address"]): + device = {"mac": item["mac_address"], "name": item["mac_address"]} + devices.append(device) + else: + continue + _LOGGER.debug("Devices: %s", devices) + self.results = devices diff --git a/homeassistant/components/cppm_tracker/manifest.json b/homeassistant/components/cppm_tracker/manifest.json new file mode 100644 index 000000000..b2abc40dc --- /dev/null +++ b/homeassistant/components/cppm_tracker/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "cppm_tracker", + "name": "Cppm tracker", + "documentation": "https://www.home-assistant.io/integrations/cppm_tracker", + "requirements": [ + "clearpasspy==1.0.2" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/cpuspeed/__init__.py b/homeassistant/components/cpuspeed/__init__.py new file mode 100644 index 000000000..c6121a688 --- /dev/null +++ b/homeassistant/components/cpuspeed/__init__.py @@ -0,0 +1 @@ +"""The cpuspeed component.""" diff --git a/homeassistant/components/cpuspeed/manifest.json b/homeassistant/components/cpuspeed/manifest.json new file mode 100644 index 000000000..7950cad9b --- /dev/null +++ b/homeassistant/components/cpuspeed/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "cpuspeed", + "name": "Cpuspeed", + "documentation": "https://www.home-assistant.io/integrations/cpuspeed", + "requirements": [ + "py-cpuinfo==5.0.0" + ], + "dependencies": [], + "codeowners": [ + "@fabaff" + ] +} diff --git a/homeassistant/components/cpuspeed/sensor.py b/homeassistant/components/cpuspeed/sensor.py new file mode 100644 index 000000000..53598e24c --- /dev/null +++ b/homeassistant/components/cpuspeed/sensor.py @@ -0,0 +1,84 @@ +"""Support for displaying the current CPU speed.""" +import logging + +from cpuinfo import cpuinfo +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_NAME +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +ATTR_BRAND = "Brand" +ATTR_HZ = "GHz Advertised" +ATTR_ARCH = "arch" + +HZ_ACTUAL_RAW = "hz_actual_raw" +HZ_ADVERTISED_RAW = "hz_advertised_raw" + +DEFAULT_NAME = "CPU speed" + +ICON = "mdi:pulse" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + {vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string} +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the CPU speed sensor.""" + name = config.get(CONF_NAME) + + add_entities([CpuSpeedSensor(name)], True) + + +class CpuSpeedSensor(Entity): + """Representation of a CPU sensor.""" + + def __init__(self, name): + """Initialize the sensor.""" + self._name = name + self._state = None + self.info = None + self._unit_of_measurement = "GHz" + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self._unit_of_measurement + + @property + def device_state_attributes(self): + """Return the state attributes.""" + if self.info is not None: + attrs = {ATTR_ARCH: self.info["arch"], ATTR_BRAND: self.info["brand"]} + + if HZ_ADVERTISED_RAW in self.info: + attrs[ATTR_HZ] = round(self.info[HZ_ADVERTISED_RAW][0] / 10 ** 9, 2) + return attrs + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return ICON + + def update(self): + """Get the latest data and updates the state.""" + + self.info = cpuinfo.get_cpu_info() + if HZ_ACTUAL_RAW in self.info: + self._state = round(float(self.info[HZ_ACTUAL_RAW][0]) / 10 ** 9, 2) + else: + self._state = None diff --git a/homeassistant/components/crimereports/__init__.py b/homeassistant/components/crimereports/__init__.py new file mode 100644 index 000000000..57af9df4d --- /dev/null +++ b/homeassistant/components/crimereports/__init__.py @@ -0,0 +1 @@ +"""The crimereports component.""" diff --git a/homeassistant/components/crimereports/manifest.json b/homeassistant/components/crimereports/manifest.json new file mode 100644 index 000000000..c5cc45d31 --- /dev/null +++ b/homeassistant/components/crimereports/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "crimereports", + "name": "Crimereports", + "documentation": "https://www.home-assistant.io/integrations/crimereports", + "requirements": [ + "crimereports==1.0.1" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/crimereports/sensor.py b/homeassistant/components/crimereports/sensor.py new file mode 100644 index 000000000..cf5b2e374 --- /dev/null +++ b/homeassistant/components/crimereports/sensor.py @@ -0,0 +1,130 @@ +"""Sensor for Crime Reports.""" +from collections import defaultdict +from datetime import timedelta +import logging + +import crimereports +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_LATITUDE, + ATTR_LONGITUDE, + CONF_EXCLUDE, + CONF_INCLUDE, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_NAME, + CONF_RADIUS, + LENGTH_KILOMETERS, + LENGTH_METERS, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import slugify +from homeassistant.util.distance import convert +from homeassistant.util.dt import now + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "crimereports" + +EVENT_INCIDENT = f"{DOMAIN}_incident" + +SCAN_INTERVAL = timedelta(minutes=30) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_RADIUS): vol.Coerce(float), + vol.Inclusive(CONF_LATITUDE, "coordinates"): cv.latitude, + vol.Inclusive(CONF_LONGITUDE, "coordinates"): cv.longitude, + vol.Optional(CONF_INCLUDE): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_EXCLUDE): vol.All(cv.ensure_list, [cv.string]), + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Crime Reports platform.""" + latitude = config.get(CONF_LATITUDE, hass.config.latitude) + longitude = config.get(CONF_LONGITUDE, hass.config.longitude) + name = config.get(CONF_NAME) + radius = config.get(CONF_RADIUS) + include = config.get(CONF_INCLUDE) + exclude = config.get(CONF_EXCLUDE) + + add_entities( + [CrimeReportsSensor(hass, name, latitude, longitude, radius, include, exclude)], + True, + ) + + +class CrimeReportsSensor(Entity): + """Representation of a Crime Reports Sensor.""" + + def __init__(self, hass, name, latitude, longitude, radius, include, exclude): + """Initialize the Crime Reports sensor.""" + self._hass = hass + self._name = name + self._include = include + self._exclude = exclude + radius_kilometers = convert(radius, LENGTH_METERS, LENGTH_KILOMETERS) + self._crimereports = crimereports.CrimeReports( + (latitude, longitude), radius_kilometers + ) + self._attributes = None + self._state = None + self._previous_incidents = set() + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._attributes + + def _incident_event(self, incident): + """Fire if an event occurs.""" + data = { + "type": incident.get("type"), + "description": incident.get("friendly_description"), + "timestamp": incident.get("timestamp"), + "location": incident.get("location"), + } + if incident.get("coordinates"): + data.update( + { + ATTR_LATITUDE: incident.get("coordinates")[0], + ATTR_LONGITUDE: incident.get("coordinates")[1], + } + ) + self._hass.bus.fire(EVENT_INCIDENT, data) + + def update(self): + """Update device state.""" + incident_counts = defaultdict(int) + incidents = self._crimereports.get_incidents( + now().date(), include=self._include, exclude=self._exclude + ) + fire_events = len(self._previous_incidents) > 0 + if len(incidents) < len(self._previous_incidents): + self._previous_incidents = set() + for incident in incidents: + incident_type = slugify(incident.get("type")) + incident_counts[incident_type] += 1 + if fire_events and incident.get("id") not in self._previous_incidents: + self._incident_event(incident) + self._previous_incidents.add(incident.get("id")) + self._attributes = {ATTR_ATTRIBUTION: crimereports.ATTRIBUTION} + self._attributes.update(incident_counts) + self._state = len(incidents) diff --git a/homeassistant/components/cups/__init__.py b/homeassistant/components/cups/__init__.py new file mode 100644 index 000000000..7cd5ce4ca --- /dev/null +++ b/homeassistant/components/cups/__init__.py @@ -0,0 +1 @@ +"""The cups component.""" diff --git a/homeassistant/components/cups/manifest.json b/homeassistant/components/cups/manifest.json new file mode 100644 index 000000000..e30d64510 --- /dev/null +++ b/homeassistant/components/cups/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "cups", + "name": "Cups", + "documentation": "https://www.home-assistant.io/integrations/cups", + "requirements": [ + "pycups==1.9.73" + ], + "dependencies": [], + "codeowners": [ + "@fabaff" + ] +} diff --git a/homeassistant/components/cups/sensor.py b/homeassistant/components/cups/sensor.py new file mode 100644 index 000000000..7581891af --- /dev/null +++ b/homeassistant/components/cups/sensor.py @@ -0,0 +1,340 @@ +"""Details about printers which are connected to CUPS.""" +from datetime import timedelta +import importlib +import logging + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.exceptions import PlatformNotReady +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +ATTR_MARKER_TYPE = "marker_type" +ATTR_MARKER_LOW_LEVEL = "marker_low_level" +ATTR_MARKER_HIGH_LEVEL = "marker_high_level" +ATTR_PRINTER_NAME = "printer_name" +ATTR_DEVICE_URI = "device_uri" +ATTR_PRINTER_INFO = "printer_info" +ATTR_PRINTER_IS_SHARED = "printer_is_shared" +ATTR_PRINTER_LOCATION = "printer_location" +ATTR_PRINTER_MODEL = "printer_model" +ATTR_PRINTER_STATE_MESSAGE = "printer_state_message" +ATTR_PRINTER_STATE_REASON = "printer_state_reason" +ATTR_PRINTER_TYPE = "printer_type" +ATTR_PRINTER_URI_SUPPORTED = "printer_uri_supported" + +CONF_PRINTERS = "printers" +CONF_IS_CUPS_SERVER = "is_cups_server" + +DEFAULT_HOST = "127.0.0.1" +DEFAULT_PORT = 631 +DEFAULT_IS_CUPS_SERVER = True + +ICON_PRINTER = "mdi:printer" +ICON_MARKER = "mdi:water" + +SCAN_INTERVAL = timedelta(minutes=1) + +PRINTER_STATES = {3: "idle", 4: "printing", 5: "stopped"} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_PRINTERS): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_IS_CUPS_SERVER, default=DEFAULT_IS_CUPS_SERVER): cv.boolean, + vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the CUPS sensor.""" + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) + printers = config.get(CONF_PRINTERS) + is_cups = config.get(CONF_IS_CUPS_SERVER) + + if is_cups: + data = CupsData(host, port, None) + data.update() + if data.available is False: + _LOGGER.error("Unable to connect to CUPS server: %s:%s", host, port) + raise PlatformNotReady() + + dev = [] + for printer in printers: + if printer not in data.printers: + _LOGGER.error("Printer is not present: %s", printer) + continue + dev.append(CupsSensor(data, printer)) + + if "marker-names" in data.attributes[printer]: + for marker in data.attributes[printer]["marker-names"]: + dev.append(MarkerSensor(data, printer, marker, True)) + + add_entities(dev, True) + return + + data = CupsData(host, port, printers) + data.update() + if data.available is False: + _LOGGER.error("Unable to connect to IPP printer: %s:%s", host, port) + raise PlatformNotReady() + + dev = [] + for printer in printers: + dev.append(IPPSensor(data, printer)) + + if "marker-names" in data.attributes[printer]: + for marker in data.attributes[printer]["marker-names"]: + dev.append(MarkerSensor(data, printer, marker, False)) + + add_entities(dev, True) + + +class CupsSensor(Entity): + """Representation of a CUPS sensor.""" + + def __init__(self, data, printer): + """Initialize the CUPS sensor.""" + self.data = data + self._name = printer + self._printer = None + self._available = False + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + if self._printer is None: + return None + + key = self._printer["printer-state"] + return PRINTER_STATES.get(key, key) + + @property + def available(self): + """Return True if entity is available.""" + return self._available + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return ICON_PRINTER + + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + if self._printer is None: + return None + + return { + ATTR_DEVICE_URI: self._printer["device-uri"], + ATTR_PRINTER_INFO: self._printer["printer-info"], + ATTR_PRINTER_IS_SHARED: self._printer["printer-is-shared"], + ATTR_PRINTER_LOCATION: self._printer["printer-location"], + ATTR_PRINTER_MODEL: self._printer["printer-make-and-model"], + ATTR_PRINTER_STATE_MESSAGE: self._printer["printer-state-message"], + ATTR_PRINTER_STATE_REASON: self._printer["printer-state-reasons"], + ATTR_PRINTER_TYPE: self._printer["printer-type"], + ATTR_PRINTER_URI_SUPPORTED: self._printer["printer-uri-supported"], + } + + def update(self): + """Get the latest data and updates the states.""" + self.data.update() + self._printer = self.data.printers.get(self._name) + self._available = self.data.available + + +class IPPSensor(Entity): + """Implementation of the IPPSensor. + + This sensor represents the status of the printer. + """ + + def __init__(self, data, name): + """Initialize the sensor.""" + self.data = data + self._name = name + self._attributes = None + self._available = False + + @property + def name(self): + """Return the name of the sensor.""" + return self._attributes["printer-make-and-model"] + + @property + def icon(self): + """Return the icon to use in the frontend.""" + return ICON_PRINTER + + @property + def available(self): + """Return True if entity is available.""" + return self._available + + @property + def state(self): + """Return the state of the sensor.""" + if self._attributes is None: + return None + + key = self._attributes["printer-state"] + return PRINTER_STATES.get(key, key) + + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + if self._attributes is None: + return None + + state_attributes = {} + + if "printer-info" in self._attributes: + state_attributes[ATTR_PRINTER_INFO] = self._attributes["printer-info"] + + if "printer-location" in self._attributes: + state_attributes[ATTR_PRINTER_LOCATION] = self._attributes[ + "printer-location" + ] + + if "printer-state-message" in self._attributes: + state_attributes[ATTR_PRINTER_STATE_MESSAGE] = self._attributes[ + "printer-state-message" + ] + + if "printer-state-reasons" in self._attributes: + state_attributes[ATTR_PRINTER_STATE_REASON] = self._attributes[ + "printer-state-reasons" + ] + + if "printer-uri-supported" in self._attributes: + state_attributes[ATTR_PRINTER_URI_SUPPORTED] = self._attributes[ + "printer-uri-supported" + ] + + return state_attributes + + def update(self): + """Fetch new state data for the sensor.""" + self.data.update() + self._attributes = self.data.attributes.get(self._name) + self._available = self.data.available + + +class MarkerSensor(Entity): + """Implementation of the MarkerSensor. + + This sensor represents the percentage of ink or toner. + """ + + def __init__(self, data, printer, name, is_cups): + """Initialize the sensor.""" + self.data = data + self._name = name + self._printer = printer + self._index = data.attributes[printer]["marker-names"].index(name) + self._is_cups = is_cups + self._attributes = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def icon(self): + """Return the icon to use in the frontend.""" + return ICON_MARKER + + @property + def state(self): + """Return the state of the sensor.""" + if self._attributes is None: + return None + + return self._attributes[self._printer]["marker-levels"][self._index] + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return "%" + + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + if self._attributes is None: + return None + + high_level = self._attributes[self._printer].get("marker-high-levels") + if isinstance(high_level, list): + high_level = high_level[self._index] + + low_level = self._attributes[self._printer].get("marker-low-levels") + if isinstance(low_level, list): + low_level = low_level[self._index] + + marker_types = self._attributes[self._printer]["marker-types"] + if isinstance(marker_types, list): + marker_types = marker_types[self._index] + + if self._is_cups: + printer_name = self._printer + else: + printer_name = self._attributes[self._printer]["printer-make-and-model"] + + return { + ATTR_MARKER_HIGH_LEVEL: high_level, + ATTR_MARKER_LOW_LEVEL: low_level, + ATTR_MARKER_TYPE: marker_types, + ATTR_PRINTER_NAME: printer_name, + } + + def update(self): + """Update the state of the sensor.""" + # Data fetching is done by CupsSensor/IPPSensor + self._attributes = self.data.attributes + + +class CupsData: + """Get the latest data from CUPS and update the state.""" + + def __init__(self, host, port, ipp_printers): + """Initialize the data object.""" + self._host = host + self._port = port + self._ipp_printers = ipp_printers + self.is_cups = ipp_printers is None + self.printers = None + self.attributes = {} + self.available = False + + def update(self): + """Get the latest data from CUPS.""" + cups = importlib.import_module("cups") + + try: + conn = cups.Connection(host=self._host, port=self._port) + if self.is_cups: + self.printers = conn.getPrinters() + for printer in self.printers: + self.attributes[printer] = conn.getPrinterAttributes(name=printer) + else: + for ipp_printer in self._ipp_printers: + self.attributes[ipp_printer] = conn.getPrinterAttributes( + uri=f"ipp://{self._host}:{self._port}/{ipp_printer}" + ) + + self.available = True + except RuntimeError: + self.available = False diff --git a/homeassistant/components/currencylayer/__init__.py b/homeassistant/components/currencylayer/__init__.py new file mode 100644 index 000000000..237392ec1 --- /dev/null +++ b/homeassistant/components/currencylayer/__init__.py @@ -0,0 +1 @@ +"""The currencylayer component.""" diff --git a/homeassistant/components/currencylayer/manifest.json b/homeassistant/components/currencylayer/manifest.json new file mode 100644 index 000000000..2db35dead --- /dev/null +++ b/homeassistant/components/currencylayer/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "currencylayer", + "name": "Currencylayer", + "documentation": "https://www.home-assistant.io/integrations/currencylayer", + "requirements": [], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/currencylayer/sensor.py b/homeassistant/components/currencylayer/sensor.py new file mode 100644 index 000000000..cbad07c02 --- /dev/null +++ b/homeassistant/components/currencylayer/sensor.py @@ -0,0 +1,120 @@ +"""Support for currencylayer.com exchange rates service.""" +from datetime import timedelta +import logging + +import requests +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + ATTR_ATTRIBUTION, + CONF_API_KEY, + CONF_BASE, + CONF_NAME, + CONF_QUOTE, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) +_RESOURCE = "http://apilayer.net/api/live" + +ATTRIBUTION = "Data provided by currencylayer.com" + +DEFAULT_BASE = "USD" +DEFAULT_NAME = "CurrencyLayer Sensor" + +ICON = "mdi:currency" + +SCAN_INTERVAL = timedelta(hours=2) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_QUOTE): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_BASE, default=DEFAULT_BASE): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Currencylayer sensor.""" + base = config.get(CONF_BASE) + api_key = config.get(CONF_API_KEY) + parameters = {"source": base, "access_key": api_key, "format": 1} + + rest = CurrencylayerData(_RESOURCE, parameters) + + response = requests.get(_RESOURCE, params=parameters, timeout=10) + sensors = [] + for variable in config["quote"]: + sensors.append(CurrencylayerSensor(rest, base, variable)) + if "error" in response.json(): + return False + add_entities(sensors, True) + + +class CurrencylayerSensor(Entity): + """Implementing the Currencylayer sensor.""" + + def __init__(self, rest, base, quote): + """Initialize the sensor.""" + self.rest = rest + self._quote = quote + self._base = base + self._state = None + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._quote + + @property + def name(self): + """Return the name of the sensor.""" + return self._base + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return ICON + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + return {ATTR_ATTRIBUTION: ATTRIBUTION} + + def update(self): + """Update current date.""" + self.rest.update() + value = self.rest.data + if value is not None: + self._state = round(value[f"{self._base}{self._quote}"], 4) + + +class CurrencylayerData: + """Get data from Currencylayer.org.""" + + def __init__(self, resource, parameters): + """Initialize the data object.""" + self._resource = resource + self._parameters = parameters + self.data = None + + def update(self): + """Get the latest data from Currencylayer.""" + try: + result = requests.get(self._resource, params=self._parameters, timeout=10) + if "error" in result.json(): + raise ValueError(result.json()["error"]["info"]) + self.data = result.json()["quotes"] + _LOGGER.debug("Currencylayer data updated: %s", result.json()["timestamp"]) + except ValueError as err: + _LOGGER.error("Check Currencylayer API %s", err.args) + self.data = None diff --git a/homeassistant/components/daikin.py b/homeassistant/components/daikin.py deleted file mode 100644 index 8983ecf82..000000000 --- a/homeassistant/components/daikin.py +++ /dev/null @@ -1,138 +0,0 @@ -""" -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: - """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/daikin/.translations/bg.json b/homeassistant/components/daikin/.translations/bg.json new file mode 100644 index 000000000..b0ddcbf49 --- /dev/null +++ b/homeassistant/components/daikin/.translations/bg.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e", + "device_fail": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430 \u043f\u0440\u0438 \u0441\u044a\u0437\u0434\u0430\u0432\u0430\u043d\u0435\u0442\u043e \u043d\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e.", + "device_timeout": "\u041d\u0435\u0432\u044a\u0437\u043c\u043e\u0436\u043d\u043e\u0441\u0442 \u0437\u0430 \u0441\u0432\u043e\u0435\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u0441 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e." + }, + "step": { + "user": { + "data": { + "host": "\u0410\u0434\u0440\u0435\u0441" + }, + "description": "\u0412\u044a\u0432\u0435\u0434\u0435\u0442\u0435 IP \u0430\u0434\u0440\u0435\u0441 \u043d\u0430 \u0432\u0430\u0448\u0438\u044f \u043a\u043b\u0438\u043c\u0430\u0442\u0438\u043a Daikin.", + "title": "\u041a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u043a\u043b\u0438\u043c\u0430\u0442\u0438\u043a Daikin" + } + }, + "title": "\u041a\u043b\u0438\u043c\u0430\u0442\u0438\u043a Daikin" + } +} \ No newline at end of file diff --git a/homeassistant/components/daikin/.translations/ca.json b/homeassistant/components/daikin/.translations/ca.json new file mode 100644 index 000000000..2fa60015c --- /dev/null +++ b/homeassistant/components/daikin/.translations/ca.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat", + "device_fail": "S'ha produ\u00eft un error inesperat al crear el dispositiu.", + "device_timeout": "S'ha acabat el temps d'espera en la connexi\u00f3 amb el dispositiu." + }, + "step": { + "user": { + "data": { + "host": "Amfitri\u00f3" + }, + "description": "Introdueix l'adre\u00e7a IP del teu Daikin AC.", + "title": "Configuraci\u00f3 de Daikin AC" + } + }, + "title": "Daikin AC" + } +} \ No newline at end of file diff --git a/homeassistant/components/daikin/.translations/da.json b/homeassistant/components/daikin/.translations/da.json new file mode 100644 index 000000000..856bb1445 --- /dev/null +++ b/homeassistant/components/daikin/.translations/da.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Enheden er allerede konfigureret", + "device_fail": "Uventet fejl ved oprettelse af enhed.", + "device_timeout": "Timeout ved tilslutning til enheden." + }, + "step": { + "user": { + "data": { + "host": "V\u00e6rt" + }, + "description": "Indtast IP-adresse p\u00e5 dit Daikin AC.", + "title": "Konfigurer Daikin AC" + } + }, + "title": "Daikin AC" + } +} \ No newline at end of file diff --git a/homeassistant/components/daikin/.translations/de.json b/homeassistant/components/daikin/.translations/de.json new file mode 100644 index 000000000..0a09c7b5c --- /dev/null +++ b/homeassistant/components/daikin/.translations/de.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "device_fail": "Unerwarteter Fehler beim Erstellen des Ger\u00e4ts.", + "device_timeout": "Zeit\u00fcberschreitung beim Verbinden mit dem Ger\u00e4t." + }, + "step": { + "user": { + "data": { + "host": "Host" + }, + "description": "Geben Sie die IP-Adresse Ihrer Daikin AC ein.", + "title": "Daikin AC konfigurieren" + } + }, + "title": "Daikin AC" + } +} \ No newline at end of file diff --git a/homeassistant/components/daikin/.translations/en.json b/homeassistant/components/daikin/.translations/en.json new file mode 100644 index 000000000..1605e1dc8 --- /dev/null +++ b/homeassistant/components/daikin/.translations/en.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "device_fail": "Unexpected error creating device.", + "device_timeout": "Timeout connecting to the device." + }, + "step": { + "user": { + "data": { + "host": "Host" + }, + "description": "Enter IP address of your Daikin AC.", + "title": "Configure Daikin AC" + } + }, + "title": "Daikin AC" + } +} \ No newline at end of file diff --git a/homeassistant/components/daikin/.translations/es-419.json b/homeassistant/components/daikin/.translations/es-419.json new file mode 100644 index 000000000..6fa2b664a --- /dev/null +++ b/homeassistant/components/daikin/.translations/es-419.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado", + "device_fail": "Error inesperado al crear el dispositivo.", + "device_timeout": "Tiempo de espera de conexi\u00f3n al dispositivo." + }, + "step": { + "user": { + "data": { + "host": "Host" + }, + "description": "Introduzca la direcci\u00f3n IP de su Daikin AC.", + "title": "Configurar Daikin AC" + } + }, + "title": "Daikin AC" + } +} \ No newline at end of file diff --git a/homeassistant/components/daikin/.translations/es.json b/homeassistant/components/daikin/.translations/es.json new file mode 100644 index 000000000..d3a733a3f --- /dev/null +++ b/homeassistant/components/daikin/.translations/es.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado", + "device_fail": "Error inesperado al crear el dispositivo.", + "device_timeout": "Tiempo de espera agotado al conectar con el dispositivo." + }, + "step": { + "user": { + "data": { + "host": "Host" + }, + "description": "Introduce la IP de tu aire acondicionado Daikin", + "title": "Configurar aire acondicionado Daikin" + } + }, + "title": "Aire acondicionado Daikin" + } +} \ No newline at end of file diff --git a/homeassistant/components/daikin/.translations/fr.json b/homeassistant/components/daikin/.translations/fr.json new file mode 100644 index 000000000..cfd4b7442 --- /dev/null +++ b/homeassistant/components/daikin/.translations/fr.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "device_fail": "Erreur inattendue lors de la cr\u00e9ation du p\u00e9riph\u00e9rique.", + "device_timeout": "D\u00e9lai de connexion au p\u00e9riph\u00e9rique expir\u00e9." + }, + "step": { + "user": { + "data": { + "host": "H\u00f4te" + }, + "description": "Entrez l'adresse IP de votre Daikin AC.", + "title": "Configurer Daikin AC" + } + }, + "title": "Daikin AC" + } +} \ No newline at end of file diff --git a/homeassistant/components/daikin/.translations/hu.json b/homeassistant/components/daikin/.translations/hu.json new file mode 100644 index 000000000..f433a6215 --- /dev/null +++ b/homeassistant/components/daikin/.translations/hu.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6zt m\u00e1r konfigur\u00e1ltuk", + "device_fail": "Az eszk\u00f6z l\u00e9trehoz\u00e1sakor v\u00e1ratlan hiba l\u00e9pett fel.", + "device_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s a k\u00e9sz\u00fcl\u00e9k csatlakoz\u00e1sakor." + }, + "step": { + "user": { + "data": { + "host": "Hoszt" + }, + "description": "Add meg a Daikin l\u00e9gkond\u00edcion\u00e1l\u00f3 IP-c\u00edm\u00e9t.", + "title": "A Daikin l\u00e9gkond\u00edcion\u00e1l\u00f3 konfigur\u00e1l\u00e1sa" + } + }, + "title": "Daikin L\u00e9gkond\u00edci\u00f3n\u00e1l\u00f3" + } +} \ No newline at end of file diff --git a/homeassistant/components/daikin/.translations/it.json b/homeassistant/components/daikin/.translations/it.json new file mode 100644 index 000000000..0b8151d23 --- /dev/null +++ b/homeassistant/components/daikin/.translations/it.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "device_fail": "Errore inatteso durante la creazione del dispositivo.", + "device_timeout": "Tempo scaduto per la connessione al dispositivo." + }, + "step": { + "user": { + "data": { + "host": "Host" + }, + "description": "Inserisci l'indirizzo IP del tuo Daikin AC.", + "title": "Configura Daikin AC" + } + }, + "title": "Daikin AC" + } +} \ No newline at end of file diff --git a/homeassistant/components/daikin/.translations/ko.json b/homeassistant/components/daikin/.translations/ko.json new file mode 100644 index 000000000..4b1d1bd86 --- /dev/null +++ b/homeassistant/components/daikin/.translations/ko.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "device_fail": "\uae30\uae30\ub97c \uad6c\uc131\ud558\ub294 \ub3c4\uc911 \uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4.", + "device_timeout": "\uae30\uae30 \uc5f0\uacb0 \uc2dc\uac04\uc774 \ucd08\uacfc\ud588\uc2b5\ub2c8\ub2e4." + }, + "step": { + "user": { + "data": { + "host": "\ud638\uc2a4\ud2b8" + }, + "description": "\ub2e4\uc774\ud0a8 \uc5d0\uc5b4\ucee8\uc758 IP \uc8fc\uc18c\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694.", + "title": "\ub2e4\uc774\ud0a8 \uc5d0\uc5b4\ucee8 \uad6c\uc131" + } + }, + "title": "\ub2e4\uc774\ud0a8 \uc5d0\uc5b4\ucee8" + } +} \ No newline at end of file diff --git a/homeassistant/components/daikin/.translations/lb.json b/homeassistant/components/daikin/.translations/lb.json new file mode 100644 index 000000000..cdf98f5e5 --- /dev/null +++ b/homeassistant/components/daikin/.translations/lb.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Apparat ass scho konfigur\u00e9iert", + "device_fail": "Onerwaarte Feeler beim erstelle vum Apparat.", + "device_timeout": "Z\u00e4it Iwwerschreidung beim verbannen mam Apparat." + }, + "step": { + "user": { + "data": { + "host": "Apparat" + }, + "description": "Gitt d'IP Adresse vum Daikin AC an:", + "title": "Daikin AC konfigur\u00e9ieren" + } + }, + "title": "Daikin AC" + } +} \ No newline at end of file diff --git a/homeassistant/components/daikin/.translations/nl.json b/homeassistant/components/daikin/.translations/nl.json new file mode 100644 index 000000000..683bb61dd --- /dev/null +++ b/homeassistant/components/daikin/.translations/nl.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd", + "device_fail": "Onverwachte fout bij het aanmaken van een apparaat.", + "device_timeout": "Time-out voor verbinding met het apparaat." + }, + "step": { + "user": { + "data": { + "host": "Host" + }, + "description": "Voer het IP-adres van uw Daikin AC in.", + "title": "Daikin AC instellen" + } + }, + "title": "Daikin AC" + } +} \ No newline at end of file diff --git a/homeassistant/components/daikin/.translations/nn.json b/homeassistant/components/daikin/.translations/nn.json new file mode 100644 index 000000000..67d4f8526 --- /dev/null +++ b/homeassistant/components/daikin/.translations/nn.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Daikin AC" + } +} \ No newline at end of file diff --git a/homeassistant/components/daikin/.translations/no.json b/homeassistant/components/daikin/.translations/no.json new file mode 100644 index 000000000..806106c5e --- /dev/null +++ b/homeassistant/components/daikin/.translations/no.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert", + "device_fail": "Uventet feil under oppretting av enheten.", + "device_timeout": "Tidsavbrudd for tilkobling til enheten." + }, + "step": { + "user": { + "data": { + "host": "Vert" + }, + "description": "Angi IP-adressen til din Daikin AC.", + "title": "Konfigurer Daikin AC" + } + }, + "title": "Daikin AC" + } +} \ No newline at end of file diff --git a/homeassistant/components/daikin/.translations/pl.json b/homeassistant/components/daikin/.translations/pl.json new file mode 100644 index 000000000..5d5448a93 --- /dev/null +++ b/homeassistant/components/daikin/.translations/pl.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", + "device_fail": "Nieoczekiwany b\u0142\u0105d tworzenia urz\u0105dzenia.", + "device_timeout": "Przekroczono limit czasu \u0142\u0105czenia z urz\u0105dzeniem." + }, + "step": { + "user": { + "data": { + "host": "Host" + }, + "description": "Wprowad\u017a adres IP Daikin AC.", + "title": "Konfiguracja Daikin AC" + } + }, + "title": "Daikin AC" + } +} \ No newline at end of file diff --git a/homeassistant/components/daikin/.translations/pt-BR.json b/homeassistant/components/daikin/.translations/pt-BR.json new file mode 100644 index 000000000..bbdf68ed7 --- /dev/null +++ b/homeassistant/components/daikin/.translations/pt-BR.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "O dispositivo j\u00e1 est\u00e1 configurado", + "device_fail": "Erro inesperado ao criar dispositivo.", + "device_timeout": "Excedido tempo limite conectando ao dispositivo" + }, + "step": { + "user": { + "data": { + "host": "Host" + }, + "description": "Digite o endere\u00e7o IP do seu AC Daikin.", + "title": "Configurar o AC Daikin" + } + }, + "title": "AC Daikin" + } +} \ No newline at end of file diff --git a/homeassistant/components/daikin/.translations/pt.json b/homeassistant/components/daikin/.translations/pt.json new file mode 100644 index 000000000..34b4c86e7 --- /dev/null +++ b/homeassistant/components/daikin/.translations/pt.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "O dispositivo j\u00e1 est\u00e1 configurado", + "device_fail": "Erro inesperado ao criar dispositivo.", + "device_timeout": "Tempo excedido a tentar ligar ao dispositivo." + }, + "step": { + "user": { + "data": { + "host": "Servidor" + }, + "description": "Introduza o endere\u00e7o IP do seu Daikin AC.", + "title": "Configurar o Daikin AC" + } + }, + "title": "Daikin AC" + } +} \ No newline at end of file diff --git a/homeassistant/components/daikin/.translations/ru.json b/homeassistant/components/daikin/.translations/ru.json new file mode 100644 index 000000000..00a517f70 --- /dev/null +++ b/homeassistant/components/daikin/.translations/ru.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.", + "device_fail": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430 \u043f\u0440\u0438 \u0441\u043e\u0437\u0434\u0430\u043d\u0438\u0438 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430.", + "device_timeout": "\u0418\u0441\u0442\u0435\u043a\u043b\u043e \u0432\u0440\u0435\u043c\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f \u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443." + }, + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 IP-\u0430\u0434\u0440\u0435\u0441 \u0412\u0430\u0448\u0435\u0433\u043e Daikin AC.", + "title": "Daikin AC" + } + }, + "title": "Daikin AC" + } +} \ No newline at end of file diff --git a/homeassistant/components/daikin/.translations/sl.json b/homeassistant/components/daikin/.translations/sl.json new file mode 100644 index 000000000..088b354fb --- /dev/null +++ b/homeassistant/components/daikin/.translations/sl.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Naprava je \u017ee konfigurirana", + "device_fail": "Nepri\u010dakovana napaka pri ustvarjanju naprave.", + "device_timeout": "\u010casovna omejitev za priklop na napravo je potekla." + }, + "step": { + "user": { + "data": { + "host": "Gostitelj" + }, + "description": "Vnesite naslov IP va\u0161e Daikin klime.", + "title": "Nastavite Daikin klimo" + } + }, + "title": "Daikin AC" + } +} \ No newline at end of file diff --git a/homeassistant/components/daikin/.translations/sv.json b/homeassistant/components/daikin/.translations/sv.json new file mode 100644 index 000000000..0f1247197 --- /dev/null +++ b/homeassistant/components/daikin/.translations/sv.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten \u00e4r redan konfigurerad", + "device_fail": "Ov\u00e4ntat fel vid skapande av enhet.", + "device_timeout": "Timeout f\u00f6r anslutning till enheten." + }, + "step": { + "user": { + "data": { + "host": "V\u00e4rddatorn" + }, + "description": "Ange IP-adressen f\u00f6r din Daikin AC.", + "title": "Konfigurera Daikin AC" + } + }, + "title": "Daikin AC" + } +} \ No newline at end of file diff --git a/homeassistant/components/daikin/.translations/th.json b/homeassistant/components/daikin/.translations/th.json new file mode 100644 index 000000000..8f0fdda37 --- /dev/null +++ b/homeassistant/components/daikin/.translations/th.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "\u0e42\u0e2e\u0e2a\u0e15\u0e4c" + } + } + }, + "title": "Daikin AC" + } +} \ No newline at end of file diff --git a/homeassistant/components/daikin/.translations/zh-Hans.json b/homeassistant/components/daikin/.translations/zh-Hans.json new file mode 100644 index 000000000..5123dc236 --- /dev/null +++ b/homeassistant/components/daikin/.translations/zh-Hans.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "\u8bbe\u5907\u5df2\u914d\u7f6e\u5b8c\u6210", + "device_fail": "\u521b\u5efa\u8bbe\u5907\u65f6\u51fa\u73b0\u610f\u5916\u9519\u8bef\u3002", + "device_timeout": "\u8fde\u63a5\u8bbe\u5907\u8d85\u65f6\u3002" + }, + "step": { + "user": { + "data": { + "host": "\u4e3b\u673a" + }, + "description": "\u8f93\u5165\u60a8\u7684 Daikin \u7a7a\u8c03\u7684 IP \u5730\u5740\u3002", + "title": "\u914d\u7f6e Daikin \u7a7a\u8c03" + } + }, + "title": "Daikin \u7a7a\u8c03" + } +} \ No newline at end of file diff --git a/homeassistant/components/daikin/.translations/zh-Hant.json b/homeassistant/components/daikin/.translations/zh-Hant.json new file mode 100644 index 000000000..457b7d1b8 --- /dev/null +++ b/homeassistant/components/daikin/.translations/zh-Hant.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "\u8a2d\u5099\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "device_fail": "\u5275\u5efa\u8a2d\u5099\u6642\u767c\u751f\u672a\u77e5\u932f\u8aa4\u3002", + "device_timeout": "\u9023\u7dda\u81f3\u8a2d\u5099\u903e\u6642\u3002" + }, + "step": { + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef" + }, + "description": "\u8f38\u5165\u60a8\u7684\u5927\u91d1\u7a7a\u8abf IP \u4f4d\u5740\u3002", + "title": "\u8a2d\u5b9a\u5927\u91d1\u7a7a\u8abf" + } + }, + "title": "\u5927\u91d1\u7a7a\u8abf\uff08Daikin AC\uff09" + } +} \ No newline at end of file diff --git a/homeassistant/components/daikin/__init__.py b/homeassistant/components/daikin/__init__.py new file mode 100644 index 000000000..209bf71e5 --- /dev/null +++ b/homeassistant/components/daikin/__init__.py @@ -0,0 +1,153 @@ +"""Platform for the Daikin AC.""" +import asyncio +from datetime import timedelta +import logging + +from aiohttp import ClientConnectionError +from async_timeout import timeout +from pydaikin.appliance import Appliance +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_HOST, CONF_HOSTS +from homeassistant.exceptions import ConfigEntryNotReady +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.util import Throttle + +from . import config_flow # noqa: F401 + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "daikin" + +PARALLEL_UPDATES = 0 +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) + +COMPONENT_TYPES = ["climate", "sensor", "switch"] + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + {vol.Optional(CONF_HOSTS, default=[]): vol.All(cv.ensure_list, [cv.string])} + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, config): + """Establish connection with Daikin.""" + if DOMAIN not in config: + return True + + hosts = config[DOMAIN].get(CONF_HOSTS) + if not hosts: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT} + ) + ) + for host in hosts: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data={CONF_HOST: host} + ) + ) + return True + + +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): + """Establish connection with Daikin.""" + conf = entry.data + daikin_api = await daikin_api_setup(hass, conf[CONF_HOST]) + if not daikin_api: + return False + hass.data.setdefault(DOMAIN, {}).update({entry.entry_id: daikin_api}) + for component in COMPONENT_TYPES: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + return True + + +async def async_unload_entry(hass, config_entry): + """Unload a config entry.""" + await asyncio.wait( + [ + hass.config_entries.async_forward_entry_unload(config_entry, component) + for component in COMPONENT_TYPES + ] + ) + hass.data[DOMAIN].pop(config_entry.entry_id) + if not hass.data[DOMAIN]: + hass.data.pop(DOMAIN) + return True + + +async def daikin_api_setup(hass, host): + """Create a Daikin instance only once.""" + + session = hass.helpers.aiohttp_client.async_get_clientsession() + try: + with timeout(10): + device = Appliance(host, session) + await device.init() + except asyncio.TimeoutError: + _LOGGER.debug("Connection to %s timed out", host) + raise ConfigEntryNotReady + except ClientConnectionError: + _LOGGER.debug("ClientConnectionError to %s", host) + raise ConfigEntryNotReady + except Exception: # pylint: disable=broad-except + _LOGGER.error("Unexpected error creating device %s", host) + return None + + api = DaikinApi(device) + + return api + + +class DaikinApi: + """Keep the Daikin instance in one place and centralize the update.""" + + def __init__(self, device): + """Initialize the Daikin Handle.""" + self.device = device + self.name = device.values["name"] + self.ip_address = device.ip + self._available = True + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + async def async_update(self, **kwargs): + """Pull the latest data from Daikin.""" + try: + await self.device.update_status() + self._available = True + except ClientConnectionError: + _LOGGER.warning("Connection failed for %s", self.ip_address) + self._available = False + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._available + + @property + def mac(self): + """Return mac-address of device.""" + return self.device.values.get(CONNECTION_NETWORK_MAC) + + @property + def device_info(self): + """Return a device description for device registry.""" + info = self.device.values + return { + "connections": {(CONNECTION_NETWORK_MAC, self.mac)}, + "identifieres": self.mac, + "manufacturer": "Daikin", + "model": info.get("model"), + "name": info.get("name"), + "sw_version": info.get("ver").replace("_", "."), + } diff --git a/homeassistant/components/daikin/climate.py b/homeassistant/components/daikin/climate.py new file mode 100644 index 000000000..d46ea26d4 --- /dev/null +++ b/homeassistant/components/daikin/climate.py @@ -0,0 +1,268 @@ +"""Support for the Daikin HVAC.""" +import logging + +from pydaikin import appliance +import voluptuous as vol + +from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateDevice +from homeassistant.components.climate.const import ( + ATTR_FAN_MODE, + ATTR_HVAC_MODE, + ATTR_PRESET_MODE, + ATTR_SWING_MODE, + HVAC_MODE_COOL, + HVAC_MODE_DRY, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_OFF, + PRESET_AWAY, + PRESET_NONE, + SUPPORT_FAN_MODE, + SUPPORT_PRESET_MODE, + SUPPORT_SWING_MODE, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.const import ATTR_TEMPERATURE, CONF_HOST, CONF_NAME, TEMP_CELSIUS +import homeassistant.helpers.config_validation as cv + +from . import DOMAIN as DAIKIN_DOMAIN +from .const import ( + ATTR_INSIDE_TEMPERATURE, + ATTR_OUTSIDE_TEMPERATURE, + ATTR_STATE_OFF, + ATTR_STATE_ON, + ATTR_TARGET_TEMPERATURE, +) + +_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 = { + HVAC_MODE_FAN_ONLY: "fan", + HVAC_MODE_DRY: "dry", + HVAC_MODE_COOL: "cool", + HVAC_MODE_HEAT: "hot", + HVAC_MODE_HEAT_COOL: "auto", + HVAC_MODE_OFF: "off", +} + +DAIKIN_TO_HA_STATE = { + "fan": HVAC_MODE_FAN_ONLY, + "dry": HVAC_MODE_DRY, + "cool": HVAC_MODE_COOL, + "hot": HVAC_MODE_HEAT, + "auto": HVAC_MODE_HEAT_COOL, + "off": HVAC_MODE_OFF, +} + +HA_PRESET_TO_DAIKIN = {PRESET_AWAY: "on", PRESET_NONE: "off"} + +HA_ATTR_TO_DAIKIN = { + ATTR_PRESET_MODE: "en_hol", + ATTR_HVAC_MODE: "mode", + ATTR_FAN_MODE: "f_rate", + ATTR_SWING_MODE: "f_dir", + ATTR_INSIDE_TEMPERATURE: "htemp", + ATTR_OUTSIDE_TEMPERATURE: "otemp", + ATTR_TARGET_TEMPERATURE: "stemp", +} + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Old way of setting up the Daikin HVAC platform. + + Can only be called when a user accidentally mentions the platform in their + config. But even in that case it would have been ignored. + """ + pass + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up Daikin climate based on config_entry.""" + daikin_api = hass.data[DAIKIN_DOMAIN].get(entry.entry_id) + async_add_entities([DaikinClimate(daikin_api)]) + + +class DaikinClimate(ClimateDevice): + """Representation of a Daikin HVAC.""" + + def __init__(self, api): + """Initialize the climate device.""" + + self._api = api + self._list = { + ATTR_HVAC_MODE: list(HA_STATE_TO_DAIKIN), + ATTR_FAN_MODE: self._api.device.fan_rate, + ATTR_SWING_MODE: list( + map( + str.title, + appliance.daikin_values(HA_ATTR_TO_DAIKIN[ATTR_SWING_MODE]), + ) + ), + } + + self._supported_features = SUPPORT_TARGET_TEMPERATURE + + if self._api.device.support_away_mode: + self._supported_features |= SUPPORT_PRESET_MODE + + if self._api.device.support_fan_rate: + self._supported_features |= SUPPORT_FAN_MODE + + if self._api.device.support_swing_mode: + self._supported_features |= SUPPORT_SWING_MODE + + async def _set(self, settings): + """Set device settings using API.""" + values = {} + + for attr in [ATTR_TEMPERATURE, ATTR_FAN_MODE, ATTR_SWING_MODE, ATTR_HVAC_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 attr == ATTR_HVAC_MODE: + values[daikin_attr] = HA_STATE_TO_DAIKIN[value] + elif value 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[HA_ATTR_TO_DAIKIN[ATTR_TARGET_TEMPERATURE]] = str(int(value)) + except ValueError: + _LOGGER.error("Invalid temperature %s", value) + + if values: + await 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 unique_id(self): + """Return a unique ID.""" + return self._api.mac + + @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._api.device.inside_temperature + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._api.device.target_temperature + + @property + def target_temperature_step(self): + """Return the supported step of target temperature.""" + return 1 + + async def async_set_temperature(self, **kwargs): + """Set new target temperature.""" + await self._set(kwargs) + + @property + def hvac_mode(self): + """Return current operation ie. heat, cool, idle.""" + daikin_mode = self._api.device.represent(HA_ATTR_TO_DAIKIN[ATTR_HVAC_MODE])[1] + return DAIKIN_TO_HA_STATE.get(daikin_mode, HVAC_MODE_HEAT_COOL) + + @property + def hvac_modes(self): + """Return the list of available operation modes.""" + return self._list.get(ATTR_HVAC_MODE) + + async def async_set_hvac_mode(self, hvac_mode): + """Set HVAC mode.""" + await self._set({ATTR_HVAC_MODE: hvac_mode}) + + @property + def fan_mode(self): + """Return the fan setting.""" + return self._api.device.represent(HA_ATTR_TO_DAIKIN[ATTR_FAN_MODE])[1].title() + + async def async_set_fan_mode(self, fan_mode): + """Set fan mode.""" + await self._set({ATTR_FAN_MODE: fan_mode}) + + @property + def fan_modes(self): + """List of available fan modes.""" + return self._list.get(ATTR_FAN_MODE) + + @property + def swing_mode(self): + """Return the fan setting.""" + return self._api.device.represent(HA_ATTR_TO_DAIKIN[ATTR_SWING_MODE])[1].title() + + async def async_set_swing_mode(self, swing_mode): + """Set new target temperature.""" + await self._set({ATTR_SWING_MODE: swing_mode}) + + @property + def swing_modes(self): + """List of available swing modes.""" + return self._list.get(ATTR_SWING_MODE) + + @property + def preset_mode(self): + """Return the preset_mode.""" + if ( + self._api.device.represent(HA_ATTR_TO_DAIKIN[ATTR_PRESET_MODE])[1] + == HA_PRESET_TO_DAIKIN[PRESET_AWAY] + ): + return PRESET_AWAY + return PRESET_NONE + + async def async_set_preset_mode(self, preset_mode): + """Set preset mode.""" + if preset_mode == PRESET_AWAY: + await self._api.device.set_holiday(ATTR_STATE_ON) + else: + await self._api.device.set_holiday(ATTR_STATE_OFF) + + @property + def preset_modes(self): + """List of available preset modes.""" + return list(HA_PRESET_TO_DAIKIN) + + async def async_update(self): + """Retrieve latest state.""" + await self._api.async_update() + + async def async_turn_on(self): + """Turn device on.""" + await self._api.device.set({}) + + async def async_turn_off(self): + """Turn device off.""" + await self._api.device.set( + {HA_ATTR_TO_DAIKIN[ATTR_HVAC_MODE]: HA_STATE_TO_DAIKIN[HVAC_MODE_OFF]} + ) + + @property + def device_info(self): + """Return a device description for device registry.""" + return self._api.device_info diff --git a/homeassistant/components/daikin/config_flow.py b/homeassistant/components/daikin/config_flow.py new file mode 100644 index 000000000..bd90a87db --- /dev/null +++ b/homeassistant/components/daikin/config_flow.py @@ -0,0 +1,73 @@ +"""Config flow for the Daikin platform.""" +import asyncio +import logging + +from aiohttp import ClientError +from async_timeout import timeout +from pydaikin.appliance import Appliance +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST + +from .const import KEY_IP, KEY_MAC + +_LOGGER = logging.getLogger(__name__) + + +@config_entries.HANDLERS.register("daikin") +class FlowHandler(config_entries.ConfigFlow): + """Handle a config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + async def _create_entry(self, host, mac): + """Register new entry.""" + # Check if mac already is registered + for entry in self._async_current_entries(): + if entry.data[KEY_MAC] == mac: + return self.async_abort(reason="already_configured") + + return self.async_create_entry(title=host, data={CONF_HOST: host, KEY_MAC: mac}) + + async def _create_device(self, host): + """Create device.""" + + try: + device = Appliance( + host, self.hass.helpers.aiohttp_client.async_get_clientsession() + ) + with timeout(10): + await device.init() + except asyncio.TimeoutError: + return self.async_abort(reason="device_timeout") + except ClientError: + _LOGGER.exception("ClientError") + return self.async_abort(reason="device_fail") + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected error creating device") + return self.async_abort(reason="device_fail") + + mac = device.values.get("mac") + return await self._create_entry(host, mac) + + async def async_step_user(self, user_input=None): + """User initiated config flow.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=vol.Schema({vol.Required(CONF_HOST): str}) + ) + return await self._create_device(user_input[CONF_HOST]) + + async def async_step_import(self, user_input): + """Import a config entry.""" + host = user_input.get(CONF_HOST) + if not host: + return await self.async_step_user() + return await self._create_device(host) + + async def async_step_discovery(self, user_input): + """Initialize step from discovery.""" + _LOGGER.info("Discovered device: %s", user_input) + return await self._create_entry(user_input[KEY_IP], user_input[KEY_MAC]) diff --git a/homeassistant/components/daikin/const.py b/homeassistant/components/daikin/const.py new file mode 100644 index 000000000..ef24a51be --- /dev/null +++ b/homeassistant/components/daikin/const.py @@ -0,0 +1,27 @@ +"""Constants for Daikin.""" +from homeassistant.const import CONF_ICON, CONF_NAME, CONF_TYPE + +ATTR_TARGET_TEMPERATURE = "target_temperature" +ATTR_INSIDE_TEMPERATURE = "inside_temperature" +ATTR_OUTSIDE_TEMPERATURE = "outside_temperature" + +ATTR_STATE_ON = "on" +ATTR_STATE_OFF = "off" + +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, + }, +} + +KEY_MAC = "mac" +KEY_IP = "ip" diff --git a/homeassistant/components/daikin/manifest.json b/homeassistant/components/daikin/manifest.json new file mode 100644 index 000000000..f81cb1715 --- /dev/null +++ b/homeassistant/components/daikin/manifest.json @@ -0,0 +1,14 @@ +{ + "domain": "daikin", + "name": "Daikin", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/daikin", + "requirements": [ + "pydaikin==1.6.1" + ], + "dependencies": [], + "codeowners": [ + "@fredrike", + "@rofrantz" + ] +} diff --git a/homeassistant/components/daikin/sensor.py b/homeassistant/components/daikin/sensor.py new file mode 100644 index 000000000..f83566e66 --- /dev/null +++ b/homeassistant/components/daikin/sensor.py @@ -0,0 +1,94 @@ +"""Support for Daikin AC sensors.""" +import logging + +from homeassistant.const import CONF_ICON, CONF_NAME, CONF_TYPE +from homeassistant.helpers.entity import Entity +from homeassistant.util.unit_system import UnitSystem + +from . import DOMAIN as DAIKIN_DOMAIN +from .const import ( + ATTR_INSIDE_TEMPERATURE, + ATTR_OUTSIDE_TEMPERATURE, + SENSOR_TYPE_TEMPERATURE, + SENSOR_TYPES, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Old way of setting up the Daikin sensors. + + Can only be called when a user accidentally mentions the platform in their + config. But even in that case it would have been ignored. + """ + pass + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up Daikin climate based on config_entry.""" + daikin_api = hass.data[DAIKIN_DOMAIN].get(entry.entry_id) + sensors = [ATTR_INSIDE_TEMPERATURE] + if daikin_api.device.support_outside_temperature: + sensors.append(ATTR_OUTSIDE_TEMPERATURE) + async_add_entities( + [ + DaikinClimateSensor(daikin_api, sensor, hass.config.units) + for sensor in sensors + ] + ) + + +class DaikinClimateSensor(Entity): + """Representation of a Sensor.""" + + def __init__(self, api, monitored_state, units: UnitSystem, name=None) -> None: + """Initialize the sensor.""" + self._api = api + self._sensor = SENSOR_TYPES.get(monitored_state) + if name is None: + name = "{} {}".format(self._sensor[CONF_NAME], api.name) + + self._name = "{} {}".format(name, monitored_state.replace("_", " ")) + self._device_attribute = monitored_state + + if self._sensor[CONF_TYPE] == SENSOR_TYPE_TEMPERATURE: + self._unit_of_measurement = units.temperature_unit + + @property + def unique_id(self): + """Return a unique ID.""" + return f"{self._api.mac}-{self._device_attribute}" + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return self._sensor[CONF_ICON] + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + if self._device_attribute == ATTR_INSIDE_TEMPERATURE: + return self._api.device.inside_temperature + if self._device_attribute == ATTR_OUTSIDE_TEMPERATURE: + return self._api.device.outside_temperature + return None + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit_of_measurement + + async def async_update(self): + """Retrieve latest state.""" + await self._api.async_update() + + @property + def device_info(self): + """Return a device description for device registry.""" + return self._api.device_info diff --git a/homeassistant/components/daikin/strings.json b/homeassistant/components/daikin/strings.json new file mode 100644 index 000000000..4badc8b72 --- /dev/null +++ b/homeassistant/components/daikin/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "title": "Daikin AC", + "step": { + "user": { + "title": "Configure Daikin AC", + "description": "Enter IP address of your Daikin AC.", + "data": { + "host": "Host" + } + } + }, + "abort": { + "device_timeout": "Timeout connecting to the device.", + "device_fail": "Unexpected error creating device.", + "already_configured": "Device is already configured" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/daikin/switch.py b/homeassistant/components/daikin/switch.py new file mode 100644 index 000000000..4d3b0d3ea --- /dev/null +++ b/homeassistant/components/daikin/switch.py @@ -0,0 +1,79 @@ +"""Support for Daikin AirBase zones.""" +import logging + +from homeassistant.helpers.entity import ToggleEntity + +from . import DOMAIN as DAIKIN_DOMAIN + +_LOGGER = logging.getLogger(__name__) + +ZONE_ICON = "mdi:home-circle" + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Old way of setting up the platform. + + Can only be called when a user accidentally mentions the platform in their + config. But even in that case it would have been ignored. + """ + pass + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up Daikin climate based on config_entry.""" + daikin_api = hass.data[DAIKIN_DOMAIN][entry.entry_id] + zones = daikin_api.device.zones + if zones: + async_add_entities( + [ + DaikinZoneSwitch(daikin_api, zone_id) + for zone_id, zone in enumerate(zones) + if zone != ("-", "0") + ] + ) + + +class DaikinZoneSwitch(ToggleEntity): + """Representation of a zone.""" + + def __init__(self, daikin_api, zone_id): + """Initialize the zone.""" + self._api = daikin_api + self._zone_id = zone_id + + @property + def unique_id(self): + """Return a unique ID.""" + return f"{self._api.mac}-zone{self._zone_id}" + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return ZONE_ICON + + @property + def name(self): + """Return the name of the sensor.""" + return "{} {}".format(self._api.name, self._api.device.zones[self._zone_id][0]) + + @property + def is_on(self): + """Return the state of the sensor.""" + return self._api.device.zones[self._zone_id][1] == "1" + + @property + def device_info(self): + """Return a device description for device registry.""" + return self._api.device_info + + async def async_update(self): + """Retrieve latest state.""" + await self._api.async_update() + + async def async_turn_on(self, **kwargs): + """Turn the zone on.""" + await self._api.device.set_zone(self._zone_id, "1") + + async def async_turn_off(self, **kwargs): + """Turn the zone off.""" + await self._api.device.set_zone(self._zone_id, "0") diff --git a/homeassistant/components/danfoss_air/__init__.py b/homeassistant/components/danfoss_air/__init__.py new file mode 100644 index 000000000..b1dbf890e --- /dev/null +++ b/homeassistant/components/danfoss_air/__init__.py @@ -0,0 +1,96 @@ +"""Support for Danfoss Air HRV.""" +from datetime import timedelta +import logging + +from pydanfossair.commands import ReadCommand +from pydanfossair.danfossclient import DanfossClient +import voluptuous as vol + +from homeassistant.const import CONF_HOST +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv +from homeassistant.util import Throttle + +_LOGGER = logging.getLogger(__name__) + +DANFOSS_AIR_PLATFORMS = ["sensor", "binary_sensor", "switch"] +DOMAIN = "danfoss_air" + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) + +CONFIG_SCHEMA = vol.Schema( + {DOMAIN: vol.Schema({vol.Required(CONF_HOST): cv.string})}, extra=vol.ALLOW_EXTRA +) + + +def setup(hass, config): + """Set up the Danfoss Air component.""" + conf = config[DOMAIN] + + hass.data[DOMAIN] = DanfossAir(conf[CONF_HOST]) + + for platform in DANFOSS_AIR_PLATFORMS: + discovery.load_platform(hass, platform, DOMAIN, {}, config) + + return True + + +class DanfossAir: + """Handle all communication with Danfoss Air CCM unit.""" + + def __init__(self, host): + """Initialize the Danfoss Air CCM connection.""" + self._data = {} + + self._client = DanfossClient(host) + + def get_value(self, item): + """Get value for sensor.""" + return self._data.get(item) + + def update_state(self, command, state_command): + """Send update command to Danfoss Air CCM.""" + self._data[state_command] = self._client.command(command) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Use the data from Danfoss Air API.""" + _LOGGER.debug("Fetching data from Danfoss Air CCM module") + + self._data[ReadCommand.exhaustTemperature] = self._client.command( + ReadCommand.exhaustTemperature + ) + self._data[ReadCommand.outdoorTemperature] = self._client.command( + ReadCommand.outdoorTemperature + ) + self._data[ReadCommand.supplyTemperature] = self._client.command( + ReadCommand.supplyTemperature + ) + self._data[ReadCommand.extractTemperature] = self._client.command( + ReadCommand.extractTemperature + ) + self._data[ReadCommand.humidity] = round( + self._client.command(ReadCommand.humidity), 2 + ) + self._data[ReadCommand.filterPercent] = round( + self._client.command(ReadCommand.filterPercent), 2 + ) + self._data[ReadCommand.bypass] = self._client.command(ReadCommand.bypass) + self._data[ReadCommand.fan_step] = self._client.command(ReadCommand.fan_step) + self._data[ReadCommand.supply_fan_speed] = self._client.command( + ReadCommand.supply_fan_speed + ) + self._data[ReadCommand.exhaust_fan_speed] = self._client.command( + ReadCommand.exhaust_fan_speed + ) + self._data[ReadCommand.away_mode] = self._client.command(ReadCommand.away_mode) + self._data[ReadCommand.boost] = self._client.command(ReadCommand.boost) + self._data[ReadCommand.battery_percent] = self._client.command( + ReadCommand.battery_percent + ) + self._data[ReadCommand.bypass] = self._client.command(ReadCommand.bypass) + self._data[ReadCommand.automatic_bypass] = self._client.command( + ReadCommand.automatic_bypass + ) + + _LOGGER.debug("Done fetching data from Danfoss Air CCM module") diff --git a/homeassistant/components/danfoss_air/binary_sensor.py b/homeassistant/components/danfoss_air/binary_sensor.py new file mode 100644 index 000000000..d5aab8b35 --- /dev/null +++ b/homeassistant/components/danfoss_air/binary_sensor.py @@ -0,0 +1,56 @@ +"""Support for the for Danfoss Air HRV binary sensors.""" +from homeassistant.components.binary_sensor import BinarySensorDevice + +from . import DOMAIN as DANFOSS_AIR_DOMAIN + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the available Danfoss Air sensors etc.""" + from pydanfossair.commands import ReadCommand + + data = hass.data[DANFOSS_AIR_DOMAIN] + + sensors = [ + ["Danfoss Air Bypass Active", ReadCommand.bypass, "opening"], + ["Danfoss Air Away Mode Active", ReadCommand.away_mode, None], + ] + + dev = [] + + for sensor in sensors: + dev.append(DanfossAirBinarySensor(data, sensor[0], sensor[1], sensor[2])) + + add_entities(dev, True) + + +class DanfossAirBinarySensor(BinarySensorDevice): + """Representation of a Danfoss Air binary sensor.""" + + def __init__(self, data, name, sensor_type, device_class): + """Initialize the Danfoss Air binary sensor.""" + self._data = data + self._name = name + self._state = None + self._type = sensor_type + self._device_class = device_class + + @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 device_class(self): + """Type of device class.""" + return self._device_class + + def update(self): + """Fetch new state data for the sensor.""" + self._data.update() + + self._state = self._data.get_value(self._type) diff --git a/homeassistant/components/danfoss_air/manifest.json b/homeassistant/components/danfoss_air/manifest.json new file mode 100644 index 000000000..189b685d4 --- /dev/null +++ b/homeassistant/components/danfoss_air/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "danfoss_air", + "name": "Danfoss air", + "documentation": "https://www.home-assistant.io/integrations/danfoss_air", + "requirements": [ + "pydanfossair==0.1.0" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/danfoss_air/sensor.py b/homeassistant/components/danfoss_air/sensor.py new file mode 100644 index 000000000..ea0002d0a --- /dev/null +++ b/homeassistant/components/danfoss_air/sensor.py @@ -0,0 +1,111 @@ +"""Support for the for Danfoss Air HRV sensors.""" +import logging + +from homeassistant.const import ( + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_TEMPERATURE, + TEMP_CELSIUS, +) +from homeassistant.helpers.entity import Entity + +from . import DOMAIN as DANFOSS_AIR_DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the available Danfoss Air sensors etc.""" + from pydanfossair.commands import ReadCommand + + data = hass.data[DANFOSS_AIR_DOMAIN] + + sensors = [ + [ + "Danfoss Air Exhaust Temperature", + TEMP_CELSIUS, + ReadCommand.exhaustTemperature, + DEVICE_CLASS_TEMPERATURE, + ], + [ + "Danfoss Air Outdoor Temperature", + TEMP_CELSIUS, + ReadCommand.outdoorTemperature, + DEVICE_CLASS_TEMPERATURE, + ], + [ + "Danfoss Air Supply Temperature", + TEMP_CELSIUS, + ReadCommand.supplyTemperature, + DEVICE_CLASS_TEMPERATURE, + ], + [ + "Danfoss Air Extract Temperature", + TEMP_CELSIUS, + ReadCommand.extractTemperature, + DEVICE_CLASS_TEMPERATURE, + ], + ["Danfoss Air Remaining Filter", "%", ReadCommand.filterPercent, None], + ["Danfoss Air Humidity", "%", ReadCommand.humidity, DEVICE_CLASS_HUMIDITY], + ["Danfoss Air Fan Step", "%", ReadCommand.fan_step, None], + ["Dandoss Air Exhaust Fan Speed", "RPM", ReadCommand.exhaust_fan_speed, None], + ["Dandoss Air Supply Fan Speed", "RPM", ReadCommand.supply_fan_speed, None], + [ + "Dandoss Air Dial Battery", + "%", + ReadCommand.battery_percent, + DEVICE_CLASS_BATTERY, + ], + ] + + dev = [] + + for sensor in sensors: + dev.append(DanfossAir(data, sensor[0], sensor[1], sensor[2], sensor[3])) + + add_entities(dev, True) + + +class DanfossAir(Entity): + """Representation of a Sensor.""" + + def __init__(self, data, name, sensor_unit, sensor_type, device_class): + """Initialize the sensor.""" + self._data = data + self._name = name + self._state = None + self._type = sensor_type + self._unit = sensor_unit + self._device_class = device_class + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def device_class(self): + """Return the device class of the sensor.""" + return self._device_class + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit + + def update(self): + """Update the new state of the sensor. + + This is done through the DanfossAir object that does the actual + communication with the Air CCM. + """ + self._data.update() + + self._state = self._data.get_value(self._type) + if self._state is None: + _LOGGER.debug("Could not get data for %s", self._type) diff --git a/homeassistant/components/danfoss_air/switch.py b/homeassistant/components/danfoss_air/switch.py new file mode 100644 index 000000000..8a1e0f9c7 --- /dev/null +++ b/homeassistant/components/danfoss_air/switch.py @@ -0,0 +1,84 @@ +"""Support for the for Danfoss Air HRV sswitches.""" +import logging + +from homeassistant.components.switch import SwitchDevice + +from . import DOMAIN as DANFOSS_AIR_DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Danfoss Air HRV switch platform.""" + from pydanfossair.commands import ReadCommand, UpdateCommand + + data = hass.data[DANFOSS_AIR_DOMAIN] + + switches = [ + [ + "Danfoss Air Boost", + ReadCommand.boost, + UpdateCommand.boost_activate, + UpdateCommand.boost_deactivate, + ], + [ + "Danfoss Air Bypass", + ReadCommand.bypass, + UpdateCommand.bypass_activate, + UpdateCommand.bypass_deactivate, + ], + [ + "Danfoss Air Automatic Bypass", + ReadCommand.automatic_bypass, + UpdateCommand.bypass_activate, + UpdateCommand.bypass_deactivate, + ], + ] + + dev = [] + + for switch in switches: + dev.append(DanfossAir(data, switch[0], switch[1], switch[2], switch[3])) + + add_entities(dev) + + +class DanfossAir(SwitchDevice): + """Representation of a Danfoss Air HRV Switch.""" + + def __init__(self, data, name, state_command, on_command, off_command): + """Initialize the switch.""" + self._data = data + self._name = name + self._state_command = state_command + self._on_command = on_command + self._off_command = off_command + self._state = None + + @property + def name(self): + """Return the name of the switch.""" + return self._name + + @property + def is_on(self): + """Return true if switch is on.""" + return self._state + + def turn_on(self, **kwargs): + """Turn the switch on.""" + _LOGGER.debug("Turning on switch with command %s", self._on_command) + self._data.update_state(self._on_command, self._state_command) + + def turn_off(self, **kwargs): + """Turn the switch off.""" + _LOGGER.debug("Turning off switch with command %s", self._off_command) + self._data.update_state(self._off_command, self._state_command) + + def update(self): + """Update the switch's state.""" + self._data.update() + + self._state = self._data.get_value(self._state_command) + if self._state is None: + _LOGGER.debug("Could not get data for %s", self._state_command) diff --git a/homeassistant/components/darksky/__init__.py b/homeassistant/components/darksky/__init__.py new file mode 100644 index 000000000..90a5d06dc --- /dev/null +++ b/homeassistant/components/darksky/__init__.py @@ -0,0 +1 @@ +"""The darksky component.""" diff --git a/homeassistant/components/darksky/manifest.json b/homeassistant/components/darksky/manifest.json new file mode 100644 index 000000000..0046e5146 --- /dev/null +++ b/homeassistant/components/darksky/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "darksky", + "name": "Darksky", + "documentation": "https://www.home-assistant.io/integrations/darksky", + "requirements": [ + "python-forecastio==1.4.0" + ], + "dependencies": [], + "codeowners": [ + "@fabaff" + ] +} diff --git a/homeassistant/components/darksky/sensor.py b/homeassistant/components/darksky/sensor.py new file mode 100644 index 000000000..5b6da5d11 --- /dev/null +++ b/homeassistant/components/darksky/sensor.py @@ -0,0 +1,832 @@ +"""Support for Dark Sky weather service.""" +from datetime import timedelta +import logging + +import forecastio +from requests.exceptions import ConnectionError as ConnectError, HTTPError, Timeout +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + ATTR_ATTRIBUTION, + CONF_API_KEY, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_MONITORED_CONDITIONS, + CONF_NAME, + CONF_SCAN_INTERVAL, + UNIT_UV_INDEX, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +_LOGGER = logging.getLogger(__name__) + +ATTRIBUTION = "Powered by Dark Sky" + +CONF_FORECAST = "forecast" +CONF_HOURLY_FORECAST = "hourly_forecast" +CONF_LANGUAGE = "language" +CONF_UNITS = "units" + +DEFAULT_LANGUAGE = "en" +DEFAULT_NAME = "Dark Sky" +SCAN_INTERVAL = timedelta(seconds=300) + +DEPRECATED_SENSOR_TYPES = { + "apparent_temperature_max", + "apparent_temperature_min", + "temperature_max", + "temperature_min", +} + +# Sensor types are defined like so: +# Name, si unit, us unit, ca unit, uk unit, uk2 unit +SENSOR_TYPES = { + "summary": [ + "Summary", + None, + None, + None, + None, + None, + None, + ["currently", "hourly", "daily"], + ], + "minutely_summary": ["Minutely Summary", None, None, None, None, None, None, []], + "hourly_summary": ["Hourly Summary", None, None, None, None, None, None, []], + "daily_summary": ["Daily Summary", None, None, None, None, None, None, []], + "icon": [ + "Icon", + None, + None, + None, + None, + None, + None, + ["currently", "hourly", "daily"], + ], + "nearest_storm_distance": [ + "Nearest Storm Distance", + "km", + "mi", + "km", + "km", + "mi", + "mdi:weather-lightning", + ["currently"], + ], + "nearest_storm_bearing": [ + "Nearest Storm Bearing", + "°", + "°", + "°", + "°", + "°", + "mdi:weather-lightning", + ["currently"], + ], + "precip_type": [ + "Precip", + None, + None, + None, + None, + None, + "mdi:weather-pouring", + ["currently", "minutely", "hourly", "daily"], + ], + "precip_intensity": [ + "Precip Intensity", + "mm/h", + "in", + "mm/h", + "mm/h", + "mm/h", + "mdi:weather-rainy", + ["currently", "minutely", "hourly", "daily"], + ], + "precip_probability": [ + "Precip Probability", + "%", + "%", + "%", + "%", + "%", + "mdi:water-percent", + ["currently", "minutely", "hourly", "daily"], + ], + "precip_accumulation": [ + "Precip Accumulation", + "cm", + "in", + "cm", + "cm", + "cm", + "mdi:weather-snowy", + ["hourly", "daily"], + ], + "temperature": [ + "Temperature", + "°C", + "°F", + "°C", + "°C", + "°C", + "mdi:thermometer", + ["currently", "hourly"], + ], + "apparent_temperature": [ + "Apparent Temperature", + "°C", + "°F", + "°C", + "°C", + "°C", + "mdi:thermometer", + ["currently", "hourly"], + ], + "dew_point": [ + "Dew Point", + "°C", + "°F", + "°C", + "°C", + "°C", + "mdi:thermometer", + ["currently", "hourly", "daily"], + ], + "wind_speed": [ + "Wind Speed", + "m/s", + "mph", + "km/h", + "mph", + "mph", + "mdi:weather-windy", + ["currently", "hourly", "daily"], + ], + "wind_bearing": [ + "Wind Bearing", + "°", + "°", + "°", + "°", + "°", + "mdi:compass", + ["currently", "hourly", "daily"], + ], + "wind_gust": [ + "Wind Gust", + "m/s", + "mph", + "km/h", + "mph", + "mph", + "mdi:weather-windy-variant", + ["currently", "hourly", "daily"], + ], + "cloud_cover": [ + "Cloud Coverage", + "%", + "%", + "%", + "%", + "%", + "mdi:weather-partly-cloudy", + ["currently", "hourly", "daily"], + ], + "humidity": [ + "Humidity", + "%", + "%", + "%", + "%", + "%", + "mdi:water-percent", + ["currently", "hourly", "daily"], + ], + "pressure": [ + "Pressure", + "mbar", + "mbar", + "mbar", + "mbar", + "mbar", + "mdi:gauge", + ["currently", "hourly", "daily"], + ], + "visibility": [ + "Visibility", + "km", + "mi", + "km", + "km", + "mi", + "mdi:eye", + ["currently", "hourly", "daily"], + ], + "ozone": [ + "Ozone", + "DU", + "DU", + "DU", + "DU", + "DU", + "mdi:eye", + ["currently", "hourly", "daily"], + ], + "apparent_temperature_max": [ + "Daily High Apparent Temperature", + "°C", + "°F", + "°C", + "°C", + "°C", + "mdi:thermometer", + ["daily"], + ], + "apparent_temperature_high": [ + "Daytime High Apparent Temperature", + "°C", + "°F", + "°C", + "°C", + "°C", + "mdi:thermometer", + ["daily"], + ], + "apparent_temperature_min": [ + "Daily Low Apparent Temperature", + "°C", + "°F", + "°C", + "°C", + "°C", + "mdi:thermometer", + ["daily"], + ], + "apparent_temperature_low": [ + "Overnight Low Apparent Temperature", + "°C", + "°F", + "°C", + "°C", + "°C", + "mdi:thermometer", + ["daily"], + ], + "temperature_max": [ + "Daily High Temperature", + "°C", + "°F", + "°C", + "°C", + "°C", + "mdi:thermometer", + ["daily"], + ], + "temperature_high": [ + "Daytime High Temperature", + "°C", + "°F", + "°C", + "°C", + "°C", + "mdi:thermometer", + ["daily"], + ], + "temperature_min": [ + "Daily Low Temperature", + "°C", + "°F", + "°C", + "°C", + "°C", + "mdi:thermometer", + ["daily"], + ], + "temperature_low": [ + "Overnight Low Temperature", + "°C", + "°F", + "°C", + "°C", + "°C", + "mdi:thermometer", + ["daily"], + ], + "precip_intensity_max": [ + "Daily Max Precip Intensity", + "mm/h", + "in", + "mm/h", + "mm/h", + "mm/h", + "mdi:thermometer", + ["daily"], + ], + "uv_index": [ + "UV Index", + UNIT_UV_INDEX, + UNIT_UV_INDEX, + UNIT_UV_INDEX, + UNIT_UV_INDEX, + UNIT_UV_INDEX, + "mdi:weather-sunny", + ["currently", "hourly", "daily"], + ], + "moon_phase": [ + "Moon Phase", + None, + None, + None, + None, + None, + "mdi:weather-night", + ["daily"], + ], + "sunrise_time": [ + "Sunrise", + None, + None, + None, + None, + None, + "mdi:white-balance-sunny", + ["daily"], + ], + "sunset_time": [ + "Sunset", + None, + None, + None, + None, + None, + "mdi:weather-night", + ["daily"], + ], + "alerts": ["Alerts", None, None, None, None, None, "mdi:alert-circle-outline", []], +} + +CONDITION_PICTURES = { + "clear-day": ["/static/images/darksky/weather-sunny.svg", "mdi:weather-sunny"], + "clear-night": ["/static/images/darksky/weather-night.svg", "mdi:weather-night"], + "rain": ["/static/images/darksky/weather-pouring.svg", "mdi:weather-pouring"], + "snow": ["/static/images/darksky/weather-snowy.svg", "mdi:weather-snowy"], + "sleet": ["/static/images/darksky/weather-hail.svg", "mdi:weather-snowy-rainy"], + "wind": ["/static/images/darksky/weather-windy.svg", "mdi:weather-windy"], + "fog": ["/static/images/darksky/weather-fog.svg", "mdi:weather-fog"], + "cloudy": ["/static/images/darksky/weather-cloudy.svg", "mdi:weather-cloudy"], + "partly-cloudy-day": [ + "/static/images/darksky/weather-partlycloudy.svg", + "mdi:weather-partly-cloudy", + ], + "partly-cloudy-night": [ + "/static/images/darksky/weather-cloudy.svg", + "mdi:weather-night-partly-cloudy", + ], +} + +# Language Supported Codes +LANGUAGE_CODES = [ + "ar", + "az", + "be", + "bg", + "bn", + "bs", + "ca", + "cs", + "da", + "de", + "el", + "en", + "ja", + "ka", + "kn", + "ko", + "eo", + "es", + "et", + "fi", + "fr", + "he", + "hi", + "hr", + "hu", + "id", + "is", + "it", + "kw", + "lv", + "ml", + "mr", + "nb", + "nl", + "pa", + "pl", + "pt", + "ro", + "ru", + "sk", + "sl", + "sr", + "sv", + "ta", + "te", + "tet", + "tr", + "uk", + "ur", + "x-pig-latin", + "zh", + "zh-tw", +] + +ALLOWED_UNITS = ["auto", "si", "us", "ca", "uk", "uk2"] + +ALERTS_ATTRS = ["time", "description", "expires", "severity", "uri", "regions", "title"] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_MONITORED_CONDITIONS): vol.All( + cv.ensure_list, [vol.In(SENSOR_TYPES)] + ), + vol.Required(CONF_API_KEY): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_UNITS): vol.In(ALLOWED_UNITS), + vol.Optional(CONF_LANGUAGE, default=DEFAULT_LANGUAGE): vol.In(LANGUAGE_CODES), + vol.Inclusive( + CONF_LATITUDE, "coordinates", "Latitude and longitude must exist together" + ): cv.latitude, + vol.Inclusive( + CONF_LONGITUDE, "coordinates", "Latitude and longitude must exist together" + ): cv.longitude, + vol.Optional(CONF_FORECAST): vol.All(cv.ensure_list, [vol.Range(min=0, max=7)]), + vol.Optional(CONF_HOURLY_FORECAST): vol.All( + cv.ensure_list, [vol.Range(min=0, max=48)] + ), + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Dark Sky sensor.""" + latitude = config.get(CONF_LATITUDE, hass.config.latitude) + longitude = config.get(CONF_LONGITUDE, hass.config.longitude) + language = config.get(CONF_LANGUAGE) + interval = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) + + if CONF_UNITS in config: + units = config[CONF_UNITS] + elif hass.config.units.is_metric: + units = "si" + else: + units = "us" + + forecast_data = DarkSkyData( + api_key=config.get(CONF_API_KEY, None), + latitude=latitude, + longitude=longitude, + units=units, + language=language, + interval=interval, + ) + forecast_data.update() + forecast_data.update_currently() + + # If connection failed don't setup platform. + if forecast_data.data is None: + return + + name = config.get(CONF_NAME) + + forecast = config.get(CONF_FORECAST) + forecast_hour = config.get(CONF_HOURLY_FORECAST) + sensors = [] + for variable in config[CONF_MONITORED_CONDITIONS]: + if variable in DEPRECATED_SENSOR_TYPES: + _LOGGER.warning("Monitored condition %s is deprecated", variable) + if not SENSOR_TYPES[variable][7] or "currently" in SENSOR_TYPES[variable][7]: + if variable == "alerts": + sensors.append(DarkSkyAlertSensor(forecast_data, variable, name)) + else: + sensors.append(DarkSkySensor(forecast_data, variable, name)) + + if forecast is not None and "daily" in SENSOR_TYPES[variable][7]: + for forecast_day in forecast: + sensors.append( + DarkSkySensor( + forecast_data, variable, name, forecast_day=forecast_day + ) + ) + if forecast_hour is not None and "hourly" in SENSOR_TYPES[variable][7]: + for forecast_h in forecast_hour: + sensors.append( + DarkSkySensor( + forecast_data, variable, name, forecast_hour=forecast_h + ) + ) + + add_entities(sensors, True) + + +class DarkSkySensor(Entity): + """Implementation of a Dark Sky sensor.""" + + def __init__( + self, forecast_data, sensor_type, name, forecast_day=None, forecast_hour=None + ): + """Initialize the sensor.""" + self.client_name = name + self._name = SENSOR_TYPES[sensor_type][0] + self.forecast_data = forecast_data + self.type = sensor_type + self.forecast_day = forecast_day + self.forecast_hour = forecast_hour + self._state = None + self._icon = None + self._unit_of_measurement = None + + @property + def name(self): + """Return the name of the sensor.""" + if self.forecast_day is not None: + return f"{self.client_name} {self._name} {self.forecast_day}d" + if self.forecast_hour is not None: + return f"{self.client_name} {self._name} {self.forecast_hour}h" + return f"{self.client_name} {self._name}" + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._unit_of_measurement + + @property + def unit_system(self): + """Return the unit system of this entity.""" + return self.forecast_data.unit_system + + @property + def entity_picture(self): + """Return the entity picture to use in the frontend, if any.""" + if self._icon is None or "summary" not in self.type: + return None + + if self._icon in CONDITION_PICTURES: + return CONDITION_PICTURES[self._icon][0] + + return None + + def update_unit_of_measurement(self): + """Update units based on unit system.""" + unit_index = {"si": 1, "us": 2, "ca": 3, "uk": 4, "uk2": 5}.get( + self.unit_system, 1 + ) + self._unit_of_measurement = SENSOR_TYPES[self.type][unit_index] + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + if "summary" in self.type and self._icon in CONDITION_PICTURES: + return CONDITION_PICTURES[self._icon][1] + + return SENSOR_TYPES[self.type][6] + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return {ATTR_ATTRIBUTION: ATTRIBUTION} + + def update(self): + """Get the latest data from Dark Sky and updates the states.""" + # Call the API for new forecast data. Each sensor will re-trigger this + # same exact call, but that's fine. We cache results for a short period + # of time to prevent hitting API limits. Note that Dark Sky will + # charge users for too many calls in 1 day, so take care when updating. + self.forecast_data.update() + self.update_unit_of_measurement() + + if self.type == "minutely_summary": + self.forecast_data.update_minutely() + minutely = self.forecast_data.data_minutely + self._state = getattr(minutely, "summary", "") + self._icon = getattr(minutely, "icon", "") + elif self.type == "hourly_summary": + self.forecast_data.update_hourly() + hourly = self.forecast_data.data_hourly + self._state = getattr(hourly, "summary", "") + self._icon = getattr(hourly, "icon", "") + elif self.forecast_hour is not None: + self.forecast_data.update_hourly() + hourly = self.forecast_data.data_hourly + if hasattr(hourly, "data"): + self._state = self.get_state(hourly.data[self.forecast_hour]) + else: + self._state = 0 + elif self.type == "daily_summary": + self.forecast_data.update_daily() + daily = self.forecast_data.data_daily + self._state = getattr(daily, "summary", "") + self._icon = getattr(daily, "icon", "") + elif self.forecast_day is not None: + self.forecast_data.update_daily() + daily = self.forecast_data.data_daily + if hasattr(daily, "data"): + self._state = self.get_state(daily.data[self.forecast_day]) + else: + self._state = 0 + else: + self.forecast_data.update_currently() + currently = self.forecast_data.data_currently + self._state = self.get_state(currently) + + def get_state(self, data): + """ + Return a new state based on the type. + + If the sensor type is unknown, the current state is returned. + """ + lookup_type = convert_to_camel(self.type) + state = getattr(data, lookup_type, None) + + if state is None: + return state + + if "summary" in self.type: + self._icon = getattr(data, "icon", "") + + # Some state data needs to be rounded to whole values or converted to + # percentages + if self.type in ["precip_probability", "cloud_cover", "humidity"]: + return round(state * 100, 1) + + if self.type in [ + "dew_point", + "temperature", + "apparent_temperature", + "temperature_low", + "apparent_temperature_low", + "temperature_min", + "apparent_temperature_min", + "temperature_high", + "apparent_temperature_high", + "temperature_max", + "apparent_temperature_max", + "precip_accumulation", + "pressure", + "ozone", + "uvIndex", + ]: + return round(state, 1) + return state + + +class DarkSkyAlertSensor(Entity): + """Implementation of a Dark Sky sensor.""" + + def __init__(self, forecast_data, sensor_type, name): + """Initialize the sensor.""" + self.client_name = name + self._name = SENSOR_TYPES[sensor_type][0] + self.forecast_data = forecast_data + self.type = sensor_type + self._state = None + self._icon = None + self._alerts = None + + @property + def name(self): + """Return the name of the sensor.""" + return f"{self.client_name} {self._name}" + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + if self._state is not None and self._state > 0: + return "mdi:alert-circle" + return "mdi:alert-circle-outline" + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._alerts + + def update(self): + """Get the latest data from Dark Sky and updates the states.""" + # Call the API for new forecast data. Each sensor will re-trigger this + # same exact call, but that's fine. We cache results for a short period + # of time to prevent hitting API limits. Note that Dark Sky will + # charge users for too many calls in 1 day, so take care when updating. + self.forecast_data.update() + self.forecast_data.update_alerts() + alerts = self.forecast_data.data_alerts + self._state = self.get_state(alerts) + + def get_state(self, data): + """ + Return a new state based on the type. + + If the sensor type is unknown, the current state is returned. + """ + alerts = {} + if data is None: + self._alerts = alerts + return data + + multiple_alerts = len(data) > 1 + for i, alert in enumerate(data): + for attr in ALERTS_ATTRS: + if multiple_alerts: + dkey = attr + "_" + str(i) + else: + dkey = attr + alerts[dkey] = getattr(alert, attr) + self._alerts = alerts + + return len(data) + + +def convert_to_camel(data): + """ + Convert snake case (foo_bar_bat) to camel case (fooBarBat). + + This is not pythonic, but needed for certain situations. + """ + components = data.split("_") + return components[0] + "".join(x.title() for x in components[1:]) + + +class DarkSkyData: + """Get the latest data from Darksky.""" + + def __init__(self, api_key, latitude, longitude, units, language, interval): + """Initialize the data object.""" + self._api_key = api_key + self.latitude = latitude + self.longitude = longitude + self.units = units + self.language = language + + self.data = None + self.unit_system = None + self.data_currently = None + self.data_minutely = None + self.data_hourly = None + self.data_daily = None + self.data_alerts = None + + # Apply throttling to methods using configured interval + self.update = Throttle(interval)(self._update) + self.update_currently = Throttle(interval)(self._update_currently) + self.update_minutely = Throttle(interval)(self._update_minutely) + self.update_hourly = Throttle(interval)(self._update_hourly) + self.update_daily = Throttle(interval)(self._update_daily) + self.update_alerts = Throttle(interval)(self._update_alerts) + + def _update(self): + """Get the latest data from Dark Sky.""" + try: + self.data = forecastio.load_forecast( + self._api_key, + self.latitude, + self.longitude, + units=self.units, + lang=self.language, + ) + except (ConnectError, HTTPError, Timeout, ValueError) as error: + _LOGGER.error("Unable to connect to Dark Sky: %s", error) + self.data = None + self.unit_system = self.data and self.data.json["flags"]["units"] + + def _update_currently(self): + """Update currently data.""" + self.data_currently = self.data and self.data.currently() + + def _update_minutely(self): + """Update minutely data.""" + self.data_minutely = self.data and self.data.minutely() + + def _update_hourly(self): + """Update hourly data.""" + self.data_hourly = self.data and self.data.hourly() + + def _update_daily(self): + """Update daily data.""" + self.data_daily = self.data and self.data.daily() + + def _update_alerts(self): + """Update alerts data.""" + self.data_alerts = self.data and self.data.alerts() diff --git a/homeassistant/components/darksky/weather.py b/homeassistant/components/darksky/weather.py new file mode 100644 index 000000000..41f063399 --- /dev/null +++ b/homeassistant/components/darksky/weather.py @@ -0,0 +1,264 @@ +"""Support for retrieving meteorological data from Dark Sky.""" +from datetime import timedelta +import logging + +import forecastio +from requests.exceptions import ConnectionError as ConnectError, HTTPError, Timeout +import voluptuous as vol + +from homeassistant.components.weather import ( + ATTR_FORECAST_CONDITION, + ATTR_FORECAST_PRECIPITATION, + ATTR_FORECAST_TEMP, + ATTR_FORECAST_TEMP_LOW, + ATTR_FORECAST_TIME, + ATTR_FORECAST_WIND_BEARING, + ATTR_FORECAST_WIND_SPEED, + PLATFORM_SCHEMA, + WeatherEntity, +) +from homeassistant.const import ( + CONF_API_KEY, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_MODE, + CONF_NAME, + PRESSURE_HPA, + PRESSURE_INHG, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.util import Throttle +from homeassistant.util.dt import utc_from_timestamp +from homeassistant.util.pressure import convert as convert_pressure + +_LOGGER = logging.getLogger(__name__) + +ATTRIBUTION = "Powered by Dark Sky" + +FORECAST_MODE = ["hourly", "daily"] + +MAP_CONDITION = { + "clear-day": "sunny", + "clear-night": "clear-night", + "rain": "rainy", + "snow": "snowy", + "sleet": "snowy-rainy", + "wind": "windy", + "fog": "fog", + "cloudy": "cloudy", + "partly-cloudy-day": "partlycloudy", + "partly-cloudy-night": "partlycloudy", + "hail": "hail", + "thunderstorm": "lightning", + "tornado": None, +} + +CONF_UNITS = "units" + +DEFAULT_NAME = "Dark Sky" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_API_KEY): cv.string, + vol.Optional(CONF_LATITUDE): cv.latitude, + vol.Optional(CONF_LONGITUDE): cv.longitude, + vol.Optional(CONF_MODE, default="hourly"): vol.In(FORECAST_MODE), + vol.Optional(CONF_UNITS): vol.In(["auto", "si", "us", "ca", "uk", "uk2"]), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + } +) + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=3) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Dark Sky weather.""" + latitude = config.get(CONF_LATITUDE, hass.config.latitude) + longitude = config.get(CONF_LONGITUDE, hass.config.longitude) + name = config.get(CONF_NAME) + mode = config.get(CONF_MODE) + + units = config.get(CONF_UNITS) + if not units: + units = "ca" if hass.config.units.is_metric else "us" + + dark_sky = DarkSkyData(config.get(CONF_API_KEY), latitude, longitude, units) + + add_entities([DarkSkyWeather(name, dark_sky, mode)], True) + + +class DarkSkyWeather(WeatherEntity): + """Representation of a weather condition.""" + + def __init__(self, name, dark_sky, mode): + """Initialize Dark Sky weather.""" + self._name = name + self._dark_sky = dark_sky + self._mode = mode + + self._ds_data = None + self._ds_currently = None + self._ds_hourly = None + self._ds_daily = None + + @property + def available(self): + """Return if weather data is available from Dark Sky.""" + return self._ds_data is not None + + @property + def attribution(self): + """Return the attribution.""" + return ATTRIBUTION + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def temperature(self): + """Return the temperature.""" + return self._ds_currently.get("temperature") + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + if self._dark_sky.units is None: + return None + return TEMP_FAHRENHEIT if "us" in self._dark_sky.units else TEMP_CELSIUS + + @property + def humidity(self): + """Return the humidity.""" + return round(self._ds_currently.get("humidity") * 100.0, 2) + + @property + def wind_speed(self): + """Return the wind speed.""" + return self._ds_currently.get("windSpeed") + + @property + def wind_bearing(self): + """Return the wind bearing.""" + return self._ds_currently.get("windBearing") + + @property + def ozone(self): + """Return the ozone level.""" + return self._ds_currently.get("ozone") + + @property + def pressure(self): + """Return the pressure.""" + pressure = self._ds_currently.get("pressure") + if "us" in self._dark_sky.units: + return round(convert_pressure(pressure, PRESSURE_HPA, PRESSURE_INHG), 2) + return pressure + + @property + def visibility(self): + """Return the visibility.""" + return self._ds_currently.get("visibility") + + @property + def condition(self): + """Return the weather condition.""" + return MAP_CONDITION.get(self._ds_currently.get("icon")) + + @property + def forecast(self): + """Return the forecast array.""" + # Per conversation with Joshua Reyes of Dark Sky, to get the total + # forecasted precipitation, you have to multiple the intensity by + # the hours for the forecast interval + def calc_precipitation(intensity, hours): + amount = None + if intensity is not None: + amount = round((intensity * hours), 1) + return amount if amount > 0 else None + + data = None + + if self._mode == "daily": + data = [ + { + ATTR_FORECAST_TIME: utc_from_timestamp( + entry.d.get("time") + ).isoformat(), + ATTR_FORECAST_TEMP: entry.d.get("temperatureHigh"), + ATTR_FORECAST_TEMP_LOW: entry.d.get("temperatureLow"), + ATTR_FORECAST_PRECIPITATION: calc_precipitation( + entry.d.get("precipIntensity"), 24 + ), + ATTR_FORECAST_WIND_SPEED: entry.d.get("windSpeed"), + ATTR_FORECAST_WIND_BEARING: entry.d.get("windBearing"), + ATTR_FORECAST_CONDITION: MAP_CONDITION.get(entry.d.get("icon")), + } + for entry in self._ds_daily.data + ] + else: + data = [ + { + ATTR_FORECAST_TIME: utc_from_timestamp( + entry.d.get("time") + ).isoformat(), + ATTR_FORECAST_TEMP: entry.d.get("temperature"), + ATTR_FORECAST_PRECIPITATION: calc_precipitation( + entry.d.get("precipIntensity"), 1 + ), + ATTR_FORECAST_CONDITION: MAP_CONDITION.get(entry.d.get("icon")), + } + for entry in self._ds_hourly.data + ] + + return data + + def update(self): + """Get the latest data from Dark Sky.""" + self._dark_sky.update() + + self._ds_data = self._dark_sky.data + currently = self._dark_sky.currently + self._ds_currently = currently.d if currently else {} + self._ds_hourly = self._dark_sky.hourly + self._ds_daily = self._dark_sky.daily + + +class DarkSkyData: + """Get the latest data from Dark Sky.""" + + def __init__(self, api_key, latitude, longitude, units): + """Initialize the data object.""" + self._api_key = api_key + self.latitude = latitude + self.longitude = longitude + self.requested_units = units + + self.data = None + self.currently = None + self.hourly = None + self.daily = None + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data from Dark Sky.""" + try: + self.data = forecastio.load_forecast( + self._api_key, self.latitude, self.longitude, units=self.requested_units + ) + self.currently = self.data.currently() + self.hourly = self.data.hourly() + self.daily = self.data.daily() + except (ConnectError, HTTPError, Timeout, ValueError) as error: + _LOGGER.error("Unable to connect to Dark Sky. %s", error) + self.data = None + + @property + def units(self): + """Get the unit system of returned data.""" + if self.data is None: + return None + return self.data.json.get("flags").get("units") diff --git a/homeassistant/components/datadog.py b/homeassistant/components/datadog.py deleted file mode 100644 index 58503d718..000000000 --- a/homeassistant/components/datadog.py +++ /dev/null @@ -1,104 +0,0 @@ -""" -A component which allows you to send data to Datadog. - -For more details about this component, please refer to the documentation at -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.helpers import state as state_helper -import homeassistant.helpers.config_validation as cv - -REQUIREMENTS = ['datadog==0.15.0'] - -_LOGGER = logging.getLogger(__name__) - -CONF_RATE = 'rate' -DEFAULT_HOST = 'localhost' -DEFAULT_PORT = 8125 -DEFAULT_PREFIX = 'hass' -DEFAULT_RATE = 1 -DOMAIN = 'datadog' - -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - vol.Required(CONF_HOST, default=DEFAULT_HOST): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_PREFIX, default=DEFAULT_PREFIX): cv.string, - vol.Optional(CONF_RATE, default=DEFAULT_RATE): - vol.All(vol.Coerce(int), vol.Range(min=1)), - }), -}, extra=vol.ALLOW_EXTRA) - - -def setup(hass, config): - """Set up the Datadog component.""" - from datadog import initialize, statsd - - conf = config[DOMAIN] - host = conf.get(CONF_HOST) - port = conf.get(CONF_PORT) - sample_rate = conf.get(CONF_RATE) - prefix = conf.get(CONF_PREFIX) - - initialize(statsd_host=host, statsd_port=port) - - def logbook_entry_listener(event): - """Listen for logbook entries and send them as events.""" - name = event.data.get('name') - message = event.data.get('message') - - statsd.event( - title="Home Assistant", - text="%%% \n **{}** {} \n %%%".format(name, message), - tags=[ - "entity:{}".format(event.data.get('entity_id')), - "domain:{}".format(event.data.get('domain')) - ] - ) - - _LOGGER.debug('Sent event %s', event.data.get('entity_id')) - - def state_changed_listener(event): - """Listen for new messages on the bus and sends them to Datadog.""" - state = event.data.get('new_state') - - if state is None or state.state == STATE_UNKNOWN: - return - - if state.attributes.get('hidden') is True: - return - - states = dict(state.attributes) - metric = "{}.{}".format(prefix, state.domain) - tags = ["entity:{}".format(state.entity_id)] - - for key, value in states.items(): - if isinstance(value, (float, int)): - attribute = "{}.{}".format(metric, key.replace(' ', '_')) - statsd.gauge( - attribute, value, sample_rate=sample_rate, tags=tags) - - _LOGGER.debug( - "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) - return - - statsd.gauge(metric, value, sample_rate=sample_rate, tags=tags) - - _LOGGER.debug('Sent metric %s: %s (tags: %s)', metric, value, tags) - - hass.bus.listen(EVENT_LOGBOOK_ENTRY, logbook_entry_listener) - hass.bus.listen(EVENT_STATE_CHANGED, state_changed_listener) - - return True diff --git a/homeassistant/components/datadog/__init__.py b/homeassistant/components/datadog/__init__.py new file mode 100644 index 000000000..adb8bb1f9 --- /dev/null +++ b/homeassistant/components/datadog/__init__.py @@ -0,0 +1,105 @@ +"""Support for sending data to Datadog.""" +import logging + +from datadog import initialize, statsd +import voluptuous as vol + +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 + +_LOGGER = logging.getLogger(__name__) + +CONF_RATE = "rate" +DEFAULT_HOST = "localhost" +DEFAULT_PORT = 8125 +DEFAULT_PREFIX = "hass" +DEFAULT_RATE = 1 +DOMAIN = "datadog" + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_PREFIX, default=DEFAULT_PREFIX): cv.string, + vol.Optional(CONF_RATE, default=DEFAULT_RATE): vol.All( + vol.Coerce(int), vol.Range(min=1) + ), + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +def setup(hass, config): + """Set up the Datadog component.""" + + conf = config[DOMAIN] + host = conf.get(CONF_HOST) + port = conf.get(CONF_PORT) + sample_rate = conf.get(CONF_RATE) + prefix = conf.get(CONF_PREFIX) + + initialize(statsd_host=host, statsd_port=port) + + def logbook_entry_listener(event): + """Listen for logbook entries and send them as events.""" + name = event.data.get("name") + message = event.data.get("message") + + statsd.event( + title="Home Assistant", + text=f"%%% \n **{name}** {message} \n %%%", + tags=[ + "entity:{}".format(event.data.get("entity_id")), + "domain:{}".format(event.data.get("domain")), + ], + ) + + _LOGGER.debug("Sent event %s", event.data.get("entity_id")) + + def state_changed_listener(event): + """Listen for new messages on the bus and sends them to Datadog.""" + state = event.data.get("new_state") + + if state is None or state.state == STATE_UNKNOWN: + return + + if state.attributes.get("hidden") is True: + return + + states = dict(state.attributes) + metric = f"{prefix}.{state.domain}" + tags = [f"entity:{state.entity_id}"] + + for key, value in states.items(): + if isinstance(value, (float, int)): + attribute = "{}.{}".format(metric, key.replace(" ", "_")) + statsd.gauge(attribute, value, sample_rate=sample_rate, tags=tags) + + _LOGGER.debug("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) + return + + statsd.gauge(metric, value, sample_rate=sample_rate, tags=tags) + + _LOGGER.debug("Sent metric %s: %s (tags: %s)", metric, value, tags) + + hass.bus.listen(EVENT_LOGBOOK_ENTRY, logbook_entry_listener) + hass.bus.listen(EVENT_STATE_CHANGED, state_changed_listener) + + return True diff --git a/homeassistant/components/datadog/manifest.json b/homeassistant/components/datadog/manifest.json new file mode 100644 index 000000000..36de2ff21 --- /dev/null +++ b/homeassistant/components/datadog/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "datadog", + "name": "Datadog", + "documentation": "https://www.home-assistant.io/integrations/datadog", + "requirements": [ + "datadog==0.15.0" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/ddwrt/__init__.py b/homeassistant/components/ddwrt/__init__.py new file mode 100644 index 000000000..a2c368112 --- /dev/null +++ b/homeassistant/components/ddwrt/__init__.py @@ -0,0 +1 @@ +"""The ddwrt component.""" diff --git a/homeassistant/components/ddwrt/device_tracker.py b/homeassistant/components/ddwrt/device_tracker.py new file mode 100644 index 000000000..bd2728d03 --- /dev/null +++ b/homeassistant/components/ddwrt/device_tracker.py @@ -0,0 +1,168 @@ +"""Support for DD-WRT routers.""" +import logging +import re + +import requests +import voluptuous as vol + +from homeassistant.components.device_tracker import ( + DOMAIN, + PLATFORM_SCHEMA, + DeviceScanner, +) +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +_DDWRT_DATA_REGEX = re.compile(r"\{(\w+)::([^\}]*)\}") +_MAC_REGEX = re.compile(r"(([0-9A-Fa-f]{1,2}\:){5}[0-9A-Fa-f]{1,2})") + +DEFAULT_SSL = False +DEFAULT_VERIFY_SSL = True +CONF_WIRELESS_ONLY = "wireless_only" +DEFAULT_WIRELESS_ONLY = True + +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_SSL, default=DEFAULT_SSL): cv.boolean, + vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, + vol.Optional(CONF_WIRELESS_ONLY, default=DEFAULT_WIRELESS_ONLY): cv.boolean, + } +) + + +def get_scanner(hass, config): + """Validate the configuration and return a DD-WRT scanner.""" + try: + return DdWrtDeviceScanner(config[DOMAIN]) + except ConnectionError: + return None + + +class DdWrtDeviceScanner(DeviceScanner): + """This class queries a wireless router running DD-WRT firmware.""" + + def __init__(self, config): + """Initialize the DD-WRT scanner.""" + self.protocol = "https" if config[CONF_SSL] else "http" + self.verify_ssl = config[CONF_VERIFY_SSL] + self.host = config[CONF_HOST] + self.username = config[CONF_USERNAME] + self.password = config[CONF_PASSWORD] + self.wireless_only = config[CONF_WIRELESS_ONLY] + + self.last_results = {} + self.mac2name = {} + + # Test the router is accessible + url = f"{self.protocol}://{self.host}/Status_Wireless.live.asp" + data = self.get_ddwrt_data(url) + if not data: + raise ConnectionError("Cannot connect to DD-Wrt router") + + def scan_devices(self): + """Scan for new devices and return a list with found device IDs.""" + self._update_info() + + return self.last_results + + def get_device_name(self, device): + """Return the name of the given device or None if we don't know.""" + # If not initialised and not already scanned and not found. + if device not in self.mac2name: + url = f"{self.protocol}://{self.host}/Status_Lan.live.asp" + data = self.get_ddwrt_data(url) + + if not data: + return None + + dhcp_leases = data.get("dhcp_leases", None) + + if not dhcp_leases: + return None + + # Remove leading and trailing quotes and spaces + cleaned_str = dhcp_leases.replace('"', "").replace("'", "").replace(" ", "") + elements = cleaned_str.split(",") + num_clients = int(len(elements) / 5) + self.mac2name = {} + for idx in range(0, num_clients): + # The data is a single array + # every 5 elements represents one host, the MAC + # is the third element and the name is the first. + mac_index = (idx * 5) + 2 + if mac_index < len(elements): + mac = elements[mac_index] + self.mac2name[mac] = elements[idx * 5] + + return self.mac2name.get(device) + + def _update_info(self): + """Ensure the information from the DD-WRT router is up to date. + + Return boolean if scanning successful. + """ + _LOGGER.info("Checking ARP") + + endpoint = "Wireless" if self.wireless_only else "Lan" + url = f"{self.protocol}://{self.host}/Status_{endpoint}.live.asp" + data = self.get_ddwrt_data(url) + + if not data: + return False + + self.last_results = [] + + if self.wireless_only: + active_clients = data.get("active_wireless", None) + else: + active_clients = data.get("arp_table", None) + if not active_clients: + return False + + # The DD-WRT UI uses its own data format and then + # regex's out values so this is done here too + # Remove leading and trailing single quotes. + clean_str = active_clients.strip().strip("'") + elements = clean_str.split("','") + + self.last_results.extend(item for item in elements if _MAC_REGEX.match(item)) + + return True + + def get_ddwrt_data(self, url): + """Retrieve data from DD-WRT and return parsed result.""" + try: + response = requests.get( + url, + auth=(self.username, self.password), + timeout=4, + verify=self.verify_ssl, + ) + except requests.exceptions.Timeout: + _LOGGER.exception("Connection to the router timed out") + return + if response.status_code == 200: + return _parse_ddwrt_response(response.text) + if response.status_code == 401: + # Authentication error + _LOGGER.exception( + "Failed to authenticate, check your username and password" + ) + return + _LOGGER.error("Invalid response from DD-WRT: %s", response) + + +def _parse_ddwrt_response(data_str): + """Parse the DD-WRT data format.""" + return dict(_DDWRT_DATA_REGEX.findall(data_str)) diff --git a/homeassistant/components/ddwrt/manifest.json b/homeassistant/components/ddwrt/manifest.json new file mode 100644 index 000000000..874b24e37 --- /dev/null +++ b/homeassistant/components/ddwrt/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "ddwrt", + "name": "Ddwrt", + "documentation": "https://www.home-assistant.io/integrations/ddwrt", + "requirements": [], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/deconz/.translations/bg.json b/homeassistant/components/deconz/.translations/bg.json index 2ea657620..fb75fc81f 100644 --- a/homeassistant/components/deconz/.translations/bg.json +++ b/homeassistant/components/deconz/.translations/bg.json @@ -2,25 +2,108 @@ "config": { "abort": { "already_configured": "\u041c\u043e\u0441\u0442\u044a\u0442 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d", + "already_in_progress": "\u0412 \u043c\u043e\u043c\u0435\u043d\u0442\u0430 \u0442\u0435\u0447\u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u0431\u0430\u0437\u043e\u0432\u0430 \u0441\u0442\u0430\u043d\u0446\u0438\u044f.", "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" + "not_deconz_bridge": "\u041d\u0435 \u0435 deCONZ \u0431\u0430\u0437\u043e\u0432\u0430 \u0441\u0442\u0430\u043d\u0446\u0438\u044f", + "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", + "updated_instance": "\u041e\u0431\u043d\u043e\u0432\u044f\u0432\u0430\u043d\u0435 \u043d\u0430 deCONZ \u0441 \u043d\u043e\u0432 \u0430\u0434\u0440\u0435\u0441" }, "error": { "no_key": "\u041d\u0435 \u043c\u043e\u0436\u0430 \u0434\u0430 \u0441\u0435 \u043f\u043e\u043b\u0443\u0447\u0438 API \u043a\u043b\u044e\u0447" }, + "flow_title": "deCONZ Zigbee \u0448\u043b\u044e\u0437 ({host})", "step": { + "hassio_confirm": { + "data": { + "allow_clip_sensor": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438 \u0438\u043c\u043f\u043e\u0440\u0442\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u0432\u0438\u0440\u0442\u0443\u0430\u043b\u043d\u0438 \u0441\u0435\u043d\u0437\u043e\u0440\u0438", + "allow_deconz_groups": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438 \u0438\u043c\u043f\u043e\u0440\u0442\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u0433\u0440\u0443\u043f\u0438 \u043e\u0442 deCONZ" + }, + "description": "\u0418\u0441\u043a\u0430\u0442\u0435 \u043b\u0438 \u0434\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u0442\u0435 Home Assistant \u0434\u0430 \u0441\u0435 \u0441\u0432\u044a\u0440\u0437\u0432\u0430 \u0441 deCONZ \u0431\u0430\u0437\u043e\u0432\u0430 \u0441\u0442\u0430\u043d\u0446\u0438\u044f, \u043f\u0440\u0435\u0434\u043e\u0441\u0442\u0430\u0432\u0435\u043d \u043e\u0442 \u0434\u043e\u0431\u0430\u0432\u043a\u0430\u0442\u0430 \u0437\u0430 hass.io {addon}?", + "title": "deCONZ Zigbee \u0431\u0430\u0437\u043e\u0432\u0430 \u0441\u0442\u0430\u043d\u0446\u0438\u044f \u0447\u0440\u0435\u0437 Hass.io \u0434\u043e\u0431\u0430\u0432\u043a\u0430" + }, "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')" + "host": "\u0410\u0434\u0440\u0435\u0441", + "port": "\u041f\u043e\u0440\u0442" }, "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\"", + "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\u0438\u0434\u0435\u0442\u0435 \u043d\u0430 deCONZ Settings -> Gateway -> Advanced\n2. \u041d\u0430\u0442\u0438\u0441\u043d\u0435\u0442\u0435 \u0431\u0443\u0442\u043e\u043d\u0430 \"Authenticate app\"", "title": "\u0412\u0440\u044a\u0437\u043a\u0430 \u0441 deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438 \u0438\u043c\u043f\u043e\u0440\u0442\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u0432\u0438\u0440\u0442\u0443\u0430\u043b\u043d\u0438 \u0441\u0435\u043d\u0437\u043e\u0440\u0438", + "allow_deconz_groups": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438 \u0438\u043c\u043f\u043e\u0440\u0442\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u0433\u0440\u0443\u043f\u0438 \u043e\u0442 deCONZ" + }, + "title": "\u0414\u043e\u043f\u044a\u043b\u043d\u0438\u0442\u0435\u043b\u043d\u0438 \u043e\u043f\u0446\u0438\u0438 \u0437\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 deCONZ" } }, - "title": "deCONZ" + "title": "deCONZ Zigbee \u0448\u043b\u044e\u0437" + }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "\u0418 \u0434\u0432\u0430\u0442\u0430 \u0431\u0443\u0442\u043e\u043d\u0430", + "button_1": "\u041f\u044a\u0440\u0432\u0438 \u0431\u0443\u0442\u043e\u043d", + "button_2": "\u0412\u0442\u043e\u0440\u0438 \u0431\u0443\u0442\u043e\u043d", + "button_3": "\u0422\u0440\u0435\u0442\u0438 \u0431\u0443\u0442\u043e\u043d", + "button_4": "\u0427\u0435\u0442\u0432\u044a\u0440\u0442\u0438 \u0431\u0443\u0442\u043e\u043d", + "close": "\u0417\u0430\u0442\u0432\u0430\u0440\u044f\u043d\u0435", + "dim_down": "\u0417\u0430\u0442\u044a\u043c\u043d\u044f\u0432\u0430\u043d\u0435", + "dim_up": "\u041e\u0441\u0432\u0435\u0442\u044f\u0432\u0430\u043d\u0435", + "left": "\u041b\u044f\u0432\u043e", + "open": "\u041e\u0442\u0432\u0430\u0440\u044f\u043d\u0435", + "right": "\u0414\u044f\u0441\u043d\u043e", + "side_1": "\u0421\u0442\u0440\u0430\u043d\u0430 1", + "side_2": "\u0421\u0442\u0440\u0430\u043d\u0430 2", + "side_3": "\u0421\u0442\u0440\u0430\u043d\u0430 3", + "side_4": "\u0421\u0442\u0440\u0430\u043d\u0430 4", + "side_5": "\u0421\u0442\u0440\u0430\u043d\u0430 5", + "side_6": "\u0421\u0442\u0440\u0430\u043d\u0430 6", + "turn_off": "\u0418\u0437\u043a\u043b\u044e\u0447\u0438", + "turn_on": "\u0412\u043a\u043b\u044e\u0447\u0438" + }, + "trigger_type": { + "remote_awakened": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0441\u0435 \u0441\u044a\u0431\u0443\u0434\u0438", + "remote_button_double_press": "\"{subtype}\" \u0431\u0443\u0442\u043e\u043d\u044a\u0442 \u0431\u0435\u0448\u0435 \u043d\u0430\u0442\u0438\u0441\u043d\u0430\u0442 \u0434\u0432\u0443\u043a\u0440\u0430\u0442\u043d\u043e", + "remote_button_long_press": "\"{subtype}\" \u0431\u0443\u0442\u043e\u043d\u044a\u0442 \u0431\u0435\u0448\u0435 \u043d\u0430\u0442\u0438\u0441\u043d\u0430\u0442 \u043f\u0440\u043e\u0434\u044a\u043b\u0436\u0438\u0442\u0435\u043b\u043d\u043e", + "remote_button_long_release": "\"{subtype}\" \u0431\u0443\u0442\u043e\u043d\u044a\u0442 \u0431\u0435\u0448\u0435 \u043e\u0442\u043f\u0443\u0441\u043d\u0430\u0442 \u0441\u043b\u0435\u0434 \u043f\u0440\u043e\u0434\u044a\u043b\u0436\u0438\u0442\u0435\u043b\u043d\u043e \u043d\u0430\u0442\u0438\u0441\u043a\u0430\u043d\u0435", + "remote_button_quadruple_press": "\"{subtype}\" \u0431\u0443\u0442\u043e\u043d\u044a\u0442 \u0431\u0435\u0448\u0435 \u043d\u0430\u0442\u0438\u0441\u043d\u0430\u0442 \u0447\u0435\u0442\u0438\u0440\u0438\u043a\u0440\u0430\u0442\u043d\u043e", + "remote_button_quintuple_press": "\"{subtype}\" \u0431\u0443\u0442\u043e\u043d\u044a\u0442 \u0431\u0435\u0448\u0435 \u043d\u0430\u0442\u0438\u0441\u043d\u0430\u0442 \u043f\u0435\u0442\u043a\u0440\u0430\u0442\u043d\u043e", + "remote_button_rotated": "\u0417\u0430\u0432\u044a\u0440\u0442\u044f\u043d \u0431\u0443\u0442\u043e\u043d \"{subtype}\"", + "remote_button_rotation_stopped": "\u0421\u043f\u0440\u044f \u0432\u044a\u0440\u0442\u0435\u043d\u0435\u0442\u043e \u043d\u0430 \u0431\u0443\u0442\u043e\u043d \"{subtype}\"", + "remote_button_short_press": "\"{subtype}\" \u0431\u0443\u0442\u043e\u043d\u044a\u0442 \u0431\u0435\u0448\u0435 \u043d\u0430\u0442\u0438\u0441\u043d\u0430\u0442", + "remote_button_short_release": "\"{subtype}\" \u0431\u0443\u0442\u043e\u043d\u044a\u0442 \u0431\u0435\u0448\u0435 \u043e\u0442\u043f\u0443\u0441\u043d\u0430\u0442", + "remote_button_triple_press": "\"{subtype}\" \u0431\u0443\u0442\u043e\u043d\u044a\u0442 \u0431\u0435\u0448\u0435 \u043d\u0430\u0442\u0438\u0441\u043d\u0430\u0442 \u0442\u0440\u0438\u043a\u0440\u0430\u0442\u043d\u043e", + "remote_double_tap": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \"{subtype}\" \u0435 \u043f\u043e\u0447\u0443\u043a\u0430\u043d\u043e \u0434\u0432\u0430 \u043f\u044a\u0442\u0438", + "remote_falling": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u043f\u0430\u0434\u0430", + "remote_gyro_activated": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0435 \u0440\u0430\u0437\u043a\u043b\u0430\u0442\u0435\u043d\u043e", + "remote_moved": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0435 \u043f\u0440\u0435\u043c\u0435\u0441\u0442\u0435\u043d\u043e \u0441 \"{subtype}\" \u043d\u0430\u0433\u043e\u0440\u0435", + "remote_rotate_from_side_1": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0435 \u0437\u0430\u0432\u044a\u0440\u0442\u044f\u043d\u043e \u043e\u0442 \"\u0441\u0442\u0440\u0430\u043d\u0430 1\" \u043a\u044a\u043c \" {subtype} \"", + "remote_rotate_from_side_2": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0435 \u0437\u0430\u0432\u044a\u0440\u0442\u044f\u043d\u043e \u043e\u0442 \"\u0441\u0442\u0440\u0430\u043d\u0430 2\" \u043a\u044a\u043c \" {subtype} \"", + "remote_rotate_from_side_3": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0435 \u0437\u0430\u0432\u044a\u0440\u0442\u044f\u043d\u043e \u043e\u0442 \"\u0441\u0442\u0440\u0430\u043d\u0430 3\" \u043a\u044a\u043c \" {subtype} \"", + "remote_rotate_from_side_4": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0435 \u0437\u0430\u0432\u044a\u0440\u0442\u044f\u043d\u043e \u043e\u0442 \"\u0441\u0442\u0440\u0430\u043d\u0430 4\" \u043a\u044a\u043c \" {subtype} \"", + "remote_rotate_from_side_5": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0435 \u0437\u0430\u0432\u044a\u0440\u0442\u044f\u043d\u043e \u043e\u0442 \"\u0441\u0442\u0440\u0430\u043d\u0430 5\" \u043a\u044a\u043c \" {subtype} \"", + "remote_rotate_from_side_6": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0435 \u0437\u0430\u0432\u044a\u0440\u0442\u044f\u043d\u043e \u043e\u0442 \"\u0441\u0442\u0440\u0430\u043d\u0430 6\" \u043a\u044a\u043c \" {subtype} \"" + } + }, + "options": { + "step": { + "async_step_deconz_devices": { + "data": { + "allow_clip_sensor": "\u0420\u0430\u0437\u0440\u0435\u0448\u0430\u0432\u0430\u043d\u0435 \u043d\u0430 deCONZ CLIP \u0441\u0435\u043d\u0437\u043e\u0440\u0438", + "allow_deconz_groups": "\u0420\u0430\u0437\u0440\u0435\u0448\u0430\u0432\u0430\u043d\u0435 \u043d\u0430 deCONZ \u0441\u0432\u0435\u0442\u043b\u0438\u043d\u043d\u0438 \u0433\u0440\u0443\u043f\u0438" + }, + "description": "\u041a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u0439\u0442\u0435 \u0432\u0438\u0434\u0438\u043c\u043e\u0441\u0442\u0442\u0430 \u043d\u0430 \u0442\u0438\u043f\u043e\u0432\u0435\u0442\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 deCONZ" + }, + "deconz_devices": { + "data": { + "allow_clip_sensor": "\u0420\u0430\u0437\u0440\u0435\u0448\u0430\u0432\u0430\u043d\u0435 \u043d\u0430 deCONZ CLIP \u0441\u0435\u043d\u0437\u043e\u0440\u0438", + "allow_deconz_groups": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438 deCONZ \u0441\u0432\u0435\u0442\u043b\u0438\u043d\u043d\u0438 \u0433\u0440\u0443\u043f\u0438" + }, + "description": "\u041a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u0439\u0442\u0435 \u0432\u0438\u0434\u0438\u043c\u043e\u0441\u0442\u0442\u0430 \u043d\u0430 \u0442\u0438\u043f\u043e\u0432\u0435\u0442\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 deCONZ" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/ca.json b/homeassistant/components/deconz/.translations/ca.json index 0a9e6fdee..a51bfa056 100644 --- a/homeassistant/components/deconz/.translations/ca.json +++ b/homeassistant/components/deconz/.translations/ca.json @@ -2,32 +2,108 @@ "config": { "abort": { "already_configured": "L'enlla\u00e7 ja est\u00e0 configurat", + "already_in_progress": "El flux de dades de configuraci\u00f3 per l'enlla\u00e7 ja est\u00e0 en curs.", "no_bridges": "No s'han descobert enlla\u00e7os amb deCONZ", - "one_instance_only": "El component nom\u00e9s admet una inst\u00e0ncia deCONZ" + "not_deconz_bridge": "No \u00e9s un enlla\u00e7 deCONZ", + "one_instance_only": "El component nom\u00e9s admet una inst\u00e0ncia deCONZ", + "updated_instance": "S'ha actualitzat la inst\u00e0ncia de deCONZ amb una nova adre\u00e7a" }, "error": { "no_key": "No s'ha pogut obtenir una clau API" }, + "flow_title": "Passarel\u00b7la d'enlla\u00e7 deCONZ Zigbee ({host})", "step": { + "hassio_confirm": { + "data": { + "allow_clip_sensor": "Permet la importaci\u00f3 de sensors virtuals", + "allow_deconz_groups": "Permet la importaci\u00f3 de grups deCONZ" + }, + "description": "Vols configurar Home Assistant perqu\u00e8 es connecti amb la passarel\u00b7la deCONZ proporcionada pel complement de Hass.io: {addon}?", + "title": "Passarel\u00b7la d'enlla\u00e7 deCONZ Zigbee (complement de Hass.io)" + }, "init": { "data": { "host": "Amfitri\u00f3", - "port": "Port (predeterminat: '80')" + "port": "Port" }, - "title": "Definiu la passarel\u00b7la deCONZ" + "title": "Definici\u00f3 de la passarel\u00b7la deCONZ" }, "link": { - "description": "Desbloqueja la teva passarel\u00b7la d'enlla\u00e7 deCONZ per a registrar-te amb Home Assistant.\n\n1. V\u00e9s a la configuraci\u00f3 del sistema deCONZ\n2. Prem el bot\u00f3 \"Desbloquejar passarel\u00b7la\"", + "description": "Desbloqueja la teva passarel\u00b7la d'enlla\u00e7 deCONZ per a registrar-te amb Home Assistant.\n\n1. V\u00e9s a la configuraci\u00f3 del sistema deCONZ -> Passarel\u00b7la -> Avan\u00e7at\n2. Prem el bot\u00f3 \"Autenticar applicaci\u00f3\"", "title": "Vincular amb deCONZ" }, "options": { "data": { "allow_clip_sensor": "Permet la importaci\u00f3 de sensors virtuals", - "allow_deconz_groups": "Permet la importaci\u00f3 de grups deCONZ" + "allow_deconz_groups": "Permetre la importaci\u00f3 de grups deCONZ" }, - "title": "Opcions de configuraci\u00f3 addicionals per deCONZ" + "title": "Opcions de configuraci\u00f3 addicionals de deCONZ" } }, - "title": "deCONZ" + "title": "Passarel\u00b7la d'enlla\u00e7 deCONZ Zigbee" + }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "Ambd\u00f3s botons", + "button_1": "Primer bot\u00f3", + "button_2": "Segon bot\u00f3", + "button_3": "Tercer bot\u00f3", + "button_4": "Quart bot\u00f3", + "close": "Tanca", + "dim_down": "Atenua la brillantor", + "dim_up": "Augmenta la brillantor", + "left": "Esquerra", + "open": "Obert", + "right": "Dreta", + "side_1": "cara 1", + "side_2": "cara 2", + "side_3": "cara 3", + "side_4": "cara 4", + "side_5": "cara 5", + "side_6": "cara 6", + "turn_off": "Desactiva", + "turn_on": "Activa" + }, + "trigger_type": { + "remote_awakened": "Dispositiu despertat", + "remote_button_double_press": "Bot\u00f3 \"{subtype}\" clicat dues vegades consecutives", + "remote_button_long_press": "Bot\u00f3 \"{subtype}\" premut continuament", + "remote_button_long_release": "Bot\u00f3 \"{subtype}\" alliberat despr\u00e9s d'una estona premut", + "remote_button_quadruple_press": "Bot\u00f3 \"{subtype}\" clicat quatre vegades consecutives", + "remote_button_quintuple_press": "Bot\u00f3 \"{subtype}\" clicat cinc vegades consecutives", + "remote_button_rotated": "Bot\u00f3 \"{subtype}\" girat", + "remote_button_rotation_stopped": "La rotaci\u00f3 del bot\u00f3 \"{subtype}\" s'ha aturat", + "remote_button_short_press": "Bot\u00f3 \"{subtype}\" premut", + "remote_button_short_release": "Bot\u00f3 \"{subtype}\" alliberat", + "remote_button_triple_press": "Bot\u00f3 \"{subtype}\" clicat tres vegades consecutives", + "remote_double_tap": "Dispositiu \"{subtype}\" tocat dues vegades", + "remote_falling": "Dispositiu en caiguda lliure", + "remote_gyro_activated": "Dispositiu sacsejat", + "remote_moved": "Dispositiu mogut amb la \"{subtype}\" amunt", + "remote_rotate_from_side_1": "Dispositiu rotat de la \"cara 1\" a la \"{subtype}\"", + "remote_rotate_from_side_2": "Dispositiu rotat de la \"cara 2\" a la \"{subtype}\"", + "remote_rotate_from_side_3": "Dispositiu rotat de la \"cara 3\" a la \"{subtype}\"", + "remote_rotate_from_side_4": "Dispositiu rotat de la \"cara 4\" a la \"{subtype}\"", + "remote_rotate_from_side_5": "Dispositiu rotat de la \"cara 5\" a la \"{subtype}\"", + "remote_rotate_from_side_6": "Dispositiu rotat de la \"cara 6\" a la \"{subtype}\"" + } + }, + "options": { + "step": { + "async_step_deconz_devices": { + "data": { + "allow_clip_sensor": "Permet sensors deCONZ CLIP", + "allow_deconz_groups": "Permet grups de llums deCONZ" + }, + "description": "Configura la visibilitat dels tipus dels dispositius deCONZ" + }, + "deconz_devices": { + "data": { + "allow_clip_sensor": "Permet sensors deCONZ CLIP", + "allow_deconz_groups": "Permet grups de llums deCONZ" + }, + "description": "Configura la visibilitat dels tipus dels dispositius deCONZ" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/cs.json b/homeassistant/components/deconz/.translations/cs.json index 1588766e4..c66569079 100644 --- a/homeassistant/components/deconz/.translations/cs.json +++ b/homeassistant/components/deconz/.translations/cs.json @@ -8,11 +8,12 @@ "error": { "no_key": "Nelze z\u00edskat kl\u00ed\u010d API" }, + "flow_title": "Br\u00e1na deCONZ ZigBee ({host})", "step": { "init": { "data": { "host": "Hostitel", - "port": "Port (v\u00fdchoz\u00ed hodnota: '80')" + "port": "Port" }, "title": "Definujte br\u00e1nu deCONZ" }, @@ -23,7 +24,7 @@ "options": { "data": { "allow_clip_sensor": "Povolit import virtu\u00e1ln\u00edch \u010didel", - "allow_deconz_groups": "Povolit import skupin deCONZ " + "allow_deconz_groups": "Povolit import skupin deCONZ" }, "title": "Dal\u0161\u00ed mo\u017enosti konfigurace pro deCONZ" } diff --git a/homeassistant/components/deconz/.translations/da.json b/homeassistant/components/deconz/.translations/da.json index 7f9aad831..ec9c4dc35 100644 --- a/homeassistant/components/deconz/.translations/da.json +++ b/homeassistant/components/deconz/.translations/da.json @@ -1,18 +1,36 @@ { "config": { "abort": { + "already_configured": "Bridge er allerede konfigureret", + "already_in_progress": "Bro konfiguration er allerede i gang.", "no_bridges": "Ingen deConz bridge fundet", - "one_instance_only": "Komponenten underst\u00f8tter kun \u00e9n deCONZ forekomst" + "not_deconz_bridge": "Ikke en deCONZ bro", + "one_instance_only": "Komponenten underst\u00f8tter kun \u00e9n deCONZ forekomst", + "updated_instance": "Opdaterede deCONZ instans med ny v\u00e6rtsadresse" }, "error": { "no_key": "Kunne ikke f\u00e5 en API-n\u00f8gle" }, + "flow_title": "deCONZ Zigbee gateway ({host})", "step": { + "hassio_confirm": { + "data": { + "allow_clip_sensor": "Tillad import af virtuelle sensorer", + "allow_deconz_groups": "Tillad import af deCONZ grupper" + }, + "description": "Vil du konfigurere Home Assistant til at oprette forbindelse til deCONZ gateway leveret af Hass.io add-on {addon}?", + "title": "deCONZ Zigbee-gateway via Hass.io add-on" + }, "init": { "data": { "host": "V\u00e6rt", - "port": "Port (standardv\u00e6rdi: '80')" - } + "port": "Port" + }, + "title": "Definer deCONZ gateway" + }, + "link": { + "description": "L\u00e5s din deCONZ-gateway op for at registrere dig med Home Assistant. \n\n 1. G\u00e5 til deCONZ settings -> Gateway -> Advanced\n 2. Tryk p\u00e5 knappen \"Authenticate app\"", + "title": "Link med deCONZ" }, "options": { "data": { @@ -21,6 +39,43 @@ }, "title": "Ekstra konfiguration valgmuligheder for deCONZ" } + }, + "title": "deCONZ Zigbee gateway" + }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "Begge knapper", + "button_1": "F\u00f8rste knap", + "button_2": "Anden knap", + "button_3": "Tredje knap", + "button_4": "Fjerde knap", + "close": "Luk", + "dim_down": "D\u00e6mp ned", + "dim_up": "D\u00e6mp op", + "left": "Venstre", + "open": "\u00c5ben", + "right": "H\u00f8jre" + }, + "trigger_type": { + "remote_gyro_activated": "Enhed rystet" + } + }, + "options": { + "step": { + "async_step_deconz_devices": { + "data": { + "allow_clip_sensor": "Tillad deCONZ CLIP sensorer", + "allow_deconz_groups": "Tillad deCONZ lys grupper" + }, + "description": "Konfigurer synligheden af deCONZ-enhedstyper" + }, + "deconz_devices": { + "data": { + "allow_clip_sensor": "Tillad deCONZ CLIP sensorer", + "allow_deconz_groups": "Tillad deCONZ lys grupper" + }, + "description": "Konfigurer synligheden af deCONZ-enhedstyper" + } } } } \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/de.json b/homeassistant/components/deconz/.translations/de.json index 51b496906..2bf0667ca 100644 --- a/homeassistant/components/deconz/.translations/de.json +++ b/homeassistant/components/deconz/.translations/de.json @@ -2,22 +2,34 @@ "config": { "abort": { "already_configured": "Bridge ist bereits konfiguriert", + "already_in_progress": "Der Konfigurationsablauf f\u00fcr die Bridge wird bereits ausgef\u00fchrt.", "no_bridges": "Keine deCON-Bridges entdeckt", - "one_instance_only": "Komponente unterst\u00fctzt nur eine deCONZ-Instanz" + "not_deconz_bridge": "Keine deCONZ Bridge entdeckt", + "one_instance_only": "Komponente unterst\u00fctzt nur eine deCONZ-Instanz", + "updated_instance": "deCONZ-Instanz mit neuer Host-Adresse aktualisiert" }, "error": { "no_key": "Es konnte kein API-Schl\u00fcssel abgerufen werden" }, + "flow_title": "deCONZ Zigbee Gateway", "step": { + "hassio_confirm": { + "data": { + "allow_clip_sensor": "Import virtueller Sensoren zulassen", + "allow_deconz_groups": "Import von deCONZ-Gruppen zulassen" + }, + "description": "M\u00f6chtest du Home Assistant so konfigurieren, dass er eine Verbindung mit dem deCONZ gateway herstellt, der vom Add-on hass.io {addon} bereitgestellt wird?", + "title": "deCONZ Zigbee Gateway \u00fcber das Hass.io Add-on" + }, "init": { "data": { "host": "Host", - "port": "Port (Standartwert : '80')" + "port": "Port" }, - "title": "Definieren Sie den deCONZ-Gateway" + "title": "Definiere das 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\"", + "description": "Entsperre dein deCONZ-Gateway, um es bei Home Assistant zu registrieren. \n\n 1. Gehe in die deCONZ-Systemeinstellungen \n 2. Dr\u00fccke die Taste \"Gateway entsperren\"", "title": "Mit deCONZ verbinden" }, "options": { @@ -29,5 +41,53 @@ } }, "title": "deCONZ Zigbee Gateway" + }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "Beide Tasten", + "button_1": "Erste Taste", + "button_2": "Zweite Taste", + "button_3": "Dritte Taste", + "button_4": "Vierte Taste", + "close": "Schlie\u00dfen", + "dim_down": "Dimmer runter", + "dim_up": "Dimmer hoch", + "left": "Links", + "open": "Offen", + "right": "Rechts", + "turn_off": "Ausschalten", + "turn_on": "Einschalten" + }, + "trigger_type": { + "remote_button_double_press": "\"{subtype}\" Taste doppelt angeklickt", + "remote_button_long_press": "\"{subtype}\" Taste kontinuierlich gedr\u00fcckt", + "remote_button_long_release": "\"{subtype}\" Taste nach langem Dr\u00fccken losgelassen", + "remote_button_quadruple_press": "\"{subtype}\" Taste vierfach geklickt", + "remote_button_quintuple_press": "\"{subtype}\" Taste f\u00fcnffach geklickt", + "remote_button_rotated": "Button gedreht \"{subtype}\".", + "remote_button_rotation_stopped": "Die Tastendrehung \"{subtype}\" wurde gestoppt", + "remote_button_short_press": "\"{subtype}\" Taste gedr\u00fcckt", + "remote_button_short_release": "\"{subtype}\" Taste losgelassen", + "remote_button_triple_press": "\"{subtype}\" Taste dreimal geklickt", + "remote_gyro_activated": "Ger\u00e4t ersch\u00fcttert" + } + }, + "options": { + "step": { + "async_step_deconz_devices": { + "data": { + "allow_clip_sensor": "deCONZ CLIP-Sensoren zulassen", + "allow_deconz_groups": "deCONZ-Lichtgruppen zulassen" + }, + "description": "Konfigurieren der Sichtbarkeit von deCONZ-Ger\u00e4tetypen" + }, + "deconz_devices": { + "data": { + "allow_clip_sensor": "deCONZ CLIP-Sensoren zulassen", + "allow_deconz_groups": "deCONZ-Lichtgruppen zulassen" + }, + "description": "Sichtbarkeit der deCONZ-Ger\u00e4tetypen konfigurieren" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/en.json b/homeassistant/components/deconz/.translations/en.json index f55f64ca4..63798bab5 100644 --- a/homeassistant/components/deconz/.translations/en.json +++ b/homeassistant/components/deconz/.translations/en.json @@ -2,22 +2,34 @@ "config": { "abort": { "already_configured": "Bridge is already configured", + "already_in_progress": "Config flow for bridge is already in progress.", "no_bridges": "No deCONZ bridges discovered", - "one_instance_only": "Component only supports one deCONZ instance" + "not_deconz_bridge": "Not a deCONZ bridge", + "one_instance_only": "Component only supports one deCONZ instance", + "updated_instance": "Updated deCONZ instance with new host address" }, "error": { "no_key": "Couldn't get an API key" }, + "flow_title": "deCONZ Zigbee gateway ({host})", "step": { + "hassio_confirm": { + "data": { + "allow_clip_sensor": "Allow importing virtual sensors", + "allow_deconz_groups": "Allow importing deCONZ groups" + }, + "description": "Do you want to configure Home Assistant to connect to the deCONZ gateway provided by the hass.io add-on {addon}?", + "title": "deCONZ Zigbee gateway via Hass.io add-on" + }, "init": { "data": { "host": "Host", - "port": "Port (default value: '80')" + "port": "Port" }, "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", + "description": "Unlock your deCONZ gateway to register with Home Assistant.\n\n1. Go to deCONZ Settings -> Gateway -> Advanced\n2. Press \"Authenticate app\" button", "title": "Link with deCONZ" }, "options": { @@ -29,5 +41,69 @@ } }, "title": "deCONZ Zigbee gateway" + }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "Both buttons", + "button_1": "First button", + "button_2": "Second button", + "button_3": "Third button", + "button_4": "Fourth button", + "close": "Close", + "dim_down": "Dim down", + "dim_up": "Dim up", + "left": "Left", + "open": "Open", + "right": "Right", + "side_1": "Side 1", + "side_2": "Side 2", + "side_3": "Side 3", + "side_4": "Side 4", + "side_5": "Side 5", + "side_6": "Side 6", + "turn_off": "Turn off", + "turn_on": "Turn on" + }, + "trigger_type": { + "remote_awakened": "Device awakened", + "remote_button_double_press": "\"{subtype}\" button double clicked", + "remote_button_long_press": "\"{subtype}\" button continuously pressed", + "remote_button_long_release": "\"{subtype}\" button released after long press", + "remote_button_quadruple_press": "\"{subtype}\" button quadruple clicked", + "remote_button_quintuple_press": "\"{subtype}\" button quintuple clicked", + "remote_button_rotated": "Button rotated \"{subtype}\"", + "remote_button_rotation_stopped": "Button rotation \"{subtype}\" stopped", + "remote_button_short_press": "\"{subtype}\" button pressed", + "remote_button_short_release": "\"{subtype}\" button released", + "remote_button_triple_press": "\"{subtype}\" button triple clicked", + "remote_double_tap": "Device \"{subtype}\" double tapped", + "remote_falling": "Device in free fall", + "remote_gyro_activated": "Device shaken", + "remote_moved": "Device moved with \"{subtype}\" up", + "remote_rotate_from_side_1": "Device rotated from \"side 1\" to \"{subtype}\"", + "remote_rotate_from_side_2": "Device rotated from \"side 2\" to \"{subtype}\"", + "remote_rotate_from_side_3": "Device rotated from \"side 3\" to \"{subtype}\"", + "remote_rotate_from_side_4": "Device rotated from \"side 4\" to \"{subtype}\"", + "remote_rotate_from_side_5": "Device rotated from \"side 5\" to \"{subtype}\"", + "remote_rotate_from_side_6": "Device rotated from \"side 6\" to \"{subtype}\"" + } + }, + "options": { + "step": { + "async_step_deconz_devices": { + "data": { + "allow_clip_sensor": "Allow deCONZ CLIP sensors", + "allow_deconz_groups": "Allow deCONZ light groups" + }, + "description": "Configure visibility of deCONZ device types" + }, + "deconz_devices": { + "data": { + "allow_clip_sensor": "Allow deCONZ CLIP sensors", + "allow_deconz_groups": "Allow deCONZ light groups" + }, + "description": "Configure visibility of deCONZ device types" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/es-419.json b/homeassistant/components/deconz/.translations/es-419.json index ab47a5b43..448b654c8 100644 --- a/homeassistant/components/deconz/.translations/es-419.json +++ b/homeassistant/components/deconz/.translations/es-419.json @@ -2,30 +2,73 @@ "config": { "abort": { "already_configured": "El Bridge ya est\u00e1 configurado", + "already_in_progress": "El flujo de configuraci\u00f3n para el puente ya est\u00e1 en progreso.", "no_bridges": "No se descubrieron puentes deCONZ", - "one_instance_only": "El componente solo admite una instancia deCONZ" + "not_deconz_bridge": "No es un puente deCONZ", + "one_instance_only": "El componente solo admite una instancia deCONZ", + "updated_instance": "Instancia deCONZ actualizada con nueva direcci\u00f3n de host" }, "error": { "no_key": "No se pudo obtener una clave de API" }, "step": { + "hassio_confirm": { + "data": { + "allow_clip_sensor": "Permitir la importaci\u00f3n de sensores virtuales", + "allow_deconz_groups": "Permitir la importaci\u00f3n de grupos deCONZ" + }, + "description": "\u00bfDesea configurar Home Assistant para conectarse a la puerta de enlace deCONZ proporcionada por el complemento hass.io {addon}?", + "title": "deCONZ Zigbee gateway a trav\u00e9s del complemento Hass.io" + }, "init": { "data": { "host": "Host", - "port": "Puerto (valor predeterminado: '80')" + "port": "Puerto" }, "title": "Definir el gateway deCONZ" }, "link": { + "description": "Desbloquee su puerta de enlace deCONZ para registrarse con Home Assistant. \n\n 1. Vaya a Configuraci\u00f3n deCONZ - > Gateway - > Avanzado \n 2. Presione el bot\u00f3n \"Autenticar aplicaci\u00f3n\"", "title": "Enlazar con deCONZ" }, "options": { "data": { "allow_clip_sensor": "Permitir la importaci\u00f3n de sensores virtuales", "allow_deconz_groups": "Permitir la importaci\u00f3n de grupos deCONZ" - } + }, + "title": "Opciones de configuraci\u00f3n adicionales para deCONZ" } }, "title": "deCONZ Zigbee gateway" + }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "Ambos botones", + "button_1": "Primer bot\u00f3n", + "button_2": "Segundo bot\u00f3n", + "button_3": "Tercer bot\u00f3n", + "button_4": "Cuarto bot\u00f3n", + "close": "Cerrar", + "left": "Izquierda", + "open": "Abrir", + "right": "Derecha", + "turn_off": "Apagar", + "turn_on": "Encender" + }, + "trigger_type": { + "remote_button_rotated": "Bot\u00f3n girado \"{subtype}\"", + "remote_gyro_activated": "Dispositivo agitado" + } + }, + "options": { + "step": { + "async_step_deconz_devices": { + "data": { + "allow_clip_sensor": "Permitir sensores deCONZ CLIP", + "allow_deconz_groups": "Permitir grupos de luz deCONZ" + }, + "description": "Configurar la visibilidad de los tipos de dispositivos deCONZ" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/es.json b/homeassistant/components/deconz/.translations/es.json new file mode 100644 index 000000000..47fd99c48 --- /dev/null +++ b/homeassistant/components/deconz/.translations/es.json @@ -0,0 +1,109 @@ +{ + "config": { + "abort": { + "already_configured": "El puente ya esta configurado", + "already_in_progress": "El flujo de configuraci\u00f3n para el puente ya est\u00e1 en curso.", + "no_bridges": "No se han descubierto puentes deCONZ", + "not_deconz_bridge": "No es un puente deCONZ", + "one_instance_only": "El componente solo admite una instancia de deCONZ", + "updated_instance": "Instancia deCONZ actualizada con nueva direcci\u00f3n de host" + }, + "error": { + "no_key": "No se pudo obtener una clave API" + }, + "flow_title": "pasarela deCONZ Zigbee ({host})", + "step": { + "hassio_confirm": { + "data": { + "allow_clip_sensor": "Permitir importar sensores virtuales", + "allow_deconz_groups": "Permite importar grupos de deCONZ" + }, + "description": "\u00bfQuieres configurar Home Assistant para que se conecte al gateway de deCONZ proporcionado por el add-on {addon} de hass.io?", + "title": "Add-on deCONZ Zigbee v\u00eda Hass.io" + }, + "init": { + "data": { + "host": "Host", + "port": "Puerto" + }, + "title": "Definir pasarela deCONZ" + }, + "link": { + "description": "Desbloquea tu gateway de deCONZ para registrarte con Home Assistant.\n\n1. Dir\u00edgete a deCONZ Settings -> Gateway -> Advanced\n2. Pulsa el bot\u00f3n \"Authenticate app\"", + "title": "Enlazar con deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "Permitir importar sensores virtuales", + "allow_deconz_groups": "Permitir la importaci\u00f3n de grupos deCONZ" + }, + "title": "Opciones de configuraci\u00f3n adicionales para deCONZ" + } + }, + "title": "Pasarela Zigbee deCONZ" + }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "Ambos botones", + "button_1": "Primer bot\u00f3n", + "button_2": "Segundo bot\u00f3n", + "button_3": "Tercer bot\u00f3n", + "button_4": "Cuarto bot\u00f3n", + "close": "Cerrar", + "dim_down": "Bajar la intensidad", + "dim_up": "Subir la intensidad", + "left": "Izquierda", + "open": "Abierto", + "right": "Derecha", + "side_1": "Lado 1", + "side_2": "Lado 2", + "side_3": "Lado 3", + "side_4": "Lado 4", + "side_5": "Lado 5", + "side_6": "Lado 6", + "turn_off": "Apagar", + "turn_on": "Encender" + }, + "trigger_type": { + "remote_awakened": "Dispositivo despertado", + "remote_button_double_press": "Bot\u00f3n \"{subtype}\" pulsado dos veces consecutivas", + "remote_button_long_press": "Bot\u00f3n \"{subtype}\" pulsado continuamente", + "remote_button_long_release": "Bot\u00f3n \"{subtype}\" liberado despu\u00e9s de un rato pulsado", + "remote_button_quadruple_press": "Bot\u00f3n \"{subtype}\" pulsado cuatro veces consecutivas", + "remote_button_quintuple_press": "Bot\u00f3n \"{subtype}\" pulsado cinco veces consecutivas", + "remote_button_rotated": "Bot\u00f3n \"{subtype}\" girado", + "remote_button_rotation_stopped": "Bot\u00f3n rotativo \"{subtipo}\" detenido", + "remote_button_short_press": "Bot\u00f3n \"{subtype}\" pulsado", + "remote_button_short_release": "Bot\u00f3n \"{subtype}\" liberado", + "remote_button_triple_press": "Bot\u00f3n \"{subtype}\" pulsado cuatro veces consecutivas", + "remote_double_tap": "Dispositivo \" {subtype} \" doble pulsaci\u00f3n", + "remote_falling": "Dispositivo en ca\u00edda libre", + "remote_gyro_activated": "Dispositivo sacudido", + "remote_moved": "Dispositivo movido con \"{subtipo}\" hacia arriba", + "remote_rotate_from_side_1": "Dispositivo girado del \"lado 1\" al \" {subtype} \"", + "remote_rotate_from_side_2": "Dispositivo girado del \"lado 2\" al \" {subtype} \"", + "remote_rotate_from_side_3": "Dispositivo girado del \"lado 3\" al \" {subtype} \"", + "remote_rotate_from_side_4": "Dispositivo girado del \"lado 4\" al \" {subtype} \"", + "remote_rotate_from_side_5": "Dispositivo girado del \"lado 5\" al \" {subtype} \"", + "remote_rotate_from_side_6": "Dispositivo girado de \"lado 6\" a \" {subtype} \"" + } + }, + "options": { + "step": { + "async_step_deconz_devices": { + "data": { + "allow_clip_sensor": "Permitir sensores deCONZ CLIP", + "allow_deconz_groups": "Permitir grupos de luz deCONZ" + }, + "description": "Configurar la visibilidad de los tipos de dispositivos deCONZ" + }, + "deconz_devices": { + "data": { + "allow_clip_sensor": "Permitir sensores deCONZ CLIP", + "allow_deconz_groups": "Permitir grupos de luz deCONZ" + }, + "description": "Configurar la visibilidad de los tipos de dispositivos deCONZ" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/et.json b/homeassistant/components/deconz/.translations/et.json new file mode 100644 index 000000000..93c54b391 --- /dev/null +++ b/homeassistant/components/deconz/.translations/et.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "init": { + "data": { + "host": "", + "port": "" + } + } + }, + "title": "deCONZ Zigbee l\u00fc\u00fcs" + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/fr.json b/homeassistant/components/deconz/.translations/fr.json index 56399a3c6..1a4232e08 100644 --- a/homeassistant/components/deconz/.translations/fr.json +++ b/homeassistant/components/deconz/.translations/fr.json @@ -2,22 +2,34 @@ "config": { "abort": { "already_configured": "Ce pont est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "Le flux de configuration pour le pont est d\u00e9j\u00e0 en cours.", "no_bridges": "Aucun pont deCONZ n'a \u00e9t\u00e9 d\u00e9couvert", - "one_instance_only": "Le composant prend uniquement en charge une instance deCONZ" + "not_deconz_bridge": "Pas un pont deCONZ", + "one_instance_only": "Le composant prend uniquement en charge une instance deCONZ", + "updated_instance": "Instance deCONZ mise \u00e0 jour avec la nouvelle adresse d'h\u00f4te" }, "error": { "no_key": "Impossible d'obtenir une cl\u00e9 d'API" }, + "flow_title": "Passerelle deCONZ Zigbee ({host})", "step": { + "hassio_confirm": { + "data": { + "allow_clip_sensor": "Autoriser l'importation de capteurs virtuels", + "allow_deconz_groups": "Autoriser l'importation des groupes deCONZ" + }, + "description": "Voulez-vous configurer Home Assistant pour qu'il se connecte \u00e0 la passerelle deCONZ fournie par l'add-on hass.io {addon} ?", + "title": "Passerelle deCONZ Zigbee via l'add-on Hass.io" + }, "init": { "data": { "host": "H\u00f4te", - "port": "Port (valeur par d\u00e9faut : 80)" + "port": "Port" }, "title": "Initialiser la passerelle deCONZ" }, "link": { - "description": "D\u00e9verrouillez votre passerelle deCONZ pour vous enregistrer aupr\u00e8s de Home Assistant. \n\n 1. Acc\u00e9dez aux param\u00e8tres du syst\u00e8me deCONZ \n 2. Cliquez sur \"D\u00e9verrouiller la passerelle\"", + "description": "D\u00e9verrouillez votre passerelle deCONZ pour vous enregistrer avec Home Assistant. \n\n 1. Acc\u00e9dez aux param\u00e8tres avanc\u00e9s du syst\u00e8me deCONZ \n 2. Cliquez sur \"D\u00e9verrouiller la passerelle\"", "title": "Lien vers deCONZ" }, "options": { @@ -29,5 +41,69 @@ } }, "title": "Passerelle deCONZ Zigbee" + }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "Les deux boutons", + "button_1": "Premier bouton", + "button_2": "Deuxi\u00e8me bouton", + "button_3": "Troisi\u00e8me bouton", + "button_4": "Quatri\u00e8me bouton", + "close": "Ferm\u00e9", + "dim_down": "Assombrir", + "dim_up": "\u00c9claircir", + "left": "Gauche", + "open": "Ouvert", + "right": "Droite", + "side_1": "Face 1", + "side_2": "Face 2", + "side_3": "Face 3", + "side_4": "Face 4", + "side_5": "Face 5", + "side_6": "Face 6", + "turn_off": "\u00c9teint", + "turn_on": "Allum\u00e9" + }, + "trigger_type": { + "remote_awakened": "Appareil r\u00e9veill\u00e9", + "remote_button_double_press": "Bouton \"{subtype}\" double cliqu\u00e9", + "remote_button_long_press": "Bouton \"{subtype}\" appuy\u00e9 continuellement", + "remote_button_long_release": "Bouton \"{subtype}\" rel\u00e2ch\u00e9 apr\u00e8s appui long", + "remote_button_quadruple_press": "Bouton \"{subtype}\" quadruple cliqu\u00e9", + "remote_button_quintuple_press": "Bouton \"{subtype}\" quintuple cliqu\u00e9", + "remote_button_rotated": "Bouton \"{subtype}\" tourn\u00e9", + "remote_button_rotation_stopped": "La rotation du bouton \" {subtype} \" s'est arr\u00eat\u00e9e", + "remote_button_short_press": "Bouton \"{subtype}\" appuy\u00e9", + "remote_button_short_release": "Bouton \"{subtype}\" rel\u00e2ch\u00e9", + "remote_button_triple_press": "Bouton \"{subtype}\" triple cliqu\u00e9", + "remote_double_tap": "Appareil \"{subtype}\" tapot\u00e9 deux fois", + "remote_falling": "Appareil en chute libre", + "remote_gyro_activated": "Appareil secou\u00e9", + "remote_moved": "Appareil d\u00e9plac\u00e9 avec \"{subtype}\" vers le haut", + "remote_rotate_from_side_1": "Appareil tourn\u00e9 de \"c\u00f4t\u00e9 1\" \u00e0 \"{subtype}\"", + "remote_rotate_from_side_2": "Appareil tourn\u00e9 de \"c\u00f4t\u00e9 2\" \u00e0 \"{subtype}\"", + "remote_rotate_from_side_3": "Appareil tourn\u00e9 de \"c\u00f4t\u00e9 3\" \u00e0 \"{subtype}\"", + "remote_rotate_from_side_4": "Appareil tourn\u00e9 de \"c\u00f4t\u00e9 4\" \u00e0 \"{subtype}\"", + "remote_rotate_from_side_5": "Appareil tourn\u00e9 de \"c\u00f4t\u00e9 5\" \u00e0 \"{subtype}\"", + "remote_rotate_from_side_6": "Appareil tourn\u00e9 de \"c\u00f4t\u00e9 6\" \u00e0 \"{subtype}\"" + } + }, + "options": { + "step": { + "async_step_deconz_devices": { + "data": { + "allow_clip_sensor": "Autoriser les capteurs deCONZ CLIP", + "allow_deconz_groups": "Autoriser les groupes de lumi\u00e8res deCONZ" + }, + "description": "Configurer la visibilit\u00e9 des appareils de type deCONZ" + }, + "deconz_devices": { + "data": { + "allow_clip_sensor": "Autoriser les capteurs deCONZ CLIP", + "allow_deconz_groups": "Autoriser les groupes de lumi\u00e8res deCONZ" + }, + "description": "Configurer la visibilit\u00e9 des appareils de type deCONZ" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/he.json b/homeassistant/components/deconz/.translations/he.json index b4b3d54e0..89a2d6995 100644 --- a/homeassistant/components/deconz/.translations/he.json +++ b/homeassistant/components/deconz/.translations/he.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "\u05d4\u05de\u05d2\u05e9\u05e8 \u05db\u05d1\u05e8 \u05de\u05d5\u05d2\u05d3\u05e8", - "no_bridges": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05de\u05d2\u05e9\u05e8\u05d9 deCONZ" + "no_bridges": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05de\u05d2\u05e9\u05e8\u05d9 deCONZ", + "one_instance_only": "\u05d4\u05e8\u05db\u05d9\u05d1 \u05ea\u05d5\u05de\u05da \u05e8\u05e7 \u05d0\u05d7\u05d3 deCONZ \u05dc\u05de\u05e9\u05dc" }, "error": { "no_key": "\u05dc\u05d0 \u05e0\u05d9\u05ea\u05df \u05d4\u05d9\u05d4 \u05dc\u05e7\u05d1\u05dc \u05de\u05e4\u05ea\u05d7 API" diff --git a/homeassistant/components/deconz/.translations/hr.json b/homeassistant/components/deconz/.translations/hr.json new file mode 100644 index 000000000..2f2eb6df2 --- /dev/null +++ b/homeassistant/components/deconz/.translations/hr.json @@ -0,0 +1,17 @@ +{ + "config": { + "step": { + "init": { + "data": { + "host": "Host", + "port": "Port" + } + }, + "options": { + "data": { + "allow_clip_sensor": "Dopusti uvoz virtualnih senzora" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/hu.json b/homeassistant/components/deconz/.translations/hu.json index c1fd76c50..9e8109107 100644 --- a/homeassistant/components/deconz/.translations/hu.json +++ b/homeassistant/components/deconz/.translations/hu.json @@ -11,16 +11,28 @@ "step": { "init": { "data": { - "host": "H\u00e1zigazda (Host)", - "port": "Port (alap\u00e9rtelmezett \u00e9rt\u00e9k: '80')" + "host": "Hoszt", + "port": "Port" }, "title": "deCONZ \u00e1tj\u00e1r\u00f3 megad\u00e1sa" }, "link": { "description": "Oldja fel a deCONZ \u00e1tj\u00e1r\u00f3t a Home Assistant-ban val\u00f3 regisztr\u00e1l\u00e1shoz.\n\n1. Menjen a deCONZ rendszer be\u00e1ll\u00edt\u00e1sokhoz\n2. Nyomja meg az \"\u00c1tj\u00e1r\u00f3 felold\u00e1sa\" gombot", "title": "Kapcsol\u00f3d\u00e1s a deCONZ-hoz" + }, + "options": { + "data": { + "allow_clip_sensor": "Virtu\u00e1lis szenzorok import\u00e1l\u00e1s\u00e1nak enged\u00e9lyez\u00e9se", + "allow_deconz_groups": "deCONZ csoportok import\u00e1l\u00e1s\u00e1nak enged\u00e9lyez\u00e9se" + }, + "title": "Extra be\u00e1ll\u00edt\u00e1si lehet\u0151s\u00e9gek a deCONZhoz" } }, - "title": "deCONZ" + "title": "deCONZ Zigbee gateway" + }, + "device_automation": { + "trigger_subtype": { + "close": "Bez\u00e1r\u00e1s" + } } } \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/id.json b/homeassistant/components/deconz/.translations/id.json new file mode 100644 index 000000000..7d0b3163a --- /dev/null +++ b/homeassistant/components/deconz/.translations/id.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "Bridge sudah dikonfigurasi", + "no_bridges": "deCONZ bridges tidak ditemukan", + "one_instance_only": "Komponen hanya mendukung satu instance deCONZ" + }, + "error": { + "no_key": "Tidak bisa mendapatkan kunci API" + }, + "step": { + "init": { + "data": { + "host": "Host", + "port": "Port (nilai default: '80')" + }, + "title": "Tentukan deCONZ gateway" + }, + "link": { + "description": "Buka gerbang deCONZ Anda untuk mendaftar dengan Home Assistant. \n\n 1. Pergi ke pengaturan sistem deCONZ \n 2. Tekan tombol \"Buka Kunci Gateway\"", + "title": "Tautan dengan deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "Izinkan mengimpor sensor virtual", + "allow_deconz_groups": "Izinkan mengimpor grup deCONZ" + }, + "title": "Opsi konfigurasi tambahan untuk deCONZ" + } + }, + "title": "deCONZ Zigbee gateway" + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/it.json b/homeassistant/components/deconz/.translations/it.json index 87dcd0610..99e562212 100644 --- a/homeassistant/components/deconz/.translations/it.json +++ b/homeassistant/components/deconz/.translations/it.json @@ -2,22 +2,34 @@ "config": { "abort": { "already_configured": "Il Bridge \u00e8 gi\u00e0 configurato", + "already_in_progress": "Il flusso di configurazione per bridge \u00e8 gi\u00e0 in corso.", "no_bridges": "Nessun bridge deCONZ rilevato", - "one_instance_only": "Il componente supporto solo un'istanza di deCONZ" + "not_deconz_bridge": "Non \u00e8 un bridge deCONZ", + "one_instance_only": "Il componente supporto solo un'istanza di deCONZ", + "updated_instance": "Istanza deCONZ aggiornata con nuovo indirizzo host" }, "error": { "no_key": "Impossibile ottenere una API key" }, + "flow_title": "Gateway Zigbee deCONZ ({host})", "step": { + "hassio_confirm": { + "data": { + "allow_clip_sensor": "Consenti l'importazione di sensori virtuali", + "allow_deconz_groups": "Consenti l'importazione di gruppi deCONZ" + }, + "description": "Vuoi configurare Home Assistant per connettersi al gateway deCONZ fornito dal componente aggiuntivo di Hass.io: {addon}?", + "title": "Gateway Pigmee deCONZ tramite il componente aggiuntivo di Hass.io" + }, "init": { "data": { "host": "Host", - "port": "Porta (valore di default: '80')" + "port": "Porta" }, "title": "Definisci il gateway deCONZ" }, "link": { - "description": "Sblocca il tuo gateway deCONZ per registrarlo in Home Assistant.\n\n1. Vai nelle impostazioni di sistema di deCONZ\n2. Premi il bottone \"Unlock Gateway\"", + "description": "Sblocca il tuo gateway deCONZ per registrarti con Home Assistant.\n\n1. Vai a Impostazioni deCONZ -> Gateway -> Avanzate\n2. Premere il pulsante \"Autentica app\"", "title": "Collega con deCONZ" }, "options": { @@ -28,6 +40,70 @@ "title": "Opzioni di configurazione extra per deCONZ" } }, - "title": "deCONZ" + "title": "Gateway Zigbee deCONZ" + }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "Entrambi", + "button_1": "Primo", + "button_2": "Secondo pulsante", + "button_3": "Terzo pulsante", + "button_4": "Quarto pulsante", + "close": "Chiudere", + "dim_down": "Diminuire luminosit\u00e0", + "dim_up": "Aumentare luminosit\u00e0", + "left": "Sinistra", + "open": "Aperto", + "right": "Destra", + "side_1": "Lato 1", + "side_2": "Lato 2", + "side_3": "Lato 3", + "side_4": "Lato 4", + "side_5": "Lato 5", + "side_6": "Lato 6", + "turn_off": "Spegnere", + "turn_on": "Accendere" + }, + "trigger_type": { + "remote_awakened": "Dispositivo risvegliato", + "remote_button_double_press": "Pulsante \"{subtype}\" cliccato due volte", + "remote_button_long_press": "Pulsante \"{subtype}\" premuto continuamente", + "remote_button_long_release": "Pulsante \"{subtype}\" rilasciato dopo una lunga pressione", + "remote_button_quadruple_press": "Pulsante \"{subtype}\" cliccato quattro volte", + "remote_button_quintuple_press": "Pulsante \"{subtype}\" cliccato cinque volte", + "remote_button_rotated": "Pulsante ruotato \"{subtype}\"", + "remote_button_rotation_stopped": "La rotazione dei pulsanti \"{subtype}\" si \u00e8 arrestata", + "remote_button_short_press": "Pulsante \"{subtype}\" premuto", + "remote_button_short_release": "Pulsante \"{subtype}\" rilasciato", + "remote_button_triple_press": "Pulsante \"{subtype}\" cliccato tre volte", + "remote_double_tap": "Dispositivo \"{subtype}\" toccato due volte", + "remote_falling": "Dispositivo in caduta libera", + "remote_gyro_activated": "Dispositivo in vibrazione", + "remote_moved": "Dispositivo spostato con \"{subtype}\" verso l'alto", + "remote_rotate_from_side_1": "Dispositivo ruotato da \"lato 1\" a \"{subtype}\"", + "remote_rotate_from_side_2": "Dispositivo ruotato da \"lato 2\" a \"{subtype}\"", + "remote_rotate_from_side_3": "Dispositivo ruotato da \"lato 3\" a \"{subtype}\"", + "remote_rotate_from_side_4": "Dispositivo ruotato da \"lato 4\" a \"{subtype}\"", + "remote_rotate_from_side_5": "Dispositivo ruotato da \"lato 5\" a \"{subtype}\"", + "remote_rotate_from_side_6": "Dispositivo ruotato da \"lato 6\" a \"{subtype}\"" + } + }, + "options": { + "step": { + "async_step_deconz_devices": { + "data": { + "allow_clip_sensor": "Consentire sensori CLIP deCONZ", + "allow_deconz_groups": "Consentire gruppi luce deCONZ" + }, + "description": "Configurare la visibilit\u00e0 dei tipi di dispositivi deCONZ" + }, + "deconz_devices": { + "data": { + "allow_clip_sensor": "Consentire sensori CLIP deCONZ", + "allow_deconz_groups": "Consentire gruppi luce deCONZ" + }, + "description": "Configurare la visibilit\u00e0 dei tipi di dispositivi deCONZ" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/ko.json b/homeassistant/components/deconz/.translations/ko.json index a584a1db9..fede936b9 100644 --- a/homeassistant/components/deconz/.translations/ko.json +++ b/homeassistant/components/deconz/.translations/ko.json @@ -2,22 +2,34 @@ "config": { "abort": { "already_configured": "\ube0c\ub9bf\uc9c0\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "already_in_progress": "\ube0c\ub9bf\uc9c0 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589\uc911\uc785\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" + "not_deconz_bridge": "deCONZ \ube0c\ub9bf\uc9c0\uac00 \uc544\ub2d9\ub2c8\ub2e4", + "one_instance_only": "\uad6c\uc131\uc694\uc18c\ub294 \ud558\ub098\uc758 deCONZ \uc778\uc2a4\ud134\uc2a4\ub9cc \uc9c0\uc6d0\ud569\ub2c8\ub2e4", + "updated_instance": "deCONZ \uc778\uc2a4\ud134\uc2a4\ub97c \uc0c8\ub85c\uc6b4 \ud638\uc2a4\ud2b8 \uc8fc\uc18c\ub85c \uc5c5\ub370\uc774\ud2b8\ud588\uc2b5\ub2c8\ub2e4" }, "error": { "no_key": "API \ud0a4\ub97c \uac00\uc838\uc62c \uc218 \uc5c6\uc2b5\ub2c8\ub2e4" }, + "flow_title": "deCONZ Zigbee \uac8c\uc774\ud2b8\uc6e8\uc774 ({host})", "step": { + "hassio_confirm": { + "data": { + "allow_clip_sensor": "\uac00\uc0c1 \uc13c\uc11c \uac00\uc838\uc624\uae30 \ud5c8\uc6a9", + "allow_deconz_groups": "deCONZ \uadf8\ub8f9 \uac00\uc838\uc624\uae30 \ud5c8\uc6a9" + }, + "description": "Hass.io {addon} \uc560\ub4dc\uc628\uc73c\ub85c deCONZ \uac8c\uc774\ud2b8\uc6e8\uc774\uc5d0 \uc5f0\uacb0\ud558\ub3c4\ub85d Home Assistant \ub97c \uad6c\uc131 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "title": "Hass.io \uc560\ub4dc\uc628\uc758 deCONZ Zigbee \uac8c\uc774\ud2b8\uc6e8\uc774" + }, "init": { "data": { "host": "\ud638\uc2a4\ud2b8", - "port": "\ud3ec\ud2b8 (\uae30\ubcf8\uac12: '80')" + "port": "\ud3ec\ud2b8" }, "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 ", + "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. \"Authenticate app\" \ubc84\ud2bc\uc744 \ub20c\ub7ec\uc8fc\uc138\uc694", "title": "deCONZ\uc640 \uc5f0\uacb0" }, "options": { @@ -25,9 +37,73 @@ "allow_clip_sensor": "\uac00\uc0c1 \uc13c\uc11c \uac00\uc838\uc624\uae30 \ud5c8\uc6a9", "allow_deconz_groups": "deCONZ \uadf8\ub8f9 \uac00\uc838\uc624\uae30 \ud5c8\uc6a9" }, - "title": "deCONZ\ub97c \uc704\ud55c \ucd94\uac00 \uad6c\uc131 \uc635\uc158" + "title": "deCONZ \ucd94\uac00 \uad6c\uc131 \uc635\uc158" } }, "title": "deCONZ Zigbee \uac8c\uc774\ud2b8\uc6e8\uc774" + }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "\ub450 \uac1c", + "button_1": "\uccab \ubc88\uc9f8", + "button_2": "\ub450 \ubc88\uc9f8", + "button_3": "\uc138 \ubc88\uc9f8", + "button_4": "\ub124 \ubc88\uc9f8", + "close": "\ub2eb\uae30", + "dim_down": "\uc5b4\ub461\uac8c \ud558\uae30", + "dim_up": "\ubc1d\uac8c \ud558\uae30", + "left": "\uc67c\ucabd", + "open": "\uc5f4\uae30", + "right": "\uc624\ub978\ucabd", + "side_1": "\uba74 1", + "side_2": "\uba74 2", + "side_3": "\uba74 3", + "side_4": "\uba74 4", + "side_5": "\uba74 5", + "side_6": "\uba74 6", + "turn_off": "\ub044\uae30", + "turn_on": "\ucf1c\uae30" + }, + "trigger_type": { + "remote_awakened": "\uae30\uae30 \uc808\uc804 \ubaa8\ub4dc \ud574\uc81c\ub428", + "remote_button_double_press": "\"{subtype}\" \ubc84\ud2bc\uc744 \ub450 \ubc88 \ub204\ub984", + "remote_button_long_press": "\"{subtype}\" \ubc84\ud2bc\uc744 \uacc4\uc18d \ub204\ub984", + "remote_button_long_release": "\"{subtype}\" \ubc84\ud2bc\uc744 \uae38\uac8c \ub20c\ub800\ub2e4\uac00 \ub5cc", + "remote_button_quadruple_press": "\"{subtype}\" \ubc84\ud2bc\uc744 \ub124 \ubc88 \ub204\ub984", + "remote_button_quintuple_press": "\"{subtype}\" \ubc84\ud2bc\uc744 \ub2e4\uc12f \ubc88 \ub204\ub984", + "remote_button_rotated": "\"{subtype}\" \ubc84\ud2bc\uc744 \ud68c\uc804", + "remote_button_rotation_stopped": "\"{subtype}\" \ubc84\ud2bc\uc744 \ud68c\uc804 \uc815\uc9c0", + "remote_button_short_press": "\"{subtype}\" \ubc84\ud2bc\uc744 \ub204\ub984", + "remote_button_short_release": "\"{subtype}\" \ubc84\ud2bc\uc744 \ub5cc", + "remote_button_triple_press": "\"{subtype}\" \ubc84\ud2bc\uc744 \uc138 \ubc88 \ub204\ub984", + "remote_double_tap": "\uae30\uae30\uc758 \"{subtype}\" \uac00 \ub354\ube14\ud0ed \ub428", + "remote_falling": "\uae30\uae30\uac00 \ub5a8\uc5b4\uc9d0", + "remote_gyro_activated": "\uae30\uae30 \ud754\ub4e6", + "remote_moved": "\uae30\uae30\uc758 \"{subtype}\" \uac00 \uc704\ub85c \ud5a5\ud55c\ucc44\ub85c \uc6c0\uc9c1\uc784", + "remote_rotate_from_side_1": "\"\uba74 1\" \uc5d0\uc11c \"{subtype}\" \ub85c \uae30\uae30\uac00 \ud68c\uc804\ub428", + "remote_rotate_from_side_2": "\"\uba74 2\" \uc5d0\uc11c \"{subtype}\" \ub85c \uae30\uae30\uac00 \ud68c\uc804\ub428", + "remote_rotate_from_side_3": "\"\uba74 3\" \uc5d0\uc11c \"{subtype}\" \ub85c \uae30\uae30\uac00 \ud68c\uc804\ub428", + "remote_rotate_from_side_4": "\"\uba74 4\" \uc5d0\uc11c \"{subtype}\" \ub85c \uae30\uae30\uac00 \ud68c\uc804\ub428", + "remote_rotate_from_side_5": "\"\uba74 5\" \uc5d0\uc11c \"{subtype}\" \ub85c \uae30\uae30\uac00 \ud68c\uc804\ub428", + "remote_rotate_from_side_6": "\"\uba74 6\" \uc5d0\uc11c \"{subtype}\" \ub85c \uae30\uae30\uac00 \ud68c\uc804\ub428" + } + }, + "options": { + "step": { + "async_step_deconz_devices": { + "data": { + "allow_clip_sensor": "deCONZ CLIP \uc13c\uc11c \ud5c8\uc6a9", + "allow_deconz_groups": "deCONZ \ub77c\uc774\ud2b8 \uadf8\ub8f9 \ud5c8\uc6a9" + }, + "description": "deCONZ \uae30\uae30 \uc720\ud615\uc758 \ud45c\uc2dc \uc5ec\ubd80 \uad6c\uc131" + }, + "deconz_devices": { + "data": { + "allow_clip_sensor": "deCONZ CLIP \uc13c\uc11c \ud5c8\uc6a9", + "allow_deconz_groups": "deCONZ \ub77c\uc774\ud2b8 \uadf8\ub8f9 \ud5c8\uc6a9" + }, + "description": "deCONZ \uae30\uae30 \uc720\ud615\uc758 \ud45c\uc2dc \uc5ec\ubd80 \uad6c\uc131" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/lb.json b/homeassistant/components/deconz/.translations/lb.json index 3de7de9dd..07f88732c 100644 --- a/homeassistant/components/deconz/.translations/lb.json +++ b/homeassistant/components/deconz/.translations/lb.json @@ -2,22 +2,34 @@ "config": { "abort": { "already_configured": "Bridge ass schon konfigur\u00e9iert", + "already_in_progress": "Konfiguratioun fir d\u00ebsen Apparat ass schonn am gaang.", "no_bridges": "Keng dECONZ bridges fonnt", - "one_instance_only": "Komponent \u00ebnnerst\u00ebtzt n\u00ebmmen eng deCONZ Instanz" + "not_deconz_bridge": "Keng deCONZ Bridge", + "one_instance_only": "Komponent \u00ebnnerst\u00ebtzt n\u00ebmmen eng deCONZ Instanz", + "updated_instance": "deCONZ Instanz gouf mat der neier Adress vum Apparat ge\u00e4nnert" }, "error": { "no_key": "Konnt keen API Schl\u00ebssel kr\u00e9ien" }, + "flow_title": "deCONZ Zigbee gateway ({host})", "step": { + "hassio_confirm": { + "data": { + "allow_clip_sensor": "Erlaabt den Import vun virtuellen Sensoren", + "allow_deconz_groups": "Erlaabt den Import vun deCONZ Gruppen" + }, + "description": "W\u00ebllt dir Home Assistant konfigur\u00e9iere fir sech mat der deCONZ gateway ze verbannen d\u00e9i vum hass.io add-on {addon} bereet gestallt g\u00ebtt?", + "title": "deCONZ Zigbee gateway via Hass.io add-on" + }, "init": { "data": { "host": "Host", - "port": "Port (Standard Wert: '80')" + "port": "Port" }, "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", + "description": "Entsperrt \u00e4r deCONZ gateway fir se mat Home Assistant ze registr\u00e9ieren.\n\n1. Gidd op deCONZ System Astellungen\n2. Dr\u00e9ckt \"Unlock\" Gateway Kn\u00e4ppchen", "title": "Link mat deCONZ" }, "options": { @@ -28,6 +40,70 @@ "title": "Extra Konfiguratiouns Optiounen fir deCONZ" } }, - "title": "deCONZ" + "title": "deCONZ Zigbee gateway" + }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "B\u00e9id Kn\u00e4ppchen", + "button_1": "\u00c9ischte Kn\u00e4ppchen", + "button_2": "Zweete Kn\u00e4ppchen", + "button_3": "Dr\u00ebtte Kn\u00e4ppchen", + "button_4": "V\u00e9ierte Kn\u00e4ppchen", + "close": "Zoumaachen", + "dim_down": "Verd\u00e4ischteren", + "dim_up": "Erhellen", + "left": "L\u00e9nks", + "open": "Op", + "right": "Riets", + "side_1": "S\u00e4it 1", + "side_2": "S\u00e4it 2", + "side_3": "S\u00e4it 3", + "side_4": "S\u00e4it 4", + "side_5": "S\u00e4it 5", + "side_6": "S\u00e4it 6", + "turn_off": "Ausschalten", + "turn_on": "Uschalten" + }, + "trigger_type": { + "remote_awakened": "Apparat erw\u00e4cht", + "remote_button_double_press": "\"{subtype}\" Kn\u00e4ppche zwee mol gedr\u00e9ckt", + "remote_button_long_press": "\"{subtype}\" Kn\u00e4ppche permanent gedr\u00e9ckt", + "remote_button_long_release": "\"{subtype}\" Kn\u00e4ppche no laangem unhalen lassgelooss", + "remote_button_quadruple_press": "\"{subtype}\" Kn\u00e4ppche v\u00e9ier mol gedr\u00e9ckt", + "remote_button_quintuple_press": "\"{subtype}\" Kn\u00e4ppche f\u00ebnnef mol gedr\u00e9ckt", + "remote_button_rotated": "Kn\u00e4ppche gedr\u00e9int \"{subtype}\"", + "remote_button_rotation_stopped": "Kn\u00e4ppchen Rotatioun \"{subtype}\" gestoppt", + "remote_button_short_press": "\"{subtype}\" Kn\u00e4ppche gedr\u00e9ckt", + "remote_button_short_release": "\"{subtype}\" Kn\u00e4ppche lassgelooss", + "remote_button_triple_press": "\"{subtype}\" Kn\u00e4ppche dr\u00e4imol gedr\u00e9ckt", + "remote_double_tap": "Apparat \"{subtype}\" zwee mol gedr\u00e9ckt", + "remote_falling": "Apparat am fr\u00e4ie Fall", + "remote_gyro_activated": "Apparat ger\u00ebselt", + "remote_moved": "Apparat beweegt mat \"{subtype}\" erop", + "remote_rotate_from_side_1": "Apparat rot\u00e9iert vun der \"S\u00e4it 1\" op \"{subtype}\"", + "remote_rotate_from_side_2": "Apparat rot\u00e9iert vun der \"S\u00e4it 2\" op \"{subtype}\"", + "remote_rotate_from_side_3": "Apparat rot\u00e9iert vun der \"S\u00e4it 3\" op \"{subtype}\"", + "remote_rotate_from_side_4": "Apparat rot\u00e9iert vun der \"S\u00e4it 4\" op \"{subtype}\"", + "remote_rotate_from_side_5": "Apparat rot\u00e9iert vun der \"S\u00e4it 5\" op \"{subtype}\"", + "remote_rotate_from_side_6": "Apparat rot\u00e9iert vun der \"S\u00e4it\" 6 op \"{subtype}\"" + } + }, + "options": { + "step": { + "async_step_deconz_devices": { + "data": { + "allow_clip_sensor": "deCONZ Clip Sensoren erlaben", + "allow_deconz_groups": "deCONZ Luucht Gruppen erlaben" + }, + "description": "Visibilit\u00e9it vun deCONZ Apparater konfigur\u00e9ieren" + }, + "deconz_devices": { + "data": { + "allow_clip_sensor": "deCONZ Clip Sensoren erlaben", + "allow_deconz_groups": "deCONZ Luucht Gruppen erlaben" + }, + "description": "Visibilit\u00e9it vun deCONZ Apparater konfigur\u00e9ieren" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/nl.json b/homeassistant/components/deconz/.translations/nl.json index 9084d22f4..c0ee391b0 100644 --- a/homeassistant/components/deconz/.translations/nl.json +++ b/homeassistant/components/deconz/.translations/nl.json @@ -2,22 +2,34 @@ "config": { "abort": { "already_configured": "Bridge is al geconfigureerd", + "already_in_progress": "Configuratiestroom voor bridge wordt al ingesteld.", "no_bridges": "Geen deCONZ bruggen ontdekt", - "one_instance_only": "Component ondersteunt slechts \u00e9\u00e9n deCONZ instance" + "not_deconz_bridge": "Dit is geen deCONZ bridge", + "one_instance_only": "Component ondersteunt slechts \u00e9\u00e9n deCONZ instance", + "updated_instance": "DeCONZ-instantie bijgewerkt met nieuw host-adres" }, "error": { "no_key": "Kon geen API-sleutel ophalen" }, + "flow_title": "deCONZ Zigbee gateway ( {host} )", "step": { + "hassio_confirm": { + "data": { + "allow_clip_sensor": "Sta het importeren van virtuele sensoren toe", + "allow_deconz_groups": "Sta de import van deCONZ-groepen toe" + }, + "description": "Wilt u de Home Assistant configureren om verbinding te maken met de deCONZ gateway van de hass.io add-on {addon}?", + "title": "deCONZ Zigbee Gateway via Hass.io add-on" + }, "init": { "data": { "host": "Host", - "port": "Poort (standaard: '80')" + "port": "Poort" }, "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\"", + "description": "Ontgrendel je deCONZ gateway om te registreren met Home Assistant.\n\n1. Ga naar deCONZ systeeminstellingen (Instellingen -> Gateway -> Geavanceerd)\n2. Druk op de knop \"Gateway ontgrendelen\"", "title": "Koppel met deCONZ" }, "options": { @@ -29,5 +41,69 @@ } }, "title": "deCONZ Zigbee gateway" + }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "Beide knoppen", + "button_1": "Eerste knop", + "button_2": "Tweede knop", + "button_3": "Derde knop", + "button_4": "Vierde knop", + "close": "Sluiten", + "dim_down": "Dim omlaag", + "dim_up": "Dim omhoog", + "left": "Links", + "open": "Open", + "right": "Rechts", + "side_1": "Zijde 1", + "side_2": "Zijde 2", + "side_3": "Zijde 3", + "side_4": "Zijde 4", + "side_5": "Zijde 5", + "side_6": "Zijde 6", + "turn_off": "Uitschakelen", + "turn_on": "Inschakelen" + }, + "trigger_type": { + "remote_awakened": "Apparaat is gewekt", + "remote_button_double_press": "\"{subtype}\" knop dubbel geklikt", + "remote_button_long_press": "\" {subtype} \" knop continu ingedrukt", + "remote_button_long_release": "\"{subtype}\" knop losgelaten na lang indrukken van de knop", + "remote_button_quadruple_press": "\" {subtype} \" knop viervoudig aangeklikt", + "remote_button_quintuple_press": "\" {subtype} \" knop vijf keer aangeklikt", + "remote_button_rotated": "Knop gedraaid \" {subtype} \"", + "remote_button_rotation_stopped": "Knoprotatie \" {subtype} \" gestopt", + "remote_button_short_press": "\" {subtype} \" knop ingedrukt", + "remote_button_short_release": "\"{subtype}\" knop losgelaten", + "remote_button_triple_press": "\" {subtype} \" knop driemaal geklikt", + "remote_double_tap": "Apparaat \"{subtype}\" dubbel getikt", + "remote_falling": "Apparaat in vrije val", + "remote_gyro_activated": "Apparaat geschud", + "remote_moved": "Apparaat verplaatst met \"{subtype}\" omhoog", + "remote_rotate_from_side_1": "Apparaat gedraaid van \"zijde 1\" naar \"{subtype}\"\".", + "remote_rotate_from_side_2": "Apparaat gedraaid van \"zijde 2\" naar \"{subtype}\"\".", + "remote_rotate_from_side_3": "Apparaat gedraaid van \"zijde 3\" naar \" {subtype} \"", + "remote_rotate_from_side_4": "Apparaat gedraaid van \"zijde 4\" naar \" {subtype} \"", + "remote_rotate_from_side_5": "Apparaat gedraaid van \"zijde 5\" naar \" {subtype} \"", + "remote_rotate_from_side_6": "Apparaat gedraaid van \"zijde 6\" naar \" {subtype} \"" + } + }, + "options": { + "step": { + "async_step_deconz_devices": { + "data": { + "allow_clip_sensor": "DeCONZ CLIP sensoren toestaan", + "allow_deconz_groups": "DeCONZ-lichtgroepen toestaan" + }, + "description": "De zichtbaarheid van deCONZ-apparaattypen configureren" + }, + "deconz_devices": { + "data": { + "allow_clip_sensor": "DeCONZ CLIP sensoren toestaan", + "allow_deconz_groups": "Sta deCONZ-lichtgroepen toe" + }, + "description": "Configureer de zichtbaarheid van deCONZ-apparaattypen" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/nn.json b/homeassistant/components/deconz/.translations/nn.json new file mode 100644 index 000000000..46933ced4 --- /dev/null +++ b/homeassistant/components/deconz/.translations/nn.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "Brua er allereie konfigurert", + "no_bridges": "Oppdaga ingen deCONZ-bruer", + "one_instance_only": "Komponenten st\u00f8ttar berre \u00e9in deCONZ-instans" + }, + "error": { + "no_key": "Kunne ikkje f\u00e5 ein API-n\u00f8kkel" + }, + "step": { + "init": { + "data": { + "host": "Vert", + "port": "Port (standardverdi: '80')" + }, + "title": "Definer deCONZ-gateway" + }, + "link": { + "description": "L\u00e5s opp deCONZ-gatewayen din for \u00e5 registrere den med Home Assistant.\n\n1. G\u00e5 til systeminnstillingane til deCONZ\n2. Trykk p\u00e5 \"L\u00e5s opp gateway\"-knappen", + "title": "Link med deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "Tillat importering av virtuelle sensorar", + "allow_deconz_groups": "Tillat \u00e5 importera deCONZ-grupper" + }, + "title": "Ekstra konfigurasjonsalternativ for deCONZ" + } + }, + "title": "deCONZ Zigbee gateway" + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/no.json b/homeassistant/components/deconz/.translations/no.json index 27868814e..2c1dd6874 100644 --- a/homeassistant/components/deconz/.translations/no.json +++ b/homeassistant/components/deconz/.translations/no.json @@ -2,22 +2,34 @@ "config": { "abort": { "already_configured": "Broen er allerede konfigurert", + "already_in_progress": "Konfigurasjonsflyt for bro p\u00e5g\u00e5r allerede.", "no_bridges": "Ingen deCONZ broer oppdaget", - "one_instance_only": "Komponenten st\u00f8tter bare \u00e9n deCONZ forekomst" + "not_deconz_bridge": "Ikke en deCONZ bro", + "one_instance_only": "Komponenten st\u00f8tter bare \u00e9n deCONZ forekomst", + "updated_instance": "Oppdatert deCONZ forekomst med ny vertsadresse" }, "error": { "no_key": "Kunne ikke f\u00e5 en API-n\u00f8kkel" }, + "flow_title": "deCONZ Zigbee gateway ({host})", "step": { + "hassio_confirm": { + "data": { + "allow_clip_sensor": "Tillat import av virtuelle sensorer", + "allow_deconz_groups": "Tillat import av deCONZ grupper" + }, + "description": "\u00d8nsker du \u00e5 konfigurere Home Assistent for \u00e5 koble til deCONZ gateway gitt av Hass.io tillegget {addon}?", + "title": "deCONZ Zigbee gateway via Hass.io tillegg" + }, "init": { "data": { "host": "Vert", - "port": "Port (standardverdi: '80')" + "port": "Port" }, "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", + "description": "L\u00e5s opp deCONZ-gatewayen din for \u00e5 registrere deg med Home Assistant. \n\n 1. G\u00e5 til deCONZ-systeminnstillinger -> Gateway -> Avansert \n 2. Trykk p\u00e5 \"L\u00e5s opp gateway\" knappen", "title": "Koble til deCONZ" }, "options": { @@ -29,5 +41,69 @@ } }, "title": "deCONZ Zigbee gateway" + }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "Begge knappene", + "button_1": "F\u00f8rste knapp", + "button_2": "Andre knapp", + "button_3": "Tredje knapp", + "button_4": "Fjerde knapp", + "close": "Lukk", + "dim_down": "Dimm ned", + "dim_up": "Dimm opp", + "left": "Venstre", + "open": "\u00c5pen", + "right": "H\u00f8yre", + "side_1": "Side 1", + "side_2": "Side 2", + "side_3": "Side 3", + "side_4": "Side 4", + "side_5": "Side 5", + "side_6": "Side 6", + "turn_off": "Skru av", + "turn_on": "Sl\u00e5 p\u00e5" + }, + "trigger_type": { + "remote_awakened": "Enheten ble vekket", + "remote_button_double_press": "\"{subtype}\"-knappen ble dobbeltklikket", + "remote_button_long_press": "\"{subtype}\"-knappen ble kontinuerlig trykket", + "remote_button_long_release": "\"{subtype}\"-knappen sluppet etter langt trykk", + "remote_button_quadruple_press": "\"{subtype}\"-knappen ble firedoblet klikket", + "remote_button_quintuple_press": "\"{subtype}\"-knappen femdobbelt klikket", + "remote_button_rotated": "Knappen roterte \"{subtype}\"", + "remote_button_rotation_stopped": "Knapperotasjon \"{subtype}\" stoppet", + "remote_button_short_press": "\"{subtype}\" -knappen ble trykket", + "remote_button_short_release": "\"{subtype}\"-knappen sluppet", + "remote_button_triple_press": "\"{subtype}\"-knappen trippel klikket", + "remote_double_tap": "Enheten \" {subtype} \" dobbeltklikket", + "remote_falling": "Enheten er i fritt fall", + "remote_gyro_activated": "Enhet er ristet", + "remote_moved": "Enheten ble flyttet med \"{under type}\" opp", + "remote_rotate_from_side_1": "Enheten rotert fra \"side 1\" til \" {subtype} \"", + "remote_rotate_from_side_2": "Enheten rotert fra \"side 2\" til \" {subtype} \"", + "remote_rotate_from_side_3": "Enheten rotert fra \"side 3\" til \" {subtype} \"", + "remote_rotate_from_side_4": "Enheten rotert fra \"side 4\" til \" {subtype} \"", + "remote_rotate_from_side_5": "Enheten rotert fra \"side 5\" til \" {subtype} \"", + "remote_rotate_from_side_6": "Enheten rotert fra \"side 6\" til \" {subtype} \"" + } + }, + "options": { + "step": { + "async_step_deconz_devices": { + "data": { + "allow_clip_sensor": "Tillat deCONZ CLIP-sensorer", + "allow_deconz_groups": "Tillat deCONZ lys grupper" + }, + "description": "Konfigurere synlighet av deCONZ enhetstyper" + }, + "deconz_devices": { + "data": { + "allow_clip_sensor": "Tillat deCONZ CLIP-sensorer", + "allow_deconz_groups": "Tillat deCONZ lys grupper" + }, + "description": "Konfigurere synlighet av deCONZ enhetstyper" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/pl.json b/homeassistant/components/deconz/.translations/pl.json index 5dd87d9e4..eafecf87d 100644 --- a/homeassistant/components/deconz/.translations/pl.json +++ b/homeassistant/components/deconz/.translations/pl.json @@ -2,22 +2,34 @@ "config": { "abort": { "already_configured": "Mostek jest ju\u017c skonfigurowany", + "already_in_progress": "Konfigurowanie mostka jest ju\u017c w toku.", "no_bridges": "Nie odkryto mostk\u00f3w deCONZ", - "one_instance_only": "Komponent obs\u0142uguje tylko jedn\u0105 instancj\u0119 deCONZ" + "not_deconz_bridge": "To nie jest mostek deCONZ", + "one_instance_only": "Komponent obs\u0142uguje tylko jedn\u0105 instancj\u0119 deCONZ", + "updated_instance": "Zaktualizowano instancj\u0119 deCONZ o nowy adres hosta" }, "error": { "no_key": "Nie mo\u017cna uzyska\u0107 klucza API" }, + "flow_title": "Bramka deCONZ Zigbee ({host})", "step": { + "hassio_confirm": { + "data": { + "allow_clip_sensor": "Zezwalaj na importowanie wirtualnych sensor\u00f3w", + "allow_deconz_groups": "Zezwalaj na importowanie grup deCONZ" + }, + "description": "Czy chcesz skonfigurowa\u0107 Home Assistant, aby po\u0142\u0105czy\u0142 si\u0119 z bramk\u0105 deCONZ dostarczon\u0105 przez dodatek Hass.io {addon}?", + "title": "Bramka deCONZ Zigbee przez dodatek Hass.io" + }, "init": { "data": { "host": "Host", - "port": "Port (warto\u015b\u0107 domy\u015blna: \"80\")" + "port": "Port" }, "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\"", + "description": "Odblokuj bramk\u0119 deCONZ, aby zarejestrowa\u0107 j\u0105 w Home Assistant. \n\n 1. Przejd\u017a do ustawienia deCONZ > bramka > Zaawansowane\n 2. Naci\u015bnij przycisk \"Uwierzytelnij aplikacj\u0119\"", "title": "Po\u0142\u0105cz z deCONZ" }, "options": { @@ -28,6 +40,70 @@ "title": "Dodatkowe opcje konfiguracji dla deCONZ" } }, - "title": "deCONZ" + "title": "Brama deCONZ Zigbee" + }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "oba przyciski", + "button_1": "pierwszy przycisk", + "button_2": "drugi przycisk", + "button_3": "trzeci przycisk", + "button_4": "czwarty przycisk", + "close": "nast\u0105pi zamkni\u0119cie", + "dim_down": "nast\u0105pi zmniejszenie jasno\u015bci", + "dim_up": "nast\u0105pi zwi\u0119kszenie jasno\u015bci", + "left": "w lewo", + "open": "otwarcie", + "right": "w prawo", + "side_1": "strona 1", + "side_2": "strona 2", + "side_3": "strona 3", + "side_4": "strona 4", + "side_5": "strona 5", + "side_6": "strona 6", + "turn_off": "nast\u0105pi wy\u0142\u0105czenie", + "turn_on": "nast\u0105pi w\u0142\u0105czenie" + }, + "trigger_type": { + "remote_awakened": "urz\u0105dzenie si\u0119 obudzi", + "remote_button_double_press": "przycisk \"{subtype}\" zostanie podw\u00f3jnie naci\u015bni\u0119ty", + "remote_button_long_press": "przycisk \"{subtype}\" zostanie naci\u015bni\u0119ty w spos\u00f3b ci\u0105g\u0142y", + "remote_button_long_release": "przycisk \"{subtype}\" zostanie zwolniony po d\u0142ugim naci\u015bni\u0119ciu", + "remote_button_quadruple_press": "przycisk \"{subtype}\" zostanie czterokrotnie naci\u015bni\u0119ty", + "remote_button_quintuple_press": "przycisk \"{subtype}\" zostanie pi\u0119ciokrotnie naci\u015bni\u0119ty", + "remote_button_rotated": "przycisk zostanie obr\u00f3cony \"{subtype}\"", + "remote_button_rotation_stopped": "nast\u0105pi zatrzymanie obrotu przycisku \"{subtype}\"", + "remote_button_short_press": "przycisk \"{subtype}\" zostanie naci\u015bni\u0119ty", + "remote_button_short_release": "przycisk \"{subtype}\" zostanie zwolniony", + "remote_button_triple_press": "przycisk \"{subtype}\" zostanie trzykrotnie naci\u015bni\u0119ty", + "remote_double_tap": "urz\u0105dzenie \"{subtype}\" zostanie dwukrotnie pukni\u0119te", + "remote_falling": "urz\u0105dzenie zarejestruje swobodny spadek", + "remote_gyro_activated": "nast\u0105pi potrz\u0105\u015bni\u0119cie urz\u0105dzeniem", + "remote_moved": "urz\u0105dzenie poruszone z \"{subtype}\" w g\u00f3r\u0119", + "remote_rotate_from_side_1": "urz\u0105dzenie obr\u00f3cone ze \"strona 1\" na \"{subtype}\"", + "remote_rotate_from_side_2": "urz\u0105dzenie obr\u00f3cone ze \"strona 2\" na \"{subtype}\"", + "remote_rotate_from_side_3": "urz\u0105dzenie obr\u00f3cone ze \"strona 3\" na \"{subtype}\"", + "remote_rotate_from_side_4": "urz\u0105dzenie obr\u00f3cone ze \"strona 4\" na \"{subtype}\"", + "remote_rotate_from_side_5": "urz\u0105dzenie obr\u00f3cone ze \"strona 5\" na \"{subtype}\"", + "remote_rotate_from_side_6": "urz\u0105dzenie obr\u00f3cone ze \"strona 6\" na \"{subtype}\"" + } + }, + "options": { + "step": { + "async_step_deconz_devices": { + "data": { + "allow_clip_sensor": "Zezwalaj na czujniki deCONZ CLIP", + "allow_deconz_groups": "Zezwalaj na grupy \u015bwiate\u0142 deCONZ" + }, + "description": "Skonfiguruj widoczno\u015b\u0107 urz\u0105dze\u0144 deCONZ" + }, + "deconz_devices": { + "data": { + "allow_clip_sensor": "Zezwalaj na czujniki deCONZ CLIP", + "allow_deconz_groups": "Zezwalaj na grupy \u015bwiate\u0142 deCONZ" + }, + "description": "Skonfiguruj widoczno\u015b\u0107 typ\u00f3w urz\u0105dze\u0144 deCONZ" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/pt-BR.json b/homeassistant/components/deconz/.translations/pt-BR.json index be79e7e46..8d54c4708 100644 --- a/homeassistant/components/deconz/.translations/pt-BR.json +++ b/homeassistant/components/deconz/.translations/pt-BR.json @@ -2,13 +2,24 @@ "config": { "abort": { "already_configured": "A ponte j\u00e1 est\u00e1 configurada", + "already_in_progress": "Fluxo de configura\u00e7\u00e3o para ponte j\u00e1 est\u00e1 em andamento.", "no_bridges": "N\u00e3o h\u00e1 pontes de deCONZ descobertas", - "one_instance_only": "Componente suporta apenas uma inst\u00e2ncia deCONZ" + "not_deconz_bridge": "N\u00e3o \u00e9 uma ponte deCONZ", + "one_instance_only": "Componente suporta apenas uma inst\u00e2ncia deCONZ", + "updated_instance": "Atualiza\u00e7\u00e3o da inst\u00e2ncia deCONZ com novo endere\u00e7o de host" }, "error": { "no_key": "N\u00e3o foi poss\u00edvel obter uma chave de API" }, "step": { + "hassio_confirm": { + "data": { + "allow_clip_sensor": "Permitir a importa\u00e7\u00e3o de sensores virtuais", + "allow_deconz_groups": "Permitir a importa\u00e7\u00e3o de grupos deCONZ" + }, + "description": "Deseja configurar o Home Assistant para conectar-se ao gateway deCONZ fornecido pelo add-on hass.io {addon} ?", + "title": "Gateway deCONZ Zigbee via add-on Hass.io" + }, "init": { "data": { "host": "Hospedeiro", @@ -29,5 +40,16 @@ } }, "title": "Gateway deCONZ Zigbee" + }, + "options": { + "step": { + "async_step_deconz_devices": { + "data": { + "allow_clip_sensor": "Permitir sensores deCONZ CLIP", + "allow_deconz_groups": "Permitir grupos de luz deCONZ" + }, + "description": "Configurar visibilidade dos tipos de dispositivos deCONZ" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/pt.json b/homeassistant/components/deconz/.translations/pt.json index 1f7b82090..f24d7692a 100644 --- a/homeassistant/components/deconz/.translations/pt.json +++ b/homeassistant/components/deconz/.translations/pt.json @@ -12,22 +12,33 @@ "init": { "data": { "host": "Servidor", - "port": "Porta (por omiss\u00e3o: '80')" + "port": "Porta" }, "title": "Defina o gateway deCONZ" }, "link": { "description": "Desbloqueie o seu gateway deCONZ para se registar no Home Assistant. \n\n 1. V\u00e1 para as configura\u00e7\u00f5es do sistema deCONZ \n 2. Pressione o bot\u00e3o \"Desbloquear Gateway\"", - "title": "Link com deCONZ" + "title": "Liga\u00e7\u00e3o com deCONZ" }, "options": { "data": { "allow_clip_sensor": "Permitir a importa\u00e7\u00e3o de sensores virtuais", "allow_deconz_groups": "Permitir a importa\u00e7\u00e3o de grupos deCONZ" }, - "title": "Op\u00e7\u00f5es extra de configura\u00e7\u00e3o para deCONZ" + "title": "Op\u00e7\u00f5es de configura\u00e7\u00e3o extra para deCONZ" } }, - "title": "deCONZ" + "title": "Gateway Zigbee deCONZ" + }, + "device_automation": { + "trigger_subtype": { + "left": "Esquerda", + "side_1": "Lado 1", + "side_2": "Lado 2", + "side_3": "Lado 3", + "side_4": "Lado 4", + "side_5": "Lado 5", + "side_6": "Lado 6" + } } } \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/ro.json b/homeassistant/components/deconz/.translations/ro.json new file mode 100644 index 000000000..2d6fc6a39 --- /dev/null +++ b/homeassistant/components/deconz/.translations/ro.json @@ -0,0 +1,14 @@ +{ + "config": { + "step": { + "init": { + "data": { + "port": "Port" + } + }, + "link": { + "description": "Debloca\u021bi gateway-ul DECONZ pentru a v\u0103 \u00eenregistra la Home Assistant. \n\n 1. Accesa\u021bi Set\u0103rile deCONZ - > Gateway - > Avansat \n 2. Ap\u0103sa\u021bi butonul \u201eAutentifica\u021bi aplica\u021bia\u201d" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/ru.json b/homeassistant/components/deconz/.translations/ru.json index 56490f67c..f0398e530 100644 --- a/homeassistant/components/deconz/.translations/ru.json +++ b/homeassistant/components/deconz/.translations/ru.json @@ -1,23 +1,35 @@ { "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" + "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0448\u043b\u044e\u0437\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.", + "already_in_progress": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0448\u043b\u044e\u0437\u0430 \u0443\u0436\u0435 \u043d\u0430\u0447\u0430\u0442\u0430.", + "no_bridges": "\u0428\u043b\u044e\u0437\u044b deCONZ \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b.", + "not_deconz_bridge": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u044f\u0432\u043b\u044f\u0435\u0442\u0441\u044f \u0448\u043b\u044e\u0437\u043e\u043c deCONZ.", + "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.", + "updated_instance": "\u0410\u0434\u0440\u0435\u0441 \u0445\u043e\u0441\u0442\u0430 deCONZ \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d." }, "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" + "no_key": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u043a\u043b\u044e\u0447 API." }, + "flow_title": "\u0428\u043b\u044e\u0437 Zigbee deCONZ ({host})", "step": { + "hassio_confirm": { + "data": { + "allow_clip_sensor": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u044c \u0438\u043c\u043f\u043e\u0440\u0442 \u0432\u0438\u0440\u0442\u0443\u0430\u043b\u044c\u043d\u044b\u0445 \u0434\u0430\u0442\u0447\u0438\u043a\u043e\u0432", + "allow_deconz_groups": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u044c \u0438\u043c\u043f\u043e\u0440\u0442 \u0433\u0440\u0443\u043f\u043f deCONZ" + }, + "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043a deCONZ (\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0435 \u0434\u043b\u044f Hass.io \"{addon}\")?", + "title": "Zigbee \u0448\u043b\u044e\u0437 deCONZ (\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0435 \u0434\u043b\u044f Hass.io)" + }, "init": { "data": { "host": "\u0425\u043e\u0441\u0442", - "port": "\u041f\u043e\u0440\u0442 (\u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e: '80')" + "port": "\u041f\u043e\u0440\u0442" }, - "title": "\u041e\u043f\u0440\u0435\u0434\u0435\u043b\u0438\u0442\u044c \u0448\u043b\u044e\u0437 deCONZ" + "title": "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", + "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 -> Gateway -> Advanced.\n2. \u041d\u0430\u0436\u043c\u0438\u0442\u0435 \u043a\u043d\u043e\u043f\u043a\u0443 \u00abAuthenticate app\u00bb.", "title": "\u0421\u0432\u044f\u0437\u044c \u0441 deCONZ" }, "options": { @@ -29,5 +41,69 @@ } }, "title": "deCONZ" + }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "\u041e\u0431\u0435 \u043a\u043d\u043e\u043f\u043a\u0438", + "button_1": "\u041f\u0435\u0440\u0432\u0430\u044f \u043a\u043d\u043e\u043f\u043a\u0430", + "button_2": "\u0412\u0442\u043e\u0440\u0430\u044f \u043a\u043d\u043e\u043f\u043a\u0430", + "button_3": "\u0422\u0440\u0435\u0442\u044c\u044f \u043a\u043d\u043e\u043f\u043a\u0430", + "button_4": "\u0427\u0435\u0442\u0432\u0435\u0440\u0442\u0430\u044f \u043a\u043d\u043e\u043f\u043a\u0430", + "close": "\u0417\u0430\u043a\u0440\u044b\u0432\u0430\u0435\u0442\u0441\u044f", + "dim_down": "\u042f\u0440\u043a\u043e\u0441\u0442\u044c \u0443\u043c\u0435\u043d\u044c\u0448\u0430\u0435\u0442\u0441\u044f", + "dim_up": "\u042f\u0440\u043a\u043e\u0441\u0442\u044c \u0443\u0432\u0435\u043b\u0438\u0447\u0438\u0432\u0430\u0435\u0442\u0441\u044f", + "left": "\u041d\u0430\u043b\u0435\u0432\u043e", + "open": "\u041e\u0442\u043a\u0440\u044b\u0432\u0430\u0435\u0442\u0441\u044f", + "right": "\u041d\u0430\u043f\u0440\u0430\u0432\u043e", + "side_1": "\u0413\u0440\u0430\u043d\u044c 1", + "side_2": "\u0413\u0440\u0430\u043d\u044c 2", + "side_3": "\u0413\u0440\u0430\u043d\u044c 3", + "side_4": "\u0413\u0440\u0430\u043d\u044c 4", + "side_5": "\u0413\u0440\u0430\u043d\u044c 5", + "side_6": "\u0413\u0440\u0430\u043d\u044c 6", + "turn_off": "\u0412\u044b\u043a\u043b\u044e\u0447\u0430\u0435\u0442\u0441\u044f", + "turn_on": "\u0412\u043a\u043b\u044e\u0447\u0430\u0435\u0442\u0441\u044f" + }, + "trigger_type": { + "remote_awakened": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0440\u0430\u0437\u0431\u0443\u0434\u0438\u043b\u0438", + "remote_button_double_press": "\"{subtype}\" \u043d\u0430\u0436\u0430\u0442\u0430 \u0434\u0432\u0430 \u0440\u0430\u0437\u0430", + "remote_button_long_press": "\"{subtype}\" \u043d\u0435\u043f\u0440\u0435\u0440\u044b\u0432\u043d\u043e \u043d\u0430\u0436\u0430\u0442\u0430", + "remote_button_long_release": "\"{subtype}\" \u043e\u0442\u043f\u0443\u0449\u0435\u043d\u0430 \u043f\u043e\u0441\u043b\u0435 \u043d\u0435\u043f\u0440\u0435\u0440\u044b\u0432\u043d\u043e\u0433\u043e \u043d\u0430\u0436\u0430\u0442\u0438\u044f", + "remote_button_quadruple_press": "\"{subtype}\" \u043d\u0430\u0436\u0430\u0442\u0430 \u0447\u0435\u0442\u044b\u0440\u0435 \u0440\u0430\u0437\u0430", + "remote_button_quintuple_press": "\"{subtype}\" \u043d\u0430\u0436\u0430\u0442\u0430 \u043f\u044f\u0442\u044c \u0440\u0430\u0437", + "remote_button_rotated": "\"{subtype}\" \u0432\u0440\u0430\u0449\u0430\u0435\u0442\u0441\u044f", + "remote_button_rotation_stopped": "\"{subtype}\" \u043f\u0440\u0435\u043a\u0440\u0430\u0442\u0438\u043b\u0430 \u0432\u0440\u0430\u0449\u0435\u043d\u0438\u0435", + "remote_button_short_press": "\"{subtype}\" \u043d\u0430\u0436\u0430\u0442\u0430", + "remote_button_short_release": "\"{subtype}\" \u043e\u0442\u043f\u0443\u0449\u0435\u043d\u0430", + "remote_button_triple_press": "\"{subtype}\" \u043d\u0430\u0436\u0430\u0442\u0430 \u0442\u0440\u0438 \u0440\u0430\u0437\u0430", + "remote_double_tap": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c \"{subtype}\" \u043f\u043e\u0441\u0442\u0443\u0447\u0430\u043b\u0438 \u0434\u0432\u0430\u0436\u0434\u044b", + "remote_falling": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0432 \u0441\u0432\u043e\u0431\u043e\u0434\u043d\u043e\u043c \u043f\u0430\u0434\u0435\u043d\u0438\u0438", + "remote_gyro_activated": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0432\u0441\u0442\u0440\u044f\u0445\u043d\u0443\u043b\u0438", + "remote_moved": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0441\u0434\u0432\u0438\u043d\u0443\u043b\u0438, \u043a\u043e\u0433\u0434\u0430 \"{subtype}\" \u0441\u0432\u0435\u0440\u0445\u0443", + "remote_rotate_from_side_1": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043f\u0435\u0440\u0435\u0432\u0435\u0440\u043d\u0443\u043b\u0438 \u0441 \u0413\u0440\u0430\u043d\u0438 1 \u043d\u0430 \"{subtype}\"", + "remote_rotate_from_side_2": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043f\u0435\u0440\u0435\u0432\u0435\u0440\u043d\u0443\u043b\u0438 \u0441 \u0413\u0440\u0430\u043d\u0438 2 \u043d\u0430 \"{subtype}\"", + "remote_rotate_from_side_3": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043f\u0435\u0440\u0435\u0432\u0435\u0440\u043d\u0443\u043b\u0438 \u0441 \u0413\u0440\u0430\u043d\u0438 3 \u043d\u0430 \"{subtype}\"", + "remote_rotate_from_side_4": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043f\u0435\u0440\u0435\u0432\u0435\u0440\u043d\u0443\u043b\u0438 \u0441 \u0413\u0440\u0430\u043d\u0438 4 \u043d\u0430 \"{subtype}\"", + "remote_rotate_from_side_5": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043f\u0435\u0440\u0435\u0432\u0435\u0440\u043d\u0443\u043b\u0438 \u0441 \u0413\u0440\u0430\u043d\u0438 5 \u043d\u0430 \"{subtype}\"", + "remote_rotate_from_side_6": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043f\u0435\u0440\u0435\u0432\u0435\u0440\u043d\u0443\u043b\u0438 \u0441 \u0413\u0440\u0430\u043d\u0438 6 \u043d\u0430 \"{subtype}\"" + } + }, + "options": { + "step": { + "async_step_deconz_devices": { + "data": { + "allow_clip_sensor": "\u041e\u0442\u043e\u0431\u0440\u0430\u0436\u0430\u0442\u044c \u0434\u0430\u0442\u0447\u0438\u043a\u0438 deCONZ CLIP", + "allow_deconz_groups": "\u041e\u0442\u043e\u0431\u0440\u0430\u0436\u0430\u0442\u044c \u0433\u0440\u0443\u043f\u043f\u044b \u043e\u0441\u0432\u0435\u0449\u0435\u043d\u0438\u044f deCONZ" + }, + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0432\u0438\u0434\u0438\u043c\u043e\u0441\u0442\u0438 \u0442\u0438\u043f\u043e\u0432 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432 deCONZ" + }, + "deconz_devices": { + "data": { + "allow_clip_sensor": "\u041e\u0442\u043e\u0431\u0440\u0430\u0436\u0430\u0442\u044c \u0434\u0430\u0442\u0447\u0438\u043a\u0438 deCONZ CLIP", + "allow_deconz_groups": "\u041e\u0442\u043e\u0431\u0440\u0430\u0436\u0430\u0442\u044c \u0433\u0440\u0443\u043f\u043f\u044b \u043e\u0441\u0432\u0435\u0449\u0435\u043d\u0438\u044f deCONZ" + }, + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0432\u0438\u0434\u0438\u043c\u043e\u0441\u0442\u0438 \u0442\u0438\u043f\u043e\u0432 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432 deCONZ" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/sl.json b/homeassistant/components/deconz/.translations/sl.json index bc7a2cbd8..0edfc11af 100644 --- a/homeassistant/components/deconz/.translations/sl.json +++ b/homeassistant/components/deconz/.translations/sl.json @@ -2,22 +2,34 @@ "config": { "abort": { "already_configured": "Most je \u017ee nastavljen", + "already_in_progress": "Konfiguracijski tok za most je \u017ee v teku.", "no_bridges": "Ni odkritih mostov deCONZ", - "one_instance_only": "Komponenta podpira le en primerek deCONZ" + "not_deconz_bridge": "Ni deCONZ most", + "one_instance_only": "Komponenta podpira le en primerek deCONZ", + "updated_instance": "Posodobljen deCONZ z novim naslovom gostitelja" }, "error": { "no_key": "Klju\u010da API ni mogo\u010de dobiti" }, + "flow_title": "deCONZ Zigbee prehod ({host})", "step": { + "hassio_confirm": { + "data": { + "allow_clip_sensor": "Dovoli uvoz virtualnih senzorjev", + "allow_deconz_groups": "Dovoli uvoz deCONZ skupin" + }, + "description": "\u017delite konfigurirati Home Assistant-a za povezavo z deCONZ prehodom, ki ga ponuja hass.io dodatek {addon} ?", + "title": "deCONZ Zigbee prehod preko dodatka Hass.io" + }, "init": { "data": { "host": "Gostitelj", - "port": "Vrata (privzeta vrednost: '80')" + "port": "Vrata" }, "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\"", + "description": "Odklenite va\u0161 deCONZ gateway za registracijo s Home Assistant-om. \n1. Pojdite v deCONZ sistemske nastavitve\n2. Pritisnite tipko \"odkleni prehod\"", "title": "Povezava z deCONZ" }, "options": { @@ -28,6 +40,70 @@ "title": "Dodatne mo\u017enosti konfiguracije za deCONZ" } }, - "title": "deCONZ" + "title": "deCONZ Zigbee prehod" + }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "Oba gumba", + "button_1": "Prvi gumb", + "button_2": "Drugi gumb", + "button_3": "Tretji gumb", + "button_4": "\u010cetrti gumb", + "close": "Zapri", + "dim_down": "Zatemnite", + "dim_up": "pove\u010dajte mo\u010d", + "left": "Levo", + "open": "Odprto", + "right": "Desno", + "side_1": "Stran 1", + "side_2": "Stran 2", + "side_3": "Stran 3", + "side_4": "Stran 4", + "side_5": "Stran 5", + "side_6": "Stran 6", + "turn_off": "Ugasni", + "turn_on": "Pri\u017egi" + }, + "trigger_type": { + "remote_awakened": "Naprava se je prebudila", + "remote_button_double_press": "Dvakrat kliknete gumb \"{subtype}\"", + "remote_button_long_press": "\"{subtype}\" gumb neprekinjeno pritisnjen", + "remote_button_long_release": "\"{subtype}\" gumb spro\u0161\u010den po dolgem pritisku", + "remote_button_quadruple_press": "\"{subtype}\" gumb \u0161tirikrat kliknjen", + "remote_button_quintuple_press": "\"{subtype}\" gumb petkrat kliknjen", + "remote_button_rotated": "Gumb \"{subtype}\" zasukan", + "remote_button_rotation_stopped": "Vrtenje \"{subtype}\" gumba se je ustavilo", + "remote_button_short_press": "Pritisnjen \"{subtype}\" gumb", + "remote_button_short_release": "Gumb \"{subtype}\" spro\u0161\u010den", + "remote_button_triple_press": "Gumb \"{subtype}\" trikrat kliknjen", + "remote_double_tap": "Naprava \"{subtype}\" dvakrat dotaknjena", + "remote_falling": "Naprava v prostem padu", + "remote_gyro_activated": "Naprava se je pretresla", + "remote_moved": "Naprava je premaknjena s \"{subtype}\" navzgor", + "remote_rotate_from_side_1": "Naprava je zasukana iz \"strani 1\" v \"{subtype}\"", + "remote_rotate_from_side_2": "Naprava je zasukana iz \"strani 2\" v \"{subtype}\"", + "remote_rotate_from_side_3": "Naprava je zasukana iz \"strani 3\" v \"{subtype}\"", + "remote_rotate_from_side_4": "Naprava je zasukana iz \"strani 4\" v \"{subtype}\"", + "remote_rotate_from_side_5": "Naprava je zasukana iz \"strani 5\" v \"{subtype}\"", + "remote_rotate_from_side_6": "Naprava je zasukana iz \"strani 6\" v \"{subtype}\"" + } + }, + "options": { + "step": { + "async_step_deconz_devices": { + "data": { + "allow_clip_sensor": "Dovoli deCONZ CLIP senzorje", + "allow_deconz_groups": "Dovolite deCONZ skupine lu\u010di" + }, + "description": "Konfiguracija vidnosti tipov naprav deCONZ" + }, + "deconz_devices": { + "data": { + "allow_clip_sensor": "Dovoli deCONZ CLIP senzorje", + "allow_deconz_groups": "Dovolite deCONZ skupine lu\u010di" + }, + "description": "Konfiguracija vidnosti tipov naprav deCONZ" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/sv.json b/homeassistant/components/deconz/.translations/sv.json index 88cf8742a..a7b5160e8 100644 --- a/homeassistant/components/deconz/.translations/sv.json +++ b/homeassistant/components/deconz/.translations/sv.json @@ -2,13 +2,24 @@ "config": { "abort": { "already_configured": "Bryggan \u00e4r redan konfigurerad", + "already_in_progress": "Konfigurations fl\u00f6det f\u00f6r bryggan p\u00e5g\u00e5r redan.", "no_bridges": "Inga deCONZ-bryggor uppt\u00e4cktes", - "one_instance_only": "Komponenten st\u00f6djer endast en deCONZ-instans" + "not_deconz_bridge": "Inte en deCONZ-brygga", + "one_instance_only": "Komponenten st\u00f6djer endast en deCONZ-instans", + "updated_instance": "Uppdaterad deCONZ-instans med ny v\u00e4rdadress" }, "error": { "no_key": "Det gick inte att ta emot en API-nyckel" }, "step": { + "hassio_confirm": { + "data": { + "allow_clip_sensor": "Till\u00e5t import av virtuella sensorer", + "allow_deconz_groups": "Till\u00e5t import av deCONZ-grupper" + }, + "description": "Vill du konfigurera Home Assistant f\u00f6r att ansluta till deCONZ gateway som tillhandah\u00e5lls av hass.io till\u00e4gg {addon}?", + "title": "deCONZ Zigbee gateway via Hass.io till\u00e4gg" + }, "init": { "data": { "host": "V\u00e4rd", @@ -28,6 +39,6 @@ "title": "Extra konfigurationsalternativ f\u00f6r deCONZ" } }, - "title": "deCONZ" + "title": "deCONZ Zigbee Gateway" } } \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/th.json b/homeassistant/components/deconz/.translations/th.json new file mode 100644 index 000000000..e40765e82 --- /dev/null +++ b/homeassistant/components/deconz/.translations/th.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "init": { + "data": { + "port": "Port" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/zh-Hant.json b/homeassistant/components/deconz/.translations/zh-Hant.json index 5cd1a14d4..0a0e40a8d 100644 --- a/homeassistant/components/deconz/.translations/zh-Hant.json +++ b/homeassistant/components/deconz/.translations/zh-Hant.json @@ -2,22 +2,34 @@ "config": { "abort": { "already_configured": "Bridge \u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "already_in_progress": "Bridge \u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d\u3002", "no_bridges": "\u672a\u641c\u5c0b\u5230 deCONZ Bridfe", - "one_instance_only": "\u7d44\u4ef6\u50c5\u652f\u63f4\u4e00\u7d44 deCONZ \u5be6\u4f8b" + "not_deconz_bridge": "\u975e deCONZ Bridge \u8a2d\u5099", + "one_instance_only": "\u7d44\u4ef6\u50c5\u652f\u63f4\u4e00\u7d44 deCONZ \u7269\u4ef6", + "updated_instance": "\u4f7f\u7528\u65b0\u4e3b\u6a5f\u7aef\u4f4d\u5740\u66f4\u65b0 deCONZ \u7269\u4ef6" }, "error": { "no_key": "\u7121\u6cd5\u53d6\u5f97 API key" }, + "flow_title": "deCONZ Zigbee \u9598\u9053\u5668\uff08{host}\uff09", "step": { + "hassio_confirm": { + "data": { + "allow_clip_sensor": "\u5141\u8a31\u532f\u5165\u865b\u64ec\u611f\u61c9\u5668", + "allow_deconz_groups": "\u5141\u8a31\u532f\u5165 deCONZ \u7fa4\u7d44" + }, + "description": "\u662f\u5426\u8981\u8a2d\u5b9a Home Assistant \u4ee5\u9023\u7dda\u81f3 Hass.io \u9644\u52a0\u7d44\u4ef6 {addon} \u4e4b deCONZ \u9598\u9053\u5668\uff1f", + "title": "\u900f\u904e Hass.io \u9644\u52a0\u7d44\u4ef6 deCONZ Zigbee \u9598\u9053\u5668" + }, "init": { "data": { "host": "\u4e3b\u6a5f\u7aef", - "port": "\u901a\u8a0a\u57e0\uff08\u9810\u8a2d\u503c\uff1a'80'\uff09" + "port": "\u901a\u8a0a\u57e0" }, - "title": "\u5b9a\u7fa9 deCONZ \u7db2\u95dc" + "title": "\u5b9a\u7fa9 deCONZ \u9598\u9053\u5668" }, "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", + "description": "\u89e3\u9664 deCONZ \u9598\u9053\u5668\u9396\u5b9a\uff0c\u4ee5\u65bc Home Assistant \u9032\u884c\u8a3b\u518a\u3002\n\n1. \u9032\u5165 deCONZ \u7cfb\u7d71\u8a2d\u5b9a -> \u9598\u9053\u5668 -> \u9032\u968e\u8a2d\u5b9a\n2. \u6309\u4e0b\u300c\u8a8d\u8b49\u7a0b\u5f0f\uff08Authenticate app\uff09\u300d\u6309\u9215", "title": "\u9023\u7d50\u81f3 deCONZ" }, "options": { @@ -28,6 +40,70 @@ "title": "deCONZ \u9644\u52a0\u8a2d\u5b9a\u9078\u9805" } }, - "title": "deCONZ" + "title": "deCONZ Zigbee \u9598\u9053\u5668" + }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "\u5169\u500b\u6309\u9215", + "button_1": "\u7b2c\u4e00\u500b\u6309\u9215", + "button_2": "\u7b2c\u4e8c\u500b\u6309\u9215", + "button_3": "\u7b2c\u4e09\u500b\u6309\u9215", + "button_4": "\u7b2c\u56db\u500b\u6309\u9215", + "close": "\u95dc\u9589", + "dim_down": "\u8abf\u6697", + "dim_up": "\u8abf\u4eae", + "left": "\u5de6", + "open": "\u958b\u555f", + "right": "\u53f3", + "side_1": "\u7b2c 1 \u9762", + "side_2": "\u7b2c 2 \u9762", + "side_3": "\u7b2c 3 \u9762", + "side_4": "\u7b2c 4 \u9762", + "side_5": "\u7b2c 5 \u9762", + "side_6": "\u7b2c 6 \u9762", + "turn_off": "\u95dc\u9589", + "turn_on": "\u958b\u555f" + }, + "trigger_type": { + "remote_awakened": "\u8a2d\u5099\u5df2\u559a\u9192", + "remote_button_double_press": "\"{subtype}\" \u6309\u9215\u96d9\u64ca", + "remote_button_long_press": "\"{subtype}\" \u6309\u9215\u6301\u7e8c\u6309\u4e0b", + "remote_button_long_release": "\u9577\u6309\u5f8c\u91cb\u653e \"{subtype}\" \u6309\u9215", + "remote_button_quadruple_press": "\"{subtype}\" \u6309\u9215\u56db\u9023\u9ede\u64ca", + "remote_button_quintuple_press": "\"{subtype}\" \u6309\u9215\u4e94\u9023\u9ede\u64ca", + "remote_button_rotated": "\u65cb\u8f49 \"{subtype}\" \u6309\u9215", + "remote_button_rotation_stopped": "\u65cb\u8f49 \"{subtype}\" \u6309\u9215\u5df2\u505c\u6b62", + "remote_button_short_press": "\"{subtype}\" \u6309\u9215\u5df2\u6309\u4e0b", + "remote_button_short_release": "\"{subtype}\" \u6309\u9215\u5df2\u91cb\u653e", + "remote_button_triple_press": "\"{subtype}\" \u6309\u9215\u4e09\u9023\u9ede\u64ca", + "remote_double_tap": "\u8a2d\u5099 \"{subtype}\" \u96d9\u6572", + "remote_falling": "\u8a2d\u5099\u81ea\u7531\u843d\u4e0b", + "remote_gyro_activated": "\u8a2d\u5099\u6416\u6643", + "remote_moved": "\u8a2d\u5099\u79fb\u52d5\u81f3 \"{subtype}\" \u671d\u4e0a", + "remote_rotate_from_side_1": "\u8a2d\u5099\u7531\u300c\u7b2c 1 \u9762\u300d\u65cb\u8f49\u81f3\u300c{subtype}\u300d", + "remote_rotate_from_side_2": "\u8a2d\u5099\u7531\u300c\u7b2c 2 \u9762\u300d\u65cb\u8f49\u81f3\u300c{subtype}\u300d", + "remote_rotate_from_side_3": "\u8a2d\u5099\u7531\u300c\u7b2c 3 \u9762\u300d\u65cb\u8f49\u81f3\u300c{subtype}\u300d", + "remote_rotate_from_side_4": "\u8a2d\u5099\u7531\u300c\u7b2c 4 \u9762\u300d\u65cb\u8f49\u81f3\u300c{subtype}\u300d", + "remote_rotate_from_side_5": "\u8a2d\u5099\u7531\u300c\u7b2c 5 \u9762\u300d\u65cb\u8f49\u81f3\u300c{subtype}\u300d", + "remote_rotate_from_side_6": "\u8a2d\u5099\u7531\u300c\u7b2c 6 \u9762\u300d\u65cb\u8f49\u81f3\u300c{subtype}\u300d" + } + }, + "options": { + "step": { + "async_step_deconz_devices": { + "data": { + "allow_clip_sensor": "\u5141\u8a31 deCONZ CLIP \u611f\u61c9\u5668", + "allow_deconz_groups": "\u5141\u8a31 deCONZ \u71c8\u5149\u7fa4\u7d44" + }, + "description": "\u8a2d\u5b9a deCONZ \u53ef\u8996\u8a2d\u5099\u985e\u578b" + }, + "deconz_devices": { + "data": { + "allow_clip_sensor": "\u5141\u8a31 deCONZ CLIP \u611f\u61c9\u5668", + "allow_deconz_groups": "\u5141\u8a31 deCONZ \u71c8\u5149\u7fa4\u7d44" + }, + "description": "\u8a2d\u5b9a deCONZ \u53ef\u8996\u8a2d\u5099\u985e\u578b" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/deconz/__init__.py b/homeassistant/components/deconz/__init__.py index 82f4233a7..0ea91d10b 100644 --- a/homeassistant/components/deconz/__init__.py +++ b/homeassistant/components/deconz/__init__.py @@ -1,73 +1,20 @@ -""" -Support for deCONZ devices. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/deconz/ -""" +"""Support for deCONZ devices.""" import voluptuous as vol -from homeassistant import config_entries -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.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, async_dispatcher_send) -from homeassistant.util import slugify -from homeassistant.util.json import load_json +from homeassistant.const import EVENT_HOMEASSISTANT_STOP -# Loading the config flow file will register the flow -from .config_flow import configured_hosts -from .const import ( - CONF_ALLOW_CLIP_SENSOR, CONFIG_FILE, DATA_DECONZ_EVENT, - DATA_DECONZ_ID, DATA_DECONZ_UNSUB, DOMAIN, _LOGGER) +from .config_flow import get_master_gateway +from .const import CONF_BRIDGEID, CONF_MASTER_GATEWAY, CONF_UUID, DOMAIN +from .gateway import DeconzGateway, get_gateway_from_config_entry +from .services import async_setup_services, async_unload_services -REQUIREMENTS = ['pydeconz==47'] - -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, -}) - -SERVICE_DEVICE_REFRESH = 'device_refresh' +CONFIG_SCHEMA = vol.Schema( + {DOMAIN: vol.Schema({}, extra=vol.ALLOW_EXTRA)}, extra=vol.ALLOW_EXTRA +) 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, - context={'source': config_entries.SOURCE_IMPORT}, - data=deconz_config - )) + """Old way of setting up deCONZ integrations.""" return True @@ -77,199 +24,61 @@ async def async_setup_entry(hass, 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") + if DOMAIN not in hass.data: + hass.data[DOMAIN] = {} + + if not config_entry.options: + await async_update_master_gateway(hass, config_entry) + + gateway = DeconzGateway(hass, config_entry) + + if not await gateway.async_setup(): return False - @callback - def async_add_device_callback(device_type, device): - """Handle event of new device creation in deCONZ.""" - if not isinstance(device, list): - device = [device] - async_dispatcher_send( - hass, 'deconz_new_{}'.format(device_type), device) + hass.data[DOMAIN][gateway.bridgeid] = gateway - 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() + await gateway.async_update_device_registry() - if result is False: - return False + if CONF_UUID not in config_entry.data: + await async_add_uuid_to_config_entry(hass, config_entry) - hass.data[DOMAIN] = deconz - hass.data[DATA_DECONZ_ID] = {} - hass.data[DATA_DECONZ_EVENT] = [] - hass.data[DATA_DECONZ_UNSUB] = [] + await async_setup_services(hass) - for component in ['binary_sensor', 'light', 'scene', 'sensor', 'switch']: - hass.async_create_task(hass.config_entries.async_forward_entry_setup( - config_entry, component)) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, gateway.shutdown) - @callback - def async_add_remote(sensors): - """Set up remote from deCONZ.""" - from pydeconz.sensor import SWITCH as DECONZ_REMOTE - allow_clip_sensor = config_entry.data.get(CONF_ALLOW_CLIP_SENSOR, True) - for sensor in sensors: - if sensor.type in DECONZ_REMOTE and \ - not (not allow_clip_sensor and sensor.type.startswith('CLIP')): - hass.data[DATA_DECONZ_EVENT].append(DeconzEvent(hass, sensor)) - hass.data[DATA_DECONZ_UNSUB].append( - async_dispatcher_connect(hass, 'deconz_new_sensor', async_add_remote)) - - async_add_remote(deconz.sensors.values()) - - deconz.start() - - device_registry = await \ - hass.helpers.device_registry.async_get_registry() - device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - connections={(CONNECTION_NETWORK_MAC, deconz.config.mac)}, - identifiers={(DOMAIN, deconz.config.bridgeid)}, - manufacturer='Dresden Elektronik', model=deconz.config.modelid, - name=deconz.config.name, sw_version=deconz.config.swversion) - - 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) - - async def async_refresh_devices(call): - """Refresh available devices from deCONZ.""" - deconz = hass.data[DOMAIN] - - groups = list(deconz.groups.keys()) - lights = list(deconz.lights.keys()) - scenes = list(deconz.scenes.keys()) - sensors = list(deconz.sensors.keys()) - - if not await deconz.async_load_parameters(): - return - - async_add_device_callback( - 'group', [group - for group_id, group in deconz.groups.items() - if group_id not in groups] - ) - - async_add_device_callback( - 'light', [light - for light_id, light in deconz.lights.items() - if light_id not in lights] - ) - - async_add_device_callback( - 'scene', [scene - for scene_id, scene in deconz.scenes.items() - if scene_id not in scenes] - ) - - async_add_device_callback( - 'sensor', [sensor - for sensor_id, sensor in deconz.sensors.items() - if sensor_id not in sensors] - ) - - hass.services.async_register( - DOMAIN, SERVICE_DEVICE_REFRESH, async_refresh_devices) - - @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() + gateway = hass.data[DOMAIN].pop(config_entry.data[CONF_BRIDGEID]) - for component in ['binary_sensor', 'light', 'scene', 'sensor', 'switch']: - await hass.config_entries.async_forward_entry_unload( - config_entry, component) + if not hass.data[DOMAIN]: + await async_unload_services(hass) - dispatchers = hass.data[DATA_DECONZ_UNSUB] - for unsub_dispatcher in dispatchers: - unsub_dispatcher() - hass.data[DATA_DECONZ_UNSUB] = [] + elif gateway.master: + await async_update_master_gateway(hass, config_entry) + new_master_gateway = next(iter(hass.data[DOMAIN].values())) + await async_update_master_gateway(hass, new_master_gateway.config_entry) - for event in hass.data[DATA_DECONZ_EVENT]: - event.async_will_remove_from_hass() - hass.data[DATA_DECONZ_EVENT].remove(event) - - hass.data[DATA_DECONZ_ID] = [] - - return True + return await gateway.async_reset() -class DeconzEvent: - """When you want signals instead of entities. +async def async_update_master_gateway(hass, config_entry): + """Update master gateway boolean. - Stateless sensors such as remotes are expected to generate an event - instead of a sensor entity in hass. + Called by setup_entry and unload_entry. + Makes sure there is always one master available. """ + master = not get_master_gateway(hass) + options = {**config_entry.options, CONF_MASTER_GATEWAY: master} - 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) + hass.config_entries.async_update_entry(config_entry, options=options) - @callback - def async_will_remove_from_hass(self) -> None: - """Disconnect event object when removed.""" - self._device.remove_callback(self.async_update_callback) - self._device = None - @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) +async def async_add_uuid_to_config_entry(hass, config_entry): + """Add UUID to config entry to help discovery identify entries.""" + gateway = get_gateway_from_config_entry(hass, config_entry) + config = {**config_entry.data, CONF_UUID: gateway.api.config.uuid} + + hass.config_entries.async_update_entry(config_entry, data=config) diff --git a/homeassistant/components/deconz/binary_sensor.py b/homeassistant/components/deconz/binary_sensor.py new file mode 100644 index 000000000..0fdc5904c --- /dev/null +++ b/homeassistant/components/deconz/binary_sensor.py @@ -0,0 +1,96 @@ +"""Support for deCONZ binary sensors.""" +from pydeconz.sensor import Presence, Vibration + +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.const import ATTR_TEMPERATURE +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import ATTR_DARK, ATTR_ON, NEW_SENSOR +from .deconz_device import DeconzDevice +from .gateway import DeconzEntityHandler, get_gateway_from_config_entry + +ATTR_ORIENTATION = "orientation" +ATTR_TILTANGLE = "tiltangle" +ATTR_VIBRATIONSTRENGTH = "vibrationstrength" + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Old way of setting up deCONZ platforms.""" + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the deCONZ binary sensor.""" + gateway = get_gateway_from_config_entry(hass, config_entry) + + entity_handler = DeconzEntityHandler(gateway) + + @callback + def async_add_sensor(sensors, new=True): + """Add binary sensor from deCONZ.""" + entities = [] + + for sensor in sensors: + + if new and sensor.BINARY: + new_sensor = DeconzBinarySensor(sensor, gateway) + entity_handler.add_entity(new_sensor) + entities.append(new_sensor) + + async_add_entities(entities, True) + + gateway.listeners.append( + async_dispatcher_connect( + hass, gateway.async_signal_new_device(NEW_SENSOR), async_add_sensor + ) + ) + + async_add_sensor(gateway.api.sensors.values()) + + +class DeconzBinarySensor(DeconzDevice, BinarySensorDevice): + """Representation of a deCONZ binary sensor.""" + + @callback + def async_update_callback(self, force_update=False): + """Update the sensor's state.""" + changed = set(self._device.changed_keys) + keys = {"on", "reachable", "state"} + if force_update or any(key in changed for key in keys): + self.async_schedule_update_ha_state() + + @property + def is_on(self): + """Return true if sensor is on.""" + return self._device.is_tripped + + @property + def device_class(self): + """Return the class of the sensor.""" + return self._device.SENSOR_CLASS + + @property + def icon(self): + """Return the icon to use in the frontend.""" + return self._device.SENSOR_ICON + + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + attr = {} + + if self._device.on is not None: + attr[ATTR_ON] = self._device.on + + if self._device.secondary_temperature is not None: + attr[ATTR_TEMPERATURE] = self._device.secondary_temperature + + if self._device.type in Presence.ZHATYPE and self._device.dark is not None: + attr[ATTR_DARK] = self._device.dark + + elif self._device.type in Vibration.ZHATYPE: + attr[ATTR_ORIENTATION] = self._device.orientation + attr[ATTR_TILTANGLE] = self._device.tiltangle + attr[ATTR_VIBRATIONSTRENGTH] = self._device.vibrationstrength + + return attr diff --git a/homeassistant/components/deconz/climate.py b/homeassistant/components/deconz/climate.py new file mode 100644 index 000000000..ba1f1ce84 --- /dev/null +++ b/homeassistant/components/deconz/climate.py @@ -0,0 +1,128 @@ +"""Support for deCONZ climate devices.""" +from pydeconz.sensor import Thermostat + +from homeassistant.components.climate import ClimateDevice +from homeassistant.components.climate.const import ( + HVAC_MODE_AUTO, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import ATTR_OFFSET, ATTR_VALVE, NEW_SENSOR +from .deconz_device import DeconzDevice +from .gateway import get_gateway_from_config_entry + +SUPPORT_HVAC = [HVAC_MODE_AUTO, HVAC_MODE_HEAT, HVAC_MODE_OFF] + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Old way of setting up deCONZ platforms.""" + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the deCONZ climate devices. + + Thermostats are based on the same device class as sensors in deCONZ. + """ + gateway = get_gateway_from_config_entry(hass, config_entry) + + @callback + def async_add_climate(sensors, new=True): + """Add climate devices from deCONZ.""" + entities = [] + + for sensor in sensors: + + if new and sensor.type in Thermostat.ZHATYPE: + entities.append(DeconzThermostat(sensor, gateway)) + + async_add_entities(entities, True) + + gateway.listeners.append( + async_dispatcher_connect( + hass, gateway.async_signal_new_device(NEW_SENSOR), async_add_climate + ) + ) + + async_add_climate(gateway.api.sensors.values()) + + +class DeconzThermostat(DeconzDevice, ClimateDevice): + """Representation of a deCONZ thermostat.""" + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_TARGET_TEMPERATURE + + @property + def hvac_mode(self): + """Return hvac operation ie. heat, cool mode. + + Need to be one of HVAC_MODE_*. + """ + if self._device.mode in SUPPORT_HVAC: + return self._device.mode + if self._device.state_on: + return HVAC_MODE_HEAT + return HVAC_MODE_OFF + + @property + def hvac_modes(self): + """Return the list of available hvac operation modes. + + Need to be a subset of HVAC_MODES. + """ + return SUPPORT_HVAC + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._device.temperature + + @property + def target_temperature(self): + """Return the target temperature.""" + return self._device.heatsetpoint + + async def async_set_temperature(self, **kwargs): + """Set new target temperature.""" + data = {} + + if ATTR_TEMPERATURE in kwargs: + data["heatsetpoint"] = kwargs[ATTR_TEMPERATURE] * 100 + + await self._device.async_set_config(data) + + async def async_set_hvac_mode(self, hvac_mode): + """Set new target hvac mode.""" + if hvac_mode == HVAC_MODE_AUTO: + data = {"mode": "auto"} + elif hvac_mode == HVAC_MODE_HEAT: + data = {"mode": "heat"} + elif hvac_mode == HVAC_MODE_OFF: + data = {"mode": "off"} + + await self._device.async_set_config(data) + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def device_state_attributes(self): + """Return the state attributes of the thermostat.""" + attr = {} + + if self._device.offset: + attr[ATTR_OFFSET] = self._device.offset + + if self._device.valve is not None: + attr[ATTR_VALVE] = self._device.valve + + return attr diff --git a/homeassistant/components/deconz/config_flow.py b/homeassistant/components/deconz/config_flow.py index 65fcf51b9..c84192456 100644 --- a/homeassistant/components/deconz/config_flow.py +++ b/homeassistant/components/deconz/config_flow.py @@ -1,173 +1,293 @@ """Config flow to configure deCONZ component.""" +import asyncio +from urllib.parse import urlparse +import async_timeout +from pydeconz.errors import RequestError, ResponseError +from pydeconz.utils import async_discovery, async_get_api_key, async_get_gateway_config import voluptuous as vol from homeassistant import config_entries -from homeassistant.core import callback +from homeassistant.components import ssdp from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT +from homeassistant.core import callback from homeassistant.helpers import aiohttp_client -from homeassistant.util.json import load_json from .const import ( - CONF_ALLOW_DECONZ_GROUPS, CONF_ALLOW_CLIP_SENSOR, CONFIG_FILE, DOMAIN) + _LOGGER, + CONF_ALLOW_CLIP_SENSOR, + CONF_ALLOW_DECONZ_GROUPS, + CONF_BRIDGEID, + CONF_UUID, + DEFAULT_ALLOW_CLIP_SENSOR, + DEFAULT_ALLOW_DECONZ_GROUPS, + DEFAULT_PORT, + DOMAIN, +) - -CONF_BRIDGEID = 'bridgeid' +DECONZ_MANUFACTURERURL = "http://www.dresden-elektronik.de" +CONF_SERIAL = "serial" @callback -def configured_hosts(hass): - """Return a set of the configured hosts.""" - return set(entry.data[CONF_HOST] for entry - in hass.config_entries.async_entries(DOMAIN)) +def configured_gateways(hass): + """Return a set of all configured gateways.""" + return { + entry.data[CONF_BRIDGEID]: entry + for entry in hass.config_entries.async_entries(DOMAIN) + } -@config_entries.HANDLERS.register(DOMAIN) -class DeconzFlowHandler(config_entries.ConfigFlow): +@callback +def get_master_gateway(hass): + """Return the gateway which is marked as master.""" + for gateway in hass.data[DOMAIN].values(): + if gateway.master: + return gateway + + +class DeconzFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a deCONZ config flow.""" VERSION = 1 CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + _hassio_discovery = None + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return DeconzOptionsFlowHandler(config_entry) + def __init__(self): """Initialize the deCONZ config flow.""" self.bridges = [] self.deconz_config = {} - async def async_step_user(self, user_input=None): - """Handle a flow initialized by the user.""" - return await self.async_step_init(user_input) - async def async_step_init(self, user_input=None): + """Needed in order to not require re-translation of strings.""" + return await self.async_step_user(user_input) + + async def async_step_user(self, user_input=None): """Handle a deCONZ config flow start. - Only allows one instance to be set up. If only one bridge is found go to link step. If more than one bridge is found let user choose bridge to link. + If no bridge is found allow user to manually input configuration. """ - 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() + self.deconz_config = user_input + return await self.async_step_link() + session = aiohttp_client.async_get_clientsession(self.hass) - self.bridges = await async_discovery(session) + + try: + with async_timeout.timeout(10): + self.bridges = await async_discovery(session) + + except (asyncio.TimeoutError, ResponseError): + self.bridges = [] if len(self.bridges) == 1: self.deconz_config = self.bridges[0] return await self.async_step_link() + if 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) - }) + step_id="init", + data_schema=vol.Schema({vol.Required(CONF_HOST): vol.In(hosts)}), ) - return self.async_abort( - reason='no_bridges' + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=DEFAULT_PORT): int, + } + ), ) async def async_step_link(self, user_input=None): """Attempt to link with the deCONZ bridge.""" - from pydeconz.utils import async_get_api_key errors = {} if user_input is not None: - if configured_hosts(self.hass): - return self.async_abort(reason='one_instance_only') session = aiohttp_client.async_get_clientsession(self.hass) - api_key = await async_get_api_key(session, **self.deconz_config) - if api_key: + + try: + with async_timeout.timeout(10): + api_key = await async_get_api_key(session, **self.deconz_config) + + except (ResponseError, RequestError, asyncio.TimeoutError): + errors["base"] = "no_key" + + else: self.deconz_config[CONF_API_KEY] = api_key - return await self.async_step_options() - errors['base'] = 'no_key' + return await self._create_entry() - return self.async_show_form( - step_id='link', - errors=errors, + return self.async_show_form(step_id="link", errors=errors) + + async def _create_entry(self): + """Create entry for gateway.""" + if CONF_BRIDGEID not in self.deconz_config: + session = aiohttp_client.async_get_clientsession(self.hass) + + try: + with async_timeout.timeout(10): + gateway_config = await async_get_gateway_config( + session, **self.deconz_config + ) + self.deconz_config[CONF_BRIDGEID] = gateway_config.bridgeid + self.deconz_config[CONF_UUID] = gateway_config.uuid + + except asyncio.TimeoutError: + return self.async_abort(reason="no_bridges") + + return self.async_create_entry( + title="deCONZ-" + self.deconz_config[CONF_BRIDGEID], data=self.deconz_config ) - async def async_step_options(self, user_input=None): - """Extra options for deCONZ. + def _update_entry(self, entry, host, port, api_key=None): + """Update existing entry.""" + if ( + entry.data[CONF_HOST] == host + and entry.data[CONF_PORT] == port + and (api_key is None or entry.data[CONF_API_KEY] == api_key) + ): + return self.async_abort(reason="already_configured") - CONF_CLIP_SENSOR -- Allow user to choose if they want clip sensors. - CONF_DECONZ_GROUPS -- Allow user to choose if they want deCONZ groups. - """ - from pydeconz.utils import async_get_bridgeid + entry.data[CONF_HOST] = host + entry.data[CONF_PORT] = port - if user_input is not None: - self.deconz_config[CONF_ALLOW_CLIP_SENSOR] = \ - user_input[CONF_ALLOW_CLIP_SENSOR] - self.deconz_config[CONF_ALLOW_DECONZ_GROUPS] = \ - user_input[CONF_ALLOW_DECONZ_GROUPS] + if api_key is not None: + entry.data[CONF_API_KEY] = api_key - if CONF_BRIDGEID not in self.deconz_config: - session = aiohttp_client.async_get_clientsession(self.hass) - self.deconz_config[CONF_BRIDGEID] = await async_get_bridgeid( - session, **self.deconz_config) + self.hass.config_entries.async_update_entry(entry) + return self.async_abort(reason="updated_instance") - return self.async_create_entry( - title='deCONZ-' + self.deconz_config[CONF_BRIDGEID], - data=self.deconz_config - ) + async def async_step_ssdp(self, discovery_info): + """Handle a discovered deCONZ bridge.""" + if discovery_info[ssdp.ATTR_UPNP_MANUFACTURER_URL] != DECONZ_MANUFACTURERURL: + return self.async_abort(reason="not_deconz_bridge") - return self.async_show_form( - step_id='options', - data_schema=vol.Schema({ - vol.Optional(CONF_ALLOW_CLIP_SENSOR): bool, - vol.Optional(CONF_ALLOW_DECONZ_GROUPS): bool, - }), - ) + uuid = discovery_info[ssdp.ATTR_UPNP_UDN].replace("uuid:", "") - async def async_step_discovery(self, discovery_info): - """Prepare configuration for a discovered deCONZ bridge. + _LOGGER.debug("deCONZ gateway discovered (%s)", uuid) + + parsed_url = urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION]) + + for entry in self.hass.config_entries.async_entries(DOMAIN): + if uuid == entry.data.get(CONF_UUID): + if entry.source == "hassio": + return self.async_abort(reason="already_configured") + return self._update_entry(entry, parsed_url.hostname, parsed_url.port) + + bridgeid = discovery_info[ssdp.ATTR_UPNP_SERIAL] + if any( + bridgeid == flow["context"][CONF_BRIDGEID] + for flow in self._async_in_progress() + ): + return self.async_abort(reason="already_in_progress") + + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + self.context[CONF_BRIDGEID] = bridgeid + self.context["title_placeholders"] = {"host": parsed_url.hostname} + + self.deconz_config = { + CONF_HOST: parsed_url.hostname, + CONF_PORT: parsed_url.port, + } + + return await self.async_step_link() + + async def async_step_hassio(self, user_input=None): + """Prepare configuration for a Hass.io deCONZ bridge. This flow is triggered by the discovery component. """ - deconz_config = {} - deconz_config[CONF_HOST] = discovery_info.get(CONF_HOST) - deconz_config[CONF_PORT] = discovery_info.get(CONF_PORT) - deconz_config[CONF_BRIDGEID] = discovery_info.get('serial') + bridgeid = user_input[CONF_SERIAL] + gateway_entries = configured_gateways(self.hass) - 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] + if bridgeid in gateway_entries: + entry = gateway_entries[bridgeid] + return self._update_entry( + entry, + user_input[CONF_HOST], + user_input[CONF_PORT], + user_input[CONF_API_KEY], + ) - return await self.async_step_import(deconz_config) + self._hassio_discovery = user_input - async def async_step_import(self, import_config): - """Import a deCONZ bridge as a config entry. + return await self.async_step_hassio_confirm() - This flow is triggered by `async_setup` for configured bridges. - This flow is also triggered by `async_step_discovery`. + async def async_step_hassio_confirm(self, user_input=None): + """Confirm a Hass.io discovery.""" + if user_input is not None: + self.deconz_config = { + CONF_HOST: self._hassio_discovery[CONF_HOST], + CONF_PORT: self._hassio_discovery[CONF_PORT], + CONF_BRIDGEID: self._hassio_discovery[CONF_SERIAL], + CONF_API_KEY: self._hassio_discovery[CONF_API_KEY], + } - This will execute for any bridge that does not have a - config entry yet (based on host). + return await self._create_entry() - If an API key is provided, we will create an entry. - Otherwise we will delegate to `link` step which - will ask user to link the bridge. - """ - if configured_hosts(self.hass): - return self.async_abort(reason='one_instance_only') + return self.async_show_form( + step_id="hassio_confirm", + description_placeholders={"addon": self._hassio_discovery["addon"]}, + ) - self.deconz_config = import_config - if CONF_API_KEY not in import_config: - return await self.async_step_link() - user_input = {CONF_ALLOW_CLIP_SENSOR: True, - CONF_ALLOW_DECONZ_GROUPS: True} - return await self.async_step_options(user_input=user_input) +class DeconzOptionsFlowHandler(config_entries.OptionsFlow): + """Handle deCONZ options.""" + + def __init__(self, config_entry): + """Initialize deCONZ options flow.""" + self.config_entry = config_entry + self.options = dict(config_entry.options) + + async def async_step_init(self, user_input=None): + """Manage the deCONZ options.""" + return await self.async_step_deconz_devices() + + async def async_step_deconz_devices(self, user_input=None): + """Manage the deconz devices options.""" + if user_input is not None: + self.options[CONF_ALLOW_CLIP_SENSOR] = user_input[CONF_ALLOW_CLIP_SENSOR] + self.options[CONF_ALLOW_DECONZ_GROUPS] = user_input[ + CONF_ALLOW_DECONZ_GROUPS + ] + return self.async_create_entry(title="", data=self.options) + + return self.async_show_form( + step_id="deconz_devices", + data_schema=vol.Schema( + { + vol.Optional( + CONF_ALLOW_CLIP_SENSOR, + default=self.config_entry.options.get( + CONF_ALLOW_CLIP_SENSOR, DEFAULT_ALLOW_CLIP_SENSOR + ), + ): bool, + vol.Optional( + CONF_ALLOW_DECONZ_GROUPS, + default=self.config_entry.options.get( + CONF_ALLOW_DECONZ_GROUPS, DEFAULT_ALLOW_DECONZ_GROUPS + ), + ): bool, + } + ), + ) diff --git a/homeassistant/components/deconz/const.py b/homeassistant/components/deconz/const.py index e629d57f2..a663f99bf 100644 --- a/homeassistant/components/deconz/const.py +++ b/homeassistant/components/deconz/const.py @@ -1,20 +1,51 @@ """Constants for the deCONZ component.""" import logging -_LOGGER = logging.getLogger('homeassistant.components.deconz') +_LOGGER = logging.getLogger(__package__) -DOMAIN = 'deconz' -CONFIG_FILE = 'deconz.conf' -DATA_DECONZ_EVENT = 'deconz_events' -DATA_DECONZ_ID = 'deconz_entities' -DATA_DECONZ_UNSUB = 'deconz_dispatchers' -DECONZ_DOMAIN = 'deconz' +DOMAIN = "deconz" -CONF_ALLOW_CLIP_SENSOR = 'allow_clip_sensor' -CONF_ALLOW_DECONZ_GROUPS = 'allow_deconz_groups' +CONF_BRIDGEID = "bridgeid" +CONF_UUID = "uuid" -ATTR_DARK = 'dark' -ATTR_ON = 'on' +DEFAULT_PORT = 80 +DEFAULT_ALLOW_CLIP_SENSOR = False +DEFAULT_ALLOW_DECONZ_GROUPS = True + +CONF_ALLOW_CLIP_SENSOR = "allow_clip_sensor" +CONF_ALLOW_DECONZ_GROUPS = "allow_deconz_groups" +CONF_MASTER_GATEWAY = "master" + +SUPPORTED_PLATFORMS = [ + "binary_sensor", + "climate", + "cover", + "light", + "scene", + "sensor", + "switch", +] + +NEW_GROUP = "groups" +NEW_LIGHT = "lights" +NEW_SCENE = "scenes" +NEW_SENSOR = "sensors" + +NEW_DEVICE = { + NEW_GROUP: "deconz_new_group_{}", + NEW_LIGHT: "deconz_new_light_{}", + NEW_SCENE: "deconz_new_scene_{}", + NEW_SENSOR: "deconz_new_sensor_{}", +} + +ATTR_DARK = "dark" +ATTR_OFFSET = "offset" +ATTR_ON = "on" +ATTR_VALVE = "valve" + +DAMPERS = ["Level controllable output"] +WINDOW_COVERS = ["Window covering device"] +COVER_TYPES = DAMPERS + WINDOW_COVERS POWER_PLUGS = ["On/Off plug-in unit", "Smart plug"] SIRENS = ["Warning device"] diff --git a/homeassistant/components/deconz/cover.py b/homeassistant/components/deconz/cover.py new file mode 100644 index 000000000..6e5e616fb --- /dev/null +++ b/homeassistant/components/deconz/cover.py @@ -0,0 +1,108 @@ +"""Support for deCONZ covers.""" +from homeassistant.components.cover import ( + ATTR_POSITION, + SUPPORT_CLOSE, + SUPPORT_OPEN, + SUPPORT_SET_POSITION, + SUPPORT_STOP, + CoverDevice, +) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import COVER_TYPES, DAMPERS, NEW_LIGHT, WINDOW_COVERS +from .deconz_device import DeconzDevice +from .gateway import get_gateway_from_config_entry + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Old way of setting up deCONZ platforms.""" + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up covers for deCONZ component. + + Covers are based on same device class as lights in deCONZ. + """ + gateway = get_gateway_from_config_entry(hass, config_entry) + + @callback + def async_add_cover(lights): + """Add cover from deCONZ.""" + entities = [] + + for light in lights: + if light.type in COVER_TYPES: + entities.append(DeconzCover(light, gateway)) + + async_add_entities(entities, True) + + gateway.listeners.append( + async_dispatcher_connect( + hass, gateway.async_signal_new_device(NEW_LIGHT), async_add_cover + ) + ) + + async_add_cover(gateway.api.lights.values()) + + +class DeconzCover(DeconzDevice, CoverDevice): + """Representation of a deCONZ cover.""" + + def __init__(self, device, gateway): + """Set up cover device.""" + super().__init__(device, gateway) + + self._features = SUPPORT_OPEN + self._features |= SUPPORT_CLOSE + self._features |= SUPPORT_STOP + self._features |= SUPPORT_SET_POSITION + + @property + def current_cover_position(self): + """Return the current position of the cover.""" + return 100 - int(self._device.brightness / 255 * 100) + + @property + def is_closed(self): + """Return if the cover is closed.""" + return self._device.state + + @property + def device_class(self): + """Return the class of the cover.""" + if self._device.type in DAMPERS: + return "damper" + if self._device.type in WINDOW_COVERS: + return "window" + + @property + def supported_features(self): + """Flag supported features.""" + return self._features + + async def async_set_cover_position(self, **kwargs): + """Move the cover to a specific position.""" + position = kwargs[ATTR_POSITION] + data = {"on": False} + + if position < 100: + data["on"] = True + data["bri"] = 255 - int(position / 100 * 255) + + await self._device.async_set_state(data) + + async def async_open_cover(self, **kwargs): + """Open cover.""" + data = {ATTR_POSITION: 100} + await self.async_set_cover_position(**data) + + async def async_close_cover(self, **kwargs): + """Close cover.""" + data = {ATTR_POSITION: 0} + await self.async_set_cover_position(**data) + + async def async_stop_cover(self, **kwargs): + """Stop cover.""" + data = {"bri_inc": 0} + await self._device.async_set_state(data) diff --git a/homeassistant/components/deconz/deconz_device.py b/homeassistant/components/deconz/deconz_device.py new file mode 100644 index 000000000..68daee6cf --- /dev/null +++ b/homeassistant/components/deconz/deconz_device.py @@ -0,0 +1,111 @@ +"""Base class for deCONZ devices.""" +from homeassistant.core import callback +from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN as DECONZ_DOMAIN + + +class DeconzBase: + """Common base for deconz entities and events.""" + + def __init__(self, device, gateway): + """Set up device and add update callback to get data from websocket.""" + self._device = device + self.gateway = gateway + self.listeners = [] + + @property + def unique_id(self): + """Return a unique identifier for this device.""" + return self._device.uniqueid + + @property + def serial(self): + """Return a serial number for this device.""" + if self._device.uniqueid is None or self._device.uniqueid.count(":") != 7: + return None + + return self._device.uniqueid.split("-", 1)[0] + + @property + def device_info(self): + """Return a device description for device registry.""" + if self.serial is None: + return None + + bridgeid = self.gateway.api.config.bridgeid + + return { + "connections": {(CONNECTION_ZIGBEE, self.serial)}, + "identifiers": {(DECONZ_DOMAIN, self.serial)}, + "manufacturer": self._device.manufacturer, + "model": self._device.modelid, + "name": self._device.name, + "sw_version": self._device.swversion, + "via_device": (DECONZ_DOMAIN, bridgeid), + } + + +class DeconzDevice(DeconzBase, Entity): + """Representation of a deCONZ device.""" + + def __init__(self, device, gateway): + """Set up device and add update callback to get data from websocket.""" + super().__init__(device, gateway) + + self.unsub_dispatcher = None + + @property + def entity_registry_enabled_default(self): + """Return if the entity should be enabled when first added to the entity registry.""" + if not self.gateway.option_allow_clip_sensor and self._device.type.startswith( + "CLIP" + ): + return False + + if ( + not self.gateway.option_allow_deconz_groups + and self._device.type == "LightGroup" + ): + return False + + return True + + async def async_added_to_hass(self): + """Subscribe to device events.""" + self._device.register_async_callback(self.async_update_callback) + self.gateway.deconz_ids[self.entity_id] = self._device.deconz_id + self.listeners.append( + async_dispatcher_connect( + self.hass, self.gateway.signal_reachable, self.async_update_callback + ) + ) + + async def async_will_remove_from_hass(self) -> None: + """Disconnect device object when removed.""" + self._device.remove_callback(self.async_update_callback) + del self.gateway.deconz_ids[self.entity_id] + for unsub_dispatcher in self.listeners: + unsub_dispatcher() + + @callback + def async_update_callback(self, force_update=False): + """Update the device's state.""" + self.async_schedule_update_ha_state() + + @property + def available(self): + """Return True if device is available.""" + return self.gateway.available and self._device.reachable + + @property + def name(self): + """Return the name of the device.""" + return self._device.name + + @property + def should_poll(self): + """No polling needed.""" + return False diff --git a/homeassistant/components/deconz/deconz_event.py b/homeassistant/components/deconz/deconz_event.py new file mode 100644 index 000000000..31588db1f --- /dev/null +++ b/homeassistant/components/deconz/deconz_event.py @@ -0,0 +1,61 @@ +"""Representation of a deCONZ remote.""" +from homeassistant.const import CONF_EVENT, CONF_ID +from homeassistant.core import callback +from homeassistant.util import slugify + +from .const import _LOGGER +from .deconz_device import DeconzBase + +CONF_DECONZ_EVENT = "deconz_event" +CONF_UNIQUE_ID = "unique_id" + + +class DeconzEvent(DeconzBase): + """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, device, gateway): + """Register callback that will be used for signals.""" + super().__init__(device, gateway) + + self._device.register_async_callback(self.async_update_callback) + + self.device_id = None + self.event_id = slugify(self._device.name) + _LOGGER.debug("deCONZ event created: %s", self.event_id) + + @property + def device(self): + """Return Event device.""" + return self._device + + @callback + def async_will_remove_from_hass(self) -> None: + """Disconnect event object when removed.""" + self._device.remove_callback(self.async_update_callback) + self._device = None + + @callback + def async_update_callback(self, force_update=False): + """Fire the event if reason is that state is updated.""" + if "state" in self._device.changed_keys: + data = { + CONF_ID: self.event_id, + CONF_UNIQUE_ID: self.serial, + CONF_EVENT: self._device.state, + } + self.gateway.hass.bus.async_fire(CONF_DECONZ_EVENT, data) + + async def async_update_device_registry(self): + """Update device registry.""" + device_registry = ( + await self.gateway.hass.helpers.device_registry.async_get_registry() + ) + + entry = device_registry.async_get_or_create( + config_entry_id=self.gateway.config_entry.entry_id, **self.device_info + ) + self.device_id = entry.id diff --git a/homeassistant/components/deconz/device_trigger.py b/homeassistant/components/deconz/device_trigger.py new file mode 100644 index 000000000..d057de23d --- /dev/null +++ b/homeassistant/components/deconz/device_trigger.py @@ -0,0 +1,365 @@ +"""Provides device automations for deconz events.""" +import voluptuous as vol + +import homeassistant.components.automation.event as event +from homeassistant.components.device_automation import TRIGGER_BASE_SCHEMA +from homeassistant.components.device_automation.exceptions import ( + InvalidDeviceAutomationConfig, +) +from homeassistant.const import ( + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_EVENT, + CONF_PLATFORM, + CONF_TYPE, +) + +from . import DOMAIN +from .config_flow import configured_gateways +from .deconz_event import CONF_DECONZ_EVENT, CONF_UNIQUE_ID +from .gateway import get_gateway_from_config_entry + +CONF_SUBTYPE = "subtype" + +CONF_SHORT_PRESS = "remote_button_short_press" +CONF_SHORT_RELEASE = "remote_button_short_release" +CONF_LONG_PRESS = "remote_button_long_press" +CONF_LONG_RELEASE = "remote_button_long_release" +CONF_DOUBLE_PRESS = "remote_button_double_press" +CONF_TRIPLE_PRESS = "remote_button_triple_press" +CONF_QUADRUPLE_PRESS = "remote_button_quadruple_press" +CONF_QUINTUPLE_PRESS = "remote_button_quintuple_press" +CONF_ROTATED = "remote_button_rotated" +CONF_ROTATION_STOPPED = "remote_button_rotation_stopped" +CONF_AWAKE = "remote_awakened" +CONF_MOVE = "remote_moved" +CONF_DOUBLE_TAP = "remote_double_tap" +CONF_SHAKE = "remote_gyro_activated" +CONF_FREE_FALL = "remote_falling" +CONF_ROTATE_FROM_SIDE_1 = "remote_rotate_from_side_1" +CONF_ROTATE_FROM_SIDE_2 = "remote_rotate_from_side_2" +CONF_ROTATE_FROM_SIDE_3 = "remote_rotate_from_side_3" +CONF_ROTATE_FROM_SIDE_4 = "remote_rotate_from_side_4" +CONF_ROTATE_FROM_SIDE_5 = "remote_rotate_from_side_5" +CONF_ROTATE_FROM_SIDE_6 = "remote_rotate_from_side_6" + +CONF_TURN_ON = "turn_on" +CONF_TURN_OFF = "turn_off" +CONF_DIM_UP = "dim_up" +CONF_DIM_DOWN = "dim_down" +CONF_LEFT = "left" +CONF_RIGHT = "right" +CONF_OPEN = "open" +CONF_CLOSE = "close" +CONF_BOTH_BUTTONS = "both_buttons" +CONF_BUTTON_1 = "button_1" +CONF_BUTTON_2 = "button_2" +CONF_BUTTON_3 = "button_3" +CONF_BUTTON_4 = "button_4" +CONF_SIDE_1 = "side_1" +CONF_SIDE_2 = "side_2" +CONF_SIDE_3 = "side_3" +CONF_SIDE_4 = "side_4" +CONF_SIDE_5 = "side_5" +CONF_SIDE_6 = "side_6" + + +HUE_DIMMER_REMOTE_MODEL_GEN1 = "RWL020" +HUE_DIMMER_REMOTE_MODEL_GEN2 = "RWL021" +HUE_DIMMER_REMOTE = { + (CONF_SHORT_PRESS, CONF_TURN_ON): 1000, + (CONF_SHORT_RELEASE, CONF_TURN_ON): 1002, + (CONF_LONG_PRESS, CONF_TURN_ON): 1001, + (CONF_LONG_RELEASE, CONF_TURN_ON): 1003, + (CONF_SHORT_PRESS, CONF_DIM_UP): 2000, + (CONF_SHORT_RELEASE, CONF_DIM_UP): 2002, + (CONF_LONG_PRESS, CONF_DIM_UP): 2001, + (CONF_LONG_RELEASE, CONF_DIM_UP): 2003, + (CONF_SHORT_PRESS, CONF_DIM_DOWN): 3000, + (CONF_SHORT_RELEASE, CONF_DIM_DOWN): 3002, + (CONF_LONG_PRESS, CONF_DIM_DOWN): 3001, + (CONF_LONG_RELEASE, CONF_DIM_DOWN): 3003, + (CONF_SHORT_PRESS, CONF_TURN_OFF): 4000, + (CONF_SHORT_RELEASE, CONF_TURN_OFF): 4002, + (CONF_LONG_PRESS, CONF_TURN_OFF): 4001, + (CONF_LONG_RELEASE, CONF_TURN_OFF): 4003, +} + +HUE_TAP_REMOTE_MODEL = "ZGPSWITCH" +HUE_TAP_REMOTE = { + (CONF_SHORT_PRESS, CONF_BUTTON_1): 34, + (CONF_SHORT_PRESS, CONF_BUTTON_2): 16, + (CONF_SHORT_PRESS, CONF_BUTTON_3): 17, + (CONF_SHORT_PRESS, CONF_BUTTON_4): 18, +} + +SYMFONISK_SOUND_CONTROLLER_MODEL = "SYMFONISK Sound Controller" +SYMFONISK_SOUND_CONTROLLER = { + (CONF_SHORT_PRESS, CONF_TURN_ON): 1002, + (CONF_DOUBLE_PRESS, CONF_TURN_ON): 1004, + (CONF_TRIPLE_PRESS, CONF_TURN_ON): 1005, + (CONF_ROTATED, CONF_LEFT): 2001, + (CONF_ROTATION_STOPPED, CONF_LEFT): 2003, + (CONF_ROTATED, CONF_RIGHT): 3001, + (CONF_ROTATION_STOPPED, CONF_RIGHT): 3003, +} + +TRADFRI_ON_OFF_SWITCH_MODEL = "TRADFRI on/off switch" +TRADFRI_ON_OFF_SWITCH = { + (CONF_SHORT_PRESS, CONF_TURN_ON): 1002, + (CONF_LONG_PRESS, CONF_TURN_ON): 1001, + (CONF_LONG_RELEASE, CONF_TURN_ON): 1003, + (CONF_SHORT_PRESS, CONF_TURN_OFF): 2002, + (CONF_LONG_PRESS, CONF_TURN_OFF): 2001, + (CONF_LONG_RELEASE, CONF_TURN_OFF): 2003, +} + +TRADFRI_OPEN_CLOSE_REMOTE_MODEL = "TRADFRI open/close remote" +TRADFRI_OPEN_CLOSE_REMOTE = { + (CONF_SHORT_PRESS, CONF_OPEN): 1002, + (CONF_LONG_PRESS, CONF_OPEN): 1003, + (CONF_SHORT_PRESS, CONF_CLOSE): 2002, + (CONF_LONG_PRESS, CONF_CLOSE): 2003, +} + +TRADFRI_REMOTE_MODEL = "TRADFRI remote control" +TRADFRI_REMOTE = { + (CONF_SHORT_PRESS, CONF_TURN_ON): 1002, + (CONF_LONG_PRESS, CONF_TURN_ON): 1001, + (CONF_SHORT_PRESS, CONF_DIM_UP): 2002, + (CONF_LONG_PRESS, CONF_DIM_UP): 2001, + (CONF_LONG_RELEASE, CONF_DIM_UP): 2003, + (CONF_SHORT_PRESS, CONF_DIM_DOWN): 3002, + (CONF_LONG_PRESS, CONF_DIM_DOWN): 3001, + (CONF_LONG_RELEASE, CONF_DIM_DOWN): 3003, + (CONF_SHORT_PRESS, CONF_LEFT): 4002, + (CONF_LONG_PRESS, CONF_LEFT): 4001, + (CONF_LONG_RELEASE, CONF_LEFT): 4003, + (CONF_SHORT_PRESS, CONF_RIGHT): 5002, + (CONF_LONG_PRESS, CONF_RIGHT): 5001, + (CONF_LONG_RELEASE, CONF_RIGHT): 5003, +} + +TRADFRI_WIRELESS_DIMMER_MODEL = "TRADFRI wireless dimmer" +TRADFRI_WIRELESS_DIMMER = { + (CONF_ROTATED, CONF_LEFT): 3002, + (CONF_ROTATED, CONF_RIGHT): 2002, +} + +AQARA_CUBE_MODEL = "lumi.sensor_cube" +AQARA_CUBE_MODEL_ALT1 = "lumi.sensor_cube.aqgl01" +AQARA_CUBE = { + (CONF_ROTATE_FROM_SIDE_1, CONF_SIDE_2): 6002, + (CONF_ROTATE_FROM_SIDE_1, CONF_SIDE_3): 3002, + (CONF_ROTATE_FROM_SIDE_1, CONF_SIDE_4): 4002, + (CONF_ROTATE_FROM_SIDE_1, CONF_SIDE_5): 1002, + (CONF_ROTATE_FROM_SIDE_1, CONF_SIDE_6): 5002, + (CONF_ROTATE_FROM_SIDE_2, CONF_SIDE_1): 2006, + (CONF_ROTATE_FROM_SIDE_2, CONF_SIDE_3): 3006, + (CONF_ROTATE_FROM_SIDE_2, CONF_SIDE_4): 4006, + (CONF_ROTATE_FROM_SIDE_2, CONF_SIDE_5): 1006, + (CONF_ROTATE_FROM_SIDE_2, CONF_SIDE_6): 5006, + (CONF_ROTATE_FROM_SIDE_3, CONF_SIDE_1): 2003, + (CONF_ROTATE_FROM_SIDE_3, CONF_SIDE_2): 6003, + (CONF_ROTATE_FROM_SIDE_3, CONF_SIDE_4): 4003, + (CONF_ROTATE_FROM_SIDE_3, CONF_SIDE_5): 1003, + (CONF_ROTATE_FROM_SIDE_3, CONF_SIDE_6): 5003, + (CONF_ROTATE_FROM_SIDE_4, CONF_SIDE_1): 2004, + (CONF_ROTATE_FROM_SIDE_4, CONF_SIDE_2): 6004, + (CONF_ROTATE_FROM_SIDE_4, CONF_SIDE_3): 3004, + (CONF_ROTATE_FROM_SIDE_4, CONF_SIDE_5): 1004, + (CONF_ROTATE_FROM_SIDE_4, CONF_SIDE_6): 5004, + (CONF_ROTATE_FROM_SIDE_5, CONF_SIDE_1): 2001, + (CONF_ROTATE_FROM_SIDE_5, CONF_SIDE_2): 6001, + (CONF_ROTATE_FROM_SIDE_5, CONF_SIDE_3): 3001, + (CONF_ROTATE_FROM_SIDE_5, CONF_SIDE_4): 4001, + (CONF_ROTATE_FROM_SIDE_5, CONF_SIDE_6): 5001, + (CONF_ROTATE_FROM_SIDE_6, CONF_SIDE_1): 2005, + (CONF_ROTATE_FROM_SIDE_6, CONF_SIDE_2): 6005, + (CONF_ROTATE_FROM_SIDE_6, CONF_SIDE_3): 3005, + (CONF_ROTATE_FROM_SIDE_6, CONF_SIDE_4): 4005, + (CONF_ROTATE_FROM_SIDE_6, CONF_SIDE_5): 1005, + (CONF_MOVE, CONF_SIDE_1): 2000, + (CONF_MOVE, CONF_SIDE_2): 6000, + (CONF_MOVE, CONF_SIDE_3): 3000, + (CONF_MOVE, CONF_SIDE_4): 4000, + (CONF_MOVE, CONF_SIDE_5): 1000, + (CONF_MOVE, CONF_SIDE_6): 5000, + (CONF_DOUBLE_TAP, CONF_SIDE_1): 2002, + (CONF_DOUBLE_TAP, CONF_SIDE_2): 6002, + (CONF_DOUBLE_TAP, CONF_SIDE_3): 3003, + (CONF_DOUBLE_TAP, CONF_SIDE_4): 4004, + (CONF_DOUBLE_TAP, CONF_SIDE_5): 1001, + (CONF_DOUBLE_TAP, CONF_SIDE_6): 5005, + (CONF_AWAKE, ""): 7000, + (CONF_FREE_FALL, ""): 7008, + (CONF_SHAKE, ""): 7007, +} + +AQARA_DOUBLE_WALL_SWITCH_MODEL = "lumi.remote.b286acn01" +AQARA_DOUBLE_WALL_SWITCH = { + (CONF_SHORT_PRESS, CONF_LEFT): 1002, + (CONF_LONG_PRESS, CONF_LEFT): 1001, + (CONF_DOUBLE_PRESS, CONF_LEFT): 1004, + (CONF_SHORT_PRESS, CONF_RIGHT): 2002, + (CONF_LONG_PRESS, CONF_RIGHT): 2001, + (CONF_DOUBLE_PRESS, CONF_RIGHT): 2004, + (CONF_SHORT_PRESS, CONF_BOTH_BUTTONS): 3002, + (CONF_LONG_PRESS, CONF_BOTH_BUTTONS): 3001, + (CONF_DOUBLE_PRESS, CONF_BOTH_BUTTONS): 3004, +} + +AQARA_DOUBLE_WALL_SWITCH_WXKG02LM_MODEL = "lumi.sensor_86sw2" +AQARA_DOUBLE_WALL_SWITCH_WXKG02LM = { + (CONF_SHORT_PRESS, CONF_LEFT): 1002, + (CONF_SHORT_PRESS, CONF_RIGHT): 2002, + (CONF_SHORT_PRESS, CONF_BOTH_BUTTONS): 3002, +} + +AQARA_MINI_SWITCH_MODEL = "lumi.remote.b1acn01" +AQARA_MINI_SWITCH = { + (CONF_SHORT_PRESS, CONF_TURN_ON): 1002, + (CONF_DOUBLE_PRESS, CONF_TURN_ON): 1004, + (CONF_LONG_PRESS, CONF_TURN_ON): 1001, + (CONF_LONG_RELEASE, CONF_TURN_ON): 1003, +} + +AQARA_ROUND_SWITCH_MODEL = "lumi.sensor_switch" +AQARA_ROUND_SWITCH = { + (CONF_SHORT_PRESS, CONF_TURN_ON): 1000, + (CONF_SHORT_RELEASE, CONF_TURN_ON): 1002, + (CONF_DOUBLE_PRESS, CONF_TURN_ON): 1004, + (CONF_TRIPLE_PRESS, CONF_TURN_ON): 1005, + (CONF_QUADRUPLE_PRESS, CONF_TURN_ON): 1006, + (CONF_QUINTUPLE_PRESS, CONF_TURN_ON): 1010, + (CONF_LONG_PRESS, CONF_TURN_ON): 1001, + (CONF_LONG_RELEASE, CONF_TURN_ON): 1003, +} + +AQARA_SQUARE_SWITCH_MODEL = "lumi.sensor_switch.aq3" +AQARA_SQUARE_SWITCH = { + (CONF_SHORT_PRESS, CONF_TURN_ON): 1002, + (CONF_DOUBLE_PRESS, CONF_TURN_ON): 1004, + (CONF_LONG_PRESS, CONF_TURN_ON): 1001, + (CONF_LONG_RELEASE, CONF_TURN_ON): 1003, + (CONF_SHAKE, ""): 1007, +} + +AQARA_SQUARE_SWITCH_WXKG11LM_2016_MODEL = "lumi.sensor_switch.aq2" +AQARA_SQUARE_SWITCH_WXKG11LM_2016 = { + (CONF_SHORT_PRESS, CONF_TURN_ON): 1002, + (CONF_DOUBLE_PRESS, CONF_TURN_ON): 1004, + (CONF_TRIPLE_PRESS, CONF_TURN_ON): 1005, + (CONF_QUADRUPLE_PRESS, CONF_TURN_ON): 1006, +} + +REMOTES = { + HUE_DIMMER_REMOTE_MODEL_GEN1: HUE_DIMMER_REMOTE, + HUE_DIMMER_REMOTE_MODEL_GEN2: HUE_DIMMER_REMOTE, + HUE_TAP_REMOTE_MODEL: HUE_TAP_REMOTE, + SYMFONISK_SOUND_CONTROLLER_MODEL: SYMFONISK_SOUND_CONTROLLER, + TRADFRI_ON_OFF_SWITCH_MODEL: TRADFRI_ON_OFF_SWITCH, + TRADFRI_OPEN_CLOSE_REMOTE_MODEL: TRADFRI_OPEN_CLOSE_REMOTE, + TRADFRI_REMOTE_MODEL: TRADFRI_REMOTE, + TRADFRI_WIRELESS_DIMMER_MODEL: TRADFRI_WIRELESS_DIMMER, + AQARA_CUBE_MODEL: AQARA_CUBE, + AQARA_CUBE_MODEL_ALT1: AQARA_CUBE, + AQARA_DOUBLE_WALL_SWITCH_MODEL: AQARA_DOUBLE_WALL_SWITCH, + AQARA_DOUBLE_WALL_SWITCH_WXKG02LM_MODEL: AQARA_DOUBLE_WALL_SWITCH_WXKG02LM, + AQARA_MINI_SWITCH_MODEL: AQARA_MINI_SWITCH, + AQARA_ROUND_SWITCH_MODEL: AQARA_ROUND_SWITCH, + AQARA_SQUARE_SWITCH_MODEL: AQARA_SQUARE_SWITCH, + AQARA_SQUARE_SWITCH_WXKG11LM_2016_MODEL: AQARA_SQUARE_SWITCH_WXKG11LM_2016, +} + +TRIGGER_SCHEMA = TRIGGER_BASE_SCHEMA.extend( + {vol.Required(CONF_TYPE): str, vol.Required(CONF_SUBTYPE): str} +) + + +def _get_deconz_event_from_device_id(hass, device_id): + """Resolve deconz event from device id.""" + deconz_config_entries = configured_gateways(hass) + for config_entry in deconz_config_entries.values(): + + gateway = get_gateway_from_config_entry(hass, config_entry) + for deconz_event in gateway.events: + + if device_id == deconz_event.device_id: + return deconz_event + + return None + + +async def async_validate_trigger_config(hass, config): + """Validate config.""" + config = TRIGGER_SCHEMA(config) + + device_registry = await hass.helpers.device_registry.async_get_registry() + device = device_registry.async_get(config[CONF_DEVICE_ID]) + + trigger = (config[CONF_TYPE], config[CONF_SUBTYPE]) + + if ( + not device + or device.model not in REMOTES + or trigger not in REMOTES[device.model] + ): + raise InvalidDeviceAutomationConfig + + return config + + +async def async_attach_trigger(hass, config, action, automation_info): + """Listen for state changes based on configuration.""" + device_registry = await hass.helpers.device_registry.async_get_registry() + device = device_registry.async_get(config[CONF_DEVICE_ID]) + + trigger = (config[CONF_TYPE], config[CONF_SUBTYPE]) + + trigger = REMOTES[device.model][trigger] + + deconz_event = _get_deconz_event_from_device_id(hass, device.id) + if deconz_event is None: + raise InvalidDeviceAutomationConfig + + event_id = deconz_event.serial + + event_config = { + event.CONF_PLATFORM: "event", + event.CONF_EVENT_TYPE: CONF_DECONZ_EVENT, + event.CONF_EVENT_DATA: {CONF_UNIQUE_ID: event_id, CONF_EVENT: trigger}, + } + + event_config = event.TRIGGER_SCHEMA(event_config) + return await event.async_attach_trigger( + hass, event_config, action, automation_info, platform_type="device" + ) + + +async def async_get_triggers(hass, device_id): + """List device triggers. + + Make sure device is a supported remote model. + Retrieve the deconz event object matching device entry. + Generate device trigger list. + """ + device_registry = await hass.helpers.device_registry.async_get_registry() + device = device_registry.async_get(device_id) + + if device.model not in REMOTES: + return + + triggers = [] + for trigger, subtype in REMOTES[device.model].keys(): + triggers.append( + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_PLATFORM: "device", + CONF_TYPE: trigger, + CONF_SUBTYPE: subtype, + } + ) + + return triggers diff --git a/homeassistant/components/deconz/errors.py b/homeassistant/components/deconz/errors.py new file mode 100644 index 000000000..be13e579c --- /dev/null +++ b/homeassistant/components/deconz/errors.py @@ -0,0 +1,18 @@ +"""Errors for the deCONZ component.""" +from homeassistant.exceptions import HomeAssistantError + + +class DeconzException(HomeAssistantError): + """Base class for deCONZ exceptions.""" + + +class AlreadyConfigured(DeconzException): + """Gateway is already configured.""" + + +class AuthenticationRequired(DeconzException): + """Unknown error occurred.""" + + +class CannotConnect(DeconzException): + """Unable to connect to the gateway.""" diff --git a/homeassistant/components/deconz/gateway.py b/homeassistant/components/deconz/gateway.py new file mode 100644 index 000000000..083af2dca --- /dev/null +++ b/homeassistant/components/deconz/gateway.py @@ -0,0 +1,267 @@ +"""Representation of a deCONZ gateway.""" +import asyncio + +import async_timeout +from pydeconz import DeconzSession, errors + +from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT +from homeassistant.core import callback +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.entity_registry import ( + DISABLED_CONFIG_ENTRY, + async_get_registry, +) + +from .const import ( + _LOGGER, + CONF_ALLOW_CLIP_SENSOR, + CONF_ALLOW_DECONZ_GROUPS, + CONF_BRIDGEID, + CONF_MASTER_GATEWAY, + DEFAULT_ALLOW_CLIP_SENSOR, + DEFAULT_ALLOW_DECONZ_GROUPS, + DOMAIN, + NEW_DEVICE, + SUPPORTED_PLATFORMS, +) +from .errors import AuthenticationRequired, CannotConnect + + +@callback +def get_gateway_from_config_entry(hass, config_entry): + """Return gateway with a matching bridge id.""" + return hass.data[DOMAIN][config_entry.data[CONF_BRIDGEID]] + + +class DeconzGateway: + """Manages a single deCONZ gateway.""" + + def __init__(self, hass, config_entry) -> None: + """Initialize the system.""" + self.hass = hass + self.config_entry = config_entry + + self.available = True + self.api = None + self.deconz_ids = {} + self.events = [] + self.listeners = [] + + @property + def bridgeid(self) -> str: + """Return the unique identifier of the gateway.""" + return self.config_entry.data[CONF_BRIDGEID] + + @property + def master(self) -> bool: + """Gateway which is used with deCONZ services without defining id.""" + return self.config_entry.options[CONF_MASTER_GATEWAY] + + @property + def option_allow_clip_sensor(self) -> bool: + """Allow loading clip sensor from gateway.""" + return self.config_entry.options.get( + CONF_ALLOW_CLIP_SENSOR, DEFAULT_ALLOW_CLIP_SENSOR + ) + + @property + def option_allow_deconz_groups(self) -> bool: + """Allow loading deCONZ groups from gateway.""" + return self.config_entry.options.get( + CONF_ALLOW_DECONZ_GROUPS, DEFAULT_ALLOW_DECONZ_GROUPS + ) + + async def async_update_device_registry(self) -> None: + """Update device registry.""" + device_registry = await self.hass.helpers.device_registry.async_get_registry() + device_registry.async_get_or_create( + config_entry_id=self.config_entry.entry_id, + connections={(CONNECTION_NETWORK_MAC, self.api.config.mac)}, + identifiers={(DOMAIN, self.api.config.bridgeid)}, + manufacturer="Dresden Elektronik", + model=self.api.config.modelid, + name=self.api.config.name, + sw_version=self.api.config.swversion, + ) + + async def async_setup(self) -> bool: + """Set up a deCONZ gateway.""" + hass = self.hass + + try: + self.api = await get_gateway( + hass, + self.config_entry.data, + self.async_add_device_callback, + self.async_connection_status_callback, + ) + + except CannotConnect: + raise ConfigEntryNotReady + + except Exception as err: # pylint: disable=broad-except + _LOGGER.error("Error connecting with deCONZ gateway: %s", err) + return False + + for component in SUPPORTED_PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup( + self.config_entry, component + ) + ) + + self.api.start() + + self.config_entry.add_update_listener(self.async_new_address) + self.config_entry.add_update_listener(self.async_options_updated) + + return True + + @staticmethod + async def async_new_address(hass, entry) -> None: + """Handle signals of gateway getting new address. + + This is a static method because a class method (bound method), + can not be used with weak references. + """ + gateway = get_gateway_from_config_entry(hass, entry) + if gateway.api.host != entry.data[CONF_HOST]: + gateway.api.close() + gateway.api.host = entry.data[CONF_HOST] + gateway.api.start() + + @property + def signal_reachable(self) -> str: + """Gateway specific event to signal a change in connection status.""" + return f"deconz-reachable-{self.bridgeid}" + + @callback + def async_connection_status_callback(self, available) -> None: + """Handle signals of gateway connection status.""" + self.available = available + async_dispatcher_send(self.hass, self.signal_reachable, True) + + @property + def signal_options_update(self) -> str: + """Event specific per deCONZ entry to signal new options.""" + return f"deconz-options-{self.bridgeid}" + + @staticmethod + async def async_options_updated(hass, entry) -> None: + """Triggered by config entry options updates.""" + gateway = get_gateway_from_config_entry(hass, entry) + + registry = await async_get_registry(hass) + async_dispatcher_send(hass, gateway.signal_options_update, registry) + + @callback + def async_signal_new_device(self, device_type) -> str: + """Gateway specific event to signal new device.""" + return NEW_DEVICE[device_type].format(self.bridgeid) + + @callback + def async_add_device_callback(self, device_type, device) -> None: + """Handle event of new device creation in deCONZ.""" + if not isinstance(device, list): + device = [device] + async_dispatcher_send( + self.hass, self.async_signal_new_device(device_type), device + ) + + @callback + def shutdown(self, event) -> None: + """Wrap the call to deconz.close. + + Used as an argument to EventBus.async_listen_once. + """ + self.api.close() + + async def async_reset(self): + """Reset this gateway to default state.""" + self.api.async_connection_status_callback = None + self.api.close() + + for component in SUPPORTED_PLATFORMS: + await self.hass.config_entries.async_forward_entry_unload( + self.config_entry, component + ) + + for unsub_dispatcher in self.listeners: + unsub_dispatcher() + self.listeners = [] + + for event in self.events: + event.async_will_remove_from_hass() + self.events.clear() + + self.deconz_ids = {} + return True + + +async def get_gateway( + hass, config, async_add_device_callback, async_connection_status_callback +) -> DeconzSession: + """Create a gateway object and verify configuration.""" + session = aiohttp_client.async_get_clientsession(hass) + + deconz = DeconzSession( + session, + config[CONF_HOST], + config[CONF_PORT], + config[CONF_API_KEY], + async_add_device=async_add_device_callback, + connection_status=async_connection_status_callback, + ) + try: + with async_timeout.timeout(10): + await deconz.initialize() + return deconz + + except errors.Unauthorized: + _LOGGER.warning("Invalid key for deCONZ at %s", config[CONF_HOST]) + raise AuthenticationRequired + + except (asyncio.TimeoutError, errors.RequestError): + _LOGGER.error("Error connecting to deCONZ gateway at %s", config[CONF_HOST]) + raise CannotConnect + + +class DeconzEntityHandler: + """Platform entity handler to help with updating disabled by.""" + + def __init__(self, gateway) -> None: + """Create an entity handler.""" + self.gateway = gateway + self._entities = [] + + gateway.listeners.append( + async_dispatcher_connect( + gateway.hass, gateway.signal_options_update, self.update_entity_registry + ) + ) + + @callback + def add_entity(self, entity) -> None: + """Add a new entity to handler.""" + self._entities.append(entity) + + @callback + def update_entity_registry(self, entity_registry) -> None: + """Update entity registry disabled by status.""" + for entity in self._entities: + + if entity.entity_registry_enabled_default != entity.enabled: + disabled_by = None + + if entity.enabled: + disabled_by = DISABLED_CONFIG_ENTRY + + entity_registry.async_update_entity( + entity.registry_entry.entity_id, disabled_by=disabled_by + ) diff --git a/homeassistant/components/deconz/light.py b/homeassistant/components/deconz/light.py new file mode 100644 index 000000000..af708a153 --- /dev/null +++ b/homeassistant/components/deconz/light.py @@ -0,0 +1,234 @@ +"""Support for deCONZ lights.""" +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 + +from .const import ( + COVER_TYPES, + DOMAIN as DECONZ_DOMAIN, + NEW_GROUP, + NEW_LIGHT, + SWITCH_TYPES, +) +from .deconz_device import DeconzDevice +from .gateway import DeconzEntityHandler, get_gateway_from_config_entry + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Old way of setting up deCONZ platforms.""" + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the deCONZ lights and groups from a config entry.""" + gateway = get_gateway_from_config_entry(hass, config_entry) + + entity_handler = DeconzEntityHandler(gateway) + + @callback + def async_add_light(lights): + """Add light from deCONZ.""" + entities = [] + + for light in lights: + if light.type not in COVER_TYPES + SWITCH_TYPES: + entities.append(DeconzLight(light, gateway)) + + async_add_entities(entities, True) + + gateway.listeners.append( + async_dispatcher_connect( + hass, gateway.async_signal_new_device(NEW_LIGHT), async_add_light + ) + ) + + @callback + def async_add_group(groups): + """Add group from deCONZ.""" + entities = [] + + for group in groups: + if group.lights: + new_group = DeconzGroup(group, gateway) + entity_handler.add_entity(new_group) + entities.append(new_group) + + async_add_entities(entities, True) + + gateway.listeners.append( + async_dispatcher_connect( + hass, gateway.async_signal_new_device(NEW_GROUP), async_add_group + ) + ) + + async_add_light(gateway.api.lights.values()) + async_add_group(gateway.api.groups.values()) + + +class DeconzLight(DeconzDevice, Light): + """Representation of a deCONZ light.""" + + def __init__(self, device, gateway): + """Set up light.""" + super().__init__(device, gateway) + + self._features = SUPPORT_BRIGHTNESS + self._features |= SUPPORT_FLASH + self._features |= SUPPORT_TRANSITION + + if self._device.ct is not None: + self._features |= SUPPORT_COLOR_TEMP + + if self._device.xy is not None: + self._features |= SUPPORT_COLOR + + if self._device.effect is not None: + self._features |= SUPPORT_EFFECT + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + return self._device.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.""" + if self._device.colormode != "ct": + return None + + return self._device.ct + + @property + def hs_color(self): + """Return the hs color value.""" + if self._device.colormode in ("xy", "hs") and self._device.xy: + return color_util.color_xy_to_hs(*self._device.xy) + return None + + @property + def is_on(self): + """Return true if light is on.""" + return self._device.state + + @property + def supported_features(self): + """Flag supported features.""" + return self._features + + 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) + elif "IKEA" in (self._device.manufacturer or ""): + data["transitiontime"] = 0 + + 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._device.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._device.async_set_state(data) + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + attributes = {} + attributes["is_deconz_group"] = self._device.type == "LightGroup" + + return attributes + + +class DeconzGroup(DeconzLight): + """Representation of a deCONZ group.""" + + def __init__(self, device, gateway): + """Set up group and create an unique id.""" + super().__init__(device, gateway) + + self._unique_id = f"{self.gateway.api.config.bridgeid}-{self._device.deconz_id}" + + @property + def unique_id(self): + """Return a unique identifier for this device.""" + return self._unique_id + + @property + def device_info(self): + """Return a device description for device registry.""" + bridgeid = self.gateway.api.config.bridgeid + + return { + "identifiers": {(DECONZ_DOMAIN, self.unique_id)}, + "manufacturer": "Dresden Elektronik", + "model": "deCONZ group", + "name": self._device.name, + "via_device": (DECONZ_DOMAIN, bridgeid), + } + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + attributes = dict(super().device_state_attributes) + attributes["all_on"] = self._device.all_on + + return attributes diff --git a/homeassistant/components/deconz/manifest.json b/homeassistant/components/deconz/manifest.json new file mode 100644 index 000000000..30b006003 --- /dev/null +++ b/homeassistant/components/deconz/manifest.json @@ -0,0 +1,18 @@ +{ + "domain": "deconz", + "name": "Deconz", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/deconz", + "requirements": [ + "pydeconz==65" + ], + "ssdp": [ + { + "manufacturer": "Royal Philips Electronics" + } + ], + "dependencies": [], + "codeowners": [ + "@kane610" + ] +} diff --git a/homeassistant/components/deconz/scene.py b/homeassistant/components/deconz/scene.py new file mode 100644 index 000000000..a84e799d4 --- /dev/null +++ b/homeassistant/components/deconz/scene.py @@ -0,0 +1,61 @@ +"""Support for deCONZ scenes.""" +from homeassistant.components.scene import Scene +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import NEW_SCENE +from .gateway import get_gateway_from_config_entry + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Old way of setting up deCONZ platforms.""" + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up scenes for deCONZ component.""" + gateway = get_gateway_from_config_entry(hass, config_entry) + + @callback + def async_add_scene(scenes): + """Add scene from deCONZ.""" + entities = [] + + for scene in scenes: + entities.append(DeconzScene(scene, gateway)) + + async_add_entities(entities) + + gateway.listeners.append( + async_dispatcher_connect( + hass, gateway.async_signal_new_device(NEW_SCENE), async_add_scene + ) + ) + + async_add_scene(gateway.api.scenes.values()) + + +class DeconzScene(Scene): + """Representation of a deCONZ scene.""" + + def __init__(self, scene, gateway): + """Set up a scene.""" + self._scene = scene + self.gateway = gateway + + async def async_added_to_hass(self): + """Subscribe to sensors events.""" + self.gateway.deconz_ids[self.entity_id] = self._scene.deconz_id + + async def async_will_remove_from_hass(self) -> None: + """Disconnect scene object when removed.""" + del self.gateway.deconz_ids[self.entity_id] + self._scene = None + + 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/deconz/sensor.py b/homeassistant/components/deconz/sensor.py new file mode 100644 index 000000000..4ffaba9b4 --- /dev/null +++ b/homeassistant/components/deconz/sensor.py @@ -0,0 +1,240 @@ +"""Support for deCONZ sensors.""" +from pydeconz.sensor import Consumption, Daylight, LightLevel, Power, Switch, Thermostat + +from homeassistant.const import ATTR_TEMPERATURE, ATTR_VOLTAGE, DEVICE_CLASS_BATTERY +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) + +from .const import ATTR_DARK, ATTR_ON, NEW_SENSOR +from .deconz_device import DeconzDevice +from .deconz_event import DeconzEvent +from .gateway import DeconzEntityHandler, get_gateway_from_config_entry + +ATTR_CURRENT = "current" +ATTR_POWER = "power" +ATTR_DAYLIGHT = "daylight" +ATTR_EVENT_ID = "event_id" + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Old way of setting up deCONZ platforms.""" + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the deCONZ sensors.""" + gateway = get_gateway_from_config_entry(hass, config_entry) + + batteries = set() + battery_handler = DeconzBatteryHandler(gateway) + entity_handler = DeconzEntityHandler(gateway) + + @callback + def async_add_sensor(sensors, new=True): + """Add sensors from deCONZ. + + Create DeconzEvent if part of ZHAType list. + Create DeconzSensor if not a ZHAType and not a binary sensor. + Create DeconzBattery if sensor has a battery attribute. + If new is false it means an existing sensor has got a battery state reported. + """ + entities = [] + + for sensor in sensors: + + if new and sensor.type in Switch.ZHATYPE: + + if gateway.option_allow_clip_sensor or not sensor.type.startswith( + "CLIP" + ): + new_event = DeconzEvent(sensor, gateway) + hass.async_create_task(new_event.async_update_device_registry()) + gateway.events.append(new_event) + + elif new and not sensor.BINARY and sensor.type not in Thermostat.ZHATYPE: + + new_sensor = DeconzSensor(sensor, gateway) + entity_handler.add_entity(new_sensor) + entities.append(new_sensor) + + if sensor.battery is not None: + new_battery = DeconzBattery(sensor, gateway) + if new_battery.unique_id not in batteries: + batteries.add(new_battery.unique_id) + entities.append(new_battery) + battery_handler.remove_tracker(sensor) + else: + battery_handler.create_tracker(sensor) + + async_add_entities(entities, True) + + gateway.listeners.append( + async_dispatcher_connect( + hass, gateway.async_signal_new_device(NEW_SENSOR), async_add_sensor + ) + ) + + async_add_sensor(gateway.api.sensors.values()) + + +class DeconzSensor(DeconzDevice): + """Representation of a deCONZ sensor.""" + + @callback + def async_update_callback(self, force_update=False): + """Update the sensor's state.""" + changed = set(self._device.changed_keys) + keys = {"on", "reachable", "state"} + if force_update or any(key in changed for key in keys): + self.async_schedule_update_ha_state() + + @property + def state(self): + """Return the state of the sensor.""" + return self._device.state + + @property + def device_class(self): + """Return the class of the sensor.""" + return self._device.SENSOR_CLASS + + @property + def icon(self): + """Return the icon to use in the frontend.""" + return self._device.SENSOR_ICON + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this sensor.""" + return self._device.SENSOR_UNIT + + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + attr = {} + + if self._device.on is not None: + attr[ATTR_ON] = self._device.on + + if self._device.secondary_temperature is not None: + attr[ATTR_TEMPERATURE] = self._device.secondary_temperature + + if self._device.type in Consumption.ZHATYPE: + attr[ATTR_POWER] = self._device.power + + elif self._device.type in Daylight.ZHATYPE: + attr[ATTR_DAYLIGHT] = self._device.daylight + + elif self._device.type in LightLevel.ZHATYPE and self._device.dark is not None: + attr[ATTR_DARK] = self._device.dark + + elif self._device.type in Power.ZHATYPE: + attr[ATTR_CURRENT] = self._device.current + attr[ATTR_VOLTAGE] = self._device.voltage + + return attr + + +class DeconzBattery(DeconzDevice): + """Battery class for when a device is only represented as an event.""" + + @callback + def async_update_callback(self, force_update=False): + """Update the battery's state, if needed.""" + changed = set(self._device.changed_keys) + keys = {"battery", "reachable"} + if force_update or any(key in changed for key in keys): + self.async_schedule_update_ha_state() + + @property + def unique_id(self): + """Return a unique identifier for this device.""" + return f"{self.serial}-battery" + + @property + def state(self): + """Return the state of the battery.""" + return self._device.battery + + @property + def name(self): + """Return the name of the battery.""" + return f"{self._device.name} Battery Level" + + @property + def device_class(self): + """Return the class of the sensor.""" + return DEVICE_CLASS_BATTERY + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity.""" + return "%" + + @property + def device_state_attributes(self): + """Return the state attributes of the battery.""" + attr = {} + + if self._device.type in Switch.ZHATYPE: + for event in self.gateway.events: + if self._device == event.device: + attr[ATTR_EVENT_ID] = event.event_id + + return attr + + +class DeconzSensorStateTracker: + """Track sensors without a battery state and signal when battery state exist.""" + + def __init__(self, sensor, gateway): + """Set up tracker.""" + self.sensor = sensor + self.gateway = gateway + sensor.register_async_callback(self.async_update_callback) + + @callback + def close(self): + """Clean up tracker.""" + self.sensor.remove_callback(self.async_update_callback) + self.gateway = None + self.sensor = None + + @callback + def async_update_callback(self): + """Sensor state updated.""" + if "battery" in self.sensor.changed_keys: + async_dispatcher_send( + self.gateway.hass, + self.gateway.async_signal_new_device(NEW_SENSOR), + [self.sensor], + False, + ) + + +class DeconzBatteryHandler: + """Creates and stores trackers for sensors without a battery state.""" + + def __init__(self, gateway): + """Set up battery handler.""" + self.gateway = gateway + self._trackers = set() + + @callback + def create_tracker(self, sensor): + """Create new tracker for battery state.""" + for tracker in self._trackers: + if sensor == tracker.sensor: + return + self._trackers.add(DeconzSensorStateTracker(sensor, self.gateway)) + + @callback + def remove_tracker(self, sensor): + """Remove tracker of battery state.""" + for tracker in self._trackers: + if sensor == tracker.sensor: + tracker.close() + self._trackers.remove(tracker) + break diff --git a/homeassistant/components/deconz/services.py b/homeassistant/components/deconz/services.py new file mode 100644 index 000000000..9d133acdb --- /dev/null +++ b/homeassistant/components/deconz/services.py @@ -0,0 +1,166 @@ +"""deCONZ services.""" +import voluptuous as vol + +from homeassistant.helpers import config_validation as cv + +from .config_flow import get_master_gateway +from .const import ( + _LOGGER, + CONF_BRIDGEID, + DOMAIN, + NEW_GROUP, + NEW_LIGHT, + NEW_SCENE, + NEW_SENSOR, +) + +DECONZ_SERVICES = "deconz_services" + +SERVICE_FIELD = "field" +SERVICE_ENTITY = "entity" +SERVICE_DATA = "data" + +SERVICE_CONFIGURE_DEVICE = "configure" +SERVICE_CONFIGURE_DEVICE_SCHEMA = vol.All( + vol.Schema( + { + vol.Optional(SERVICE_ENTITY): cv.entity_id, + vol.Optional(SERVICE_FIELD): cv.matches_regex("/.*"), + vol.Required(SERVICE_DATA): dict, + vol.Optional(CONF_BRIDGEID): str, + } + ), + cv.has_at_least_one_key(SERVICE_ENTITY, SERVICE_FIELD), +) + +SERVICE_DEVICE_REFRESH = "device_refresh" +SERVICE_DEVICE_REFRESH_SCHEMA = vol.All(vol.Schema({vol.Optional(CONF_BRIDGEID): str})) + + +async def async_setup_services(hass): + """Set up services for deCONZ integration.""" + if hass.data.get(DECONZ_SERVICES, False): + return + + hass.data[DECONZ_SERVICES] = True + + async def async_call_deconz_service(service_call): + """Call correct deCONZ service.""" + service = service_call.service + service_data = service_call.data + + if service == SERVICE_CONFIGURE_DEVICE: + await async_configure_service(hass, service_data) + + elif service == SERVICE_DEVICE_REFRESH: + await async_refresh_devices_service(hass, service_data) + + hass.services.async_register( + DOMAIN, + SERVICE_CONFIGURE_DEVICE, + async_call_deconz_service, + schema=SERVICE_CONFIGURE_DEVICE_SCHEMA, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_DEVICE_REFRESH, + async_call_deconz_service, + schema=SERVICE_DEVICE_REFRESH_SCHEMA, + ) + + +async def async_unload_services(hass): + """Unload deCONZ services.""" + if not hass.data.get(DECONZ_SERVICES): + return + + hass.data[DECONZ_SERVICES] = False + + hass.services.async_remove(DOMAIN, SERVICE_CONFIGURE_DEVICE) + hass.services.async_remove(DOMAIN, SERVICE_DEVICE_REFRESH) + + +async def async_configure_service(hass, data): + """Set attribute of device in deCONZ. + + Entity is used to resolve to a device path (e.g. '/lights/1'). + Field is a string representing either a full path + (e.g. '/lights/1/state') when entity is not specified, or a + subpath (e.g. '/state') when used together with entity. + 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/ + """ + bridgeid = data.get(CONF_BRIDGEID) + field = data.get(SERVICE_FIELD, "") + entity_id = data.get(SERVICE_ENTITY) + data = data[SERVICE_DATA] + + gateway = get_master_gateway(hass) + if bridgeid: + gateway = hass.data[DOMAIN][bridgeid] + + if entity_id: + try: + field = gateway.deconz_ids[entity_id] + field + except KeyError: + _LOGGER.error("Could not find the entity %s", entity_id) + return + + await gateway.api.request("put", field, json=data) + + +async def async_refresh_devices_service(hass, data): + """Refresh available devices from deCONZ.""" + gateway = get_master_gateway(hass) + if CONF_BRIDGEID in data: + gateway = hass.data[DOMAIN][data[CONF_BRIDGEID]] + + groups = set(gateway.api.groups.keys()) + lights = set(gateway.api.lights.keys()) + scenes = set(gateway.api.scenes.keys()) + sensors = set(gateway.api.sensors.keys()) + + await gateway.api.refresh_state() + + gateway.async_add_device_callback( + NEW_GROUP, + [ + group + for group_id, group in gateway.api.groups.items() + if group_id not in groups + ], + ) + + gateway.async_add_device_callback( + NEW_LIGHT, + [ + light + for light_id, light in gateway.api.lights.items() + if light_id not in lights + ], + ) + + gateway.async_add_device_callback( + NEW_SCENE, + [ + scene + for scene_id, scene in gateway.api.scenes.items() + if scene_id not in scenes + ], + ) + + gateway.async_add_device_callback( + NEW_SENSOR, + [ + sensor + for sensor_id, sensor in gateway.api.sensors.items() + if sensor_id not in sensors + ], + ) diff --git a/homeassistant/components/deconz/services.yaml b/homeassistant/components/deconz/services.yaml index fa0fb8e14..bd5c2eb6a 100644 --- a/homeassistant/components/deconz/services.yaml +++ b/homeassistant/components/deconz/services.yaml @@ -1,15 +1,25 @@ configure: - description: Set attribute of device in deCONZ. See https://home-assistant.io/components/deconz/#device-services for details. + description: Set attribute of device in deCONZ. See https://home-assistant.io/integrations/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' + field: + description: >- + Field is a string representing a full path to deCONZ endpoint (when + entity is not specified) or a subpath of the device path for the + entity (when entity is specified). + example: '"/lights/1/state" or "/state"' data: description: Data is a json object with what data you want to alter. example: '{"on": true}' + bridgeid: + description: (Optional) Bridgeid is a string unique for each deCONZ hardware. It can be found as part of the integration name. + example: '00212EFFFF012345' device_refresh: - description: Refresh device lists from deCONZ. \ No newline at end of file + description: Refresh device lists from deCONZ. + fields: + bridgeid: + description: (Optional) Bridgeid is a string unique for each deCONZ hardware. It can be found as part of the integration name. + example: '00212EFFFF012345' diff --git a/homeassistant/components/deconz/strings.json b/homeassistant/components/deconz/strings.json index 09549a300..a00e10f3e 100644 --- a/homeassistant/components/deconz/strings.json +++ b/homeassistant/components/deconz/strings.json @@ -1,33 +1,102 @@ { - "config": { - "title": "deCONZ Zigbee gateway", - "step": { - "init": { - "title": "Define deCONZ gateway", - "data": { - "host": "Host", - "port": "Port (default value: '80')" - } - }, - "link": { - "title": "Link with deCONZ", - "description": "Unlock your deCONZ gateway to register with Home Assistant.\n\n1. Go to deCONZ system settings\n2. Press \"Unlock Gateway\" button" - }, - "options": { - "title": "Extra configuration options for deCONZ", - "data":{ - "allow_clip_sensor": "Allow importing virtual sensors", - "allow_deconz_groups": "Allow importing deCONZ groups" - } - } - }, - "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" + "config": { + "title": "deCONZ Zigbee gateway", + "flow_title": "deCONZ Zigbee gateway ({host})", + "step": { + "init": { + "title": "Define deCONZ gateway", + "data": { + "host": "Host", + "port": "Port" } + }, + "link": { + "title": "Link with deCONZ", + "description": "Unlock your deCONZ gateway to register with Home Assistant.\n\n1. Go to deCONZ Settings -> Gateway -> Advanced\n2. Press \"Authenticate app\" button" + }, + "options": { + "title": "Extra configuration options for deCONZ", + "data": { + "allow_clip_sensor": "Allow importing virtual sensors", + "allow_deconz_groups": "Allow importing deCONZ groups" + } + }, + "hassio_confirm": { + "title": "deCONZ Zigbee gateway via Hass.io add-on", + "description": "Do you want to configure Home Assistant to connect to the deCONZ gateway provided by the hass.io add-on {addon}?", + "data": { + "allow_clip_sensor": "Allow importing virtual sensors", + "allow_deconz_groups": "Allow importing deCONZ groups" + } + } + }, + "error": { + "no_key": "Couldn't get an API key" + }, + "abort": { + "already_configured": "Bridge is already configured", + "already_in_progress": "Config flow for bridge is already in progress.", + "no_bridges": "No deCONZ bridges discovered", + "not_deconz_bridge": "Not a deCONZ bridge", + "one_instance_only": "Component only supports one deCONZ instance", + "updated_instance": "Updated deCONZ instance with new host address" } -} \ No newline at end of file + }, + "options": { + "step": { + "deconz_devices": { + "description": "Configure visibility of deCONZ device types", + "data": { + "allow_clip_sensor": "Allow deCONZ CLIP sensors", + "allow_deconz_groups": "Allow deCONZ light groups" + } + } + } + }, + "device_automation": { + "trigger_type": { + "remote_button_short_press": "\"{subtype}\" button pressed", + "remote_button_short_release": "\"{subtype}\" button released", + "remote_button_long_press": "\"{subtype}\" button continuously pressed", + "remote_button_long_release": "\"{subtype}\" button released after long press", + "remote_button_double_press": "\"{subtype}\" button double clicked", + "remote_button_triple_press": "\"{subtype}\" button triple clicked", + "remote_button_quadruple_press": "\"{subtype}\" button quadruple clicked", + "remote_button_quintuple_press": "\"{subtype}\" button quintuple clicked", + "remote_button_rotated": "Button rotated \"{subtype}\"", + "remote_button_rotation_stopped": "Button rotation \"{subtype}\" stopped", + "remote_falling": "Device in free fall", + "remote_awakened": "Device awakened", + "remote_moved": "Device moved with \"{subtype}\" up", + "remote_double_tap": "Device \"{subtype}\" double tapped", + "remote_gyro_activated": "Device shaken", + "remote_rotate_from_side_1": "Device rotated from \"side 1\" to \"{subtype}\"", + "remote_rotate_from_side_2": "Device rotated from \"side 2\" to \"{subtype}\"", + "remote_rotate_from_side_3": "Device rotated from \"side 3\" to \"{subtype}\"", + "remote_rotate_from_side_4": "Device rotated from \"side 4\" to \"{subtype}\"", + "remote_rotate_from_side_5": "Device rotated from \"side 5\" to \"{subtype}\"", + "remote_rotate_from_side_6": "Device rotated from \"side 6\" to \"{subtype}\"" + }, + "trigger_subtype": { + "turn_on": "Turn on", + "turn_off": "Turn off", + "dim_up": "Dim up", + "dim_down": "Dim down", + "left": "Left", + "right": "Right", + "open": "Open", + "close": "Close", + "both_buttons": "Both buttons", + "button_1": "First button", + "button_2": "Second button", + "button_3": "Third button", + "button_4": "Fourth button", + "side_1": "Side 1", + "side_2": "Side 2", + "side_3": "Side 3", + "side_4": "Side 4", + "side_5": "Side 5", + "side_6": "Side 6" + } + } +} diff --git a/homeassistant/components/deconz/switch.py b/homeassistant/components/deconz/switch.py new file mode 100644 index 000000000..1b5125658 --- /dev/null +++ b/homeassistant/components/deconz/switch.py @@ -0,0 +1,81 @@ +"""Support for deCONZ switches.""" +from homeassistant.components.switch import SwitchDevice +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import NEW_LIGHT, POWER_PLUGS, SIRENS +from .deconz_device import DeconzDevice +from .gateway import get_gateway_from_config_entry + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Old way of setting up deCONZ platforms.""" + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up switches for deCONZ component. + + Switches are based same device class as lights in deCONZ. + """ + gateway = get_gateway_from_config_entry(hass, config_entry) + + @callback + def async_add_switch(lights): + """Add switch from deCONZ.""" + entities = [] + + for light in lights: + + if light.type in POWER_PLUGS: + entities.append(DeconzPowerPlug(light, gateway)) + + elif light.type in SIRENS: + entities.append(DeconzSiren(light, gateway)) + + async_add_entities(entities, True) + + gateway.listeners.append( + async_dispatcher_connect( + hass, gateway.async_signal_new_device(NEW_LIGHT), async_add_switch + ) + ) + + async_add_switch(gateway.api.lights.values()) + + +class DeconzPowerPlug(DeconzDevice, SwitchDevice): + """Representation of a deCONZ power plug.""" + + @property + def is_on(self): + """Return true if switch is on.""" + return self._device.state + + async def async_turn_on(self, **kwargs): + """Turn on switch.""" + data = {"on": True} + await self._device.async_set_state(data) + + async def async_turn_off(self, **kwargs): + """Turn off switch.""" + data = {"on": False} + await self._device.async_set_state(data) + + +class DeconzSiren(DeconzDevice, SwitchDevice): + """Representation of a deCONZ siren.""" + + @property + def is_on(self): + """Return true if switch is on.""" + return self._device.alert == "lselect" + + async def async_turn_on(self, **kwargs): + """Turn on switch.""" + data = {"alert": "lselect"} + await self._device.async_set_state(data) + + async def async_turn_off(self, **kwargs): + """Turn off switch.""" + data = {"alert": "none"} + await self._device.async_set_state(data) diff --git a/homeassistant/components/decora/__init__.py b/homeassistant/components/decora/__init__.py new file mode 100644 index 000000000..694ff77fd --- /dev/null +++ b/homeassistant/components/decora/__init__.py @@ -0,0 +1 @@ +"""The decora component.""" diff --git a/homeassistant/components/decora/light.py b/homeassistant/components/decora/light.py new file mode 100644 index 000000000..6ca427f24 --- /dev/null +++ b/homeassistant/components/decora/light.py @@ -0,0 +1,162 @@ +"""Support for Decora dimmers.""" +import copy +from functools import wraps +import logging +import time + +from bluepy.btle import BTLEException # pylint: disable=import-error, no-member +import decora # pylint: disable=import-error, no-member +import voluptuous as vol + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + PLATFORM_SCHEMA, + SUPPORT_BRIGHTNESS, + Light, +) +from homeassistant.const import CONF_API_KEY, CONF_DEVICES, CONF_NAME +import homeassistant.helpers.config_validation as cv +import homeassistant.util as util + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_DECORA_LED = SUPPORT_BRIGHTNESS + + +def _name_validator(config): + """Validate the name.""" + config = copy.deepcopy(config) + for address, device_config in config[CONF_DEVICES].items(): + if CONF_NAME not in device_config: + device_config[CONF_NAME] = util.slugify(address) + + return config + + +DEVICE_SCHEMA = vol.Schema( + {vol.Optional(CONF_NAME): cv.string, vol.Required(CONF_API_KEY): cv.string} +) + +PLATFORM_SCHEMA = vol.Schema( + vol.All( + PLATFORM_SCHEMA.extend( + {vol.Optional(CONF_DEVICES, default={}): {cv.string: DEVICE_SCHEMA}} + ), + _name_validator, + ) +) + + +def retry(method): + """Retry bluetooth commands.""" + + @wraps(method) + def wrapper_retry(device, *args, **kwargs): + """Try send command and retry on error.""" + + initial = time.monotonic() + while True: + if time.monotonic() - initial >= 10: + return None + try: + return method(device, *args, **kwargs) + except (decora.decoraException, AttributeError, BTLEException): + _LOGGER.warning( + "Decora connect error for device %s. " "Reconnecting...", + device.name, + ) + # pylint: disable=protected-access + device._switch.connect() + + return wrapper_retry + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up an Decora switch.""" + lights = [] + for address, device_config in config[CONF_DEVICES].items(): + device = {} + device["name"] = device_config[CONF_NAME] + device["key"] = device_config[CONF_API_KEY] + device["address"] = address + light = DecoraLight(device) + lights.append(light) + + add_entities(lights) + + +class DecoraLight(Light): + """Representation of an Decora light.""" + + def __init__(self, device): + """Initialize the light.""" + + self._name = device["name"] + self._address = device["address"] + self._key = device["key"] + self._switch = decora.decora(self._address, self._key) + self._brightness = 0 + self._state = False + + @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 self._brightness + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_DECORA_LED + + @property + def should_poll(self): + """We can read the device state, so poll.""" + return True + + @property + def assumed_state(self): + """We can read the actual state.""" + return False + + @retry + def set_state(self, brightness): + """Set the state of this lamp to the provided brightness.""" + self._switch.set_brightness(int(brightness / 2.55)) + self._brightness = brightness + + @retry + def turn_on(self, **kwargs): + """Turn the specified or all lights on.""" + brightness = kwargs.get(ATTR_BRIGHTNESS) + self._switch.on() + self._state = True + + if brightness is not None: + self.set_state(brightness) + + @retry + def turn_off(self, **kwargs): + """Turn the specified or all lights off.""" + self._switch.off() + self._state = False + + @retry + def update(self): + """Synchronise internal state with the actual light state.""" + self._brightness = self._switch.get_brightness() * 2.55 + self._state = self._switch.get_on() diff --git a/homeassistant/components/decora/manifest.json b/homeassistant/components/decora/manifest.json new file mode 100644 index 000000000..5142b5fb2 --- /dev/null +++ b/homeassistant/components/decora/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "decora", + "name": "Decora", + "documentation": "https://www.home-assistant.io/integrations/decora", + "requirements": [ + "bluepy==1.1.4", + "decora==0.6" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/decora_wifi/__init__.py b/homeassistant/components/decora_wifi/__init__.py new file mode 100644 index 000000000..b4bea7345 --- /dev/null +++ b/homeassistant/components/decora_wifi/__init__.py @@ -0,0 +1 @@ +"""The decora_wifi component.""" diff --git a/homeassistant/components/decora_wifi/light.py b/homeassistant/components/decora_wifi/light.py new file mode 100644 index 000000000..7d8aa104b --- /dev/null +++ b/homeassistant/components/decora_wifi/light.py @@ -0,0 +1,145 @@ +"""Interfaces with the myLeviton API for Decora Smart WiFi products.""" + +import logging + +# pylint: disable=import-error +from decora_wifi import DecoraWiFiSession +from decora_wifi.models.person import Person +from decora_wifi.models.residence import Residence +from decora_wifi.models.residential_account import ResidentialAccount +import voluptuous as vol + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_TRANSITION, + PLATFORM_SCHEMA, + SUPPORT_BRIGHTNESS, + SUPPORT_TRANSITION, + Light, +) +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, EVENT_HOMEASSISTANT_STOP +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +# Validation of the user's configuration +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + {vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string} +) + +NOTIFICATION_ID = "leviton_notification" +NOTIFICATION_TITLE = "myLeviton Decora Setup" + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Decora WiFi platform.""" + + email = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + session = DecoraWiFiSession() + + try: + success = session.login(email, password) + + # If login failed, notify user. + if success is None: + msg = "Failed to log into myLeviton Services. Check credentials." + _LOGGER.error(msg) + hass.components.persistent_notification.create( + msg, title=NOTIFICATION_TITLE, notification_id=NOTIFICATION_ID + ) + return False + + # Gather all the available devices... + perms = session.user.get_residential_permissions() + all_switches = [] + for permission in perms: + if permission.residentialAccountId is not None: + acct = ResidentialAccount(session, permission.residentialAccountId) + for residence in acct.get_residences(): + for switch in residence.get_iot_switches(): + all_switches.append(switch) + elif permission.residenceId is not None: + residence = Residence(session, permission.residenceId) + for switch in residence.get_iot_switches(): + all_switches.append(switch) + + add_entities(DecoraWifiLight(sw) for sw in all_switches) + except ValueError: + _LOGGER.error("Failed to communicate with myLeviton Service.") + + # Listen for the stop event and log out. + def logout(event): + """Log out...""" + try: + if session is not None: + Person.logout(session) + except ValueError: + _LOGGER.error("Failed to log out of myLeviton Service.") + + hass.bus.listen(EVENT_HOMEASSISTANT_STOP, logout) + + +class DecoraWifiLight(Light): + """Representation of a Decora WiFi switch.""" + + def __init__(self, switch): + """Initialize the switch.""" + self._switch = switch + + @property + def supported_features(self): + """Return supported features.""" + if self._switch.canSetLevel: + return SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION + return 0 + + @property + def name(self): + """Return the display name of this switch.""" + return self._switch.name + + @property + def brightness(self): + """Return the brightness of the dimmer switch.""" + return int(self._switch.brightness * 255 / 100) + + @property + def is_on(self): + """Return true if switch is on.""" + return self._switch.power == "ON" + + def turn_on(self, **kwargs): + """Instruct the switch to turn on & adjust brightness.""" + attribs = {"power": "ON"} + + if ATTR_BRIGHTNESS in kwargs: + min_level = self._switch.data.get("minLevel", 0) + max_level = self._switch.data.get("maxLevel", 100) + brightness = int(kwargs[ATTR_BRIGHTNESS] * max_level / 255) + brightness = max(brightness, min_level) + attribs["brightness"] = brightness + + if ATTR_TRANSITION in kwargs: + transition = int(kwargs[ATTR_TRANSITION]) + attribs["fadeOnTime"] = attribs["fadeOffTime"] = transition + + try: + self._switch.update_attributes(attribs) + except ValueError: + _LOGGER.error("Failed to turn on myLeviton switch.") + + def turn_off(self, **kwargs): + """Instruct the switch to turn off.""" + attribs = {"power": "OFF"} + try: + self._switch.update_attributes(attribs) + except ValueError: + _LOGGER.error("Failed to turn off myLeviton switch.") + + def update(self): + """Fetch new state data for this switch.""" + try: + self._switch.refresh() + except ValueError: + _LOGGER.error("Failed to update myLeviton switch data.") diff --git a/homeassistant/components/decora_wifi/manifest.json b/homeassistant/components/decora_wifi/manifest.json new file mode 100644 index 000000000..14b8829fa --- /dev/null +++ b/homeassistant/components/decora_wifi/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "decora_wifi", + "name": "Decora wifi", + "documentation": "https://www.home-assistant.io/integrations/decora_wifi", + "requirements": [ + "decora_wifi==1.4" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/default_config/__init__.py b/homeassistant/components/default_config/__init__.py new file mode 100644 index 000000000..506904a50 --- /dev/null +++ b/homeassistant/components/default_config/__init__.py @@ -0,0 +1,17 @@ +"""Component providing default configuration for new users.""" +try: + import av +except ImportError: + av = None + +from homeassistant.setup import async_setup_component + +DOMAIN = "default_config" + + +async def async_setup(hass, config): + """Initialize default configuration.""" + if av is None: + return True + + return await async_setup_component(hass, "stream", config) diff --git a/homeassistant/components/default_config/manifest.json b/homeassistant/components/default_config/manifest.json new file mode 100644 index 000000000..a240599c4 --- /dev/null +++ b/homeassistant/components/default_config/manifest.json @@ -0,0 +1,24 @@ +{ + "domain": "default_config", + "name": "Default config", + "documentation": "https://www.home-assistant.io/integrations/default_config", + "requirements": [], + "dependencies": [ + "automation", + "cloud", + "config", + "frontend", + "history", + "logbook", + "map", + "mobile_app", + "person", + "script", + "ssdp", + "sun", + "system_health", + "updater", + "zeroconf" + ], + "codeowners": [] +} diff --git a/homeassistant/components/delijn/__init__.py b/homeassistant/components/delijn/__init__.py new file mode 100644 index 000000000..cdec12658 --- /dev/null +++ b/homeassistant/components/delijn/__init__.py @@ -0,0 +1 @@ +"""The delijn component.""" diff --git a/homeassistant/components/delijn/manifest.json b/homeassistant/components/delijn/manifest.json new file mode 100644 index 000000000..2d550a085 --- /dev/null +++ b/homeassistant/components/delijn/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "delijn", + "name": "De Lijn", + "documentation": "https://www.home-assistant.io/integrations/delijn", + "dependencies": [], + "codeowners": ["@bollewolle"], + "requirements": ["pydelijn==0.5.1"] +} diff --git a/homeassistant/components/delijn/sensor.py b/homeassistant/components/delijn/sensor.py new file mode 100644 index 000000000..2cd238d07 --- /dev/null +++ b/homeassistant/components/delijn/sensor.py @@ -0,0 +1,117 @@ +"""Support for De Lijn (Flemish public transport) information.""" +import logging + +from pydelijn.api import Passages +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ATTR_ATTRIBUTION, DEVICE_CLASS_TIMESTAMP +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +ATTRIBUTION = "Data provided by data.delijn.be" + +CONF_NEXT_DEPARTURE = "next_departure" +CONF_STOP_ID = "stop_id" +CONF_API_KEY = "api_key" +CONF_NUMBER_OF_DEPARTURES = "number_of_departures" + +DEFAULT_NAME = "De Lijn" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_NEXT_DEPARTURE): [ + { + vol.Required(CONF_STOP_ID): cv.string, + vol.Optional(CONF_NUMBER_OF_DEPARTURES, default=5): cv.positive_int, + } + ], + } +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Create the sensor.""" + api_key = config[CONF_API_KEY] + name = DEFAULT_NAME + + session = async_get_clientsession(hass) + + sensors = [] + for nextpassage in config[CONF_NEXT_DEPARTURE]: + stop_id = nextpassage[CONF_STOP_ID] + number_of_departures = nextpassage[CONF_NUMBER_OF_DEPARTURES] + line = Passages( + hass.loop, stop_id, number_of_departures, api_key, session, True + ) + await line.get_passages() + if line.passages is None: + _LOGGER.warning("No data received from De Lijn") + return + sensors.append(DeLijnPublicTransportSensor(line, name)) + + async_add_entities(sensors, True) + + +class DeLijnPublicTransportSensor(Entity): + """Representation of a Ruter sensor.""" + + def __init__(self, line, name): + """Initialize the sensor.""" + self.line = line + self._attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} + self._name = name + self._state = None + + async def async_update(self): + """Get the latest data from the De Lijn API.""" + await self.line.get_passages() + if self.line.passages is None: + _LOGGER.warning("No data received from De Lijn") + return + try: + first = self.line.passages[0] + if first["due_at_realtime"] is not None: + first_passage = first["due_at_realtime"] + else: + first_passage = first["due_at_schedule"] + self._state = first_passage + self._name = first["stopname"] + self._attributes["stopname"] = first["stopname"] + self._attributes["line_number_public"] = first["line_number_public"] + self._attributes["line_transport_type"] = first["line_transport_type"] + self._attributes["final_destination"] = first["final_destination"] + self._attributes["due_at_schedule"] = first["due_at_schedule"] + self._attributes["due_at_realtime"] = first["due_at_realtime"] + self._attributes["next_passages"] = self.line.passages + except (KeyError, IndexError) as error: + _LOGGER.debug("Error getting data from De Lijn: %s", error) + + @property + def device_class(self): + """Return the device class.""" + return DEVICE_CLASS_TIMESTAMP + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def icon(self): + """Return the icon of the sensor.""" + return "mdi:bus" + + @property + def device_state_attributes(self): + """Return attributes for the sensor.""" + return self._attributes diff --git a/homeassistant/components/deluge/__init__.py b/homeassistant/components/deluge/__init__.py new file mode 100644 index 000000000..ad40b688f --- /dev/null +++ b/homeassistant/components/deluge/__init__.py @@ -0,0 +1 @@ +"""The deluge component.""" diff --git a/homeassistant/components/deluge/manifest.json b/homeassistant/components/deluge/manifest.json new file mode 100644 index 000000000..b2eb3ada6 --- /dev/null +++ b/homeassistant/components/deluge/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "deluge", + "name": "Deluge", + "documentation": "https://www.home-assistant.io/integrations/deluge", + "requirements": [ + "deluge-client==1.7.1" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/deluge/sensor.py b/homeassistant/components/deluge/sensor.py new file mode 100644 index 000000000..7df87490c --- /dev/null +++ b/homeassistant/components/deluge/sensor.py @@ -0,0 +1,147 @@ +"""Support for monitoring the Deluge BitTorrent client API.""" +import logging + +from deluge_client import DelugeRPCClient, FailedToReconnectException +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_HOST, + CONF_MONITORED_VARIABLES, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + STATE_IDLE, +) +from homeassistant.exceptions import PlatformNotReady +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) +_THROTTLED_REFRESH = None + +DEFAULT_NAME = "Deluge" +DEFAULT_PORT = 58846 +DHT_UPLOAD = 1000 +DHT_DOWNLOAD = 1000 +SENSOR_TYPES = { + "current_status": ["Status", None], + "download_speed": ["Down Speed", "kB/s"], + "upload_speed": ["Up Speed", "kB/s"], +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Required(CONF_USERNAME): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_MONITORED_VARIABLES, default=[]): vol.All( + cv.ensure_list, [vol.In(SENSOR_TYPES)] + ), + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Deluge sensors.""" + + name = config.get(CONF_NAME) + host = config.get(CONF_HOST) + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + port = config.get(CONF_PORT) + + deluge_api = DelugeRPCClient(host, port, username, password) + try: + deluge_api.connect() + except ConnectionRefusedError: + _LOGGER.error("Connection to Deluge Daemon failed") + raise PlatformNotReady + dev = [] + for variable in config[CONF_MONITORED_VARIABLES]: + dev.append(DelugeSensor(variable, deluge_api, name)) + + add_entities(dev) + + +class DelugeSensor(Entity): + """Representation of a Deluge sensor.""" + + def __init__(self, sensor_type, deluge_client, client_name): + """Initialize the sensor.""" + self._name = SENSOR_TYPES[sensor_type][0] + self.client = deluge_client + self.type = sensor_type + self.client_name = client_name + self._state = None + self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self.data = None + self._available = False + + @property + def name(self): + """Return the name of the sensor.""" + return f"{self.client_name} {self._name}" + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def available(self): + """Return true if device is available.""" + return self._available + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._unit_of_measurement + + def update(self): + """Get the latest data from Deluge and updates the state.""" + + try: + self.data = self.client.call( + "core.get_session_status", + [ + "upload_rate", + "download_rate", + "dht_upload_rate", + "dht_download_rate", + ], + ) + self._available = True + except FailedToReconnectException: + _LOGGER.error("Connection to Deluge Daemon Lost") + self._available = False + return + + upload = self.data[b"upload_rate"] - self.data[b"dht_upload_rate"] + download = self.data[b"download_rate"] - self.data[b"dht_download_rate"] + + if self.type == "current_status": + if self.data: + if upload > 0 and download > 0: + self._state = "Up/Down" + elif upload > 0 and download == 0: + self._state = "Seeding" + elif upload == 0 and download > 0: + self._state = "Downloading" + else: + self._state = STATE_IDLE + else: + self._state = None + + if self.data: + if self.type == "download_speed": + kb_spd = float(download) + kb_spd = kb_spd / 1024 + self._state = round(kb_spd, 2 if kb_spd < 0.1 else 1) + elif self.type == "upload_speed": + kb_spd = float(upload) + kb_spd = kb_spd / 1024 + self._state = round(kb_spd, 2 if kb_spd < 0.1 else 1) diff --git a/homeassistant/components/deluge/switch.py b/homeassistant/components/deluge/switch.py new file mode 100644 index 000000000..7ac98f284 --- /dev/null +++ b/homeassistant/components/deluge/switch.py @@ -0,0 +1,114 @@ +"""Support for setting the Deluge BitTorrent client in Pause.""" +import logging + +from deluge_client import DelugeRPCClient, FailedToReconnectException +import voluptuous as vol + +from homeassistant.components.switch import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + STATE_OFF, + STATE_ON, +) +from homeassistant.exceptions import PlatformNotReady +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import ToggleEntity + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = "Deluge Switch" +DEFAULT_PORT = 58846 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Required(CONF_USERNAME): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Deluge switch.""" + + name = config.get(CONF_NAME) + host = config.get(CONF_HOST) + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + port = config.get(CONF_PORT) + + deluge_api = DelugeRPCClient(host, port, username, password) + try: + deluge_api.connect() + except ConnectionRefusedError: + _LOGGER.error("Connection to Deluge Daemon failed") + raise PlatformNotReady + + add_entities([DelugeSwitch(deluge_api, name)]) + + +class DelugeSwitch(ToggleEntity): + """Representation of a Deluge switch.""" + + def __init__(self, deluge_client, name): + """Initialize the Deluge switch.""" + self._name = name + self.deluge_client = deluge_client + self._state = STATE_OFF + self._available = False + + @property + def name(self): + """Return the name of the switch.""" + return self._name + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @property + def is_on(self): + """Return true if device is on.""" + return self._state == STATE_ON + + @property + def available(self): + """Return true if device is available.""" + return self._available + + def turn_on(self, **kwargs): + """Turn the device on.""" + torrent_ids = self.deluge_client.call("core.get_session_state") + self.deluge_client.call("core.resume_torrent", torrent_ids) + + def turn_off(self, **kwargs): + """Turn the device off.""" + torrent_ids = self.deluge_client.call("core.get_session_state") + self.deluge_client.call("core.pause_torrent", torrent_ids) + + def update(self): + """Get the latest data from deluge and updates the state.""" + + try: + torrent_list = self.deluge_client.call( + "core.get_torrents_status", {}, ["paused"] + ) + self._available = True + except FailedToReconnectException: + _LOGGER.error("Connection to Deluge Daemon Lost") + self._available = False + return + for torrent in torrent_list.values(): + item = torrent.popitem() + if not item[1]: + self._state = STATE_ON + return + + self._state = STATE_OFF diff --git a/homeassistant/components/demo.py b/homeassistant/components/demo.py deleted file mode 100644 index c2c786614..000000000 --- a/homeassistant/components/demo.py +++ /dev/null @@ -1,231 +0,0 @@ -""" -Set up the demo environment that mimics interaction with devices. - -For more details about this component, please refer to the documentation -https://home-assistant.io/components/demo/ -""" -import asyncio -import time - -from homeassistant import bootstrap -import homeassistant.core as ha -from homeassistant.const import ATTR_ENTITY_ID, CONF_PLATFORM - -DEPENDENCIES = ['conversation', 'introduction', 'zone'] -DOMAIN = 'demo' - -COMPONENTS_WITH_DEMO_PLATFORM = [ - 'alarm_control_panel', - 'binary_sensor', - 'calendar', - 'camera', - 'climate', - 'cover', - 'device_tracker', - 'fan', - 'image_processing', - 'light', - 'lock', - 'media_player', - 'notify', - 'sensor', - 'switch', - 'tts', - 'mailbox', -] - - -@asyncio.coroutine -def async_setup(hass, config): - """Set up the demo environment.""" - group = hass.components.group - configurator = hass.components.configurator - persistent_notification = hass.components.persistent_notification - - config.setdefault(ha.DOMAIN, {}) - config.setdefault(DOMAIN, {}) - - if config[DOMAIN].get('hide_demo_state') != 1: - hass.states.async_set('a.Demo_Mode', 'Enabled') - - # Setup sun - if not hass.config.latitude: - hass.config.latitude = 32.87336 - - if not hass.config.longitude: - hass.config.longitude = 117.22743 - - tasks = [ - bootstrap.async_setup_component(hass, 'sun') - ] - - # Set up demo platforms - demo_config = config.copy() - for component in COMPONENTS_WITH_DEMO_PLATFORM: - demo_config[component] = {CONF_PLATFORM: 'demo'} - tasks.append( - bootstrap.async_setup_component(hass, component, demo_config)) - - # Set up input select - tasks.append(bootstrap.async_setup_component( - hass, 'input_select', - {'input_select': - {'living_room_preset': {'options': ['Visitors', - 'Visitors with kids', - 'Home Alone']}, - 'who_cooks': {'icon': 'mdi:panda', - 'initial': 'Anne Therese', - 'name': 'Cook today', - 'options': ['Paulus', 'Anne Therese']}}})) - # Set up input boolean - tasks.append(bootstrap.async_setup_component( - hass, 'input_boolean', - {'input_boolean': {'notify': { - 'icon': 'mdi:car', - 'initial': False, - 'name': 'Notify Anne Therese is home'}}})) - - # Set up input boolean - tasks.append(bootstrap.async_setup_component( - hass, 'input_number', - {'input_number': { - 'noise_allowance': {'icon': 'mdi:bell-ring', - 'min': 0, - 'max': 10, - 'name': 'Allowed Noise', - 'unit_of_measurement': 'dB'}}})) - - # Set up weblink - tasks.append(bootstrap.async_setup_component( - hass, 'weblink', - {'weblink': {'entities': [{'name': 'Router', - 'url': 'http://192.168.1.1'}]}})) - - results = yield from asyncio.gather(*tasks, loop=hass.loop) - - if any(not result for result in results): - return False - - # Set up example persistent notification - persistent_notification.async_create( - 'This is an example of a persistent notification.', - title='Example Notification') - - # Set up room groups - lights = sorted(hass.states.async_entity_ids('light')) - switches = sorted(hass.states.async_entity_ids('switch')) - media_players = sorted(hass.states.async_entity_ids('media_player')) - - 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', - {'script': { - 'demo': { - 'alias': 'Toggle {}'.format(lights[0].split('.')[1]), - 'sequence': [{ - 'service': 'light.turn_off', - 'data': {ATTR_ENTITY_ID: lights[0]} - }, { - 'delay': {'seconds': 5} - }, { - 'service': 'light.turn_on', - 'data': {ATTR_ENTITY_ID: lights[0]} - }, { - 'delay': {'seconds': 5} - }, { - 'service': 'light.turn_off', - 'data': {ATTR_ENTITY_ID: lights[0]} - }] - }}})) - - # Set up scenes - tasks2.append(bootstrap.async_setup_component( - hass, 'scene', - {'scene': [ - {'name': 'Romantic lights', - 'entities': { - lights[0]: True, - lights[1]: {'state': 'on', 'xy_color': [0.33, 0.66], - 'brightness': 200}, - }}, - {'name': 'Switch on and off', - 'entities': { - switches[0]: True, - switches[1]: False, - }}, - ]})) - - tasks2.append(group.Group.async_create_group(hass, 'Living Room', [ - lights[1], switches[0], 'input_select.living_room_preset', - 'cover.living_room_window', media_players[1], - 'scene.romantic_lights'])) - tasks2.append(group.Group.async_create_group(hass, 'Bedroom', [ - lights[0], switches[1], media_players[0], - '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', [ - 'lock.front_door', 'lock.kitchen_door', - 'garage_door.right_garage_door', 'garage_door.left_garage_door'])) - tasks2.append(group.Group.async_create_group(hass, 'Automations', [ - 'input_select.who_cooks', 'input_boolean.notify', ])) - tasks2.append(group.Group.async_create_group(hass, 'People', [ - 'device_tracker.demo_anne_therese', 'device_tracker.demo_home_boy', - 'device_tracker.demo_paulus'])) - tasks2.append(group.Group.async_create_group(hass, 'Downstairs', [ - 'group.living_room', 'group.kitchen', - 'scene.romantic_lights', 'cover.kitchen_window', - 'cover.living_room_window', 'group.doors', - 'climate.ecobee', - ], view=True)) - - results = yield from asyncio.gather(*tasks2, loop=hass.loop) - - if any(not result for result in results): - return False - - # Set up configurator - configurator_ids = [] - - def hue_configuration_callback(data): - """Fake callback, mark config as done.""" - time.sleep(2) - - # First time it is called, pretend it failed. - if len(configurator_ids) == 1: - configurator.notify_errors( - configurator_ids[0], - "Failed to register, please try again.") - - configurator_ids.append(0) - else: - configurator.request_done(configurator_ids[0]) - - def setup_configurator(): - """Set up a configurator.""" - request_id = configurator.request_config( - "Philips Hue", hue_configuration_callback, - description=("Press the button on the bridge to register Philips " - "Hue with Home Assistant."), - description_image="/static/images/config_philips_hue.jpg", - fields=[{'id': 'username', 'name': 'Username'}], - submit_caption="I have pressed the button" - ) - configurator_ids.append(request_id) - - hass.async_add_job(setup_configurator) - - return True diff --git a/homeassistant/components/demo/.translations/bg.json b/homeassistant/components/demo/.translations/bg.json new file mode 100644 index 000000000..3b1f5f8a8 --- /dev/null +++ b/homeassistant/components/demo/.translations/bg.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "\u0414\u0435\u043c\u043e\u043d\u0441\u0442\u0440\u0430\u0446\u0438\u044f" + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/.translations/ca.json b/homeassistant/components/demo/.translations/ca.json new file mode 100644 index 000000000..944d358e7 --- /dev/null +++ b/homeassistant/components/demo/.translations/ca.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Demostraci\u00f3" + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/.translations/de.json b/homeassistant/components/demo/.translations/de.json new file mode 100644 index 000000000..ef01fcb4f --- /dev/null +++ b/homeassistant/components/demo/.translations/de.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Demo" + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/.translations/en.json b/homeassistant/components/demo/.translations/en.json new file mode 100644 index 000000000..ef01fcb4f --- /dev/null +++ b/homeassistant/components/demo/.translations/en.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Demo" + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/.translations/es.json b/homeassistant/components/demo/.translations/es.json new file mode 100644 index 000000000..ef01fcb4f --- /dev/null +++ b/homeassistant/components/demo/.translations/es.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Demo" + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/.translations/fr.json b/homeassistant/components/demo/.translations/fr.json new file mode 100644 index 000000000..bc093330c --- /dev/null +++ b/homeassistant/components/demo/.translations/fr.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "D\u00e9mo" + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/.translations/hu.json b/homeassistant/components/demo/.translations/hu.json new file mode 100644 index 000000000..51f0cd006 --- /dev/null +++ b/homeassistant/components/demo/.translations/hu.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Dem\u00f3" + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/.translations/it.json b/homeassistant/components/demo/.translations/it.json new file mode 100644 index 000000000..ef01fcb4f --- /dev/null +++ b/homeassistant/components/demo/.translations/it.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Demo" + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/.translations/ja.json b/homeassistant/components/demo/.translations/ja.json new file mode 100644 index 000000000..529170b11 --- /dev/null +++ b/homeassistant/components/demo/.translations/ja.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "\u30c7\u30e2" + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/.translations/lb.json b/homeassistant/components/demo/.translations/lb.json new file mode 100644 index 000000000..ef01fcb4f --- /dev/null +++ b/homeassistant/components/demo/.translations/lb.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Demo" + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/.translations/nl.json b/homeassistant/components/demo/.translations/nl.json new file mode 100644 index 000000000..ef01fcb4f --- /dev/null +++ b/homeassistant/components/demo/.translations/nl.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Demo" + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/.translations/no.json b/homeassistant/components/demo/.translations/no.json new file mode 100644 index 000000000..ef01fcb4f --- /dev/null +++ b/homeassistant/components/demo/.translations/no.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Demo" + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/.translations/pl.json b/homeassistant/components/demo/.translations/pl.json new file mode 100644 index 000000000..ef01fcb4f --- /dev/null +++ b/homeassistant/components/demo/.translations/pl.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Demo" + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/.translations/pt-BR.json b/homeassistant/components/demo/.translations/pt-BR.json new file mode 100644 index 000000000..8183f28ae --- /dev/null +++ b/homeassistant/components/demo/.translations/pt-BR.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Demonstra\u00e7\u00e3o" + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/.translations/pt.json b/homeassistant/components/demo/.translations/pt.json new file mode 100644 index 000000000..8183f28ae --- /dev/null +++ b/homeassistant/components/demo/.translations/pt.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Demonstra\u00e7\u00e3o" + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/.translations/ru.json b/homeassistant/components/demo/.translations/ru.json new file mode 100644 index 000000000..0438252a4 --- /dev/null +++ b/homeassistant/components/demo/.translations/ru.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "\u0414\u0435\u043c\u043e" + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/.translations/sl.json b/homeassistant/components/demo/.translations/sl.json new file mode 100644 index 000000000..ef01fcb4f --- /dev/null +++ b/homeassistant/components/demo/.translations/sl.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Demo" + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/.translations/zh-Hant.json b/homeassistant/components/demo/.translations/zh-Hant.json new file mode 100644 index 000000000..cfb0fced0 --- /dev/null +++ b/homeassistant/components/demo/.translations/zh-Hant.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "\u5c55\u793a" + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/__init__.py b/homeassistant/components/demo/__init__.py new file mode 100644 index 000000000..b6845d9d6 --- /dev/null +++ b/homeassistant/components/demo/__init__.py @@ -0,0 +1,280 @@ +"""Set up the demo environment that mimics interaction with devices.""" +import asyncio +import logging +import time + +from homeassistant import bootstrap, config_entries +from homeassistant.const import ATTR_ENTITY_ID, EVENT_HOMEASSISTANT_START +import homeassistant.core as ha + +DOMAIN = "demo" +_LOGGER = logging.getLogger(__name__) + +COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM = [ + "air_quality", + "alarm_control_panel", + "binary_sensor", + "camera", + "climate", + "cover", + "fan", + "light", + "lock", + "media_player", + "sensor", + "switch", + "water_heater", +] + +COMPONENTS_WITH_DEMO_PLATFORM = [ + "tts", + "stt", + "mailbox", + "notify", + "image_processing", + "calendar", + "device_tracker", +] + + +async def async_setup(hass, config): + """Set up the demo environment.""" + if DOMAIN not in config: + return True + + if not hass.config_entries.async_entries(DOMAIN): + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data={} + ) + ) + + # Set up demo platforms + for component in COMPONENTS_WITH_DEMO_PLATFORM: + hass.async_create_task( + hass.helpers.discovery.async_load_platform(component, DOMAIN, {}, config) + ) + + config.setdefault(ha.DOMAIN, {}) + config.setdefault(DOMAIN, {}) + + # Set up sun + if not hass.config.latitude: + hass.config.latitude = 32.87336 + + if not hass.config.longitude: + hass.config.longitude = 117.22743 + + tasks = [bootstrap.async_setup_component(hass, "sun", config)] + + # Set up input select + tasks.append( + bootstrap.async_setup_component( + hass, + "input_select", + { + "input_select": { + "living_room_preset": { + "options": ["Visitors", "Visitors with kids", "Home Alone"] + }, + "who_cooks": { + "icon": "mdi:panda", + "initial": "Anne Therese", + "name": "Cook today", + "options": ["Paulus", "Anne Therese"], + }, + } + }, + ) + ) + + # Set up input boolean + tasks.append( + bootstrap.async_setup_component( + hass, + "input_boolean", + { + "input_boolean": { + "notify": { + "icon": "mdi:car", + "initial": False, + "name": "Notify Anne Therese is home", + } + } + }, + ) + ) + + # Set up input boolean + tasks.append( + bootstrap.async_setup_component( + hass, + "input_number", + { + "input_number": { + "noise_allowance": { + "icon": "mdi:bell-ring", + "min": 0, + "max": 10, + "name": "Allowed Noise", + "unit_of_measurement": "dB", + } + } + }, + ) + ) + + # Set up weblink + tasks.append( + bootstrap.async_setup_component( + hass, + "weblink", + { + "weblink": { + "entities": [{"name": "Router", "url": "http://192.168.1.1"}] + } + }, + ) + ) + + results = await asyncio.gather(*tasks) + + if any(not result for result in results): + return False + + # Set up example persistent notification + hass.components.persistent_notification.async_create( + "This is an example of a persistent notification.", title="Example Notification" + ) + + # Set up configurator + configurator_ids = [] + configurator = hass.components.configurator + + def hue_configuration_callback(data): + """Fake callback, mark config as done.""" + time.sleep(2) + + # First time it is called, pretend it failed. + if len(configurator_ids) == 1: + configurator.notify_errors( + configurator_ids[0], "Failed to register, please try again." + ) + + configurator_ids.append(0) + else: + configurator.request_done(configurator_ids[0]) + + request_id = configurator.async_request_config( + "Philips Hue", + hue_configuration_callback, + description=( + "Press the button on the bridge to register Philips " + "Hue with Home Assistant." + ), + description_image="/static/images/config_philips_hue.jpg", + fields=[{"id": "username", "name": "Username"}], + submit_caption="I have pressed the button", + ) + configurator_ids.append(request_id) + + async def demo_start_listener(_event): + """Finish set up.""" + await finish_setup(hass, config) + + hass.bus.async_listen(EVENT_HOMEASSISTANT_START, demo_start_listener) + + return True + + +async def async_setup_entry(hass, config_entry): + """Set the config entry up.""" + # Set up demo platforms with config entry + for component in COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, component) + ) + return True + + +async def finish_setup(hass, config): + """Finish set up once demo platforms are set up.""" + switches = None + lights = None + + while not switches and not lights: + # Not all platforms might be loaded. + if switches is not None: + await asyncio.sleep(0) + switches = sorted(hass.states.async_entity_ids("switch")) + lights = sorted(hass.states.async_entity_ids("light")) + + # Set up history graph + await bootstrap.async_setup_component( + hass, + "history_graph", + { + "history_graph": { + "switches": { + "name": "Recent Switches", + "entities": switches, + "hours_to_show": 1, + "refresh": 60, + } + } + }, + ) + + # Set up scripts + await bootstrap.async_setup_component( + hass, + "script", + { + "script": { + "demo": { + "alias": "Toggle {}".format(lights[0].split(".")[1]), + "sequence": [ + { + "service": "light.turn_off", + "data": {ATTR_ENTITY_ID: lights[0]}, + }, + {"delay": {"seconds": 5}}, + { + "service": "light.turn_on", + "data": {ATTR_ENTITY_ID: lights[0]}, + }, + {"delay": {"seconds": 5}}, + { + "service": "light.turn_off", + "data": {ATTR_ENTITY_ID: lights[0]}, + }, + ], + } + } + }, + ) + + # Set up scenes + await bootstrap.async_setup_component( + hass, + "scene", + { + "scene": [ + { + "name": "Romantic lights", + "entities": { + lights[0]: True, + lights[1]: { + "state": "on", + "xy_color": [0.33, 0.66], + "brightness": 200, + }, + }, + }, + { + "name": "Switch on and off", + "entities": {switches[0]: True, switches[1]: False}, + }, + ] + }, + ) diff --git a/homeassistant/components/demo/air_quality.py b/homeassistant/components/demo/air_quality.py new file mode 100644 index 000000000..9fe0f675d --- /dev/null +++ b/homeassistant/components/demo/air_quality.py @@ -0,0 +1,55 @@ +"""Demo platform that offers fake air quality data.""" +from homeassistant.components.air_quality import AirQualityEntity + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the Air Quality.""" + async_add_entities( + [DemoAirQuality("Home", 14, 23, 100), DemoAirQuality("Office", 4, 16, None)] + ) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Demo config entry.""" + await async_setup_platform(hass, {}, async_add_entities) + + +class DemoAirQuality(AirQualityEntity): + """Representation of Air Quality data.""" + + def __init__(self, name, pm_2_5, pm_10, n2o): + """Initialize the Demo Air Quality.""" + self._name = name + self._pm_2_5 = pm_2_5 + self._pm_10 = pm_10 + self._n2o = n2o + + @property + def name(self): + """Return the name of the sensor.""" + return "{} {}".format("Demo Air Quality", self._name) + + @property + def should_poll(self): + """No polling needed for Demo Air Quality.""" + return False + + @property + def particulate_matter_2_5(self): + """Return the particulate matter 2.5 level.""" + return self._pm_2_5 + + @property + def particulate_matter_10(self): + """Return the particulate matter 10 level.""" + return self._pm_10 + + @property + def nitrogen_oxide(self): + """Return the nitrogen oxide (N2O) level.""" + return self._n2o + + @property + def attribution(self): + """Return the attribution.""" + return "Powered by Home Assistant" diff --git a/homeassistant/components/demo/alarm_control_panel.py b/homeassistant/components/demo/alarm_control_panel.py new file mode 100644 index 000000000..0323b68b1 --- /dev/null +++ b/homeassistant/components/demo/alarm_control_panel.py @@ -0,0 +1,65 @@ +"""Demo platform that has two fake alarm control panels.""" +import datetime + +from homeassistant.components.manual.alarm_control_panel import ManualAlarm +from homeassistant.const import ( + CONF_DELAY_TIME, + CONF_PENDING_TIME, + CONF_TRIGGER_TIME, + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_CUSTOM_BYPASS, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_DISARMED, + STATE_ALARM_TRIGGERED, +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the Demo alarm control panel platform.""" + async_add_entities( + [ + ManualAlarm( + hass, + "Alarm", + "1234", + None, + True, + False, + { + STATE_ALARM_ARMED_AWAY: { + CONF_DELAY_TIME: datetime.timedelta(seconds=0), + CONF_PENDING_TIME: datetime.timedelta(seconds=5), + CONF_TRIGGER_TIME: datetime.timedelta(seconds=10), + }, + STATE_ALARM_ARMED_HOME: { + CONF_DELAY_TIME: datetime.timedelta(seconds=0), + CONF_PENDING_TIME: datetime.timedelta(seconds=5), + CONF_TRIGGER_TIME: datetime.timedelta(seconds=10), + }, + STATE_ALARM_ARMED_NIGHT: { + CONF_DELAY_TIME: datetime.timedelta(seconds=0), + CONF_PENDING_TIME: datetime.timedelta(seconds=5), + CONF_TRIGGER_TIME: datetime.timedelta(seconds=10), + }, + STATE_ALARM_DISARMED: { + CONF_DELAY_TIME: datetime.timedelta(seconds=0), + CONF_TRIGGER_TIME: datetime.timedelta(seconds=10), + }, + STATE_ALARM_ARMED_CUSTOM_BYPASS: { + CONF_DELAY_TIME: datetime.timedelta(seconds=0), + CONF_PENDING_TIME: datetime.timedelta(seconds=5), + CONF_TRIGGER_TIME: datetime.timedelta(seconds=10), + }, + STATE_ALARM_TRIGGERED: { + CONF_PENDING_TIME: datetime.timedelta(seconds=5) + }, + }, + ) + ] + ) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Demo config entry.""" + await async_setup_platform(hass, {}, async_add_entities) diff --git a/homeassistant/components/demo/binary_sensor.py b/homeassistant/components/demo/binary_sensor.py new file mode 100644 index 000000000..0f6dfa9f3 --- /dev/null +++ b/homeassistant/components/demo/binary_sensor.py @@ -0,0 +1,66 @@ +"""Demo platform that has two fake binary sensors.""" +from homeassistant.components.binary_sensor import BinarySensorDevice + +from . import DOMAIN + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the Demo binary sensor platform.""" + async_add_entities( + [ + DemoBinarySensor("binary_1", "Basement Floor Wet", False, "moisture"), + DemoBinarySensor("binary_2", "Movement Backyard", True, "motion"), + ] + ) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Demo config entry.""" + await async_setup_platform(hass, {}, async_add_entities) + + +class DemoBinarySensor(BinarySensorDevice): + """representation of a Demo binary sensor.""" + + def __init__(self, unique_id, name, state, device_class): + """Initialize the demo sensor.""" + self._unique_id = unique_id + self._name = name + self._state = state + self._sensor_type = device_class + + @property + def device_info(self): + """Return device info.""" + return { + "identifiers": { + # Serial numbers are unique identifiers within a specific domain + (DOMAIN, self.unique_id) + }, + "name": self.name, + } + + @property + def unique_id(self): + """Return the unique id.""" + return self._unique_id + + @property + def device_class(self): + """Return the class of this sensor.""" + return self._sensor_type + + @property + def should_poll(self): + """No polling needed for a demo binary sensor.""" + return False + + @property + def name(self): + """Return the name of the binary sensor.""" + return self._name + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self._state diff --git a/homeassistant/components/demo/calendar.py b/homeassistant/components/demo/calendar.py new file mode 100644 index 000000000..42cb2b137 --- /dev/null +++ b/homeassistant/components/demo/calendar.py @@ -0,0 +1,88 @@ +"""Demo platform that has two fake binary sensors.""" +import copy + +from homeassistant.components.calendar import CalendarEventDevice, get_date +import homeassistant.util.dt as dt_util + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Demo Calendar platform.""" + calendar_data_future = DemoGoogleCalendarDataFuture() + calendar_data_current = DemoGoogleCalendarDataCurrent() + add_entities( + [ + DemoGoogleCalendar(hass, calendar_data_future, "Calendar 1"), + DemoGoogleCalendar(hass, calendar_data_current, "Calendar 2"), + ] + ) + + +class DemoGoogleCalendarData: + """Representation of a Demo Calendar element.""" + + event = None + + async def async_get_events(self, hass, start_date, end_date): + """Get all events in a specific time frame.""" + event = copy.copy(self.event) + event["title"] = event["summary"] + event["start"] = get_date(event["start"]).isoformat() + event["end"] = get_date(event["end"]).isoformat() + return [event] + + +class DemoGoogleCalendarDataFuture(DemoGoogleCalendarData): + """Representation of a Demo Calendar for a future event.""" + + def __init__(self): + """Set the event to a future event.""" + one_hour_from_now = dt_util.now() + dt_util.dt.timedelta(minutes=30) + self.event = { + "start": {"dateTime": one_hour_from_now.isoformat()}, + "end": { + "dateTime": ( + one_hour_from_now + dt_util.dt.timedelta(minutes=60) + ).isoformat() + }, + "summary": "Future Event", + } + + +class DemoGoogleCalendarDataCurrent(DemoGoogleCalendarData): + """Representation of a Demo Calendar for a current event.""" + + def __init__(self): + """Set the event data.""" + middle_of_event = dt_util.now() - dt_util.dt.timedelta(minutes=30) + self.event = { + "start": {"dateTime": middle_of_event.isoformat()}, + "end": { + "dateTime": ( + middle_of_event + dt_util.dt.timedelta(minutes=60) + ).isoformat() + }, + "summary": "Current Event", + } + + +class DemoGoogleCalendar(CalendarEventDevice): + """Representation of a Demo Calendar element.""" + + def __init__(self, hass, calendar_data, name): + """Initialize demo calendar.""" + self.data = calendar_data + self._name = name + + @property + def event(self): + """Return the next upcoming event.""" + return self.data.event + + @property + def name(self): + """Return the name of the entity.""" + return self._name + + async def async_get_events(self, hass, start_date, end_date): + """Return calendar events within a datetime range.""" + return await self.data.async_get_events(hass, start_date, end_date) diff --git a/homeassistant/components/demo/camera.py b/homeassistant/components/demo/camera.py new file mode 100644 index 000000000..f639dae97 --- /dev/null +++ b/homeassistant/components/demo/camera.py @@ -0,0 +1,88 @@ +"""Demo camera platform that has a fake camera.""" +import logging +import os + +from homeassistant.components.camera import SUPPORT_ON_OFF, Camera + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the Demo camera platform.""" + async_add_entities([DemoCamera("Demo camera")]) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Demo config entry.""" + await async_setup_platform(hass, {}, async_add_entities) + + +class DemoCamera(Camera): + """The representation of a Demo camera.""" + + def __init__(self, name): + """Initialize demo camera component.""" + super().__init__() + self._name = name + self._motion_status = False + self.is_streaming = True + self._images_index = 0 + + def camera_image(self): + """Return a faked still image response.""" + self._images_index = (self._images_index + 1) % 4 + + image_path = os.path.join( + os.path.dirname(__file__), f"demo_{self._images_index}.jpg" + ) + _LOGGER.debug("Loading camera_image: %s", image_path) + with open(image_path, "rb") as file: + return file.read() + + @property + def name(self): + """Return the name of this camera.""" + return self._name + + @property + def should_poll(self): + """Demo camera doesn't need poll. + + Need explicitly call schedule_update_ha_state() after state changed. + """ + return False + + @property + def supported_features(self): + """Camera support turn on/off features.""" + return SUPPORT_ON_OFF + + @property + def is_on(self): + """Whether camera is on (streaming).""" + return self.is_streaming + + @property + def motion_detection_enabled(self): + """Camera Motion Detection Status.""" + return self._motion_status + + def enable_motion_detection(self): + """Enable the Motion detection in base station (Arm).""" + self._motion_status = True + self.schedule_update_ha_state() + + def disable_motion_detection(self): + """Disable the motion detection in base station (Disarm).""" + self._motion_status = False + self.schedule_update_ha_state() + + def turn_off(self): + """Turn off camera.""" + self.is_streaming = False + self.schedule_update_ha_state() + + def turn_on(self): + """Turn on camera.""" + self.is_streaming = True + self.schedule_update_ha_state() diff --git a/homeassistant/components/demo/climate.py b/homeassistant/components/demo/climate.py new file mode 100644 index 000000000..0edcf618b --- /dev/null +++ b/homeassistant/components/demo/climate.py @@ -0,0 +1,322 @@ +"""Demo platform that offers a fake climate device.""" +import logging + +from homeassistant.components.climate import ClimateDevice +from homeassistant.components.climate.const import ( + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + CURRENT_HVAC_COOL, + CURRENT_HVAC_HEAT, + HVAC_MODE_AUTO, + HVAC_MODE_COOL, + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_OFF, + HVAC_MODES, + SUPPORT_AUX_HEAT, + SUPPORT_FAN_MODE, + SUPPORT_PRESET_MODE, + SUPPORT_SWING_MODE, + SUPPORT_TARGET_HUMIDITY, + SUPPORT_TARGET_TEMPERATURE, + SUPPORT_TARGET_TEMPERATURE_RANGE, +) +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT + +from . import DOMAIN + +SUPPORT_FLAGS = 0 +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the Demo climate devices.""" + async_add_entities( + [ + DemoClimate( + unique_id="climate_1", + name="HeatPump", + target_temperature=68, + unit_of_measurement=TEMP_FAHRENHEIT, + preset=None, + current_temperature=77, + fan_mode=None, + target_humidity=None, + current_humidity=None, + swing_mode=None, + hvac_mode=HVAC_MODE_HEAT, + hvac_action=CURRENT_HVAC_HEAT, + aux=None, + target_temp_high=None, + target_temp_low=None, + hvac_modes=[HVAC_MODE_HEAT, HVAC_MODE_OFF], + ), + DemoClimate( + unique_id="climate_2", + name="Hvac", + target_temperature=21, + unit_of_measurement=TEMP_CELSIUS, + preset=None, + current_temperature=22, + fan_mode="On High", + target_humidity=67, + current_humidity=54, + swing_mode="Off", + hvac_mode=HVAC_MODE_COOL, + hvac_action=CURRENT_HVAC_COOL, + aux=False, + target_temp_high=None, + target_temp_low=None, + hvac_modes=[mode for mode in HVAC_MODES if mode != HVAC_MODE_HEAT_COOL], + ), + DemoClimate( + unique_id="climate_3", + name="Ecobee", + target_temperature=None, + unit_of_measurement=TEMP_CELSIUS, + preset="home", + preset_modes=["home", "eco"], + current_temperature=23, + fan_mode="Auto Low", + target_humidity=None, + current_humidity=None, + swing_mode="Auto", + hvac_mode=HVAC_MODE_HEAT_COOL, + hvac_action=None, + aux=None, + target_temp_high=24, + target_temp_low=21, + hvac_modes=[HVAC_MODE_HEAT_COOL, HVAC_MODE_COOL, HVAC_MODE_HEAT], + ), + ] + ) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Demo climate devices config entry.""" + await async_setup_platform(hass, {}, async_add_entities) + + +class DemoClimate(ClimateDevice): + """Representation of a demo climate device.""" + + def __init__( + self, + unique_id, + name, + target_temperature, + unit_of_measurement, + preset, + current_temperature, + fan_mode, + target_humidity, + current_humidity, + swing_mode, + hvac_mode, + hvac_action, + aux, + target_temp_high, + target_temp_low, + hvac_modes, + preset_modes=None, + ): + """Initialize the climate device.""" + self._unique_id = unique_id + self._name = name + self._support_flags = SUPPORT_FLAGS + if target_temperature is not None: + self._support_flags = self._support_flags | SUPPORT_TARGET_TEMPERATURE + if preset is not None: + self._support_flags = self._support_flags | SUPPORT_PRESET_MODE + if 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 swing_mode is not None: + self._support_flags = self._support_flags | SUPPORT_SWING_MODE + if hvac_action is not None: + self._support_flags = self._support_flags + if aux is not None: + self._support_flags = self._support_flags | SUPPORT_AUX_HEAT + if HVAC_MODE_HEAT_COOL in hvac_modes or HVAC_MODE_AUTO in hvac_modes: + self._support_flags = self._support_flags | SUPPORT_TARGET_TEMPERATURE_RANGE + self._target_temperature = target_temperature + self._target_humidity = target_humidity + self._unit_of_measurement = unit_of_measurement + self._preset = preset + self._preset_modes = preset_modes + self._current_temperature = current_temperature + self._current_humidity = current_humidity + self._current_fan_mode = fan_mode + self._hvac_action = hvac_action + self._hvac_mode = hvac_mode + self._aux = aux + self._current_swing_mode = swing_mode + self._fan_modes = ["On Low", "On High", "Auto Low", "Auto High", "Off"] + self._hvac_modes = hvac_modes + self._swing_modes = ["Auto", "1", "2", "3", "Off"] + self._target_temperature_high = target_temp_high + self._target_temperature_low = target_temp_low + + @property + def device_info(self): + """Return device info.""" + return { + "identifiers": { + # Serial numbers are unique identifiers within a specific domain + (DOMAIN, self.unique_id) + }, + "name": self.name, + } + + @property + def unique_id(self): + """Return the unique id.""" + return self._unique_id + + @property + def supported_features(self): + """Return the list of supported features.""" + return self._support_flags + + @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 target_temperature_high(self): + """Return the highbound target temperature we try to reach.""" + return self._target_temperature_high + + @property + def target_temperature_low(self): + """Return the lowbound target temperature we try to reach.""" + return self._target_temperature_low + + @property + def current_humidity(self): + """Return the current humidity.""" + return self._current_humidity + + @property + def target_humidity(self): + """Return the humidity we try to reach.""" + return self._target_humidity + + @property + def hvac_action(self): + """Return current operation ie. heat, cool, idle.""" + return self._hvac_action + + @property + def hvac_mode(self): + """Return hvac target hvac state.""" + return self._hvac_mode + + @property + def hvac_modes(self): + """Return the list of available operation modes.""" + return self._hvac_modes + + @property + def preset_mode(self): + """Return preset mode.""" + return self._preset + + @property + def preset_modes(self): + """Return preset modes.""" + return self._preset_modes + + @property + def is_aux_heat(self): + """Return true if aux heat is on.""" + return self._aux + + @property + def fan_mode(self): + """Return the fan setting.""" + return self._current_fan_mode + + @property + def fan_modes(self): + """Return the list of available fan modes.""" + return self._fan_modes + + @property + def swing_mode(self): + """Return the swing setting.""" + return self._current_swing_mode + + @property + def swing_modes(self): + """List of available swing modes.""" + return self._swing_modes + + async def async_set_temperature(self, **kwargs): + """Set new target temperatures.""" + if kwargs.get(ATTR_TEMPERATURE) is not None: + self._target_temperature = kwargs.get(ATTR_TEMPERATURE) + if ( + kwargs.get(ATTR_TARGET_TEMP_HIGH) is not None + and kwargs.get(ATTR_TARGET_TEMP_LOW) is not None + ): + self._target_temperature_high = kwargs.get(ATTR_TARGET_TEMP_HIGH) + self._target_temperature_low = kwargs.get(ATTR_TARGET_TEMP_LOW) + self.async_write_ha_state() + + async def async_set_humidity(self, humidity): + """Set new humidity level.""" + self._target_humidity = humidity + self.async_write_ha_state() + + async def async_set_swing_mode(self, swing_mode): + """Set new swing mode.""" + self._current_swing_mode = swing_mode + self.async_write_ha_state() + + async def async_set_fan_mode(self, fan_mode): + """Set new fan mode.""" + self._current_fan_mode = fan_mode + self.async_write_ha_state() + + async def async_set_hvac_mode(self, hvac_mode): + """Set new operation mode.""" + self._hvac_mode = hvac_mode + self.async_write_ha_state() + + async def async_set_preset_mode(self, preset_mode): + """Update preset_mode on.""" + self._preset = preset_mode + self.async_write_ha_state() + + def turn_aux_heat_on(self): + """Turn auxiliary heater on.""" + self._aux = True + self.async_write_ha_state() + + def turn_aux_heat_off(self): + """Turn auxiliary heater off.""" + self._aux = False + self.async_write_ha_state() diff --git a/homeassistant/components/demo/config_flow.py b/homeassistant/components/demo/config_flow.py new file mode 100644 index 000000000..e6b275920 --- /dev/null +++ b/homeassistant/components/demo/config_flow.py @@ -0,0 +1,16 @@ +"""Config flow to configure demo component.""" + +from homeassistant import config_entries + +# pylint: disable=unused-import +from . import DOMAIN + + +class DemoConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Demo configuration flow.""" + + VERSION = 1 + + async def async_step_import(self, import_info): + """Set the config entry up from yaml.""" + return self.async_create_entry(title="Demo", data={}) diff --git a/homeassistant/components/demo/const.py b/homeassistant/components/demo/const.py new file mode 100644 index 000000000..e11b0b073 --- /dev/null +++ b/homeassistant/components/demo/const.py @@ -0,0 +1,3 @@ +"""Constants for the Demo component.""" +DOMAIN = "demo" +SERVICE_RANDOMIZE_DEVICE_TRACKER_DATA = "randomize_device_tracker_data" diff --git a/homeassistant/components/demo/cover.py b/homeassistant/components/demo/cover.py new file mode 100644 index 000000000..20e3a52aa --- /dev/null +++ b/homeassistant/components/demo/cover.py @@ -0,0 +1,257 @@ +"""Demo platform for the cover component.""" +from homeassistant.components.cover import ( + ATTR_POSITION, + ATTR_TILT_POSITION, + SUPPORT_CLOSE, + SUPPORT_OPEN, + CoverDevice, +) +from homeassistant.helpers.event import track_utc_time_change + +from . import DOMAIN + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the Demo covers.""" + async_add_entities( + [ + DemoCover(hass, "cover_1", "Kitchen Window"), + DemoCover(hass, "cover_2", "Hall Window", 10), + DemoCover(hass, "cover_3", "Living Room Window", 70, 50), + DemoCover( + hass, + "cover_4", + "Garage Door", + device_class="garage", + supported_features=(SUPPORT_OPEN | SUPPORT_CLOSE), + ), + ] + ) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Demo config entry.""" + await async_setup_platform(hass, {}, async_add_entities) + + +class DemoCover(CoverDevice): + """Representation of a demo cover.""" + + def __init__( + self, + hass, + unique_id, + name, + position=None, + tilt_position=None, + device_class=None, + supported_features=None, + ): + """Initialize the cover.""" + self.hass = hass + self._unique_id = unique_id + self._name = name + self._position = position + self._device_class = device_class + self._supported_features = supported_features + self._set_position = None + self._set_tilt_position = None + self._tilt_position = tilt_position + self._requested_closing = True + self._requested_closing_tilt = True + self._unsub_listener_cover = None + self._unsub_listener_cover_tilt = None + self._is_opening = False + self._is_closing = False + if position is None: + self._closed = True + else: + self._closed = self.current_cover_position <= 0 + + @property + def device_info(self): + """Return device info.""" + return { + "identifiers": { + # Serial numbers are unique identifiers within a specific domain + (DOMAIN, self.unique_id) + }, + "name": self.name, + } + + @property + def unique_id(self): + """Return unique ID for cover.""" + return self._unique_id + + @property + def name(self): + """Return the name of the cover.""" + return self._name + + @property + def should_poll(self): + """No polling needed for a demo cover.""" + return False + + @property + def current_cover_position(self): + """Return the current position of the cover.""" + return self._position + + @property + def current_cover_tilt_position(self): + """Return the current tilt position of the cover.""" + return self._tilt_position + + @property + def is_closed(self): + """Return if the cover is closed.""" + return self._closed + + @property + def is_closing(self): + """Return if the cover is closing.""" + return self._is_closing + + @property + def is_opening(self): + """Return if the cover is opening.""" + return self._is_opening + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return self._device_class + + @property + def supported_features(self): + """Flag supported features.""" + if self._supported_features is not None: + return self._supported_features + return super().supported_features + + def close_cover(self, **kwargs): + """Close the cover.""" + if self._position == 0: + return + if self._position is None: + self._closed = True + self.schedule_update_ha_state() + return + + self._is_closing = True + self._listen_cover() + self._requested_closing = True + self.schedule_update_ha_state() + + def close_cover_tilt(self, **kwargs): + """Close the cover tilt.""" + if self._tilt_position in (0, None): + return + + self._listen_cover_tilt() + self._requested_closing_tilt = True + + def open_cover(self, **kwargs): + """Open the cover.""" + if self._position == 100: + return + if self._position is None: + self._closed = False + self.schedule_update_ha_state() + return + + self._is_opening = True + self._listen_cover() + self._requested_closing = False + self.schedule_update_ha_state() + + def open_cover_tilt(self, **kwargs): + """Open the cover tilt.""" + if self._tilt_position in (100, None): + return + + self._listen_cover_tilt() + self._requested_closing_tilt = False + + 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 + + self._listen_cover() + self._requested_closing = position < self._position + + 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 + + self._listen_cover_tilt() + self._requested_closing_tilt = tilt_position < self._tilt_position + + def stop_cover(self, **kwargs): + """Stop the cover.""" + self._is_closing = False + self._is_opening = False + if self._position is None: + return + if self._unsub_listener_cover is not None: + self._unsub_listener_cover() + self._unsub_listener_cover = None + self._set_position = None + + def stop_cover_tilt(self, **kwargs): + """Stop the cover tilt.""" + if self._tilt_position is None: + return + + if self._unsub_listener_cover_tilt is not None: + self._unsub_listener_cover_tilt() + self._unsub_listener_cover_tilt = None + self._set_tilt_position = None + + def _listen_cover(self): + """Listen for changes in cover.""" + if self._unsub_listener_cover is None: + self._unsub_listener_cover = track_utc_time_change( + self.hass, self._time_changed_cover + ) + + def _time_changed_cover(self, now): + """Track time changes.""" + if self._requested_closing: + self._position -= 10 + else: + self._position += 10 + + if self._position in (100, 0, self._set_position): + self.stop_cover() + + self._closed = self.current_cover_position <= 0 + + self.schedule_update_ha_state() + + def _listen_cover_tilt(self): + """Listen for changes in cover tilt.""" + if self._unsub_listener_cover_tilt is None: + self._unsub_listener_cover_tilt = track_utc_time_change( + self.hass, self._time_changed_cover_tilt + ) + + def _time_changed_cover_tilt(self, now): + """Track time changes.""" + if self._requested_closing_tilt: + self._tilt_position -= 10 + else: + self._tilt_position += 10 + + if self._tilt_position in (100, 0, self._set_tilt_position): + self.stop_cover_tilt() + + self.schedule_update_ha_state() diff --git a/homeassistant/components/camera/demo_0.jpg b/homeassistant/components/demo/demo_0.jpg similarity index 100% rename from homeassistant/components/camera/demo_0.jpg rename to homeassistant/components/demo/demo_0.jpg diff --git a/homeassistant/components/camera/demo_1.jpg b/homeassistant/components/demo/demo_1.jpg similarity index 100% rename from homeassistant/components/camera/demo_1.jpg rename to homeassistant/components/demo/demo_1.jpg diff --git a/homeassistant/components/camera/demo_2.jpg b/homeassistant/components/demo/demo_2.jpg similarity index 100% rename from homeassistant/components/camera/demo_2.jpg rename to homeassistant/components/demo/demo_2.jpg diff --git a/homeassistant/components/camera/demo_3.jpg b/homeassistant/components/demo/demo_3.jpg similarity index 100% rename from homeassistant/components/camera/demo_3.jpg rename to homeassistant/components/demo/demo_3.jpg diff --git a/homeassistant/components/demo/device_tracker.py b/homeassistant/components/demo/device_tracker.py new file mode 100644 index 000000000..028641115 --- /dev/null +++ b/homeassistant/components/demo/device_tracker.py @@ -0,0 +1,41 @@ +"""Demo platform for the Device tracker component.""" +import random + +from .const import DOMAIN, SERVICE_RANDOMIZE_DEVICE_TRACKER_DATA + + +def setup_scanner(hass, config, see, discovery_info=None): + """Set up the demo tracker.""" + + def offset(): + """Return random offset.""" + return (random.randrange(500, 2000)) / 2e5 * random.choice((-1, 1)) + + def random_see(dev_id, name): + """Randomize a sighting.""" + see( + dev_id=dev_id, + host_name=name, + gps=(hass.config.latitude + offset(), hass.config.longitude + offset()), + gps_accuracy=random.randrange(50, 150), + battery=random.randrange(10, 90), + ) + + def observe(call=None): + """Observe three entities.""" + random_see("demo_paulus", "Paulus") + random_see("demo_anne_therese", "Anne Therese") + + observe() + + see( + dev_id="demo_home_boy", + host_name="Home Boy", + gps=[hass.config.latitude - 0.00002, hass.config.longitude + 0.00002], + gps_accuracy=20, + battery=53, + ) + + hass.services.register(DOMAIN, SERVICE_RANDOMIZE_DEVICE_TRACKER_DATA, observe) + + return True diff --git a/homeassistant/components/demo/fan.py b/homeassistant/components/demo/fan.py new file mode 100644 index 000000000..966ba51ca --- /dev/null +++ b/homeassistant/components/demo/fan.py @@ -0,0 +1,103 @@ +"""Demo fan platform that has a fake fan.""" +from homeassistant.components.fan import ( + SPEED_HIGH, + SPEED_LOW, + SPEED_MEDIUM, + SUPPORT_DIRECTION, + SUPPORT_OSCILLATE, + SUPPORT_SET_SPEED, + FanEntity, +) +from homeassistant.const import STATE_OFF + +FULL_SUPPORT = SUPPORT_SET_SPEED | SUPPORT_OSCILLATE | SUPPORT_DIRECTION +LIMITED_SUPPORT = SUPPORT_SET_SPEED + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the demo fan platform.""" + async_add_entities( + [ + DemoFan(hass, "Living Room Fan", FULL_SUPPORT), + DemoFan(hass, "Ceiling Fan", LIMITED_SUPPORT), + ] + ) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Demo config entry.""" + await async_setup_platform(hass, {}, async_add_entities) + + +class DemoFan(FanEntity): + """A demonstration fan component.""" + + def __init__(self, hass, name: str, supported_features: int) -> None: + """Initialize the entity.""" + self.hass = hass + self._supported_features = supported_features + self._speed = STATE_OFF + self.oscillating = None + self._direction = None + self._name = name + + if supported_features & SUPPORT_OSCILLATE: + self.oscillating = False + if supported_features & SUPPORT_DIRECTION: + self._direction = "forward" + + @property + def name(self) -> str: + """Get entity name.""" + return self._name + + @property + def should_poll(self): + """No polling needed for a demo fan.""" + return False + + @property + def speed(self) -> str: + """Return the current speed.""" + return self._speed + + @property + 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, **kwargs) -> None: + """Turn on the entity.""" + if speed is None: + speed = SPEED_MEDIUM + self.set_speed(speed) + + def turn_off(self, **kwargs) -> None: + """Turn off the entity.""" + self.oscillate(False) + self.set_speed(STATE_OFF) + + def set_speed(self, speed: str) -> None: + """Set the speed of the fan.""" + self._speed = speed + self.schedule_update_ha_state() + + def set_direction(self, direction: str) -> None: + """Set the direction of the fan.""" + self._direction = direction + self.schedule_update_ha_state() + + def oscillate(self, oscillating: bool) -> None: + """Set oscillation.""" + self.oscillating = oscillating + self.schedule_update_ha_state() + + @property + def current_direction(self) -> str: + """Fan direction.""" + return self._direction + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return self._supported_features diff --git a/homeassistant/components/demo/geo_location.py b/homeassistant/components/demo/geo_location.py new file mode 100644 index 000000000..6fc8e9c2e --- /dev/null +++ b/homeassistant/components/demo/geo_location.py @@ -0,0 +1,147 @@ +"""Demo platform for the geolocation component.""" +from datetime import timedelta +import logging +from math import cos, pi, radians, sin +import random +from typing import Optional + +from homeassistant.components.geo_location import GeolocationEvent +from homeassistant.helpers.event import track_time_interval + +_LOGGER = logging.getLogger(__name__) + +AVG_KM_PER_DEGREE = 111.0 +DEFAULT_UNIT_OF_MEASUREMENT = "km" +DEFAULT_UPDATE_INTERVAL = timedelta(minutes=1) +MAX_RADIUS_IN_KM = 50 +NUMBER_OF_DEMO_DEVICES = 5 + +EVENT_NAMES = [ + "Bushfire", + "Hazard Reduction", + "Grass Fire", + "Burn off", + "Structure Fire", + "Fire Alarm", + "Thunderstorm", + "Tornado", + "Cyclone", + "Waterspout", + "Dust Storm", + "Blizzard", + "Ice Storm", + "Earthquake", + "Tsunami", +] + +SOURCE = "demo" + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Demo geolocations.""" + DemoManager(hass, add_entities) + + +class DemoManager: + """Device manager for demo geolocation events.""" + + def __init__(self, hass, add_entities): + """Initialise the demo geolocation event manager.""" + self._hass = hass + self._add_entities = add_entities + self._managed_devices = [] + self._update(count=NUMBER_OF_DEMO_DEVICES) + self._init_regular_updates() + + def _generate_random_event(self): + """Generate a random event in vicinity of this HA instance.""" + home_latitude = self._hass.config.latitude + home_longitude = self._hass.config.longitude + + # Approx. 111km per degree (north-south). + radius_in_degrees = random.random() * MAX_RADIUS_IN_KM / AVG_KM_PER_DEGREE + radius_in_km = radius_in_degrees * AVG_KM_PER_DEGREE + angle = random.random() * 2 * pi + # Compute coordinates based on radius and angle. Adjust longitude value + # based on HA's latitude. + latitude = home_latitude + radius_in_degrees * sin(angle) + longitude = home_longitude + radius_in_degrees * cos(angle) / cos( + radians(home_latitude) + ) + + event_name = random.choice(EVENT_NAMES) + return DemoGeolocationEvent( + event_name, radius_in_km, latitude, longitude, DEFAULT_UNIT_OF_MEASUREMENT + ) + + def _init_regular_updates(self): + """Schedule regular updates based on configured time interval.""" + track_time_interval( + self._hass, lambda now: self._update(), DEFAULT_UPDATE_INTERVAL + ) + + def _update(self, count=1): + """Remove events and add new random events.""" + # Remove devices. + for _ in range(1, count + 1): + if self._managed_devices: + device = random.choice(self._managed_devices) + if device: + _LOGGER.debug("Removing %s", device) + self._managed_devices.remove(device) + self._hass.add_job(device.async_remove()) + # Generate new devices from events. + new_devices = [] + for _ in range(1, count + 1): + new_device = self._generate_random_event() + _LOGGER.debug("Adding %s", new_device) + new_devices.append(new_device) + self._managed_devices.append(new_device) + self._add_entities(new_devices) + + +class DemoGeolocationEvent(GeolocationEvent): + """This represents a demo geolocation event.""" + + def __init__(self, name, distance, latitude, longitude, unit_of_measurement): + """Initialize entity with data provided.""" + self._name = name + self._distance = distance + self._latitude = latitude + self._longitude = longitude + self._unit_of_measurement = unit_of_measurement + + @property + def source(self) -> str: + """Return source value of this external event.""" + return SOURCE + + @property + def name(self) -> Optional[str]: + """Return the name of the event.""" + return self._name + + @property + def should_poll(self): + """No polling needed for a demo geolocation event.""" + return False + + @property + def distance(self) -> Optional[float]: + """Return distance value of this external event.""" + return self._distance + + @property + def latitude(self) -> Optional[float]: + """Return latitude value of this external event.""" + return self._latitude + + @property + def longitude(self) -> Optional[float]: + """Return longitude value of this external event.""" + return self._longitude + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit_of_measurement diff --git a/homeassistant/components/demo/image_processing.py b/homeassistant/components/demo/image_processing.py new file mode 100644 index 000000000..918360950 --- /dev/null +++ b/homeassistant/components/demo/image_processing.py @@ -0,0 +1,99 @@ +"""Support for the demo image processing.""" +from homeassistant.components.image_processing import ( + ATTR_AGE, + ATTR_CONFIDENCE, + ATTR_GENDER, + ATTR_NAME, + ImageProcessingFaceEntity, +) +from homeassistant.components.openalpr_local.image_processing import ( + ImageProcessingAlprEntity, +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the demo image processing platform.""" + add_entities( + [ + DemoImageProcessingAlpr("camera.demo_camera", "Demo Alpr"), + DemoImageProcessingFace("camera.demo_camera", "Demo Face"), + ] + ) + + +class DemoImageProcessingAlpr(ImageProcessingAlprEntity): + """Demo ALPR image processing entity.""" + + def __init__(self, camera_entity, name): + """Initialize demo ALPR image processing entity.""" + super().__init__() + + self._name = name + self._camera = camera_entity + + @property + def camera_entity(self): + """Return camera entity id from process pictures.""" + return self._camera + + @property + def confidence(self): + """Return minimum confidence for send events.""" + return 80 + + @property + def name(self): + """Return the name of the entity.""" + return self._name + + def process_image(self, image): + """Process image.""" + demo_data = { + "AC3829": 98.3, + "BE392034": 95.5, + "CD02394": 93.4, + "DF923043": 90.8, + } + + self.process_plates(demo_data, 1) + + +class DemoImageProcessingFace(ImageProcessingFaceEntity): + """Demo face identify image processing entity.""" + + def __init__(self, camera_entity, name): + """Initialize demo face image processing entity.""" + super().__init__() + + self._name = name + self._camera = camera_entity + + @property + def camera_entity(self): + """Return camera entity id from process pictures.""" + return self._camera + + @property + def confidence(self): + """Return minimum confidence for send events.""" + return 80 + + @property + def name(self): + """Return the name of the entity.""" + return self._name + + def process_image(self, image): + """Process image.""" + demo_data = [ + { + ATTR_CONFIDENCE: 98.34, + ATTR_NAME: "Hans", + ATTR_AGE: 16.0, + ATTR_GENDER: "male", + }, + {ATTR_NAME: "Helena", ATTR_AGE: 28.0, ATTR_GENDER: "female"}, + {ATTR_CONFIDENCE: 62.53, ATTR_NAME: "Luna"}, + ] + + self.process_faces(demo_data, 4) diff --git a/homeassistant/components/demo/light.py b/homeassistant/components/demo/light.py new file mode 100644 index 000000000..a2c06b729 --- /dev/null +++ b/homeassistant/components/demo/light.py @@ -0,0 +1,192 @@ +"""Demo light platform that implements lights.""" +import random + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP, + ATTR_EFFECT, + ATTR_HS_COLOR, + ATTR_WHITE_VALUE, + SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, + SUPPORT_COLOR_TEMP, + SUPPORT_EFFECT, + SUPPORT_WHITE_VALUE, + Light, +) + +from . import DOMAIN + +LIGHT_COLORS = [(56, 86), (345, 75)] + +LIGHT_EFFECT_LIST = ["rainbow", "none"] + +LIGHT_TEMPS = [240, 380] + +SUPPORT_DEMO = ( + SUPPORT_BRIGHTNESS + | SUPPORT_COLOR_TEMP + | SUPPORT_EFFECT + | SUPPORT_COLOR + | SUPPORT_WHITE_VALUE +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the demo light platform.""" + async_add_entities( + [ + DemoLight( + "light_1", + "Bed Light", + False, + True, + effect_list=LIGHT_EFFECT_LIST, + effect=LIGHT_EFFECT_LIST[0], + ), + DemoLight( + "light_2", "Ceiling Lights", True, True, LIGHT_COLORS[0], LIGHT_TEMPS[1] + ), + DemoLight( + "light_3", "Kitchen Lights", True, True, LIGHT_COLORS[1], LIGHT_TEMPS[0] + ), + ] + ) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Demo config entry.""" + await async_setup_platform(hass, {}, async_add_entities) + + +class DemoLight(Light): + """Representation of a demo light.""" + + 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._hs_color = hs_color + self._ct = ct or random.choice(LIGHT_TEMPS) + self._brightness = brightness + self._white = white + self._effect_list = effect_list + self._effect = effect + self._available = True + + @property + def device_info(self): + """Return device info.""" + return { + "identifiers": { + # Serial numbers are unique identifiers within a specific domain + (DOMAIN, self.unique_id) + }, + "name": self.name, + } + + @property + def should_poll(self) -> bool: + """No polling needed for a demo light.""" + return False + + @property + 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 self._available + + @property + def brightness(self) -> int: + """Return the brightness of this light between 0..255.""" + return self._brightness + + @property + def hs_color(self) -> tuple: + """Return the hs color value.""" + return self._hs_color + + @property + def color_temp(self) -> int: + """Return the CT color temperature.""" + return self._ct + + @property + def white_value(self) -> int: + """Return the white value of this light between 0..255.""" + return self._white + + @property + def effect_list(self) -> list: + """Return the list of supported effects.""" + return self._effect_list + + @property + def effect(self) -> str: + """Return the current effect.""" + return self._effect + + @property + def is_on(self) -> bool: + """Return true if light is on.""" + return self._state + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return SUPPORT_DEMO + + def turn_on(self, **kwargs) -> None: + """Turn the light on.""" + self._state = True + + 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] + + if ATTR_BRIGHTNESS in kwargs: + self._brightness = kwargs[ATTR_BRIGHTNESS] + + if ATTR_WHITE_VALUE in kwargs: + self._white = kwargs[ATTR_WHITE_VALUE] + + if ATTR_EFFECT in kwargs: + self._effect = kwargs[ATTR_EFFECT] + + # As we have disabled polling, we need to inform + # Home Assistant about updates in our state ourselves. + self.schedule_update_ha_state() + + def turn_off(self, **kwargs) -> None: + """Turn the light off.""" + self._state = False + + # As we have disabled polling, we need to inform + # Home Assistant about updates in our state ourselves. + self.schedule_update_ha_state() diff --git a/homeassistant/components/demo/lock.py b/homeassistant/components/demo/lock.py new file mode 100644 index 000000000..5074741d8 --- /dev/null +++ b/homeassistant/components/demo/lock.py @@ -0,0 +1,65 @@ +"""Demo lock platform that has two fake locks.""" +from homeassistant.components.lock import SUPPORT_OPEN, LockDevice +from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the Demo lock platform.""" + async_add_entities( + [ + DemoLock("Front Door", STATE_LOCKED), + DemoLock("Kitchen Door", STATE_UNLOCKED), + DemoLock("Openable Lock", STATE_LOCKED, True), + ] + ) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Demo config entry.""" + await async_setup_platform(hass, {}, async_add_entities) + + +class DemoLock(LockDevice): + """Representation of a Demo lock.""" + + def __init__(self, name, state, openable=False): + """Initialize the lock.""" + self._name = name + self._state = state + self._openable = openable + + @property + def should_poll(self): + """No polling needed for a demo lock.""" + return False + + @property + def name(self): + """Return the name of the lock if any.""" + return self._name + + @property + def is_locked(self): + """Return true if lock is locked.""" + return self._state == STATE_LOCKED + + def lock(self, **kwargs): + """Lock the device.""" + self._state = STATE_LOCKED + self.schedule_update_ha_state() + + 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/demo/mailbox.py b/homeassistant/components/demo/mailbox.py new file mode 100644 index 000000000..77030623c --- /dev/null +++ b/homeassistant/components/demo/mailbox.py @@ -0,0 +1,80 @@ +"""Support for a demo mailbox.""" +from hashlib import sha1 +import logging +import os + +from homeassistant.components.mailbox import CONTENT_TYPE_MPEG, Mailbox, StreamError +from homeassistant.util import dt + +_LOGGER = logging.getLogger(__name__) + +MAILBOX_NAME = "DemoMailbox" + + +async def async_get_handler(hass, config, discovery_info=None): + """Set up the Demo mailbox.""" + return DemoMailbox(hass, MAILBOX_NAME) + + +class DemoMailbox(Mailbox): + """Demo Mailbox.""" + + def __init__(self, hass, name): + """Initialize Demo mailbox.""" + super().__init__(hass, name) + self._messages = {} + txt = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. " + for idx in range(0, 10): + msgtime = int(dt.as_timestamp(dt.utcnow()) - 3600 * 24 * (10 - idx)) + msgtxt = "Message {}. {}".format(idx + 1, txt * (1 + idx * (idx % 2))) + msgsha = sha1(msgtxt.encode("utf-8")).hexdigest() + msg = { + "info": { + "origtime": msgtime, + "callerid": "John Doe <212-555-1212>", + "duration": "10", + }, + "text": msgtxt, + "sha": msgsha, + } + self._messages[msgsha] = msg + + @property + def media_type(self): + """Return the supported media type.""" + return CONTENT_TYPE_MPEG + + @property + def can_delete(self): + """Return if messages can be deleted.""" + return True + + @property + def has_media(self): + """Return if messages have attached media files.""" + return True + + async def async_get_media(self, msgid): + """Return the media blob for the msgid.""" + if msgid not in self._messages: + raise StreamError("Message not found") + + audio_path = os.path.join(os.path.dirname(__file__), "tts.mp3") + with open(audio_path, "rb") as file: + return file.read() + + async def async_get_messages(self): + """Return a list of the current messages.""" + return sorted( + self._messages.values(), + key=lambda item: item["info"]["origtime"], + reverse=True, + ) + + def async_delete(self, msgid): + """Delete the specified messages.""" + if msgid in self._messages: + _LOGGER.info("Deleting: %s", msgid) + del self._messages[msgid] + self.async_update() + return True diff --git a/homeassistant/components/demo/manifest.json b/homeassistant/components/demo/manifest.json new file mode 100644 index 000000000..a4802c3b6 --- /dev/null +++ b/homeassistant/components/demo/manifest.json @@ -0,0 +1,15 @@ +{ + "domain": "demo", + "name": "Demo", + "documentation": "https://www.home-assistant.io/integrations/demo", + "requirements": [], + "dependencies": [ + "conversation", + "zone", + "group", + "configurator" + ], + "codeowners": [ + "@home-assistant/core" + ] +} diff --git a/homeassistant/components/demo/media_player.py b/homeassistant/components/demo/media_player.py new file mode 100644 index 000000000..9d7c3892a --- /dev/null +++ b/homeassistant/components/demo/media_player.py @@ -0,0 +1,472 @@ +"""Demo implementation of the media player.""" +from homeassistant.components.media_player import MediaPlayerDevice +from homeassistant.components.media_player.const import ( + MEDIA_TYPE_MOVIE, + MEDIA_TYPE_MUSIC, + MEDIA_TYPE_TVSHOW, + SUPPORT_CLEAR_PLAYLIST, + SUPPORT_NEXT_TRACK, + SUPPORT_PAUSE, + SUPPORT_PLAY, + SUPPORT_PLAY_MEDIA, + SUPPORT_PREVIOUS_TRACK, + SUPPORT_SEEK, + SUPPORT_SELECT_SOUND_MODE, + SUPPORT_SELECT_SOURCE, + SUPPORT_SHUFFLE_SET, + SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, + SUPPORT_VOLUME_MUTE, + SUPPORT_VOLUME_SET, + SUPPORT_VOLUME_STEP, +) +from homeassistant.const import STATE_OFF, STATE_PAUSED, STATE_PLAYING +import homeassistant.util.dt as dt_util + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the media player demo platform.""" + async_add_entities( + [ + DemoYoutubePlayer( + "Living Room", + "eyU3bRy2x44", + "♥♥ The Best Fireplace Video (3 hours)", + 300, + ), + DemoYoutubePlayer( + "Bedroom", "kxopViU98Xo", "Epic sax guy 10 hours", 360000 + ), + DemoMusicPlayer(), + DemoTVShowPlayer(), + ] + ) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Demo config entry.""" + await async_setup_platform(hass, {}, async_add_entities) + + +YOUTUBE_COVER_URL_FORMAT = "https://img.youtube.com/vi/{}/hqdefault.jpg" +SOUND_MODE_LIST = ["Dummy Music", "Dummy Movie"] +DEFAULT_SOUND_MODE = "Dummy Music" + +YOUTUBE_PLAYER_SUPPORT = ( + SUPPORT_PAUSE + | SUPPORT_VOLUME_SET + | SUPPORT_VOLUME_MUTE + | SUPPORT_TURN_ON + | SUPPORT_TURN_OFF + | SUPPORT_PLAY_MEDIA + | SUPPORT_PLAY + | SUPPORT_SHUFFLE_SET + | SUPPORT_SELECT_SOUND_MODE + | SUPPORT_SELECT_SOURCE + | SUPPORT_SEEK +) + +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_VOLUME_STEP + | SUPPORT_PREVIOUS_TRACK + | SUPPORT_NEXT_TRACK + | SUPPORT_SELECT_SOUND_MODE +) + +NETFLIX_PLAYER_SUPPORT = ( + SUPPORT_PAUSE + | SUPPORT_TURN_ON + | SUPPORT_TURN_OFF + | SUPPORT_SELECT_SOURCE + | SUPPORT_PLAY + | SUPPORT_SHUFFLE_SET + | SUPPORT_PREVIOUS_TRACK + | SUPPORT_NEXT_TRACK + | SUPPORT_SELECT_SOUND_MODE +) + + +class AbstractDemoPlayer(MediaPlayerDevice): + """A demo media players.""" + + # We only implement the methods that we support + + def __init__(self, name, device_class=None): + """Initialize the demo device.""" + self._name = name + self._player_state = STATE_PLAYING + self._volume_level = 1.0 + self._volume_muted = False + self._shuffle = False + self._sound_mode_list = SOUND_MODE_LIST + self._sound_mode = DEFAULT_SOUND_MODE + self._device_class = device_class + + @property + def should_poll(self): + """Push an update after each command.""" + return False + + @property + def name(self): + """Return the name of the media player.""" + return self._name + + @property + def state(self): + """Return the state of the player.""" + return self._player_state + + @property + def volume_level(self): + """Return the volume level of the media player (0..1).""" + return self._volume_level + + @property + def is_volume_muted(self): + """Return boolean if volume is currently muted.""" + return self._volume_muted + + @property + def shuffle(self): + """Boolean if shuffling is enabled.""" + return self._shuffle + + @property + def sound_mode(self): + """Return the current sound mode.""" + return self._sound_mode + + @property + def sound_mode_list(self): + """Return a list of available sound modes.""" + return self._sound_mode_list + + @property + def device_class(self): + """Return the device class of the media player.""" + return self._device_class + + def turn_on(self): + """Turn the media player on.""" + self._player_state = STATE_PLAYING + self.schedule_update_ha_state() + + def turn_off(self): + """Turn the media player off.""" + self._player_state = STATE_OFF + self.schedule_update_ha_state() + + def mute_volume(self, mute): + """Mute the volume.""" + self._volume_muted = mute + self.schedule_update_ha_state() + + def volume_up(self): + """Increase volume.""" + self._volume_level = min(1.0, self._volume_level + 0.1) + self.schedule_update_ha_state() + + def volume_down(self): + """Decrease volume.""" + self._volume_level = max(0.0, self._volume_level - 0.1) + self.schedule_update_ha_state() + + def set_volume_level(self, volume): + """Set the volume level, range 0..1.""" + self._volume_level = volume + self.schedule_update_ha_state() + + def media_play(self): + """Send play command.""" + self._player_state = STATE_PLAYING + self.schedule_update_ha_state() + + def media_pause(self): + """Send pause command.""" + self._player_state = STATE_PAUSED + self.schedule_update_ha_state() + + def set_shuffle(self, shuffle): + """Enable/disable shuffle mode.""" + self._shuffle = shuffle + self.schedule_update_ha_state() + + def select_sound_mode(self, sound_mode): + """Select sound mode.""" + self._sound_mode = sound_mode + self.schedule_update_ha_state() + + +class DemoYoutubePlayer(AbstractDemoPlayer): + """A Demo media player that only supports YouTube.""" + + # We only implement the methods that we support + + def __init__(self, name, youtube_id=None, media_title=None, duration=360): + """Initialize the demo device.""" + super().__init__(name) + self.youtube_id = youtube_id + self._media_title = media_title + self._duration = duration + self._progress = int(duration * 0.15) + self._progress_updated_at = dt_util.utcnow() + + @property + def media_content_id(self): + """Return the content ID of current playing media.""" + return self.youtube_id + + @property + def media_content_type(self): + """Return the content type of current playing media.""" + return MEDIA_TYPE_MOVIE + + @property + def media_duration(self): + """Return the duration of current playing media in seconds.""" + return self._duration + + @property + def media_image_url(self): + """Return the image url of current playing media.""" + return YOUTUBE_COVER_URL_FORMAT.format(self.youtube_id) + + @property + def media_title(self): + """Return the title of current playing media.""" + return self._media_title + + @property + def app_name(self): + """Return the current running application.""" + return "YouTube" + + @property + def supported_features(self): + """Flag media player features that are supported.""" + return YOUTUBE_PLAYER_SUPPORT + + @property + def media_position(self): + """Position of current playing media in seconds.""" + if self._progress is None: + return None + + position = self._progress + + if self._player_state == STATE_PLAYING: + position += (dt_util.utcnow() - self._progress_updated_at).total_seconds() + + return 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(). + """ + if self._player_state == STATE_PLAYING: + return self._progress_updated_at + + def play_media(self, media_type, media_id, **kwargs): + """Play a piece of media.""" + self.youtube_id = media_id + self.schedule_update_ha_state() + + def media_pause(self): + """Send pause command.""" + self._progress = self.media_position + self._progress_updated_at = dt_util.utcnow() + super().media_pause() + + +class DemoMusicPlayer(AbstractDemoPlayer): + """A Demo media player that only supports YouTube.""" + + # We only implement the methods that we support + + tracks = [ + ("Technohead", "I Wanna Be A Hippy (Flamman & Abraxas Radio Mix)"), + ("Paul Elstak", "Luv U More"), + ("Dune", "Hardcore Vibes"), + ("Nakatomi", "Children Of The Night"), + ("Party Animals", "Have You Ever Been Mellow? (Flamman & Abraxas Radio Mix)"), + ("Rob G.*", "Ecstasy, You Got What I Need"), + ("Lipstick", "I'm A Raver"), + ("4 Tune Fairytales", "My Little Fantasy (Radio Edit)"), + ("Prophet", "The Big Boys Don't Cry"), + ("Lovechild", "All Out Of Love (DJ Weirdo & Sim Remix)"), + ("Stingray & Sonic Driver", "Cold As Ice (El Bruto Remix)"), + ("Highlander", "Hold Me Now (Bass-D & King Matthew Remix)"), + ("Juggernaut", 'Ruffneck Rules Da Artcore Scene (12" Edit)'), + ("Diss Reaction", "Jiiieehaaaa "), + ("Flamman And Abraxas", "Good To Go (Radio Mix)"), + ("Critical Mass", "Dancing Together"), + ( + "Charly Lownoise & Mental Theo", + "Ultimate Sex Track (Bass-D & King Matthew Remix)", + ), + ] + + def __init__(self): + """Initialize the demo device.""" + super().__init__("Walkman") + self._cur_track = 0 + + @property + def media_content_id(self): + """Return the content ID of current playing media.""" + return "bounzz-1" + + @property + def media_content_type(self): + """Return the content type of current playing media.""" + return MEDIA_TYPE_MUSIC + + @property + def media_duration(self): + """Return the duration of current playing media in seconds.""" + return 213 + + @property + def media_image_url(self): + """Return the image url of current playing media.""" + return "https://graph.facebook.com/v2.5/107771475912710/" "picture?type=large" + + @property + def media_title(self): + """Return the title of current playing media.""" + return self.tracks[self._cur_track][1] if self.tracks else "" + + @property + def media_artist(self): + """Return the artist of current playing media (Music track only).""" + return self.tracks[self._cur_track][0] if self.tracks else "" + + @property + def media_album_name(self): + """Return the album of current playing media (Music track only).""" + return "Bounzz" + + @property + def media_track(self): + """Return the track number of current media (Music track only).""" + return self._cur_track + 1 + + @property + def supported_features(self): + """Flag media player features that are supported.""" + return MUSIC_PLAYER_SUPPORT + + def media_previous_track(self): + """Send previous track command.""" + if self._cur_track > 0: + self._cur_track -= 1 + self.schedule_update_ha_state() + + def media_next_track(self): + """Send next track command.""" + if self._cur_track < len(self.tracks) - 1: + self._cur_track += 1 + self.schedule_update_ha_state() + + def clear_playlist(self): + """Clear players playlist.""" + self.tracks = [] + self._cur_track = 0 + self._player_state = STATE_OFF + self.schedule_update_ha_state() + + +class DemoTVShowPlayer(AbstractDemoPlayer): + """A Demo media player that only supports YouTube.""" + + # We only implement the methods that we support + + def __init__(self): + """Initialize the demo device.""" + super().__init__("Lounge room") + self._cur_episode = 1 + self._episode_count = 13 + self._source = "dvd" + + @property + def media_content_id(self): + """Return the content ID of current playing media.""" + return "house-of-cards-1" + + @property + def media_content_type(self): + """Return the content type of current playing media.""" + return MEDIA_TYPE_TVSHOW + + @property + def media_duration(self): + """Return the duration of current playing media in seconds.""" + return 3600 + + @property + def media_image_url(self): + """Return the image url of current playing media.""" + return "https://graph.facebook.com/v2.5/HouseofCards/picture?width=400" + + @property + def media_title(self): + """Return the title of current playing media.""" + return f"Chapter {self._cur_episode}" + + @property + def media_series_title(self): + """Return the series title of current playing media (TV Show only).""" + return "House of Cards" + + @property + def media_season(self): + """Return the season of current playing media (TV Show only).""" + return 1 + + @property + def media_episode(self): + """Return the episode of current playing media (TV Show only).""" + return self._cur_episode + + @property + def app_name(self): + """Return the current running application.""" + return "Netflix" + + @property + def source(self): + """Return the current input source.""" + return self._source + + @property + def supported_features(self): + """Flag media player features that are supported.""" + return NETFLIX_PLAYER_SUPPORT + + def media_previous_track(self): + """Send previous track command.""" + if self._cur_episode > 1: + self._cur_episode -= 1 + self.schedule_update_ha_state() + + def media_next_track(self): + """Send next track command.""" + if self._cur_episode < self._episode_count: + self._cur_episode += 1 + self.schedule_update_ha_state() + + def select_source(self, source): + """Set the input source.""" + self._source = source + self.schedule_update_ha_state() diff --git a/homeassistant/components/demo/notify.py b/homeassistant/components/demo/notify.py new file mode 100644 index 000000000..f390c042c --- /dev/null +++ b/homeassistant/components/demo/notify.py @@ -0,0 +1,27 @@ +"""Demo notification service.""" +from homeassistant.components.notify import BaseNotificationService + +EVENT_NOTIFY = "notify" + + +def get_service(hass, config, discovery_info=None): + """Get the demo notification service.""" + return DemoNotificationService(hass) + + +class DemoNotificationService(BaseNotificationService): + """Implement demo notification service.""" + + def __init__(self, hass): + """Initialize the service.""" + self.hass = hass + + @property + def targets(self): + """Return a dictionary of registered targets.""" + return {"test target name": "test target id"} + + def send_message(self, message="", **kwargs): + """Send a message to a user.""" + kwargs["message"] = message + self.hass.bus.fire(EVENT_NOTIFY, kwargs) diff --git a/homeassistant/components/demo/remote.py b/homeassistant/components/demo/remote.py new file mode 100644 index 000000000..70e0d3c8b --- /dev/null +++ b/homeassistant/components/demo/remote.py @@ -0,0 +1,71 @@ +"""Demo platform that has two fake remotes.""" +from homeassistant.components.remote import RemoteDevice +from homeassistant.const import DEVICE_DEFAULT_NAME + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Demo config entry.""" + setup_platform(hass, {}, async_add_entities) + + +def setup_platform(hass, config, add_entities_callback, discovery_info=None): + """Set up the demo remotes.""" + add_entities_callback( + [ + DemoRemote("Remote One", False, None), + DemoRemote("Remote Two", True, "mdi:remote"), + ] + ) + + +class DemoRemote(RemoteDevice): + """Representation of a demo remote.""" + + def __init__(self, name, state, icon): + """Initialize the Demo Remote.""" + self._name = name or DEVICE_DEFAULT_NAME + self._state = state + self._icon = icon + self._last_command_sent = None + + @property + def should_poll(self): + """No polling needed for a demo remote.""" + return False + + @property + def name(self): + """Return the name of the device if any.""" + return self._name + + @property + def icon(self): + """Return the icon to use for device if any.""" + return self._icon + + @property + def is_on(self): + """Return true if remote is on.""" + return self._state + + @property + def device_state_attributes(self): + """Return device state attributes.""" + if self._last_command_sent is not None: + return {"last_command_sent": self._last_command_sent} + + def turn_on(self, **kwargs): + """Turn the remote on.""" + self._state = True + self.schedule_update_ha_state() + + def turn_off(self, **kwargs): + """Turn the remote off.""" + self._state = False + self.schedule_update_ha_state() + + def send_command(self, command, **kwargs): + """Send a command to a device.""" + for com in command: + self._last_command_sent = com + self.schedule_update_ha_state() diff --git a/homeassistant/components/demo/sensor.py b/homeassistant/components/demo/sensor.py new file mode 100644 index 000000000..d2b246446 --- /dev/null +++ b/homeassistant/components/demo/sensor.py @@ -0,0 +1,96 @@ +"""Demo platform that has a couple of fake sensors.""" +from homeassistant.const import ( + ATTR_BATTERY_LEVEL, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_TEMPERATURE, + TEMP_CELSIUS, +) +from homeassistant.helpers.entity import Entity + +from . import DOMAIN + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the Demo sensors.""" + async_add_entities( + [ + DemoSensor( + "sensor_1", + "Outside Temperature", + 15.6, + DEVICE_CLASS_TEMPERATURE, + TEMP_CELSIUS, + 12, + ), + DemoSensor( + "sensor_2", "Outside Humidity", 54, DEVICE_CLASS_HUMIDITY, "%", None + ), + ] + ) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Demo config entry.""" + await async_setup_platform(hass, {}, async_add_entities) + + +class DemoSensor(Entity): + """Representation of a Demo sensor.""" + + def __init__( + self, unique_id, name, state, device_class, unit_of_measurement, battery + ): + """Initialize the sensor.""" + self._unique_id = unique_id + self._name = name + self._state = state + self._device_class = device_class + self._unit_of_measurement = unit_of_measurement + self._battery = battery + + @property + def device_info(self): + """Return device info.""" + return { + "identifiers": { + # Serial numbers are unique identifiers within a specific domain + (DOMAIN, self.unique_id) + }, + "name": self.name, + } + + @property + def unique_id(self): + """Return the unique id.""" + return self._unique_id + + @property + def should_poll(self): + """No polling needed for a demo sensor.""" + return False + + @property + def device_class(self): + """Return the device class of the sensor.""" + return self._device_class + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit this state is expressed in.""" + return self._unit_of_measurement + + @property + def device_state_attributes(self): + """Return the state attributes.""" + if self._battery: + return {ATTR_BATTERY_LEVEL: self._battery} diff --git a/homeassistant/components/demo/services.yaml b/homeassistant/components/demo/services.yaml new file mode 100644 index 000000000..a8a96b21c --- /dev/null +++ b/homeassistant/components/demo/services.yaml @@ -0,0 +1,2 @@ +randomize_device_tracker_data: + description: Demonstrates using a device tracker to see where devices are located \ No newline at end of file diff --git a/homeassistant/components/demo/strings.json b/homeassistant/components/demo/strings.json new file mode 100644 index 000000000..a2c010328 --- /dev/null +++ b/homeassistant/components/demo/strings.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Demo" + } +} diff --git a/homeassistant/components/demo/stt.py b/homeassistant/components/demo/stt.py new file mode 100644 index 000000000..e0367fad6 --- /dev/null +++ b/homeassistant/components/demo/stt.py @@ -0,0 +1,66 @@ +"""Support for the demo for speech to text service.""" +from typing import List + +from aiohttp import StreamReader + +from homeassistant.components.stt import Provider, SpeechMetadata, SpeechResult +from homeassistant.components.stt.const import ( + AudioBitRates, + AudioChannels, + AudioCodecs, + AudioFormats, + AudioSampleRates, + SpeechResultState, +) + +SUPPORT_LANGUAGES = ["en", "de"] + + +async def async_get_engine(hass, config, discovery_info=None): + """Set up Demo speech component.""" + return DemoProvider() + + +class DemoProvider(Provider): + """Demo speech API provider.""" + + @property + def supported_languages(self) -> List[str]: + """Return a list of supported languages.""" + return SUPPORT_LANGUAGES + + @property + def supported_formats(self) -> List[AudioFormats]: + """Return a list of supported formats.""" + return [AudioFormats.WAV] + + @property + def supported_codecs(self) -> List[AudioCodecs]: + """Return a list of supported codecs.""" + return [AudioCodecs.PCM] + + @property + def supported_bit_rates(self) -> List[AudioBitRates]: + """Return a list of supported bit rates.""" + return [AudioBitRates.BITRATE_16] + + @property + def supported_sample_rates(self) -> List[AudioSampleRates]: + """Return a list of supported sample rates.""" + return [AudioSampleRates.SAMPLERATE_16000, AudioSampleRates.SAMPLERATE_44100] + + @property + def supported_channels(self) -> List[AudioChannels]: + """Return a list of supported channels.""" + return [AudioChannels.CHANNEL_STEREO] + + async def async_process_audio_stream( + self, metadata: SpeechMetadata, stream: StreamReader + ) -> SpeechResult: + """Process an audio stream to STT service.""" + + # Read available data + async for _ in stream.iter_chunked(4096): + pass + + return SpeechResult("Turn the Kitchen Lights on", SpeechResultState.SUCCESS) diff --git a/homeassistant/components/demo/switch.py b/homeassistant/components/demo/switch.py new file mode 100644 index 000000000..5c651198f --- /dev/null +++ b/homeassistant/components/demo/switch.py @@ -0,0 +1,100 @@ +"""Demo platform that has two fake switches.""" +from homeassistant.components.switch import SwitchDevice +from homeassistant.const import DEVICE_DEFAULT_NAME + +from . import DOMAIN + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the demo switches.""" + async_add_entities( + [ + DemoSwitch("swith1", "Decorative Lights", True, None, True), + DemoSwitch("swith2", "AC", False, "mdi:air-conditioner", False), + ] + ) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Demo config entry.""" + await async_setup_platform(hass, {}, async_add_entities) + + +class DemoSwitch(SwitchDevice): + """Representation of a demo switch.""" + + def __init__(self, unique_id, name, state, icon, assumed, device_class=None): + """Initialize the Demo switch.""" + self._unique_id = unique_id + self._name = name or DEVICE_DEFAULT_NAME + self._state = state + self._icon = icon + self._assumed = assumed + self._device_class = device_class + + @property + def device_info(self): + """Return device info.""" + return { + "identifiers": { + # Serial numbers are unique identifiers within a specific domain + (DOMAIN, self.unique_id) + }, + "name": self.name, + } + + @property + def unique_id(self): + """Return the unique id.""" + return self._unique_id + + @property + def should_poll(self): + """No polling needed for a demo switch.""" + return False + + @property + def name(self): + """Return the name of the device if any.""" + return self._name + + @property + def icon(self): + """Return the icon to use for device if any.""" + return self._icon + + @property + def assumed_state(self): + """Return if the state is based on assumptions.""" + return self._assumed + + @property + def current_power_w(self): + """Return the current power usage in W.""" + if self._state: + return 100 + + @property + def today_energy_kwh(self): + """Return the today total energy usage in kWh.""" + return 15 + + @property + def is_on(self): + """Return true if switch is on.""" + return self._state + + @property + def device_class(self): + """Return device of entity.""" + return self._device_class + + def turn_on(self, **kwargs): + """Turn the switch on.""" + self._state = True + self.schedule_update_ha_state() + + def turn_off(self, **kwargs): + """Turn the device off.""" + self._state = False + self.schedule_update_ha_state() diff --git a/homeassistant/components/tts/demo.mp3 b/homeassistant/components/demo/tts.mp3 similarity index 100% rename from homeassistant/components/tts/demo.mp3 rename to homeassistant/components/demo/tts.mp3 diff --git a/homeassistant/components/demo/tts.py b/homeassistant/components/demo/tts.py new file mode 100644 index 000000000..b7be6349d --- /dev/null +++ b/homeassistant/components/demo/tts.py @@ -0,0 +1,54 @@ +"""Support for the demo for text to speech service.""" +import os + +import voluptuous as vol + +from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider + +SUPPORT_LANGUAGES = ["en", "de"] + +DEFAULT_LANG = "en" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + {vol.Optional(CONF_LANG, default=DEFAULT_LANG): vol.In(SUPPORT_LANGUAGES)} +) + + +def get_engine(hass, config, discovery_info=None): + """Set up Demo speech component.""" + return DemoProvider(config.get(CONF_LANG, DEFAULT_LANG)) + + +class DemoProvider(Provider): + """Demo speech API provider.""" + + def __init__(self, lang): + """Initialize demo provider.""" + self._lang = lang + self.name = "Demo" + + @property + def default_language(self): + """Return the default language.""" + return self._lang + + @property + def supported_languages(self): + """Return list of supported languages.""" + return SUPPORT_LANGUAGES + + @property + def supported_options(self): + """Return list of supported options like voice, emotionen.""" + return ["voice", "age"] + + def get_tts_audio(self, message, language, options=None): + """Load TTS from demo.""" + filename = os.path.join(os.path.dirname(__file__), "tts.mp3") + try: + with open(filename, "rb") as voice: + data = voice.read() + except OSError: + return (None, None) + + return ("mp3", data) diff --git a/homeassistant/components/demo/vacuum.py b/homeassistant/components/demo/vacuum.py new file mode 100644 index 000000000..fb64f17a4 --- /dev/null +++ b/homeassistant/components/demo/vacuum.py @@ -0,0 +1,376 @@ +"""Demo platform for the vacuum component.""" +import logging + +from homeassistant.components.vacuum import ( + ATTR_CLEANED_AREA, + STATE_CLEANING, + STATE_DOCKED, + STATE_IDLE, + STATE_PAUSED, + STATE_RETURNING, + SUPPORT_BATTERY, + SUPPORT_CLEAN_SPOT, + SUPPORT_FAN_SPEED, + SUPPORT_LOCATE, + SUPPORT_PAUSE, + SUPPORT_RETURN_HOME, + SUPPORT_SEND_COMMAND, + SUPPORT_START, + SUPPORT_STATE, + SUPPORT_STATUS, + SUPPORT_STOP, + SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, + StateVacuumDevice, + VacuumDevice, +) + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_MINIMAL_SERVICES = SUPPORT_TURN_ON | SUPPORT_TURN_OFF + +SUPPORT_BASIC_SERVICES = ( + SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_STATUS | SUPPORT_BATTERY +) + +SUPPORT_MOST_SERVICES = ( + SUPPORT_TURN_ON + | SUPPORT_TURN_OFF + | SUPPORT_STOP + | SUPPORT_RETURN_HOME + | SUPPORT_STATUS + | SUPPORT_BATTERY +) + +SUPPORT_ALL_SERVICES = ( + SUPPORT_TURN_ON + | SUPPORT_TURN_OFF + | SUPPORT_PAUSE + | SUPPORT_STOP + | SUPPORT_RETURN_HOME + | SUPPORT_FAN_SPEED + | SUPPORT_SEND_COMMAND + | SUPPORT_LOCATE + | SUPPORT_STATUS + | SUPPORT_BATTERY + | SUPPORT_CLEAN_SPOT +) + +SUPPORT_STATE_SERVICES = ( + SUPPORT_STATE + | SUPPORT_PAUSE + | SUPPORT_STOP + | SUPPORT_RETURN_HOME + | SUPPORT_FAN_SPEED + | SUPPORT_BATTERY + | SUPPORT_CLEAN_SPOT + | SUPPORT_START +) + +FAN_SPEEDS = ["min", "medium", "high", "max"] +DEMO_VACUUM_COMPLETE = "0_Ground_floor" +DEMO_VACUUM_MOST = "1_First_floor" +DEMO_VACUUM_BASIC = "2_Second_floor" +DEMO_VACUUM_MINIMAL = "3_Third_floor" +DEMO_VACUUM_NONE = "4_Fourth_floor" +DEMO_VACUUM_STATE = "5_Fifth_floor" + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Demo config entry.""" + setup_platform(hass, {}, async_add_entities) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Demo vacuums.""" + add_entities( + [ + DemoVacuum(DEMO_VACUUM_COMPLETE, SUPPORT_ALL_SERVICES), + DemoVacuum(DEMO_VACUUM_MOST, SUPPORT_MOST_SERVICES), + DemoVacuum(DEMO_VACUUM_BASIC, SUPPORT_BASIC_SERVICES), + DemoVacuum(DEMO_VACUUM_MINIMAL, SUPPORT_MINIMAL_SERVICES), + DemoVacuum(DEMO_VACUUM_NONE, 0), + StateDemoVacuum(DEMO_VACUUM_STATE), + ] + ) + + +class DemoVacuum(VacuumDevice): + """Representation of a demo vacuum.""" + + def __init__(self, name, supported_features): + """Initialize the vacuum.""" + self._name = name + self._supported_features = supported_features + self._state = False + self._status = "Charging" + self._fan_speed = FAN_SPEEDS[1] + self._cleaned_area = 0 + self._battery_level = 100 + + @property + def name(self): + """Return the name of the vacuum.""" + return self._name + + @property + def should_poll(self): + """No polling needed for a demo vacuum.""" + return False + + @property + def is_on(self): + """Return true if vacuum is on.""" + return self._state + + @property + def status(self): + """Return the status of the vacuum.""" + if self.supported_features & SUPPORT_STATUS == 0: + return + + return self._status + + @property + def fan_speed(self): + """Return the status of the vacuum.""" + if self.supported_features & SUPPORT_FAN_SPEED == 0: + return + + return self._fan_speed + + @property + def fan_speed_list(self): + """Return the status of the vacuum.""" + assert self.supported_features & SUPPORT_FAN_SPEED != 0 + return FAN_SPEEDS + + @property + def battery_level(self): + """Return the status of the vacuum.""" + if self.supported_features & SUPPORT_BATTERY == 0: + return + + return max(0, min(100, self._battery_level)) + + @property + def device_state_attributes(self): + """Return device state attributes.""" + return {ATTR_CLEANED_AREA: round(self._cleaned_area, 2)} + + @property + def supported_features(self): + """Flag supported features.""" + return self._supported_features + + def turn_on(self, **kwargs): + """Turn the vacuum on.""" + if self.supported_features & SUPPORT_TURN_ON == 0: + return + + self._state = True + self._cleaned_area += 5.32 + self._battery_level -= 2 + self._status = "Cleaning" + self.schedule_update_ha_state() + + def turn_off(self, **kwargs): + """Turn the vacuum off.""" + if self.supported_features & SUPPORT_TURN_OFF == 0: + return + + self._state = False + self._status = "Charging" + self.schedule_update_ha_state() + + def stop(self, **kwargs): + """Stop the vacuum.""" + if self.supported_features & SUPPORT_STOP == 0: + return + + self._state = False + self._status = "Stopping the current task" + self.schedule_update_ha_state() + + def clean_spot(self, **kwargs): + """Perform a spot clean-up.""" + if self.supported_features & SUPPORT_CLEAN_SPOT == 0: + return + + self._state = True + self._cleaned_area += 1.32 + self._battery_level -= 1 + self._status = "Cleaning spot" + self.schedule_update_ha_state() + + def locate(self, **kwargs): + """Locate the vacuum (usually by playing a song).""" + if self.supported_features & SUPPORT_LOCATE == 0: + return + + self._status = "Hi, I'm over here!" + self.schedule_update_ha_state() + + def start_pause(self, **kwargs): + """Start, pause or resume the cleaning task.""" + if self.supported_features & SUPPORT_PAUSE == 0: + return + + self._state = not self._state + if self._state: + self._status = "Resuming the current task" + self._cleaned_area += 1.32 + self._battery_level -= 1 + else: + self._status = "Pausing the current task" + self.schedule_update_ha_state() + + def set_fan_speed(self, fan_speed, **kwargs): + """Set the vacuum's fan speed.""" + if self.supported_features & SUPPORT_FAN_SPEED == 0: + return + + if fan_speed in self.fan_speed_list: + self._fan_speed = fan_speed + self.schedule_update_ha_state() + + def return_to_base(self, **kwargs): + """Tell the vacuum to return to its dock.""" + if self.supported_features & SUPPORT_RETURN_HOME == 0: + return + + self._state = False + self._status = "Returning home..." + self._battery_level += 5 + self.schedule_update_ha_state() + + def send_command(self, command, params=None, **kwargs): + """Send a command to the vacuum.""" + if self.supported_features & SUPPORT_SEND_COMMAND == 0: + return + + self._status = f"Executing {command}({params})" + self._state = True + self.schedule_update_ha_state() + + +class StateDemoVacuum(StateVacuumDevice): + """Representation of a demo vacuum supporting states.""" + + def __init__(self, name): + """Initialize the vacuum.""" + self._name = name + self._supported_features = SUPPORT_STATE_SERVICES + self._state = STATE_DOCKED + self._fan_speed = FAN_SPEEDS[1] + self._cleaned_area = 0 + self._battery_level = 100 + + @property + def name(self): + """Return the name of the vacuum.""" + return self._name + + @property + def should_poll(self): + """No polling needed for a demo vacuum.""" + return False + + @property + def supported_features(self): + """Flag supported features.""" + return self._supported_features + + @property + def state(self): + """Return the current state of the vacuum.""" + return self._state + + @property + def battery_level(self): + """Return the current battery level of the vacuum.""" + if self.supported_features & SUPPORT_BATTERY == 0: + return + + return max(0, min(100, self._battery_level)) + + @property + def fan_speed(self): + """Return the current fan speed of the vacuum.""" + if self.supported_features & SUPPORT_FAN_SPEED == 0: + return + + return self._fan_speed + + @property + def fan_speed_list(self): + """Return the list of supported fan speeds.""" + if self.supported_features & SUPPORT_FAN_SPEED == 0: + return + return FAN_SPEEDS + + @property + def device_state_attributes(self): + """Return device state attributes.""" + return {ATTR_CLEANED_AREA: round(self._cleaned_area, 2)} + + def start(self): + """Start or resume the cleaning task.""" + if self.supported_features & SUPPORT_START == 0: + return + + if self._state != STATE_CLEANING: + self._state = STATE_CLEANING + self._cleaned_area += 1.32 + self._battery_level -= 1 + self.schedule_update_ha_state() + + def pause(self): + """Pause the cleaning task.""" + if self.supported_features & SUPPORT_PAUSE == 0: + return + + if self._state == STATE_CLEANING: + self._state = STATE_PAUSED + self.schedule_update_ha_state() + + def stop(self, **kwargs): + """Stop the cleaning task, do not return to dock.""" + if self.supported_features & SUPPORT_STOP == 0: + return + + self._state = STATE_IDLE + self.schedule_update_ha_state() + + def return_to_base(self, **kwargs): + """Return dock to charging base.""" + if self.supported_features & SUPPORT_RETURN_HOME == 0: + return + + self._state = STATE_RETURNING + self.schedule_update_ha_state() + + self.hass.loop.call_later(30, self.__set_state_to_dock) + + def clean_spot(self, **kwargs): + """Perform a spot clean-up.""" + if self.supported_features & SUPPORT_CLEAN_SPOT == 0: + return + + self._state = STATE_CLEANING + self._cleaned_area += 1.32 + self._battery_level -= 1 + self.schedule_update_ha_state() + + def set_fan_speed(self, fan_speed, **kwargs): + """Set the vacuum's fan speed.""" + if self.supported_features & SUPPORT_FAN_SPEED == 0: + return + + if fan_speed in self.fan_speed_list: + self._fan_speed = fan_speed + self.schedule_update_ha_state() + + def __set_state_to_dock(self): + self._state = STATE_DOCKED + self.schedule_update_ha_state() diff --git a/homeassistant/components/demo/water_heater.py b/homeassistant/components/demo/water_heater.py new file mode 100644 index 000000000..f9aca1412 --- /dev/null +++ b/homeassistant/components/demo/water_heater.py @@ -0,0 +1,117 @@ +"""Demo platform that offers a fake water heater device.""" +from homeassistant.components.water_heater import ( + SUPPORT_AWAY_MODE, + SUPPORT_OPERATION_MODE, + SUPPORT_TARGET_TEMPERATURE, + WaterHeaterDevice, +) +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT + +SUPPORT_FLAGS_HEATER = ( + SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE | SUPPORT_AWAY_MODE +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the Demo water_heater devices.""" + async_add_entities( + [ + DemoWaterHeater("Demo Water Heater", 119, TEMP_FAHRENHEIT, False, "eco"), + DemoWaterHeater("Demo Water Heater Celsius", 45, TEMP_CELSIUS, True, "eco"), + ] + ) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Demo config entry.""" + await async_setup_platform(hass, {}, async_add_entities) + + +class DemoWaterHeater(WaterHeaterDevice): + """Representation of a demo water_heater device.""" + + def __init__( + self, name, target_temperature, unit_of_measurement, away, current_operation + ): + """Initialize the water_heater device.""" + self._name = name + self._support_flags = SUPPORT_FLAGS_HEATER + 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 current_operation is not None: + self._support_flags = self._support_flags | SUPPORT_OPERATION_MODE + self._target_temperature = target_temperature + self._unit_of_measurement = unit_of_measurement + self._away = away + self._current_operation = current_operation + self._operation_list = [ + "eco", + "electric", + "performance", + "high_demand", + "heat_pump", + "gas", + "off", + ] + + @property + def supported_features(self): + """Return the list of supported features.""" + return self._support_flags + + @property + def should_poll(self): + """Return the polling state.""" + return False + + @property + def name(self): + """Return the name of the water_heater device.""" + return self._name + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return self._unit_of_measurement + + @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 is_away_mode_on(self): + """Return if away mode is on.""" + return self._away + + def set_temperature(self, **kwargs): + """Set new target temperatures.""" + self._target_temperature = kwargs.get(ATTR_TEMPERATURE) + self.schedule_update_ha_state() + + def set_operation_mode(self, operation_mode): + """Set new operation mode.""" + self._current_operation = operation_mode + self.schedule_update_ha_state() + + def turn_away_mode_on(self): + """Turn away mode on.""" + self._away = True + self.schedule_update_ha_state() + + def turn_away_mode_off(self): + """Turn away mode off.""" + self._away = False + self.schedule_update_ha_state() diff --git a/homeassistant/components/demo/weather.py b/homeassistant/components/demo/weather.py new file mode 100644 index 000000000..8cc1b2f95 --- /dev/null +++ b/homeassistant/components/demo/weather.py @@ -0,0 +1,170 @@ +"""Demo platform that offers fake meteorological data.""" +from datetime import timedelta + +from homeassistant.components.weather import ( + ATTR_FORECAST_CONDITION, + ATTR_FORECAST_PRECIPITATION, + ATTR_FORECAST_TEMP, + ATTR_FORECAST_TEMP_LOW, + ATTR_FORECAST_TIME, + WeatherEntity, +) +from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT +import homeassistant.util.dt as dt_util + +CONDITION_CLASSES = { + "cloudy": [], + "fog": [], + "hail": [], + "lightning": [], + "lightning-rainy": [], + "partlycloudy": [], + "pouring": [], + "rainy": ["shower rain"], + "snowy": [], + "snowy-rainy": [], + "sunny": ["sunshine"], + "windy": [], + "windy-variant": [], + "exceptional": [], +} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Demo config entry.""" + setup_platform(hass, {}, async_add_entities) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Demo weather.""" + add_entities( + [ + DemoWeather( + "South", + "Sunshine", + 21.6414, + 92, + 1099, + 0.5, + TEMP_CELSIUS, + [ + ["rainy", 1, 22, 15], + ["rainy", 5, 19, 8], + ["cloudy", 0, 15, 9], + ["sunny", 0, 12, 6], + ["partlycloudy", 2, 14, 7], + ["rainy", 15, 18, 7], + ["fog", 0.2, 21, 12], + ], + ), + DemoWeather( + "North", + "Shower rain", + -12, + 54, + 987, + 4.8, + TEMP_FAHRENHEIT, + [ + ["snowy", 2, -10, -15], + ["partlycloudy", 1, -13, -14], + ["sunny", 0, -18, -22], + ["sunny", 0.1, -23, -23], + ["snowy", 4, -19, -20], + ["sunny", 0.3, -14, -19], + ["sunny", 0, -9, -12], + ], + ), + ] + ) + + +class DemoWeather(WeatherEntity): + """Representation of a weather condition.""" + + def __init__( + self, + name, + condition, + temperature, + humidity, + pressure, + wind_speed, + temperature_unit, + forecast, + ): + """Initialize the Demo weather.""" + self._name = name + self._condition = condition + self._temperature = temperature + self._temperature_unit = temperature_unit + self._humidity = humidity + self._pressure = pressure + self._wind_speed = wind_speed + self._forecast = forecast + + @property + def name(self): + """Return the name of the sensor.""" + return "{} {}".format("Demo Weather", self._name) + + @property + def should_poll(self): + """No polling needed for a demo weather condition.""" + return False + + @property + def temperature(self): + """Return the temperature.""" + return self._temperature + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return self._temperature_unit + + @property + def humidity(self): + """Return the humidity.""" + return self._humidity + + @property + def wind_speed(self): + """Return the wind speed.""" + return self._wind_speed + + @property + def pressure(self): + """Return the pressure.""" + return self._pressure + + @property + def condition(self): + """Return the weather condition.""" + return [ + k for k, v in CONDITION_CLASSES.items() if self._condition.lower() in v + ][0] + + @property + def attribution(self): + """Return the attribution.""" + return "Powered by Home Assistant" + + @property + def forecast(self): + """Return the forecast.""" + reftime = dt_util.now().replace(hour=16, minute=00) + + forecast_data = [] + for entry in self._forecast: + data_dict = { + ATTR_FORECAST_TIME: reftime.isoformat(), + ATTR_FORECAST_CONDITION: entry[0], + ATTR_FORECAST_PRECIPITATION: entry[1], + ATTR_FORECAST_TEMP: entry[2], + ATTR_FORECAST_TEMP_LOW: entry[3], + } + reftime = reftime + timedelta(hours=4) + forecast_data.append(data_dict) + + return forecast_data diff --git a/homeassistant/components/denon/__init__.py b/homeassistant/components/denon/__init__.py new file mode 100644 index 000000000..ab8cd1b89 --- /dev/null +++ b/homeassistant/components/denon/__init__.py @@ -0,0 +1 @@ +"""The denon component.""" diff --git a/homeassistant/components/denon/manifest.json b/homeassistant/components/denon/manifest.json new file mode 100644 index 000000000..92b2aebab --- /dev/null +++ b/homeassistant/components/denon/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "denon", + "name": "Denon", + "documentation": "https://www.home-assistant.io/integrations/denon", + "requirements": [], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/denon/media_player.py b/homeassistant/components/denon/media_player.py new file mode 100644 index 000000000..cd9d6e8fe --- /dev/null +++ b/homeassistant/components/denon/media_player.py @@ -0,0 +1,292 @@ +"""Support for Denon Network Receivers.""" +import logging +import telnetlib + +import voluptuous as vol + +from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerDevice +from homeassistant.components.media_player.const import ( + SUPPORT_NEXT_TRACK, + SUPPORT_PAUSE, + SUPPORT_PLAY, + SUPPORT_PREVIOUS_TRACK, + SUPPORT_SELECT_SOURCE, + SUPPORT_STOP, + SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, + SUPPORT_VOLUME_MUTE, + SUPPORT_VOLUME_SET, +) +from homeassistant.const import CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = "Music station" + +SUPPORT_DENON = ( + SUPPORT_VOLUME_SET + | SUPPORT_VOLUME_MUTE + | SUPPORT_TURN_ON + | SUPPORT_TURN_OFF + | SUPPORT_SELECT_SOURCE +) +SUPPORT_MEDIA_MODES = ( + SUPPORT_PAUSE + | SUPPORT_STOP + | SUPPORT_PREVIOUS_TRACK + | SUPPORT_NEXT_TRACK + | SUPPORT_PLAY +) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + } +) + +NORMAL_INPUTS = { + "Cd": "CD", + "Dvd": "DVD", + "Blue ray": "BD", + "TV": "TV", + "Satellite / Cable": "SAT/CBL", + "Game": "GAME", + "Game2": "GAME2", + "Video Aux": "V.AUX", + "Dock": "DOCK", +} + +MEDIA_MODES = { + "Tuner": "TUNER", + "Media server": "SERVER", + "Ipod dock": "IPOD", + "Net/USB": "NET/USB", + "Rapsody": "RHAPSODY", + "Napster": "NAPSTER", + "Pandora": "PANDORA", + "LastFM": "LASTFM", + "Flickr": "FLICKR", + "Favorites": "FAVORITES", + "Internet Radio": "IRADIO", + "USB/IPOD": "USB/IPOD", +} + +# Sub-modes of 'NET/USB' +# {'USB': 'USB', 'iPod Direct': 'IPD', 'Internet Radio': 'IRP', +# 'Favorites': 'FVP'} + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Denon platform.""" + denon = DenonDevice(config.get(CONF_NAME), config.get(CONF_HOST)) + + if denon.update(): + add_entities([denon]) + + +class DenonDevice(MediaPlayerDevice): + """Representation of a Denon device.""" + + def __init__(self, name, host): + """Initialize the Denon device.""" + self._name = name + self._host = host + self._pwstate = "PWSTANDBY" + self._volume = 0 + # Initial value 60dB, changed if we get a MVMAX + self._volume_max = 60 + self._source_list = NORMAL_INPUTS.copy() + self._source_list.update(MEDIA_MODES) + self._muted = False + self._mediasource = "" + self._mediainfo = "" + + self._should_setup_sources = True + + def _setup_sources(self, telnet): + # NSFRN - Network name + nsfrn = self.telnet_request(telnet, "NSFRN ?")[len("NSFRN ") :] + if nsfrn: + self._name = nsfrn + + # SSFUN - Configured sources with names + self._source_list = {} + for line in self.telnet_request(telnet, "SSFUN ?", all_lines=True): + source, configured_name = line[len("SSFUN") :].split(" ", 1) + self._source_list[configured_name] = source + + # SSSOD - Deleted sources + for line in self.telnet_request(telnet, "SSSOD ?", all_lines=True): + source, status = line[len("SSSOD") :].split(" ", 1) + if status == "DEL": + for pretty_name, name in self._source_list.items(): + if source == name: + del self._source_list[pretty_name] + break + + @classmethod + def telnet_request(cls, telnet, command, all_lines=False): + """Execute `command` and return the response.""" + _LOGGER.debug("Sending: %s", command) + telnet.write(command.encode("ASCII") + b"\r") + lines = [] + while True: + line = telnet.read_until(b"\r", timeout=0.2) + if not line: + break + lines.append(line.decode("ASCII").strip()) + _LOGGER.debug("Received: %s", line) + + if all_lines: + return lines + return lines[0] if lines else "" + + def telnet_command(self, command): + """Establish a telnet connection and sends `command`.""" + telnet = telnetlib.Telnet(self._host) + _LOGGER.debug("Sending: %s", command) + telnet.write(command.encode("ASCII") + b"\r") + telnet.read_very_eager() # skip response + telnet.close() + + def update(self): + """Get the latest details from the device.""" + try: + telnet = telnetlib.Telnet(self._host) + except OSError: + return False + + if self._should_setup_sources: + self._setup_sources(telnet) + self._should_setup_sources = False + + self._pwstate = self.telnet_request(telnet, "PW?") + for line in self.telnet_request(telnet, "MV?", all_lines=True): + if line.startswith("MVMAX "): + # only grab two digit max, don't care about any half digit + self._volume_max = int(line[len("MVMAX ") : len("MVMAX XX")]) + continue + if line.startswith("MV"): + self._volume = int(line[len("MV") :]) + self._muted = self.telnet_request(telnet, "MU?") == "MUON" + self._mediasource = self.telnet_request(telnet, "SI?")[len("SI") :] + + if self._mediasource in MEDIA_MODES.values(): + self._mediainfo = "" + answer_codes = [ + "NSE0", + "NSE1X", + "NSE2X", + "NSE3X", + "NSE4", + "NSE5", + "NSE6", + "NSE7", + "NSE8", + ] + for line in self.telnet_request(telnet, "NSE", all_lines=True): + self._mediainfo += line[len(answer_codes.pop(0)) :] + "\n" + else: + self._mediainfo = self.source + + telnet.close() + 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 self._pwstate == "PWSTANDBY": + return STATE_OFF + if self._pwstate == "PWON": + return STATE_ON + + return None + + @property + def volume_level(self): + """Volume level of the media player (0..1).""" + return self._volume / self._volume_max + + @property + def is_volume_muted(self): + """Return boolean if volume is currently muted.""" + return self._muted + + @property + def source_list(self): + """Return the list of available input sources.""" + return sorted(list(self._source_list.keys())) + + @property + def media_title(self): + """Return the current media info.""" + return self._mediainfo + + @property + def supported_features(self): + """Flag media player features that are supported.""" + if self._mediasource in MEDIA_MODES.values(): + return SUPPORT_DENON | SUPPORT_MEDIA_MODES + return SUPPORT_DENON + + @property + def source(self): + """Return the current input source.""" + for pretty_name, name in self._source_list.items(): + if self._mediasource == name: + return pretty_name + + def turn_off(self): + """Turn off media player.""" + self.telnet_command("PWSTANDBY") + + def volume_up(self): + """Volume up media player.""" + self.telnet_command("MVUP") + + def volume_down(self): + """Volume down media player.""" + self.telnet_command("MVDOWN") + + def set_volume_level(self, volume): + """Set volume level, range 0..1.""" + self.telnet_command("MV" + str(round(volume * self._volume_max)).zfill(2)) + + def mute_volume(self, mute): + """Mute (true) or unmute (false) media player.""" + self.telnet_command("MU" + ("ON" if mute else "OFF")) + + def media_play(self): + """Play media player.""" + self.telnet_command("NS9A") + + def media_pause(self): + """Pause media player.""" + self.telnet_command("NS9B") + + def media_stop(self): + """Pause media player.""" + self.telnet_command("NS9C") + + def media_next_track(self): + """Send the next track command.""" + self.telnet_command("NS9D") + + def media_previous_track(self): + """Send the previous track command.""" + self.telnet_command("NS9E") + + def turn_on(self): + """Turn the media player on.""" + self.telnet_command("PWON") + + def select_source(self, source): + """Select input source.""" + self.telnet_command("SI" + self._source_list.get(source)) diff --git a/homeassistant/components/denonavr/__init__.py b/homeassistant/components/denonavr/__init__.py new file mode 100644 index 000000000..dee84449d --- /dev/null +++ b/homeassistant/components/denonavr/__init__.py @@ -0,0 +1 @@ +"""The denonavr component.""" diff --git a/homeassistant/components/denonavr/manifest.json b/homeassistant/components/denonavr/manifest.json new file mode 100644 index 000000000..9e084c78e --- /dev/null +++ b/homeassistant/components/denonavr/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "denonavr", + "name": "Denonavr", + "documentation": "https://www.home-assistant.io/integrations/denonavr", + "requirements": [ + "denonavr==0.7.10" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/denonavr/media_player.py b/homeassistant/components/denonavr/media_player.py new file mode 100644 index 000000000..1725b2d10 --- /dev/null +++ b/homeassistant/components/denonavr/media_player.py @@ -0,0 +1,400 @@ +"""Support for Denon AVR receivers using their HTTP interface.""" + +from collections import namedtuple +import logging + +import denonavr +import voluptuous as vol + +from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerDevice +from homeassistant.components.media_player.const import ( + MEDIA_TYPE_CHANNEL, + MEDIA_TYPE_MUSIC, + SUPPORT_NEXT_TRACK, + SUPPORT_PAUSE, + SUPPORT_PLAY, + SUPPORT_PLAY_MEDIA, + SUPPORT_PREVIOUS_TRACK, + SUPPORT_SELECT_SOUND_MODE, + SUPPORT_SELECT_SOURCE, + SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, + SUPPORT_VOLUME_MUTE, + SUPPORT_VOLUME_SET, + SUPPORT_VOLUME_STEP, +) +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_TIMEOUT, + CONF_ZONE, + STATE_OFF, + STATE_ON, + STATE_PAUSED, + STATE_PLAYING, +) +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +ATTR_SOUND_MODE_RAW = "sound_mode_raw" + +CONF_INVALID_ZONES_ERR = "Invalid Zone (expected Zone2 or Zone3)" +CONF_SHOW_ALL_SOURCES = "show_all_sources" +CONF_VALID_ZONES = ["Zone2", "Zone3"] +CONF_ZONES = "zones" + +DEFAULT_SHOW_SOURCES = False +DEFAULT_TIMEOUT = 2 + +KEY_DENON_CACHE = "denonavr_hosts" + +SUPPORT_DENON = ( + SUPPORT_VOLUME_STEP + | SUPPORT_VOLUME_MUTE + | SUPPORT_TURN_ON + | SUPPORT_TURN_OFF + | SUPPORT_SELECT_SOURCE + | SUPPORT_VOLUME_SET +) + +SUPPORT_MEDIA_MODES = ( + SUPPORT_PLAY_MEDIA + | SUPPORT_PAUSE + | SUPPORT_PREVIOUS_TRACK + | SUPPORT_NEXT_TRACK + | SUPPORT_VOLUME_SET + | SUPPORT_PLAY +) + +DENON_ZONE_SCHEMA = vol.Schema( + { + vol.Required(CONF_ZONE): vol.In(CONF_VALID_ZONES, CONF_INVALID_ZONES_ERR), + vol.Optional(CONF_NAME): cv.string, + } +) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_HOST): cv.string, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_SHOW_ALL_SOURCES, default=DEFAULT_SHOW_SOURCES): cv.boolean, + vol.Optional(CONF_ZONES): vol.All(cv.ensure_list, [DENON_ZONE_SCHEMA]), + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + } +) + +NewHost = namedtuple("NewHost", ["host", "name"]) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Denon platform.""" + + # Initialize list with receivers to be started + receivers = [] + + cache = hass.data.get(KEY_DENON_CACHE) + if cache is None: + cache = hass.data[KEY_DENON_CACHE] = set() + + # Get config option for show_all_sources and timeout + show_all_sources = config.get(CONF_SHOW_ALL_SOURCES) + timeout = config.get(CONF_TIMEOUT) + + # Get config option for additional zones + zones = config.get(CONF_ZONES) + if zones is not None: + add_zones = {} + for entry in zones: + add_zones[entry[CONF_ZONE]] = entry.get(CONF_NAME) + else: + add_zones = None + + # Start assignment of host and name + new_hosts = [] + # 1. option: manual setting + if config.get(CONF_HOST) is not None: + host = config.get(CONF_HOST) + name = config.get(CONF_NAME) + new_hosts.append(NewHost(host=host, name=name)) + + # 2. option: discovery using netdisco + if discovery_info is not None: + host = discovery_info.get("host") + name = discovery_info.get("name") + new_hosts.append(NewHost(host=host, name=name)) + + # 3. option: discovery using denonavr library + 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 + 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 + # starting + if entry.host not in cache: + new_device = denonavr.DenonAVR( + host=entry.host, + name=entry.name, + show_all_inputs=show_all_sources, + timeout=timeout, + add_zones=add_zones, + ) + for new_zone in new_device.zones.values(): + receivers.append(DenonDevice(new_zone)) + cache.add(host) + _LOGGER.info("Denon receiver at host %s initialized", host) + + # Add all freshly discovered receivers + if receivers: + add_entities(receivers) + + +class DenonDevice(MediaPlayerDevice): + """Representation of a Denon Media Player Device.""" + + def __init__(self, receiver): + """Initialize the device.""" + self._receiver = receiver + self._name = self._receiver.name + self._muted = self._receiver.muted + self._volume = self._receiver.volume + self._current_source = self._receiver.input_func + self._source_list = self._receiver.input_func_list + self._state = self._receiver.state + self._power = self._receiver.power + self._media_image_url = self._receiver.image_url + self._title = self._receiver.title + self._artist = self._receiver.artist + self._album = self._receiver.album + self._band = self._receiver.band + self._frequency = self._receiver.frequency + self._station = self._receiver.station + + self._sound_mode_support = self._receiver.support_sound_mode + if self._sound_mode_support: + self._sound_mode = self._receiver.sound_mode + self._sound_mode_raw = self._receiver.sound_mode_raw + self._sound_mode_list = self._receiver.sound_mode_list + else: + self._sound_mode = None + self._sound_mode_raw = None + self._sound_mode_list = None + + self._supported_features_base = SUPPORT_DENON + self._supported_features_base |= ( + self._sound_mode_support and SUPPORT_SELECT_SOUND_MODE + ) + + def update(self): + """Get the latest status information from device.""" + self._receiver.update() + self._name = self._receiver.name + self._muted = self._receiver.muted + self._volume = self._receiver.volume + self._current_source = self._receiver.input_func + self._source_list = self._receiver.input_func_list + self._state = self._receiver.state + self._power = self._receiver.power + self._media_image_url = self._receiver.image_url + self._title = self._receiver.title + self._artist = self._receiver.artist + self._album = self._receiver.album + self._band = self._receiver.band + self._frequency = self._receiver.frequency + self._station = self._receiver.station + if self._sound_mode_support: + self._sound_mode = self._receiver.sound_mode + self._sound_mode_raw = self._receiver.sound_mode_raw + + @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 is_volume_muted(self): + """Return boolean if volume is currently muted.""" + return self._muted + + @property + def volume_level(self): + """Volume level of the media player (0..1).""" + # Volume is sent in a format like -50.0. Minimum is -80.0, + # maximum is 18.0 + return (float(self._volume) + 80) / 100 + + @property + def source(self): + """Return the current input source.""" + return self._current_source + + @property + def source_list(self): + """Return a list of available input sources.""" + return self._source_list + + @property + def sound_mode(self): + """Return the current matched sound mode.""" + return self._sound_mode + + @property + def sound_mode_list(self): + """Return a list of available sound modes.""" + return self._sound_mode_list + + @property + def supported_features(self): + """Flag media player features that are supported.""" + if self._current_source in self._receiver.netaudio_func_list: + return self._supported_features_base | SUPPORT_MEDIA_MODES + return self._supported_features_base + + @property + def media_content_id(self): + """Content ID of current playing media.""" + return None + + @property + def media_content_type(self): + """Content type of current playing media.""" + if self._state == STATE_PLAYING or self._state == STATE_PAUSED: + return MEDIA_TYPE_MUSIC + return MEDIA_TYPE_CHANNEL + + @property + def media_duration(self): + """Duration of current playing media in seconds.""" + return None + + @property + def media_image_url(self): + """Image url of current playing media.""" + if self._current_source in self._receiver.playing_func_list: + return self._media_image_url + return None + + @property + def media_title(self): + """Title of current playing media.""" + if self._current_source not in self._receiver.playing_func_list: + return self._current_source + if self._title is not None: + return self._title + return self._frequency + + @property + def media_artist(self): + """Artist of current playing media, music track only.""" + if self._artist is not None: + return self._artist + return self._band + + @property + def media_album_name(self): + """Album name of current playing media, music track only.""" + if self._album is not None: + return self._album + return self._station + + @property + def media_album_artist(self): + """Album artist of current playing media, music track only.""" + return None + + @property + def media_track(self): + """Track number of current playing media, music track only.""" + return None + + @property + def media_series_title(self): + """Title of series of current playing media, TV show only.""" + return None + + @property + def media_season(self): + """Season of current playing media, TV show only.""" + return None + + @property + def media_episode(self): + """Episode of current playing media, TV show only.""" + return None + + @property + def device_state_attributes(self): + """Return device specific state attributes.""" + attributes = {} + if ( + self._sound_mode_raw is not None + and self._sound_mode_support + and self._power == "ON" + ): + attributes[ATTR_SOUND_MODE_RAW] = self._sound_mode_raw + return attributes + + def media_play_pause(self): + """Simulate play pause media player.""" + return self._receiver.toggle_play_pause() + + def media_previous_track(self): + """Send previous track command.""" + return self._receiver.previous_track() + + def media_next_track(self): + """Send next track command.""" + return self._receiver.next_track() + + def select_source(self, source): + """Select input source.""" + return self._receiver.set_input_func(source) + + def select_sound_mode(self, sound_mode): + """Select sound mode.""" + return self._receiver.set_sound_mode(sound_mode) + + def turn_on(self): + """Turn on media player.""" + if self._receiver.power_on(): + self._state = STATE_ON + + def turn_off(self): + """Turn off media player.""" + if self._receiver.power_off(): + self._state = STATE_OFF + + def volume_up(self): + """Volume up the media player.""" + return self._receiver.volume_up() + + def volume_down(self): + """Volume down media player.""" + return self._receiver.volume_down() + + def set_volume_level(self, volume): + """Set volume level, range 0..1.""" + # Volume has to be sent in a format like -50.0. Minimum is -80.0, + # maximum is 18.0 + volume_denon = float((volume * 100) - 80) + if volume_denon > 18: + volume_denon = float(18) + try: + if self._receiver.set_volume(volume_denon): + self._volume = volume_denon + except ValueError: + pass + + def mute_volume(self, mute): + """Send mute command.""" + return self._receiver.mute(mute) diff --git a/homeassistant/components/deutsche_bahn/__init__.py b/homeassistant/components/deutsche_bahn/__init__.py new file mode 100644 index 000000000..0b696174f --- /dev/null +++ b/homeassistant/components/deutsche_bahn/__init__.py @@ -0,0 +1 @@ +"""The deutsche_bahn component.""" diff --git a/homeassistant/components/deutsche_bahn/manifest.json b/homeassistant/components/deutsche_bahn/manifest.json new file mode 100644 index 000000000..9a2bf6660 --- /dev/null +++ b/homeassistant/components/deutsche_bahn/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "deutsche_bahn", + "name": "Deutsche bahn", + "documentation": "https://www.home-assistant.io/integrations/deutsche_bahn", + "requirements": [ + "schiene==0.23" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/deutsche_bahn/sensor.py b/homeassistant/components/deutsche_bahn/sensor.py new file mode 100644 index 000000000..204518b2c --- /dev/null +++ b/homeassistant/components/deutsche_bahn/sensor.py @@ -0,0 +1,121 @@ +"""Support for information about the German train system.""" +from datetime import timedelta +import logging + +import schiene +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +import homeassistant.util.dt as dt_util + +_LOGGER = logging.getLogger(__name__) + +CONF_DESTINATION = "to" +CONF_START = "from" +CONF_OFFSET = "offset" +DEFAULT_OFFSET = timedelta(minutes=0) +CONF_ONLY_DIRECT = "only_direct" +DEFAULT_ONLY_DIRECT = False + +ICON = "mdi:train" + +SCAN_INTERVAL = timedelta(minutes=2) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_DESTINATION): cv.string, + vol.Required(CONF_START): cv.string, + vol.Optional(CONF_OFFSET, default=DEFAULT_OFFSET): cv.time_period, + vol.Optional(CONF_ONLY_DIRECT, default=DEFAULT_ONLY_DIRECT): cv.boolean, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Deutsche Bahn Sensor.""" + start = config.get(CONF_START) + destination = config.get(CONF_DESTINATION) + offset = config.get(CONF_OFFSET) + only_direct = config.get(CONF_ONLY_DIRECT) + + add_entities([DeutscheBahnSensor(start, destination, offset, only_direct)], True) + + +class DeutscheBahnSensor(Entity): + """Implementation of a Deutsche Bahn sensor.""" + + def __init__(self, start, goal, offset, only_direct): + """Initialize the sensor.""" + self._name = f"{start} to {goal}" + self.data = SchieneData(start, goal, offset, only_direct) + self._state = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def icon(self): + """Return the icon for the frontend.""" + return ICON + + @property + def state(self): + """Return the departure time of the next train.""" + return self._state + + @property + def device_state_attributes(self): + """Return the state attributes.""" + connections = self.data.connections[0] + if len(self.data.connections) > 1: + connections["next"] = self.data.connections[1]["departure"] + if len(self.data.connections) > 2: + connections["next_on"] = self.data.connections[2]["departure"] + return connections + + def update(self): + """Get the latest delay from bahn.de and updates the state.""" + self.data.update() + self._state = self.data.connections[0].get("departure", "Unknown") + if self.data.connections[0].get("delay", 0) != 0: + self._state += " + {}".format(self.data.connections[0]["delay"]) + + +class SchieneData: + """Pull data from the bahn.de web page.""" + + def __init__(self, start, goal, offset, only_direct): + """Initialize the sensor.""" + + self.start = start + self.goal = goal + self.offset = offset + self.only_direct = only_direct + self.schiene = schiene.Schiene() + self.connections = [{}] + + def update(self): + """Update the connection data.""" + self.connections = self.schiene.connections( + self.start, + self.goal, + dt_util.as_local(dt_util.utcnow() + self.offset), + self.only_direct, + ) + + if not self.connections: + self.connections = [{}] + + for con in self.connections: + # Detail info is not useful. Having a more consistent interface + # simplifies usage of template sensors. + if "details" in con: + con.pop("details") + delay = con.get("delay", {"delay_departure": 0, "delay_arrival": 0}) + con["delay"] = delay["delay_departure"] + con["delay_arrival"] = delay["delay_arrival"] + con["ontime"] = con.get("ontime", False) diff --git a/homeassistant/components/device_automation/__init__.py b/homeassistant/components/device_automation/__init__.py new file mode 100644 index 000000000..872a4af6c --- /dev/null +++ b/homeassistant/components/device_automation/__init__.py @@ -0,0 +1,263 @@ +"""Helpers for device automations.""" +import asyncio +import logging +from types import ModuleType +from typing import Any, List, MutableMapping + +import voluptuous as vol +import voluptuous_serialize + +from homeassistant.components import websocket_api +from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_registry import async_entries_for_device +from homeassistant.loader import IntegrationNotFound, async_get_integration + +from .exceptions import InvalidDeviceAutomationConfig + +# mypy: allow-untyped-calls, allow-untyped-defs + +DOMAIN = "device_automation" + +_LOGGER = logging.getLogger(__name__) + + +TRIGGER_BASE_SCHEMA = vol.Schema( + { + vol.Required(CONF_PLATFORM): "device", + vol.Required(CONF_DOMAIN): str, + vol.Required(CONF_DEVICE_ID): str, + } +) + +TYPES = { + # platform name, get automations function, get capabilities function + "trigger": ( + "device_trigger", + "async_get_triggers", + "async_get_trigger_capabilities", + ), + "condition": ( + "device_condition", + "async_get_conditions", + "async_get_condition_capabilities", + ), + "action": ("device_action", "async_get_actions", "async_get_action_capabilities"), +} + + +async def async_setup(hass, config): + """Set up device automation.""" + hass.components.websocket_api.async_register_command( + websocket_device_automation_list_actions + ) + hass.components.websocket_api.async_register_command( + websocket_device_automation_list_conditions + ) + hass.components.websocket_api.async_register_command( + websocket_device_automation_list_triggers + ) + hass.components.websocket_api.async_register_command( + websocket_device_automation_get_action_capabilities + ) + hass.components.websocket_api.async_register_command( + websocket_device_automation_get_condition_capabilities + ) + hass.components.websocket_api.async_register_command( + websocket_device_automation_get_trigger_capabilities + ) + return True + + +async def async_get_device_automation_platform( + hass: HomeAssistant, domain: str, automation_type: str +) -> ModuleType: + """Load device automation platform for integration. + + Throws InvalidDeviceAutomationConfig if the integration is not found or does not support device automation. + """ + platform_name = TYPES[automation_type][0] + try: + integration = await async_get_integration(hass, domain) + platform = integration.get_platform(platform_name) + except IntegrationNotFound: + raise InvalidDeviceAutomationConfig(f"Integration '{domain}' not found") + except ImportError: + raise InvalidDeviceAutomationConfig( + f"Integration '{domain}' does not support device automation {automation_type}s" + ) + + return platform + + +async def _async_get_device_automations_from_domain( + hass, domain, automation_type, device_id +): + """List device automations.""" + try: + platform = await async_get_device_automation_platform( + hass, domain, automation_type + ) + except InvalidDeviceAutomationConfig: + return None + + function_name = TYPES[automation_type][1] + + return await getattr(platform, function_name)(hass, device_id) + + +async def _async_get_device_automations(hass, automation_type, device_id): + """List device automations.""" + device_registry, entity_registry = await asyncio.gather( + hass.helpers.device_registry.async_get_registry(), + hass.helpers.entity_registry.async_get_registry(), + ) + + domains = set() + automations: List[MutableMapping[str, Any]] = [] + device = device_registry.async_get(device_id) + for entry_id in device.config_entries: + config_entry = hass.config_entries.async_get_entry(entry_id) + domains.add(config_entry.domain) + + entity_entries = async_entries_for_device(entity_registry, device_id) + for entity_entry in entity_entries: + domains.add(entity_entry.domain) + + device_automations = await asyncio.gather( + *( + _async_get_device_automations_from_domain( + hass, domain, automation_type, device_id + ) + for domain in domains + ) + ) + for device_automation in device_automations: + if device_automation is not None: + automations.extend(device_automation) + + return automations + + +async def _async_get_device_automation_capabilities(hass, automation_type, automation): + """List device automations.""" + try: + platform = await async_get_device_automation_platform( + hass, automation[CONF_DOMAIN], automation_type + ) + except InvalidDeviceAutomationConfig: + return {} + + function_name = TYPES[automation_type][2] + + if not hasattr(platform, function_name): + # The device automation has no capabilities + return {} + + try: + capabilities = await getattr(platform, function_name)(hass, automation) + except InvalidDeviceAutomationConfig: + return {} + + capabilities = capabilities.copy() + + extra_fields = capabilities.get("extra_fields") + if extra_fields is None: + capabilities["extra_fields"] = [] + else: + capabilities["extra_fields"] = voluptuous_serialize.convert( + extra_fields, custom_serializer=cv.custom_serializer + ) + + return capabilities + + +@websocket_api.async_response +@websocket_api.websocket_command( + { + vol.Required("type"): "device_automation/action/list", + vol.Required("device_id"): str, + } +) +async def websocket_device_automation_list_actions(hass, connection, msg): + """Handle request for device actions.""" + device_id = msg["device_id"] + actions = await _async_get_device_automations(hass, "action", device_id) + connection.send_result(msg["id"], actions) + + +@websocket_api.async_response +@websocket_api.websocket_command( + { + vol.Required("type"): "device_automation/condition/list", + vol.Required("device_id"): str, + } +) +async def websocket_device_automation_list_conditions(hass, connection, msg): + """Handle request for device conditions.""" + device_id = msg["device_id"] + conditions = await _async_get_device_automations(hass, "condition", device_id) + connection.send_result(msg["id"], conditions) + + +@websocket_api.async_response +@websocket_api.websocket_command( + { + vol.Required("type"): "device_automation/trigger/list", + vol.Required("device_id"): str, + } +) +async def websocket_device_automation_list_triggers(hass, connection, msg): + """Handle request for device triggers.""" + device_id = msg["device_id"] + triggers = await _async_get_device_automations(hass, "trigger", device_id) + connection.send_result(msg["id"], triggers) + + +@websocket_api.async_response +@websocket_api.websocket_command( + { + vol.Required("type"): "device_automation/action/capabilities", + vol.Required("action"): dict, + } +) +async def websocket_device_automation_get_action_capabilities(hass, connection, msg): + """Handle request for device action capabilities.""" + action = msg["action"] + capabilities = await _async_get_device_automation_capabilities( + hass, "action", action + ) + connection.send_result(msg["id"], capabilities) + + +@websocket_api.async_response +@websocket_api.websocket_command( + { + vol.Required("type"): "device_automation/condition/capabilities", + vol.Required("condition"): dict, + } +) +async def websocket_device_automation_get_condition_capabilities(hass, connection, msg): + """Handle request for device condition capabilities.""" + condition = msg["condition"] + capabilities = await _async_get_device_automation_capabilities( + hass, "condition", condition + ) + connection.send_result(msg["id"], capabilities) + + +@websocket_api.async_response +@websocket_api.websocket_command( + { + vol.Required("type"): "device_automation/trigger/capabilities", + vol.Required("trigger"): dict, + } +) +async def websocket_device_automation_get_trigger_capabilities(hass, connection, msg): + """Handle request for device trigger capabilities.""" + trigger = msg["trigger"] + capabilities = await _async_get_device_automation_capabilities( + hass, "trigger", trigger + ) + connection.send_result(msg["id"], capabilities) diff --git a/homeassistant/components/device_automation/const.py b/homeassistant/components/device_automation/const.py new file mode 100644 index 000000000..40bfc4ca0 --- /dev/null +++ b/homeassistant/components/device_automation/const.py @@ -0,0 +1,8 @@ +"""Constants for device automations.""" +CONF_IS_OFF = "is_off" +CONF_IS_ON = "is_on" +CONF_TOGGLE = "toggle" +CONF_TURN_OFF = "turn_off" +CONF_TURN_ON = "turn_on" +CONF_TURNED_OFF = "turned_off" +CONF_TURNED_ON = "turned_on" diff --git a/homeassistant/components/device_automation/exceptions.py b/homeassistant/components/device_automation/exceptions.py new file mode 100644 index 000000000..2f7c0df01 --- /dev/null +++ b/homeassistant/components/device_automation/exceptions.py @@ -0,0 +1,6 @@ +"""Device automation exceptions.""" +from homeassistant.exceptions import HomeAssistantError + + +class InvalidDeviceAutomationConfig(HomeAssistantError): + """When device automation config is invalid.""" diff --git a/homeassistant/components/device_automation/manifest.json b/homeassistant/components/device_automation/manifest.json new file mode 100644 index 000000000..50b1f9d35 --- /dev/null +++ b/homeassistant/components/device_automation/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "device_automation", + "name": "Device automation", + "documentation": "https://www.home-assistant.io/integrations/device_automation", + "requirements": [], + "dependencies": [ + "webhook" + ], + "codeowners": [ + "@home-assistant/core" + ] +} diff --git a/homeassistant/components/device_automation/toggle_entity.py b/homeassistant/components/device_automation/toggle_entity.py new file mode 100644 index 000000000..7d84eb921 --- /dev/null +++ b/homeassistant/components/device_automation/toggle_entity.py @@ -0,0 +1,236 @@ +"""Device automation helpers for toggle entity.""" +from typing import Any, Dict, List + +import voluptuous as vol + +from homeassistant.components.automation import ( + AutomationActionType, + state as state_automation, +) +from homeassistant.components.device_automation.const import ( + CONF_IS_OFF, + CONF_IS_ON, + CONF_TOGGLE, + CONF_TURN_OFF, + CONF_TURN_ON, + CONF_TURNED_OFF, + CONF_TURNED_ON, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_CONDITION, + CONF_ENTITY_ID, + CONF_FOR, + CONF_PLATFORM, + CONF_TYPE, +) +from homeassistant.core import CALLBACK_TYPE, Context, HomeAssistant +from homeassistant.helpers import condition, config_validation as cv +from homeassistant.helpers.entity_registry import async_entries_for_device +from homeassistant.helpers.typing import ConfigType, TemplateVarsType + +from . import TRIGGER_BASE_SCHEMA + +# mypy: allow-untyped-calls, allow-untyped-defs + +ENTITY_ACTIONS = [ + { + # Turn entity off + CONF_TYPE: CONF_TURN_OFF + }, + { + # Turn entity on + CONF_TYPE: CONF_TURN_ON + }, + { + # Toggle entity + CONF_TYPE: CONF_TOGGLE + }, +] + +ENTITY_CONDITIONS = [ + { + # True when entity is turned off + CONF_CONDITION: "device", + CONF_TYPE: CONF_IS_OFF, + }, + { + # True when entity is turned on + CONF_CONDITION: "device", + CONF_TYPE: CONF_IS_ON, + }, +] + +ENTITY_TRIGGERS = [ + { + # Trigger when entity is turned off + CONF_PLATFORM: "device", + CONF_TYPE: CONF_TURNED_OFF, + }, + { + # Trigger when entity is turned on + CONF_PLATFORM: "device", + CONF_TYPE: CONF_TURNED_ON, + }, +] + +ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( + { + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TYPE): vol.In([CONF_TOGGLE, CONF_TURN_OFF, CONF_TURN_ON]), + } +) + +CONDITION_SCHEMA = cv.DEVICE_CONDITION_BASE_SCHEMA.extend( + { + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TYPE): vol.In([CONF_IS_OFF, CONF_IS_ON]), + vol.Optional(CONF_FOR): cv.positive_time_period_dict, + } +) + +TRIGGER_SCHEMA = TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TYPE): vol.In([CONF_TURNED_OFF, CONF_TURNED_ON]), + vol.Optional(CONF_FOR): cv.positive_time_period_dict, + } +) + + +async def async_call_action_from_config( + hass: HomeAssistant, + config: ConfigType, + variables: TemplateVarsType, + context: Context, + domain: str, +) -> None: + """Change state based on configuration.""" + action_type = config[CONF_TYPE] + if action_type == CONF_TURN_ON: + action = "turn_on" + elif action_type == CONF_TURN_OFF: + action = "turn_off" + else: + action = "toggle" + + service_data = {ATTR_ENTITY_ID: config[CONF_ENTITY_ID]} + + await hass.services.async_call( + domain, action, service_data, blocking=True, context=context + ) + + +def async_condition_from_config(config: ConfigType) -> condition.ConditionCheckerType: + """Evaluate state based on configuration.""" + condition_type = config[CONF_TYPE] + if condition_type == CONF_IS_ON: + stat = "on" + else: + stat = "off" + state_config = { + condition.CONF_CONDITION: "state", + condition.CONF_ENTITY_ID: config[CONF_ENTITY_ID], + condition.CONF_STATE: stat, + } + if CONF_FOR in config: + state_config[CONF_FOR] = config[CONF_FOR] + + return condition.state_from_config(state_config) + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: AutomationActionType, + automation_info: dict, +) -> CALLBACK_TYPE: + """Listen for state changes based on configuration.""" + trigger_type = config[CONF_TYPE] + if trigger_type == CONF_TURNED_ON: + from_state = "off" + to_state = "on" + else: + from_state = "on" + to_state = "off" + state_config = { + state_automation.CONF_PLATFORM: "state", + state_automation.CONF_ENTITY_ID: config[CONF_ENTITY_ID], + state_automation.CONF_FROM: from_state, + state_automation.CONF_TO: to_state, + } + if CONF_FOR in config: + state_config[CONF_FOR] = config[CONF_FOR] + + state_config = state_automation.TRIGGER_SCHEMA(state_config) + return await state_automation.async_attach_trigger( + hass, state_config, action, automation_info, platform_type="device" + ) + + +async def _async_get_automations( + hass: HomeAssistant, device_id: str, automation_templates: List[dict], domain: str +) -> List[dict]: + """List device automations.""" + automations: List[Dict[str, Any]] = [] + entity_registry = await hass.helpers.entity_registry.async_get_registry() + + entries = [ + entry + for entry in async_entries_for_device(entity_registry, device_id) + if entry.domain == domain + ] + + for entry in entries: + automations.extend( + ( + { + **template, + "device_id": device_id, + "entity_id": entry.entity_id, + "domain": domain, + } + for template in automation_templates + ) + ) + + return automations + + +async def async_get_actions( + hass: HomeAssistant, device_id: str, domain: str +) -> List[dict]: + """List device actions.""" + return await _async_get_automations(hass, device_id, ENTITY_ACTIONS, domain) + + +async def async_get_conditions( + hass: HomeAssistant, device_id: str, domain: str +) -> List[Dict[str, str]]: + """List device conditions.""" + return await _async_get_automations(hass, device_id, ENTITY_CONDITIONS, domain) + + +async def async_get_triggers( + hass: HomeAssistant, device_id: str, domain: str +) -> List[dict]: + """List device triggers.""" + return await _async_get_automations(hass, device_id, ENTITY_TRIGGERS, domain) + + +async def async_get_condition_capabilities(hass: HomeAssistant, config: dict) -> dict: + """List condition capabilities.""" + return { + "extra_fields": vol.Schema( + {vol.Optional(CONF_FOR): cv.positive_time_period_dict} + ) + } + + +async def async_get_trigger_capabilities(hass: HomeAssistant, config: dict) -> dict: + """List trigger capabilities.""" + return { + "extra_fields": vol.Schema( + {vol.Optional(CONF_FOR): cv.positive_time_period_dict} + ) + } diff --git a/homeassistant/components/device_sun_light_trigger.py b/homeassistant/components/device_sun_light_trigger.py deleted file mode 100644 index 641ade730..000000000 --- a/homeassistant/components/device_sun_light_trigger.py +++ /dev/null @@ -1,182 +0,0 @@ -""" -Provides functionality to turn on lights based on the states. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/device_sun_light_trigger/ -""" -import asyncio -import logging -from datetime import timedelta - -import voluptuous as vol - -from homeassistant.core import callback -import homeassistant.util.dt as dt_util -from homeassistant.const import STATE_HOME, STATE_NOT_HOME -from homeassistant.helpers.event import ( - async_track_point_in_utc_time, async_track_state_change) -from homeassistant.helpers.sun import is_up, get_astral_event_next -import homeassistant.helpers.config_validation as cv - -DOMAIN = 'device_sun_light_trigger' -DEPENDENCIES = ['light', 'device_tracker', 'group'] - -CONF_DEVICE_GROUP = 'device_group' -CONF_DISABLE_TURN_OFF = 'disable_turn_off' -CONF_LIGHT_GROUP = 'light_group' -CONF_LIGHT_PROFILE = 'light_profile' - -DEFAULT_DISABLE_TURN_OFF = False -DEFAULT_LIGHT_PROFILE = 'relax' - -LIGHT_TRANSITION_TIME = timedelta(minutes=15) - -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - vol.Optional(CONF_DEVICE_GROUP): cv.entity_id, - vol.Optional(CONF_DISABLE_TURN_OFF, default=DEFAULT_DISABLE_TURN_OFF): - cv.boolean, - vol.Optional(CONF_LIGHT_GROUP): cv.string, - vol.Optional(CONF_LIGHT_PROFILE, default=DEFAULT_LIGHT_PROFILE): - cv.string, - }), -}, extra=vol.ALLOW_EXTRA) - - -@asyncio.coroutine -def async_setup(hass, config): - """Set up the triggers to control lights based on device presence.""" - logger = logging.getLogger(__name__) - 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) - light_profile = conf.get(CONF_LIGHT_PROFILE) - device_group = conf.get( - CONF_DEVICE_GROUP, device_tracker.ENTITY_ID_ALL_DEVICES) - device_entity_ids = group.get_entity_ids( - 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(light_group, light.DOMAIN) - - if not light_ids: - logger.error("No lights found to turn on") - return False - - def calc_time_for_light_when_sunset(): - """Calculate the time when to start fading lights in when sun sets. - - Returns None if no next_setting data available. - - Async friendly. - """ - next_setting = get_astral_event_next(hass, 'sunset') - if not next_setting: - return None - return next_setting - LIGHT_TRANSITION_TIME * len(light_ids) - - def async_turn_on_before_sunset(light_id): - """Turn on lights.""" - if not device_tracker.is_on() or light.is_on(light_id): - return - light.async_turn_on(light_id, - transition=LIGHT_TRANSITION_TIME.seconds, - profile=light_profile) - - def async_turn_on_factory(light_id): - """Generate turn on callbacks as factory.""" - @callback - def async_turn_on_light(now): - """Turn on specific light.""" - async_turn_on_before_sunset(light_id) - - return async_turn_on_light - - # Track every time sun rises so we can schedule a time-based - # pre-sun set event - @callback - def schedule_light_turn_on(now): - """Turn on all the lights at the moment sun sets. - - We will schedule to have each light start after one another - and slowly transition in. - """ - start_point = calc_time_for_light_when_sunset() - if not start_point: - return - - for index, light_id in enumerate(light_ids): - async_track_point_in_utc_time( - hass, async_turn_on_factory(light_id), - start_point + index * LIGHT_TRANSITION_TIME) - - async_track_point_in_utc_time(hass, schedule_light_turn_on, - get_astral_event_next(hass, 'sunrise')) - - # If the sun is already above horizon schedule the time-based pre-sun set - # event. - if is_up(hass): - schedule_light_turn_on(None) - - @callback - def check_light_on_dev_state_change(entity, old_state, new_state): - """Handle tracked device state changes.""" - 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 - now = dt_util.utcnow() - start_point = calc_time_for_light_when_sunset() - - # Do we need lights? - if light_needed: - logger.info("Home coming event for %s. Turning lights on", entity) - 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? - # Check this by seeing if current time is later then the point - # in time when we would start putting the lights on. - elif (start_point and - start_point < now < get_astral_event_next(hass, 'sunset')): - - # Check for every light if it would be on if someone was home - # 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(light_id) - - else: - # If this light didn't happen to be turned on yet so - # will all the following then, break. - break - - async_track_state_change( - hass, device_entity_ids, check_light_on_dev_state_change, - STATE_NOT_HOME, STATE_HOME) - - if disable_turn_off: - return True - - @callback - def turn_off_lights_when_all_leave(entity, old_state, new_state): - """Handle device group state change.""" - 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(light_ids) - - async_track_state_change( - hass, device_group, turn_off_lights_when_all_leave, - STATE_HOME, STATE_NOT_HOME) - - return True diff --git a/homeassistant/components/device_sun_light_trigger/__init__.py b/homeassistant/components/device_sun_light_trigger/__init__.py new file mode 100644 index 000000000..64831cfac --- /dev/null +++ b/homeassistant/components/device_sun_light_trigger/__init__.py @@ -0,0 +1,225 @@ +"""Support to turn on lights based on the states.""" +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.components.light import ( + ATTR_PROFILE, + ATTR_TRANSITION, + DOMAIN as DOMAIN_LIGHT, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_HOME, + STATE_NOT_HOME, + SUN_EVENT_SUNRISE, + SUN_EVENT_SUNSET, +) +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import ( + async_track_point_in_utc_time, + async_track_state_change, +) +from homeassistant.helpers.sun import get_astral_event_next, is_up +import homeassistant.util.dt as dt_util + +DOMAIN = "device_sun_light_trigger" +CONF_DEVICE_GROUP = "device_group" +CONF_DISABLE_TURN_OFF = "disable_turn_off" +CONF_LIGHT_GROUP = "light_group" +CONF_LIGHT_PROFILE = "light_profile" + +DEFAULT_DISABLE_TURN_OFF = False +DEFAULT_LIGHT_PROFILE = "relax" + +LIGHT_TRANSITION_TIME = timedelta(minutes=15) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Optional(CONF_DEVICE_GROUP): cv.entity_id, + vol.Optional( + CONF_DISABLE_TURN_OFF, default=DEFAULT_DISABLE_TURN_OFF + ): cv.boolean, + vol.Optional(CONF_LIGHT_GROUP): cv.string, + vol.Optional( + CONF_LIGHT_PROFILE, default=DEFAULT_LIGHT_PROFILE + ): cv.string, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, config): + """Set up the triggers to control lights based on device presence.""" + logger = logging.getLogger(__name__) + device_tracker = hass.components.device_tracker + group = hass.components.group + light = hass.components.light + person = hass.components.person + conf = config[DOMAIN] + disable_turn_off = conf.get(CONF_DISABLE_TURN_OFF) + light_group = conf.get(CONF_LIGHT_GROUP, light.ENTITY_ID_ALL_LIGHTS) + light_profile = conf.get(CONF_LIGHT_PROFILE) + device_group = conf.get(CONF_DEVICE_GROUP, device_tracker.ENTITY_ID_ALL_DEVICES) + device_entity_ids = group.get_entity_ids(device_group, device_tracker.DOMAIN) + device_entity_ids.extend(group.get_entity_ids(device_group, person.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(light_group, light.DOMAIN) + + if not light_ids: + logger.error("No lights found to turn on") + return False + + def calc_time_for_light_when_sunset(): + """Calculate the time when to start fading lights in when sun sets. + + Returns None if no next_setting data available. + + Async friendly. + """ + next_setting = get_astral_event_next(hass, SUN_EVENT_SUNSET) + if not next_setting: + return None + return next_setting - LIGHT_TRANSITION_TIME * len(light_ids) + + def async_turn_on_before_sunset(light_id): + """Turn on lights.""" + if not device_tracker.is_on() or light.is_on(light_id): + return + hass.async_create_task( + hass.services.async_call( + DOMAIN_LIGHT, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: light_id, + ATTR_TRANSITION: LIGHT_TRANSITION_TIME.seconds, + ATTR_PROFILE: light_profile, + }, + ) + ) + + def async_turn_on_factory(light_id): + """Generate turn on callbacks as factory.""" + + @callback + def async_turn_on_light(now): + """Turn on specific light.""" + async_turn_on_before_sunset(light_id) + + return async_turn_on_light + + # Track every time sun rises so we can schedule a time-based + # pre-sun set event + @callback + def schedule_light_turn_on(now): + """Turn on all the lights at the moment sun sets. + + We will schedule to have each light start after one another + and slowly transition in. + """ + start_point = calc_time_for_light_when_sunset() + if not start_point: + return + + for index, light_id in enumerate(light_ids): + async_track_point_in_utc_time( + hass, + async_turn_on_factory(light_id), + start_point + index * LIGHT_TRANSITION_TIME, + ) + + async_track_point_in_utc_time( + hass, schedule_light_turn_on, get_astral_event_next(hass, SUN_EVENT_SUNRISE) + ) + + # If the sun is already above horizon schedule the time-based pre-sun set + # event. + if is_up(hass): + schedule_light_turn_on(None) + + @callback + def check_light_on_dev_state_change(entity, old_state, new_state): + """Handle tracked device state changes.""" + 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 + now = dt_util.utcnow() + start_point = calc_time_for_light_when_sunset() + + # Do we need lights? + if light_needed: + logger.info("Home coming event for %s. Turning lights on", entity) + hass.async_create_task( + hass.services.async_call( + DOMAIN_LIGHT, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: light_ids, ATTR_PROFILE: light_profile}, + ) + ) + + # Are we in the time span were we would turn on the lights + # if someone would be home? + # Check this by seeing if current time is later then the point + # in time when we would start putting the lights on. + elif start_point and start_point < now < get_astral_event_next( + hass, SUN_EVENT_SUNSET + ): + + # Check for every light if it would be on if someone was home + # 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: + hass.async_create_task( + hass.services.async_call( + DOMAIN_LIGHT, SERVICE_TURN_ON, {ATTR_ENTITY_ID: light_id} + ) + ) + + else: + # If this light didn't happen to be turned on yet so + # will all the following then, break. + break + + async_track_state_change( + hass, + device_entity_ids, + check_light_on_dev_state_change, + STATE_NOT_HOME, + STATE_HOME, + ) + + if disable_turn_off: + return True + + @callback + def turn_off_lights_when_all_leave(entity, old_state, new_state): + """Handle device group state change.""" + if not group.is_on(light_group): + return + + logger.info("Everyone has left but there are lights on. Turning them off") + hass.async_create_task( + hass.services.async_call( + DOMAIN_LIGHT, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: light_ids} + ) + ) + + async_track_state_change( + hass, device_group, turn_off_lights_when_all_leave, STATE_HOME, STATE_NOT_HOME + ) + + return True diff --git a/homeassistant/components/device_sun_light_trigger/manifest.json b/homeassistant/components/device_sun_light_trigger/manifest.json new file mode 100644 index 000000000..3bea097b8 --- /dev/null +++ b/homeassistant/components/device_sun_light_trigger/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "device_sun_light_trigger", + "name": "Device sun light trigger", + "documentation": "https://www.home-assistant.io/integrations/device_sun_light_trigger", + "requirements": [], + "dependencies": [ + "device_tracker", + "group", + "light", + "person" + ], + "codeowners": [] +} diff --git a/homeassistant/components/device_sun_light_trigger/services.yaml b/homeassistant/components/device_sun_light_trigger/services.yaml new file mode 100644 index 000000000..e69de29bb diff --git a/homeassistant/components/device_tracker/.translations/bg.json b/homeassistant/components/device_tracker/.translations/bg.json new file mode 100644 index 000000000..68affa5af --- /dev/null +++ b/homeassistant/components/device_tracker/.translations/bg.json @@ -0,0 +1,8 @@ +{ + "device_automation": { + "condition_type": { + "is_home": "{entity_name} \u0435 \u0443 \u0434\u043e\u043c\u0430", + "is_not_home": "{entity_name} \u043d\u0435 \u0435 \u0443 \u0434\u043e\u043c\u0430" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/.translations/ca.json b/homeassistant/components/device_tracker/.translations/ca.json new file mode 100644 index 000000000..3a9584155 --- /dev/null +++ b/homeassistant/components/device_tracker/.translations/ca.json @@ -0,0 +1,8 @@ +{ + "device_automation": { + "condition_type": { + "is_home": "{entity_name} \u00e9s a casa", + "is_not_home": "{entity_name} no \u00e9s a casa" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/.translations/cs.json b/homeassistant/components/device_tracker/.translations/cs.json new file mode 100644 index 000000000..7e82f1a34 --- /dev/null +++ b/homeassistant/components/device_tracker/.translations/cs.json @@ -0,0 +1,8 @@ +{ + "device_automation": { + "condition_type": { + "is_home": "{entity_name} je doma", + "is_not_home": "{entity_name} nen\u00ed doma" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/.translations/de.json b/homeassistant/components/device_tracker/.translations/de.json new file mode 100644 index 000000000..90a81db6b --- /dev/null +++ b/homeassistant/components/device_tracker/.translations/de.json @@ -0,0 +1,8 @@ +{ + "device_automation": { + "condition_type": { + "is_home": "{entity_name} ist Zuhause", + "is_not_home": "{entity_name} ist nicht zu Hause" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/.translations/en.json b/homeassistant/components/device_tracker/.translations/en.json new file mode 100644 index 000000000..102260847 --- /dev/null +++ b/homeassistant/components/device_tracker/.translations/en.json @@ -0,0 +1,8 @@ +{ + "device_automation": { + "condition_type": { + "is_home": "{entity_name} is home", + "is_not_home": "{entity_name} is not home" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/.translations/es.json b/homeassistant/components/device_tracker/.translations/es.json new file mode 100644 index 000000000..cfbf7bcfe --- /dev/null +++ b/homeassistant/components/device_tracker/.translations/es.json @@ -0,0 +1,8 @@ +{ + "device_automation": { + "condition_type": { + "is_home": "{entity_name} est\u00e1 en casa", + "is_not_home": "{entity_name} no est\u00e1 en casa" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/.translations/fr.json b/homeassistant/components/device_tracker/.translations/fr.json new file mode 100644 index 000000000..4c59d5ea1 --- /dev/null +++ b/homeassistant/components/device_tracker/.translations/fr.json @@ -0,0 +1,8 @@ +{ + "device_automation": { + "condition_type": { + "is_home": "{entity_name} est \u00e0 la maison", + "is_not_home": "{entity_name} n'est pas \u00e0 la maison" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/.translations/it.json b/homeassistant/components/device_tracker/.translations/it.json new file mode 100644 index 000000000..112afc668 --- /dev/null +++ b/homeassistant/components/device_tracker/.translations/it.json @@ -0,0 +1,8 @@ +{ + "device_automation": { + "condition_type": { + "is_home": "{entity_name} \u00e8 in casa", + "is_not_home": "{entity_name} non \u00e8 in casa" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/.translations/ko.json b/homeassistant/components/device_tracker/.translations/ko.json new file mode 100644 index 000000000..92137ea27 --- /dev/null +++ b/homeassistant/components/device_tracker/.translations/ko.json @@ -0,0 +1,8 @@ +{ + "device_automation": { + "condition_type": { + "is_home": "{entity_name} \uc774(\uac00) \uc9d1\uc5d0 \uc788\uc2b5\ub2c8\ub2e4", + "is_not_home": "{entity_name} \uc774(\uac00) \uc678\ucd9c\uc911\uc785\ub2c8\ub2e4" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/.translations/lb.json b/homeassistant/components/device_tracker/.translations/lb.json new file mode 100644 index 000000000..2c49f6926 --- /dev/null +++ b/homeassistant/components/device_tracker/.translations/lb.json @@ -0,0 +1,8 @@ +{ + "device_automation": { + "condition_type": { + "is_home": "{entity_name} ass doheem", + "is_not_home": "{entity_name} ass net doheem" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/.translations/nl.json b/homeassistant/components/device_tracker/.translations/nl.json new file mode 100644 index 000000000..31ab788f1 --- /dev/null +++ b/homeassistant/components/device_tracker/.translations/nl.json @@ -0,0 +1,8 @@ +{ + "device_automation": { + "condition_type": { + "is_home": "{entity_name} is thuis", + "is_not_home": "{entity_name} is niet thuis" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/.translations/no.json b/homeassistant/components/device_tracker/.translations/no.json new file mode 100644 index 000000000..d714b5b7d --- /dev/null +++ b/homeassistant/components/device_tracker/.translations/no.json @@ -0,0 +1,8 @@ +{ + "device_automation": { + "condition_type": { + "is_home": "{entity_name} er hjemme", + "is_not_home": "{entity_name} er ikke hjemme" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/.translations/pl.json b/homeassistant/components/device_tracker/.translations/pl.json new file mode 100644 index 000000000..3930031ad --- /dev/null +++ b/homeassistant/components/device_tracker/.translations/pl.json @@ -0,0 +1,8 @@ +{ + "device_automation": { + "condition_type": { + "is_home": "urz\u0105dzenie {entity_name} jest w domu", + "is_not_home": "urz\u0105dzenie {entity_name} jest poza domem" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/.translations/pt.json b/homeassistant/components/device_tracker/.translations/pt.json new file mode 100644 index 000000000..8a8f66218 --- /dev/null +++ b/homeassistant/components/device_tracker/.translations/pt.json @@ -0,0 +1,8 @@ +{ + "device_automation": { + "condition_type": { + "is_home": "{entity_name} est\u00e1 em casa", + "is_not_home": "{entity_name} n\u00e3o est\u00e1 em casa" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/.translations/ru.json b/homeassistant/components/device_tracker/.translations/ru.json new file mode 100644 index 000000000..58767361f --- /dev/null +++ b/homeassistant/components/device_tracker/.translations/ru.json @@ -0,0 +1,8 @@ +{ + "device_automation": { + "condition_type": { + "is_home": "{entity_name} \u0434\u043e\u043c\u0430", + "is_not_home": "{entity_name} \u043d\u0435 \u0434\u043e\u043c\u0430" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/.translations/sl.json b/homeassistant/components/device_tracker/.translations/sl.json new file mode 100644 index 000000000..11d876883 --- /dev/null +++ b/homeassistant/components/device_tracker/.translations/sl.json @@ -0,0 +1,8 @@ +{ + "device_automation": { + "condition_type": { + "is_home": "{entity_name} je doma", + "is_not_home": "{entity_name} ni doma" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/.translations/zh-Hant.json b/homeassistant/components/device_tracker/.translations/zh-Hant.json new file mode 100644 index 000000000..456e09ebf --- /dev/null +++ b/homeassistant/components/device_tracker/.translations/zh-Hant.json @@ -0,0 +1,8 @@ +{ + "device_automation": { + "condition_type": { + "is_home": "{entity_name} \u5728\u5bb6", + "is_not_home": "{entity_name} \u4e0d\u5728\u5bb6" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index 408672a97..a160e580c 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -1,115 +1,99 @@ -""" -Provide functionality to keep track of devices. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/device_tracker/ -""" +"""Provide functionality to keep track of devices.""" import asyncio -from datetime import timedelta -import logging -from typing import Any, List, Sequence, Callable import voluptuous as vol -from homeassistant.setup import async_prepare_setup_platform -from homeassistant.core import callback -from homeassistant.loader import bind_hass -from homeassistant.components import group, zone -from homeassistant.components.zone.zone import async_active_zone -from homeassistant.config import load_yaml_config_file, async_log_exception -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers 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 +from homeassistant.components import group +from homeassistant.const import ATTR_GPS_ACCURACY, STATE_HOME +from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv -from homeassistant import util -from homeassistant.util.async_ import run_coroutine_threadsafe -import homeassistant.util.dt as dt_util -from homeassistant.util.yaml import dump - from homeassistant.helpers.event import async_track_utc_time_change -from homeassistant.const import ( - ATTR_GPS_ACCURACY, ATTR_LATITUDE, ATTR_LONGITUDE, CONF_NAME, CONF_MAC, - DEVICE_DEFAULT_NAME, STATE_HOME, STATE_NOT_HOME, ATTR_ENTITY_ID, - CONF_ICON, ATTR_ICON, ATTR_NAME) +from homeassistant.helpers.typing import ConfigType, GPSType, HomeAssistantType +from homeassistant.loader import bind_hass -_LOGGER = logging.getLogger(__name__) +from . import legacy, setup +from .config_entry import ( # noqa: F401 pylint: disable=unused-import + async_setup_entry, + async_unload_entry, +) +from .const import ( + ATTR_ATTRIBUTES, + ATTR_BATTERY, + ATTR_CONSIDER_HOME, + ATTR_DEV_ID, + ATTR_GPS, + ATTR_HOST_NAME, + ATTR_LOCATION_NAME, + ATTR_MAC, + ATTR_SOURCE_TYPE, + CONF_AWAY_HIDE, + CONF_CONSIDER_HOME, + CONF_NEW_DEVICE_DEFAULTS, + CONF_SCAN_INTERVAL, + CONF_TRACK_NEW, + DEFAULT_AWAY_HIDE, + DEFAULT_CONSIDER_HOME, + DEFAULT_TRACK_NEW, + DOMAIN, + PLATFORM_TYPE_LEGACY, + SOURCE_TYPE_BLUETOOTH, + SOURCE_TYPE_BLUETOOTH_LE, + SOURCE_TYPE_GPS, + SOURCE_TYPE_ROUTER, +) +from .legacy import DeviceScanner # noqa: F401 pylint: disable=unused-import -DOMAIN = 'device_tracker' -DEPENDENCIES = ['zone', 'group'] +ENTITY_ID_ALL_DEVICES = group.ENTITY_ID_FORMAT.format("all_devices") -GROUP_NAME_ALL_DEVICES = 'all devices' -ENTITY_ID_ALL_DEVICES = group.ENTITY_ID_FORMAT.format('all_devices') +SERVICE_SEE = "see" -ENTITY_ID_FORMAT = DOMAIN + '.{}' +SOURCE_TYPES = ( + SOURCE_TYPE_GPS, + SOURCE_TYPE_ROUTER, + SOURCE_TYPE_BLUETOOTH, + SOURCE_TYPE_BLUETOOTH_LE, +) -YAML_DEVICES = 'known_devices.yaml' - -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) - -CONF_SCAN_INTERVAL = 'interval_seconds' -DEFAULT_SCAN_INTERVAL = timedelta(seconds=12) - -CONF_AWAY_HIDE = 'hide_if_away' -DEFAULT_AWAY_HIDE = False - -EVENT_NEW_DEVICE = 'device_tracker_new_device' - -SERVICE_SEE = 'see' - -ATTR_ATTRIBUTES = 'attributes' -ATTR_BATTERY = 'battery' -ATTR_DEV_ID = 'dev_id' -ATTR_GPS = 'gps' -ATTR_HOST_NAME = 'host_name' -ATTR_LOCATION_NAME = 'location_name' -ATTR_MAC = 'mac' -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): cv.boolean, - vol.Optional(CONF_CONSIDER_HOME, - default=DEFAULT_CONSIDER_HOME): vol.All( - cv.time_period, cv.positive_timedelta), - vol.Optional(CONF_NEW_DEVICE_DEFAULTS, - default={}): NEW_DEVICE_DEFAULTS_SCHEMA -}) -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, - })) +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): cv.boolean, + vol.Optional(CONF_CONSIDER_HOME, default=DEFAULT_CONSIDER_HOME): vol.All( + cv.time_period, cv.positive_timedelta + ), + vol.Optional(CONF_NEW_DEVICE_DEFAULTS, default={}): NEW_DEVICE_DEFAULTS_SCHEMA, + } +) +PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE.extend(PLATFORM_SCHEMA.schema) +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 @@ -120,632 +104,80 @@ def is_on(hass: HomeAssistantType, entity_id: str = None): 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: int = 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), - (ATTR_DEV_ID, dev_id), - (ATTR_HOST_NAME, host_name), - (ATTR_LOCATION_NAME, location_name), - (ATTR_GPS, gps), - (ATTR_GPS_ACCURACY, gps_accuracy), - (ATTR_BATTERY, battery)) if value is not None} + data = { + key: value + for key, value in ( + (ATTR_MAC, mac), + (ATTR_DEV_ID, dev_id), + (ATTR_HOST_NAME, host_name), + (ATTR_LOCATION_NAME, location_name), + (ATTR_GPS, gps), + (ATTR_GPS_ACCURACY, gps_accuracy), + (ATTR_BATTERY, battery), + ) + if value is not None + } if attributes: data[ATTR_ATTRIBUTES] = attributes hass.services.call(DOMAIN, SERVICE_SEE, data) -@asyncio.coroutine -def async_setup(hass: HomeAssistantType, config: ConfigType): +async def async_setup(hass: HomeAssistantType, config: ConfigType): """Set up the device tracker.""" - yaml_path = hass.config.path(YAML_DEVICES) + tracker = await legacy.get_tracker(hass, config) - conf = config.get(DOMAIN, []) - conf = conf[0] if conf else {} - consider_home = conf.get(CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME) + legacy_platforms = await setup.async_extract_config(hass, config) - 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) + setup_tasks = [ + legacy_platform.async_setup_legacy(hass, tracker) + for legacy_platform in legacy_platforms + ] - devices = yield from async_load_config(yaml_path, hass, consider_home) - tracker = DeviceTracker( - hass, consider_home, track_new, defaults, devices) - - @asyncio.coroutine - def async_setup_platform(p_type, p_config, disc_info=None): - """Set up a device tracker platform.""" - platform = yield from async_prepare_setup_platform( - hass, config, DOMAIN, p_type) - if platform is None: - return - - _LOGGER.info("Setting up %s.%s", DOMAIN, p_type) - try: - scanner = None - setup = None - if hasattr(platform, 'async_get_scanner'): - scanner = yield from platform.async_get_scanner( - hass, {DOMAIN: p_config}) - elif hasattr(platform, 'get_scanner'): - scanner = yield from hass.async_add_job( - platform.get_scanner, hass, {DOMAIN: p_config}) - elif hasattr(platform, 'async_setup_scanner'): - setup = yield from platform.async_setup_scanner( - hass, p_config, tracker.async_see, disc_info) - elif hasattr(platform, 'setup_scanner'): - setup = yield from hass.async_add_job( - platform.setup_scanner, hass, p_config, tracker.see, - disc_info) - else: - raise HomeAssistantError("Invalid device_tracker platform.") - - if scanner: - async_setup_scanner_platform( - hass, p_config, scanner, tracker.async_see, p_type) - return - - if not setup: - _LOGGER.error("Error setting up platform %s", p_type) - return - - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Error setting up platform %s", p_type) - - setup_tasks = [async_setup_platform(p_type, p_config) for p_type, p_config - in config_per_platform(config, DOMAIN)] if setup_tasks: - yield from asyncio.wait(setup_tasks, loop=hass.loop) + await asyncio.wait(setup_tasks) tracker.async_setup_group() - @asyncio.coroutine - def async_platform_discovered(platform, info): + async def async_platform_discovered(p_type, info): """Load a platform.""" - yield from async_setup_platform(platform, {}, disc_info=info) + platform = await setup.async_create_platform_type(hass, config, p_type, {}) + + if platform is None or platform.type != PLATFORM_TYPE_LEGACY: + return + + await platform.async_setup_legacy(hass, tracker, info) discovery.async_listen_platform(hass, DOMAIN, async_platform_discovered) # Clean up stale devices async_track_utc_time_change( - hass, tracker.async_update_stale, second=range(0, 60, 5)) + hass, tracker.async_update_stale, second=range(0, 60, 5) + ) - @asyncio.coroutine - def async_see_service(call): + async def async_see_service(call): """Service to see a device.""" # 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) + data.pop("hostname", None) + data.pop("battery_status", None) + await tracker.async_see(**data) hass.services.async_register( - DOMAIN, SERVICE_SEE, async_see_service, SERVICE_SEE_PAYLOAD_SCHEMA) + DOMAIN, SERVICE_SEE, async_see_service, SERVICE_SEE_PAYLOAD_SCHEMA + ) # restore - yield from tracker.async_setup_tracked_device() + await tracker.async_setup_tracked_device() return True - - -class DeviceTracker: - """Representation of a device tracker.""" - - def __init__(self, hass: HomeAssistantType, consider_home: timedelta, - 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 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) - - for dev in devices: - if self.devices[dev.dev_id] is not dev: - _LOGGER.warning('Duplicate device IDs detected %s', dev.dev_id) - if dev.mac and self.mac_to_dev[dev.mac] is not dev: - _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: 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, 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: 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. - """ - if mac is None and dev_id is None: - raise HomeAssistantError('Neither mac or device id passed in') - elif mac is not None: - mac = str(mac).upper() - device = self.mac_to_dev.get(mac) - if not device: - dev_id = util.slugify(host_name or '') or util.slugify(mac) - else: - dev_id = cv.slug(str(dev_id).lower()) - device = self.devices.get(dev_id) - - if device: - yield from device.async_seen( - host_name, location_name, gps, gps_accuracy, battery, - attributes, source_type, consider_home) - if device.track: - yield from device.async_update_ha_state() - return - - # If no device can be found, create it - dev_id = util.ensure_unique_string(dev_id, self.devices.keys()) - device = Device( - self.hass, consider_home or self.consider_home, self.track_new, - dev_id, mac, (host_name or dev_id).replace('_', ' '), - 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 - - yield from device.async_seen( - host_name, location_name, gps, gps_accuracy, battery, attributes, - source_type) - - if device.track: - yield from device.async_update_ha_state() - - # During init, we ignore the group - if self.group and self.track_new: - self.group.async_set_group( - util.slugify(GROUP_NAME_ALL_DEVICES), visible=False, - name=GROUP_NAME_ALL_DEVICES, add=[device.entity_id]) - - 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_create_task( - self.async_update_config( - self.hass.config.path(YAML_DEVICES), dev_id, device) - ) - - async def async_update_config(self, path, dev_id, device): - """Add device to YAML configuration file. - - This method is a coroutine. - """ - async with self._is_updating: - await self.hass.async_add_executor_job( - update_config, self.hass.config.path(YAML_DEVICES), - dev_id, device) - - @callback - def async_setup_group(self): - """Initialize group for all tracked devices. - - This method must be run in the event loop. - """ - entity_ids = [dev.entity_id for dev in self.devices.values() - if dev.track] - - self.group = self.hass.components.group - self.group.async_set_group( - util.slugify(GROUP_NAME_ALL_DEVICES), visible=False, - name=GROUP_NAME_ALL_DEVICES, entity_ids=entity_ids) - - @callback - def async_update_stale(self, now: dt_util.dt.datetime): - """Update stale devices. - - This method must be run in the event loop. - """ - for device in self.devices.values(): - if (device.track and device.last_update_home) and \ - device.stale(now): - self.hass.async_add_job(device.async_update_ha_state(True)) - - @asyncio.coroutine - def async_setup_tracked_device(self): - """Set up all not exists tracked devices. - - This method is a coroutine. - """ - @asyncio.coroutine - def async_init_single_device(dev): - """Init a single device_tracker entity.""" - yield from dev.async_added_to_hass() - yield from dev.async_update_ha_state() - - tasks = [] - for device in self.devices.values(): - if device.track and not device.last_seen: - tasks.append(self.hass.async_add_job( - async_init_single_device(device))) - - if tasks: - yield from asyncio.wait(tasks, loop=self.hass.loop) - - -class Device(Entity): - """Represent a tracked device.""" - - host_name = None # type: str - location_name = None # type: str - gps = None # type: GPSType - gps_accuracy = 0 # type: int - last_seen = None # type: dt_util.dt.datetime - consider_home = None # type: dt_util.dt.timedelta - battery = None # type: int - attributes = None # type: dict - icon = None # type: str - - # Track if the last update of this device was HOME. - last_update_home = False - _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) -> None: - """Initialize a device.""" - self.hass = hass - self.entity_id = ENTITY_ID_FORMAT.format(dev_id) - - # Timedelta object how long we consider a device home if it is not - # detected anymore. - self.consider_home = consider_home - - # Device ID - self.dev_id = dev_id - self.mac = mac - - # If we should track this device - self.track = track - - # Configured name - self.config_name = name - - # Configured picture - if gravatar is not None: - self.config_picture = get_gravatar_for_email(gravatar) - else: - self.config_picture = picture - - self.icon = icon - - self.away_hide = hide_if_away - - self.source_type = None - - self._attributes = {} - - @property - def name(self): - """Return the name of the entity.""" - return self.config_name or self.host_name or DEVICE_DEFAULT_NAME - - @property - def state(self): - """Return the state of the device.""" - return self._state - - @property - def entity_picture(self): - """Return the picture of the device.""" - return self.config_picture - - @property - def state_attributes(self): - """Return the device state attributes.""" - attr = { - ATTR_SOURCE_TYPE: self.source_type - } - - if self.gps: - attr[ATTR_LATITUDE] = self.gps[0] - attr[ATTR_LONGITUDE] = self.gps[1] - attr[ATTR_GPS_ACCURACY] = self.gps_accuracy - - if self.battery: - attr[ATTR_BATTERY] = self.battery - - return attr - - @property - def device_state_attributes(self): - """Return device state attributes.""" - return self._attributes - - @property - def hidden(self): - """If device should be hidden.""" - 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: 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 - if attributes: - self._attributes.update(attributes) - - self.gps = None - - if gps is not None: - try: - self.gps = float(gps[0]), float(gps[1]) - self.gps_accuracy = gps_accuracy or 0 - except (ValueError, TypeError, IndexError): - self.gps = None - self.gps_accuracy = 0 - _LOGGER.warning( - "Could not parse gps value for %s: %s", self.dev_id, gps) - - # pylint: disable=not-an-iterable - yield from self.async_update() - - def stale(self, now: dt_util.dt.datetime = None): - """Return if device state is stale. - - Async friendly. - """ - return self.last_seen and \ - (now or dt_util.utcnow()) - self.last_seen > self.consider_home - - @asyncio.coroutine - def async_update(self): - """Update state of entity. - - This method is a coroutine. - """ - if not self.last_seen: - return - if self.location_name: - self._state = self.location_name - elif self.gps is not None and self.source_type == SOURCE_TYPE_GPS: - 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 - elif zone_state.entity_id == zone.ENTITY_ID_HOME: - self._state = STATE_HOME - else: - self._state = zone_state.name - elif self.stale(): - self._state = STATE_NOT_HOME - self.gps = None - self.last_update_home = False - else: - self._state = STATE_HOME - self.last_update_home = True - - @asyncio.coroutine - def async_added_to_hass(self): - """Add an entity.""" - state = yield from async_get_last_state(self.hass, self.entity_id) - if not state: - return - self._state = state.state - - for attr, var in ( - (ATTR_SOURCE_TYPE, 'source_type'), - (ATTR_GPS_ACCURACY, 'gps_accuracy'), - (ATTR_BATTERY, 'battery'), - ): - if attr in state.attributes: - setattr(self, var, state.attributes[attr]) - - if ATTR_LONGITUDE in state.attributes: - self.gps = (state.attributes[ATTR_LATITUDE], - state.attributes[ATTR_LONGITUDE]) - - -class DeviceScanner: - """Device scanner object.""" - - hass = None # type: HomeAssistantType - - def scan_devices(self) -> List[str]: - """Scan for devices.""" - raise NotImplementedError() - - def async_scan_devices(self) -> Any: - """Scan for devices. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(self.scan_devices) - - def get_device_name(self, device: str) -> str: - """Get the name of a device.""" - raise NotImplementedError() - - 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, device) - - def get_extra_attributes(self, device: str) -> dict: - """Get the extra attributes of a device.""" - raise NotImplementedError() - - def async_get_extra_attributes(self, device: str) -> Any: - """Get the extra attributes of a device. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(self.get_extra_attributes, device) - - -def load_config(path: str, hass: HomeAssistantType, consider_home: timedelta): - """Load devices from YAML configuration file.""" - return run_coroutine_threadsafe( - async_load_config(path, hass, consider_home), hass.loop).result() - - -@asyncio.coroutine -def async_load_config(path: str, hass: HomeAssistantType, - consider_home: timedelta): - """Load devices from YAML configuration file. - - This method is a coroutine. - """ - dev_schema = vol.Schema({ - vol.Required(CONF_NAME): cv.string, - 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)), - vol.Optional(CONF_AWAY_HIDE, default=DEFAULT_AWAY_HIDE): cv.boolean, - vol.Optional('gravatar', default=None): vol.Any(None, cv.string), - vol.Optional('picture', default=None): vol.Any(None, cv.string), - vol.Optional(CONF_CONSIDER_HOME, default=consider_home): vol.All( - cv.time_period, cv.positive_timedelta), - }) - try: - result = [] - try: - devices = yield from hass.async_add_job( - load_yaml_config_file, path) - except HomeAssistantError as err: - _LOGGER.error("Unable to load %s: %s", path, str(err)) - 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) - except vol.Invalid as exp: - async_log_exception(exp, dev_id, devices, hass) - else: - result.append(Device(hass, **device)) - return result - except (HomeAssistantError, FileNotFoundError): - # When YAML file could not be loaded/did not contain a dict - return [] - - -@callback -def async_setup_scanner_platform(hass: HomeAssistantType, config: ConfigType, - scanner: Any, async_see_device: Callable, - platform: str): - """Set up the connect scanner-based platform to device tracker. - - This method must be run in the event loop. - """ - interval = config.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - update_lock = asyncio.Lock(loop=hass.loop) - scanner.hass = hass - - # Initial scan of each mac we also tell about host name for config - seen = set() # type: Any - - async def async_device_tracker_scan(now: dt_util.dt.datetime): - """Handle interval matches.""" - if update_lock.locked(): - _LOGGER.warning( - "Updating device list from %s took longer than the scheduled " - "scan interval %s", platform, interval) - return - - async with update_lock: - found_devices = await scanner.async_scan_devices() - - for mac in found_devices: - if mac in seen: - host_name = None - else: - host_name = await scanner.async_get_device_name(mac) - seen.add(mac) - - try: - extra_attributes = (await - scanner.async_get_extra_attributes(mac)) - except NotImplementedError: - extra_attributes = dict() - - kwargs = { - 'mac': mac, - 'host_name': host_name, - 'source_type': SOURCE_TYPE_ROUTER, - 'attributes': { - 'scanner': scanner.__class__.__name__, - **extra_attributes - } - } - - zone_home = hass.states.get(zone.ENTITY_ID_HOME) - if zone_home: - kwargs['gps'] = [zone_home.attributes[ATTR_LATITUDE], - zone_home.attributes[ATTR_LONGITUDE]] - kwargs['gps_accuracy'] = 0 - - hass.async_add_job(async_see_device(**kwargs)) - - async_track_time_interval(hass, async_device_tracker_scan, interval) - hass.async_add_job(async_device_tracker_scan(None)) - - -def update_config(path: str, dev_id: str, device: Device): - """Add device to YAML configuration file.""" - with open(path, 'a') as out: - device = {device.dev_id: { - ATTR_NAME: device.name, - ATTR_MAC: device.mac, - ATTR_ICON: device.icon, - 'picture': device.config_picture, - 'track': device.track, - CONF_AWAY_HIDE: device.away_hide, - }} - out.write('\n') - out.write(dump(device)) - - -def get_gravatar_for_email(email: str): - """Return an 80px Gravatar for the given email address. - - Async friendly. - """ - import hashlib - url = 'https://www.gravatar.com/avatar/{}.jpg?s=80&d=wavatar' - return url.format(hashlib.md5(email.encode('utf-8').lower()).hexdigest()) diff --git a/homeassistant/components/device_tracker/actiontec.py b/homeassistant/components/device_tracker/actiontec.py deleted file mode 100644 index 72d9992c6..000000000 --- a/homeassistant/components/device_tracker/actiontec.py +++ /dev/null @@ -1,120 +0,0 @@ -""" -Support for Actiontec MI424WR (Verizon FIOS) routers. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/device_tracker.actiontec/ -""" -import logging -import re -import telnetlib -from collections import namedtuple -import voluptuous as vol - -import homeassistant.helpers.config_validation as cv -import homeassistant.util.dt as dt_util -from homeassistant.components.device_tracker import ( - DOMAIN, PLATFORM_SCHEMA, DeviceScanner) -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME - -_LOGGER = logging.getLogger(__name__) - -_LEASES_REGEX = re.compile( - r'(?P([0-9]{1,3}[\.]){3}[0-9]{1,3})' + - r'\smac:\s(?P([0-9a-f]{2}[:-]){5}([0-9a-f]{2}))' + - r'\svalid\sfor:\s(?P(-?\d+))' + - r'\ssec') - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_USERNAME): cv.string -}) - - -def get_scanner(hass, config): - """Validate the configuration and return an Actiontec scanner.""" - scanner = ActiontecDeviceScanner(config[DOMAIN]) - return scanner if scanner.success_init else None - - -Device = namedtuple('Device', ['mac', 'ip', 'last_update']) - - -class ActiontecDeviceScanner(DeviceScanner): - """This class queries an actiontec router for connected devices.""" - - def __init__(self, config): - """Initialize the scanner.""" - self.host = config[CONF_HOST] - self.username = config[CONF_USERNAME] - self.password = config[CONF_PASSWORD] - self.last_results = [] - data = self.get_actiontec_data() - self.success_init = data is not None - _LOGGER.info("canner initialized") - - 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] - - 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: - return None - for client in self.last_results: - if client.mac == device: - return client.ip - return None - - def _update_info(self): - """Ensure the information from the router is up to date. - - Return boolean if scanning successful. - """ - _LOGGER.info("Scanning") - if not self.success_init: - return False - - now = dt_util.now() - actiontec_data = self.get_actiontec_data() - if not actiontec_data: - return False - self.last_results = [Device(data['mac'], name, now) - for name, data in actiontec_data.items() - if data['timevalid'] > -60] - _LOGGER.info("Scan successful") - return True - - def get_actiontec_data(self): - """Retrieve data from Actiontec MI424WR and return parsed result.""" - try: - telnet = telnetlib.Telnet(self.host) - telnet.read_until(b'Username: ') - telnet.write((self.username + '\n').encode('ascii')) - telnet.read_until(b'Password: ') - telnet.write((self.password + '\n').encode('ascii')) - prompt = telnet.read_until( - b'Wireless Broadband Router> ').split(b'\n')[-1] - telnet.write('firewall mac_cache_dump\n'.encode('ascii')) - telnet.write('\n'.encode('ascii')) - telnet.read_until(prompt) - leases_result = telnet.read_until(prompt).split(b'\n')[1:-1] - telnet.write('exit\n'.encode('ascii')) - except EOFError: - _LOGGER.exception("Unexpected response from router") - return - except ConnectionRefusedError: - _LOGGER.exception("Connection refused by router. Telnet enabled?") - return None - - devices = {} - for lease in leases_result: - match = _LEASES_REGEX.search(lease.decode('utf-8')) - if match is not None: - devices[match.group('ip')] = { - 'ip': match.group('ip'), - 'mac': match.group('mac').upper(), - 'timevalid': int(match.group('timevalid')) - } - return devices diff --git a/homeassistant/components/device_tracker/aruba.py b/homeassistant/components/device_tracker/aruba.py deleted file mode 100644 index 142842b12..000000000 --- a/homeassistant/components/device_tracker/aruba.py +++ /dev/null @@ -1,128 +0,0 @@ -""" -Support for Aruba Access Points. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/device_tracker.aruba/ -""" -import logging -import re - -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__) - -REQUIREMENTS = ['pexpect==4.6.0'] - -_DEVICES_REGEX = re.compile( - 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+') - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_USERNAME): cv.string -}) - - -def get_scanner(hass, config): - """Validate the configuration and return a Aruba scanner.""" - scanner = ArubaDeviceScanner(config[DOMAIN]) - - return scanner if scanner.success_init else None - - -class ArubaDeviceScanner(DeviceScanner): - """This class queries a Aruba Access Point for connected devices.""" - - def __init__(self, config): - """Initialize the scanner.""" - self.host = config[CONF_HOST] - self.username = config[CONF_USERNAME] - self.password = config[CONF_PASSWORD] - - self.last_results = {} - - # Test the router is accessible. - data = self.get_aruba_data() - self.success_init = data is not None - - 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] - - 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: - return None - for client in self.last_results: - if client['mac'] == device: - return client['name'] - return None - - def _update_info(self): - """Ensure the information from the Aruba Access Point is up to date. - - Return boolean if scanning successful. - """ - if not self.success_init: - return False - - data = self.get_aruba_data() - if not data: - return False - - self.last_results = data.values() - return True - - def get_aruba_data(self): - """Retrieve data from Aruba Access Point and return parsed result.""" - import pexpect - connect = 'ssh {}@{}' - ssh = pexpect.spawn(connect.format(self.username, self.host)) - query = ssh.expect(['password:', pexpect.TIMEOUT, pexpect.EOF, - 'continue connecting (yes/no)?', - 'Host key verification failed.', - 'Connection refused', - 'Connection timed out'], timeout=120) - if query == 1: - _LOGGER.error("Timeout") - return - if query == 2: - _LOGGER.error("Unexpected response from router") - return - if query == 3: - ssh.sendline('yes') - ssh.expect('password:') - elif query == 4: - _LOGGER.error("Host key changed") - return - elif query == 5: - _LOGGER.error("Connection refused by server") - return - elif query == 6: - _LOGGER.error("Connection timed out") - return - ssh.sendline(self.password) - ssh.expect('#') - ssh.sendline('show clients') - ssh.expect('#') - devices_result = ssh.before.split(b'\r\n') - ssh.sendline('exit') - - devices = {} - for device in devices_result: - match = _DEVICES_REGEX.search(device.decode('utf-8')) - if match: - devices[match.group('ip')] = { - 'ip': match.group('ip'), - 'mac': match.group('mac').upper(), - 'name': match.group('name') - } - return devices diff --git a/homeassistant/components/device_tracker/asuswrt.py b/homeassistant/components/device_tracker/asuswrt.py deleted file mode 100644 index 710a07f77..000000000 --- a/homeassistant/components/device_tracker/asuswrt.py +++ /dev/null @@ -1,388 +0,0 @@ -""" -Support for ASUSWRT routers. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/device_tracker.asuswrt/ -""" -import logging -import re -import socket -import telnetlib -from collections import namedtuple - -import voluptuous as vol - -import homeassistant.helpers.config_validation as cv -from homeassistant.components.device_tracker import ( - DOMAIN, PLATFORM_SCHEMA, DeviceScanner) -from homeassistant.const import ( - CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_PORT, CONF_MODE, - CONF_PROTOCOL) - -REQUIREMENTS = ['pexpect==4.6.0'] - -_LOGGER = logging.getLogger(__name__) - -CONF_PUB_KEY = 'pub_key' -CONF_SSH_KEY = 'ssh_key' -CONF_REQUIRE_IP = 'require_ip' -DEFAULT_SSH_PORT = 22 -SECRET_GROUP = 'Password or SSH Key' - -PLATFORM_SCHEMA = vol.All( - cv.has_at_least_one_key(CONF_PASSWORD, CONF_PUB_KEY, CONF_SSH_KEY), - 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_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 - })) - - -_LEASES_CMD = 'cat /var/lib/misc/dnsmasq.leases' -_LEASES_REGEX = re.compile( - r'\w+\s' + - r'(?P(([0-9a-f]{2}[:-]){5}([0-9a-f]{2})))\s' + - r'(?P([0-9]{1,3}[\.]){3}[0-9]{1,3})\s' + - r'(?P([^\s]+))') - -# Command to get both 5GHz and 2.4GHz clients -_WL_CMD = 'for dev in `nvram get wl_ifnames`; do wl -i $dev assoclist; done' -_WL_REGEX = re.compile( - r'\w+\s' + - r'(?P(([0-9A-F]{2}[:-]){5}([0-9A-F]{2})))') - -_IP_NEIGH_CMD = 'ip neigh' -_IP_NEIGH_REGEX = re.compile( - r'(?P([0-9]{1,3}[\.]){3}[0-9]{1,3}|' - r'([0-9a-fA-F]{1,4}:){1,7}[0-9a-fA-F]{0,4}(:[0-9a-fA-F]{1,4}){1,7})\s' - r'\w+\s' - 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'.*') - - -def get_scanner(hass, config): - """Validate the configuration and return an ASUS-WRT scanner.""" - scanner = AsusWrtDeviceScanner(config[DOMAIN]) - - return scanner if scanner.success_init else None - - -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): - """This class queries a router running ASUSWRT firmware.""" - - # Eighth attribute needed for mode (AP mode vs router mode) - def __init__(self, config): - """Initialize the scanner.""" - self.host = config[CONF_HOST] - self.username = config[CONF_USERNAME] - self.password = config.get(CONF_PASSWORD, '') - self.ssh_key = config.get('ssh_key', config.get('pub_key', '')) - self.protocol = config[CONF_PROTOCOL] - self.mode = config[CONF_MODE] - self.port = config[CONF_PORT] - self.require_ip = config[CONF_REQUIRE_IP] - - if self.protocol == 'ssh': - self.connection = SshConnection( - self.host, self.port, self.username, self.password, - self.ssh_key) - else: - self.connection = TelnetConnection( - self.host, self.port, self.username, self.password) - - self.last_results = {} - - # Test the router is accessible. - data = self.get_asuswrt_data() - self.success_init = data is not None - - def scan_devices(self): - """Scan for new devices and return a list with found device IDs.""" - self._update_info() - 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 device not in self.last_results: - return None - return self.last_results[device].name - - def _update_info(self): - """Ensure the information from the ASUSWRT router is up to date. - - Return boolean if scanning successful. - """ - if not self.success_init: - return False - - _LOGGER.info('Checking Devices') - data = self.get_asuswrt_data() - if not data: - return False - - self.last_results = data - return True - - def get_asuswrt_data(self): - """Retrieve data from ASUSWRT. - - 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 = {} - 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 - - 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 - - -class _Connection: - def __init__(self): - self._connected = False - - @property - def connected(self): - """Return connection state.""" - return self._connected - - def connect(self): - """Mark current connection state as connected.""" - self._connected = True - - def disconnect(self): - """Mark current connection state as disconnected.""" - self._connected = False - - -class SshConnection(_Connection): - """Maintains an SSH connection to an ASUS-WRT router.""" - - def __init__(self, host, port, username, password, ssh_key): - """Initialize the SSH connection properties.""" - super().__init__() - - self._ssh = None - self._host = host - self._port = port - self._username = username - self._password = password - self._ssh_key = ssh_key - - 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. - """ - from pexpect import pxssh, exceptions - - try: - if not self.connected: - self.connect() - 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. %s", self._ssh.before) - self.disconnect() - return None - except pxssh.ExceptionPxssh as err: - _LOGGER.error("Unexpected SSH error: %s", err) - self.disconnect() - return None - except AssertionError as err: - _LOGGER.error("Connection to router unavailable: %s", err) - self.disconnect() - return None - - def connect(self): - """Connect to the ASUS-WRT SSH server.""" - from pexpect import pxssh - - self._ssh = pxssh.pxssh() - if self._ssh_key: - 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, quiet=False, - password=self._password, port=self._port) - - super().connect() - - def disconnect(self): - """Disconnect the current SSH connection.""" - try: - self._ssh.logout() - except Exception: # pylint: disable=broad-except - pass - finally: - self._ssh = None - - super().disconnect() - - -class TelnetConnection(_Connection): - """Maintains a Telnet connection to an ASUS-WRT router.""" - - def __init__(self, host, port, username, password): - """Initialize the Telnet connection properties.""" - super().__init__() - - self._telnet = None - self._host = host - self._port = port - self._username = username - self._password = password - self._prompt_string = None - - 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. - """ - try: - if not self.connected: - self.connect() - 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() - return None - except ConnectionRefusedError: - _LOGGER.error("Connection refused by router. Telnet enabled?") - self.disconnect() - return None - except socket.gaierror as exc: - _LOGGER.error("Socket exception: %s", exc) - self.disconnect() - return None - except OSError as exc: - _LOGGER.error("OSError: %s", exc) - self.disconnect() - return None - - def connect(self): - """Connect to the ASUS-WRT Telnet server.""" - self._telnet = telnetlib.Telnet(self._host) - self._telnet.read_until(b'login: ') - self._telnet.write((self._username + '\n').encode('ascii')) - self._telnet.read_until(b'Password: ') - self._telnet.write((self._password + '\n').encode('ascii')) - self._prompt_string = self._telnet.read_until(b'#').split(b'\n')[-1] - - super().connect() - - def disconnect(self): - """Disconnect the current Telnet connection.""" - try: - self._telnet.write('exit\n'.encode('ascii')) - except Exception: # pylint: disable=broad-except - pass - - super().disconnect() diff --git a/homeassistant/components/device_tracker/automatic.py b/homeassistant/components/device_tracker/automatic.py deleted file mode 100644 index 4fcc550d7..000000000 --- a/homeassistant/components/device_tracker/automatic.py +++ /dev/null @@ -1,364 +0,0 @@ -""" -Support for the Automatic platform. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/device_tracker.automatic/ -""" -import asyncio -from datetime import timedelta -import json -import logging -import os - -from aiohttp import web -import voluptuous as vol - -from homeassistant.components.device_tracker import ( - 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 -from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.event import async_track_time_interval - -REQUIREMENTS = ['aioautomatic==0.6.5'] - -_LOGGER = logging.getLogger(__name__) - -ATTR_FUEL_LEVEL = 'fuel_level' -AUTOMATIC_CONFIG_FILE = '.automatic/session-{}.json' - -CONF_CLIENT_ID = 'client_id' -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 -DEPENDENCIES = ['http'] - -EVENT_AUTOMATIC_UPDATE = 'automatic_update' - -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): vol.All(cv.ensure_list, [cv.string]), -}) - - -def _get_refresh_token_from_file(hass, filename): - """Attempt to load session data from file.""" - path = hass.config.path(filename) - - if not os.path.isfile(path): - return None - - try: - with open(path) as data_file: - data = json.load(data_file) - if data is None: - return None - - return data.get(DATA_REFRESH_TOKEN) - except ValueError: - return None - - -def _write_refresh_token_to_file(hass, filename, refresh_token): - """Attempt to store session data to file.""" - path = hass.config.path(filename) - - os.makedirs(os.path.dirname(path), exist_ok=True) - with open(path, 'w+') as data_file: - json.dump({ - DATA_REFRESH_TOKEN: refresh_token - }, data_file) - - -@asyncio.coroutine -def async_setup_scanner(hass, config, async_see, discovery_info=None): - """Validate the configuration and return an Automatic scanner.""" - import aioautomatic - - hass.http.register_view(AutomaticAuthCallbackView()) - - scope = FULL_SCOPE if config.get(CONF_CURRENT_LOCATION) else DEFAULT_SCOPE - - client = aioautomatic.Client( - client_id=config[CONF_CLIENT_ID], - client_secret=config[CONF_SECRET], - client_session=async_get_clientsession(hass), - request_kwargs={'timeout': DEFAULT_TIMEOUT}) - - filename = AUTOMATIC_CONFIG_FILE.format(config[CONF_CLIENT_ID]) - refresh_token = yield from hass.async_add_job( - _get_refresh_token_from_file, hass, filename) - - @asyncio.coroutine - def initialize_data(session): - """Initialize the AutomaticData object from the created session.""" - hass.async_add_job( - _write_refresh_token_to_file, hass, filename, - session.refresh_token) - data = AutomaticData( - hass, client, session, config.get(CONF_DEVICES), async_see) - - # Load the initial vehicle data - vehicles = yield from session.get_vehicles() - for vehicle in vehicles: - hass.async_add_job(data.load_vehicle(vehicle)) - - # Create a task instead of adding a tracking job, since this task will - # run until the websocket connection is closed. - hass.loop.create_task(data.ws_connect()) - - if refresh_token is not None: - try: - session = yield from client.create_session_from_refresh_token( - refresh_token) - yield from initialize_data(session) - return True - except aioautomatic.exceptions.AutomaticError as err: - _LOGGER.error(str(err)) - - configurator = hass.components.configurator - request_id = configurator.async_request_config( - "Automatic", description=( - "Authorization required for Automatic device tracker."), - link_name="Click here to authorize Home Assistant.", - link_url=client.generate_oauth_url(scope), - entity_picture="/static/images/logo_automatic.png", - ) - - @asyncio.coroutine - def initialize_callback(code, state): - """Call after OAuth2 response is returned.""" - try: - session = yield from client.create_session_from_oauth_code( - code, state) - yield from initialize_data(session) - configurator.async_request_done(request_id) - except aioautomatic.exceptions.AutomaticError as err: - _LOGGER.error(str(err)) - configurator.async_notify_errors(request_id, str(err)) - return False - - if DATA_CONFIGURING not in hass.data: - hass.data[DATA_CONFIGURING] = {} - - hass.data[DATA_CONFIGURING][client.state] = initialize_callback - return True - - -class AutomaticAuthCallbackView(HomeAssistantView): - """Handle OAuth finish callback requests.""" - - requires_auth = False - url = '/api/automatic/callback' - name = 'api:automatic:callback' - - @callback - def get(self, request): # pylint: disable=no-self-use - """Finish OAuth callback request.""" - hass = request.app['hass'] - params = request.query - response = web.HTTPFound('/states') - - if 'state' not in params or 'code' not in params: - if 'error' in params: - _LOGGER.error( - "Error authorizing Automatic: %s", params['error']) - 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") - return response - - code = params['code'] - state = params['state'] - initialize_callback = hass.data[DATA_CONFIGURING][state] - hass.async_add_job(initialize_callback(code, state)) - - return response - - -class AutomaticData: - """A class representing an Automatic cloud service connection.""" - - def __init__(self, hass, client, session, devices, async_see): - """Initialize the automatic device scanner.""" - self.hass = hass - self.devices = devices - self.vehicle_info = {} - self.vehicle_seen = {} - self.client = client - self.session = session - self.async_see = async_see - self.ws_reconnect_handle = None - self.ws_close_requested = False - - self.client.on_app_event( - lambda name, event: self.hass.async_add_job( - self.handle_event(name, event))) - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.ws_close()) - - @asyncio.coroutine - def handle_event(self, name, event): - """Coroutine to update state for a real time event.""" - import aioautomatic - - 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") - try: - vehicle = yield from event.get_vehicle() - except aioautomatic.exceptions.AutomaticError as err: - _LOGGER.error(str(err)) - return - yield from self.get_vehicle_info(vehicle) - - 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, - self.vehicle_seen[event.vehicle.id]) - return - self.vehicle_seen[event.vehicle.id] = event.created_at - - kwargs = self.vehicle_info[event.vehicle.id] - if kwargs is None: - # Ignored device - return - - # If this is a vehicle status report, update the fuel level - if name == "vehicle:status_report": - fuel_level = event.vehicle.fuel_level_percent - if fuel_level is not None: - kwargs[ATTR_ATTRIBUTES][ATTR_FUEL_LEVEL] = fuel_level - - # Send the device seen notification - if event.location is not None: - kwargs[ATTR_GPS] = (event.location.lat, event.location.lon) - kwargs[ATTR_GPS_ACCURACY] = event.location.accuracy_m - - yield from self.async_see(**kwargs) - - @asyncio.coroutine - def ws_connect(self, now=None): - """Open the websocket connection.""" - import aioautomatic - self.ws_close_requested = False - - if self.ws_reconnect_handle is not None: - _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") - return - except aioautomatic.exceptions.AutomaticError as err: - if self.ws_reconnect_handle is None: - # Show log error and retry connection every 5 minutes - _LOGGER.error("Error opening websocket connection: %s", err) - self.ws_reconnect_handle = async_track_time_interval( - self.hass, self.ws_connect, timedelta(minutes=5)) - return - - if self.ws_reconnect_handle is not None: - self.ws_reconnect_handle() - self.ws_reconnect_handle = None - - _LOGGER.info("Websocket connected") - - try: - yield from ws_loop_future - except aioautomatic.exceptions.AutomaticError as err: - _LOGGER.error(str(err)) - - _LOGGER.info("Websocket closed") - - # If websocket was close was not requested, attempt to reconnect - if not self.ws_close_requested: - self.hass.loop.create_task(self.ws_connect()) - - @asyncio.coroutine - def ws_close(self): - """Close the websocket connection.""" - self.ws_close_requested = True - if self.ws_reconnect_handle is not None: - self.ws_reconnect_handle() - self.ws_reconnect_handle = None - - yield from self.client.ws_close() - - @asyncio.coroutine - def load_vehicle(self, vehicle): - """Load the vehicle's initial state and update hass.""" - kwargs = yield from self.get_vehicle_info(vehicle) - yield from self.async_see(**kwargs) - - @asyncio.coroutine - def get_vehicle_info(self, vehicle): - """Fetch the latest vehicle info from automatic.""" - import aioautomatic - - name = vehicle.display_name - if name is None: - name = ' '.join(filter(None, ( - str(vehicle.year), vehicle.make, vehicle.model))) - - if self.devices is not None and name not in self.devices: - self.vehicle_info[vehicle.id] = None - return - - self.vehicle_info[vehicle.id] = kwargs = { - ATTR_DEV_ID: vehicle.id, - ATTR_HOST_NAME: name, - ATTR_MAC: vehicle.id, - ATTR_ATTRIBUTES: { - ATTR_FUEL_LEVEL: vehicle.fuel_level_percent, - } - } - self.vehicle_seen[vehicle.id] = \ - vehicle.updated_at or vehicle.created_at - - if vehicle.latest_location is not None: - location = vehicle.latest_location - kwargs[ATTR_GPS] = (location.lat, location.lon) - kwargs[ATTR_GPS_ACCURACY] = location.accuracy_m - return kwargs - - trips = [] - try: - # Get the most recent trip for this vehicle - trips = yield from self.session.get_trips( - vehicle=vehicle.id, limit=1) - except aioautomatic.exceptions.AutomaticError as err: - _LOGGER.error(str(err)) - - if trips: - location = trips[0].end_location - kwargs[ATTR_GPS] = (location.lat, location.lon) - kwargs[ATTR_GPS_ACCURACY] = location.accuracy_m - - if trips[0].ended_at >= self.vehicle_seen[vehicle.id]: - self.vehicle_seen[vehicle.id] = trips[0].ended_at - - return kwargs diff --git a/homeassistant/components/device_tracker/bbox.py b/homeassistant/components/device_tracker/bbox.py deleted file mode 100644 index 6d870364d..000000000 --- a/homeassistant/components/device_tracker/bbox.py +++ /dev/null @@ -1,82 +0,0 @@ -""" -Support for French FAI Bouygues Bbox routers. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/device_tracker.bbox/ -""" -from collections import namedtuple -import logging -from datetime import timedelta - -import homeassistant.util.dt as dt_util -from homeassistant.components.device_tracker import DOMAIN, DeviceScanner -from homeassistant.util import Throttle - -REQUIREMENTS = ['pybbox==0.0.5-alpha'] - -_LOGGER = logging.getLogger(__name__) - -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=60) - - -def get_scanner(hass, config): - """Validate the configuration and return a Bbox scanner.""" - scanner = BboxDeviceScanner(config[DOMAIN]) - - return scanner if scanner.success_init else None - - -Device = namedtuple('Device', ['mac', 'name', 'ip', 'last_update']) - - -class BboxDeviceScanner(DeviceScanner): - """This class scans for devices connected to the bbox.""" - - def __init__(self, config): - """Initialize the scanner.""" - self.last_results = [] # type: List[Device] - - 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 - - @Throttle(MIN_TIME_BETWEEN_SCANS) - def _update_info(self): - """Check the Bbox for devices. - - Returns boolean if scanning successful. - """ - _LOGGER.info("Scanning...") - - import pybbox - - box = pybbox.Bbox() - result = box.get_all_connected_devices() - - now = dt_util.now() - last_results = [] - for device in result: - if device['active'] != 1: - continue - last_results.append( - Device(device['macaddress'], device['hostname'], - device['ipaddress'], now)) - - self.last_results = last_results - - _LOGGER.info("Scan successful") - return True diff --git a/homeassistant/components/device_tracker/bluetooth_le_tracker.py b/homeassistant/components/device_tracker/bluetooth_le_tracker.py deleted file mode 100644 index 47b86ab9a..000000000 --- a/homeassistant/components/device_tracker/bluetooth_le_tracker.py +++ /dev/null @@ -1,112 +0,0 @@ -""" -Tracking for bluetooth low energy devices. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/device_tracker.bluetooth_le_tracker/ -""" -import logging - -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, SOURCE_TYPE_BLUETOOTH_LE -) -import homeassistant.util.dt as dt_util - -_LOGGER = logging.getLogger(__name__) - -REQUIREMENTS = ['pygatt==3.2.0'] - -BLE_PREFIX = 'BLE_' -MIN_SEEN_NEW = 5 - - -def setup_scanner(hass, config, see, discovery_info=None): - """Set up the Bluetooth LE Scanner.""" - # pylint: disable=import-error - import pygatt - new_devices = {} - - def see_device(address, name, new_device=False): - """Mark a device as seen.""" - if new_device: - if address in new_devices: - _LOGGER.debug( - "Seen %s %s times", address, new_devices[address]) - new_devices[address] += 1 - if new_devices[address] >= MIN_SEEN_NEW: - _LOGGER.debug("Adding %s to tracked devices", address) - devs_to_track.append(address) - else: - return - else: - _LOGGER.debug("Seen %s for the first time", address) - new_devices[address] = 1 - return - - see(mac=BLE_PREFIX + address, host_name=name.strip("\x00"), - source_type=SOURCE_TYPE_BLUETOOTH_LE) - - def discover_ble_devices(): - """Discover Bluetooth LE devices.""" - _LOGGER.debug("Discovering Bluetooth LE devices") - try: - adapter = pygatt.GATTToolBackend() - devs = adapter.scan() - - devices = {x['address']: x['name'] for x in devs} - _LOGGER.debug("Bluetooth LE devices discovered = %s", devices) - except RuntimeError as error: - _LOGGER.error("Error during Bluetooth LE scan: %s", error) - return {} - return devices - - yaml_path = hass.config.path(YAML_DEVICES) - devs_to_track = [] - devs_donot_track = [] - - # Load all known devices. - # We just need the devices so set consider_home and home range - # to 0 - for device in load_config(yaml_path, hass, 0): - # check if device is a valid bluetooth device - if device.mac and device.mac[:4].upper() == BLE_PREFIX: - if device.track: - _LOGGER.debug("Adding %s to BLE tracker", device.mac) - devs_to_track.append(device.mac[4:]) - else: - _LOGGER.debug("Adding %s to BLE do not track", device.mac) - devs_donot_track.append(device.mac[4:]) - - # if track new devices is true discover new devices - # on every scan. - track_new = config.get(CONF_TRACK_NEW) - - if not devs_to_track and not track_new: - _LOGGER.warning("No Bluetooth LE devices to track!") - return False - - interval = config.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - - def update_ble(now): - """Lookup Bluetooth LE devices and update status.""" - devs = discover_ble_devices() - for mac in devs_to_track: - if mac not in devs: - continue - - if devs[mac] is None: - devs[mac] = mac - see_device(mac, devs[mac]) - - if track_new: - for address in devs: - if address not in devs_to_track and \ - address not in devs_donot_track: - _LOGGER.info("Discovered Bluetooth LE device %s", address) - see_device(address, devs[address], new_device=True) - - track_point_in_utc_time(hass, update_ble, dt_util.utcnow() + interval) - - update_ble(dt_util.utcnow()) - return True diff --git a/homeassistant/components/device_tracker/bluetooth_tracker.py b/homeassistant/components/device_tracker/bluetooth_tracker.py deleted file mode 100644 index d22a1ba7c..000000000 --- a/homeassistant/components/device_tracker/bluetooth_tracker.py +++ /dev/null @@ -1,119 +0,0 @@ -""" -Tracking for bluetooth devices. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/device_tracker.bluetooth_tracker/ -""" -import logging - -import voluptuous as vol - -import homeassistant.helpers.config_validation as cv -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, SOURCE_TYPE_BLUETOOTH, - DOMAIN) -import homeassistant.util.dt as dt_util - -_LOGGER = logging.getLogger(__name__) - -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_REQUEST_RSSI): cv.boolean -}) - - -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(mac, name, rssi=None): - """Mark a device as seen.""" - 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 = %d", len(result)) - return result - - yaml_path = hass.config.path(YAML_DEVICES) - devs_to_track = [] - devs_donot_track = [] - - # Load all known devices. - # We just need the devices so set consider_home and home range - # to 0 - for device in load_config(yaml_path, hass, 0): - # Check if device is a valid bluetooth device - if device.mac and device.mac[:3].upper() == BT_PREFIX: - if device.track: - devs_to_track.append(device.mac[3:]) - else: - devs_donot_track.append(device.mac[3:]) - - # If track new devices is true discover new devices on startup. - track_new = config.get(CONF_TRACK_NEW, DEFAULT_TRACK_NEW) - if track_new: - for dev in discover_devices(): - if dev[0] not in devs_to_track and \ - dev[0] not in devs_donot_track: - devs_to_track.append(dev[0]) - 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(_): - """Update Bluetooth and set timer for the next update.""" - update_bluetooth_once() - track_point_in_utc_time( - hass, update_bluetooth, dt_util.utcnow() + interval) - - def update_bluetooth_once(): - """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: - devs_to_track.append(dev[0]) - for mac in devs_to_track: - _LOGGER.debug("Scanning %s", mac) - result = bluetooth.lookup_name(mac, timeout=5) - rssi = None - if request_rssi: - rssi = BluetoothRSSI(mac).request_rssi() - if result is None: - # Could not lookup device name - continue - see_device(mac, result, rssi) - except bluetooth.BluetoothError: - _LOGGER.exception("Error looking up Bluetooth device") - - def handle_update_bluetooth(call): - """Update bluetooth devices on demand.""" - update_bluetooth_once() - - update_bluetooth(dt_util.utcnow()) - - hass.services.register( - DOMAIN, "bluetooth_tracker_update", handle_update_bluetooth) - - return True diff --git a/homeassistant/components/device_tracker/bmw_connected_drive.py b/homeassistant/components/device_tracker/bmw_connected_drive.py deleted file mode 100644 index 02a126531..000000000 --- a/homeassistant/components/device_tracker/bmw_connected_drive.py +++ /dev/null @@ -1,58 +0,0 @@ -"""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: - """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/bt_home_hub_5.py b/homeassistant/components/device_tracker/bt_home_hub_5.py deleted file mode 100644 index 21c41df3a..000000000 --- a/homeassistant/components/device_tracker/bt_home_hub_5.py +++ /dev/null @@ -1,78 +0,0 @@ -""" -Support for BT Home Hub 5. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/device_tracker.bt_home_hub_5/ -""" -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 - -REQUIREMENTS = ['bthomehub5-devicelist==0.1.1'] - -_LOGGER = logging.getLogger(__name__) - -CONF_DEFAULT_IP = '192.168.1.254' - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_HOST, default=CONF_DEFAULT_IP): cv.string, -}) - - -def get_scanner(hass, config): - """Return a BT Home Hub 5 scanner if successful.""" - scanner = BTHomeHub5DeviceScanner(config[DOMAIN]) - - return scanner if scanner.success_init else None - - -class BTHomeHub5DeviceScanner(DeviceScanner): - """This class queries a BT Home Hub 5.""" - - def __init__(self, config): - """Initialise the scanner.""" - import bthomehub5_devicelist - - _LOGGER.info("Initialising BT Home Hub 5") - self.host = config[CONF_HOST] - self.last_results = {} - - # Test the router is accessible - data = bthomehub5_devicelist.get_devicelist(self.host) - self.success_init = data is not None - - def scan_devices(self): - """Scan for new devices and return a list with found device IDs.""" - self.update_info() - - return (device 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.""" - # If not initialised and not already scanned and not found. - if device not in self.last_results: - self.update_info() - - if not self.last_results: - return None - - return self.last_results.get(device) - - def update_info(self): - """Ensure the information from the BT Home Hub 5 is up to date.""" - import bthomehub5_devicelist - - _LOGGER.info("Scanning") - - data = bthomehub5_devicelist.get_devicelist(self.host) - - if not data: - _LOGGER.warning("Error scanning devices") - return - - self.last_results = data diff --git a/homeassistant/components/device_tracker/cisco_ios.py b/homeassistant/components/device_tracker/cisco_ios.py deleted file mode 100644 index 1afea2c16..000000000 --- a/homeassistant/components/device_tracker/cisco_ios.py +++ /dev/null @@ -1,155 +0,0 @@ -""" -Support for Cisco IOS Routers. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/device_tracker.cisco_ios/ -""" -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_PASSWORD, CONF_USERNAME, \ - CONF_PORT - -_LOGGER = logging.getLogger(__name__) - -REQUIREMENTS = ['pexpect==4.6.0'] - -PLATFORM_SCHEMA = vol.All( - PLATFORM_SCHEMA.extend({ - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Optional(CONF_PASSWORD, default=''): cv.string, - vol.Optional(CONF_PORT): cv.port, - }) -) - - -def get_scanner(hass, config): - """Validate the configuration and return a Cisco scanner.""" - scanner = CiscoDeviceScanner(config[DOMAIN]) - - return scanner if scanner.success_init else None - - -class CiscoDeviceScanner(DeviceScanner): - """This class queries a wireless router running Cisco IOS firmware.""" - - def __init__(self, config): - """Initialize the scanner.""" - self.host = config[CONF_HOST] - self.username = config[CONF_USERNAME] - self.port = config.get(CONF_PORT) - self.password = config.get(CONF_PASSWORD) - - self.last_results = {} - - self.success_init = self._update_info() - _LOGGER.info('cisco_ios scanner initialized') - - def get_device_name(self, device): - """Get the firmware doesn't save the name of the wireless device.""" - return None - - def scan_devices(self): - """Scan for new devices and return a list with found device IDs.""" - self._update_info() - - return self.last_results - - def _update_info(self): - """ - Ensure the information from the Cisco router is up to date. - - Returns boolean if scanning successful. - """ - string_result = self._get_arp_data() - - if string_result: - self.last_results = [] - last_results = [] - - lines_result = string_result.splitlines() - - # Remove the first two lines, as they contains the arp command - # and the arp table titles e.g. - # show ip arp - # Protocol Address | Age (min) | Hardware Addr | Type | Interface - lines_result = lines_result[2:] - - for line in lines_result: - parts = line.split() - if len(parts) != 6: - continue - - # ['Internet', '10.10.11.1', '-', '0027.d32d.0123', 'ARPA', - # 'GigabitEthernet0'] - age = parts[2] - hw_addr = parts[3] - - if age != "-": - mac = _parse_cisco_mac_address(hw_addr) - age = int(age) - if age < 1: - last_results.append(mac) - - self.last_results = last_results - return True - - return False - - def _get_arp_data(self): - """Open connection to the router and get arp entries.""" - from pexpect import pxssh - import re - - try: - cisco_ssh = pxssh.pxssh() - cisco_ssh.login(self.host, self.username, self.password, - port=self.port, auto_prompt_reset=False) - - # Find the hostname - initial_line = cisco_ssh.before.decode('utf-8').splitlines() - router_hostname = initial_line[len(initial_line) - 1] - router_hostname += "#" - # Set the discovered hostname as prompt - regex_expression = ('(?i)^%s' % router_hostname).encode() - cisco_ssh.PROMPT = re.compile(regex_expression, re.MULTILINE) - # Allow full arp table to print at once - cisco_ssh.sendline("terminal length 0") - cisco_ssh.prompt(1) - - cisco_ssh.sendline("show ip arp") - cisco_ssh.prompt(1) - - devices_result = cisco_ssh.before - - return devices_result.decode('utf-8') - except pxssh.ExceptionPxssh as px_e: - _LOGGER.error("pxssh failed on login") - _LOGGER.error(px_e) - - return None - - -def _parse_cisco_mac_address(cisco_hardware_addr): - """ - Parse a Cisco formatted HW address to normal MAC. - - e.g. convert - 001d.ec02.07ab - - to: - 00:1D:EC:02:07:AB - - Takes in cisco_hwaddr: HWAddr String from Cisco ARP table - Returns a regular standard MAC address - """ - cisco_hardware_addr = cisco_hardware_addr.replace('.', '') - blocks = [cisco_hardware_addr[x:x + 2] - for x in range(0, len(cisco_hardware_addr), 2)] - - return ':'.join(blocks).upper() diff --git a/homeassistant/components/device_tracker/config_entry.py b/homeassistant/components/device_tracker/config_entry.py new file mode 100644 index 000000000..6c5cacac5 --- /dev/null +++ b/homeassistant/components/device_tracker/config_entry.py @@ -0,0 +1,133 @@ +"""Code to set up a device tracker platform using a config entry.""" +from typing import Optional + +from homeassistant.components import zone +from homeassistant.const import ( + ATTR_BATTERY_LEVEL, + ATTR_GPS_ACCURACY, + ATTR_LATITUDE, + ATTR_LONGITUDE, + STATE_HOME, + STATE_NOT_HOME, +) +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent + +from .const import ATTR_SOURCE_TYPE, DOMAIN, LOGGER + + +async def async_setup_entry(hass, entry): + """Set up an entry.""" + component: Optional[EntityComponent] = hass.data.get(DOMAIN) + + if component is None: + component = hass.data[DOMAIN] = EntityComponent(LOGGER, DOMAIN, hass) + + return await component.async_setup_entry(entry) + + +async def async_unload_entry(hass, entry): + """Unload an entry.""" + return await hass.data[DOMAIN].async_unload_entry(entry) + + +class BaseTrackerEntity(Entity): + """Represent a tracked device.""" + + @property + def battery_level(self): + """Return the battery level of the device. + + Percentage from 0-100. + """ + return None + + @property + def source_type(self): + """Return the source type, eg gps or router, of the device.""" + raise NotImplementedError + + @property + def state_attributes(self): + """Return the device state attributes.""" + attr = {ATTR_SOURCE_TYPE: self.source_type} + + if self.battery_level: + attr[ATTR_BATTERY_LEVEL] = self.battery_level + + return attr + + +class TrackerEntity(BaseTrackerEntity): + """Represent a tracked device.""" + + @property + def location_accuracy(self): + """Return the location accuracy of the device. + + Value in meters. + """ + return 0 + + @property + def location_name(self) -> str: + """Return a location name for the current location of the device.""" + return None + + @property + def latitude(self) -> float: + """Return latitude value of the device.""" + return NotImplementedError + + @property + def longitude(self) -> float: + """Return longitude value of the device.""" + return NotImplementedError + + @property + def state(self): + """Return the state of the device.""" + if self.location_name: + return self.location_name + + if self.latitude is not None: + zone_state = zone.async_active_zone( + self.hass, self.latitude, self.longitude, self.location_accuracy + ) + if zone_state is None: + state = STATE_NOT_HOME + elif zone_state.entity_id == zone.ENTITY_ID_HOME: + state = STATE_HOME + else: + state = zone_state.name + return state + + return None + + @property + def state_attributes(self): + """Return the device state attributes.""" + attr = {} + attr.update(super().state_attributes) + if self.latitude is not None: + attr[ATTR_LATITUDE] = self.latitude + attr[ATTR_LONGITUDE] = self.longitude + attr[ATTR_GPS_ACCURACY] = self.location_accuracy + + return attr + + +class ScannerEntity(BaseTrackerEntity): + """Represent a tracked device that is on a scanned network.""" + + @property + def state(self): + """Return the state of the device.""" + if self.is_connected: + return STATE_HOME + return STATE_NOT_HOME + + @property + def is_connected(self): + """Return true if the device is connected to the network.""" + raise NotImplementedError diff --git a/homeassistant/components/device_tracker/const.py b/homeassistant/components/device_tracker/const.py new file mode 100644 index 000000000..1778a87b3 --- /dev/null +++ b/homeassistant/components/device_tracker/const.py @@ -0,0 +1,40 @@ +"""Device tracker constants.""" +from datetime import timedelta +import logging + +LOGGER = logging.getLogger(__package__) + +DOMAIN = "device_tracker" +ENTITY_ID_FORMAT = DOMAIN + ".{}" + +PLATFORM_TYPE_LEGACY = "legacy" +PLATFORM_TYPE_ENTITY = "entity_platform" + +SOURCE_TYPE_GPS = "gps" +SOURCE_TYPE_ROUTER = "router" +SOURCE_TYPE_BLUETOOTH = "bluetooth" +SOURCE_TYPE_BLUETOOTH_LE = "bluetooth_le" + +CONF_SCAN_INTERVAL = "interval_seconds" +SCAN_INTERVAL = timedelta(seconds=12) + +CONF_TRACK_NEW = "track_new_devices" +DEFAULT_TRACK_NEW = True + +CONF_AWAY_HIDE = "hide_if_away" +DEFAULT_AWAY_HIDE = False + +CONF_CONSIDER_HOME = "consider_home" +DEFAULT_CONSIDER_HOME = timedelta(seconds=180) + +CONF_NEW_DEVICE_DEFAULTS = "new_device_defaults" + +ATTR_ATTRIBUTES = "attributes" +ATTR_BATTERY = "battery" +ATTR_DEV_ID = "dev_id" +ATTR_GPS = "gps" +ATTR_HOST_NAME = "host_name" +ATTR_LOCATION_NAME = "location_name" +ATTR_MAC = "mac" +ATTR_SOURCE_TYPE = "source_type" +ATTR_CONSIDER_HOME = "consider_home" diff --git a/homeassistant/components/device_tracker/ddwrt.py b/homeassistant/components/device_tracker/ddwrt.py deleted file mode 100644 index 539d4fde5..000000000 --- a/homeassistant/components/device_tracker/ddwrt.py +++ /dev/null @@ -1,146 +0,0 @@ -""" -Support for DD-WRT routers. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/device_tracker.ddwrt/ -""" -import logging -import re - -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__) - -_DDWRT_DATA_REGEX = re.compile(r'\{(\w+)::([^\}]*)\}') -_MAC_REGEX = re.compile(r'(([0-9A-Fa-f]{1,2}\:){5}[0-9A-Fa-f]{1,2})') - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_USERNAME): cv.string -}) - - -def get_scanner(hass, config): - """Validate the configuration and return a DD-WRT scanner.""" - try: - return DdWrtDeviceScanner(config[DOMAIN]) - except ConnectionError: - return None - - -class DdWrtDeviceScanner(DeviceScanner): - """This class queries a wireless router running DD-WRT firmware.""" - - def __init__(self, config): - """Initialize the scanner.""" - self.host = config[CONF_HOST] - self.username = config[CONF_USERNAME] - self.password = config[CONF_PASSWORD] - - self.last_results = {} - self.mac2name = {} - - # Test the router is accessible - url = 'http://{}/Status_Wireless.live.asp'.format(self.host) - data = self.get_ddwrt_data(url) - if not data: - raise ConnectionError('Cannot connect to DD-Wrt router') - - def scan_devices(self): - """Scan for new devices and return a list with found device IDs.""" - self._update_info() - - return self.last_results - - def get_device_name(self, device): - """Return the name of the given device or None if we don't know.""" - # If not initialised and not already scanned and not found. - if device not in self.mac2name: - url = 'http://{}/Status_Lan.live.asp'.format(self.host) - data = self.get_ddwrt_data(url) - - if not data: - return None - - dhcp_leases = data.get('dhcp_leases', None) - - if not dhcp_leases: - return None - - # Remove leading and trailing quotes and spaces - cleaned_str = dhcp_leases.replace( - "\"", "").replace("\'", "").replace(" ", "") - elements = cleaned_str.split(',') - num_clients = int(len(elements) / 5) - self.mac2name = {} - for idx in range(0, num_clients): - # The data is a single array - # every 5 elements represents one host, the MAC - # is the third element and the name is the first. - mac_index = (idx * 5) + 2 - if mac_index < len(elements): - mac = elements[mac_index] - self.mac2name[mac] = elements[idx * 5] - - return self.mac2name.get(device) - - def _update_info(self): - """Ensure the information from the DD-WRT router is up to date. - - Return boolean if scanning successful. - """ - _LOGGER.info("Checking ARP") - - url = 'http://{}/Status_Wireless.live.asp'.format(self.host) - data = self.get_ddwrt_data(url) - - if not data: - return False - - self.last_results = [] - - active_clients = data.get('active_wireless', None) - if not active_clients: - return False - - # The DD-WRT UI uses its own data format and then - # regex's out values so this is done here too - # Remove leading and trailing single quotes. - clean_str = active_clients.strip().strip("'") - elements = clean_str.split("','") - - self.last_results.extend(item for item in elements - if _MAC_REGEX.match(item)) - - return True - - def get_ddwrt_data(self, url): - """Retrieve data from DD-WRT and return parsed result.""" - try: - response = requests.get( - url, auth=(self.username, self.password), timeout=4) - except requests.exceptions.Timeout: - _LOGGER.exception("Connection to the router timed out") - return - if response.status_code == 200: - return _parse_ddwrt_response(response.text) - if response.status_code == 401: - # Authentication error - _LOGGER.exception( - "Failed to authenticate, check your username and password") - return - _LOGGER.error("Invalid response from DD-WRT: %s", response) - - -def _parse_ddwrt_response(data_str): - """Parse the DD-WRT data format.""" - return { - key: val for key, val in _DDWRT_DATA_REGEX - .findall(data_str)} diff --git a/homeassistant/components/device_tracker/demo.py b/homeassistant/components/device_tracker/demo.py deleted file mode 100644 index 608fc560c..000000000 --- a/homeassistant/components/device_tracker/demo.py +++ /dev/null @@ -1,46 +0,0 @@ -""" -Demo platform for the Device tracker component. - -For more details about this platform, please refer to the documentation -https://home-assistant.io/components/demo/ -""" -import random - -from homeassistant.components.device_tracker import DOMAIN - - -def setup_scanner(hass, config, see, discovery_info=None): - """Set up the demo tracker.""" - def offset(): - """Return random offset.""" - return (random.randrange(500, 2000)) / 2e5 * random.choice((-1, 1)) - - def random_see(dev_id, name): - """Randomize a sighting.""" - see( - dev_id=dev_id, - host_name=name, - gps=(hass.config.latitude + offset(), - hass.config.longitude + offset()), - gps_accuracy=random.randrange(50, 150), - battery=random.randrange(10, 90) - ) - - def observe(call=None): - """Observe three entities.""" - random_see('demo_paulus', 'Paulus') - random_see('demo_anne_therese', 'Anne Therese') - - observe() - - see( - dev_id='demo_home_boy', - host_name='Home Boy', - gps=[hass.config.latitude - 0.00002, hass.config.longitude + 0.00002], - gps_accuracy=20, - battery=53 - ) - - hass.services.register(DOMAIN, 'demo', observe) - - return True diff --git a/homeassistant/components/device_tracker/device_condition.py b/homeassistant/components/device_tracker/device_condition.py new file mode 100644 index 000000000..9bdfc12db --- /dev/null +++ b/homeassistant/components/device_tracker/device_condition.py @@ -0,0 +1,83 @@ +"""Provides device automations for Device tracker.""" +from typing import Dict, List + +import voluptuous as vol + +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_CONDITION, + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_ENTITY_ID, + CONF_TYPE, + STATE_HOME, + STATE_NOT_HOME, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import condition, config_validation as cv, entity_registry +from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA +from homeassistant.helpers.typing import ConfigType, TemplateVarsType + +from . import DOMAIN + +CONDITION_TYPES = {"is_home", "is_not_home"} + +CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend( + { + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TYPE): vol.In(CONDITION_TYPES), + } +) + + +async def async_get_conditions( + hass: HomeAssistant, device_id: str +) -> List[Dict[str, str]]: + """List device conditions for Device tracker devices.""" + registry = await entity_registry.async_get_registry(hass) + conditions = [] + + # Get all the integrations entities for this device + for entry in entity_registry.async_entries_for_device(registry, device_id): + if entry.domain != DOMAIN: + continue + + # Add conditions for each entity that belongs to this integration + conditions.append( + { + CONF_CONDITION: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "is_home", + } + ) + conditions.append( + { + CONF_CONDITION: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "is_not_home", + } + ) + + return conditions + + +def async_condition_from_config( + config: ConfigType, config_validation: bool +) -> condition.ConditionCheckerType: + """Create a function to test a device condition.""" + if config_validation: + config = CONDITION_SCHEMA(config) + if config[CONF_TYPE] == "is_home": + state = STATE_HOME + else: + state = STATE_NOT_HOME + + def test_is_state(hass: HomeAssistant, variables: TemplateVarsType) -> bool: + """Test if an entity is a certain state.""" + return condition.state(hass, config[ATTR_ENTITY_ID], state) + + return test_is_state diff --git a/homeassistant/components/device_tracker/freebox.py b/homeassistant/components/device_tracker/freebox.py deleted file mode 100644 index 2cac81fd4..000000000 --- a/homeassistant/components/device_tracker/freebox.py +++ /dev/null @@ -1,120 +0,0 @@ -""" -Support for device tracking through Freebox routers. - -This tracker keeps track of the devices connected to the configured Freebox. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/device_tracker.freebox/ -""" -import asyncio -import copy -import logging -import socket -from collections import namedtuple -from datetime import timedelta - -import voluptuous as vol - -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.event import async_track_time_interval -from homeassistant.components.device_tracker import ( - PLATFORM_SCHEMA, CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) -from homeassistant.const import ( - CONF_HOST, CONF_PORT) - -REQUIREMENTS = ['aiofreepybox==0.0.4'] - -_LOGGER = logging.getLogger(__name__) - -FREEBOX_CONFIG_FILE = 'freebox.conf' - -PLATFORM_SCHEMA = vol.All( - PLATFORM_SCHEMA.extend({ - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_PORT): cv.port - })) - -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) - - -async def async_setup_scanner(hass, config, async_see, discovery_info=None): - """Set up the Freebox device tracker and start the polling.""" - freebox_config = copy.deepcopy(config) - if discovery_info is not None: - freebox_config[CONF_HOST] = discovery_info['properties']['api_domain'] - freebox_config[CONF_PORT] = discovery_info['properties']['https_port'] - _LOGGER.info("Discovered Freebox server: %s:%s", - freebox_config[CONF_HOST], freebox_config[CONF_PORT]) - - scanner = FreeboxDeviceScanner(hass, freebox_config, async_see) - interval = freebox_config.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - await scanner.async_start(hass, interval) - return True - - -Device = namedtuple('Device', ['id', 'name', 'ip']) - - -def _build_device(device_dict): - return Device( - device_dict['l2ident']['id'], - device_dict['primary_name'], - device_dict['l3connectivities'][0]['addr']) - - -class FreeboxDeviceScanner: - """This class scans for devices connected to the Freebox.""" - - def __init__(self, hass, config, async_see): - """Initialize the scanner.""" - from aiofreepybox import Freepybox - - self.host = config[CONF_HOST] - self.port = config[CONF_PORT] - self.token_file = hass.config.path(FREEBOX_CONFIG_FILE) - self.async_see = async_see - - # Hardcode the app description to avoid invalidating the authentication - # file at each new version. - # The version can be changed if we want the user to re-authorize HASS - # on her Freebox. - app_desc = { - 'app_id': 'hass', - 'app_name': 'Home Assistant', - 'app_version': '0.65', - 'device_name': socket.gethostname() - } - - api_version = 'v1' # Use the lowest working version. - self.fbx = Freepybox( - app_desc=app_desc, - token_file=self.token_file, - api_version=api_version) - - async def async_start(self, hass, interval): - """Perform a first update and start polling at the given interval.""" - await self.async_update_info() - interval = max(interval, MIN_TIME_BETWEEN_SCANS) - async_track_time_interval(hass, self.async_update_info, interval) - - async def async_update_info(self, now=None): - """Check the Freebox for devices.""" - from aiofreepybox.exceptions import HttpRequestError - - _LOGGER.info('Scanning devices') - - await self.fbx.open(self.host, self.port) - try: - hosts = await self.fbx.lan.get_hosts_list() - except HttpRequestError: - _LOGGER.exception('Failed to scan devices') - else: - active_devices = [_build_device(device) - for device in hosts - if device['active']] - - if active_devices: - await asyncio.wait([self.async_see(mac=d.id, host_name=d.name) - for d in active_devices]) - - await self.fbx.close() diff --git a/homeassistant/components/device_tracker/fritz.py b/homeassistant/components/device_tracker/fritz.py deleted file mode 100644 index 8c9d1988a..000000000 --- a/homeassistant/components/device_tracker/fritz.py +++ /dev/null @@ -1,94 +0,0 @@ -""" -Support for FRITZ!Box routers. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/device_tracker.fritz/ -""" -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_PASSWORD, CONF_USERNAME - -REQUIREMENTS = ['fritzconnection==0.6.5'] - -_LOGGER = logging.getLogger(__name__) - -CONF_DEFAULT_IP = '169.254.1.1' # This IP is valid for all FRITZ!Box routers. - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_HOST, default=CONF_DEFAULT_IP): cv.string, - vol.Optional(CONF_PASSWORD, default='admin'): cv.string, - vol.Optional(CONF_USERNAME, default=''): cv.string -}) - - -def get_scanner(hass, config): - """Validate the configuration and return FritzBoxScanner.""" - scanner = FritzBoxScanner(config[DOMAIN]) - return scanner if scanner.success_init else None - - -class FritzBoxScanner(DeviceScanner): - """This class queries a FRITZ!Box router.""" - - def __init__(self, config): - """Initialize the scanner.""" - self.last_results = [] - self.host = config[CONF_HOST] - self.username = config[CONF_USERNAME] - self.password = config[CONF_PASSWORD] - self.success_init = True - - # pylint: disable=import-error - import fritzconnection as fc - - # Establish a connection to the FRITZ!Box. - try: - self.fritz_box = fc.FritzHosts( - address=self.host, user=self.username, password=self.password) - except (ValueError, TypeError): - self.fritz_box = None - - # At this point it is difficult to tell if a connection is established. - # So just check for null objects. - if self.fritz_box is None or not self.fritz_box.modelname: - self.success_init = False - - if self.success_init: - _LOGGER.info("Successfully connected to %s", - self.fritz_box.modelname) - self._update_info() - else: - _LOGGER.error("Failed to establish connection to FRITZ!Box " - "with IP: %s", self.host) - - def scan_devices(self): - """Scan for new devices and return a list of found device ids.""" - self._update_info() - active_hosts = [] - for known_host in self.last_results: - if known_host['status'] == '1' and known_host.get('mac'): - active_hosts.append(known_host['mac']) - return active_hosts - - 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(device).get( - 'NewHostName' - ) - if ret == {}: - return None - return ret - - def _update_info(self): - """Retrieve latest information from the FRITZ!Box.""" - if not self.success_init: - return False - - _LOGGER.info("Scanning") - self.last_results = self.fritz_box.get_hosts_info() - return True diff --git a/homeassistant/components/device_tracker/geofency.py b/homeassistant/components/device_tracker/geofency.py deleted file mode 100644 index 7231c5127..000000000 --- a/homeassistant/components/device_tracker/geofency.py +++ /dev/null @@ -1,135 +0,0 @@ -""" -Support for the Geofency platform. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/device_tracker.geofency/ -""" -import asyncio -from functools import partial -import logging - -import voluptuous as vol - -from homeassistant.components.device_tracker import PLATFORM_SCHEMA -from homeassistant.components.http import HomeAssistantView -from homeassistant.const import ( - ATTR_LATITUDE, ATTR_LONGITUDE, HTTP_UNPROCESSABLE_ENTITY, STATE_NOT_HOME) -import homeassistant.helpers.config_validation as cv -from homeassistant.util import slugify - -_LOGGER = logging.getLogger(__name__) - -DEPENDENCIES = ['http'] - -ATTR_CURRENT_LATITUDE = 'currentLatitude' -ATTR_CURRENT_LONGITUDE = 'currentLongitude' - -BEACON_DEV_PREFIX = 'beacon' -CONF_MOBILE_BEACONS = 'mobile_beacons' - -LOCATION_ENTRY = '1' -LOCATION_EXIT = '0' - -URL = '/api/geofency' - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_MOBILE_BEACONS): vol.All( - cv.ensure_list, [cv.string]), -}) - - -def setup_scanner(hass, config, see, discovery_info=None): - """Set up an endpoint for the Geofency application.""" - mobile_beacons = config.get(CONF_MOBILE_BEACONS) or [] - - hass.http.register_view(GeofencyView(see, mobile_beacons)) - - return True - - -class GeofencyView(HomeAssistantView): - """View to handle Geofency requests.""" - - url = URL - name = 'api:geofency' - - def __init__(self, see, mobile_beacons): - """Initialize Geofency url endpoints.""" - self.see = see - self.mobile_beacons = [slugify(beacon) for beacon in mobile_beacons] - - @asyncio.coroutine - def post(self, request): - """Handle Geofency requests.""" - data = yield from request.post() - hass = request.app['hass'] - - data = self._validate_data(data) - if not data: - return ("Invalid data", HTTP_UNPROCESSABLE_ENTITY) - - if self._is_mobile_beacon(data): - return (yield from self._set_location(hass, data, None)) - if data['entry'] == LOCATION_ENTRY: - 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)) - - @staticmethod - def _validate_data(data): - """Validate POST payload.""" - data = data.copy() - - required_attributes = ['address', 'device', 'entry', - 'latitude', 'longitude', 'name'] - - valid = True - for attribute in required_attributes: - if attribute not in data: - valid = False - _LOGGER.error("'%s' not specified in message", attribute) - - if not valid: - return False - - data['address'] = data['address'].replace('\n', ' ') - data['device'] = slugify(data['device']) - data['name'] = slugify(data['name']) - - 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 - - def _is_mobile_beacon(self, data): - """Check if we have a mobile beacon.""" - return 'beaconUUID' in data and data['name'] in self.mobile_beacons - - @staticmethod - def _device_name(data): - """Return name of device tracker.""" - if 'beaconUUID' in data: - return "{}_{}".format(BEACON_DEV_PREFIX, data['name']) - return data['device'] - - @asyncio.coroutine - def _set_location(self, hass, data, location_name): - """Fire HA event to set location.""" - device = self._device_name(data) - - yield from hass.async_add_job( - partial(self.see, dev_id=device, - gps=(data[ATTR_LATITUDE], data[ATTR_LONGITUDE]), - location_name=location_name, - attributes=data)) - - return "Setting location for {}".format(device) diff --git a/homeassistant/components/device_tracker/google_maps.py b/homeassistant/components/device_tracker/google_maps.py deleted file mode 100644 index 170d3de68..000000000 --- a/homeassistant/components/device_tracker/google_maps.py +++ /dev/null @@ -1,105 +0,0 @@ -""" -Support for Google Maps location sharing. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/device_tracker.google_maps/ -""" -from datetime import timedelta -import logging - -import voluptuous as vol - -from homeassistant.components.device_tracker import ( - PLATFORM_SCHEMA, SOURCE_TYPE_GPS) -from homeassistant.const import ATTR_ID, CONF_PASSWORD, CONF_USERNAME -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.event import track_time_interval -from homeassistant.helpers.typing import ConfigType -from homeassistant.util import slugify, dt as dt_util - -REQUIREMENTS = ['locationsharinglib==2.0.11'] - -_LOGGER = logging.getLogger(__name__) - -ATTR_ADDRESS = 'address' -ATTR_FULL_NAME = 'full_name' -ATTR_LAST_SEEN = 'last_seen' -ATTR_NICKNAME = 'nickname' - -CONF_MAX_GPS_ACCURACY = 'max_gps_accuracy' - -CREDENTIALS_FILE = '.google_maps_location_sharing.cookies' - -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=30) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Optional(CONF_MAX_GPS_ACCURACY, default=100000): vol.Coerce(float), -}) - - -def setup_scanner(hass, config: ConfigType, see, discovery_info=None): - """Set up the Google Maps Location sharing scanner.""" - scanner = GoogleMapsScanner(hass, config, see) - return scanner.success_init - - -class GoogleMapsScanner: - """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] - self.max_gps_accuracy = config[CONF_MAX_GPS_ACCURACY] - - try: - self.service = Service(self.username, self.password, - hass.config.path(CREDENTIALS_FILE)) - self._update_info() - - track_time_interval( - hass, self._update_info, MIN_TIME_BETWEEN_SCANS) - - self.success_init = True - - except InvalidUser: - _LOGGER.error("You have specified invalid login credentials") - self.success_init = False - - def _update_info(self, now=None): - for person in self.service.get_all_people(): - try: - dev_id = 'google_maps_{0}'.format(slugify(person.id)) - except TypeError: - _LOGGER.warning("No location(s) shared with this account") - return - - if self.max_gps_accuracy is not None and \ - person.accuracy > self.max_gps_accuracy: - _LOGGER.info("Ignoring %s update because expected GPS " - "accuracy %s is not met: %s", - person.nickname, self.max_gps_accuracy, - person.accuracy) - continue - - attrs = { - ATTR_ADDRESS: person.address, - ATTR_FULL_NAME: person.full_name, - ATTR_ID: person.id, - ATTR_LAST_SEEN: dt_util.as_utc(person.datetime), - ATTR_NICKNAME: person.nickname, - } - self.see( - dev_id=dev_id, - gps=(person.latitude, person.longitude), - picture=person.picture_url, - source_type=SOURCE_TYPE_GPS, - gps_accuracy=person.accuracy, - attributes=attrs, - ) diff --git a/homeassistant/components/device_tracker/gpslogger.py b/homeassistant/components/device_tracker/gpslogger.py deleted file mode 100644 index 6336ba51d..000000000 --- a/homeassistant/components/device_tracker/gpslogger.py +++ /dev/null @@ -1,108 +0,0 @@ -""" -Support for the GPSLogger platform. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/device_tracker.gpslogger/ -""" -import logging -from hmac import compare_digest - -from aiohttp.web import Request, HTTPUnauthorized -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 -) -from homeassistant.helpers.typing import HomeAssistantType, ConfigType - -_LOGGER = logging.getLogger(__name__) - -DEPENDENCIES = ['http'] - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_PASSWORD): cv.string, -}) - - -async def async_setup_scanner(hass: HomeAssistantType, config: ConfigType, - async_see, discovery_info=None): - """Set up an endpoint for the GPSLogger application.""" - hass.http.register_view(GPSLoggerView(async_see, config)) - - return True - - -class GPSLoggerView(HomeAssistantView): - """View to handle GPSLogger requests.""" - - url = '/api/gpslogger' - name = 'api:gpslogger' - - def __init__(self, async_see, config): - """Initialize GPSLogger url endpoints.""" - 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 - - async def get(self, request: Request): - """Handle for GPSLogger message received as GET.""" - 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() - - if 'latitude' not in data or 'longitude' not in data: - return ('Latitude and longitude not specified.', - HTTP_UNPROCESSABLE_ENTITY) - - if 'device' not in data: - _LOGGER.error("Device id not specified") - return ('Device id not specified.', - HTTP_UNPROCESSABLE_ENTITY) - - device = data['device'].replace('-', '') - gps_location = (data['latitude'], data['longitude']) - accuracy = 200 - battery = -1 - - if 'accuracy' in data: - accuracy = int(float(data['accuracy'])) - if 'battery' in data: - battery = float(data['battery']) - - attrs = {} - if 'speed' in data: - attrs['speed'] = float(data['speed']) - if 'direction' in data: - attrs['direction'] = float(data['direction']) - if 'altitude' in data: - attrs['altitude'] = float(data['altitude']) - if 'provider' in data: - attrs['provider'] = data['provider'] - if 'activity' in data: - attrs['activity'] = data['activity'] - - 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 deleted file mode 100644 index 72817ca69..000000000 --- a/homeassistant/components/device_tracker/hitron_coda.py +++ /dev/null @@ -1,146 +0,0 @@ -""" -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_lte.py b/homeassistant/components/device_tracker/huawei_lte.py deleted file mode 100644 index 4b4eb3f00..000000000 --- a/homeassistant/components/device_tracker/huawei_lte.py +++ /dev/null @@ -1,65 +0,0 @@ -""" -Support for Huawei LTE routers. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/device_tracker.huawei_lte/ -""" -from typing import Any, Dict, List, Optional - -import attr -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_URL -from ..huawei_lte import DATA_KEY, RouterData - - -DEPENDENCIES = ['huawei_lte'] - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_URL): cv.url, -}) - - -def get_scanner(hass, config): - """Get a Huawei LTE router scanner.""" - data = hass.data[DATA_KEY].get_data(config) - return HuaweiLteScanner(data) - - -@attr.s -class HuaweiLteScanner(DeviceScanner): - """Huawei LTE router scanner.""" - - data = attr.ib(type=RouterData) - - _hosts = attr.ib(init=False, factory=dict) - - def scan_devices(self) -> List[str]: - """Scan for devices.""" - self.data.update() - self._hosts = { - x["MacAddress"]: x - for x in self.data["wlan_host_list.Hosts.Host"] - if x.get("MacAddress") - } - return list(self._hosts) - - def get_device_name(self, device: str) -> Optional[str]: - """Get name for a device.""" - host = self._hosts.get(device) - return host.get("HostName") or None if host else None - - def get_extra_attributes(self, device: str) -> Dict[str, Any]: - """ - Get extra attributes of a device. - - Some known extra attributes that may be returned in the dict - include MacAddress (MAC address), ID (client ID), IpAddress - (IP address), AssociatedSsid (associated SSID), AssociatedTime - (associated time in seconds), and HostName (host name). - """ - return self._hosts.get(device) or {} diff --git a/homeassistant/components/device_tracker/huawei_router.py b/homeassistant/components/device_tracker/huawei_router.py deleted file mode 100644 index f5e4fa8a7..000000000 --- a/homeassistant/components/device_tracker/huawei_router.py +++ /dev/null @@ -1,144 +0,0 @@ -""" -Support for HUAWEI routers. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/device_tracker.huawei_router/ -""" -import base64 -import logging -import re -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__) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_USERNAME): cv.string -}) - - -def get_scanner(hass, config): - """Validate the configuration and return a HUAWEI scanner.""" - scanner = HuaweiDeviceScanner(config[DOMAIN]) - - return scanner - - -Device = namedtuple('Device', ['name', 'ip', 'mac', 'state']) - - -class HuaweiDeviceScanner(DeviceScanner): - """This class queries a router running HUAWEI firmware.""" - - ARRAY_REGEX = re.compile(r'var UserDevinfo = new Array\((.*),null\);') - DEVICE_REGEX = re.compile(r'new USERDevice\((.*?)\),') - DEVICE_ATTR_REGEX = re.compile( - '"(?P.*?)","(?P.*?)",' - '"(?P.*?)","(?P.*?)",' - '"(?P.*?)","(?P.*?)",' - '"(?P.*?)","(?P.*?)",' - '"(?P
{} and enter ' - 'code: {}'.format(dev_flow.verification_url, - dev_flow.verification_url, - dev_flow.user_code), - title=NOTIFICATION_TITLE, notification_id=NOTIFICATION_ID - ) - - def step2_exchange(now): - """Keep trying to validate the user_code until it expires.""" - if now >= dt.as_local(dev_flow.user_code_expiry): - hass.components.persistent_notification.create( - 'Authentication code expired, please restart ' - 'Home-Assistant and try again', - title=NOTIFICATION_TITLE, - notification_id=NOTIFICATION_ID) - listener() - - try: - credentials = oauth.step2_exchange(device_flow_info=dev_flow) - except FlowExchangeError: - # not ready yet, call again - return - - storage = Storage(hass.config.path(TOKEN_FILE)) - storage.put(credentials) - do_setup(hass, config) - listener() - hass.components.persistent_notification.create( - 'We are all setup now. Check {} for calendars that have ' - 'been found'.format(YAML_DEVICES), - title=NOTIFICATION_TITLE, notification_id=NOTIFICATION_ID) - - listener = track_time_change(hass, step2_exchange, - second=range(0, 60, dev_flow.interval)) - - return True - - -def setup(hass, config): - """Set up the Google platform.""" - if DATA_INDEX not in hass.data: - hass.data[DATA_INDEX] = {} - - conf = config.get(DOMAIN, {}) - - token_file = hass.config.path(TOKEN_FILE) - if not os.path.isfile(token_file): - do_authentication(hass, conf) - else: - do_setup(hass, conf) - - return True - - -def setup_services(hass, track_new_found_calendars, calendar_service): - """Set up the service listeners.""" - def _found_calendar(call): - """Check if we know about a calendar and generate PLATFORM_DISCOVER.""" - calendar = get_calendar_info(hass, call.data) - if hass.data[DATA_INDEX].get(calendar[CONF_CAL_ID], None) is not None: - return - - hass.data[DATA_INDEX].update({calendar[CONF_CAL_ID]: calendar}) - - update_config( - hass.config.path(YAML_DEVICES), - hass.data[DATA_INDEX][calendar[CONF_CAL_ID]] - ) - - discovery.load_platform(hass, 'calendar', DOMAIN, - hass.data[DATA_INDEX][calendar[CONF_CAL_ID]]) - - hass.services.register( - DOMAIN, SERVICE_FOUND_CALENDARS, _found_calendar) - - def _scan_for_calendars(service): - """Scan for new calendars.""" - service = calendar_service.get() - cal_list = service.calendarList() - calendars = cal_list.list().execute()['items'] - for calendar in calendars: - calendar['track'] = track_new_found_calendars - hass.services.call(DOMAIN, SERVICE_FOUND_CALENDARS, - calendar) - - hass.services.register( - DOMAIN, SERVICE_SCAN_CALENDARS, _scan_for_calendars) - return True - - -def do_setup(hass, config): - """Run the setup after we have everything configured.""" - # Load calendars the user has configured - hass.data[DATA_INDEX] = load_config(hass.config.path(YAML_DEVICES)) - - calendar_service = GoogleCalendarService(hass.config.path(TOKEN_FILE)) - track_new_found_calendars = convert(config.get(CONF_TRACK_NEW), - bool, DEFAULT_CONF_TRACK_NEW) - setup_services(hass, track_new_found_calendars, calendar_service) - - # Ensure component is loaded - setup_component(hass, 'calendar', config) - - for calendar in hass.data[DATA_INDEX].values(): - discovery.load_platform(hass, 'calendar', DOMAIN, calendar) - - # Look for any new calendars - hass.services.call(DOMAIN, SERVICE_SCAN_CALENDARS, None) - return True - - -class GoogleCalendarService: - """Calendar service interface to Google.""" - - def __init__(self, token_file): - """Init the Google Calendar service.""" - self.token_file = token_file - - def get(self): - """Get the calendar service from the storage file token.""" - import httplib2 - from oauth2client.file import Storage - from googleapiclient import discovery as google_discovery - credentials = Storage(self.token_file).get() - http = credentials.authorize(httplib2.Http()) - service = google_discovery.build( - 'calendar', 'v3', http=http, cache_discovery=False) - return service - - -def get_calendar_info(hass, calendar): - """Convert data from Google into DEVICE_SCHEMA.""" - calendar_info = DEVICE_SCHEMA({ - CONF_CAL_ID: calendar['id'], - CONF_ENTITIES: [{ - CONF_TRACK: calendar['track'], - CONF_NAME: calendar['summary'], - CONF_DEVICE_ID: generate_entity_id( - '{}', calendar['summary'], hass=hass), - }] - }) - return calendar_info - - -def load_config(path): - """Load the google_calendar_devices.yaml.""" - calendars = {} - try: - with open(path) as file: - data = yaml.safe_load(file) - for calendar in data: - try: - calendars.update({calendar[CONF_CAL_ID]: - DEVICE_SCHEMA(calendar)}) - except VoluptuousError as exception: - # keep going - _LOGGER.warning("Calendar Invalid Data: %s", exception) - except FileNotFoundError: - # When YAML file could not be loaded/did not contain a dict - return {} - - return calendars - - -def update_config(path, calendar): - """Write the google_calendar_devices.yaml.""" - with open(path, 'a') as out: - out.write('\n') - yaml.dump([calendar], out, default_flow_style=False) diff --git a/homeassistant/components/google/__init__.py b/homeassistant/components/google/__init__.py new file mode 100644 index 000000000..0e7ccd33b --- /dev/null +++ b/homeassistant/components/google/__init__.py @@ -0,0 +1,395 @@ +"""Support for Google - Calendar Event Devices.""" +from datetime import datetime, timedelta +import logging +import os + +from googleapiclient import discovery as google_discovery +import httplib2 +from oauth2client.client import ( + FlowExchangeError, + OAuth2DeviceCodeError, + OAuth2WebServerFlow, +) +from oauth2client.file import Storage +import voluptuous as vol +from voluptuous.error import Error as VoluptuousError +import yaml + +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import generate_entity_id +from homeassistant.helpers.event import track_time_change +from homeassistant.util import convert, dt + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "google" +ENTITY_ID_FORMAT = DOMAIN + ".{}" + +CONF_CLIENT_ID = "client_id" +CONF_CLIENT_SECRET = "client_secret" +CONF_TRACK_NEW = "track_new_calendar" + +CONF_CAL_ID = "cal_id" +CONF_DEVICE_ID = "device_id" +CONF_NAME = "name" +CONF_ENTITIES = "entities" +CONF_TRACK = "track" +CONF_SEARCH = "search" +CONF_OFFSET = "offset" +CONF_IGNORE_AVAILABILITY = "ignore_availability" +CONF_MAX_RESULTS = "max_results" + +DEFAULT_CONF_TRACK_NEW = True +DEFAULT_CONF_OFFSET = "!!" + +EVENT_CALENDAR_ID = "calendar_id" +EVENT_DESCRIPTION = "description" +EVENT_END_CONF = "end" +EVENT_END_DATE = "end_date" +EVENT_END_DATETIME = "end_date_time" +EVENT_IN = "in" +EVENT_IN_DAYS = "days" +EVENT_IN_WEEKS = "weeks" +EVENT_START_CONF = "start" +EVENT_START_DATE = "start_date" +EVENT_START_DATETIME = "start_date_time" +EVENT_SUMMARY = "summary" +EVENT_TYPES_CONF = "event_types" + +NOTIFICATION_ID = "google_calendar_notification" +NOTIFICATION_TITLE = "Google Calendar Setup" +GROUP_NAME_ALL_CALENDARS = "Google Calendar Sensors" + +SERVICE_SCAN_CALENDARS = "scan_for_calendars" +SERVICE_FOUND_CALENDARS = "found_calendar" +SERVICE_ADD_EVENT = "add_event" + +DATA_INDEX = "google_calendars" + +YAML_DEVICES = f"{DOMAIN}_calendars.yaml" +SCOPES = "https://www.googleapis.com/auth/calendar" + +TOKEN_FILE = f".{DOMAIN}.token" + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_CLIENT_ID): cv.string, + vol.Required(CONF_CLIENT_SECRET): cv.string, + vol.Optional(CONF_TRACK_NEW): cv.boolean, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + +_SINGLE_CALSEARCH_CONFIG = vol.Schema( + { + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_DEVICE_ID): cv.string, + vol.Optional(CONF_IGNORE_AVAILABILITY, default=True): cv.boolean, + vol.Optional(CONF_OFFSET): cv.string, + vol.Optional(CONF_SEARCH): cv.string, + vol.Optional(CONF_TRACK): cv.boolean, + vol.Optional(CONF_MAX_RESULTS): cv.positive_int, + } +) + +DEVICE_SCHEMA = vol.Schema( + { + vol.Required(CONF_CAL_ID): cv.string, + vol.Required(CONF_ENTITIES, None): vol.All( + cv.ensure_list, [_SINGLE_CALSEARCH_CONFIG] + ), + }, + extra=vol.ALLOW_EXTRA, +) + +_EVENT_IN_TYPES = vol.Schema( + { + vol.Exclusive(EVENT_IN_DAYS, EVENT_TYPES_CONF): cv.positive_int, + vol.Exclusive(EVENT_IN_WEEKS, EVENT_TYPES_CONF): cv.positive_int, + } +) + +ADD_EVENT_SERVICE_SCHEMA = vol.Schema( + { + vol.Required(EVENT_CALENDAR_ID): cv.string, + vol.Required(EVENT_SUMMARY): cv.string, + vol.Optional(EVENT_DESCRIPTION, default=""): cv.string, + vol.Exclusive(EVENT_START_DATE, EVENT_START_CONF): cv.date, + vol.Exclusive(EVENT_END_DATE, EVENT_END_CONF): cv.date, + vol.Exclusive(EVENT_START_DATETIME, EVENT_START_CONF): cv.datetime, + vol.Exclusive(EVENT_END_DATETIME, EVENT_END_CONF): cv.datetime, + vol.Exclusive(EVENT_IN, EVENT_START_CONF, EVENT_END_CONF): _EVENT_IN_TYPES, + } +) + + +def do_authentication(hass, hass_config, config): + """Notify user of actions and authenticate. + + Notify user of user_code and verification_url then poll + until we have an access token. + """ + oauth = OAuth2WebServerFlow( + client_id=config[CONF_CLIENT_ID], + client_secret=config[CONF_CLIENT_SECRET], + scope="https://www.googleapis.com/auth/calendar", + redirect_uri="Home-Assistant.io", + ) + try: + dev_flow = oauth.step1_get_device_and_user_codes() + except OAuth2DeviceCodeError as err: + hass.components.persistent_notification.create( + "Error: {}
You will need to restart hass after fixing." "".format(err), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID, + ) + return False + + hass.components.persistent_notification.create( + "In order to authorize Home-Assistant to view your calendars " + 'you must visit: {} and enter ' + "code: {}".format( + dev_flow.verification_url, dev_flow.verification_url, dev_flow.user_code + ), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID, + ) + + def step2_exchange(now): + """Keep trying to validate the user_code until it expires.""" + if now >= dt.as_local(dev_flow.user_code_expiry): + hass.components.persistent_notification.create( + "Authentication code expired, please restart " + "Home-Assistant and try again", + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID, + ) + listener() + + try: + credentials = oauth.step2_exchange(device_flow_info=dev_flow) + except FlowExchangeError: + # not ready yet, call again + return + + storage = Storage(hass.config.path(TOKEN_FILE)) + storage.put(credentials) + do_setup(hass, hass_config, config) + listener() + hass.components.persistent_notification.create( + "We are all setup now. Check {} for calendars that have " + "been found".format(YAML_DEVICES), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID, + ) + + listener = track_time_change( + hass, step2_exchange, second=range(0, 60, dev_flow.interval) + ) + + return True + + +def setup(hass, config): + """Set up the Google platform.""" + if DATA_INDEX not in hass.data: + hass.data[DATA_INDEX] = {} + + conf = config.get(DOMAIN, {}) + if not conf: + # component is set up by tts platform + return True + + token_file = hass.config.path(TOKEN_FILE) + if not os.path.isfile(token_file): + do_authentication(hass, config, conf) + else: + if not check_correct_scopes(token_file): + do_authentication(hass, config, conf) + else: + do_setup(hass, config, conf) + + return True + + +def check_correct_scopes(token_file): + """Check for the correct scopes in file.""" + tokenfile = open(token_file, "r").read() + if "readonly" in tokenfile: + _LOGGER.warning("Please re-authenticate with Google.") + return False + return True + + +def setup_services(hass, hass_config, track_new_found_calendars, calendar_service): + """Set up the service listeners.""" + + def _found_calendar(call): + """Check if we know about a calendar and generate PLATFORM_DISCOVER.""" + calendar = get_calendar_info(hass, call.data) + if hass.data[DATA_INDEX].get(calendar[CONF_CAL_ID], None) is not None: + return + + hass.data[DATA_INDEX].update({calendar[CONF_CAL_ID]: calendar}) + + update_config( + hass.config.path(YAML_DEVICES), hass.data[DATA_INDEX][calendar[CONF_CAL_ID]] + ) + + discovery.load_platform( + hass, + "calendar", + DOMAIN, + hass.data[DATA_INDEX][calendar[CONF_CAL_ID]], + hass_config, + ) + + hass.services.register(DOMAIN, SERVICE_FOUND_CALENDARS, _found_calendar) + + def _scan_for_calendars(service): + """Scan for new calendars.""" + service = calendar_service.get() + cal_list = service.calendarList() + calendars = cal_list.list().execute()["items"] + for calendar in calendars: + calendar["track"] = track_new_found_calendars + hass.services.call(DOMAIN, SERVICE_FOUND_CALENDARS, calendar) + + hass.services.register(DOMAIN, SERVICE_SCAN_CALENDARS, _scan_for_calendars) + + def _add_event(call): + """Add a new event to calendar.""" + service = calendar_service.get() + start = {} + end = {} + + if EVENT_IN in call.data: + if EVENT_IN_DAYS in call.data[EVENT_IN]: + now = datetime.now() + + start_in = now + timedelta(days=call.data[EVENT_IN][EVENT_IN_DAYS]) + end_in = start_in + timedelta(days=1) + + start = {"date": start_in.strftime("%Y-%m-%d")} + end = {"date": end_in.strftime("%Y-%m-%d")} + + elif EVENT_IN_WEEKS in call.data[EVENT_IN]: + now = datetime.now() + + start_in = now + timedelta(weeks=call.data[EVENT_IN][EVENT_IN_WEEKS]) + end_in = start_in + timedelta(days=1) + + start = {"date": start_in.strftime("%Y-%m-%d")} + end = {"date": end_in.strftime("%Y-%m-%d")} + + elif EVENT_START_DATE in call.data: + start = {"date": str(call.data[EVENT_START_DATE])} + end = {"date": str(call.data[EVENT_END_DATE])} + + elif EVENT_START_DATETIME in call.data: + start_dt = str( + call.data[EVENT_START_DATETIME].strftime("%Y-%m-%dT%H:%M:%S") + ) + end_dt = str(call.data[EVENT_END_DATETIME].strftime("%Y-%m-%dT%H:%M:%S")) + start = {"dateTime": start_dt, "timeZone": str(hass.config.time_zone)} + end = {"dateTime": end_dt, "timeZone": str(hass.config.time_zone)} + + event = { + "summary": call.data[EVENT_SUMMARY], + "description": call.data[EVENT_DESCRIPTION], + "start": start, + "end": end, + } + service_data = {"calendarId": call.data[EVENT_CALENDAR_ID], "body": event} + event = service.events().insert(**service_data).execute() + + hass.services.register( + DOMAIN, SERVICE_ADD_EVENT, _add_event, schema=ADD_EVENT_SERVICE_SCHEMA + ) + return True + + +def do_setup(hass, hass_config, config): + """Run the setup after we have everything configured.""" + # Load calendars the user has configured + hass.data[DATA_INDEX] = load_config(hass.config.path(YAML_DEVICES)) + + calendar_service = GoogleCalendarService(hass.config.path(TOKEN_FILE)) + track_new_found_calendars = convert( + config.get(CONF_TRACK_NEW), bool, DEFAULT_CONF_TRACK_NEW + ) + setup_services(hass, hass_config, track_new_found_calendars, calendar_service) + + for calendar in hass.data[DATA_INDEX].values(): + discovery.load_platform(hass, "calendar", DOMAIN, calendar, hass_config) + + # Look for any new calendars + hass.services.call(DOMAIN, SERVICE_SCAN_CALENDARS, None) + return True + + +class GoogleCalendarService: + """Calendar service interface to Google.""" + + def __init__(self, token_file): + """Init the Google Calendar service.""" + self.token_file = token_file + + def get(self): + """Get the calendar service from the storage file token.""" + credentials = Storage(self.token_file).get() + http = credentials.authorize(httplib2.Http()) + service = google_discovery.build( + "calendar", "v3", http=http, cache_discovery=False + ) + return service + + +def get_calendar_info(hass, calendar): + """Convert data from Google into DEVICE_SCHEMA.""" + calendar_info = DEVICE_SCHEMA( + { + CONF_CAL_ID: calendar["id"], + CONF_ENTITIES: [ + { + CONF_TRACK: calendar["track"], + CONF_NAME: calendar["summary"], + CONF_DEVICE_ID: generate_entity_id( + "{}", calendar["summary"], hass=hass + ), + } + ], + } + ) + return calendar_info + + +def load_config(path): + """Load the google_calendar_devices.yaml.""" + calendars = {} + try: + with open(path) as file: + data = yaml.safe_load(file) + for calendar in data: + try: + calendars.update({calendar[CONF_CAL_ID]: DEVICE_SCHEMA(calendar)}) + except VoluptuousError as exception: + # keep going + _LOGGER.warning("Calendar Invalid Data: %s", exception) + except FileNotFoundError: + # When YAML file could not be loaded/did not contain a dict + return {} + + return calendars + + +def update_config(path, calendar): + """Write the google_calendar_devices.yaml.""" + with open(path, "a") as out: + out.write("\n") + yaml.dump([calendar], out, default_flow_style=False) diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py new file mode 100644 index 000000000..8a6eb6446 --- /dev/null +++ b/homeassistant/components/google/calendar.py @@ -0,0 +1,189 @@ +"""Support for Google Calendar Search binary sensors.""" +import copy +from datetime import timedelta +import logging + +from httplib2 import ServerNotFoundError # pylint: disable=import-error + +from homeassistant.components.calendar import ( + ENTITY_ID_FORMAT, + CalendarEventDevice, + calculate_offset, + is_offset_reached, +) +from homeassistant.helpers.entity import generate_entity_id +from homeassistant.util import Throttle, dt + +from . import ( + CONF_CAL_ID, + CONF_DEVICE_ID, + CONF_ENTITIES, + CONF_IGNORE_AVAILABILITY, + CONF_MAX_RESULTS, + CONF_NAME, + CONF_OFFSET, + CONF_SEARCH, + CONF_TRACK, + DEFAULT_CONF_OFFSET, + TOKEN_FILE, + GoogleCalendarService, +) + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_GOOGLE_SEARCH_PARAMS = { + "orderBy": "startTime", + "maxResults": 5, + "singleEvents": True, +} + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) + + +def setup_platform(hass, config, add_entities, disc_info=None): + """Set up the calendar platform for event devices.""" + if disc_info is None: + return + + if not any(data[CONF_TRACK] for data in disc_info[CONF_ENTITIES]): + return + + calendar_service = GoogleCalendarService(hass.config.path(TOKEN_FILE)) + entities = [] + for data in disc_info[CONF_ENTITIES]: + if not data[CONF_TRACK]: + continue + entity_id = generate_entity_id( + ENTITY_ID_FORMAT, data[CONF_DEVICE_ID], hass=hass + ) + entity = GoogleCalendarEventDevice( + calendar_service, disc_info[CONF_CAL_ID], data, entity_id + ) + entities.append(entity) + + add_entities(entities, True) + + +class GoogleCalendarEventDevice(CalendarEventDevice): + """A calendar event device.""" + + def __init__(self, calendar_service, calendar, data, entity_id): + """Create the Calendar event device.""" + self.data = GoogleCalendarData( + calendar_service, + calendar, + data.get(CONF_SEARCH), + data.get(CONF_IGNORE_AVAILABILITY), + data.get(CONF_MAX_RESULTS), + ) + self._event = None + self._name = data[CONF_NAME] + self._offset = data.get(CONF_OFFSET, DEFAULT_CONF_OFFSET) + self._offset_reached = False + self.entity_id = entity_id + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + return {"offset_reached": self._offset_reached} + + @property + def event(self): + """Return the next upcoming event.""" + return self._event + + @property + def name(self): + """Return the name of the entity.""" + return self._name + + async def async_get_events(self, hass, start_date, end_date): + """Get all events in a specific time frame.""" + return await self.data.async_get_events(hass, start_date, end_date) + + def update(self): + """Update event data.""" + self.data.update() + event = copy.deepcopy(self.data.event) + if event is None: + self._event = event + return + event = calculate_offset(event, self._offset) + self._offset_reached = is_offset_reached(event) + self._event = event + + +class GoogleCalendarData: + """Class to utilize calendar service object to get next event.""" + + def __init__( + self, calendar_service, calendar_id, search, ignore_availability, max_results + ): + """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.max_results = max_results + self.event = None + + def _prepare_query(self): + try: + service = self.calendar_service.get() + except ServerNotFoundError: + _LOGGER.error("Unable to connect to Google") + return None, None + params = dict(DEFAULT_GOOGLE_SEARCH_PARAMS) + params["calendarId"] = self.calendar_id + if self.max_results: + params["maxResults"] = self.max_results + if self.search: + params["q"] = self.search + + return service, params + + async def async_get_events(self, hass, start_date, end_date): + """Get all events in a specific time frame.""" + service, params = await hass.async_add_executor_job(self._prepare_query) + if service is None: + return [] + params["timeMin"] = start_date.isoformat("T") + params["timeMax"] = end_date.isoformat("T") + + events = await hass.async_add_executor_job(service.events) + result = await hass.async_add_executor_job(events.list(**params).execute) + + items = result.get("items", []) + event_list = [] + for item in items: + if not self.ignore_availability and "transparency" in item.keys(): + if item["transparency"] == "opaque": + event_list.append(item) + else: + event_list.append(item) + return event_list + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data.""" + service, params = self._prepare_query() + if service is None: + return + params["timeMin"] = dt.now().isoformat("T") + + events = service.events() + result = events.list(**params).execute() + + items = result.get("items", []) + + 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 diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json new file mode 100644 index 000000000..d72cc992f --- /dev/null +++ b/homeassistant/components/google/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "google", + "name": "Google", + "documentation": "https://www.home-assistant.io/integrations/google", + "requirements": [ + "google-api-python-client==1.6.4", + "httplib2==0.10.3", + "oauth2client==4.0.0" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/google/services.yaml b/homeassistant/components/google/services.yaml new file mode 100644 index 000000000..048e886dc --- /dev/null +++ b/homeassistant/components/google/services.yaml @@ -0,0 +1,31 @@ +found_calendar: + description: Add calendar if it has not been already discovered. +scan_for_calendars: + description: Scan for new calendars. +add_event: + description: Add a new calendar event. + fields: + calendar_id: + description: The id of the calendar you want. + example: 'Your email' + summary: + description: Acts as the title of the event. + example: 'Bowling' + description: + description: The description of the event. Optional. + example: 'Birthday bowling' + start_date_time: + description: The date and time the event should start. + example: '2019-03-22 20:00:00' + end_date_time: + description: The date and time the event should end. + example: '2019-03-22 22:00:00' + start_date: + description: The date the whole day event should start. + example: '2019-03-10' + end_date: + description: The date the whole day event should end. + example: '2019-03-11' + in: + description: Days or weeks that you want to create the event in. + example: '"days": 2 or "weeks": 2' \ No newline at end of file diff --git a/homeassistant/components/google_assistant/__init__.py b/homeassistant/components/google_assistant/__init__.py index 22569af1f..107003db5 100644 --- a/homeassistant/components/google_assistant/__init__.py +++ b/homeassistant/components/google_assistant/__init__.py @@ -1,103 +1,123 @@ -""" -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 +"""Support for Actions on Google Assistant Smart Home Control.""" import logging -from typing import Dict, Any - -import aiohttp -import async_timeout +from typing import Any, Dict import voluptuous as vol # Typing imports -from homeassistant.core import HomeAssistant - from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant, ServiceCall 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 + CONF_ALIASES, + CONF_ALLOW_UNLOCK, + CONF_API_KEY, + CONF_CLIENT_EMAIL, + CONF_ENTITY_CONFIG, + CONF_EXPOSE, + CONF_EXPOSE_BY_DEFAULT, + CONF_EXPOSED_DOMAINS, + CONF_PRIVATE_KEY, + CONF_PROJECT_ID, + CONF_REPORT_STATE, + CONF_ROOM_HINT, + CONF_SECURE_DEVICES_PIN, + CONF_SERVICE_ACCOUNT, + DEFAULT_EXPOSE_BY_DEFAULT, + DEFAULT_EXPOSED_DOMAINS, + DOMAIN, + SERVICE_REQUEST_SYNC, ) -from .auth import GoogleAssistantAuthView -from .http import async_register_http +from .const import EVENT_QUERY_RECEIVED # noqa: F401 +from .http import GoogleAssistantView, GoogleConfig + +from .const import EVENT_COMMAND_RECEIVED, EVENT_SYNC_RECEIVED # noqa: F401, isort:skip _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( +ENTITY_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} - } + 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, + } +) + +GOOGLE_SERVICE_ACCOUNT = vol.Schema( + { + vol.Required(CONF_PRIVATE_KEY): cv.string, + vol.Required(CONF_CLIENT_EMAIL): cv.string, }, - extra=vol.ALLOW_EXTRA) + extra=vol.ALLOW_EXTRA, +) -@bind_hass -def request_sync(hass): - """Request sync.""" - hass.services.call(DOMAIN, SERVICE_REQUEST_SYNC) +def _check_report_state(data): + if data[CONF_REPORT_STATE]: + if CONF_SERVICE_ACCOUNT not in data: + raise vol.Invalid( + "If report state is enabled, a service account must exist" + ) + return data + + +GOOGLE_ASSISTANT_SCHEMA = vol.All( + cv.deprecated(CONF_ALLOW_UNLOCK, invalidation_version="0.95"), + vol.Schema( + { + vol.Required(CONF_PROJECT_ID): 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_API_KEY): cv.string, + vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: ENTITY_SCHEMA}, + vol.Optional(CONF_ALLOW_UNLOCK): cv.boolean, + # str on purpose, makes sure it is configured correctly. + vol.Optional(CONF_SECURE_DEVICES_PIN): str, + vol.Optional(CONF_REPORT_STATE, default=False): cv.boolean, + vol.Optional(CONF_SERVICE_ACCOUNT): GOOGLE_SERVICE_ACCOUNT, + }, + extra=vol.PREVENT_EXTRA, + ), + _check_report_state, +) + +CONFIG_SCHEMA = vol.Schema({DOMAIN: GOOGLE_ASSISTANT_SCHEMA}, extra=vol.ALLOW_EXTRA) 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): + google_config = GoogleConfig(hass, config) + await google_config.async_initialize() + + hass.http.register_view(GoogleAssistantView(google_config)) + + if google_config.should_report_state: + google_config.async_enable_report_state() + + async def request_sync_service_handler(call: ServiceCall): """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") + agent_user_id = call.data.get("agent_user_id") or call.context.user_id - # Register service only if api key is provided - if api_key is not None: + if agent_user_id is None: + _LOGGER.warning( + "No agent_user_id supplied for request_sync. Call as a user or pass in user id as agent_user_id." + ) + return + + await google_config.async_sync_entities(agent_user_id) + + # Register service only if key is provided + if CONF_API_KEY in config or CONF_SERVICE_ACCOUNT in config: hass.services.async_register( - DOMAIN, SERVICE_REQUEST_SYNC, request_sync_service_handler) + 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 deleted file mode 100644 index 5b98e2501..000000000 --- a/homeassistant/components/google_assistant/auth.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Google Assistant OAuth View.""" - -import logging -from typing import Dict, Any - -# Typing imports -# if False: -from aiohttp.web import Request, Response - -from homeassistant.core import HomeAssistant -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 index 12888ea2c..dcb87d1d9 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -1,42 +1,145 @@ """Constants for Google Assistant.""" -DOMAIN = 'google_assistant' +from homeassistant.components import ( + alarm_control_panel, + binary_sensor, + camera, + climate, + cover, + fan, + group, + input_boolean, + light, + lock, + media_player, + scene, + script, + sensor, + switch, + vacuum, +) -GOOGLE_ASSISTANT_API_ENDPOINT = '/api/google_assistant' +DOMAIN = "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' +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_ALIASES = "aliases" +CONF_API_KEY = "api_key" +CONF_ROOM_HINT = "room" +CONF_ALLOW_UNLOCK = "allow_unlock" +CONF_SECURE_DEVICES_PIN = "secure_devices_pin" +CONF_REPORT_STATE = "report_state" +CONF_SERVICE_ACCOUNT = "service_account" +CONF_CLIENT_EMAIL = "client_email" +CONF_PRIVATE_KEY = "private_key" DEFAULT_EXPOSE_BY_DEFAULT = True DEFAULT_EXPOSED_DOMAINS = [ - 'switch', 'light', 'group', 'media_player', 'fan', 'cover', 'climate' + "climate", + "cover", + "fan", + "group", + "input_boolean", + "light", + "media_player", + "scene", + "script", + "switch", + "vacuum", + "lock", + "binary_sensor", + "sensor", + "alarm_control_panel", ] -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' +PREFIX_TYPES = "action.devices.types." +TYPE_CAMERA = PREFIX_TYPES + "CAMERA" +TYPE_LIGHT = PREFIX_TYPES + "LIGHT" +TYPE_SWITCH = PREFIX_TYPES + "SWITCH" +TYPE_VACUUM = PREFIX_TYPES + "VACUUM" +TYPE_SCENE = PREFIX_TYPES + "SCENE" +TYPE_FAN = PREFIX_TYPES + "FAN" +TYPE_THERMOSTAT = PREFIX_TYPES + "THERMOSTAT" +TYPE_LOCK = PREFIX_TYPES + "LOCK" +TYPE_BLINDS = PREFIX_TYPES + "BLINDS" +TYPE_GARAGE = PREFIX_TYPES + "GARAGE" +TYPE_OUTLET = PREFIX_TYPES + "OUTLET" +TYPE_SENSOR = PREFIX_TYPES + "SENSOR" +TYPE_DOOR = PREFIX_TYPES + "DOOR" +TYPE_TV = PREFIX_TYPES + "TV" +TYPE_SPEAKER = PREFIX_TYPES + "SPEAKER" +TYPE_ALARM = PREFIX_TYPES + "SECURITYSYSTEM" -SERVICE_REQUEST_SYNC = 'request_sync' -HOMEGRAPH_URL = 'https://homegraph.googleapis.com/' -REQUEST_SYNC_BASE_URL = HOMEGRAPH_URL + 'v1/devices:requestSync' +SERVICE_REQUEST_SYNC = "request_sync" +HOMEGRAPH_URL = "https://homegraph.googleapis.com/" +HOMEGRAPH_SCOPE = "https://www.googleapis.com/auth/homegraph" +HOMEGRAPH_TOKEN_URL = "https://accounts.google.com/o/oauth2/token" +REQUEST_SYNC_BASE_URL = HOMEGRAPH_URL + "v1/devices:requestSync" +REPORT_STATE_BASE_URL = HOMEGRAPH_URL + "v1/devices:reportStateAndNotification" # Error codes used for SmartHomeError class -# https://developers.google.com/actions/smarthome/create-app#error_responses +# https://developers.google.com/actions/reference/smarthome/errors-exceptions 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' +ERR_PROTOCOL_ERROR = "protocolError" +ERR_UNKNOWN_ERROR = "unknownError" +ERR_FUNCTION_NOT_SUPPORTED = "functionNotSupported" + +ERR_ALREADY_DISARMED = "alreadyDisarmed" +ERR_ALREADY_ARMED = "alreadyArmed" + +ERR_CHALLENGE_NEEDED = "challengeNeeded" +ERR_CHALLENGE_NOT_SETUP = "challengeFailedNotSetup" +ERR_TOO_MANY_FAILED_ATTEMPTS = "tooManyFailedAttempts" +ERR_PIN_INCORRECT = "pinIncorrect" +ERR_USER_CANCELLED = "userCancelled" + +# Event types +EVENT_COMMAND_RECEIVED = "google_assistant_command" +EVENT_QUERY_RECEIVED = "google_assistant_query" +EVENT_SYNC_RECEIVED = "google_assistant_sync" + +DOMAIN_TO_GOOGLE_TYPES = { + camera.DOMAIN: TYPE_CAMERA, + climate.DOMAIN: TYPE_THERMOSTAT, + cover.DOMAIN: TYPE_BLINDS, + fan.DOMAIN: TYPE_FAN, + group.DOMAIN: TYPE_SWITCH, + input_boolean.DOMAIN: TYPE_SWITCH, + light.DOMAIN: TYPE_LIGHT, + lock.DOMAIN: TYPE_LOCK, + media_player.DOMAIN: TYPE_SWITCH, + scene.DOMAIN: TYPE_SCENE, + script.DOMAIN: TYPE_SCENE, + switch.DOMAIN: TYPE_SWITCH, + vacuum.DOMAIN: TYPE_VACUUM, + alarm_control_panel.DOMAIN: TYPE_ALARM, +} + +DEVICE_CLASS_TO_GOOGLE_TYPES = { + (cover.DOMAIN, cover.DEVICE_CLASS_GARAGE): TYPE_GARAGE, + (cover.DOMAIN, cover.DEVICE_CLASS_DOOR): TYPE_DOOR, + (switch.DOMAIN, switch.DEVICE_CLASS_SWITCH): TYPE_SWITCH, + (switch.DOMAIN, switch.DEVICE_CLASS_OUTLET): TYPE_OUTLET, + (binary_sensor.DOMAIN, binary_sensor.DEVICE_CLASS_DOOR): TYPE_DOOR, + (binary_sensor.DOMAIN, binary_sensor.DEVICE_CLASS_GARAGE_DOOR): TYPE_GARAGE, + (binary_sensor.DOMAIN, binary_sensor.DEVICE_CLASS_LOCK): TYPE_SENSOR, + (binary_sensor.DOMAIN, binary_sensor.DEVICE_CLASS_OPENING): TYPE_SENSOR, + (binary_sensor.DOMAIN, binary_sensor.DEVICE_CLASS_WINDOW): TYPE_SENSOR, + (media_player.DOMAIN, media_player.DEVICE_CLASS_TV): TYPE_TV, + (media_player.DOMAIN, media_player.DEVICE_CLASS_SPEAKER): TYPE_SPEAKER, + (sensor.DOMAIN, sensor.DEVICE_CLASS_TEMPERATURE): TYPE_SENSOR, + (sensor.DOMAIN, sensor.DEVICE_CLASS_HUMIDITY): TYPE_SENSOR, +} + +CHALLENGE_ACK_NEEDED = "ackNeeded" +CHALLENGE_PIN_NEEDED = "pinNeeded" +CHALLENGE_FAILED_PIN_NEEDED = "challengeFailedPinNeeded" + +STORE_AGENT_USER_IDS = "agent_user_ids" diff --git a/homeassistant/components/google_assistant/error.py b/homeassistant/components/google_assistant/error.py new file mode 100644 index 000000000..82c256067 --- /dev/null +++ b/homeassistant/components/google_assistant/error.py @@ -0,0 +1,37 @@ +"""Errors for Google Assistant.""" +from .const import ERR_CHALLENGE_NEEDED + + +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 + + def to_response(self): + """Convert to a response format.""" + return {"errorCode": self.code} + + +class ChallengeNeeded(SmartHomeError): + """Google Assistant Smart Home errors. + + https://developers.google.com/actions/smarthome/create-app#error_responses + """ + + def __init__(self, challenge_type): + """Initialize challenge needed error.""" + super().__init__(ERR_CHALLENGE_NEEDED, f"Challenge needed: {challenge_type}") + self.challenge_type = challenge_type + + def to_response(self): + """Convert to a response format.""" + return { + "errorCode": self.code, + "challengeNeeded": {"type": self.challenge_type}, + } diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index ef6ae109e..8a847eca7 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -1,23 +1,502 @@ """Helper classes for Google Assistant integration.""" +from asyncio import gather +from collections.abc import Mapping +import logging +import pprint +from typing import List, Optional + +from aiohttp.web import json_response + +from homeassistant.components import webhook +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_SUPPORTED_FEATURES, + CLOUD_NEVER_EXPOSED_ENTITIES, + CONF_NAME, + STATE_UNAVAILABLE, +) +from homeassistant.core import Context, HomeAssistant, State, callback +from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.storage import Store + +from . import trait +from .const import ( + CONF_ALIASES, + CONF_ROOM_HINT, + DEVICE_CLASS_TO_GOOGLE_TYPES, + DOMAIN, + DOMAIN_TO_GOOGLE_TYPES, + ERR_FUNCTION_NOT_SUPPORTED, + STORE_AGENT_USER_IDS, +) +from .error import SmartHomeError + +SYNC_DELAY = 15 +_LOGGER = logging.getLogger(__name__) -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: +class AbstractConfig: """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 {} + _unsub_report_state = None + + def __init__(self, hass): + """Initialize abstract config.""" + self.hass = hass + self._store = None + self._google_sync_unsub = {} + self._local_sdk_active = False + + async def async_initialize(self): + """Perform async initialization of config.""" + self._store = GoogleConfigStore(self.hass) + await self._store.async_load() + + @property + def enabled(self): + """Return if Google is enabled.""" + return False + + @property + def entity_config(self): + """Return entity config.""" + return {} + + @property + def secure_devices_pin(self): + """Return entity config.""" + return None + + @property + def is_reporting_state(self): + """Return if we're actively reporting states.""" + return self._unsub_report_state is not None + + @property + def is_local_sdk_active(self): + """Return if we're actively accepting local messages.""" + return self._local_sdk_active + + @property + def should_report_state(self): + """Return if states should be proactively reported.""" + return False + + @property + def local_sdk_webhook_id(self): + """Return the local SDK webhook ID. + + Return None to disable the local SDK. + """ + return None + + @property + def local_sdk_user_id(self): + """Return the user ID to be used for actions received via the local SDK.""" + raise NotImplementedError + + def should_expose(self, state) -> bool: + """Return if entity should be exposed.""" + raise NotImplementedError + + def should_2fa(self, state): + """If an entity should have 2FA checked.""" + # pylint: disable=no-self-use + return True + + async def async_report_state(self, message, agent_user_id: str): + """Send a state report to Google.""" + raise NotImplementedError + + async def async_report_state_all(self, message): + """Send a state report to Google for all previously synced users.""" + jobs = [ + self.async_report_state(message, agent_user_id) + for agent_user_id in self._store.agent_user_ids + ] + await gather(*jobs) + + def async_enable_report_state(self): + """Enable proactive mode.""" + # Circular dep + # pylint: disable=import-outside-toplevel + from .report_state import async_enable_report_state + + if self._unsub_report_state is None: + self._unsub_report_state = async_enable_report_state(self.hass, self) + + def async_disable_report_state(self): + """Disable report state.""" + if self._unsub_report_state is not None: + self._unsub_report_state() + self._unsub_report_state = None + + async def async_sync_entities(self, agent_user_id: str): + """Sync all entities to Google.""" + # Remove any pending sync + self._google_sync_unsub.pop(agent_user_id, lambda: None)() + return await self._async_request_sync_devices(agent_user_id) + + async def async_sync_entities_all(self): + """Sync all entities to Google for all registered agents.""" + res = await gather( + *[ + self.async_sync_entities(agent_user_id) + for agent_user_id in self._store.agent_user_ids + ] + ) + return max(res, default=204) + + @callback + def async_schedule_google_sync(self, agent_user_id: str): + """Schedule a sync.""" + + async def _schedule_callback(_now): + """Handle a scheduled sync callback.""" + self._google_sync_unsub.pop(agent_user_id, None) + await self.async_sync_entities(agent_user_id) + + self._google_sync_unsub.pop(agent_user_id, lambda: None)() + + self._google_sync_unsub[agent_user_id] = async_call_later( + self.hass, SYNC_DELAY, _schedule_callback + ) + + @callback + def async_schedule_google_sync_all(self): + """Schedule a sync for all registered agents.""" + for agent_user_id in self._store.agent_user_ids: + self.async_schedule_google_sync(agent_user_id) + + async def _async_request_sync_devices(self, agent_user_id: str) -> int: + """Trigger a sync with Google. + + Return value is the HTTP status code of the sync request. + """ + raise NotImplementedError + + async def async_connect_agent_user(self, agent_user_id: str): + """Add an synced and known agent_user_id. + + Called when a completed sync response have been sent to Google. + """ + self._store.add_agent_user_id(agent_user_id) + + async def async_disconnect_agent_user(self, agent_user_id: str): + """Turn off report state and disable further state reporting. + + Called when the user disconnects their account from Google. + """ + self._store.pop_agent_user_id(agent_user_id) + + @callback + def async_enable_local_sdk(self): + """Enable the local SDK.""" + webhook_id = self.local_sdk_webhook_id + + if webhook_id is None: + return + + webhook.async_register( + self.hass, DOMAIN, "Local Support", webhook_id, self._handle_local_webhook, + ) + + self._local_sdk_active = True + + @callback + def async_disable_local_sdk(self): + """Disable the local SDK.""" + if not self._local_sdk_active: + return + + webhook.async_unregister(self.hass, self.local_sdk_webhook_id) + self._local_sdk_active = False + + async def _handle_local_webhook(self, hass, webhook_id, request): + """Handle an incoming local SDK message.""" + # Circular dep + # pylint: disable=import-outside-toplevel + from . import smart_home + + payload = await request.json() + + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug("Received local message:\n%s\n", pprint.pformat(payload)) + + if not self.enabled: + return json_response(smart_home.turned_off_response(payload)) + + result = await smart_home.async_handle_message( + self.hass, self, self.local_sdk_user_id, payload + ) + + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug("Responding to local message:\n%s\n", pprint.pformat(result)) + + return json_response(result) + + +class GoogleConfigStore: + """A configuration store for google assistant.""" + + _STORAGE_VERSION = 1 + _STORAGE_KEY = DOMAIN + + def __init__(self, hass): + """Initialize a configuration store.""" + self._hass = hass + self._store = Store(hass, self._STORAGE_VERSION, self._STORAGE_KEY) + self._data = {STORE_AGENT_USER_IDS: {}} + + @property + def agent_user_ids(self): + """Return a list of connected agent user_ids.""" + return self._data[STORE_AGENT_USER_IDS] + + @callback + def add_agent_user_id(self, agent_user_id): + """Add an agent user id to store.""" + if agent_user_id not in self._data[STORE_AGENT_USER_IDS]: + self._data[STORE_AGENT_USER_IDS][agent_user_id] = {} + self._store.async_delay_save(lambda: self._data, 1.0) + + @callback + def pop_agent_user_id(self, agent_user_id): + """Remove agent user id from store.""" + if agent_user_id in self._data[STORE_AGENT_USER_IDS]: + self._data[STORE_AGENT_USER_IDS].pop(agent_user_id, None) + self._store.async_delay_save(lambda: self._data, 1.0) + + async def async_load(self): + """Store current configuration to disk.""" + data = await self._store.async_load() + if data: + self._data = data + + +class RequestData: + """Hold data associated with a particular request.""" + + def __init__( + self, + config: AbstractConfig, + user_id: str, + request_id: str, + devices: Optional[List[dict]], + ): + """Initialize the request data.""" + self.config = config + self.request_id = request_id + self.context = Context(user_id=user_id) + self.devices = devices + + +def get_google_type(domain, device_class): + """Google type based on domain and device class.""" + typ = DEVICE_CLASS_TO_GOOGLE_TYPES.get((domain, device_class)) + + return typ if typ is not None else DOMAIN_TO_GOOGLE_TYPES[domain] + + +class GoogleEntity: + """Adaptation of Entity expressed in Google's terms.""" + + def __init__(self, hass: HomeAssistant, config: AbstractConfig, state: State): + """Initialize a Google entity.""" + self.hass = hass + self.config = config + self.state = state + self._traits = None + + @property + def entity_id(self): + """Return entity ID.""" + return self.state.entity_id + + @callback + def traits(self): + """Return traits for entity.""" + if self._traits is not None: + return self._traits + + state = self.state + domain = state.domain + features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + device_class = state.attributes.get(ATTR_DEVICE_CLASS) + + self._traits = [ + Trait(self.hass, state, self.config) + for Trait in trait.TRAITS + if Trait.supported(domain, features, device_class) + ] + return self._traits + + @callback + def should_expose(self): + """If entity should be exposed.""" + return self.config.should_expose(self.state) + + @callback + def is_supported(self) -> bool: + """Return if the entity is supported by Google.""" + return bool(self.traits()) + + @callback + def might_2fa(self) -> bool: + """Return if the entity might encounter 2FA.""" + state = self.state + domain = state.domain + features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + device_class = state.attributes.get(ATTR_DEVICE_CLASS) + + return any( + trait.might_2fa(domain, features, device_class) for trait in self.traits() + ) + + async def sync_serialize(self, agent_user_id): + """Serialize entity for a SYNC response. + + https://developers.google.com/actions/smarthome/create-app#actiondevicessync + """ + state = self.state + + entity_config = self.config.entity_config.get(state.entity_id, {}) + name = (entity_config.get(CONF_NAME) or state.name).strip() + domain = state.domain + device_class = state.attributes.get(ATTR_DEVICE_CLASS) + + traits = self.traits() + + device_type = get_google_type(domain, device_class) + + device = { + "id": state.entity_id, + "name": {"name": name}, + "attributes": {}, + "traits": [trait.name for trait in traits], + "willReportState": self.config.should_report_state, + "type": device_type, + } + + # use aliases + aliases = entity_config.get(CONF_ALIASES) + if aliases: + device["name"]["nicknames"] = aliases + + if self.config.is_local_sdk_active: + device["otherDeviceIds"] = [{"deviceId": self.entity_id}] + device["customData"] = { + "webhookId": self.config.local_sdk_webhook_id, + "httpPort": self.hass.config.api.port, + "httpSSL": self.hass.config.api.use_ssl, + "proxyDeviceId": agent_user_id, + } + + for trt in traits: + device["attributes"].update(trt.sync_attributes()) + + room = entity_config.get(CONF_ROOM_HINT) + if room: + device["roomHint"] = room + return device + + dev_reg, ent_reg, area_reg = await gather( + self.hass.helpers.device_registry.async_get_registry(), + self.hass.helpers.entity_registry.async_get_registry(), + self.hass.helpers.area_registry.async_get_registry(), + ) + + entity_entry = ent_reg.async_get(state.entity_id) + if not (entity_entry and entity_entry.device_id): + return device + + device_entry = dev_reg.devices.get(entity_entry.device_id) + if not (device_entry and device_entry.area_id): + return device + + area_entry = area_reg.areas.get(device_entry.area_id) + if area_entry and area_entry.name: + device["roomHint"] = area_entry.name + + 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 + + @callback + def reachable_device_serialize(self): + """Serialize entity for a REACHABLE_DEVICE response.""" + return {"verificationId": self.entity_id} + + async def execute(self, data, command_payload): + """Execute a command. + + https://developers.google.com/actions/smarthome/create-app#actiondevicesexecute + """ + command = command_payload["command"] + params = command_payload.get("params", {}) + challenge = command_payload.get("challenge", {}) + executed = False + for trt in self.traits(): + if trt.can_execute(command, params): + await trt.execute(command, data, params, challenge) + executed = True + break + + if not executed: + raise SmartHomeError( + ERR_FUNCTION_NOT_SUPPORTED, + f"Unable to execute {command} for {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) + + if self._traits is None: + return + + for trt in self._traits: + trt.state = self.state + + +def deep_update(target, source): + """Update a nested dictionary with another nested dictionary.""" + for key, value in source.items(): + if isinstance(value, Mapping): + target[key] = deep_update(target.get(key, {}), value) + else: + target[key] = value + return target + + +@callback +def async_get_entities(hass, config) -> List[GoogleEntity]: + """Return all entities that are supported by Google.""" + entities = [] + for state in hass.states.async_all(): + if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + continue + + entity = GoogleEntity(hass, config, state) + + if entity.is_supported(): + entities.append(entity) + + return entities diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index 05bc3cbd0..233923e97 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -1,86 +1,239 @@ -""" -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/ -""" +"""Support for Google Actions Smart Home Control.""" +import asyncio +from datetime import timedelta import logging +from uuid import uuid4 -from aiohttp.hdrs import AUTHORIZATION +from aiohttp import ClientError, ClientResponseError from aiohttp.web import Request, Response +import jwt # Typing imports from homeassistant.components.http import HomeAssistantView -from homeassistant.core import callback +from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.util import dt as dt_util from .const import ( - GOOGLE_ASSISTANT_API_ENDPOINT, - CONF_ACCESS_TOKEN, - CONF_EXPOSE_BY_DEFAULT, - CONF_EXPOSED_DOMAINS, - CONF_AGENT_USER_ID, + CONF_API_KEY, + CONF_CLIENT_EMAIL, CONF_ENTITY_CONFIG, CONF_EXPOSE, - ) + CONF_EXPOSE_BY_DEFAULT, + CONF_EXPOSED_DOMAINS, + CONF_PRIVATE_KEY, + CONF_REPORT_STATE, + CONF_SECURE_DEVICES_PIN, + CONF_SERVICE_ACCOUNT, + GOOGLE_ASSISTANT_API_ENDPOINT, + HOMEGRAPH_SCOPE, + HOMEGRAPH_TOKEN_URL, + REPORT_STATE_BASE_URL, + REQUEST_SYNC_BASE_URL, +) +from .helpers import AbstractConfig 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 _get_homegraph_jwt(time, iss, key): + now = int(time.timestamp()) - def is_exposed(entity) -> bool: - """Determine if an entity should be exposed to Google Assistant.""" - if entity.attributes.get('view') is not None: + jwt_raw = { + "iss": iss, + "scope": HOMEGRAPH_SCOPE, + "aud": HOMEGRAPH_TOKEN_URL, + "iat": now, + "exp": now + 3600, + } + return jwt.encode(jwt_raw, key, algorithm="RS256").decode("utf-8") + + +async def _get_homegraph_token(hass, jwt_signed): + headers = { + "Authorization": "Bearer {}".format(jwt_signed), + "Content-Type": "application/x-www-form-urlencoded", + } + data = { + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion": jwt_signed, + } + + session = async_get_clientsession(hass) + async with session.post(HOMEGRAPH_TOKEN_URL, headers=headers, data=data) as res: + res.raise_for_status() + return await res.json() + + +class GoogleConfig(AbstractConfig): + """Config for manual setup of Google.""" + + def __init__(self, hass, config): + """Initialize the config.""" + super().__init__(hass) + self._config = config + self._access_token = None + self._access_token_renew = None + + @property + def enabled(self): + """Return if Google is enabled.""" + return True + + @property + def entity_config(self): + """Return entity config.""" + return self._config.get(CONF_ENTITY_CONFIG) or {} + + @property + def secure_devices_pin(self): + """Return entity config.""" + return self._config.get(CONF_SECURE_DEVICES_PIN) + + @property + def should_report_state(self): + """Return if states should be proactively reported.""" + return self._config.get(CONF_REPORT_STATE) + + def should_expose(self, state) -> bool: + """Return if entity should be exposed.""" + expose_by_default = self._config.get(CONF_EXPOSE_BY_DEFAULT) + exposed_domains = self._config.get(CONF_EXPOSED_DOMAINS) + + if state.attributes.get("view") is not None: # Ignore entities that are views return False - explicit_expose = \ - entity_config.get(entity.entity_id, {}).get(CONF_EXPOSE) + if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + return False - domain_exposed_by_default = \ - expose_by_default and entity.domain in exposed_domains + explicit_expose = self.entity_config.get(state.entity_id, {}).get(CONF_EXPOSE) + + domain_exposed_by_default = ( + expose_by_default and state.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 + 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)) + def should_2fa(self, state): + """If an entity should have 2FA checked.""" + return True + + async def _async_request_sync_devices(self, agent_user_id: str): + if CONF_API_KEY in self._config: + await self.async_call_homegraph_api_key( + REQUEST_SYNC_BASE_URL, {"agentUserId": agent_user_id} + ) + elif CONF_SERVICE_ACCOUNT in self._config: + await self.async_call_homegraph_api( + REQUEST_SYNC_BASE_URL, {"agentUserId": agent_user_id} + ) + else: + _LOGGER.error("No configuration for request_sync available") + + async def _async_update_token(self, force=False): + if CONF_SERVICE_ACCOUNT not in self._config: + _LOGGER.error("Trying to get homegraph api token without service account") + return + + now = dt_util.utcnow() + if not self._access_token or now > self._access_token_renew or force: + token = await _get_homegraph_token( + self.hass, + _get_homegraph_jwt( + now, + self._config[CONF_SERVICE_ACCOUNT][CONF_CLIENT_EMAIL], + self._config[CONF_SERVICE_ACCOUNT][CONF_PRIVATE_KEY], + ), + ) + self._access_token = token["access_token"] + self._access_token_renew = now + timedelta(seconds=token["expires_in"]) + + async def async_call_homegraph_api_key(self, url, data): + """Call a homegraph api with api key authentication.""" + websession = async_get_clientsession(self.hass) + try: + res = await websession.post( + url, params={"key": self._config.get(CONF_API_KEY)}, json=data + ) + _LOGGER.debug( + "Response on %s with data %s was %s", url, data, await res.text() + ) + res.raise_for_status() + return res.status + except ClientResponseError as error: + _LOGGER.error("Request for %s failed: %d", url, error.status) + return error.status + except (asyncio.TimeoutError, ClientError): + _LOGGER.error("Could not contact %s", url) + return 500 + + async def async_call_homegraph_api(self, url, data): + """Call a homegraph api with authenticaiton.""" + session = async_get_clientsession(self.hass) + + async def _call(): + headers = { + "Authorization": "Bearer {}".format(self._access_token), + "X-GFE-SSL": "yes", + } + async with session.post(url, headers=headers, json=data) as res: + _LOGGER.debug( + "Response on %s with data %s was %s", url, data, await res.text() + ) + res.raise_for_status() + return res.status + + try: + await self._async_update_token() + try: + return await _call() + except ClientResponseError as error: + if error.status == 401: + _LOGGER.warning( + "Request for %s unauthorized, renewing token and retrying", url + ) + await self._async_update_token(True) + return await _call() + raise + except ClientResponseError as error: + _LOGGER.error("Request for %s failed: %d", url, error.status) + return error.status + except (asyncio.TimeoutError, ClientError): + _LOGGER.error("Could not contact %s", url) + return 500 + + async def async_report_state(self, message, agent_user_id: str): + """Send a state report to Google.""" + data = { + "requestId": uuid4().hex, + "agentUserId": agent_user_id, + "payload": message, + } + await self.async_call_homegraph_api(REPORT_STATE_BASE_URL, data) 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 + name = "api:google_assistant" + requires_auth = True - def __init__(self, access_token, gass_config): + def __init__(self, config): """Initialize the Google Assistant request handler.""" - self.access_token = access_token - self.gass_config = gass_config + self.config = 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 + message: dict = await request.json() result = await async_handle_message( - request.app['hass'], self.gass_config, message) + request.app["hass"], self.config, request["hass_user"].id, message + ) return self.json(result) diff --git a/homeassistant/components/google_assistant/manifest.json b/homeassistant/components/google_assistant/manifest.json new file mode 100644 index 000000000..94dd3b7f0 --- /dev/null +++ b/homeassistant/components/google_assistant/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "google_assistant", + "name": "Google assistant", + "documentation": "https://www.home-assistant.io/integrations/google_assistant", + "requirements": [], + "dependencies": ["http"], + "after_dependencies": ["camera"], + "codeowners": ["@home-assistant/cloud"] +} diff --git a/homeassistant/components/google_assistant/report_state.py b/homeassistant/components/google_assistant/report_state.py new file mode 100644 index 000000000..1e8b6c020 --- /dev/null +++ b/homeassistant/components/google_assistant/report_state.py @@ -0,0 +1,71 @@ +"""Google Report State implementation.""" +import logging + +from homeassistant.const import MATCH_ALL +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.event import async_call_later + +from .error import SmartHomeError +from .helpers import AbstractConfig, GoogleEntity, async_get_entities + +# Time to wait until the homegraph updates +# https://github.com/actions-on-google/smart-home-nodejs/issues/196#issuecomment-439156639 +INITIAL_REPORT_DELAY = 60 + + +_LOGGER = logging.getLogger(__name__) + + +@callback +def async_enable_report_state(hass: HomeAssistant, google_config: AbstractConfig): + """Enable state reporting.""" + + async def async_entity_state_listener(changed_entity, old_state, new_state): + if not new_state: + return + + if not google_config.should_expose(new_state): + return + + entity = GoogleEntity(hass, google_config, new_state) + + if not entity.is_supported(): + return + + try: + entity_data = entity.query_serialize() + except SmartHomeError as err: + _LOGGER.debug("Not reporting state for %s: %s", changed_entity, err.code) + return + + if old_state: + old_entity = GoogleEntity(hass, google_config, old_state) + + # Only report to Google if data that Google cares about has changed + if entity_data == old_entity.query_serialize(): + return + + await google_config.async_report_state_all( + {"devices": {"states": {changed_entity: entity_data}}} + ) + + async def inital_report(_now): + """Report initially all states.""" + entities = {} + + for entity in async_get_entities(hass, google_config): + if not entity.should_expose(): + continue + + try: + entities[entity.entity_id] = entity.query_serialize() + except SmartHomeError: + continue + + await google_config.async_report_state_all({"devices": {"states": entities}}) + + async_call_later(hass, INITIAL_REPORT_DELAY, inital_report) + + return hass.helpers.event.async_track_state_change( + MATCH_ALL, async_entity_state_listener + ) diff --git a/homeassistant/components/google_assistant/services.yaml b/homeassistant/components/google_assistant/services.yaml index 6019b75bd..33a52c8ef 100644 --- a/homeassistant/components/google_assistant/services.yaml +++ b/homeassistant/components/google_assistant/services.yaml @@ -1,2 +1,5 @@ request_sync: - description: Send a request_sync command to Google. \ No newline at end of file + description: Send a request_sync command to Google. + fields: + agent_user_id: + description: "Optional. Only needed for automations. Specific Home Assistant user id (not username, ID in configuration > users > under username) to sync with Google Assistant. Do not need when you call this service through Home Assistant front end or API. Used in automation script or other place where context.user_id is missing." diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index 675e86f9d..b111e6dc9 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -1,286 +1,150 @@ """Support for Google Assistant Smart Home API.""" -from collections.abc import Mapping +import asyncio from itertools import product import logging +from homeassistant.const import ATTR_ENTITY_ID, __version__ 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 + ERR_DEVICE_OFFLINE, + ERR_PROTOCOL_ERROR, + ERR_UNKNOWN_ERROR, + EVENT_COMMAND_RECEIVED, + EVENT_QUERY_RECEIVED, + EVENT_SYNC_RECEIVED, ) -from .helpers import SmartHomeError +from .error import SmartHomeError +from .helpers import GoogleEntity, RequestData, async_get_entities 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, 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(self.hass, 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(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): +async def async_handle_message(hass, config, user_id, message): """Handle incoming API messages.""" - response = await _process(hass, config, message) + data = RequestData(config, user_id, message["requestId"], message.get("devices")) - if 'errorCode' in response['payload']: - _LOGGER.error('Error handling message %s: %s', - message, response['payload']) + response = await _process(hass, data, message) + + if response and "errorCode" in response["payload"]: + _LOGGER.error("Error handling message %s: %s", message, response["payload"]) return response -async def _process(hass, config, message): +async def _process(hass, data, message): """Process a message.""" - request_id = message.get('requestId') # type: str - inputs = message.get('inputs') # type: list + inputs: list = message.get("inputs") if len(inputs) != 1: return { - 'requestId': request_id, - 'payload': {'errorCode': ERR_PROTOCOL_ERROR} + "requestId": data.request_id, + "payload": {"errorCode": ERR_PROTOCOL_ERROR}, } - handler = HANDLERS.get(inputs[0].get('intent')) + handler = HANDLERS.get(inputs[0].get("intent")) if handler is None: return { - 'requestId': request_id, - 'payload': {'errorCode': ERR_PROTOCOL_ERROR} + "requestId": data.request_id, + "payload": {"errorCode": ERR_PROTOCOL_ERROR}, } try: - result = await handler(hass, config, inputs[0].get('payload')) - return {'requestId': request_id, 'payload': result} + result = await handler(hass, data, inputs[0].get("payload")) except SmartHomeError as err: + return {"requestId": data.request_id, "payload": {"errorCode": err.code}} + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected error") 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} + "requestId": data.request_id, + "payload": {"errorCode": ERR_UNKNOWN_ERROR}, } + if result is None: + return None -@HANDLERS.register('action.devices.SYNC') -async def async_devices_sync(hass, config, payload): + return {"requestId": data.request_id, "payload": result} + + +@HANDLERS.register("action.devices.SYNC") +async def async_devices_sync(hass, data, payload): """Handle action.devices.SYNC request. - https://developers.google.com/actions/smarthome/create-app#actiondevicessync + https://developers.google.com/assistant/smarthome/develop/process-intents#SYNC """ - devices = [] - for state in hass.states.async_all(): - if not config.should_expose(state): - continue + hass.bus.async_fire( + EVENT_SYNC_RECEIVED, {"request_id": data.request_id}, context=data.context + ) - entity = _GoogleEntity(hass, config, state) - serialized = entity.sync_serialize() + agent_user_id = data.context.user_id - if serialized is None: - _LOGGER.debug("No mapping for %s domain", entity.state) - continue + devices = await asyncio.gather( + *( + entity.sync_serialize(agent_user_id) + for entity in async_get_entities(hass, data.config) + if entity.should_expose() + ) + ) - devices.append(serialized) + response = {"agentUserId": agent_user_id, "devices": devices} - return { - 'agentUserId': config.agent_user_id, - 'devices': devices, - } + await data.config.async_connect_agent_user(agent_user_id) + + return response -@HANDLERS.register('action.devices.QUERY') -async def async_devices_query(hass, config, payload): +@HANDLERS.register("action.devices.QUERY") +async def async_devices_query(hass, data, payload): """Handle action.devices.QUERY request. - https://developers.google.com/actions/smarthome/create-app#actiondevicesquery + https://developers.google.com/assistant/smarthome/develop/process-intents#QUERY """ devices = {} - for device in payload.get('devices', []): - devid = device['id'] + for device in payload.get("devices", []): + devid = device["id"] state = hass.states.get(devid) + hass.bus.async_fire( + EVENT_QUERY_RECEIVED, + {"request_id": data.request_id, ATTR_ENTITY_ID: devid}, + context=data.context, + ) + if not state: # If we can't find a state, the device is offline - devices[devid] = {'online': False} + devices[devid] = {"online": False} continue - devices[devid] = _GoogleEntity(hass, config, state).query_serialize() + entity = GoogleEntity(hass, data.config, state) + devices[devid] = entity.query_serialize() - return {'devices': devices} + return {"devices": devices} -@HANDLERS.register('action.devices.EXECUTE') -async def handle_devices_execute(hass, config, payload): +@HANDLERS.register("action.devices.EXECUTE") +async def handle_devices_execute(hass, data, payload): """Handle action.devices.EXECUTE request. - https://developers.google.com/actions/smarthome/create-app#actiondevicesexecute + https://developers.google.com/assistant/smarthome/develop/process-intents#EXECUTE """ entities = {} results = {} - for command in payload['commands']: - for device, execution in product(command['devices'], - command['execution']): - entity_id = device['id'] + for command in payload["commands"]: + for device, execution in product(command["devices"], command["execution"]): + entity_id = device["id"] + + hass.bus.async_fire( + EVENT_COMMAND_RECEIVED, + { + "request_id": data.request_id, + ATTR_ENTITY_ID: entity_id, + "execution": execution, + }, + context=data.context, + ) # Happens if error occurred. Skip entity for further processing if entity_id in results: @@ -291,22 +155,21 @@ async def handle_devices_execute(hass, config, payload): if state is None: results[entity_id] = { - 'ids': [entity_id], - 'status': 'ERROR', - 'errorCode': ERR_DEVICE_OFFLINE + "ids": [entity_id], + "status": "ERROR", + "errorCode": ERR_DEVICE_OFFLINE, } continue - entities[entity_id] = _GoogleEntity(hass, config, state) + entities[entity_id] = GoogleEntity(hass, data.config, state) try: - await entities[entity_id].execute(execution['command'], - execution.get('params', {})) + await entities[entity_id].execute(data, execution) except SmartHomeError as err: results[entity_id] = { - 'ids': [entity_id], - 'status': 'ERROR', - 'errorCode': err.code + "ids": [entity_id], + "status": "ERROR", + **err.to_response(), } final_results = list(results.values()) @@ -317,10 +180,68 @@ async def handle_devices_execute(hass, config, payload): entity.async_update() - final_results.append({ - 'ids': [entity.entity_id], - 'status': 'SUCCESS', - 'states': entity.query_serialize(), - }) + final_results.append( + { + "ids": [entity.entity_id], + "status": "SUCCESS", + "states": entity.query_serialize(), + } + ) - return {'commands': final_results} + return {"commands": final_results} + + +@HANDLERS.register("action.devices.DISCONNECT") +async def async_devices_disconnect(hass, data: RequestData, payload): + """Handle action.devices.DISCONNECT request. + + https://developers.google.com/assistant/smarthome/develop/process-intents#DISCONNECT + """ + await data.config.async_disconnect_agent_user(data.context.user_id) + return None + + +@HANDLERS.register("action.devices.IDENTIFY") +async def async_devices_identify(hass, data: RequestData, payload): + """Handle action.devices.IDENTIFY request. + + https://developers.google.com/assistant/smarthome/develop/local#implement_the_identify_handler + """ + return { + "device": { + "id": data.context.user_id, + "isLocalOnly": True, + "isProxy": True, + "deviceInfo": { + "hwVersion": "UNKNOWN_HW_VERSION", + "manufacturer": "Home Assistant", + "model": "Home Assistant", + "swVersion": __version__, + }, + } + } + + +@HANDLERS.register("action.devices.REACHABLE_DEVICES") +async def async_devices_reachable(hass, data: RequestData, payload): + """Handle action.devices.REACHABLE_DEVICES request. + + https://developers.google.com/actions/smarthome/create#actiondevicesdisconnect + """ + google_ids = set(dev["id"] for dev in (data.devices or [])) + + return { + "devices": [ + entity.reachable_device_serialize() + for entity in async_get_entities(hass, data.config) + if entity.entity_id in google_ids and entity.should_expose() + ] + } + + +def turned_off_response(message): + """Return a device turned off response.""" + return { + "requestId": message.get("requestId"), + "payload": {"errorCode": "deviceTurnedOff"}, + } diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 26e80e6f0..d49632755 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -1,49 +1,112 @@ -"""Implement the Smart Home traits.""" -from homeassistant.core import DOMAIN as HA_DOMAIN +"""Implement the Google Smart Home traits.""" +import logging + from homeassistant.components import ( - climate, + alarm_control_panel, + binary_sensor, + camera, cover, - group, fan, + group, input_boolean, - media_player, light, + lock, + media_player, scene, script, + sensor, switch, + vacuum, ) +from homeassistant.components.climate import const as climate from homeassistant.const import ( + ATTR_ASSUMED_STATE, + ATTR_CODE, + ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + ATTR_TEMPERATURE, + SERVICE_ALARM_ARM_AWAY, + SERVICE_ALARM_ARM_CUSTOM_BYPASS, + SERVICE_ALARM_ARM_HOME, + SERVICE_ALARM_ARM_NIGHT, + SERVICE_ALARM_DISARM, + SERVICE_ALARM_TRIGGER, SERVICE_TURN_OFF, SERVICE_TURN_ON, + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_CUSTOM_BYPASS, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_DISARMED, + STATE_ALARM_PENDING, + STATE_ALARM_TRIGGERED, + STATE_LOCKED, STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, TEMP_CELSIUS, TEMP_FAHRENHEIT, ) +from homeassistant.core import DOMAIN as HA_DOMAIN from homeassistant.util import color as color_util, temperature as temp_util -from .const import ERR_VALUE_OUT_OF_RANGE -from .helpers import SmartHomeError +from .const import ( + CHALLENGE_ACK_NEEDED, + CHALLENGE_FAILED_PIN_NEEDED, + CHALLENGE_PIN_NEEDED, + ERR_ALREADY_ARMED, + ERR_ALREADY_DISARMED, + ERR_CHALLENGE_NOT_SETUP, + ERR_FUNCTION_NOT_SUPPORTED, + ERR_NOT_SUPPORTED, + ERR_VALUE_OUT_OF_RANGE, +) +from .error import ChallengeNeeded, 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' +_LOGGER = logging.getLogger(__name__) -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' +PREFIX_TRAITS = "action.devices.traits." +TRAIT_CAMERA_STREAM = PREFIX_TRAITS + "CameraStream" +TRAIT_ONOFF = PREFIX_TRAITS + "OnOff" +TRAIT_DOCK = PREFIX_TRAITS + "Dock" +TRAIT_STARTSTOP = PREFIX_TRAITS + "StartStop" +TRAIT_BRIGHTNESS = PREFIX_TRAITS + "Brightness" +TRAIT_COLOR_SETTING = PREFIX_TRAITS + "ColorSetting" +TRAIT_SCENE = PREFIX_TRAITS + "Scene" +TRAIT_TEMPERATURE_SETTING = PREFIX_TRAITS + "TemperatureSetting" +TRAIT_LOCKUNLOCK = PREFIX_TRAITS + "LockUnlock" +TRAIT_FANSPEED = PREFIX_TRAITS + "FanSpeed" +TRAIT_MODES = PREFIX_TRAITS + "Modes" +TRAIT_OPENCLOSE = PREFIX_TRAITS + "OpenClose" +TRAIT_VOLUME = PREFIX_TRAITS + "Volume" +TRAIT_ARMDISARM = PREFIX_TRAITS + "ArmDisarm" +TRAIT_HUMIDITY_SETTING = PREFIX_TRAITS + "HumiditySetting" + +PREFIX_COMMANDS = "action.devices.commands." +COMMAND_ONOFF = PREFIX_COMMANDS + "OnOff" +COMMAND_GET_CAMERA_STREAM = PREFIX_COMMANDS + "GetCameraStream" +COMMAND_DOCK = PREFIX_COMMANDS + "Dock" +COMMAND_STARTSTOP = PREFIX_COMMANDS + "StartStop" +COMMAND_PAUSEUNPAUSE = PREFIX_COMMANDS + "PauseUnpause" +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') + PREFIX_COMMANDS + "ThermostatTemperatureSetpoint" +) COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE = ( - PREFIX_COMMANDS + 'ThermostatTemperatureSetRange') -COMMAND_THERMOSTAT_SET_MODE = PREFIX_COMMANDS + 'ThermostatSetMode' - + PREFIX_COMMANDS + "ThermostatTemperatureSetRange" +) +COMMAND_THERMOSTAT_SET_MODE = PREFIX_COMMANDS + "ThermostatSetMode" +COMMAND_LOCKUNLOCK = PREFIX_COMMANDS + "LockUnlock" +COMMAND_FANSPEED = PREFIX_COMMANDS + "SetFanSpeed" +COMMAND_MODES = PREFIX_COMMANDS + "SetModes" +COMMAND_OPENCLOSE = PREFIX_COMMANDS + "OpenClose" +COMMAND_SET_VOLUME = PREFIX_COMMANDS + "setVolume" +COMMAND_VOLUME_RELATIVE = PREFIX_COMMANDS + "volumeRelative" +COMMAND_ARMDISARM = PREFIX_COMMANDS + "ArmDisarm" TRAITS = [] @@ -57,8 +120,8 @@ def register_trait(trait): def _google_temp_unit(units): """Return Google temperature unit.""" if units == TEMP_FAHRENHEIT: - return 'F' - return 'C' + return "F" + return "C" class _Trait: @@ -66,10 +129,16 @@ class _Trait: commands = [] - def __init__(self, hass, state): + @staticmethod + def might_2fa(domain, features, device_class): + """Return if the trait might ask for 2FA.""" + return False + + def __init__(self, hass, state, config): """Initialize a trait for a state.""" self.hass = hass self.state = state + self.config = config def sync_attributes(self): """Return attributes for a sync request.""" @@ -83,7 +152,7 @@ class _Trait: """Test if command can be executed.""" return command in self.commands - async def execute(self, command, params): + async def execute(self, command, data, params, challenge): """Execute a trait command.""" raise NotImplementedError @@ -96,19 +165,13 @@ class BrightnessTrait(_Trait): """ name = TRAIT_BRIGHTNESS - commands = [ - COMMAND_BRIGHTNESS_ABSOLUTE - ] + commands = [COMMAND_BRIGHTNESS_ABSOLUTE] @staticmethod - def supported(domain, features): + def supported(domain, features, device_class): """Test if state is supported.""" if domain == light.DOMAIN: return features & light.SUPPORT_BRIGHTNESS - if domain == cover.DOMAIN: - return features & cover.SUPPORT_SET_POSITION - if domain == media_player.DOMAIN: - return features & media_player.SUPPORT_VOLUME_SET return False @@ -124,45 +187,69 @@ class BrightnessTrait(_Trait): 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) + response["brightness"] = int(100 * (brightness / 255)) + else: + response["brightness"] = 0 return response - async def execute(self, command, params): + async def execute(self, command, data, params, challenge): """Execute a brightness command.""" domain = self.state.domain if domain == light.DOMAIN: await self.hass.services.async_call( - light.DOMAIN, light.SERVICE_TURN_ON, { + 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 self.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 self.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) + light.ATTR_BRIGHTNESS_PCT: params["brightness"], + }, + blocking=True, + context=data.context, + ) + + +@register_trait +class CameraStreamTrait(_Trait): + """Trait to stream from cameras. + + https://developers.google.com/actions/smarthome/traits/camerastream + """ + + name = TRAIT_CAMERA_STREAM + commands = [COMMAND_GET_CAMERA_STREAM] + + stream_info = None + + @staticmethod + def supported(domain, features, device_class): + """Test if state is supported.""" + if domain == camera.DOMAIN: + return features & camera.SUPPORT_STREAM + + return False + + def sync_attributes(self): + """Return stream attributes for a sync request.""" + return { + "cameraStreamSupportedProtocols": ["hls"], + "cameraStreamNeedAuthToken": False, + "cameraStreamNeedDrmEncryption": False, + } + + def query_attributes(self): + """Return camera stream attributes.""" + return self.stream_info or {} + + async def execute(self, command, data, params, challenge): + """Execute a get camera stream command.""" + url = await self.hass.components.camera.async_request_stream( + self.state.entity_id, "hls" + ) + self.stream_info = { + "cameraStreamAccessUrl": self.hass.config.api.base_url + url + } @register_trait @@ -173,12 +260,10 @@ class OnOffTrait(_Trait): """ name = TRAIT_ONOFF - commands = [ - COMMAND_ONOFF - ] + commands = [COMMAND_ONOFF] @staticmethod - def supported(domain, features): + def supported(domain, features, device_class): """Test if state is supported.""" return domain in ( group.DOMAIN, @@ -186,7 +271,6 @@ class OnOffTrait(_Trait): switch.DOMAIN, fan.DOMAIN, light.DOMAIN, - cover.DOMAIN, media_player.DOMAIN, ) @@ -196,157 +280,162 @@ class OnOffTrait(_Trait): 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} + return {"on": self.state.state != STATE_OFF} - async def execute(self, command, params): + async def execute(self, command, data, params, challenge): """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: + if domain == group.DOMAIN: service_domain = HA_DOMAIN - service = SERVICE_TURN_ON if params['on'] else SERVICE_TURN_OFF + 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 + service = SERVICE_TURN_ON if params["on"] else SERVICE_TURN_OFF - await self.hass.services.async_call(service_domain, service, { - ATTR_ENTITY_ID: self.state.entity_id - }, blocking=True) + await self.hass.services.async_call( + service_domain, + service, + {ATTR_ENTITY_ID: self.state.entity_id}, + blocking=True, + context=data.context, + ) @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, 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 self.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): +class ColorSettingTrait(_Trait): """Trait to offer color temperature functionality. https://developers.google.com/actions/smarthome/traits/colortemperature """ - name = TRAIT_COLOR_TEMP - commands = [ - COMMAND_COLOR_ABSOLUTE - ] + name = TRAIT_COLOR_SETTING + commands = [COMMAND_COLOR_ABSOLUTE] @staticmethod - def supported(domain, features): + def supported(domain, features, device_class): """Test if state is supported.""" if domain != light.DOMAIN: return False - return features & light.SUPPORT_COLOR_TEMP + return features & light.SUPPORT_COLOR_TEMP or features & light.SUPPORT_COLOR def sync_attributes(self): """Return color temperature attributes for a sync request.""" attrs = self.state.attributes - # Max Kelvin is Min Mireds K = 1000000 / mireds - # Min Kevin is Max Mireds K = 1000000 / mireds - return { - 'temperatureMaxK': color_util.color_temperature_mired_to_kelvin( - attrs.get(light.ATTR_MIN_MIREDS)), - 'temperatureMinK': color_util.color_temperature_mired_to_kelvin( - attrs.get(light.ATTR_MAX_MIREDS)), - } - - def query_attributes(self): - """Return color temperature query attributes.""" + features = attrs.get(ATTR_SUPPORTED_FEATURES, 0) 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) + if features & light.SUPPORT_COLOR: + response["colorModel"] = "hsv" + + if features & light.SUPPORT_COLOR_TEMP: + # Max Kelvin is Min Mireds K = 1000000 / mireds + # Min Kelvin is Max Mireds K = 1000000 / mireds + response["colorTemperatureRange"] = { + "temperatureMaxK": color_util.color_temperature_mired_to_kelvin( + attrs.get(light.ATTR_MIN_MIREDS) + ), + "temperatureMinK": color_util.color_temperature_mired_to_kelvin( + attrs.get(light.ATTR_MAX_MIREDS) + ), } 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', {})) + def query_attributes(self): + """Return color temperature query attributes.""" + features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + color = {} - async def execute(self, command, params): + if features & light.SUPPORT_COLOR: + color_hs = self.state.attributes.get(light.ATTR_HS_COLOR) + brightness = self.state.attributes.get(light.ATTR_BRIGHTNESS, 1) + if color_hs is not None: + color["spectrumHsv"] = { + "hue": color_hs[0], + "saturation": color_hs[1] / 100, + "value": brightness / 255, + } + + if features & light.SUPPORT_COLOR_TEMP: + temp = self.state.attributes.get(light.ATTR_COLOR_TEMP) + # Some faulty integrations might put 0 in here, raising exception. + if temp == 0: + _LOGGER.warning( + "Entity %s has incorrect color temperature %s", + self.state.entity_id, + temp, + ) + elif temp is not None: + color["temperatureK"] = color_util.color_temperature_mired_to_kelvin( + temp + ) + + response = {} + + if color: + response["color"] = color + + return response + + async def execute(self, command, data, params, challenge): """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 "temperature" in params["color"]: + 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)) + 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 self.hass.services.async_call(light.DOMAIN, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: self.state.entity_id, - light.ATTR_COLOR_TEMP: temp, - }, blocking=True) + await self.hass.services.async_call( + light.DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: self.state.entity_id, light.ATTR_COLOR_TEMP: temp}, + blocking=True, + context=data.context, + ) + + elif "spectrumRGB" in params["color"]: + # 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 self.hass.services.async_call( + light.DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: self.state.entity_id, light.ATTR_HS_COLOR: color}, + blocking=True, + context=data.context, + ) + + elif "spectrumHSV" in params["color"]: + color = params["color"]["spectrumHSV"] + saturation = color["saturation"] * 100 + brightness = color["value"] * 255 + + await self.hass.services.async_call( + light.DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: self.state.entity_id, + light.ATTR_HS_COLOR: [color["hue"], saturation], + light.ATTR_BRIGHTNESS: brightness, + }, + blocking=True, + context=data.context, + ) @register_trait @@ -357,12 +446,10 @@ class SceneTrait(_Trait): """ name = TRAIT_SCENE - commands = [ - COMMAND_ACTIVATE_SCENE - ] + commands = [COMMAND_ACTIVATE_SCENE] @staticmethod - def supported(domain, features): + def supported(domain, features, device_class): """Test if state is supported.""" return domain in (scene.DOMAIN, script.DOMAIN) @@ -375,13 +462,118 @@ class SceneTrait(_Trait): """Return scene query attributes.""" return {} - async def execute(self, command, params): + async def execute(self, command, data, params, challenge): """Execute a scene command.""" # Don't block for scripts as they can be slow. await self.hass.services.async_call( - self.state.domain, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: self.state.entity_id - }, blocking=self.state.domain != script.DOMAIN) + self.state.domain, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: self.state.entity_id}, + blocking=self.state.domain != script.DOMAIN, + context=data.context, + ) + + +@register_trait +class DockTrait(_Trait): + """Trait to offer dock functionality. + + https://developers.google.com/actions/smarthome/traits/dock + """ + + name = TRAIT_DOCK + commands = [COMMAND_DOCK] + + @staticmethod + def supported(domain, features, device_class): + """Test if state is supported.""" + return domain == vacuum.DOMAIN + + def sync_attributes(self): + """Return dock attributes for a sync request.""" + return {} + + def query_attributes(self): + """Return dock query attributes.""" + return {"isDocked": self.state.state == vacuum.STATE_DOCKED} + + async def execute(self, command, data, params, challenge): + """Execute a dock command.""" + await self.hass.services.async_call( + self.state.domain, + vacuum.SERVICE_RETURN_TO_BASE, + {ATTR_ENTITY_ID: self.state.entity_id}, + blocking=True, + context=data.context, + ) + + +@register_trait +class StartStopTrait(_Trait): + """Trait to offer StartStop functionality. + + https://developers.google.com/actions/smarthome/traits/startstop + """ + + name = TRAIT_STARTSTOP + commands = [COMMAND_STARTSTOP, COMMAND_PAUSEUNPAUSE] + + @staticmethod + def supported(domain, features, device_class): + """Test if state is supported.""" + return domain == vacuum.DOMAIN + + def sync_attributes(self): + """Return StartStop attributes for a sync request.""" + return { + "pausable": self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + & vacuum.SUPPORT_PAUSE + != 0 + } + + def query_attributes(self): + """Return StartStop query attributes.""" + return { + "isRunning": self.state.state == vacuum.STATE_CLEANING, + "isPaused": self.state.state == vacuum.STATE_PAUSED, + } + + async def execute(self, command, data, params, challenge): + """Execute a StartStop command.""" + if command == COMMAND_STARTSTOP: + if params["start"]: + await self.hass.services.async_call( + self.state.domain, + vacuum.SERVICE_START, + {ATTR_ENTITY_ID: self.state.entity_id}, + blocking=True, + context=data.context, + ) + else: + await self.hass.services.async_call( + self.state.domain, + vacuum.SERVICE_STOP, + {ATTR_ENTITY_ID: self.state.entity_id}, + blocking=True, + context=data.context, + ) + elif command == COMMAND_PAUSEUNPAUSE: + if params["pause"]: + await self.hass.services.async_call( + self.state.domain, + vacuum.SERVICE_PAUSE, + {ATTR_ENTITY_ID: self.state.entity_id}, + blocking=True, + context=data.context, + ) + else: + await self.hass.services.async_call( + self.state.domain, + vacuum.SERVICE_START, + {ATTR_ENTITY_ID: self.state.entity_id}, + blocking=True, + context=data.context, + ) @register_trait @@ -399,127 +591,937 @@ class TemperatureSettingTrait(_Trait): ] # 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', + hvac_to_google = { + climate.HVAC_MODE_HEAT: "heat", + climate.HVAC_MODE_COOL: "cool", + climate.HVAC_MODE_OFF: "off", + climate.HVAC_MODE_AUTO: "auto", + climate.HVAC_MODE_HEAT_COOL: "heatcool", + climate.HVAC_MODE_FAN_ONLY: "fan-only", + climate.HVAC_MODE_DRY: "dry", } - google_to_hass = {value: key for key, value in hass_to_google.items()} + google_to_hvac = {value: key for key, value in hvac_to_google.items()} + + preset_to_google = {climate.PRESET_ECO: "eco"} + google_to_preset = {value: key for key, value in preset_to_google.items()} @staticmethod - def supported(domain, features): + def supported(domain, features, device_class): """Test if state is supported.""" - if domain != climate.DOMAIN: - return False + if domain == climate.DOMAIN: + return True - return features & climate.SUPPORT_OPERATION_MODE + return ( + domain == sensor.DOMAIN and device_class == sensor.DEVICE_CLASS_TEMPERATURE + ) + + @property + def climate_google_modes(self): + """Return supported Google modes.""" + modes = [] + attrs = self.state.attributes + + for mode in attrs.get(climate.ATTR_HVAC_MODES, []): + google_mode = self.hvac_to_google.get(mode) + if google_mode and google_mode not in modes: + modes.append(google_mode) + + for preset in attrs.get(climate.ATTR_PRESET_MODES, []): + google_mode = self.preset_to_google.get(preset) + if google_mode and google_mode not in modes: + modes.append(google_mode) + + return modes 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.hass.config.units.temperature_unit) - } - - def query_attributes(self): - """Return temperature point and modes query attributes.""" - attrs = self.state.attributes response = {} + attrs = self.state.attributes + domain = self.state.domain + response["thermostatTemperatureUnit"] = _google_temp_unit( + self.hass.config.units.temperature_unit + ) - 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] + if domain == sensor.DOMAIN: + device_class = attrs.get(ATTR_DEVICE_CLASS) + if device_class == sensor.DEVICE_CLASS_TEMPERATURE: + response["queryOnlyTemperatureSetting"] = True - unit = self.hass.config.units.temperature_unit - - 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) + elif domain == climate.DOMAIN: + modes = self.climate_google_modes + if "off" in modes and any( + mode in modes for mode in ("heatcool", "heat", "cool") + ): + modes.append("on") + response["availableThermostatModes"] = ",".join(modes) return response - async def execute(self, command, params): + def query_attributes(self): + """Return temperature point and modes query attributes.""" + response = {} + attrs = self.state.attributes + domain = self.state.domain + unit = self.hass.config.units.temperature_unit + if domain == sensor.DOMAIN: + device_class = attrs.get(ATTR_DEVICE_CLASS) + if device_class == sensor.DEVICE_CLASS_TEMPERATURE: + current_temp = self.state.state + if current_temp not in (STATE_UNKNOWN, STATE_UNAVAILABLE): + response["thermostatTemperatureAmbient"] = round( + temp_util.convert(float(current_temp), unit, TEMP_CELSIUS), 1 + ) + + elif domain == climate.DOMAIN: + operation = self.state.state + preset = attrs.get(climate.ATTR_PRESET_MODE) + supported = attrs.get(ATTR_SUPPORTED_FEATURES, 0) + + if preset in self.preset_to_google: + response["thermostatMode"] = self.preset_to_google[preset] + else: + response["thermostatMode"] = self.hvac_to_google.get(operation) + + 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 in (climate.HVAC_MODE_AUTO, climate.HVAC_MODE_HEAT_COOL): + if supported & climate.SUPPORT_TARGET_TEMPERATURE_RANGE: + 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(ATTR_TEMPERATURE) + if target_temp is not None: + target_temp = round( + temp_util.convert(target_temp, unit, TEMP_CELSIUS), 1 + ) + response["thermostatTemperatureSetpointHigh"] = target_temp + response["thermostatTemperatureSetpointLow"] = target_temp + else: + target_temp = attrs.get(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, command, data, params, challenge): """Execute a temperature point or mode command.""" + domain = self.state.domain + if domain == sensor.DOMAIN: + raise SmartHomeError( + ERR_NOT_SUPPORTED, "Execute is not supported by sensor" + ) + # All sent in temperatures are always in Celsius unit = self.hass.config.units.temperature_unit 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) + temp = temp_util.convert( + params["thermostatTemperatureSetpoint"], TEMP_CELSIUS, unit + ) + if unit == TEMP_FAHRENHEIT: + temp = round(temp) 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)) + "Temperature should be between {} and {}".format( + min_temp, max_temp + ), + ) await self.hass.services.async_call( - climate.DOMAIN, climate.SERVICE_SET_TEMPERATURE, { - ATTR_ENTITY_ID: self.state.entity_id, - climate.ATTR_TEMPERATURE: temp - }, blocking=True) + climate.DOMAIN, + climate.SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: self.state.entity_id, ATTR_TEMPERATURE: temp}, + blocking=True, + context=data.context, + ) elif command == COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE: temp_high = temp_util.convert( - params['thermostatTemperatureSetpointHigh'], TEMP_CELSIUS, - unit) + params["thermostatTemperatureSetpointHigh"], TEMP_CELSIUS, unit + ) + if unit == TEMP_FAHRENHEIT: + temp_high = round(temp_high) 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)) + "{} and {}".format(min_temp, max_temp), + ) temp_low = temp_util.convert( - params['thermostatTemperatureSetpointLow'], TEMP_CELSIUS, unit) + params["thermostatTemperatureSetpointLow"], TEMP_CELSIUS, unit + ) + if unit == TEMP_FAHRENHEIT: + temp_low = round(temp_low) 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)) + "{} and {}".format(min_temp, max_temp), + ) + + supported = self.state.attributes.get(ATTR_SUPPORTED_FEATURES) + svc_data = {ATTR_ENTITY_ID: self.state.entity_id} + + if supported & climate.SUPPORT_TARGET_TEMPERATURE_RANGE: + svc_data[climate.ATTR_TARGET_TEMP_HIGH] = temp_high + svc_data[climate.ATTR_TARGET_TEMP_LOW] = temp_low + else: + svc_data[ATTR_TEMPERATURE] = (temp_high + temp_low) / 2 await self.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) + climate.DOMAIN, + climate.SERVICE_SET_TEMPERATURE, + svc_data, + blocking=True, + context=data.context, + ) elif command == COMMAND_THERMOSTAT_SET_MODE: + target_mode = params["thermostatMode"] + supported = self.state.attributes.get(ATTR_SUPPORTED_FEATURES) + + if target_mode == "on": + await self.hass.services.async_call( + climate.DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: self.state.entity_id}, + blocking=True, + context=data.context, + ) + return + + if target_mode == "off": + await self.hass.services.async_call( + climate.DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: self.state.entity_id}, + blocking=True, + context=data.context, + ) + return + + if target_mode in self.google_to_preset: + await self.hass.services.async_call( + climate.DOMAIN, + climate.SERVICE_SET_PRESET_MODE, + { + climate.ATTR_PRESET_MODE: self.google_to_preset[target_mode], + ATTR_ENTITY_ID: self.state.entity_id, + }, + blocking=True, + context=data.context, + ) + return + await self.hass.services.async_call( - climate.DOMAIN, climate.SERVICE_SET_OPERATION_MODE, { + climate.DOMAIN, + climate.SERVICE_SET_HVAC_MODE, + { ATTR_ENTITY_ID: self.state.entity_id, - climate.ATTR_OPERATION_MODE: - self.google_to_hass[params['thermostatMode']], - }, blocking=True) + climate.ATTR_HVAC_MODE: self.google_to_hvac[target_mode], + }, + blocking=True, + context=data.context, + ) + + +@register_trait +class HumiditySettingTrait(_Trait): + """Trait to offer humidity setting functionality. + + https://developers.google.com/actions/smarthome/traits/humiditysetting + """ + + name = TRAIT_HUMIDITY_SETTING + commands = [] + + @staticmethod + def supported(domain, features, device_class): + """Test if state is supported.""" + return domain == sensor.DOMAIN and device_class == sensor.DEVICE_CLASS_HUMIDITY + + def sync_attributes(self): + """Return humidity attributes for a sync request.""" + response = {} + attrs = self.state.attributes + domain = self.state.domain + if domain == sensor.DOMAIN: + device_class = attrs.get(ATTR_DEVICE_CLASS) + if device_class == sensor.DEVICE_CLASS_HUMIDITY: + response["queryOnlyHumiditySetting"] = True + + return response + + def query_attributes(self): + """Return humidity query attributes.""" + response = {} + attrs = self.state.attributes + domain = self.state.domain + if domain == sensor.DOMAIN: + device_class = attrs.get(ATTR_DEVICE_CLASS) + if device_class == sensor.DEVICE_CLASS_HUMIDITY: + current_humidity = self.state.state + if current_humidity not in (STATE_UNKNOWN, STATE_UNAVAILABLE): + response["humidityAmbientPercent"] = round(float(current_humidity)) + + return response + + async def execute(self, command, data, params, challenge): + """Execute a humidity command.""" + domain = self.state.domain + if domain == sensor.DOMAIN: + raise SmartHomeError( + ERR_NOT_SUPPORTED, "Execute is not supported by sensor" + ) + + +@register_trait +class LockUnlockTrait(_Trait): + """Trait to lock or unlock a lock. + + https://developers.google.com/actions/smarthome/traits/lockunlock + """ + + name = TRAIT_LOCKUNLOCK + commands = [COMMAND_LOCKUNLOCK] + + @staticmethod + def supported(domain, features, device_class): + """Test if state is supported.""" + return domain == lock.DOMAIN + + @staticmethod + def might_2fa(domain, features, device_class): + """Return if the trait might ask for 2FA.""" + return True + + def sync_attributes(self): + """Return LockUnlock attributes for a sync request.""" + return {} + + def query_attributes(self): + """Return LockUnlock query attributes.""" + return {"isLocked": self.state.state == STATE_LOCKED} + + async def execute(self, command, data, params, challenge): + """Execute an LockUnlock command.""" + if params["lock"]: + service = lock.SERVICE_LOCK + else: + _verify_pin_challenge(data, self.state, challenge) + service = lock.SERVICE_UNLOCK + + await self.hass.services.async_call( + lock.DOMAIN, + service, + {ATTR_ENTITY_ID: self.state.entity_id}, + blocking=True, + context=data.context, + ) + + +@register_trait +class ArmDisArmTrait(_Trait): + """Trait to Arm or Disarm a Security System. + + https://developers.google.com/actions/smarthome/traits/armdisarm + """ + + name = TRAIT_ARMDISARM + commands = [COMMAND_ARMDISARM] + + state_to_service = { + STATE_ALARM_ARMED_HOME: SERVICE_ALARM_ARM_HOME, + STATE_ALARM_ARMED_AWAY: SERVICE_ALARM_ARM_AWAY, + STATE_ALARM_ARMED_NIGHT: SERVICE_ALARM_ARM_NIGHT, + STATE_ALARM_ARMED_CUSTOM_BYPASS: SERVICE_ALARM_ARM_CUSTOM_BYPASS, + STATE_ALARM_TRIGGERED: SERVICE_ALARM_TRIGGER, + } + + @staticmethod + def supported(domain, features, device_class): + """Test if state is supported.""" + return domain == alarm_control_panel.DOMAIN + + @staticmethod + def might_2fa(domain, features, device_class): + """Return if the trait might ask for 2FA.""" + return True + + def sync_attributes(self): + """Return ArmDisarm attributes for a sync request.""" + response = {} + levels = [] + for state in self.state_to_service: + # level synonyms are generated from state names + # 'armed_away' becomes 'armed away' or 'away' + level_synonym = [state.replace("_", " ")] + if state != STATE_ALARM_TRIGGERED: + level_synonym.append(state.split("_")[1]) + + level = { + "level_name": state, + "level_values": [{"level_synonym": level_synonym, "lang": "en"}], + } + levels.append(level) + response["availableArmLevels"] = {"levels": levels, "ordered": False} + return response + + def query_attributes(self): + """Return ArmDisarm query attributes.""" + if "post_pending_state" in self.state.attributes: + armed_state = self.state.attributes["post_pending_state"] + else: + armed_state = self.state.state + response = {"isArmed": armed_state in self.state_to_service} + if response["isArmed"]: + response.update({"currentArmLevel": armed_state}) + return response + + async def execute(self, command, data, params, challenge): + """Execute an ArmDisarm command.""" + if params["arm"] and not params.get("cancel"): + if self.state.state == params["armLevel"]: + raise SmartHomeError(ERR_ALREADY_ARMED, "System is already armed") + if self.state.attributes["code_arm_required"]: + _verify_pin_challenge(data, self.state, challenge) + service = self.state_to_service[params["armLevel"]] + # disarm the system without asking for code when + # 'cancel' arming action is received while current status is pending + elif ( + params["arm"] + and params.get("cancel") + and self.state.state == STATE_ALARM_PENDING + ): + service = SERVICE_ALARM_DISARM + else: + if self.state.state == STATE_ALARM_DISARMED: + raise SmartHomeError(ERR_ALREADY_DISARMED, "System is already disarmed") + _verify_pin_challenge(data, self.state, challenge) + service = SERVICE_ALARM_DISARM + + await self.hass.services.async_call( + alarm_control_panel.DOMAIN, + service, + { + ATTR_ENTITY_ID: self.state.entity_id, + ATTR_CODE: data.config.secure_devices_pin, + }, + blocking=True, + context=data.context, + ) + + +@register_trait +class FanSpeedTrait(_Trait): + """Trait to control speed of Fan. + + https://developers.google.com/actions/smarthome/traits/fanspeed + """ + + name = TRAIT_FANSPEED + commands = [COMMAND_FANSPEED] + + speed_synonyms = { + fan.SPEED_OFF: ["stop", "off"], + fan.SPEED_LOW: ["slow", "low", "slowest", "lowest"], + fan.SPEED_MEDIUM: ["medium", "mid", "middle"], + fan.SPEED_HIGH: ["high", "max", "fast", "highest", "fastest", "maximum"], + } + + @staticmethod + def supported(domain, features, device_class): + """Test if state is supported.""" + if domain != fan.DOMAIN: + return False + + return features & fan.SUPPORT_SET_SPEED + + def sync_attributes(self): + """Return speed point and modes attributes for a sync request.""" + modes = self.state.attributes.get(fan.ATTR_SPEED_LIST, []) + speeds = [] + for mode in modes: + if mode not in self.speed_synonyms: + continue + speed = { + "speed_name": mode, + "speed_values": [ + {"speed_synonym": self.speed_synonyms.get(mode), "lang": "en"} + ], + } + speeds.append(speed) + + return { + "availableFanSpeeds": {"speeds": speeds, "ordered": True}, + "reversible": bool( + self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + & fan.SUPPORT_DIRECTION + ), + } + + def query_attributes(self): + """Return speed point and modes query attributes.""" + attrs = self.state.attributes + response = {} + + speed = attrs.get(fan.ATTR_SPEED) + if speed is not None: + response["on"] = speed != fan.SPEED_OFF + response["online"] = True + response["currentFanSpeedSetting"] = speed + + return response + + async def execute(self, command, data, params, challenge): + """Execute an SetFanSpeed command.""" + await self.hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_SET_SPEED, + {ATTR_ENTITY_ID: self.state.entity_id, fan.ATTR_SPEED: params["fanSpeed"]}, + blocking=True, + context=data.context, + ) + + +@register_trait +class ModesTrait(_Trait): + """Trait to set modes. + + https://developers.google.com/actions/smarthome/traits/modes + """ + + name = TRAIT_MODES + commands = [COMMAND_MODES] + + # Google requires specific mode names and settings. Here is the full list. + # https://developers.google.com/actions/reference/smarthome/traits/modes + # All settings are mapped here as of 2018-11-28 and can be used for other + # entity types. + + HA_TO_GOOGLE = {media_player.ATTR_INPUT_SOURCE: "input source"} + SUPPORTED_MODE_SETTINGS = { + "xsmall": ["xsmall", "extra small", "min", "minimum", "tiny", "xs"], + "small": ["small", "half"], + "large": ["large", "big", "full"], + "xlarge": ["extra large", "xlarge", "xl"], + "Cool": ["cool", "rapid cool", "rapid cooling"], + "Heat": ["heat"], + "Low": ["low"], + "Medium": ["medium", "med", "mid", "half"], + "High": ["high"], + "Auto": ["auto", "automatic"], + "Bake": ["bake"], + "Roast": ["roast"], + "Convection Bake": ["convection bake", "convect bake"], + "Convection Roast": ["convection roast", "convect roast"], + "Favorite": ["favorite"], + "Broil": ["broil"], + "Warm": ["warm"], + "Off": ["off"], + "On": ["on"], + "Normal": [ + "normal", + "normal mode", + "normal setting", + "standard", + "schedule", + "original", + "default", + "old settings", + ], + "None": ["none"], + "Tap Cold": ["tap cold"], + "Cold Warm": ["cold warm"], + "Hot": ["hot"], + "Extra Hot": ["extra hot"], + "Eco": ["eco"], + "Wool": ["wool", "fleece"], + "Turbo": ["turbo"], + "Rinse": ["rinse", "rinsing", "rinse wash"], + "Away": ["away", "holiday"], + "maximum": ["maximum"], + "media player": ["media player"], + "chromecast": ["chromecast"], + "tv": [ + "tv", + "television", + "tv position", + "television position", + "watching tv", + "watching tv position", + "entertainment", + "entertainment position", + ], + "am fm": ["am fm", "am radio", "fm radio"], + "internet radio": ["internet radio"], + "satellite": ["satellite"], + "game console": ["game console"], + "antifrost": ["antifrost", "anti-frost"], + "boost": ["boost"], + "Clock": ["clock"], + "Message": ["message"], + "Messages": ["messages"], + "News": ["news"], + "Disco": ["disco"], + "antifreeze": ["antifreeze", "anti-freeze", "anti freeze"], + "balanced": ["balanced", "normal"], + "swing": ["swing"], + "media": ["media", "media mode"], + "panic": ["panic"], + "ring": ["ring"], + "frozen": ["frozen", "rapid frozen", "rapid freeze"], + "cotton": ["cotton", "cottons"], + "blend": ["blend", "mix"], + "baby wash": ["baby wash"], + "synthetics": ["synthetic", "synthetics", "compose"], + "hygiene": ["hygiene", "sterilization"], + "smart": ["smart", "intelligent", "intelligence"], + "comfortable": ["comfortable", "comfort"], + "manual": ["manual"], + "energy saving": ["energy saving"], + "sleep": ["sleep"], + "quick wash": ["quick wash", "fast wash"], + "cold": ["cold"], + "airsupply": ["airsupply", "air supply"], + "dehumidification": ["dehumidication", "dehumidify"], + "game": ["game", "game mode"], + } + + @staticmethod + def supported(domain, features, device_class): + """Test if state is supported.""" + if domain != media_player.DOMAIN: + return False + + return features & media_player.SUPPORT_SELECT_SOURCE + + def sync_attributes(self): + """Return mode attributes for a sync request.""" + sources_list = self.state.attributes.get( + media_player.ATTR_INPUT_SOURCE_LIST, [] + ) + modes = [] + sources = {} + + if sources_list: + sources = { + "name": self.HA_TO_GOOGLE.get(media_player.ATTR_INPUT_SOURCE), + "name_values": [{"name_synonym": ["input source"], "lang": "en"}], + "settings": [], + "ordered": False, + } + for source in sources_list: + if source in self.SUPPORTED_MODE_SETTINGS: + src = source + synonyms = self.SUPPORTED_MODE_SETTINGS.get(src) + elif source.lower() in self.SUPPORTED_MODE_SETTINGS: + src = source.lower() + synonyms = self.SUPPORTED_MODE_SETTINGS.get(src) + + else: + continue + + sources["settings"].append( + { + "setting_name": src, + "setting_values": [{"setting_synonym": synonyms, "lang": "en"}], + } + ) + if sources: + modes.append(sources) + payload = {"availableModes": modes} + + return payload + + def query_attributes(self): + """Return current modes.""" + attrs = self.state.attributes + response = {} + mode_settings = {} + + if attrs.get(media_player.ATTR_INPUT_SOURCE_LIST): + mode_settings.update( + { + media_player.ATTR_INPUT_SOURCE: attrs.get( + media_player.ATTR_INPUT_SOURCE + ) + } + ) + if mode_settings: + response["on"] = self.state.state != STATE_OFF + response["online"] = True + response["currentModeSettings"] = mode_settings + + return response + + async def execute(self, command, data, params, challenge): + """Execute an SetModes command.""" + settings = params.get("updateModeSettings") + requested_source = settings.get( + self.HA_TO_GOOGLE.get(media_player.ATTR_INPUT_SOURCE) + ) + + if requested_source: + for src in self.state.attributes.get(media_player.ATTR_INPUT_SOURCE_LIST): + if src.lower() == requested_source.lower(): + source = src + + await self.hass.services.async_call( + media_player.DOMAIN, + media_player.SERVICE_SELECT_SOURCE, + { + ATTR_ENTITY_ID: self.state.entity_id, + media_player.ATTR_INPUT_SOURCE: source, + }, + blocking=True, + context=data.context, + ) + + +@register_trait +class OpenCloseTrait(_Trait): + """Trait to open and close a cover. + + https://developers.google.com/actions/smarthome/traits/openclose + """ + + # Cover device classes that require 2FA + COVER_2FA = (cover.DEVICE_CLASS_DOOR, cover.DEVICE_CLASS_GARAGE) + + name = TRAIT_OPENCLOSE + commands = [COMMAND_OPENCLOSE] + + override_position = None + + @staticmethod + def supported(domain, features, device_class): + """Test if state is supported.""" + if domain == cover.DOMAIN: + return True + + return domain == binary_sensor.DOMAIN and device_class in ( + binary_sensor.DEVICE_CLASS_DOOR, + binary_sensor.DEVICE_CLASS_GARAGE_DOOR, + binary_sensor.DEVICE_CLASS_LOCK, + binary_sensor.DEVICE_CLASS_OPENING, + binary_sensor.DEVICE_CLASS_WINDOW, + ) + + @staticmethod + def might_2fa(domain, features, device_class): + """Return if the trait might ask for 2FA.""" + return domain == cover.DOMAIN and device_class in OpenCloseTrait.COVER_2FA + + def sync_attributes(self): + """Return opening direction.""" + response = {} + if self.state.domain == binary_sensor.DOMAIN: + response["queryOnlyOpenClose"] = True + return response + + def query_attributes(self): + """Return state query attributes.""" + domain = self.state.domain + response = {} + + if self.override_position is not None: + response["openPercent"] = self.override_position + + elif domain == cover.DOMAIN: + # When it's an assumed state, we will return that querying state + # is not supported. + if self.state.attributes.get(ATTR_ASSUMED_STATE): + raise SmartHomeError( + ERR_NOT_SUPPORTED, "Querying state is not supported" + ) + + if self.state.state == STATE_UNKNOWN: + raise SmartHomeError( + ERR_NOT_SUPPORTED, "Querying state is not supported" + ) + + position = self.override_position or self.state.attributes.get( + cover.ATTR_CURRENT_POSITION + ) + + if position is not None: + response["openPercent"] = position + elif self.state.state != cover.STATE_CLOSED: + response["openPercent"] = 100 + else: + response["openPercent"] = 0 + + elif domain == binary_sensor.DOMAIN: + if self.state.state == STATE_ON: + response["openPercent"] = 100 + else: + response["openPercent"] = 0 + + return response + + async def execute(self, command, data, params, challenge): + """Execute an Open, close, Set position command.""" + domain = self.state.domain + + if domain == cover.DOMAIN: + svc_params = {ATTR_ENTITY_ID: self.state.entity_id} + + if params["openPercent"] == 0: + service = cover.SERVICE_CLOSE_COVER + should_verify = False + elif params["openPercent"] == 100: + service = cover.SERVICE_OPEN_COVER + should_verify = True + elif ( + self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + & cover.SUPPORT_SET_POSITION + ): + service = cover.SERVICE_SET_COVER_POSITION + should_verify = True + svc_params[cover.ATTR_POSITION] = params["openPercent"] + else: + raise SmartHomeError( + ERR_FUNCTION_NOT_SUPPORTED, "Setting a position is not supported" + ) + + if ( + should_verify + and self.state.attributes.get(ATTR_DEVICE_CLASS) + in OpenCloseTrait.COVER_2FA + ): + _verify_pin_challenge(data, self.state, challenge) + + await self.hass.services.async_call( + cover.DOMAIN, service, svc_params, blocking=True, context=data.context + ) + + if ( + self.state.attributes.get(ATTR_ASSUMED_STATE) + or self.state.state == STATE_UNKNOWN + ): + self.override_position = params["openPercent"] + + +@register_trait +class VolumeTrait(_Trait): + """Trait to control brightness of a device. + + https://developers.google.com/actions/smarthome/traits/volume + """ + + name = TRAIT_VOLUME + commands = [COMMAND_SET_VOLUME, COMMAND_VOLUME_RELATIVE] + + @staticmethod + def supported(domain, features, device_class): + """Test if state is supported.""" + if 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.""" + response = {} + + level = self.state.attributes.get(media_player.ATTR_MEDIA_VOLUME_LEVEL) + muted = self.state.attributes.get(media_player.ATTR_MEDIA_VOLUME_MUTED) + if level is not None: + # Convert 0.0-1.0 to 0-100 + response["currentVolume"] = int(level * 100) + response["isMuted"] = bool(muted) + + return response + + async def _execute_set_volume(self, data, params): + level = params["volumeLevel"] + + await self.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: level / 100, + }, + blocking=True, + context=data.context, + ) + + async def _execute_volume_relative(self, data, params): + # This could also support up/down commands using relativeSteps + relative = params["volumeRelativeLevel"] + current = self.state.attributes.get(media_player.ATTR_MEDIA_VOLUME_LEVEL) + + await self.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: current + relative / 100, + }, + blocking=True, + context=data.context, + ) + + async def execute(self, command, data, params, challenge): + """Execute a brightness command.""" + if command == COMMAND_SET_VOLUME: + await self._execute_set_volume(data, params) + elif command == COMMAND_VOLUME_RELATIVE: + await self._execute_volume_relative(data, params) + else: + raise SmartHomeError(ERR_NOT_SUPPORTED, "Command not supported") + + +def _verify_pin_challenge(data, state, challenge): + """Verify a pin challenge.""" + if not data.config.should_2fa(state): + return + if not data.config.secure_devices_pin: + raise SmartHomeError(ERR_CHALLENGE_NOT_SETUP, "Challenge is not set up") + + if not challenge: + raise ChallengeNeeded(CHALLENGE_PIN_NEEDED) + + pin = challenge.get("pin") + + if pin != data.config.secure_devices_pin: + raise ChallengeNeeded(CHALLENGE_FAILED_PIN_NEEDED) + + +def _verify_ack_challenge(data, state, challenge): + """Verify a pin challenge.""" + if not challenge or not challenge.get("ack"): + raise ChallengeNeeded(CHALLENGE_ACK_NEEDED) diff --git a/homeassistant/components/google_cloud/__init__.py b/homeassistant/components/google_cloud/__init__.py new file mode 100644 index 000000000..97b669245 --- /dev/null +++ b/homeassistant/components/google_cloud/__init__.py @@ -0,0 +1 @@ +"""The google_cloud component.""" diff --git a/homeassistant/components/google_cloud/manifest.json b/homeassistant/components/google_cloud/manifest.json new file mode 100644 index 000000000..bc9c71c5a --- /dev/null +++ b/homeassistant/components/google_cloud/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "google_cloud", + "name": "Google Cloud Platform", + "documentation": "https://www.home-assistant.io/integrations/google_cloud", + "requirements": [ + "google-cloud-texttospeech==0.4.0" + ], + "dependencies": [], + "codeowners": [ + "@lufton" + ] +} diff --git a/homeassistant/components/google_cloud/tts.py b/homeassistant/components/google_cloud/tts.py new file mode 100644 index 000000000..6721520d1 --- /dev/null +++ b/homeassistant/components/google_cloud/tts.py @@ -0,0 +1,261 @@ +"""Support for the Google Cloud TTS service.""" +import asyncio +import logging +import os + +import async_timeout +from google.cloud import texttospeech +import voluptuous as vol + +from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +CONF_KEY_FILE = "key_file" +CONF_GENDER = "gender" +CONF_VOICE = "voice" +CONF_ENCODING = "encoding" +CONF_SPEED = "speed" +CONF_PITCH = "pitch" +CONF_GAIN = "gain" +CONF_PROFILES = "profiles" + +SUPPORTED_LANGUAGES = [ + "cs-CZ", + "da-DK", + "de-DE", + "el-GR", + "en-AU", + "en-GB", + "en-IN", + "en-US", + "es-ES", + "fi-FI", + "fil-PH", + "fr-CA", + "fr-FR", + "hi-IN", + "hu-HU", + "id-ID", + "it-IT", + "ja-JP", + "ko-KR", + "nb-NO", + "nl-NL", + "pl-PL", + "pt-BR", + "pt-PT", + "ru-RU", + "sk-SK", + "sv-SE", + "tr-TR", + "uk-UA", + "vi-VN", +] +DEFAULT_LANG = "en-US" + +DEFAULT_GENDER = "NEUTRAL" + +VOICE_REGEX = r"[a-z]{2,3}-[A-Z]{2}-(Standard|Wavenet)-[A-Z]|" +DEFAULT_VOICE = "" + +DEFAULT_ENCODING = "MP3" + +MIN_SPEED = 0.25 +MAX_SPEED = 4.0 +DEFAULT_SPEED = 1.0 + +MIN_PITCH = -20.0 +MAX_PITCH = 20.0 +DEFAULT_PITCH = 0 + +MIN_GAIN = -96.0 +MAX_GAIN = 16.0 +DEFAULT_GAIN = 0 + +SUPPORTED_PROFILES = [ + "wearable-class-device", + "handset-class-device", + "headphone-class-device", + "small-bluetooth-speaker-class-device", + "medium-bluetooth-speaker-class-device", + "large-home-entertainment-class-device", + "large-automotive-class-device", + "telephony-class-application", +] + +SUPPORTED_OPTIONS = [ + CONF_VOICE, + CONF_GENDER, + CONF_ENCODING, + CONF_SPEED, + CONF_PITCH, + CONF_GAIN, + CONF_PROFILES, +] + +GENDER_SCHEMA = vol.All( + vol.Upper, vol.In(texttospeech.enums.SsmlVoiceGender.__members__) +) +VOICE_SCHEMA = cv.matches_regex(VOICE_REGEX) +SCHEMA_ENCODING = vol.All( + vol.Upper, vol.In(texttospeech.enums.AudioEncoding.__members__) +) +SPEED_SCHEMA = vol.All(vol.Coerce(float), vol.Clamp(min=MIN_SPEED, max=MAX_SPEED)) +PITCH_SCHEMA = vol.All(vol.Coerce(float), vol.Clamp(min=MIN_PITCH, max=MAX_PITCH)) +GAIN_SCHEMA = vol.All(vol.Coerce(float), vol.Clamp(min=MIN_GAIN, max=MAX_GAIN)) +PROFILES_SCHEMA = vol.All(cv.ensure_list, [vol.In(SUPPORTED_PROFILES)]) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_KEY_FILE): cv.string, + vol.Optional(CONF_LANG, default=DEFAULT_LANG): vol.In(SUPPORTED_LANGUAGES), + vol.Optional(CONF_GENDER, default=DEFAULT_GENDER): GENDER_SCHEMA, + vol.Optional(CONF_VOICE, default=DEFAULT_VOICE): VOICE_SCHEMA, + vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): SCHEMA_ENCODING, + vol.Optional(CONF_SPEED, default=DEFAULT_SPEED): SPEED_SCHEMA, + vol.Optional(CONF_PITCH, default=DEFAULT_PITCH): PITCH_SCHEMA, + vol.Optional(CONF_GAIN, default=DEFAULT_GAIN): GAIN_SCHEMA, + vol.Optional(CONF_PROFILES, default=[]): PROFILES_SCHEMA, + } +) + + +async def async_get_engine(hass, config, discovery_info=None): + """Set up Google Cloud TTS component.""" + key_file = config.get(CONF_KEY_FILE) + if key_file: + key_file = hass.config.path(key_file) + if not os.path.isfile(key_file): + _LOGGER.error("File %s doesn't exist", key_file) + return None + + return GoogleCloudTTSProvider( + hass, + key_file, + config.get(CONF_LANG), + config.get(CONF_GENDER), + config.get(CONF_VOICE), + config.get(CONF_ENCODING), + config.get(CONF_SPEED), + config.get(CONF_PITCH), + config.get(CONF_GAIN), + config.get(CONF_PROFILES), + ) + + +class GoogleCloudTTSProvider(Provider): + """The Google Cloud TTS API provider.""" + + def __init__( + self, + hass, + key_file=None, + language=DEFAULT_LANG, + gender=DEFAULT_GENDER, + voice=DEFAULT_VOICE, + encoding=DEFAULT_ENCODING, + speed=1.0, + pitch=0, + gain=0, + profiles=None, + ): + """Init Google Cloud TTS service.""" + self.hass = hass + self.name = "Google Cloud TTS" + self._language = language + self._gender = gender + self._voice = voice + self._encoding = encoding + self._speed = speed + self._pitch = pitch + self._gain = gain + self._profiles = profiles + + if key_file: + self._client = texttospeech.TextToSpeechClient.from_service_account_json( + key_file + ) + else: + self._client = texttospeech.TextToSpeechClient() + + @property + def supported_languages(self): + """Return list of supported languages.""" + return SUPPORTED_LANGUAGES + + @property + def default_language(self): + """Return the default language.""" + return self._language + + @property + def supported_options(self): + """Return a list of supported options.""" + return SUPPORTED_OPTIONS + + @property + def default_options(self): + """Return a dict including default options.""" + return { + CONF_GENDER: self._gender, + CONF_VOICE: self._voice, + CONF_ENCODING: self._encoding, + CONF_SPEED: self._speed, + CONF_PITCH: self._pitch, + CONF_GAIN: self._gain, + CONF_PROFILES: self._profiles, + } + + async def async_get_tts_audio(self, message, language, options=None): + """Load TTS from google.""" + options_schema = vol.Schema( + { + vol.Optional(CONF_GENDER, default=self._gender): GENDER_SCHEMA, + vol.Optional(CONF_VOICE, default=self._voice): VOICE_SCHEMA, + vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): SCHEMA_ENCODING, + vol.Optional(CONF_SPEED, default=self._speed): SPEED_SCHEMA, + vol.Optional(CONF_PITCH, default=self._speed): SPEED_SCHEMA, + vol.Optional(CONF_GAIN, default=DEFAULT_GAIN): GAIN_SCHEMA, + vol.Optional(CONF_PROFILES, default=[]): PROFILES_SCHEMA, + } + ) + options = options_schema(options) + + _encoding = options[CONF_ENCODING] + _voice = options[CONF_VOICE] + if _voice and not _voice.startswith(language): + language = _voice[:5] + + try: + # pylint: disable=no-member + synthesis_input = texttospeech.types.SynthesisInput(text=message) + + voice = texttospeech.types.VoiceSelectionParams( + language_code=language, + ssml_gender=texttospeech.enums.SsmlVoiceGender[options[CONF_GENDER]], + name=_voice, + ) + + audio_config = texttospeech.types.AudioConfig( + audio_encoding=texttospeech.enums.AudioEncoding[_encoding], + speaking_rate=options.get(CONF_SPEED), + pitch=options.get(CONF_PITCH), + volume_gain_db=options.get(CONF_GAIN), + effects_profile_id=options.get(CONF_PROFILES), + ) + # pylint: enable=no-member + + with async_timeout.timeout(10, loop=self.hass.loop): + response = await self.hass.async_add_executor_job( + self._client.synthesize_speech, synthesis_input, voice, audio_config + ) + return _encoding, response.audio_content + + except asyncio.TimeoutError as ex: + _LOGGER.error("Timeout for Google Cloud TTS call: %s", ex) + except Exception as ex: # pylint: disable=broad-except + _LOGGER.exception("Error occurred during Google Cloud TTS call: %s", ex) + + return None, None diff --git a/homeassistant/components/google_domains.py b/homeassistant/components/google_domains.py deleted file mode 100644 index 3b414306b..000000000 --- a/homeassistant/components/google_domains.py +++ /dev/null @@ -1,94 +0,0 @@ -""" -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/google_domains/__init__.py b/homeassistant/components/google_domains/__init__.py new file mode 100644 index 000000000..d440567d9 --- /dev/null +++ b/homeassistant/components/google_domains/__init__.py @@ -0,0 +1,85 @@ +"""Support for Google Domains.""" +import asyncio +from datetime import timedelta +import logging + +import aiohttp +import async_timeout +import voluptuous as vol + +from homeassistant.const import CONF_DOMAIN, CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME +import homeassistant.helpers.config_validation as cv + +_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, +) + + +async 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 = await _update_google_domains( + hass, session, domain, user, password, timeout + ) + + if not result: + return False + + async def update_domain_interval(now): + """Update the Google Domains entry.""" + await _update_google_domains(hass, session, domain, user, password, timeout) + + hass.helpers.event.async_track_time_interval(update_domain_interval, INTERVAL) + + return True + + +async 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): + resp = await session.get(url, params=params) + body = await 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/google_domains/manifest.json b/homeassistant/components/google_domains/manifest.json new file mode 100644 index 000000000..64076434a --- /dev/null +++ b/homeassistant/components/google_domains/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "google_domains", + "name": "Google domains", + "documentation": "https://www.home-assistant.io/integrations/google_domains", + "requirements": [], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/google_maps/__init__.py b/homeassistant/components/google_maps/__init__.py new file mode 100644 index 000000000..929df26fa --- /dev/null +++ b/homeassistant/components/google_maps/__init__.py @@ -0,0 +1 @@ +"""The google_maps component.""" diff --git a/homeassistant/components/google_maps/device_tracker.py b/homeassistant/components/google_maps/device_tracker.py new file mode 100644 index 000000000..75f370e50 --- /dev/null +++ b/homeassistant/components/google_maps/device_tracker.py @@ -0,0 +1,111 @@ +"""Support for Google Maps location sharing.""" +from datetime import timedelta +import logging + +from locationsharinglib import Service +from locationsharinglib.locationsharinglibexceptions import InvalidCookies +import voluptuous as vol + +from homeassistant.components.device_tracker import PLATFORM_SCHEMA, SOURCE_TYPE_GPS +from homeassistant.const import ( + ATTR_BATTERY_CHARGING, + ATTR_BATTERY_LEVEL, + ATTR_ID, + CONF_SCAN_INTERVAL, + CONF_USERNAME, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import track_time_interval +from homeassistant.helpers.typing import ConfigType +from homeassistant.util import dt as dt_util, slugify + +_LOGGER = logging.getLogger(__name__) + +ATTR_ADDRESS = "address" +ATTR_FULL_NAME = "full_name" +ATTR_LAST_SEEN = "last_seen" +ATTR_NICKNAME = "nickname" + +CONF_MAX_GPS_ACCURACY = "max_gps_accuracy" + +CREDENTIALS_FILE = ".google_maps_location_sharing.cookies" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_USERNAME): cv.string, + vol.Optional(CONF_MAX_GPS_ACCURACY, default=100000): vol.Coerce(float), + } +) + + +def setup_scanner(hass, config: ConfigType, see, discovery_info=None): + """Set up the Google Maps Location sharing scanner.""" + scanner = GoogleMapsScanner(hass, config, see) + return scanner.success_init + + +class GoogleMapsScanner: + """Representation of an Google Maps location sharing account.""" + + def __init__(self, hass, config: ConfigType, see) -> None: + """Initialize the scanner.""" + self.see = see + self.username = config[CONF_USERNAME] + self.max_gps_accuracy = config[CONF_MAX_GPS_ACCURACY] + self.scan_interval = config.get(CONF_SCAN_INTERVAL) or timedelta(seconds=60) + + credfile = "{}.{}".format( + hass.config.path(CREDENTIALS_FILE), slugify(self.username) + ) + try: + self.service = Service(credfile, self.username) + self._update_info() + + track_time_interval(hass, self._update_info, self.scan_interval) + + self.success_init = True + + except InvalidCookies: + _LOGGER.error( + "The cookie file provided does not provide a valid session. Please create another one and try again." + ) + self.success_init = False + + def _update_info(self, now=None): + for person in self.service.get_all_people(): + try: + dev_id = "google_maps_{0}".format(slugify(person.id)) + except TypeError: + _LOGGER.warning("No location(s) shared with this account") + return + + if ( + self.max_gps_accuracy is not None + and person.accuracy > self.max_gps_accuracy + ): + _LOGGER.info( + "Ignoring %s update because expected GPS " + "accuracy %s is not met: %s", + person.nickname, + self.max_gps_accuracy, + person.accuracy, + ) + continue + + attrs = { + ATTR_ADDRESS: person.address, + ATTR_FULL_NAME: person.full_name, + ATTR_ID: person.id, + ATTR_LAST_SEEN: dt_util.as_utc(person.datetime), + ATTR_NICKNAME: person.nickname, + ATTR_BATTERY_CHARGING: person.charging, + ATTR_BATTERY_LEVEL: person.battery_level, + } + 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/google_maps/manifest.json b/homeassistant/components/google_maps/manifest.json new file mode 100644 index 000000000..30571c338 --- /dev/null +++ b/homeassistant/components/google_maps/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "google_maps", + "name": "Google maps", + "documentation": "https://www.home-assistant.io/integrations/google_maps", + "requirements": [ + "locationsharinglib==4.1.0" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/google_pubsub/__init__.py b/homeassistant/components/google_pubsub/__init__.py new file mode 100644 index 000000000..bc7811a7a --- /dev/null +++ b/homeassistant/components/google_pubsub/__init__.py @@ -0,0 +1,96 @@ +"""Support for Google Cloud Pub/Sub.""" +import datetime +import json +import logging +import os +from typing import Any, Dict + +from google.cloud import pubsub_v1 +import voluptuous as vol + +from homeassistant.const import EVENT_STATE_CHANGED, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import Event, HomeAssistant +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entityfilter import FILTER_SCHEMA + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "google_pubsub" + +CONF_PROJECT_ID = "project_id" +CONF_TOPIC_NAME = "topic_name" +CONF_SERVICE_PRINCIPAL = "credentials_json" +CONF_FILTER = "filter" + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_PROJECT_ID): cv.string, + vol.Required(CONF_TOPIC_NAME): cv.string, + vol.Required(CONF_SERVICE_PRINCIPAL): cv.string, + vol.Required(CONF_FILTER): FILTER_SCHEMA, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +def setup(hass: HomeAssistant, yaml_config: Dict[str, Any]): + """Activate Google Pub/Sub component.""" + + config = yaml_config[DOMAIN] + project_id = config[CONF_PROJECT_ID] + topic_name = config[CONF_TOPIC_NAME] + service_principal_path = os.path.join( + hass.config.config_dir, config[CONF_SERVICE_PRINCIPAL] + ) + + if not os.path.isfile(service_principal_path): + _LOGGER.error("Path to credentials file cannot be found") + return False + + entities_filter = config[CONF_FILTER] + + publisher = pubsub_v1.PublisherClient.from_service_account_json( + service_principal_path + ) + + topic_path = publisher.topic_path( # pylint: disable=no-member + project_id, topic_name + ) + + encoder = DateTimeJSONEncoder() + + def send_to_pubsub(event: Event): + """Send states to Pub/Sub.""" + state = event.data.get("new_state") + if ( + state is None + or state.state in (STATE_UNKNOWN, "", STATE_UNAVAILABLE) + or not entities_filter(state.entity_id) + ): + return + + as_dict = state.as_dict() + data = json.dumps(obj=as_dict, default=encoder.encode).encode("utf-8") + + publisher.publish(topic_path, data=data) + + hass.bus.listen(EVENT_STATE_CHANGED, send_to_pubsub) + + return True + + +class DateTimeJSONEncoder(json.JSONEncoder): + """Encode python objects. + + Additionally add encoding for datetime objects as isoformat. + """ + + def default(self, o): # pylint: disable=method-hidden + """Implement encoding logic.""" + if isinstance(o, datetime.datetime): + return o.isoformat() + return super().default(o) diff --git a/homeassistant/components/google_pubsub/manifest.json b/homeassistant/components/google_pubsub/manifest.json new file mode 100644 index 000000000..b23a101ca --- /dev/null +++ b/homeassistant/components/google_pubsub/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "google_pubsub", + "name": "Google pubsub", + "documentation": "https://www.home-assistant.io/integrations/google_pubsub", + "requirements": [ + "google-cloud-pubsub==0.39.1" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/google_translate/__init__.py b/homeassistant/components/google_translate/__init__.py new file mode 100644 index 000000000..f7860c57d --- /dev/null +++ b/homeassistant/components/google_translate/__init__.py @@ -0,0 +1 @@ +"""The google_translate component.""" diff --git a/homeassistant/components/google_translate/manifest.json b/homeassistant/components/google_translate/manifest.json new file mode 100644 index 000000000..8b9621b42 --- /dev/null +++ b/homeassistant/components/google_translate/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "google_translate", + "name": "Google Translate", + "documentation": "https://www.home-assistant.io/integrations/google_translate", + "requirements": [ + "gTTS-token==1.1.3" + ], + "dependencies": [], + "codeowners": [ + "@awarecan" + ] +} diff --git a/homeassistant/components/google_translate/tts.py b/homeassistant/components/google_translate/tts.py new file mode 100644 index 000000000..e35a229ab --- /dev/null +++ b/homeassistant/components/google_translate/tts.py @@ -0,0 +1,180 @@ +"""Support for the Google speech service.""" +import asyncio +import logging +import re + +import aiohttp +from aiohttp.hdrs import REFERER, USER_AGENT +import async_timeout +from gtts_token import gtts_token +import voluptuous as vol +import yarl + +from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +_LOGGER = logging.getLogger(__name__) + +GOOGLE_SPEECH_URL = "https://translate.google.com/translate_tts" +MESSAGE_SIZE = 148 + +SUPPORT_LANGUAGES = [ + "af", + "sq", + "ar", + "hy", + "bn", + "ca", + "zh", + "zh-cn", + "zh-tw", + "zh-yue", + "hr", + "cs", + "da", + "nl", + "en", + "en-au", + "en-uk", + "en-us", + "eo", + "fi", + "fr", + "de", + "el", + "hi", + "hu", + "is", + "id", + "it", + "ja", + "ko", + "la", + "lv", + "mk", + "no", + "pl", + "pt", + "pt-br", + "ro", + "ru", + "sr", + "sk", + "es", + "es-es", + "es-mx", + "es-us", + "sw", + "sv", + "ta", + "th", + "tr", + "vi", + "cy", + "uk", + "bg-BG", +] + +DEFAULT_LANG = "en" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + {vol.Optional(CONF_LANG, default=DEFAULT_LANG): vol.In(SUPPORT_LANGUAGES)} +) + + +async def async_get_engine(hass, config, discovery_info=None): + """Set up Google speech component.""" + return GoogleProvider(hass, config[CONF_LANG]) + + +class GoogleProvider(Provider): + """The Google speech API provider.""" + + def __init__(self, hass, lang): + """Init Google TTS service.""" + self.hass = hass + self._lang = lang + self.headers = { + REFERER: "http://translate.google.com/", + 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.name = "Google" + + @property + def default_language(self): + """Return the default language.""" + return self._lang + + @property + def supported_languages(self): + """Return list of supported languages.""" + return SUPPORT_LANGUAGES + + async def async_get_tts_audio(self, message, language, options=None): + """Load TTS from google.""" + + token = gtts_token.Token() + websession = async_get_clientsession(self.hass) + message_parts = self._split_message_to_parts(message) + + data = b"" + for idx, part in enumerate(message_parts): + part_token = await self.hass.async_add_job(token.calculate_token, part) + + url_param = { + "ie": "UTF-8", + "tl": language, + "q": yarl.URL(part).raw_path, + "tk": part_token, + "total": len(message_parts), + "idx": idx, + "client": "tw-ob", + "textlen": len(part), + } + + try: + with async_timeout.timeout(10): + request = await websession.get( + GOOGLE_SPEECH_URL, params=url_param, headers=self.headers + ) + + if request.status != 200: + _LOGGER.error( + "Error %d on load URL %s", request.status, request.url + ) + return None, None + data += await request.read() + + except (asyncio.TimeoutError, aiohttp.ClientError): + _LOGGER.error("Timeout for google speech") + return None, None + + return "mp3", data + + @staticmethod + def _split_message_to_parts(message): + """Split message into single parts.""" + if len(message) <= MESSAGE_SIZE: + return [message] + + punc = "!()[]?.,;:" + punc_list = [re.escape(c) for c in punc] + pattern = "|".join(punc_list) + parts = re.split(pattern, message) + + def split_by_space(fullstring): + """Split a string by space.""" + if len(fullstring) > MESSAGE_SIZE: + idx = fullstring.rfind(" ", 0, MESSAGE_SIZE) + return [fullstring[:idx]] + split_by_space(fullstring[idx:]) + return [fullstring] + + msg_parts = [] + for part in parts: + msg_parts += split_by_space(part) + + return [msg for msg in msg_parts if len(msg) > 0] diff --git a/homeassistant/components/google_travel_time/__init__.py b/homeassistant/components/google_travel_time/__init__.py new file mode 100644 index 000000000..9d9a7cffe --- /dev/null +++ b/homeassistant/components/google_travel_time/__init__.py @@ -0,0 +1 @@ +"""The google_travel_time component.""" diff --git a/homeassistant/components/google_travel_time/manifest.json b/homeassistant/components/google_travel_time/manifest.json new file mode 100644 index 000000000..f4113f85a --- /dev/null +++ b/homeassistant/components/google_travel_time/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "google_travel_time", + "name": "Google travel time", + "documentation": "https://www.home-assistant.io/integrations/google_travel_time", + "requirements": [ + "googlemaps==2.5.1" + ], + "dependencies": [], + "codeowners": [ + "@robbiet480" + ] +} diff --git a/homeassistant/components/google_travel_time/sensor.py b/homeassistant/components/google_travel_time/sensor.py new file mode 100644 index 000000000..3ee72928f --- /dev/null +++ b/homeassistant/components/google_travel_time/sensor.py @@ -0,0 +1,333 @@ +"""Support for Google travel time sensors.""" +from datetime import datetime, timedelta +import logging + +import googlemaps +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_LATITUDE, + ATTR_LONGITUDE, + CONF_API_KEY, + CONF_MODE, + CONF_NAME, + EVENT_HOMEASSISTANT_START, +) +from homeassistant.helpers import location +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle +import homeassistant.util.dt as dt_util + +_LOGGER = logging.getLogger(__name__) + +ATTRIBUTION = "Powered by Google" + +CONF_DESTINATION = "destination" +CONF_OPTIONS = "options" +CONF_ORIGIN = "origin" +CONF_TRAVEL_MODE = "travel_mode" + +DEFAULT_NAME = "Google Travel Time" + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) + +ALL_LANGUAGES = [ + "ar", + "bg", + "bn", + "ca", + "cs", + "da", + "de", + "el", + "en", + "es", + "eu", + "fa", + "fi", + "fr", + "gl", + "gu", + "hi", + "hr", + "hu", + "id", + "it", + "iw", + "ja", + "kn", + "ko", + "lt", + "lv", + "ml", + "mr", + "nl", + "no", + "pl", + "pt", + "pt-BR", + "pt-PT", + "ro", + "ru", + "sk", + "sl", + "sr", + "sv", + "ta", + "te", + "th", + "tl", + "tr", + "uk", + "vi", + "zh-CN", + "zh-TW", +] + +AVOID = ["tolls", "highways", "ferries", "indoor"] +TRANSIT_PREFS = ["less_walking", "fewer_transfers"] +TRANSPORT_TYPE = ["bus", "subway", "train", "tram", "rail"] +TRAVEL_MODE = ["driving", "walking", "bicycling", "transit"] +TRAVEL_MODEL = ["best_guess", "pessimistic", "optimistic"] +UNITS = ["metric", "imperial"] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_DESTINATION): cv.string, + vol.Required(CONF_ORIGIN): cv.string, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_TRAVEL_MODE): vol.In(TRAVEL_MODE), + vol.Optional(CONF_OPTIONS, default={CONF_MODE: "driving"}): vol.All( + dict, + vol.Schema( + { + vol.Optional(CONF_MODE, default="driving"): vol.In(TRAVEL_MODE), + vol.Optional("language"): vol.In(ALL_LANGUAGES), + vol.Optional("avoid"): vol.In(AVOID), + vol.Optional("units"): vol.In(UNITS), + vol.Exclusive("arrival_time", "time"): cv.string, + vol.Exclusive("departure_time", "time"): cv.string, + vol.Optional("traffic_model"): vol.In(TRAVEL_MODEL), + vol.Optional("transit_mode"): vol.In(TRANSPORT_TYPE), + vol.Optional("transit_routing_preference"): vol.In(TRANSIT_PREFS), + } + ), + ), + } +) + +TRACKABLE_DOMAINS = ["device_tracker", "sensor", "zone", "person"] +DATA_KEY = "google_travel_time" + + +def convert_time_to_utc(timestr): + """Take a string like 08:00:00 and convert it to a unix timestamp.""" + combined = datetime.combine( + dt_util.start_of_local_day(), dt_util.parse_time(timestr) + ) + if combined < datetime.now(): + combined = combined + timedelta(days=1) + return dt_util.as_timestamp(combined) + + +def setup_platform(hass, config, add_entities_callback, discovery_info=None): + """Set up the Google travel time platform.""" + + def run_setup(event): + """ + Delay the setup until Home Assistant is fully initialized. + + This allows any entities to be created already + """ + hass.data.setdefault(DATA_KEY, []) + options = config.get(CONF_OPTIONS) + + if options.get("units") is None: + options["units"] = hass.config.units.name + + travel_mode = config.get(CONF_TRAVEL_MODE) + mode = options.get(CONF_MODE) + + if travel_mode is not None: + wstr = ( + "Google Travel Time: travel_mode is deprecated, please " + "add mode to the options dictionary instead!" + ) + _LOGGER.warning(wstr) + if mode is None: + options[CONF_MODE] = travel_mode + + titled_mode = options.get(CONF_MODE).title() + formatted_name = "{} - {}".format(DEFAULT_NAME, titled_mode) + name = config.get(CONF_NAME, formatted_name) + api_key = config.get(CONF_API_KEY) + origin = config.get(CONF_ORIGIN) + destination = config.get(CONF_DESTINATION) + + sensor = GoogleTravelTimeSensor( + hass, name, api_key, origin, destination, options + ) + hass.data[DATA_KEY].append(sensor) + + if sensor.valid_api_connection: + add_entities_callback([sensor]) + + # Wait until start event is sent to load this component. + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, run_setup) + + +class GoogleTravelTimeSensor(Entity): + """Representation of a Google travel time sensor.""" + + def __init__(self, hass, name, api_key, origin, destination, options): + """Initialize the sensor.""" + self._hass = hass + self._name = name + self._options = options + self._unit_of_measurement = "min" + self._matrix = None + self.valid_api_connection = True + + # Check if location is a trackable entity + if origin.split(".", 1)[0] in TRACKABLE_DOMAINS: + self._origin_entity_id = origin + else: + self._origin = origin + + if destination.split(".", 1)[0] in TRACKABLE_DOMAINS: + self._destination_entity_id = destination + else: + self._destination = destination + + self._client = googlemaps.Client(api_key, timeout=10) + try: + self.update() + except googlemaps.exceptions.ApiError as exp: + _LOGGER.error(exp) + self.valid_api_connection = False + return + + @property + def state(self): + """Return the state of the sensor.""" + if self._matrix is None: + return None + + _data = self._matrix["rows"][0]["elements"][0] + if "duration_in_traffic" in _data: + return round(_data["duration_in_traffic"]["value"] / 60) + if "duration" in _data: + return round(_data["duration"]["value"] / 60) + return None + + @property + def name(self): + """Get the name of the sensor.""" + return self._name + + @property + def device_state_attributes(self): + """Return the state attributes.""" + if self._matrix is None: + return None + + res = self._matrix.copy() + res.update(self._options) + del res["rows"] + _data = self._matrix["rows"][0]["elements"][0] + if "duration_in_traffic" in _data: + res["duration_in_traffic"] = _data["duration_in_traffic"]["text"] + if "duration" in _data: + res["duration"] = _data["duration"]["text"] + if "distance" in _data: + res["distance"] = _data["distance"]["text"] + res["origin"] = self._origin + res["destination"] = self._destination + res[ATTR_ATTRIBUTION] = ATTRIBUTION + return res + + @property + def unit_of_measurement(self): + """Return the unit this state is expressed in.""" + return self._unit_of_measurement + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data from Google.""" + options_copy = self._options.copy() + dtime = options_copy.get("departure_time") + atime = options_copy.get("arrival_time") + if dtime is not None and ":" in dtime: + options_copy["departure_time"] = convert_time_to_utc(dtime) + elif dtime is not None: + options_copy["departure_time"] = dtime + elif atime is None: + options_copy["departure_time"] = "now" + + if atime is not None and ":" in atime: + options_copy["arrival_time"] = convert_time_to_utc(atime) + elif atime is not None: + options_copy["arrival_time"] = atime + + # Convert device_trackers to google friendly location + if hasattr(self, "_origin_entity_id"): + self._origin = self._get_location_from_entity(self._origin_entity_id) + + if hasattr(self, "_destination_entity_id"): + self._destination = self._get_location_from_entity( + self._destination_entity_id + ) + + self._destination = self._resolve_zone(self._destination) + self._origin = self._resolve_zone(self._origin) + + if self._destination is not None and self._origin is not None: + self._matrix = self._client.distance_matrix( + self._origin, self._destination, **options_copy + ) + + def _get_location_from_entity(self, entity_id): + """Get the location from the entity state or attributes.""" + entity = self._hass.states.get(entity_id) + + if entity is None: + _LOGGER.error("Unable to find entity %s", entity_id) + self.valid_api_connection = False + return None + + # Check if the entity has location attributes + if location.has_location(entity): + return self._get_location_from_attributes(entity) + + # Check if device is in a zone + zone_entity = self._hass.states.get("zone.%s" % entity.state) + if location.has_location(zone_entity): + _LOGGER.debug( + "%s is in %s, getting zone location", entity_id, zone_entity.entity_id + ) + return self._get_location_from_attributes(zone_entity) + + # If zone was not found in state then use the state as the location + if entity_id.startswith("sensor."): + return entity.state + + # When everything fails just return nothing + return None + + @staticmethod + def _get_location_from_attributes(entity): + """Get the lat/long string from an entities attributes.""" + attr = entity.attributes + return "%s,%s" % (attr.get(ATTR_LATITUDE), attr.get(ATTR_LONGITUDE)) + + def _resolve_zone(self, friendly_name): + entities = self._hass.states.all() + for entity in entities: + if entity.domain == "zone" and entity.name == friendly_name: + return self._get_location_from_attributes(entity) + + return friendly_name diff --git a/homeassistant/components/google_wifi/__init__.py b/homeassistant/components/google_wifi/__init__.py new file mode 100644 index 000000000..a12bd9d4b --- /dev/null +++ b/homeassistant/components/google_wifi/__init__.py @@ -0,0 +1 @@ +"""The google_wifi component.""" diff --git a/homeassistant/components/google_wifi/manifest.json b/homeassistant/components/google_wifi/manifest.json new file mode 100644 index 000000000..77062cbdd --- /dev/null +++ b/homeassistant/components/google_wifi/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "google_wifi", + "name": "Google wifi", + "documentation": "https://www.home-assistant.io/integrations/google_wifi", + "requirements": [], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/google_wifi/sensor.py b/homeassistant/components/google_wifi/sensor.py new file mode 100644 index 000000000..9d6f3ea3d --- /dev/null +++ b/homeassistant/components/google_wifi/sensor.py @@ -0,0 +1,190 @@ +"""Support for retrieving status info from Google Wifi/OnHub routers.""" +from datetime import timedelta +import logging + +import requests +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_HOST, + CONF_MONITORED_CONDITIONS, + CONF_NAME, + STATE_UNKNOWN, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle, dt + +_LOGGER = logging.getLogger(__name__) + +ATTR_CURRENT_VERSION = "current_version" +ATTR_LAST_RESTART = "last_restart" +ATTR_LOCAL_IP = "local_ip" +ATTR_NEW_VERSION = "new_version" +ATTR_STATUS = "status" +ATTR_UPTIME = "uptime" + +DEFAULT_HOST = "testwifi.here" +DEFAULT_NAME = "google_wifi" + +ENDPOINT = "/api/v1/status" + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1) + +MONITORED_CONDITIONS = { + ATTR_CURRENT_VERSION: [ + ["software", "softwareVersion"], + None, + "mdi:checkbox-marked-circle-outline", + ], + ATTR_NEW_VERSION: [["software", "updateNewVersion"], None, "mdi:update"], + ATTR_UPTIME: [["system", "uptime"], "days", "mdi:timelapse"], + ATTR_LAST_RESTART: [["system", "uptime"], None, "mdi:restart"], + ATTR_LOCAL_IP: [["wan", "localIpAddress"], None, "mdi:access-point-network"], + ATTR_STATUS: [["wan", "online"], None, "mdi:google"], +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Optional( + CONF_MONITORED_CONDITIONS, default=list(MONITORED_CONDITIONS) + ): vol.All(cv.ensure_list, [vol.In(MONITORED_CONDITIONS)]), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Google Wifi sensor.""" + name = config.get(CONF_NAME) + host = config.get(CONF_HOST) + conditions = config.get(CONF_MONITORED_CONDITIONS) + + api = GoogleWifiAPI(host, conditions) + dev = [] + for condition in conditions: + dev.append(GoogleWifiSensor(api, name, condition)) + + add_entities(dev, True) + + +class GoogleWifiSensor(Entity): + """Representation of a Google Wifi sensor.""" + + def __init__(self, api, name, variable): + """Initialize a Google Wifi sensor.""" + self._api = api + self._name = name + self._state = None + + variable_info = MONITORED_CONDITIONS[variable] + self._var_name = variable + self._var_units = variable_info[1] + self._var_icon = variable_info[2] + + @property + def name(self): + """Return the name of the sensor.""" + return f"{self._name}_{self._var_name}" + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return self._var_icon + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self._var_units + + @property + def available(self): + """Return availability of Google Wifi API.""" + return self._api.available + + @property + def state(self): + """Return the state of the device.""" + return self._state + + def update(self): + """Get the latest data from the Google Wifi API.""" + self._api.update() + if self.available: + self._state = self._api.data[self._var_name] + else: + self._state = None + + +class GoogleWifiAPI: + """Get the latest data and update the states.""" + + def __init__(self, host, conditions): + """Initialize the data object.""" + uri = "http://" + resource = f"{uri}{host}{ENDPOINT}" + self._request = requests.Request("GET", resource).prepare() + self.raw_data = None + self.conditions = conditions + self.data = { + ATTR_CURRENT_VERSION: STATE_UNKNOWN, + ATTR_NEW_VERSION: STATE_UNKNOWN, + ATTR_UPTIME: STATE_UNKNOWN, + ATTR_LAST_RESTART: STATE_UNKNOWN, + ATTR_LOCAL_IP: STATE_UNKNOWN, + ATTR_STATUS: STATE_UNKNOWN, + } + self.available = True + self.update() + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data from the router.""" + try: + with requests.Session() as sess: + response = sess.send(self._request, timeout=10) + self.raw_data = response.json() + self.data_format() + self.available = True + except (ValueError, requests.exceptions.ConnectionError): + _LOGGER.warning("Unable to fetch data from Google Wifi") + self.available = False + self.raw_data = None + + def data_format(self): + """Format raw data into easily accessible dict.""" + for attr_key in self.conditions: + value = MONITORED_CONDITIONS[attr_key] + try: + primary_key = value[0][0] + sensor_key = value[0][1] + if primary_key in self.raw_data: + sensor_value = self.raw_data[primary_key][sensor_key] + # Format sensor for better readability + if attr_key == ATTR_NEW_VERSION and sensor_value == "0.0.0.0": + sensor_value = "Latest" + elif attr_key == ATTR_UPTIME: + sensor_value = round(sensor_value / (3600 * 24), 2) + elif attr_key == ATTR_LAST_RESTART: + last_restart = dt.now() - timedelta(seconds=sensor_value) + sensor_value = last_restart.strftime("%Y-%m-%d %H:%M:%S") + elif attr_key == ATTR_STATUS: + if sensor_value: + sensor_value = "Online" + else: + sensor_value = "Offline" + elif attr_key == ATTR_LOCAL_IP: + if not self.raw_data["wan"]["online"]: + sensor_value = STATE_UNKNOWN + + self.data[attr_key] = sensor_value + except KeyError: + _LOGGER.error( + "Router does not support %s field. " + "Please remove %s from monitored_conditions", + sensor_key, + attr_key, + ) + self.data[attr_key] = STATE_UNKNOWN diff --git a/homeassistant/components/gpmdp/__init__.py b/homeassistant/components/gpmdp/__init__.py new file mode 100644 index 000000000..a8aa82c69 --- /dev/null +++ b/homeassistant/components/gpmdp/__init__.py @@ -0,0 +1 @@ +"""The gpmdp component.""" diff --git a/homeassistant/components/gpmdp/manifest.json b/homeassistant/components/gpmdp/manifest.json new file mode 100644 index 000000000..a3c238947 --- /dev/null +++ b/homeassistant/components/gpmdp/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "gpmdp", + "name": "Gpmdp", + "documentation": "https://www.home-assistant.io/integrations/gpmdp", + "requirements": [ + "websocket-client==0.54.0" + ], + "dependencies": ["configurator"], + "codeowners": [] +} diff --git a/homeassistant/components/gpmdp/media_player.py b/homeassistant/components/gpmdp/media_player.py new file mode 100644 index 000000000..e7b18aacc --- /dev/null +++ b/homeassistant/components/gpmdp/media_player.py @@ -0,0 +1,388 @@ +"""Support for Google Play Music Desktop Player.""" +import json +import logging +import socket +import time + +import voluptuous as vol +from websocket import _exceptions, create_connection + +from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerDevice +from homeassistant.components.media_player.const import ( + MEDIA_TYPE_MUSIC, + SUPPORT_NEXT_TRACK, + SUPPORT_PAUSE, + SUPPORT_PLAY, + SUPPORT_PREVIOUS_TRACK, + SUPPORT_SEEK, + SUPPORT_VOLUME_SET, +) +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PORT, + STATE_OFF, + STATE_PAUSED, + STATE_PLAYING, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.util.json import load_json, save_json + +_CONFIGURING = {} +_LOGGER = logging.getLogger(__name__) + +DEFAULT_HOST = "localhost" +DEFAULT_NAME = "GPM Desktop Player" +DEFAULT_PORT = 5672 + +GPMDP_CONFIG_FILE = "gpmpd.conf" + +SUPPORT_GPMDP = ( + SUPPORT_PAUSE + | SUPPORT_PREVIOUS_TRACK + | SUPPORT_NEXT_TRACK + | SUPPORT_SEEK + | SUPPORT_VOLUME_SET + | SUPPORT_PLAY +) + +PLAYBACK_DICT = {"0": STATE_PAUSED, "1": STATE_PAUSED, "2": STATE_PLAYING} # Stopped + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + } +) + + +def request_configuration(hass, config, url, add_entities_callback): + """Request configuration steps from the user.""" + configurator = hass.components.configurator + if "gpmdp" in _CONFIGURING: + configurator.notify_errors( + _CONFIGURING["gpmdp"], "Failed to register, please try again." + ) + + return + websocket = create_connection((url), timeout=1) + websocket.send( + json.dumps( + { + "namespace": "connect", + "method": "connect", + "arguments": ["Home Assistant"], + } + ) + ) + + def gpmdp_configuration_callback(callback_data): + """Handle configuration changes.""" + while True: + + try: + msg = json.loads(websocket.recv()) + except _exceptions.WebSocketConnectionClosedException: + continue + if msg["channel"] != "connect": + continue + if msg["payload"] != "CODE_REQUIRED": + continue + pin = callback_data.get("pin") + websocket.send( + json.dumps( + { + "namespace": "connect", + "method": "connect", + "arguments": ["Home Assistant", pin], + } + ) + ) + tmpmsg = json.loads(websocket.recv()) + if tmpmsg["channel"] == "time": + _LOGGER.error( + "Error setting up GPMDP. Please pause " + "the desktop player and try again" + ) + break + code = tmpmsg["payload"] + if code == "CODE_REQUIRED": + continue + setup_gpmdp(hass, config, code, add_entities_callback) + save_json(hass.config.path(GPMDP_CONFIG_FILE), {"CODE": code}) + websocket.send( + json.dumps( + { + "namespace": "connect", + "method": "connect", + "arguments": ["Home Assistant", code], + } + ) + ) + websocket.close() + break + + _CONFIGURING["gpmdp"] = configurator.request_config( + DEFAULT_NAME, + gpmdp_configuration_callback, + description=( + "Enter the pin that is displayed in the " + "Google Play Music Desktop Player." + ), + submit_caption="Submit", + fields=[{"id": "pin", "name": "Pin Code", "type": "number"}], + ) + + +def setup_gpmdp(hass, config, code, add_entities): + """Set up gpmdp.""" + name = config.get(CONF_NAME) + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) + url = f"ws://{host}:{port}" + + if not code: + request_configuration(hass, config, url, add_entities) + return + + if "gpmdp" in _CONFIGURING: + configurator = hass.components.configurator + configurator.request_done(_CONFIGURING.pop("gpmdp")) + + add_entities([GPMDP(name, url, code)], True) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the GPMDP platform.""" + codeconfig = load_json(hass.config.path(GPMDP_CONFIG_FILE)) + if codeconfig: + code = codeconfig.get("CODE") + elif discovery_info is not None: + if "gpmdp" in _CONFIGURING: + return + code = None + else: + code = None + setup_gpmdp(hass, config, code, add_entities) + + +class GPMDP(MediaPlayerDevice): + """Representation of a GPMDP.""" + + def __init__(self, name, url, code): + """Initialize the media player.""" + + self._connection = create_connection + self._url = url + self._authorization_code = code + self._name = name + self._status = STATE_OFF + self._ws = None + self._title = None + self._artist = None + self._albumart = None + self._seek_position = None + self._duration = None + self._volume = None + self._request_id = 0 + self._available = True + + def get_ws(self): + """Check if the websocket is setup and connected.""" + if self._ws is None: + try: + self._ws = self._connection((self._url), timeout=1) + msg = json.dumps( + { + "namespace": "connect", + "method": "connect", + "arguments": ["Home Assistant", self._authorization_code], + } + ) + self._ws.send(msg) + except (socket.timeout, ConnectionRefusedError, ConnectionResetError): + self._ws = None + return self._ws + + def send_gpmdp_msg(self, namespace, method, with_id=True): + """Send ws messages to GPMDP and verify request id in response.""" + + try: + websocket = self.get_ws() + if websocket is None: + self._status = STATE_OFF + return + self._request_id += 1 + websocket.send( + json.dumps( + { + "namespace": namespace, + "method": method, + "requestID": self._request_id, + } + ) + ) + if not with_id: + return + while True: + msg = json.loads(websocket.recv()) + if "requestID" in msg: + if msg["requestID"] == self._request_id: + return msg + except ( + ConnectionRefusedError, + ConnectionResetError, + _exceptions.WebSocketTimeoutException, + _exceptions.WebSocketProtocolException, + _exceptions.WebSocketPayloadException, + _exceptions.WebSocketConnectionClosedException, + ): + self._ws = None + + def update(self): + """Get the latest details from the player.""" + time.sleep(1) + try: + self._available = True + playstate = self.send_gpmdp_msg("playback", "getPlaybackState") + if playstate is None: + return + self._status = PLAYBACK_DICT[str(playstate["value"])] + time_data = self.send_gpmdp_msg("playback", "getCurrentTime") + if time_data is not None: + self._seek_position = int(time_data["value"] / 1000) + track_data = self.send_gpmdp_msg("playback", "getCurrentTrack") + if track_data is not None: + self._title = track_data["value"]["title"] + self._artist = track_data["value"]["artist"] + self._albumart = track_data["value"]["albumArt"] + self._duration = int(track_data["value"]["duration"] / 1000) + volume_data = self.send_gpmdp_msg("volume", "getVolume") + if volume_data is not None: + self._volume = volume_data["value"] / 100 + except OSError: + self._available = False + + @property + def available(self): + """Return if media player is available.""" + return self._available + + @property + def media_content_type(self): + """Content type of current playing media.""" + return MEDIA_TYPE_MUSIC + + @property + def state(self): + """Return the state of the device.""" + return self._status + + @property + def media_title(self): + """Title of current playing media.""" + return self._title + + @property + def media_artist(self): + """Artist of current playing media (Music track only).""" + return self._artist + + @property + def media_image_url(self): + """Image url of current playing media.""" + return self._albumart + + @property + def media_seek_position(self): + """Time in seconds of current seek position.""" + return self._seek_position + + @property + def media_duration(self): + """Time in seconds of current song duration.""" + return self._duration + + @property + def volume_level(self): + """Volume level of the media player (0..1).""" + return self._volume + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def supported_features(self): + """Flag media player features that are supported.""" + return SUPPORT_GPMDP + + def media_next_track(self): + """Send media_next command to media player.""" + self.send_gpmdp_msg("playback", "forward", False) + + def media_previous_track(self): + """Send media_previous command to media player.""" + self.send_gpmdp_msg("playback", "rewind", False) + + def media_play(self): + """Send media_play command to media player.""" + self.send_gpmdp_msg("playback", "playPause", False) + self._status = STATE_PLAYING + self.schedule_update_ha_state() + + def media_pause(self): + """Send media_pause command to media player.""" + self.send_gpmdp_msg("playback", "playPause", False) + self._status = STATE_PAUSED + self.schedule_update_ha_state() + + def media_seek(self, position): + """Send media_seek command to media player.""" + websocket = self.get_ws() + if websocket is None: + return + websocket.send( + json.dumps( + { + "namespace": "playback", + "method": "setCurrentTime", + "arguments": [position * 1000], + } + ) + ) + self.schedule_update_ha_state() + + def volume_up(self): + """Send volume_up command to media player.""" + websocket = self.get_ws() + if websocket is None: + return + websocket.send('{"namespace": "volume", "method": "increaseVolume"}') + self.schedule_update_ha_state() + + def volume_down(self): + """Send volume_down command to media player.""" + websocket = self.get_ws() + if websocket is None: + return + websocket.send('{"namespace": "volume", "method": "decreaseVolume"}') + self.schedule_update_ha_state() + + def set_volume_level(self, volume): + """Set volume on media player, range(0..1).""" + websocket = self.get_ws() + if websocket is None: + return + websocket.send( + json.dumps( + { + "namespace": "volume", + "method": "setVolume", + "arguments": [volume * 100], + } + ) + ) + self.schedule_update_ha_state() diff --git a/homeassistant/components/gpsd/__init__.py b/homeassistant/components/gpsd/__init__.py new file mode 100644 index 000000000..71656d4d1 --- /dev/null +++ b/homeassistant/components/gpsd/__init__.py @@ -0,0 +1 @@ +"""The gpsd component.""" diff --git a/homeassistant/components/gpsd/manifest.json b/homeassistant/components/gpsd/manifest.json new file mode 100644 index 000000000..7bb828cac --- /dev/null +++ b/homeassistant/components/gpsd/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "gpsd", + "name": "Gpsd", + "documentation": "https://www.home-assistant.io/integrations/gpsd", + "requirements": [ + "gps3==0.33.3" + ], + "dependencies": [], + "codeowners": [ + "@fabaff" + ] +} diff --git a/homeassistant/components/gpsd/sensor.py b/homeassistant/components/gpsd/sensor.py new file mode 100644 index 000000000..8696dde72 --- /dev/null +++ b/homeassistant/components/gpsd/sensor.py @@ -0,0 +1,107 @@ +"""Support for GPSD.""" +import logging +import socket + +from gps3.agps3threaded import AGPS3mechanism +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + ATTR_LATITUDE, + ATTR_LONGITUDE, + ATTR_MODE, + CONF_HOST, + CONF_NAME, + CONF_PORT, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +ATTR_CLIMB = "climb" +ATTR_ELEVATION = "elevation" +ATTR_GPS_TIME = "gps_time" +ATTR_SPEED = "speed" + +DEFAULT_HOST = "localhost" +DEFAULT_NAME = "GPS" +DEFAULT_PORT = 2947 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the GPSD component.""" + name = config.get(CONF_NAME) + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) + + # Will hopefully be possible with the next gps3 update + # https://github.com/wadda/gps3/issues/11 + # from gps3 import gps3 + # try: + # gpsd_socket = gps3.GPSDSocket() + # gpsd_socket.connect(host=host, port=port) + # except GPSError: + # _LOGGER.warning('Not able to connect to GPSD') + # return False + + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + sock.connect((host, port)) + sock.shutdown(2) + _LOGGER.debug("Connection to GPSD possible") + except socket.error: + _LOGGER.error("Not able to connect to GPSD") + return False + + add_entities([GpsdSensor(hass, name, host, port)]) + + +class GpsdSensor(Entity): + """Representation of a GPS receiver available via GPSD.""" + + def __init__(self, hass, name, host, port): + """Initialize the GPSD sensor.""" + self.hass = hass + self._name = name + self._host = host + self._port = port + + self.agps_thread = AGPS3mechanism() + self.agps_thread.stream_data(host=self._host, port=self._port) + self.agps_thread.run_thread() + + @property + def name(self): + """Return the name.""" + return self._name + + @property + def state(self): + """Return the state of GPSD.""" + if self.agps_thread.data_stream.mode == 3: + return "3D Fix" + if self.agps_thread.data_stream.mode == 2: + return "2D Fix" + return None + + @property + def device_state_attributes(self): + """Return the state attributes of the GPS.""" + return { + ATTR_LATITUDE: self.agps_thread.data_stream.lat, + ATTR_LONGITUDE: self.agps_thread.data_stream.lon, + ATTR_ELEVATION: self.agps_thread.data_stream.alt, + ATTR_GPS_TIME: self.agps_thread.data_stream.time, + ATTR_SPEED: self.agps_thread.data_stream.speed, + ATTR_CLIMB: self.agps_thread.data_stream.climb, + ATTR_MODE: self.agps_thread.data_stream.mode, + } diff --git a/homeassistant/components/gpslogger/.translations/bg.json b/homeassistant/components/gpslogger/.translations/bg.json new file mode 100644 index 000000000..f7ce9a67f --- /dev/null +++ b/homeassistant/components/gpslogger/.translations/bg.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Home Assistant \u0442\u0440\u044f\u0431\u0432\u0430 \u0434\u0430 \u0435 \u0434\u043e\u0441\u0442\u044a\u043f\u0435\u043d \u043e\u0442 \u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442 \u0437\u0430 \u0434\u0430 \u043f\u043e\u043b\u0443\u0447\u0430\u0432\u0430 \u0441\u044a\u043e\u0431\u0449\u0435\u043d\u0438\u044f \u043e\u0442 GPSLogger.", + "one_instance_allowed": "\u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u0430 \u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f." + }, + "create_entry": { + "default": "\u0417\u0430 \u0434\u0430 \u0438\u0437\u043f\u0440\u0430\u0449\u0430\u0442\u0435 \u0441\u044a\u0431\u0438\u0442\u0438\u044f \u0434\u043e Home Assistant, \u0449\u0435 \u0442\u0440\u044f\u0431\u0432\u0430 \u0434\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u0435 \u0444\u0443\u043d\u043a\u0446\u0438\u044f\u0442\u0430 webhook \u0432 GPSLogger. \n\n \u041f\u043e\u043f\u044a\u043b\u043d\u0435\u0442\u0435 \u0441\u043b\u0435\u0434\u043d\u0430\u0442\u0430 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f: \n\n - URL: ` {webhook_url} ` \n - Method: POST \n\n \u0412\u0438\u0436\u0442\u0435 [\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u0442\u0430]({docs_url}) \u0437\u0430 \u043f\u043e\u0432\u0435\u0447\u0435 \u043f\u043e\u0434\u0440\u043e\u0431\u043d\u043e\u0441\u0442\u0438." + }, + "step": { + "user": { + "description": "\u0421\u0438\u0433\u0443\u0440\u043d\u0438 \u043b\u0438 \u0441\u0442\u0435, \u0447\u0435 \u0438\u0441\u043a\u0430\u0442\u0435 \u0434\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u0435 GPSLogger Webhook?", + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0432\u0430\u043d\u0435 \u043d\u0430 GPSLogger Webhook" + } + }, + "title": "GPSLogger Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/gpslogger/.translations/ca.json b/homeassistant/components/gpslogger/.translations/ca.json new file mode 100644 index 000000000..296159f2e --- /dev/null +++ b/homeassistant/components/gpslogger/.translations/ca.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "La inst\u00e0ncia de Home Assistant ha de ser accessible des d'Internet per rebre missatges de GPSLogger.", + "one_instance_allowed": "Nom\u00e9s cal una sola inst\u00e0ncia." + }, + "create_entry": { + "default": "Per enviar esdeveniments a Home Assistant, haur\u00e0s de configurar l'opci\u00f3 webhook de GPSLogger.\n\nCompleta la seg\u00fcent informaci\u00f3:\n\n- URL: `{webhook_url}` \n- M\u00e8tode: POST \n\nConsulta la [documentaci\u00f3]({docs_url}) per a m\u00e9s detalls." + }, + "step": { + "user": { + "description": "Est\u00e0s segur que vols configurar el Webhook de GPSLogger?", + "title": "Configuraci\u00f3 del Webhook de GPSLogger" + } + }, + "title": "Webhook de GPSLogger" + } +} \ No newline at end of file diff --git a/homeassistant/components/gpslogger/.translations/cs.json b/homeassistant/components/gpslogger/.translations/cs.json new file mode 100644 index 000000000..f79a9f5d7 --- /dev/null +++ b/homeassistant/components/gpslogger/.translations/cs.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Instalace dom\u00e1c\u00edho asistenta mus\u00ed b\u00fdt p\u0159\u00edstupn\u00e1 z internetu, aby p\u0159ij\u00edmala zpr\u00e1vy od spole\u010dnosti GPSLogger.", + "one_instance_allowed": "Povolena je pouze jedna instance." + }, + "create_entry": { + "default": "Chcete-li odeslat ud\u00e1losti do aplikace Home Assistant, budete muset nastavit funkci Webhook v n\u00e1stroji GPSLogger. \n\n Vypl\u0148te n\u00e1sleduj\u00edc\u00ed informace: \n\n - URL: ` {webhook_url} ' \n - Metoda: POST \n\n Dal\u0161\u00ed podrobnosti naleznete v [dokumentaci] ( {docs_url} )." + }, + "step": { + "user": { + "description": "Opravdu chcete nastavit GPSLogger Webhook?", + "title": "Nastavit GPSLogger Webhook" + } + }, + "title": "GPSLogger Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/gpslogger/.translations/da.json b/homeassistant/components/gpslogger/.translations/da.json new file mode 100644 index 000000000..6d5c21857 --- /dev/null +++ b/homeassistant/components/gpslogger/.translations/da.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Dit Home Assistant system skal v\u00e6re tilg\u00e6ngeligt fra internettet for at modtage GPSLogger meddelelser.", + "one_instance_allowed": "Det er kun n\u00f8dvendigt med en ops\u00e6tning" + }, + "create_entry": { + "default": "For at sende begivenheder til Home Assistant skal du konfigurere webhook funktionen i GPSLogger.\n\n Udfyld f\u00f8lgende oplysninger: \n\n - URL: `{webhook_url}`\n - Metode: POST\n \n Se [dokumentationen]({docs_url}) for yderligere oplysninger." + }, + "step": { + "user": { + "description": "Er du sikker p\u00e5 at du vil konfigurere GPSLogger Webhook?", + "title": "Konfigurer GPSLogger Webhook" + } + }, + "title": "GPSLogger Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/gpslogger/.translations/de.json b/homeassistant/components/gpslogger/.translations/de.json new file mode 100644 index 000000000..82c1dfa3e --- /dev/null +++ b/homeassistant/components/gpslogger/.translations/de.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Deine Home-Assistant-Instanz muss aus dem internet erreichbar sein, um Nachrichten von GPSLogger zu erhalten.", + "one_instance_allowed": "Nur eine einzige Instanz ist notwendig." + }, + "create_entry": { + "default": "Um Ereignisse an Home Assistant zu senden, muss das Webhook Feature in der GPSLogger konfiguriert werden.\n\n F\u00fcge die folgenden Informationen ein: \n\n - URL: ` {webhook_url} ` \n - Methode: POST \n \n Weitere Informationen finden sich in der [Dokumentation]({docs_url})." + }, + "step": { + "user": { + "description": "M\u00f6chten Sie den GPSLogger Webhook wirklich einrichten?", + "title": "GPSLogger Webhook einrichten" + } + }, + "title": "GPSLogger Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/gpslogger/.translations/en.json b/homeassistant/components/gpslogger/.translations/en.json new file mode 100644 index 000000000..ad8f978bc --- /dev/null +++ b/homeassistant/components/gpslogger/.translations/en.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive messages from GPSLogger.", + "one_instance_allowed": "Only a single instance is necessary." + }, + "create_entry": { + "default": "To send events to Home Assistant, you will need to setup the webhook feature in GPSLogger.\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nSee [the documentation]({docs_url}) for further details." + }, + "step": { + "user": { + "description": "Are you sure you want to set up the GPSLogger Webhook?", + "title": "Set up the GPSLogger Webhook" + } + }, + "title": "GPSLogger Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/gpslogger/.translations/es-419.json b/homeassistant/components/gpslogger/.translations/es-419.json new file mode 100644 index 000000000..960198eb0 --- /dev/null +++ b/homeassistant/components/gpslogger/.translations/es-419.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Su instancia de Home Assistant debe ser accesible desde Internet para recibir mensajes de GPSLogger.", + "one_instance_allowed": "Solo una instancia es necesaria." + }, + "create_entry": { + "default": "Para enviar eventos a Home Assistant, deber\u00e1 configurar la funci\u00f3n de webhook en GPSLogger. \n\n Complete la siguiente informaci\u00f3n: \n\n - URL: ` {webhook_url} ` \n - M\u00e9todo: POST \n\n Vea [la documentaci\u00f3n] ( {docs_url} ) para m\u00e1s detalles." + }, + "step": { + "user": { + "description": "\u00bfEst\u00e1 seguro de que desea configurar el Webhook de GPSLogger?", + "title": "Configurar el Webhook de GPSLogger" + } + }, + "title": "GPSLogger Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/gpslogger/.translations/es.json b/homeassistant/components/gpslogger/.translations/es.json new file mode 100644 index 000000000..7b90a5c5c --- /dev/null +++ b/homeassistant/components/gpslogger/.translations/es.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Tu Home Assistant debe ser accesible desde Internet para recibir mensajes de GPSLogger.", + "one_instance_allowed": "Solo se necesita una instancia." + }, + "create_entry": { + "default": "Para enviar eventos a Home Assistant, necesitar\u00e1s configurar la funci\u00f3n de webhook en GPSLogger.\n\nRellena la siguiente informaci\u00f3n:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nEcha un vistazo a [la documentaci\u00f3n]({docs_url}) para m\u00e1s detalles." + }, + "step": { + "user": { + "description": "\u00bfEst\u00e1s seguro de que quieres configurar el webhook de GPSLogger?", + "title": "Configurar el webhook de GPSLogger" + } + }, + "title": "Webhook de GPSLogger" + } +} \ No newline at end of file diff --git a/homeassistant/components/gpslogger/.translations/fr.json b/homeassistant/components/gpslogger/.translations/fr.json new file mode 100644 index 000000000..ae2b21777 --- /dev/null +++ b/homeassistant/components/gpslogger/.translations/fr.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Votre instance Home Assistant doit \u00eatre accessible \u00e0 partir d'Internet pour recevoir les messages GPSLogger.", + "one_instance_allowed": "Une seule instance est n\u00e9cessaire." + }, + "create_entry": { + "default": "Pour envoyer des \u00e9v\u00e9nements \u00e0 Home Assistant, vous devez configurer la fonction Webhook dans GPSLogger. \n\n Remplissez les informations suivantes: \n\n - URL: ` {webhook_url} ` \n - M\u00e9thode: POST \n\n Voir [la documentation] ( {docs_url} ) pour plus de d\u00e9tails." + }, + "step": { + "user": { + "description": "\u00cates-vous s\u00fbr de vouloir configurer le Webhook GPSLogger ?", + "title": "Configurer le Webhook GPSLogger" + } + }, + "title": "Webhook GPSLogger" + } +} \ No newline at end of file diff --git a/homeassistant/components/gpslogger/.translations/hu.json b/homeassistant/components/gpslogger/.translations/hu.json new file mode 100644 index 000000000..2d1dcad21 --- /dev/null +++ b/homeassistant/components/gpslogger/.translations/hu.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Az Home Assistantnek el\u00e9rhet\u0151nek kell lennie az internetr\u0151l, hogy megkapja a GPSLogger \u00fczeneteit.", + "one_instance_allowed": "Csak egy p\u00e9ld\u00e1ny sz\u00fcks\u00e9ges." + }, + "create_entry": { + "default": "Az esem\u00e9ny Home Assistantnek val\u00f3 k\u00fcld\u00e9s\u00e9hez be kell \u00e1ll\u00edtanod a webhook funkci\u00f3t a GPSLoggerben. \n\n Az al\u00e1bbi inform\u00e1ci\u00f3kat haszn\u00e1ld: \n\n - URL: ` {webhook_url} ` \n - Method: POST \n\n Tov\u00e1bbi r\u00e9szletek a [dokument\u00e1ci\u00f3] ( {docs_url} ) linken tal\u00e1lhat\u00f3k." + }, + "step": { + "user": { + "description": "Biztosan be szeretn\u00e9d \u00e1ll\u00edtani a GPSLogger Webhookot?", + "title": "GPSLogger Webhook be\u00e1ll\u00edt\u00e1sa" + } + }, + "title": "GPSLogger Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/gpslogger/.translations/it.json b/homeassistant/components/gpslogger/.translations/it.json new file mode 100644 index 000000000..aab8edbe4 --- /dev/null +++ b/homeassistant/components/gpslogger/.translations/it.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "La tua istanza di Home Assistant deve essere accessibile da Internet per ricevere messaggi da GPSLogger.", + "one_instance_allowed": "\u00c8 necessaria una sola istanza." + }, + "create_entry": { + "default": "Per inviare eventi a Home Assistant, dovrai configurare la funzionalit\u00e0 webhook in GPSLogger.\n\n Compila le seguenti informazioni: \n\n - URL: ` {webhook_url} ` \n - Method: POST \n\n Vedi [la documentazione]({docs_url}) for ulteriori dettagli." + }, + "step": { + "user": { + "description": "Sei sicuro di voler configurare il webhook di GPSLogger?", + "title": "Configura il webhook di GPSLogger" + } + }, + "title": "Webhook di GPSLogger" + } +} \ No newline at end of file diff --git a/homeassistant/components/gpslogger/.translations/ko.json b/homeassistant/components/gpslogger/.translations/ko.json new file mode 100644 index 000000000..786a67b0b --- /dev/null +++ b/homeassistant/components/gpslogger/.translations/ko.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "GPSLogger \uba54\uc2dc\uc9c0\ub97c \ubc1b\uc73c\ub824\uba74 \uc778\ud130\ub137\uc5d0\uc11c Home Assistant \uc778\uc2a4\ud134\uc2a4\uc5d0 \uc561\uc138\uc2a4 \ud560 \uc218 \uc788\uc5b4\uc57c \ud569\ub2c8\ub2e4.", + "one_instance_allowed": "\ud558\ub098\uc758 \uc778\uc2a4\ud134\uc2a4\ub9cc \ud544\uc694\ud569\ub2c8\ub2e4." + }, + "create_entry": { + "default": "Home Assistant \ub85c \uc774\ubca4\ud2b8\ub97c \ubcf4\ub0b4\ub824\uba74 GPSLogger \uc5d0\uc11c Webhook \uc744 \uc124\uc815\ud574\uc57c\ud569\ub2c8\ub2e4. \n\n\ub2e4\uc74c \uc815\ubcf4\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694:\n\n - URL: `{webhook_url}`\n - Method: POST\n \n \uc790\uc138\ud55c \uc815\ubcf4\ub294 [\uc548\ub0b4]({docs_url}) \ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694." + }, + "step": { + "user": { + "description": "GPSLogger Webhook \uc744 \uc124\uc815 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "title": "GPSLogger Webhook \uc124\uc815" + } + }, + "title": "GPSLogger Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/gpslogger/.translations/lb.json b/homeassistant/components/gpslogger/.translations/lb.json new file mode 100644 index 000000000..78df911c8 --- /dev/null +++ b/homeassistant/components/gpslogger/.translations/lb.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "\u00c4r Home Assistant Instanz muss iwwert Internet accessibel si fir GPSLogger Noriichten z'empf\u00e4nken.", + "one_instance_allowed": "N\u00ebmmen eng eenzeg Instanz ass n\u00e9ideg." + }, + "create_entry": { + "default": "Fir Evenementer un Home Assistant ze sch\u00e9cken, muss den Webhook Feature am GPSLogger ageriicht ginn.\n\nF\u00ebllt folgend Informatiounen aus:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nLiest [Dokumentatioun]({docs_url}) fir w\u00e9ider D\u00e9tailer." + }, + "step": { + "user": { + "description": "S\u00e9cher fir GPSLogger Webhook anzeriichten?", + "title": "GPSLogger Webhook ariichten" + } + }, + "title": "GPSLogger Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/gpslogger/.translations/nl.json b/homeassistant/components/gpslogger/.translations/nl.json new file mode 100644 index 000000000..f34a21b78 --- /dev/null +++ b/homeassistant/components/gpslogger/.translations/nl.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Uw Home Assistant-instantie moet via internet toegankelijk zijn om berichten van GPSLogger te ontvangen.", + "one_instance_allowed": "Slechts \u00e9\u00e9n instantie is nodig." + }, + "create_entry": { + "default": "Om evenementen naar Home Assistant te verzenden, moet u de webhook-functie instellen in GPSLogger. \n\n Vul de volgende info in: \n\n - URL: ` {webhook_url} ` \n - Methode: POST \n\n Zie [de documentatie] ( {docs_url} ) voor meer informatie." + }, + "step": { + "user": { + "description": "Weet je zeker dat je de GPSLogger Webhook wilt instellen?", + "title": "Configureer de GPSLogger Webhook" + } + }, + "title": "GPSLogger Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/gpslogger/.translations/no.json b/homeassistant/components/gpslogger/.translations/no.json new file mode 100644 index 000000000..488a09c37 --- /dev/null +++ b/homeassistant/components/gpslogger/.translations/no.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Home Assistant m\u00e5 v\u00e6re tilgjengelig fra internett for \u00e5 kunne motta meldinger fra GPSLogger.", + "one_instance_allowed": "Kun en forekomst er n\u00f8dvendig." + }, + "create_entry": { + "default": "For \u00e5 sende hendelser til Home Assistant, m\u00e5 du sette opp webhook-funksjonen i GPSLogger. \n\n Fyll ut f\u00f8lgende informasjon: \n\n - URL: `{webhook_url}` \n - Metode: POST \n\n Se [dokumentasjonen]({docs_url}) for ytterligere detaljer." + }, + "step": { + "user": { + "description": "Er du sikker p\u00e5 at du vil sette opp GPSLogger Webhook?", + "title": "Sett opp GPSLogger Webhook" + } + }, + "title": "GPSLogger Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/gpslogger/.translations/pl.json b/homeassistant/components/gpslogger/.translations/pl.json new file mode 100644 index 000000000..434fdd222 --- /dev/null +++ b/homeassistant/components/gpslogger/.translations/pl.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Twoja instancja Home Assistant musi by\u0107 dost\u0119pna z Internetu, aby otrzymywa\u0107 wiadomo\u015bci z GPSlogger.", + "one_instance_allowed": "Wymagana jest tylko jedna instancja." + }, + "create_entry": { + "default": "Aby wysy\u0142a\u0107 lokalizacje do Home Assistant'a, musisz skonfigurowa\u0107 webhook w aplikacji GPSLogger. \n\n Wprowad\u017a nast\u0119puj\u0105ce dane:\n\n - URL: `{webhook_url}` \n - Metoda: POST \n\nZapoznaj si\u0119 z [dokumentacj\u0105]({docs_url}), by pozna\u0107 szczeg\u00f3\u0142y." + }, + "step": { + "user": { + "description": "Na pewno chcesz skonfigurowa\u0107 Geofency?", + "title": "Konfiguracja Geofency Webhook" + } + }, + "title": "Konfiguracja Geofency Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/gpslogger/.translations/pt-BR.json b/homeassistant/components/gpslogger/.translations/pt-BR.json new file mode 100644 index 000000000..86c68a4cf --- /dev/null +++ b/homeassistant/components/gpslogger/.translations/pt-BR.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Sua inst\u00e2ncia do Home Assistant precisa estar acess\u00edvel na Internet para receber mensagens do GPSLogger.", + "one_instance_allowed": "Apenas uma \u00fanica inst\u00e2ncia \u00e9 necess\u00e1ria." + }, + "create_entry": { + "default": "Para enviar eventos para o Home Assistant, voc\u00ea precisar\u00e1 configurar o recurso webhook no GPSLogger. \n\n Preencha as seguintes informa\u00e7\u00f5es: \n\n - URL: ` {webhook_url} ` \n - M\u00e9todo: POST \n\n Veja [a documenta\u00e7\u00e3o] ( {docs_url} ) para mais detalhes." + }, + "step": { + "user": { + "description": "Tem a certeza que deseja configurar o GPSLogger Webhook?", + "title": "Configurar o GPSLogger Webhook" + } + }, + "title": "GPSLogger Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/gpslogger/.translations/pt.json b/homeassistant/components/gpslogger/.translations/pt.json new file mode 100644 index 000000000..4dcfda527 --- /dev/null +++ b/homeassistant/components/gpslogger/.translations/pt.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "A sua inst\u00e2ncia Home Assistent precisa de ser acess\u00edvel a partir da internet para receber mensagens GPSlogger.", + "one_instance_allowed": "Apenas uma inst\u00e2ncia \u00e9 necess\u00e1ria." + }, + "create_entry": { + "default": "Para enviar eventos para o Home Assistant, \u00e9 necess\u00e1rio configurar um webhook no GPslogger. \n\n Preencha as seguintes informa\u00e7\u00f5es: \n\n - URL: `{webhook_url}`\n - M\u00e9todo: POST \n\n Veja [the documentation]({docs_url}) para obter mais detalhes." + }, + "step": { + "user": { + "description": "Tem certeza de que deseja configurar o GPSLogger Webhook?", + "title": "Configurar o Geofency Webhook" + } + }, + "title": "GPSLogger Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/gpslogger/.translations/ru.json b/homeassistant/components/gpslogger/.translations/ru.json new file mode 100644 index 000000000..b33bde95a --- /dev/null +++ b/homeassistant/components/gpslogger/.translations/ru.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "\u0412\u0430\u0448 Home Assistant \u0434\u043e\u043b\u0436\u0435\u043d \u0431\u044b\u0442\u044c \u0434\u043e\u0441\u0442\u0443\u043f\u0435\u043d \u0438\u0437 \u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u0430 \u0434\u043b\u044f \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0439 GPSLogger.", + "one_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430." + }, + "create_entry": { + "default": "\u0414\u043b\u044f \u043e\u0442\u043f\u0440\u0430\u0432\u043a\u0438 \u0441\u043e\u0431\u044b\u0442\u0438\u0439 \u0432 Home Assistant \u0412\u044b \u0434\u043e\u043b\u0436\u043d\u044b \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c Webhook \u0434\u043b\u044f GPSLogger.\n\n\u0414\u043b\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0439\u0442\u0435 \u0441\u043b\u0435\u0434\u0443\u044e\u0449\u0443\u044e \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e:\n\n- URL: `{webhook_url}`\n- Method: POST\n\n\u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 [\u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438]({docs_url}) \u0434\u043b\u044f \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f \u0431\u043e\u043b\u0435\u0435 \u043f\u043e\u0434\u0440\u043e\u0431\u043d\u043e\u0439 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u0438." + }, + "step": { + "user": { + "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c GPSLogger?", + "title": "GPSLogger" + } + }, + "title": "GPSLogger" + } +} \ No newline at end of file diff --git a/homeassistant/components/gpslogger/.translations/sl.json b/homeassistant/components/gpslogger/.translations/sl.json new file mode 100644 index 000000000..8e205bef4 --- /dev/null +++ b/homeassistant/components/gpslogger/.translations/sl.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Va\u0161 Home Assistant mora biti dostopek prek interneta, da boste lahko prejemali GPSlogger sporo\u010dila.", + "one_instance_allowed": "Potrebna je samo ena instanca." + }, + "create_entry": { + "default": "\u010ce \u017eelite dogodke poslati v Home Assistant, morate v GPSLoggerju nastaviti funkcijo webhook. \n\n Izpolnite naslednje podatke: \n\n - URL: ` {webhook_url} ` \n - Metoda: POST \n\n Za ve\u010d podrobnosti si oglejte [dokumentacijo] ( {docs_url} )." + }, + "step": { + "user": { + "description": "Ali ste prepri\u010dani, da \u017eelite nastaviti GPSloggerWebhook?", + "title": "Nastavite GPSlogger Webhook" + } + }, + "title": "GPSLogger Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/gpslogger/.translations/sv.json b/homeassistant/components/gpslogger/.translations/sv.json new file mode 100644 index 000000000..3a927a70e --- /dev/null +++ b/homeassistant/components/gpslogger/.translations/sv.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Din Home Assistant instans m\u00e5ste vara \u00e5tkomlig ifr\u00e5n internet f\u00f6r att ta emot meddelanden ifr\u00e5n GPSLogger.", + "one_instance_allowed": "Endast en enda instans \u00e4r n\u00f6dv\u00e4ndig." + }, + "create_entry": { + "default": "F\u00f6r att skicka h\u00e4ndelser till Home Assistant m\u00e5ste du konfigurera webhook funktionen i GPSLogger.\n\n Fyll i f\u00f6ljande information:\n \n- URL: `{webhook_url}`\n- Method: POST\n\nSe [dokumentation]({docs_url}) om hur du konfigurerar detta f\u00f6r mer information." + }, + "step": { + "user": { + "description": "\u00c4r du s\u00e4ker p\u00e5 att du vill konfigurera GPSLogger Webhook?", + "title": "Konfigurera GPSLogger Webhook" + } + }, + "title": "GPSLogger Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/gpslogger/.translations/zh-Hans.json b/homeassistant/components/gpslogger/.translations/zh-Hans.json new file mode 100644 index 000000000..f99efa91c --- /dev/null +++ b/homeassistant/components/gpslogger/.translations/zh-Hans.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "\u60a8\u7684 Home Assistant \u5b9e\u4f8b\u9700\u8981\u53ef\u4ece\u4e92\u8054\u7f51\u8bbf\u95ee\u4ee5\u63a5\u6536 GPSLogger \u6d88\u606f\u3002", + "one_instance_allowed": "\u53ea\u6709\u4e00\u4e2a\u5b9e\u4f8b\u662f\u5fc5\u9700\u7684\u3002" + }, + "create_entry": { + "default": "\u8981\u5411 Home Assistant \u53d1\u9001\u4e8b\u4ef6\uff0c\u60a8\u9700\u8981\u914d\u7f6e GPSLogger \u7684 Webhook \u529f\u80fd\u3002\n\n\u586b\u5199\u4ee5\u4e0b\u4fe1\u606f\uff1a\n\n- URL: `{webhook_url}`\n- Method: POST\n\n\u8bf7\u53c2\u9605[\u6587\u6863]({docs_url})\u4ee5\u4e86\u89e3\u66f4\u591a\u4fe1\u606f\u3002" + }, + "step": { + "user": { + "description": "\u60a8\u786e\u5b9a\u8981\u8bbe\u7f6e GPSLogger Webhook \u5417\uff1f", + "title": "\u8bbe\u7f6e GPSLogger Webhook" + } + }, + "title": "GPSLogger Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/gpslogger/.translations/zh-Hant.json b/homeassistant/components/gpslogger/.translations/zh-Hant.json new file mode 100644 index 000000000..c21e76a6e --- /dev/null +++ b/homeassistant/components/gpslogger/.translations/zh-Hant.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Home Assistant \u7269\u4ef6\u5fc5\u9808\u80fd\u5920\u7531\u7db2\u969b\u7db2\u8def\u5b58\u53d6\uff0c\u65b9\u80fd\u63a5\u53d7 GPSLogger \u8a0a\u606f\u3002", + "one_instance_allowed": "\u50c5\u9700\u8a2d\u5b9a\u4e00\u7d44\u7269\u4ef6\u5373\u53ef\u3002" + }, + "create_entry": { + "default": "\u6b32\u50b3\u9001\u4e8b\u4ef6\u81f3 Home Assistant\uff0c\u5c07\u9700\u65bc GPSLogger \u5167\u8a2d\u5b9a webhook \u529f\u80fd\u3002\n\n\u8acb\u586b\u5beb\u4e0b\u5217\u8cc7\u8a0a\uff1a\n\n- URL: `{webhook_url}`\n- Method: POST\n\n\u8acb\u53c3\u95b1 [\u6587\u4ef6]({docs_url})\u4ee5\u4e86\u89e3\u66f4\u8a73\u7d30\u8cc7\u6599\u3002" + }, + "step": { + "user": { + "description": "\u662f\u5426\u8981\u8a2d\u5b9a GPSLogger Webhook\uff1f", + "title": "\u8a2d\u5b9a GPSLogger Webhook" + } + }, + "title": "GPSLogger Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/gpslogger/__init__.py b/homeassistant/components/gpslogger/__init__.py new file mode 100644 index 000000000..aa95d17cb --- /dev/null +++ b/homeassistant/components/gpslogger/__init__.py @@ -0,0 +1,120 @@ +"""Support for GPSLogger.""" +import logging + +from aiohttp import web +import voluptuous as vol + +from homeassistant.components.device_tracker import ( + ATTR_BATTERY, + DOMAIN as DEVICE_TRACKER, +) +from homeassistant.const import ( + ATTR_LATITUDE, + ATTR_LONGITUDE, + CONF_WEBHOOK_ID, + HTTP_OK, + HTTP_UNPROCESSABLE_ENTITY, +) +from homeassistant.helpers import config_entry_flow +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from .const import ( + ATTR_ACCURACY, + ATTR_ACTIVITY, + ATTR_ALTITUDE, + ATTR_DEVICE, + ATTR_DIRECTION, + ATTR_PROVIDER, + ATTR_SPEED, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + +TRACKER_UPDATE = f"{DOMAIN}_tracker_update" + + +DEFAULT_ACCURACY = 200 +DEFAULT_BATTERY = -1 + + +def _id(value: str) -> str: + """Coerce id by removing '-'.""" + return value.replace("-", "") + + +WEBHOOK_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE): _id, + vol.Required(ATTR_LATITUDE): cv.latitude, + vol.Required(ATTR_LONGITUDE): cv.longitude, + vol.Optional(ATTR_ACCURACY, default=DEFAULT_ACCURACY): vol.Coerce(float), + vol.Optional(ATTR_ACTIVITY): cv.string, + vol.Optional(ATTR_ALTITUDE): vol.Coerce(float), + vol.Optional(ATTR_BATTERY, default=DEFAULT_BATTERY): vol.Coerce(float), + vol.Optional(ATTR_DIRECTION): vol.Coerce(float), + vol.Optional(ATTR_PROVIDER): cv.string, + vol.Optional(ATTR_SPEED): vol.Coerce(float), + } +) + + +async def async_setup(hass, hass_config): + """Set up the GPSLogger component.""" + hass.data[DOMAIN] = {"devices": set(), "unsub_device_tracker": {}} + return True + + +async def handle_webhook(hass, webhook_id, request): + """Handle incoming webhook with GPSLogger request.""" + try: + data = WEBHOOK_SCHEMA(dict(await request.post())) + except vol.MultipleInvalid as error: + return web.Response(text=error.error_message, status=HTTP_UNPROCESSABLE_ENTITY) + + attrs = { + ATTR_SPEED: data.get(ATTR_SPEED), + ATTR_DIRECTION: data.get(ATTR_DIRECTION), + ATTR_ALTITUDE: data.get(ATTR_ALTITUDE), + ATTR_PROVIDER: data.get(ATTR_PROVIDER), + ATTR_ACTIVITY: data.get(ATTR_ACTIVITY), + } + + device = data[ATTR_DEVICE] + + async_dispatcher_send( + hass, + TRACKER_UPDATE, + device, + (data[ATTR_LATITUDE], data[ATTR_LONGITUDE]), + data[ATTR_BATTERY], + data[ATTR_ACCURACY], + attrs, + ) + + return web.Response(text=f"Setting location for {device}", status=HTTP_OK) + + +async def async_setup_entry(hass, entry): + """Configure based on config entry.""" + hass.components.webhook.async_register( + DOMAIN, "GPSLogger", entry.data[CONF_WEBHOOK_ID], handle_webhook + ) + + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, DEVICE_TRACKER) + ) + return True + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID]) + hass.data[DOMAIN]["unsub_device_tracker"].pop(entry.entry_id)() + await hass.config_entries.async_forward_entry_unload(entry, DEVICE_TRACKER) + return True + + +# pylint: disable=invalid-name +async_remove_entry = config_entry_flow.webhook_async_remove_entry diff --git a/homeassistant/components/gpslogger/config_flow.py b/homeassistant/components/gpslogger/config_flow.py new file mode 100644 index 000000000..ef90a8d16 --- /dev/null +++ b/homeassistant/components/gpslogger/config_flow.py @@ -0,0 +1,10 @@ +"""Config flow for GPSLogger.""" +from homeassistant.helpers import config_entry_flow + +from .const import DOMAIN + +config_entry_flow.register_webhook_flow( + DOMAIN, + "GPSLogger Webhook", + {"docs_url": "https://www.home-assistant.io/integrations/gpslogger/"}, +) diff --git a/homeassistant/components/gpslogger/const.py b/homeassistant/components/gpslogger/const.py new file mode 100644 index 000000000..48dc9e7a4 --- /dev/null +++ b/homeassistant/components/gpslogger/const.py @@ -0,0 +1,11 @@ +"""Const for GPSLogger.""" + +DOMAIN = "gpslogger" + +ATTR_ALTITUDE = "altitude" +ATTR_ACCURACY = "accuracy" +ATTR_ACTIVITY = "activity" +ATTR_DEVICE = "device" +ATTR_DIRECTION = "direction" +ATTR_PROVIDER = "provider" +ATTR_SPEED = "speed" diff --git a/homeassistant/components/gpslogger/device_tracker.py b/homeassistant/components/gpslogger/device_tracker.py new file mode 100644 index 000000000..d8afc377d --- /dev/null +++ b/homeassistant/components/gpslogger/device_tracker.py @@ -0,0 +1,182 @@ +"""Support for the GPSLogger device tracking.""" +import logging + +from homeassistant.components.device_tracker import SOURCE_TYPE_GPS +from homeassistant.components.device_tracker.config_entry import TrackerEntity +from homeassistant.const import ( + ATTR_BATTERY_LEVEL, + ATTR_GPS_ACCURACY, + ATTR_LATITUDE, + ATTR_LONGITUDE, +) +from homeassistant.core import callback +from homeassistant.helpers import device_registry +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.typing import HomeAssistantType + +from . import DOMAIN as GPL_DOMAIN, TRACKER_UPDATE +from .const import ( + ATTR_ACTIVITY, + ATTR_ALTITUDE, + ATTR_DIRECTION, + ATTR_PROVIDER, + ATTR_SPEED, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistantType, entry, async_add_entities): + """Configure a dispatcher connection based on a config entry.""" + + @callback + def _receive_data(device, gps, battery, accuracy, attrs): + """Receive set location.""" + if device in hass.data[GPL_DOMAIN]["devices"]: + return + + hass.data[GPL_DOMAIN]["devices"].add(device) + + async_add_entities([GPSLoggerEntity(device, gps, battery, accuracy, attrs)]) + + hass.data[GPL_DOMAIN]["unsub_device_tracker"][ + entry.entry_id + ] = async_dispatcher_connect(hass, TRACKER_UPDATE, _receive_data) + + # Restore previously loaded devices + dev_reg = await device_registry.async_get_registry(hass) + dev_ids = { + identifier[1] + for device in dev_reg.devices.values() + for identifier in device.identifiers + if identifier[0] == GPL_DOMAIN + } + if not dev_ids: + return + + entities = [] + for dev_id in dev_ids: + hass.data[GPL_DOMAIN]["devices"].add(dev_id) + entity = GPSLoggerEntity(dev_id, None, None, None, None) + entities.append(entity) + + async_add_entities(entities) + + +class GPSLoggerEntity(TrackerEntity, RestoreEntity): + """Represent a tracked device.""" + + def __init__(self, device, location, battery, accuracy, attributes): + """Set up Geofency entity.""" + self._accuracy = accuracy + self._attributes = attributes + self._name = device + self._battery = battery + self._location = location + self._unsub_dispatcher = None + self._unique_id = device + + @property + def battery_level(self): + """Return battery value of the device.""" + return self._battery + + @property + def device_state_attributes(self): + """Return device specific attributes.""" + return self._attributes + + @property + def latitude(self): + """Return latitude value of the device.""" + return self._location[0] + + @property + def longitude(self): + """Return longitude value of the device.""" + return self._location[1] + + @property + def location_accuracy(self): + """Return the gps accuracy of the device.""" + return self._accuracy + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def unique_id(self): + """Return the unique ID.""" + return self._unique_id + + @property + def device_info(self): + """Return the device info.""" + return {"name": self._name, "identifiers": {(GPL_DOMAIN, self._unique_id)}} + + @property + def source_type(self): + """Return the source type, eg gps or router, of the device.""" + return SOURCE_TYPE_GPS + + async def async_added_to_hass(self): + """Register state update callback.""" + await super().async_added_to_hass() + self._unsub_dispatcher = async_dispatcher_connect( + self.hass, TRACKER_UPDATE, self._async_receive_data + ) + + # don't restore if we got created with data + if self._location is not None: + return + + state = await self.async_get_last_state() + if state is None: + self._location = (None, None) + self._accuracy = None + self._attributes = { + ATTR_ALTITUDE: None, + ATTR_ACTIVITY: None, + ATTR_DIRECTION: None, + ATTR_PROVIDER: None, + ATTR_SPEED: None, + } + self._battery = None + return + + attr = state.attributes + self._location = (attr.get(ATTR_LATITUDE), attr.get(ATTR_LONGITUDE)) + self._accuracy = attr.get(ATTR_GPS_ACCURACY) + self._attributes = { + ATTR_ALTITUDE: attr.get(ATTR_ALTITUDE), + ATTR_ACTIVITY: attr.get(ATTR_ACTIVITY), + ATTR_DIRECTION: attr.get(ATTR_DIRECTION), + ATTR_PROVIDER: attr.get(ATTR_PROVIDER), + ATTR_SPEED: attr.get(ATTR_SPEED), + } + self._battery = attr.get(ATTR_BATTERY_LEVEL) + + async def async_will_remove_from_hass(self): + """Clean up after entity before removal.""" + await super().async_will_remove_from_hass() + self._unsub_dispatcher() + + @callback + def _async_receive_data(self, device, location, battery, accuracy, attributes): + """Mark the device as seen.""" + if device != self.name: + return + + self._location = location + self._battery = battery + self._accuracy = accuracy + self._attributes.update(attributes) + self.async_write_ha_state() diff --git a/homeassistant/components/gpslogger/manifest.json b/homeassistant/components/gpslogger/manifest.json new file mode 100644 index 000000000..cbfd79671 --- /dev/null +++ b/homeassistant/components/gpslogger/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "gpslogger", + "name": "Gpslogger", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/gpslogger", + "requirements": [], + "dependencies": [ + "webhook" + ], + "codeowners": [] +} diff --git a/homeassistant/components/gpslogger/strings.json b/homeassistant/components/gpslogger/strings.json new file mode 100644 index 000000000..d5641ef5d --- /dev/null +++ b/homeassistant/components/gpslogger/strings.json @@ -0,0 +1,18 @@ +{ + "config": { + "title": "GPSLogger Webhook", + "step": { + "user": { + "title": "Set up the GPSLogger Webhook", + "description": "Are you sure you want to set up the GPSLogger Webhook?" + } + }, + "abort": { + "one_instance_allowed": "Only a single instance is necessary.", + "not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive messages from GPSLogger." + }, + "create_entry": { + "default": "To send events to Home Assistant, you will need to setup the webhook feature in GPSLogger.\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nSee [the documentation]({docs_url}) for further details." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/graphite.py b/homeassistant/components/graphite.py deleted file mode 100644 index 2b768bc37..000000000 --- a/homeassistant/components/graphite.py +++ /dev/null @@ -1,156 +0,0 @@ -""" -Component that sends data to a Graphite installation. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/graphite/ -""" -import logging -import queue -import socket -import threading -import time - -import voluptuous as vol - -import homeassistant.helpers.config_validation as cv -from homeassistant.const import ( - CONF_HOST, CONF_PORT, CONF_PREFIX, EVENT_HOMEASSISTANT_START, - EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED) -from homeassistant.helpers import state - -_LOGGER = logging.getLogger(__name__) - -DEFAULT_HOST = 'localhost' -DEFAULT_PORT = 2003 -DEFAULT_PREFIX = 'ha' -DOMAIN = 'graphite' - -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_PREFIX, default=DEFAULT_PREFIX): cv.string, - }), -}, extra=vol.ALLOW_EXTRA) - - -def setup(hass, config): - """Set up the Graphite feeder.""" - conf = config[DOMAIN] - host = conf.get(CONF_HOST) - prefix = conf.get(CONF_PREFIX) - port = conf.get(CONF_PORT) - - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - try: - sock.connect((host, port)) - sock.shutdown(2) - _LOGGER.debug("Connection to Graphite possible") - except socket.error: - _LOGGER.error("Not able to connect to Graphite") - return False - - GraphiteFeeder(hass, host, port, prefix) - return True - - -class GraphiteFeeder(threading.Thread): - """Feed data to Graphite.""" - - def __init__(self, hass, host, port, prefix): - """Initialize the feeder.""" - super(GraphiteFeeder, self).__init__(daemon=True) - self._hass = hass - self._host = host - self._port = port - # rstrip any trailing dots in case they think they need it - self._prefix = prefix.rstrip('.') - self._queue = queue.Queue() - self._quit_object = object() - self._we_started = False - - hass.bus.listen_once(EVENT_HOMEASSISTANT_START, - self.start_listen) - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, - self.shutdown) - hass.bus.listen(EVENT_STATE_CHANGED, self.event_listener) - _LOGGER.debug("Graphite feeding to %s:%i initialized", - self._host, self._port) - - def start_listen(self, event): - """Start event-processing thread.""" - _LOGGER.debug("Event processing thread started") - self._we_started = True - self.start() - - def shutdown(self, event): - """Signal shutdown of processing event.""" - _LOGGER.debug("Event processing signaled exit") - self._queue.put(self._quit_object) - - def event_listener(self, event): - """Queue an event for processing.""" - if self.is_alive() or not self._we_started: - _LOGGER.debug("Received event") - self._queue.put(event) - else: - _LOGGER.error( - "Graphite feeder thread has died, not queuing event!") - - def _send_to_graphite(self, data): - """Send data to Graphite.""" - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(10) - sock.connect((self._host, self._port)) - sock.sendall(data.encode('ascii')) - sock.send('\n'.encode('ascii')) - sock.close() - - def _report_attributes(self, entity_id, new_state): - """Report the attributes.""" - now = time.time() - things = dict(new_state.attributes) - try: - things['state'] = state.state_as_number(new_state) - except ValueError: - pass - lines = ['%s.%s.%s %f %i' % (self._prefix, - entity_id, key.replace(' ', '_'), - value, now) - for key, value in things.items() - if isinstance(value, (float, int))] - if not lines: - return - _LOGGER.debug("Sending to graphite: %s", lines) - try: - self._send_to_graphite('\n'.join(lines)) - except socket.gaierror: - _LOGGER.error("Unable to connect to host %s", self._host) - except socket.error: - _LOGGER.exception("Failed to send data to graphite") - - def run(self): - """Run the process to export the data.""" - while True: - event = self._queue.get() - if event == self._quit_object: - _LOGGER.debug("Event processing thread stopped") - self._queue.task_done() - return - if event.event_type == EVENT_STATE_CHANGED and \ - event.data.get('new_state'): - _LOGGER.debug("Processing STATE_CHANGED event for %s", - event.data['entity_id']) - try: - self._report_attributes( - event.data['entity_id'], event.data['new_state']) - # pylint: disable=broad-except - except Exception: - # Catch this so we can avoid the thread dying and - # make it visible. - _LOGGER.exception("Failed to process STATE_CHANGED event") - else: - _LOGGER.warning( - "Processing unexpected event type %s", event.event_type) - - self._queue.task_done() diff --git a/homeassistant/components/graphite/__init__.py b/homeassistant/components/graphite/__init__.py new file mode 100644 index 000000000..bf34bc3dd --- /dev/null +++ b/homeassistant/components/graphite/__init__.py @@ -0,0 +1,157 @@ +"""Support for sending data to a Graphite installation.""" +import logging +import queue +import socket +import threading +import time + +import voluptuous as vol + +from homeassistant.const import ( + CONF_HOST, + CONF_PORT, + CONF_PREFIX, + EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP, + EVENT_STATE_CHANGED, +) +from homeassistant.helpers import state +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_HOST = "localhost" +DEFAULT_PORT = 2003 +DEFAULT_PREFIX = "ha" +DOMAIN = "graphite" + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_PREFIX, default=DEFAULT_PREFIX): cv.string, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +def setup(hass, config): + """Set up the Graphite feeder.""" + conf = config[DOMAIN] + host = conf.get(CONF_HOST) + prefix = conf.get(CONF_PREFIX) + port = conf.get(CONF_PORT) + + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + sock.connect((host, port)) + sock.shutdown(2) + _LOGGER.debug("Connection to Graphite possible") + except socket.error: + _LOGGER.error("Not able to connect to Graphite") + return False + + GraphiteFeeder(hass, host, port, prefix) + return True + + +class GraphiteFeeder(threading.Thread): + """Feed data to Graphite.""" + + def __init__(self, hass, host, port, prefix): + """Initialize the feeder.""" + super().__init__(daemon=True) + self._hass = hass + self._host = host + self._port = port + # rstrip any trailing dots in case they think they need it + self._prefix = prefix.rstrip(".") + self._queue = queue.Queue() + self._quit_object = object() + self._we_started = False + + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, self.start_listen) + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, self.shutdown) + hass.bus.listen(EVENT_STATE_CHANGED, self.event_listener) + _LOGGER.debug("Graphite feeding to %s:%i initialized", self._host, self._port) + + def start_listen(self, event): + """Start event-processing thread.""" + _LOGGER.debug("Event processing thread started") + self._we_started = True + self.start() + + def shutdown(self, event): + """Signal shutdown of processing event.""" + _LOGGER.debug("Event processing signaled exit") + self._queue.put(self._quit_object) + + def event_listener(self, event): + """Queue an event for processing.""" + if self.is_alive() or not self._we_started: + _LOGGER.debug("Received event") + self._queue.put(event) + else: + _LOGGER.error("Graphite feeder thread has died, not queuing event") + + def _send_to_graphite(self, data): + """Send data to Graphite.""" + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(10) + sock.connect((self._host, self._port)) + sock.sendall(data.encode("ascii")) + sock.send("\n".encode("ascii")) + sock.close() + + def _report_attributes(self, entity_id, new_state): + """Report the attributes.""" + now = time.time() + things = dict(new_state.attributes) + try: + things["state"] = state.state_as_number(new_state) + except ValueError: + pass + lines = [ + "%s.%s.%s %f %i" + % (self._prefix, entity_id, key.replace(" ", "_"), value, now) + for key, value in things.items() + if isinstance(value, (float, int)) + ] + if not lines: + return + _LOGGER.debug("Sending to graphite: %s", lines) + try: + self._send_to_graphite("\n".join(lines)) + except socket.gaierror: + _LOGGER.error("Unable to connect to host %s", self._host) + except socket.error: + _LOGGER.exception("Failed to send data to graphite") + + def run(self): + """Run the process to export the data.""" + while True: + event = self._queue.get() + if event == self._quit_object: + _LOGGER.debug("Event processing thread stopped") + self._queue.task_done() + return + if event.event_type == EVENT_STATE_CHANGED and event.data.get("new_state"): + _LOGGER.debug( + "Processing STATE_CHANGED event for %s", event.data["entity_id"] + ) + try: + self._report_attributes( + event.data["entity_id"], event.data["new_state"] + ) + except Exception: # pylint: disable=broad-except + # Catch this so we can avoid the thread dying and + # make it visible. + _LOGGER.exception("Failed to process STATE_CHANGED event") + else: + _LOGGER.warning("Processing unexpected event type %s", event.event_type) + + self._queue.task_done() diff --git a/homeassistant/components/graphite/manifest.json b/homeassistant/components/graphite/manifest.json new file mode 100644 index 000000000..497481282 --- /dev/null +++ b/homeassistant/components/graphite/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "graphite", + "name": "Graphite", + "documentation": "https://www.home-assistant.io/integrations/graphite", + "requirements": [], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/greeneye_monitor/__init__.py b/homeassistant/components/greeneye_monitor/__init__.py new file mode 100644 index 000000000..4f5899f6a --- /dev/null +++ b/homeassistant/components/greeneye_monitor/__init__.py @@ -0,0 +1,179 @@ +"""Support for monitoring a GreenEye Monitor energy monitor.""" +import logging + +from greeneye import Monitors +import voluptuous as vol + +from homeassistant.const import ( + CONF_NAME, + CONF_PORT, + CONF_TEMPERATURE_UNIT, + EVENT_HOMEASSISTANT_STOP, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.discovery import async_load_platform + +_LOGGER = logging.getLogger(__name__) + +CONF_CHANNELS = "channels" +CONF_COUNTED_QUANTITY = "counted_quantity" +CONF_COUNTED_QUANTITY_PER_PULSE = "counted_quantity_per_pulse" +CONF_MONITOR_SERIAL_NUMBER = "monitor" +CONF_MONITORS = "monitors" +CONF_NET_METERING = "net_metering" +CONF_NUMBER = "number" +CONF_PULSE_COUNTERS = "pulse_counters" +CONF_SERIAL_NUMBER = "serial_number" +CONF_SENSORS = "sensors" +CONF_SENSOR_TYPE = "sensor_type" +CONF_TEMPERATURE_SENSORS = "temperature_sensors" +CONF_TIME_UNIT = "time_unit" + +DATA_GREENEYE_MONITOR = "greeneye_monitor" +DOMAIN = "greeneye_monitor" + +SENSOR_TYPE_CURRENT = "current_sensor" +SENSOR_TYPE_PULSE_COUNTER = "pulse_counter" +SENSOR_TYPE_TEMPERATURE = "temperature_sensor" + +TEMPERATURE_UNIT_CELSIUS = "C" + +TIME_UNIT_SECOND = "s" +TIME_UNIT_MINUTE = "min" +TIME_UNIT_HOUR = "h" + +TEMPERATURE_SENSOR_SCHEMA = vol.Schema( + {vol.Required(CONF_NUMBER): vol.Range(1, 8), vol.Required(CONF_NAME): cv.string} +) + +TEMPERATURE_SENSORS_SCHEMA = vol.Schema( + { + vol.Required(CONF_TEMPERATURE_UNIT): cv.temperature_unit, + vol.Required(CONF_SENSORS): vol.All( + cv.ensure_list, [TEMPERATURE_SENSOR_SCHEMA] + ), + } +) + +PULSE_COUNTER_SCHEMA = vol.Schema( + { + vol.Required(CONF_NUMBER): vol.Range(1, 4), + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_COUNTED_QUANTITY): cv.string, + vol.Optional(CONF_COUNTED_QUANTITY_PER_PULSE, default=1.0): vol.Coerce(float), + vol.Optional(CONF_TIME_UNIT, default=TIME_UNIT_SECOND): vol.Any( + TIME_UNIT_SECOND, TIME_UNIT_MINUTE, TIME_UNIT_HOUR + ), + } +) + +PULSE_COUNTERS_SCHEMA = vol.All(cv.ensure_list, [PULSE_COUNTER_SCHEMA]) + +CHANNEL_SCHEMA = vol.Schema( + { + vol.Required(CONF_NUMBER): vol.Range(1, 48), + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_NET_METERING, default=False): cv.boolean, + } +) + +CHANNELS_SCHEMA = vol.All(cv.ensure_list, [CHANNEL_SCHEMA]) + +MONITOR_SCHEMA = vol.Schema( + { + vol.Required(CONF_SERIAL_NUMBER): vol.All( + cv.string, + vol.Length( + min=8, + max=8, + msg="GEM serial number must be specified as an 8-character " + "string (including leading zeroes).", + ), + vol.Coerce(int), + ), + vol.Optional(CONF_CHANNELS, default=[]): CHANNELS_SCHEMA, + vol.Optional( + CONF_TEMPERATURE_SENSORS, + default={CONF_TEMPERATURE_UNIT: TEMPERATURE_UNIT_CELSIUS, CONF_SENSORS: []}, + ): TEMPERATURE_SENSORS_SCHEMA, + vol.Optional(CONF_PULSE_COUNTERS, default=[]): PULSE_COUNTERS_SCHEMA, + } +) + +MONITORS_SCHEMA = vol.All(cv.ensure_list, [MONITOR_SCHEMA]) + +COMPONENT_SCHEMA = vol.Schema( + {vol.Required(CONF_PORT): cv.port, vol.Required(CONF_MONITORS): MONITORS_SCHEMA} +) + +CONFIG_SCHEMA = vol.Schema({DOMAIN: COMPONENT_SCHEMA}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Set up the GreenEye Monitor component.""" + + monitors = Monitors() + hass.data[DATA_GREENEYE_MONITOR] = monitors + + server_config = config[DOMAIN] + server = await monitors.start_server(server_config[CONF_PORT]) + + async def close_server(*args): + """Close the monitoring server.""" + await server.close() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, close_server) + + all_sensors = [] + for monitor_config in server_config[CONF_MONITORS]: + monitor_serial_number = { + CONF_MONITOR_SERIAL_NUMBER: monitor_config[CONF_SERIAL_NUMBER] + } + + channel_configs = monitor_config[CONF_CHANNELS] + for channel_config in channel_configs: + all_sensors.append( + { + CONF_SENSOR_TYPE: SENSOR_TYPE_CURRENT, + **monitor_serial_number, + **channel_config, + } + ) + + sensor_configs = monitor_config[CONF_TEMPERATURE_SENSORS] + if sensor_configs: + temperature_unit = { + CONF_TEMPERATURE_UNIT: sensor_configs[CONF_TEMPERATURE_UNIT] + } + for sensor_config in sensor_configs[CONF_SENSORS]: + all_sensors.append( + { + CONF_SENSOR_TYPE: SENSOR_TYPE_TEMPERATURE, + **monitor_serial_number, + **temperature_unit, + **sensor_config, + } + ) + + counter_configs = monitor_config[CONF_PULSE_COUNTERS] + for counter_config in counter_configs: + all_sensors.append( + { + CONF_SENSOR_TYPE: SENSOR_TYPE_PULSE_COUNTER, + **monitor_serial_number, + **counter_config, + } + ) + + if not all_sensors: + _LOGGER.error( + "Configuration must specify at least one " + "channel, pulse counter or temperature sensor" + ) + return False + + hass.async_create_task( + async_load_platform(hass, "sensor", DOMAIN, all_sensors, config) + ) + + return True diff --git a/homeassistant/components/greeneye_monitor/manifest.json b/homeassistant/components/greeneye_monitor/manifest.json new file mode 100644 index 000000000..eb5f19bc1 --- /dev/null +++ b/homeassistant/components/greeneye_monitor/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "greeneye_monitor", + "name": "Greeneye monitor", + "documentation": "https://www.home-assistant.io/integrations/greeneye_monitor", + "requirements": [ + "greeneye_monitor==1.0.1" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/greeneye_monitor/sensor.py b/homeassistant/components/greeneye_monitor/sensor.py new file mode 100644 index 000000000..c4b5fc678 --- /dev/null +++ b/homeassistant/components/greeneye_monitor/sensor.py @@ -0,0 +1,278 @@ +"""Support for the sensors in a GreenEye Monitor.""" +import logging + +from homeassistant.const import CONF_NAME, CONF_TEMPERATURE_UNIT, POWER_WATT +from homeassistant.helpers.entity import Entity + +from . import ( + CONF_COUNTED_QUANTITY, + CONF_COUNTED_QUANTITY_PER_PULSE, + CONF_MONITOR_SERIAL_NUMBER, + CONF_NET_METERING, + CONF_NUMBER, + CONF_SENSOR_TYPE, + CONF_TIME_UNIT, + DATA_GREENEYE_MONITOR, + SENSOR_TYPE_CURRENT, + SENSOR_TYPE_PULSE_COUNTER, + SENSOR_TYPE_TEMPERATURE, + TIME_UNIT_HOUR, + TIME_UNIT_MINUTE, + TIME_UNIT_SECOND, +) + +_LOGGER = logging.getLogger(__name__) + +DATA_PULSES = "pulses" +DATA_WATT_SECONDS = "watt_seconds" + +UNIT_WATTS = POWER_WATT + +COUNTER_ICON = "mdi:counter" +CURRENT_SENSOR_ICON = "mdi:flash" +TEMPERATURE_ICON = "mdi:thermometer" + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up a single GEM temperature sensor.""" + if not discovery_info: + return + + entities = [] + for sensor in discovery_info: + sensor_type = sensor[CONF_SENSOR_TYPE] + if sensor_type == SENSOR_TYPE_CURRENT: + entities.append( + CurrentSensor( + sensor[CONF_MONITOR_SERIAL_NUMBER], + sensor[CONF_NUMBER], + sensor[CONF_NAME], + sensor[CONF_NET_METERING], + ) + ) + elif sensor_type == SENSOR_TYPE_PULSE_COUNTER: + entities.append( + PulseCounter( + sensor[CONF_MONITOR_SERIAL_NUMBER], + sensor[CONF_NUMBER], + sensor[CONF_NAME], + sensor[CONF_COUNTED_QUANTITY], + sensor[CONF_TIME_UNIT], + sensor[CONF_COUNTED_QUANTITY_PER_PULSE], + ) + ) + elif sensor_type == SENSOR_TYPE_TEMPERATURE: + entities.append( + TemperatureSensor( + sensor[CONF_MONITOR_SERIAL_NUMBER], + sensor[CONF_NUMBER], + sensor[CONF_NAME], + sensor[CONF_TEMPERATURE_UNIT], + ) + ) + + async_add_entities(entities) + + +class GEMSensor(Entity): + """Base class for GreenEye Monitor sensors.""" + + def __init__(self, monitor_serial_number, name, sensor_type, number): + """Construct the entity.""" + self._monitor_serial_number = monitor_serial_number + self._name = name + self._sensor = None + self._sensor_type = sensor_type + self._number = number + + @property + def should_poll(self): + """GEM pushes changes, so this returns False.""" + return False + + @property + def unique_id(self): + """Return a unique ID for this sensor.""" + return "{serial}-{sensor_type}-{number}".format( + serial=self._monitor_serial_number, + sensor_type=self._sensor_type, + number=self._number, + ) + + @property + def name(self): + """Return the name of the channel.""" + return self._name + + async def async_added_to_hass(self): + """Wait for and connect to the sensor.""" + monitors = self.hass.data[DATA_GREENEYE_MONITOR] + + if not self._try_connect_to_monitor(monitors): + monitors.add_listener(self._on_new_monitor) + + def _on_new_monitor(self, *args): + monitors = self.hass.data[DATA_GREENEYE_MONITOR] + if self._try_connect_to_monitor(monitors): + monitors.remove_listener(self._on_new_monitor) + + async def async_will_remove_from_hass(self): + """Remove listener from the sensor.""" + if self._sensor: + self._sensor.remove_listener(self._schedule_update) + else: + monitors = self.hass.data[DATA_GREENEYE_MONITOR] + monitors.remove_listener(self._on_new_monitor) + + def _try_connect_to_monitor(self, monitors): + monitor = monitors.monitors.get(self._monitor_serial_number) + if not monitor: + return False + + self._sensor = self._get_sensor(monitor) + self._sensor.add_listener(self._schedule_update) + + return True + + def _get_sensor(self, monitor): + raise NotImplementedError() + + def _schedule_update(self): + self.async_schedule_update_ha_state(False) + + +class CurrentSensor(GEMSensor): + """Entity showing power usage on one channel of the monitor.""" + + def __init__(self, monitor_serial_number, number, name, net_metering): + """Construct the entity.""" + super().__init__(monitor_serial_number, name, "current", number) + self._net_metering = net_metering + + def _get_sensor(self, monitor): + return monitor.channels[self._number - 1] + + @property + def icon(self): + """Return the icon that should represent this sensor in the UI.""" + return CURRENT_SENSOR_ICON + + @property + def unit_of_measurement(self): + """Return the unit of measurement used by this sensor.""" + return UNIT_WATTS + + @property + def state(self): + """Return the current number of watts being used by the channel.""" + if not self._sensor: + return None + + return self._sensor.watts + + @property + def device_state_attributes(self): + """Return total wattseconds in the state dictionary.""" + if not self._sensor: + return None + + if self._net_metering: + watt_seconds = self._sensor.polarized_watt_seconds + else: + watt_seconds = self._sensor.absolute_watt_seconds + + return {DATA_WATT_SECONDS: watt_seconds} + + +class PulseCounter(GEMSensor): + """Entity showing rate of change in one pulse counter of the monitor.""" + + def __init__( + self, + monitor_serial_number, + number, + name, + counted_quantity, + time_unit, + counted_quantity_per_pulse, + ): + """Construct the entity.""" + super().__init__(monitor_serial_number, name, "pulse", number) + self._counted_quantity = counted_quantity + self._counted_quantity_per_pulse = counted_quantity_per_pulse + self._time_unit = time_unit + + def _get_sensor(self, monitor): + return monitor.pulse_counters[self._number - 1] + + @property + def icon(self): + """Return the icon that should represent this sensor in the UI.""" + return COUNTER_ICON + + @property + def state(self): + """Return the current rate of change for the given pulse counter.""" + if not self._sensor or self._sensor.pulses_per_second is None: + return None + + return ( + self._sensor.pulses_per_second + * self._counted_quantity_per_pulse + * self._seconds_per_time_unit + ) + + @property + def _seconds_per_time_unit(self): + """Return the number of seconds in the given display time unit.""" + if self._time_unit == TIME_UNIT_SECOND: + return 1 + if self._time_unit == TIME_UNIT_MINUTE: + return 60 + if self._time_unit == TIME_UNIT_HOUR: + return 3600 + + @property + def unit_of_measurement(self): + """Return the unit of measurement for this pulse counter.""" + return "{counted_quantity}/{time_unit}".format( + counted_quantity=self._counted_quantity, time_unit=self._time_unit + ) + + @property + def device_state_attributes(self): + """Return total pulses in the data dictionary.""" + if not self._sensor: + return None + + return {DATA_PULSES: self._sensor.pulses} + + +class TemperatureSensor(GEMSensor): + """Entity showing temperature from one temperature sensor.""" + + def __init__(self, monitor_serial_number, number, name, unit): + """Construct the entity.""" + super().__init__(monitor_serial_number, name, "temp", number) + self._unit = unit + + def _get_sensor(self, monitor): + return monitor.temperature_sensors[self._number - 1] + + @property + def icon(self): + """Return the icon that should represent this sensor in the UI.""" + return TEMPERATURE_ICON + + @property + def state(self): + """Return the current temperature being reported by this sensor.""" + if not self._sensor: + return None + + return self._sensor.temperature + + @property + def unit_of_measurement(self): + """Return the unit of measurement for this sensor (user specified).""" + return self._unit diff --git a/homeassistant/components/greenwave/__init__.py b/homeassistant/components/greenwave/__init__.py new file mode 100644 index 000000000..a7bd0cf43 --- /dev/null +++ b/homeassistant/components/greenwave/__init__.py @@ -0,0 +1 @@ +"""The greenwave component.""" diff --git a/homeassistant/components/greenwave/light.py b/homeassistant/components/greenwave/light.py new file mode 100644 index 000000000..8b85de598 --- /dev/null +++ b/homeassistant/components/greenwave/light.py @@ -0,0 +1,135 @@ +"""Support for Greenwave Reality (TCP Connected) lights.""" +from datetime import timedelta +import logging +import os + +import greenwavereality as greenwave +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 + +_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_entities, discovery_info=None): + """Set up the Greenwave Reality Platform.""" + 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_entities( + 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.""" + 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.""" + 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.""" + greenwave.turn_off(self._host, self._did, self._token) + + def update(self): + """Fetch new state data for this light.""" + 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: + """Handle Gateway data and limit updates.""" + + def __init__(self, host, token): + """Initialize the data object.""" + 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.""" + self._greenwave = greenwave.grab_bulbs(self._host, self._token) + return self._greenwave diff --git a/homeassistant/components/greenwave/manifest.json b/homeassistant/components/greenwave/manifest.json new file mode 100644 index 000000000..20a49e834 --- /dev/null +++ b/homeassistant/components/greenwave/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "greenwave", + "name": "Greenwave", + "documentation": "https://www.home-assistant.io/integrations/greenwave", + "requirements": [ + "greenwavereality==0.5.1" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index eda65d189..16094ed48 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -1,72 +1,66 @@ -""" -Provide the functionality to group entities. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/group/ -""" +"""Provide the functionality to group entities.""" import asyncio import logging +from typing import Any, Iterable, List, Optional, cast import voluptuous as vol 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_NAME, ATTR_ICON) + ATTR_ASSUMED_STATE, + ATTR_ENTITY_ID, + ATTR_ICON, + ATTR_NAME, + CONF_ICON, + CONF_NAME, + SERVICE_RELOAD, + STATE_CLOSED, + STATE_HOME, + STATE_LOCKED, + STATE_NOT_HOME, + STATE_OFF, + STATE_OK, + STATE_ON, + STATE_OPEN, + STATE_PROBLEM, + STATE_UNKNOWN, + STATE_UNLOCKED, +) from homeassistant.core import callback -from homeassistant.loader import bind_hass +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.config_validation import make_entity_service_schema 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.helpers.typing import HomeAssistantType +from homeassistant.loader import bind_hass -DOMAIN = 'group' +# mypy: allow-untyped-calls, allow-untyped-defs, no-check-untyped-defs -ENTITY_ID_FORMAT = DOMAIN + '.{}' +DOMAIN = "group" -CONF_ENTITIES = 'entities' -CONF_VIEW = 'view' -CONF_CONTROL = 'control' +ENTITY_ID_FORMAT = DOMAIN + ".{}" -ATTR_ADD_ENTITIES = 'add_entities' -ATTR_AUTO = 'auto' -ATTR_CONTROL = 'control' -ATTR_ENTITIES = 'entities' -ATTR_OBJECT_ID = 'object_id' -ATTR_ORDER = 'order' -ATTR_VIEW = 'view' -ATTR_VISIBLE = 'visible' +CONF_ENTITIES = "entities" +CONF_VIEW = "view" +CONF_CONTROL = "control" +CONF_ALL = "all" -SERVICE_SET_VISIBILITY = 'set_visibility' -SERVICE_SET = 'set' -SERVICE_REMOVE = 'remove' +ATTR_ADD_ENTITIES = "add_entities" +ATTR_AUTO = "auto" +ATTR_CONTROL = "control" +ATTR_ENTITIES = "entities" +ATTR_OBJECT_ID = "object_id" +ATTR_ORDER = "order" +ATTR_VIEW = "view" +ATTR_VISIBLE = "visible" +ATTR_ALL = "all" -CONTROL_TYPES = vol.In(['hidden', None]) +SERVICE_SET_VISIBILITY = "set_visibility" +SERVICE_SET = "set" +SERVICE_REMOVE = "remove" -SET_VISIBILITY_SERVICE_SCHEMA = vol.Schema({ - vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, - vol.Required(ATTR_VISIBLE): cv.boolean -}) - -RELOAD_SERVICE_SCHEMA = vol.Schema({}) - -SET_SERVICE_SCHEMA = vol.Schema({ - vol.Required(ATTR_OBJECT_ID): cv.slug, - vol.Optional(ATTR_NAME): cv.string, - vol.Optional(ATTR_VIEW): cv.boolean, - vol.Optional(ATTR_ICON): cv.string, - vol.Optional(ATTR_CONTROL): CONTROL_TYPES, - vol.Optional(ATTR_VISIBLE): cv.boolean, - vol.Exclusive(ATTR_ENTITIES, 'entities'): cv.entity_ids, - vol.Exclusive(ATTR_ADD_ENTITIES, 'entities'): cv.entity_ids, -}) - -REMOVE_SERVICE_SCHEMA = vol.Schema({ - vol.Required(ATTR_OBJECT_ID): cv.slug, -}) +CONTROL_TYPES = vol.In(["hidden", None]) _LOGGER = logging.getLogger(__name__) @@ -79,22 +73,30 @@ def _conf_preprocess(value): return value -GROUP_SCHEMA = vol.Schema({ - vol.Optional(CONF_ENTITIES): vol.Any(cv.entity_ids, None), - CONF_VIEW: cv.boolean, - CONF_NAME: cv.string, - CONF_ICON: cv.icon, - CONF_CONTROL: CONTROL_TYPES, -}) +GROUP_SCHEMA = vol.Schema( + { + vol.Optional(CONF_ENTITIES): vol.Any(cv.entity_ids, None), + CONF_VIEW: cv.boolean, + CONF_NAME: cv.string, + CONF_ICON: cv.icon, + CONF_CONTROL: CONTROL_TYPES, + CONF_ALL: cv.boolean, + } +) -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({cv.match_all: vol.All(_conf_preprocess, GROUP_SCHEMA)}) -}, extra=vol.ALLOW_EXTRA) +CONFIG_SCHEMA = vol.Schema( + {DOMAIN: vol.Schema({cv.match_all: vol.All(_conf_preprocess, GROUP_SCHEMA)})}, + extra=vol.ALLOW_EXTRA, +) # List of ON/OFF state tuples for groupable states -_GROUP_TYPES = [(STATE_ON, STATE_OFF), (STATE_HOME, STATE_NOT_HOME), - (STATE_OPEN, STATE_CLOSED), (STATE_LOCKED, STATE_UNLOCKED), - (STATE_PROBLEM, STATE_OK)] +_GROUP_TYPES = [ + (STATE_ON, STATE_OFF), + (STATE_HOME, STATE_NOT_HOME), + (STATE_OPEN, STATE_CLOSED), + (STATE_LOCKED, STATE_UNLOCKED), + (STATE_PROBLEM, STATE_OK), +] def _get_group_on_off(state): @@ -121,76 +123,12 @@ def is_on(hass, entity_id): @bind_hass -def reload(hass): - """Reload the automation from config.""" - hass.add_job(async_reload, hass) - - -@callback -@bind_hass -def async_reload(hass): - """Reload the automation from config.""" - hass.async_add_job(hass.services.async_call(DOMAIN, SERVICE_RELOAD)) - - -@bind_hass -def set_visibility(hass, entity_id=None, visible=True): - """Hide or shows a group.""" - data = {ATTR_ENTITY_ID: entity_id, ATTR_VISIBLE: visible} - hass.services.call(DOMAIN, SERVICE_SET_VISIBILITY, data) - - -@bind_hass -def set_group(hass, object_id, name=None, entity_ids=None, visible=None, - icon=None, view=None, control=None, add=None): - """Create/Update a group.""" - hass.add_job( - async_set_group, hass, object_id, name, entity_ids, visible, icon, - view, control, add) - - -@callback -@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/Update a group.""" - data = { - key: value for key, value in [ - (ATTR_OBJECT_ID, object_id), - (ATTR_NAME, name), - (ATTR_ENTITIES, entity_ids), - (ATTR_VISIBLE, visible), - (ATTR_ICON, icon), - (ATTR_VIEW, view), - (ATTR_CONTROL, control), - (ATTR_ADD_ENTITIES, add), - ] if value is not None - } - - hass.async_add_job(hass.services.async_call(DOMAIN, SERVICE_SET, data)) - - -@bind_hass -def remove(hass, name): - """Remove a user group.""" - hass.add_job(async_remove, hass, name) - - -@callback -@bind_hass -def async_remove(hass, object_id): - """Remove a user group.""" - data = {ATTR_OBJECT_ID: object_id} - hass.async_add_job(hass.services.async_call(DOMAIN, SERVICE_REMOVE, data)) - - -@bind_hass -def expand_entity_ids(hass, entity_ids): +def expand_entity_ids(hass: HomeAssistantType, entity_ids: Iterable[Any]) -> List[str]: """Return entity_ids with group entity ids replaced by their members. Async friendly. """ - found_ids = [] + found_ids: List[str] = [] for entity_id in entity_ids: if not isinstance(entity_id, str): continue @@ -207,9 +145,10 @@ def expand_entity_ids(hass, entity_ids): child_entities = list(child_entities) child_entities.remove(entity_id) found_ids.extend( - ent_id for ent_id - in expand_entity_ids(hass, child_entities) - if ent_id not in found_ids) + ent_id + for ent_id in expand_entity_ids(hass, child_entities) + if ent_id not in found_ids + ) else: if entity_id not in found_ids: @@ -223,7 +162,9 @@ def expand_entity_ids(hass, entity_ids): @bind_hass -def get_entity_ids(hass, entity_id, domain_filter=None): +def get_entity_ids( + hass: HomeAssistantType, entity_id: str, domain_filter: Optional[str] = None +) -> List[str]: """Get members of this group. Async friendly. @@ -235,12 +176,11 @@ def get_entity_ids(hass, entity_id, domain_filter=None): entity_ids = group.attributes[ATTR_ENTITY_ID] if not domain_filter: - return entity_ids + return cast(List[str], entity_ids) - domain_filter = domain_filter.lower() + '.' + domain_filter = domain_filter.lower() + "." - return [ent_id for ent_id in entity_ids - if ent_id.startswith(domain_filter)] + return [ent_id for ent_id in entity_ids if ent_id.startswith(domain_filter)] async def async_setup(hass, config): @@ -264,8 +204,15 @@ async def async_setup(hass, config): await component.async_add_entities(auto) hass.services.async_register( - DOMAIN, SERVICE_RELOAD, reload_service_handler, - schema=RELOAD_SERVICE_SCHEMA) + DOMAIN, SERVICE_RELOAD, reload_service_handler, schema=vol.Schema({}) + ) + + service_lock = asyncio.Lock() + + async def locked_service_handler(service): + """Handle a service with an async lock.""" + async with service_lock: + await groups_service_handler(service) async def groups_service_handler(service): """Handle dynamic group service functions.""" @@ -275,25 +222,31 @@ async def async_setup(hass, config): # new group 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 + entity_ids = ( + service.data.get(ATTR_ENTITIES) + or service.data.get(ATTR_ADD_ENTITIES) + or None + ) - extra_arg = {attr: service.data[attr] for attr in ( - ATTR_VISIBLE, ATTR_ICON, ATTR_VIEW, ATTR_CONTROL - ) if service.data.get(attr) is not None} + extra_arg = { + attr: service.data[attr] + for attr in (ATTR_VISIBLE, ATTR_ICON, ATTR_VIEW, ATTR_CONTROL) + if service.data.get(attr) is not None + } await Group.async_create_group( - hass, service.data.get(ATTR_NAME, object_id), + hass, + service.data.get(ATTR_NAME, object_id), object_id=object_id, entity_ids=entity_ids, user_defined=False, - **extra_arg + mode=service.data.get(ATTR_ALL), + **extra_arg, ) return if group is None: - _LOGGER.warning("%s:Group '%s' doesn't exist!", - service.service, object_id) + _LOGGER.warning("%s:Group '%s' doesn't exist!", service.service, object_id) return # update group @@ -329,6 +282,10 @@ async def async_setup(hass, config): group.view = service.data[ATTR_VIEW] need_update = True + if ATTR_ALL in service.data: + group.mode = all if service.data[ATTR_ALL] else any + need_update = True + if need_update: await group.async_update_ha_state() @@ -339,29 +296,51 @@ async def async_setup(hass, config): await component.async_remove_entity(entity_id) hass.services.async_register( - DOMAIN, SERVICE_SET, groups_service_handler, - schema=SET_SERVICE_SCHEMA) + DOMAIN, + SERVICE_SET, + locked_service_handler, + schema=vol.Schema( + { + vol.Required(ATTR_OBJECT_ID): cv.slug, + vol.Optional(ATTR_NAME): cv.string, + vol.Optional(ATTR_VIEW): cv.boolean, + vol.Optional(ATTR_ICON): cv.string, + vol.Optional(ATTR_CONTROL): CONTROL_TYPES, + vol.Optional(ATTR_VISIBLE): cv.boolean, + vol.Optional(ATTR_ALL): cv.boolean, + vol.Exclusive(ATTR_ENTITIES, "entities"): cv.entity_ids, + vol.Exclusive(ATTR_ADD_ENTITIES, "entities"): cv.entity_ids, + } + ), + ) hass.services.async_register( - DOMAIN, SERVICE_REMOVE, groups_service_handler, - schema=REMOVE_SERVICE_SCHEMA) + DOMAIN, + SERVICE_REMOVE, + groups_service_handler, + schema=vol.Schema({vol.Required(ATTR_OBJECT_ID): cv.slug}), + ) async def visibility_service_handler(service): """Change visibility of a group.""" visible = service.data.get(ATTR_VISIBLE) tasks = [] - for group in component.async_extract_from_service(service, - expand_group=False): + for group in await component.async_extract_from_service( + service, expand_group=False + ): group.visible = visible tasks.append(group.async_update_ha_state()) if tasks: - await asyncio.wait(tasks, loop=hass.loop) + await asyncio.wait(tasks) hass.services.async_register( - DOMAIN, SERVICE_SET_VISIBILITY, visibility_service_handler, - schema=SET_VISIBILITY_SERVICE_SCHEMA) + DOMAIN, + SERVICE_SET_VISIBILITY, + visibility_service_handler, + schema=make_entity_service_schema({vol.Required(ATTR_VISIBLE): cv.boolean}), + ) return True @@ -374,19 +353,38 @@ async def _async_process_config(hass, config, component): icon = conf.get(CONF_ICON) view = conf.get(CONF_VIEW) control = conf.get(CONF_CONTROL) + mode = conf.get(CONF_ALL) # Don't create tasks and await them all. The order is important as # groups get a number based on creation order. await Group.async_create_group( - hass, name, entity_ids, icon=icon, view=view, - control=control, object_id=object_id) + hass, + name, + entity_ids, + icon=icon, + view=view, + control=control, + object_id=object_id, + mode=mode, + ) 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, entity_ids=None): + def __init__( + self, + hass, + name, + order=None, + visible=True, + icon=None, + view=False, + control=None, + user_defined=True, + entity_ids=None, + mode=None, + ): """Initialize a group. This Object has factory function for creation. @@ -405,45 +403,82 @@ class Group(Entity): self.visible = visible self.control = control self.user_defined = user_defined + self.mode = any + if mode: + self.mode = all self._order = order self._assumed_state = False self._async_unsub_state_changed = None @staticmethod - def create_group(hass, name, entity_ids=None, user_defined=True, - visible=True, icon=None, view=False, control=None, - object_id=None): + def create_group( + hass, + name, + entity_ids=None, + user_defined=True, + visible=True, + icon=None, + view=False, + control=None, + object_id=None, + mode=None, + ): """Initialize a group.""" - return run_coroutine_threadsafe( + return asyncio.run_coroutine_threadsafe( Group.async_create_group( - hass, name, entity_ids, user_defined, visible, icon, view, - control, object_id), - hass.loop).result() + hass, + name, + entity_ids, + user_defined, + visible, + icon, + view, + control, + object_id, + mode, + ), + hass.loop, + ).result() @staticmethod - async 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, + mode=None, + ): """Initialize a group. This method must be run in the event loop. """ group = Group( - hass, name, + hass, + name, order=len(hass.states.async_entity_ids(DOMAIN)), - visible=visible, icon=icon, view=view, control=control, - user_defined=user_defined, entity_ids=entity_ids + visible=visible, + icon=icon, + view=view, + control=control, + user_defined=user_defined, + entity_ids=entity_ids, + mode=mode, ) group.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, object_id or name, hass=hass) + ENTITY_ID_FORMAT, object_id or name, hass=hass + ) # 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) + component = hass.data[DOMAIN] = EntityComponent(_LOGGER, DOMAIN, hass) await component.async_add_entities([group], True) @@ -489,10 +524,7 @@ class Group(Entity): @property def state_attributes(self): """Return the state attributes for the group.""" - data = { - ATTR_ENTITY_ID: self.tracking, - ATTR_ORDER: self._order, - } + data = {ATTR_ENTITY_ID: self.tracking, ATTR_ORDER: self._order} if not self.user_defined: data[ATTR_AUTO] = True if self.view: @@ -508,7 +540,7 @@ class Group(Entity): def update_tracked_entity_ids(self, entity_ids): """Update the member entity IDs.""" - run_coroutine_threadsafe( + asyncio.run_coroutine_threadsafe( self.async_update_tracked_entity_ids(entity_ids), self.hass.loop ).result() @@ -560,8 +592,7 @@ class Group(Entity): self._async_unsub_state_changed() self._async_unsub_state_changed = None - async 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. @@ -607,8 +638,7 @@ class Group(Entity): states = self._tracking_states for state in states: - gr_on, gr_off = \ - _get_group_on_off(state.state) + gr_on, gr_off = _get_group_on_off(state.state) if gr_on is not None: break else: @@ -621,13 +651,15 @@ class Group(Entity): if gr_on is None: return - if tr_state is None or ((gr_state == gr_on and - tr_state.state == gr_off) or - tr_state.state not in (gr_on, gr_off)): + if tr_state is None or ( + (gr_state == gr_on and tr_state.state == gr_off) + or (gr_state == gr_off and tr_state.state == gr_on) + or tr_state.state not in (gr_on, gr_off) + ): if states is None: states = self._tracking_states - if any(state.state == gr_on for state in states): + if self.mode(state.state == gr_on for state in states): self._state = gr_on else: self._state = gr_off @@ -635,14 +667,17 @@ class Group(Entity): elif tr_state.state in (gr_on, gr_off): self._state = tr_state.state - if tr_state is None or self._assumed_state and \ - not tr_state.attributes.get(ATTR_ASSUMED_STATE): + if ( + tr_state is None + or self._assumed_state + and not tr_state.attributes.get(ATTR_ASSUMED_STATE) + ): if states is None: states = self._tracking_states - self._assumed_state = any( - state.attributes.get(ATTR_ASSUMED_STATE) for state - in states) + self._assumed_state = self.mode( + state.attributes.get(ATTR_ASSUMED_STATE) for state in states + ) elif tr_state.attributes.get(ATTR_ASSUMED_STATE): self._assumed_state = True diff --git a/homeassistant/components/group/cover.py b/homeassistant/components/group/cover.py new file mode 100644 index 000000000..d9efdfa53 --- /dev/null +++ b/homeassistant/components/group/cover.py @@ -0,0 +1,316 @@ +"""This platform allows several cover to be grouped into one cover.""" +import logging +from typing import Dict, Optional, Set + +import voluptuous as vol + +from homeassistant.components.cover import ( + ATTR_CURRENT_POSITION, + ATTR_CURRENT_TILT_POSITION, + ATTR_POSITION, + ATTR_TILT_POSITION, + DOMAIN, + PLATFORM_SCHEMA, + SERVICE_CLOSE_COVER, + SERVICE_CLOSE_COVER_TILT, + SERVICE_OPEN_COVER, + SERVICE_OPEN_COVER_TILT, + SERVICE_SET_COVER_POSITION, + SERVICE_SET_COVER_TILT_POSITION, + SERVICE_STOP_COVER, + SERVICE_STOP_COVER_TILT, + SUPPORT_CLOSE, + SUPPORT_CLOSE_TILT, + SUPPORT_OPEN, + SUPPORT_OPEN_TILT, + SUPPORT_SET_POSITION, + SUPPORT_SET_TILT_POSITION, + SUPPORT_STOP, + SUPPORT_STOP_TILT, + CoverDevice, +) +from homeassistant.const import ( + ATTR_ASSUMED_STATE, + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + CONF_ENTITIES, + CONF_NAME, + STATE_CLOSED, +) +from homeassistant.core import State, callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import async_track_state_change + +# mypy: allow-incomplete-defs, allow-untyped-calls, allow-untyped-defs +# mypy: no-check-untyped-defs + +_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_entities, discovery_info=None): + """Set up the Group Cover platform.""" + async_add_entities([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: Optional[int] = 100 + self._tilt_position = None + self._supported_features = 0 + self._assumed_state = True + + self._entities = entities + self._covers: Dict[str, Set[str]] = { + KEY_OPEN_CLOSE: set(), + KEY_STOP: set(), + KEY_POSITION: set(), + } + self._tilts: Dict[str, Set[str]] = { + KEY_OPEN_CLOSE: set(), + KEY_STOP: set(), + KEY_POSITION: set(), + } + + @callback + def update_supported_features( + self, + entity_id: str, + old_state: Optional[State], + new_state: Optional[State], + update_state: bool = True, + ) -> None: + """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) -> Optional[int]: + """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/group/light.py b/homeassistant/components/group/light.py new file mode 100644 index 000000000..f0c816964 --- /dev/null +++ b/homeassistant/components/group/light.py @@ -0,0 +1,350 @@ +"""This platform allows several lights to be grouped into one light.""" +import asyncio +from collections import Counter +import itertools +import logging +from typing import Any, Callable, Iterator, List, Optional, Tuple, cast + +import voluptuous as vol + +from homeassistant.components import light +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP, + ATTR_EFFECT, + ATTR_EFFECT_LIST, + ATTR_FLASH, + ATTR_HS_COLOR, + ATTR_MAX_MIREDS, + ATTR_MIN_MIREDS, + ATTR_TRANSITION, + ATTR_WHITE_VALUE, + PLATFORM_SCHEMA, + SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, + SUPPORT_COLOR_TEMP, + SUPPORT_EFFECT, + SUPPORT_FLASH, + SUPPORT_TRANSITION, + SUPPORT_WHITE_VALUE, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + CONF_ENTITIES, + CONF_NAME, + STATE_ON, + STATE_UNAVAILABLE, +) +from homeassistant.core import CALLBACK_TYPE, State, callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import async_track_state_change +from homeassistant.helpers.typing import ConfigType, HomeAssistantType +from homeassistant.util import color as color_util + +# mypy: allow-incomplete-defs, allow-untyped-calls, allow-untyped-defs +# mypy: no-check-untyped-defs + +_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_entities, discovery_info=None +) -> None: + """Initialize light.group platform.""" + async_add_entities( + [LightGroup(cast(str, 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 + self._entity_ids = entity_ids + self._is_on = False + self._available = False + self._brightness: Optional[int] = None + self._hs_color: Optional[Tuple[float, float]] = None + self._color_temp: Optional[int] = None + self._min_mireds: Optional[int] = 154 + self._max_mireds: Optional[int] = 500 + self._white_value: Optional[int] = None + self._effect_list: Optional[List[str]] = None + self._effect: Optional[str] = None + self._supported_features: int = 0 + self._async_unsub_state_changed: Optional[CALLBACK_TYPE] = 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) + + assert self.hass is not None + 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): + """Handle removal 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} + emulate_color_temp_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] + + # Create a new entity list to mutate + updated_entities = list(self._entity_ids) + + # Walk through initial entity ids, split entity lists by support + for entity_id in self._entity_ids: + state = self.hass.states.get(entity_id) + if not state: + continue + support = state.attributes.get(ATTR_SUPPORTED_FEATURES) + # Only pass color temperature to supported entity_ids + if bool(support & SUPPORT_COLOR) and not bool( + support & SUPPORT_COLOR_TEMP + ): + emulate_color_temp_entity_ids.append(entity_id) + updated_entities.remove(entity_id) + data[ATTR_ENTITY_ID] = updated_entities + + 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] + + if not emulate_color_temp_entity_ids: + await self.hass.services.async_call( + light.DOMAIN, light.SERVICE_TURN_ON, data, blocking=True + ) + return + + emulate_color_temp_data = data.copy() + temp_k = color_util.color_temperature_mired_to_kelvin( + emulate_color_temp_data[ATTR_COLOR_TEMP] + ) + hs_color = color_util.color_temperature_to_hs(temp_k) + emulate_color_temp_data[ATTR_HS_COLOR] = hs_color + del emulate_color_temp_data[ATTR_COLOR_TEMP] + + emulate_color_temp_data[ATTR_ENTITY_ID] = emulate_color_temp_entity_ids + + await asyncio.gather( + self.hass.services.async_call( + light.DOMAIN, light.SERVICE_TURN_ON, data, blocking=True + ), + self.hass.services.async_call( + light.DOMAIN, + light.SERVICE_TURN_ON, + emulate_color_temp_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[State] = 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)) + + +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/group/manifest.json b/homeassistant/components/group/manifest.json new file mode 100644 index 000000000..195227ca2 --- /dev/null +++ b/homeassistant/components/group/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "group", + "name": "Group", + "documentation": "https://www.home-assistant.io/integrations/group", + "requirements": [], + "dependencies": [], + "codeowners": [ + "@home-assistant/core" + ] +} diff --git a/homeassistant/components/group/notify.py b/homeassistant/components/group/notify.py new file mode 100644 index 000000000..2209e0e23 --- /dev/null +++ b/homeassistant/components/group/notify.py @@ -0,0 +1,79 @@ +"""Group platform for notify component.""" +import asyncio +from collections.abc import Mapping +from copy import deepcopy +import logging + +import voluptuous as vol + +from homeassistant.components.notify import ( + ATTR_DATA, + ATTR_MESSAGE, + DOMAIN, + PLATFORM_SCHEMA, + BaseNotificationService, +) +from homeassistant.const import ATTR_SERVICE +import homeassistant.helpers.config_validation as cv + +# mypy: allow-untyped-calls, allow-untyped-defs, no-check-untyped-defs + +_LOGGER = logging.getLogger(__name__) + +CONF_SERVICES = "services" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_SERVICES): vol.All( + cv.ensure_list, + [{vol.Required(ATTR_SERVICE): cv.slug, vol.Optional(ATTR_DATA): dict}], + ) + } +) + + +def update(input_dict, update_source): + """Deep update a dictionary. + + Async friendly. + """ + for key, val in update_source.items(): + if isinstance(val, Mapping): + recurse = update(input_dict.get(key, {}), val) + input_dict[key] = recurse + else: + input_dict[key] = update_source[key] + return input_dict + + +async def async_get_service(hass, config, discovery_info=None): + """Get the Group notification service.""" + return GroupNotifyPlatform(hass, config.get(CONF_SERVICES)) + + +class GroupNotifyPlatform(BaseNotificationService): + """Implement the notification service for the group notify platform.""" + + def __init__(self, hass, entities): + """Initialize the service.""" + self.hass = hass + self.entities = entities + + async def async_send_message(self, message="", **kwargs): + """Send message to all entities in the group.""" + payload = {ATTR_MESSAGE: message} + payload.update({key: val for key, val in kwargs.items() if val}) + + tasks = [] + for entity in self.entities: + sending_payload = deepcopy(payload.copy()) + if entity.get(ATTR_DATA) is not None: + update(sending_payload, entity.get(ATTR_DATA)) + tasks.append( + self.hass.services.async_call( + DOMAIN, entity.get(ATTR_SERVICE), sending_payload + ) + ) + + if tasks: + await asyncio.wait(tasks) diff --git a/homeassistant/components/group/reproduce_state.py b/homeassistant/components/group/reproduce_state.py new file mode 100644 index 000000000..787907019 --- /dev/null +++ b/homeassistant/components/group/reproduce_state.py @@ -0,0 +1,30 @@ +"""Module that groups code required to handle state restore for component.""" +from typing import Iterable, Optional + +from homeassistant.core import Context, State +from homeassistant.helpers.state import async_reproduce_state +from homeassistant.helpers.typing import HomeAssistantType + +from . import get_entity_ids + + +async def async_reproduce_states( + hass: HomeAssistantType, states: Iterable[State], context: Optional[Context] = None +) -> None: + """Reproduce component states.""" + + states_copy = [] + for state in states: + members = get_entity_ids(hass, state.entity_id) + for member in members: + states_copy.append( + State( + member, + state.state, + state.attributes, + last_changed=state.last_changed, + last_updated=state.last_updated, + context=state.context, + ) + ) + await async_reproduce_state(hass, states_copy, blocking=True, context=context) diff --git a/homeassistant/components/group/services.yaml b/homeassistant/components/group/services.yaml index f51f8b909..68c2f04f0 100644 --- a/homeassistant/components/group/services.yaml +++ b/homeassistant/components/group/services.yaml @@ -40,6 +40,9 @@ set: add_entities: description: List of members they will change on group listening. example: domain.entity_id1, domain.entity_id2 + all: + description: Enable this option if the group should only turn on when all entities are on. + example: True remove: description: Remove a user group. diff --git a/homeassistant/components/growatt_server/__init__.py b/homeassistant/components/growatt_server/__init__.py new file mode 100644 index 000000000..14205e8d9 --- /dev/null +++ b/homeassistant/components/growatt_server/__init__.py @@ -0,0 +1 @@ +"""The Growatt server PV inverter sensor integration.""" diff --git a/homeassistant/components/growatt_server/manifest.json b/homeassistant/components/growatt_server/manifest.json new file mode 100644 index 000000000..0d4508c26 --- /dev/null +++ b/homeassistant/components/growatt_server/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "growatt_server", + "name": "Growatt Server", + "documentation": "https://www.home-assistant.io/integrations/growatt_server/", + "requirements": [ + "growattServer==0.0.1" + ], + "dependencies": [], + "codeowners": [ + "@indykoning" + ] +} diff --git a/homeassistant/components/growatt_server/sensor.py b/homeassistant/components/growatt_server/sensor.py new file mode 100644 index 000000000..2816b86be --- /dev/null +++ b/homeassistant/components/growatt_server/sensor.py @@ -0,0 +1,189 @@ +"""Read status of growatt inverters.""" +import datetime +import json +import logging +import re + +import growattServer +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +_LOGGER = logging.getLogger(__name__) + +CONF_PLANT_ID = "plant_id" +DEFAULT_PLANT_ID = "0" +DEFAULT_NAME = "Growatt" +SCAN_INTERVAL = datetime.timedelta(minutes=5) + +TOTAL_SENSOR_TYPES = { + "total_money_today": ("Total money today", "€", "plantMoneyText", None), + "total_money_total": ("Money lifetime", "€", "totalMoneyText", None), + "total_energy_today": ("Energy Today", "kWh", "todayEnergy", "power"), + "total_output_power": ("Output Power", "W", "invTodayPpv", "power"), + "total_energy_output": ("Lifetime energy output", "kWh", "totalEnergy", "power"), + "total_maximum_output": ("Maximum power", "W", "nominalPower", "power"), +} + +INVERTER_SENSOR_TYPES = { + "inverter_energy_today": ("Energy today", "kWh", "e_today", "power"), + "inverter_energy_total": ("Lifetime energy output", "kWh", "e_total", "power"), + "inverter_voltage_input_1": ("Input 1 voltage", "V", "vpv1", None), + "inverter_amperage_input_1": ("Input 1 Amperage", "A", "ipv1", None), + "inverter_wattage_input_1": ("Input 1 Wattage", "W", "ppv1", "power"), + "inverter_voltage_input_2": ("Input 2 voltage", "V", "vpv2", None), + "inverter_amperage_input_2": ("Input 2 Amperage", "A", "ipv2", None), + "inverter_wattage_input_2": ("Input 2 Wattage", "W", "ppv2", "power"), + "inverter_voltage_input_3": ("Input 3 voltage", "V", "vpv3", None), + "inverter_amperage_input_3": ("Input 3 Amperage", "A", "ipv3", None), + "inverter_wattage_input_3": ("Input 3 Wattage", "W", "ppv3", "power"), + "inverter_internal_wattage": ("Internal wattage", "W", "ppv", "power"), + "inverter_reactive_voltage": ("Reactive voltage", "V", "vacr", None), + "inverter_inverter_reactive_amperage": ("Reactive amperage", "A", "iacr", None), + "inverter_frequency": ("AC frequency", "Hz", "fac", None), + "inverter_current_wattage": ("Output power", "W", "pac", "power"), + "inverter_current_reactive_wattage": ("Reactive wattage", "W", "pacr", "power"), +} + +SENSOR_TYPES = {**TOTAL_SENSOR_TYPES, **INVERTER_SENSOR_TYPES} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PLANT_ID, default=DEFAULT_PLANT_ID): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Growatt sensor.""" + username = config[CONF_USERNAME] + password = config[CONF_PASSWORD] + plant_id = config[CONF_PLANT_ID] + name = config[CONF_NAME] + + api = growattServer.GrowattApi() + + # Log in to api and fetch first plant if no plant id is defined. + login_response = api.login(username, password) + if not login_response["success"] and login_response["errCode"] == "102": + _LOGGER.error("Username or Password may be incorrect!") + return + user_id = login_response["userId"] + if plant_id == DEFAULT_PLANT_ID: + plant_info = api.plant_list(user_id) + plant_id = plant_info["data"][0]["plantId"] + + # Get a list of inverters for specified plant to add sensors for. + inverters = api.inverter_list(plant_id) + entities = [] + probe = GrowattData(api, username, password, plant_id, True) + for sensor in TOTAL_SENSOR_TYPES: + entities.append( + GrowattInverter(probe, f"{name} Total", sensor, f"{plant_id}-{sensor}") + ) + + # Add sensors for each inverter in the specified plant. + for inverter in inverters: + probe = GrowattData(api, username, password, inverter["deviceSn"], False) + for sensor in INVERTER_SENSOR_TYPES: + entities.append( + GrowattInverter( + probe, + f"{inverter['deviceAilas']}", + sensor, + f"{inverter['deviceSn']}-{sensor}", + ) + ) + + add_entities(entities, True) + + +class GrowattInverter(Entity): + """Representation of a Growatt Sensor.""" + + def __init__(self, probe, name, sensor, unique_id): + """Initialize a PVOutput sensor.""" + self.sensor = sensor + self.probe = probe + self._name = name + self._state = None + self._unique_id = unique_id + + @property + def name(self): + """Return the name of the sensor.""" + return f"{self._name} {SENSOR_TYPES[self.sensor][0]}" + + @property + def unique_id(self): + """Return the unique id of the sensor.""" + return self._unique_id + + @property + def icon(self): + """Return the icon of the sensor.""" + return "mdi:solar-power" + + @property + def state(self): + """Return the state of the sensor.""" + return self.probe.get_data(SENSOR_TYPES[self.sensor][2]) + + @property + def device_class(self): + """Return the device class of the sensor.""" + return SENSOR_TYPES[self.sensor][3] + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return SENSOR_TYPES[self.sensor][1] + + def update(self): + """Get the latest data from the Growat API and updates the state.""" + self.probe.update() + + +class GrowattData: + """The class for handling data retrieval.""" + + def __init__(self, api, username, password, inverter_id, is_total=False): + """Initialize the probe.""" + + self.is_total = is_total + self.api = api + self.inverter_id = inverter_id + self.data = {} + self.username = username + self.password = password + + @Throttle(SCAN_INTERVAL) + def update(self): + """Update probe data.""" + self.api.login(self.username, self.password) + _LOGGER.debug("Updating data for %s", self.inverter_id) + try: + if self.is_total: + total_info = self.api.plant_info(self.inverter_id) + del total_info["deviceList"] + # PlantMoneyText comes in as "3.1/€" remove anything that isn't part of the number + total_info["plantMoneyText"] = re.sub( + r"[^\d.,]", "", total_info["plantMoneyText"] + ) + self.data = total_info + else: + inverter_info = self.api.inverter_detail(self.inverter_id) + self.data = inverter_info["data"] + except json.decoder.JSONDecodeError: + _LOGGER.error("Unable to fetch data from Growatt server") + + def get_data(self, variable): + """Get the data.""" + return self.data.get(variable) diff --git a/homeassistant/components/gstreamer/__init__.py b/homeassistant/components/gstreamer/__init__.py new file mode 100644 index 000000000..9fb97d257 --- /dev/null +++ b/homeassistant/components/gstreamer/__init__.py @@ -0,0 +1 @@ +"""The gstreamer component.""" diff --git a/homeassistant/components/gstreamer/manifest.json b/homeassistant/components/gstreamer/manifest.json new file mode 100644 index 000000000..66cae733d --- /dev/null +++ b/homeassistant/components/gstreamer/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "gstreamer", + "name": "Gstreamer", + "documentation": "https://www.home-assistant.io/integrations/gstreamer", + "requirements": [ + "gstreamer-player==1.1.2" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/gstreamer/media_player.py b/homeassistant/components/gstreamer/media_player.py new file mode 100644 index 000000000..9b371bfff --- /dev/null +++ b/homeassistant/components/gstreamer/media_player.py @@ -0,0 +1,149 @@ +"""Play media via gstreamer.""" +import logging + +from gsp import GstreamerPlayer +import voluptuous as vol + +from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerDevice +from homeassistant.components.media_player.const import ( + MEDIA_TYPE_MUSIC, + SUPPORT_NEXT_TRACK, + SUPPORT_PAUSE, + SUPPORT_PLAY, + SUPPORT_PLAY_MEDIA, + SUPPORT_VOLUME_SET, +) +from homeassistant.const import CONF_NAME, EVENT_HOMEASSISTANT_STOP, STATE_IDLE +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +CONF_PIPELINE = "pipeline" + +DOMAIN = "gstreamer" + +SUPPORT_GSTREAMER = ( + SUPPORT_VOLUME_SET + | SUPPORT_PLAY + | SUPPORT_PAUSE + | SUPPORT_PLAY_MEDIA + | SUPPORT_NEXT_TRACK +) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + {vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_PIPELINE): cv.string} +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Gstreamer platform.""" + + name = config.get(CONF_NAME) + pipeline = config.get(CONF_PIPELINE) + player = GstreamerPlayer(pipeline) + + def _shutdown(call): + """Quit the player on shutdown.""" + player.quit() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown) + add_entities([GstreamerDevice(player, name)]) + + +class GstreamerDevice(MediaPlayerDevice): + """Representation of a Gstreamer device.""" + + def __init__(self, player, name): + """Initialize the Gstreamer device.""" + self._player = player + self._name = name or DOMAIN + self._state = STATE_IDLE + self._volume = None + self._duration = None + self._uri = None + self._title = None + self._artist = None + self._album = None + + def update(self): + """Update properties.""" + self._state = self._player.state + self._volume = self._player.volume + self._duration = self._player.duration + self._uri = self._player.uri + self._title = self._player.title + self._album = self._player.album + self._artist = self._player.artist + + def set_volume_level(self, volume): + """Set the volume level.""" + self._player.volume = volume + + def play_media(self, media_type, media_id, **kwargs): + """Play media.""" + if media_type != MEDIA_TYPE_MUSIC: + _LOGGER.error("invalid media type") + return + self._player.queue(media_id) + + def media_play(self): + """Play.""" + self._player.play() + + def media_pause(self): + """Pause.""" + self._player.pause() + + def media_next_track(self): + """Next track.""" + self._player.next() + + @property + def media_content_id(self): + """Content ID of currently playing media.""" + return self._uri + + @property + def content_type(self): + """Content type of currently playing media.""" + return MEDIA_TYPE_MUSIC + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def volume_level(self): + """Return the volume level.""" + return self._volume + + @property + def supported_features(self): + """Flag media player features that are supported.""" + return SUPPORT_GSTREAMER + + @property + def state(self): + """Return the state of the player.""" + return self._state + + @property + def media_duration(self): + """Duration of current playing media in seconds.""" + return self._duration + + @property + def media_title(self): + """Media title.""" + return self._title + + @property + def media_artist(self): + """Media artist.""" + return self._artist + + @property + def media_album_name(self): + """Media album.""" + return self._album diff --git a/homeassistant/components/gtfs/__init__.py b/homeassistant/components/gtfs/__init__.py new file mode 100644 index 000000000..9c503c2bb --- /dev/null +++ b/homeassistant/components/gtfs/__init__.py @@ -0,0 +1 @@ +"""The gtfs component.""" diff --git a/homeassistant/components/gtfs/manifest.json b/homeassistant/components/gtfs/manifest.json new file mode 100644 index 000000000..b25134bb7 --- /dev/null +++ b/homeassistant/components/gtfs/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "gtfs", + "name": "Gtfs", + "documentation": "https://www.home-assistant.io/integrations/gtfs", + "requirements": [ + "pygtfs==0.1.5" + ], + "dependencies": [], + "codeowners": [ + "@robbiet480" + ] +} diff --git a/homeassistant/components/gtfs/sensor.py b/homeassistant/components/gtfs/sensor.py new file mode 100644 index 000000000..07b450dd3 --- /dev/null +++ b/homeassistant/components/gtfs/sensor.py @@ -0,0 +1,682 @@ +"""Support for GTFS (Google/General Transport Format Schema).""" +import datetime +import logging +import os +import threading +from typing import Any, Callable, Optional + +import pygtfs +from sqlalchemy.sql import text +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + ATTR_ATTRIBUTION, + CONF_NAME, + CONF_OFFSET, + DEVICE_CLASS_TIMESTAMP, + STATE_UNKNOWN, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import ConfigType, HomeAssistantType +from homeassistant.util import slugify +import homeassistant.util.dt as dt_util + +_LOGGER = logging.getLogger(__name__) + +ATTR_ARRIVAL = "arrival" +ATTR_BICYCLE = "trip_bikes_allowed_state" +ATTR_DAY = "day" +ATTR_FIRST = "first" +ATTR_DROP_OFF_DESTINATION = "destination_stop_drop_off_type_state" +ATTR_DROP_OFF_ORIGIN = "origin_stop_drop_off_type_state" +ATTR_INFO = "info" +ATTR_OFFSET = CONF_OFFSET +ATTR_LAST = "last" +ATTR_LOCATION_DESTINATION = "destination_station_location_type_name" +ATTR_LOCATION_ORIGIN = "origin_station_location_type_name" +ATTR_PICKUP_DESTINATION = "destination_stop_pickup_type_state" +ATTR_PICKUP_ORIGIN = "origin_stop_pickup_type_state" +ATTR_ROUTE_TYPE = "route_type_name" +ATTR_TIMEPOINT_DESTINATION = "destination_stop_timepoint_exact" +ATTR_TIMEPOINT_ORIGIN = "origin_stop_timepoint_exact" +ATTR_WHEELCHAIR = "trip_wheelchair_access_available" +ATTR_WHEELCHAIR_DESTINATION = "destination_station_wheelchair_boarding_available" +ATTR_WHEELCHAIR_ORIGIN = "origin_station_wheelchair_boarding_available" + +CONF_DATA = "data" +CONF_DESTINATION = "destination" +CONF_ORIGIN = "origin" +CONF_TOMORROW = "include_tomorrow" + +DEFAULT_NAME = "GTFS Sensor" +DEFAULT_PATH = "gtfs" + +BICYCLE_ALLOWED_DEFAULT = STATE_UNKNOWN +BICYCLE_ALLOWED_OPTIONS = {1: True, 2: False} +DROP_OFF_TYPE_DEFAULT = STATE_UNKNOWN +DROP_OFF_TYPE_OPTIONS = { + 0: "Regular", + 1: "Not Available", + 2: "Call Agency", + 3: "Contact Driver", +} +ICON = "mdi:train" +ICONS = { + 0: "mdi:tram", + 1: "mdi:subway", + 2: "mdi:train", + 3: "mdi:bus", + 4: "mdi:ferry", + 5: "mdi:train-variant", + 6: "mdi:gondola", + 7: "mdi:stairs", +} +LOCATION_TYPE_DEFAULT = "Stop" +LOCATION_TYPE_OPTIONS = { + 0: "Station", + 1: "Stop", + 2: "Station Entrance/Exit", + 3: "Other", +} +PICKUP_TYPE_DEFAULT = STATE_UNKNOWN +PICKUP_TYPE_OPTIONS = { + 0: "Regular", + 1: "None Available", + 2: "Call Agency", + 3: "Contact Driver", +} +ROUTE_TYPE_OPTIONS = { + 0: "Tram", + 1: "Subway", + 2: "Rail", + 3: "Bus", + 4: "Ferry", + 5: "Cable Tram", + 6: "Aerial Lift", + 7: "Funicular", +} +TIMEPOINT_DEFAULT = True +TIMEPOINT_OPTIONS = {0: False, 1: True} +WHEELCHAIR_ACCESS_DEFAULT = STATE_UNKNOWN +WHEELCHAIR_ACCESS_OPTIONS = {1: True, 2: False} +WHEELCHAIR_BOARDING_DEFAULT = STATE_UNKNOWN +WHEELCHAIR_BOARDING_OPTIONS = {1: True, 2: False} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { # type: ignore + vol.Required(CONF_ORIGIN): cv.string, + vol.Required(CONF_DESTINATION): cv.string, + vol.Required(CONF_DATA): cv.string, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_OFFSET, default=0): cv.time_period, + vol.Optional(CONF_TOMORROW, default=False): cv.boolean, + } +) + + +def get_next_departure( + schedule: Any, + start_station_id: Any, + end_station_id: Any, + offset: cv.time_period, + include_tomorrow: bool = False, +) -> dict: + """Get the next departure for the given schedule.""" + now = dt_util.now().replace(tzinfo=None) + offset + now_date = now.strftime(dt_util.DATE_STR_FORMAT) + yesterday = now - datetime.timedelta(days=1) + yesterday_date = yesterday.strftime(dt_util.DATE_STR_FORMAT) + tomorrow = now + datetime.timedelta(days=1) + tomorrow_date = tomorrow.strftime(dt_util.DATE_STR_FORMAT) + + # Fetch all departures for yesterday, today and optionally tomorrow, + # up to an overkill maximum in case of a departure every minute for those + # days. + limit = 24 * 60 * 60 * 2 + tomorrow_select = tomorrow_where = tomorrow_order = "" + if include_tomorrow: + limit = int(limit / 2 * 3) + tomorrow_name = tomorrow.strftime("%A").lower() + tomorrow_select = f"calendar.{tomorrow_name} AS tomorrow," + tomorrow_where = f"OR calendar.{tomorrow_name} = 1" + tomorrow_order = f"calendar.{tomorrow_name} DESC," + + sql_query = """ + SELECT trip.trip_id, trip.route_id, + time(origin_stop_time.arrival_time) AS origin_arrival_time, + time(origin_stop_time.departure_time) AS origin_depart_time, + date(origin_stop_time.departure_time) AS origin_depart_date, + origin_stop_time.drop_off_type AS origin_drop_off_type, + origin_stop_time.pickup_type AS origin_pickup_type, + origin_stop_time.shape_dist_traveled AS origin_dist_traveled, + origin_stop_time.stop_headsign AS origin_stop_headsign, + origin_stop_time.stop_sequence AS origin_stop_sequence, + origin_stop_time.timepoint AS origin_stop_timepoint, + time(destination_stop_time.arrival_time) AS dest_arrival_time, + time(destination_stop_time.departure_time) AS dest_depart_time, + destination_stop_time.drop_off_type AS dest_drop_off_type, + destination_stop_time.pickup_type AS dest_pickup_type, + destination_stop_time.shape_dist_traveled AS dest_dist_traveled, + destination_stop_time.stop_headsign AS dest_stop_headsign, + destination_stop_time.stop_sequence AS dest_stop_sequence, + destination_stop_time.timepoint AS dest_stop_timepoint, + calendar.{yesterday_name} AS yesterday, + calendar.{today_name} AS today, + {tomorrow_select} + calendar.start_date AS start_date, + calendar.end_date AS end_date + FROM trips trip + INNER JOIN calendar calendar + ON trip.service_id = calendar.service_id + INNER JOIN stop_times origin_stop_time + ON trip.trip_id = origin_stop_time.trip_id + INNER JOIN stops start_station + ON origin_stop_time.stop_id = start_station.stop_id + INNER JOIN stop_times destination_stop_time + ON trip.trip_id = destination_stop_time.trip_id + INNER JOIN stops end_station + ON destination_stop_time.stop_id = end_station.stop_id + WHERE (calendar.{yesterday_name} = 1 + OR calendar.{today_name} = 1 + {tomorrow_where} + ) + AND start_station.stop_id = :origin_station_id + AND end_station.stop_id = :end_station_id + AND origin_stop_sequence < dest_stop_sequence + AND calendar.start_date <= :today + AND calendar.end_date >= :today + ORDER BY calendar.{yesterday_name} DESC, + calendar.{today_name} DESC, + {tomorrow_order} + origin_stop_time.departure_time + LIMIT :limit + """.format( + yesterday_name=yesterday.strftime("%A").lower(), + today_name=now.strftime("%A").lower(), + tomorrow_select=tomorrow_select, + tomorrow_where=tomorrow_where, + tomorrow_order=tomorrow_order, + ) + result = schedule.engine.execute( + text(sql_query), + origin_station_id=start_station_id, + end_station_id=end_station_id, + today=now_date, + limit=limit, + ) + + # Create lookup timetable for today and possibly tomorrow, taking into + # account any departures from yesterday scheduled after midnight, + # as long as all departures are within the calendar date range. + timetable = {} + yesterday_start = today_start = tomorrow_start = None + yesterday_last = today_last = "" + + for row in result: + if row["yesterday"] == 1 and yesterday_date >= row["start_date"]: + extras = {"day": "yesterday", "first": None, "last": False} + if yesterday_start is None: + yesterday_start = row["origin_depart_date"] + if yesterday_start != row["origin_depart_date"]: + idx = "{} {}".format(now_date, row["origin_depart_time"]) + timetable[idx] = {**row, **extras} + yesterday_last = idx + + if row["today"] == 1: + extras = {"day": "today", "first": False, "last": False} + if today_start is None: + today_start = row["origin_depart_date"] + extras["first"] = True + if today_start == row["origin_depart_date"]: + idx_prefix = now_date + else: + idx_prefix = tomorrow_date + idx = "{} {}".format(idx_prefix, row["origin_depart_time"]) + timetable[idx] = {**row, **extras} + today_last = idx + + if ( + "tomorrow" in row + and row["tomorrow"] == 1 + and tomorrow_date <= row["end_date"] + ): + extras = {"day": "tomorrow", "first": False, "last": None} + if tomorrow_start is None: + tomorrow_start = row["origin_depart_date"] + extras["first"] = True + if tomorrow_start == row["origin_depart_date"]: + idx = "{} {}".format(tomorrow_date, row["origin_depart_time"]) + timetable[idx] = {**row, **extras} + + # Flag last departures. + for idx in filter(None, [yesterday_last, today_last]): + timetable[idx]["last"] = True + + _LOGGER.debug("Timetable: %s", sorted(timetable.keys())) + + item = {} + for key in sorted(timetable.keys()): + if dt_util.parse_datetime(key) > now: + item = timetable[key] + _LOGGER.debug( + "Departure found for station %s @ %s -> %s", start_station_id, key, item + ) + break + + if item == {}: + return {} + + # Format arrival and departure dates and times, accounting for the + # possibility of times crossing over midnight. + origin_arrival = now + if item["origin_arrival_time"] > item["origin_depart_time"]: + origin_arrival -= datetime.timedelta(days=1) + origin_arrival_time = "{} {}".format( + origin_arrival.strftime(dt_util.DATE_STR_FORMAT), item["origin_arrival_time"] + ) + + origin_depart_time = "{} {}".format(now_date, item["origin_depart_time"]) + + dest_arrival = now + if item["dest_arrival_time"] < item["origin_depart_time"]: + dest_arrival += datetime.timedelta(days=1) + dest_arrival_time = "{} {}".format( + dest_arrival.strftime(dt_util.DATE_STR_FORMAT), item["dest_arrival_time"] + ) + + dest_depart = dest_arrival + if item["dest_depart_time"] < item["dest_arrival_time"]: + dest_depart += datetime.timedelta(days=1) + dest_depart_time = "{} {}".format( + dest_depart.strftime(dt_util.DATE_STR_FORMAT), item["dest_depart_time"] + ) + + depart_time = dt_util.parse_datetime(origin_depart_time) + arrival_time = dt_util.parse_datetime(dest_arrival_time) + + origin_stop_time = { + "Arrival Time": origin_arrival_time, + "Departure Time": origin_depart_time, + "Drop Off Type": item["origin_drop_off_type"], + "Pickup Type": item["origin_pickup_type"], + "Shape Dist Traveled": item["origin_dist_traveled"], + "Headsign": item["origin_stop_headsign"], + "Sequence": item["origin_stop_sequence"], + "Timepoint": item["origin_stop_timepoint"], + } + + destination_stop_time = { + "Arrival Time": dest_arrival_time, + "Departure Time": dest_depart_time, + "Drop Off Type": item["dest_drop_off_type"], + "Pickup Type": item["dest_pickup_type"], + "Shape Dist Traveled": item["dest_dist_traveled"], + "Headsign": item["dest_stop_headsign"], + "Sequence": item["dest_stop_sequence"], + "Timepoint": item["dest_stop_timepoint"], + } + + return { + "trip_id": item["trip_id"], + "route_id": item["route_id"], + "day": item["day"], + "first": item["first"], + "last": item["last"], + "departure_time": depart_time, + "arrival_time": arrival_time, + "origin_stop_time": origin_stop_time, + "destination_stop_time": destination_stop_time, + } + + +def setup_platform( + hass: HomeAssistantType, + config: ConfigType, + add_entities: Callable[[list], None], + discovery_info: Optional[dict] = None, +) -> None: + """Set up the GTFS sensor.""" + gtfs_dir = hass.config.path(DEFAULT_PATH) + data = config[CONF_DATA] + origin = config.get(CONF_ORIGIN) + destination = config.get(CONF_DESTINATION) + name = config.get(CONF_NAME) + offset = config.get(CONF_OFFSET) + include_tomorrow = config[CONF_TOMORROW] + + if not os.path.exists(gtfs_dir): + os.makedirs(gtfs_dir) + + if not os.path.exists(os.path.join(gtfs_dir, data)): + _LOGGER.error("The given GTFS data file/folder was not found") + return + + (gtfs_root, _) = os.path.splitext(data) + + sqlite_file = f"{gtfs_root}.sqlite?check_same_thread=False" + joined_path = os.path.join(gtfs_dir, sqlite_file) + gtfs = pygtfs.Schedule(joined_path) + + # pylint: disable=no-member + if not gtfs.feeds: + pygtfs.append_feed(gtfs, os.path.join(gtfs_dir, data)) + + add_entities( + [GTFSDepartureSensor(gtfs, name, origin, destination, offset, include_tomorrow)] + ) + + +class GTFSDepartureSensor(Entity): + """Implementation of a GTFS departure sensor.""" + + def __init__( + self, + gtfs: Any, + name: Optional[Any], + origin: Any, + destination: Any, + offset: cv.time_period, + include_tomorrow: bool, + ) -> None: + """Initialize the sensor.""" + self._pygtfs = gtfs + self.origin = origin + self.destination = destination + self._include_tomorrow = include_tomorrow + self._offset = offset + self._custom_name = name + + self._available = False + self._icon = ICON + self._name = "" + self._state: Optional[str] = None + self._attributes = {} + + self._agency = None + self._departure = {} + self._destination = None + self._origin = None + self._route = None + self._trip = None + + self.lock = threading.Lock() + self.update() + + @property + def name(self) -> str: + """Return the name of the sensor.""" + return self._name + + @property + def state(self) -> Optional[str]: # type: ignore + """Return the state of the sensor.""" + return self._state + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._available + + @property + def device_state_attributes(self) -> dict: + """Return the state attributes.""" + return self._attributes + + @property + def icon(self) -> str: + """Icon to use in the frontend, if any.""" + return self._icon + + @property + def device_class(self) -> str: + """Return the class of this device.""" + return DEVICE_CLASS_TIMESTAMP + + def update(self) -> None: + """Get the latest data from GTFS and update the states.""" + with self.lock: + # Fetch valid stop information once + if not self._origin: + stops = self._pygtfs.stops_by_id(self.origin) + if not stops: + self._available = False + _LOGGER.warning("Origin stop ID %s not found", self.origin) + return + self._origin = stops[0] + + if not self._destination: + stops = self._pygtfs.stops_by_id(self.destination) + if not stops: + self._available = False + _LOGGER.warning( + "Destination stop ID %s not found", self.destination + ) + return + self._destination = stops[0] + + self._available = True + + # Fetch next departure + self._departure = get_next_departure( + self._pygtfs, + self.origin, + self.destination, + self._offset, + self._include_tomorrow, + ) + + # Define the state as a UTC timestamp with ISO 8601 format + if not self._departure: + self._state = None + else: + self._state = dt_util.as_utc( + self._departure["departure_time"] + ).isoformat() + + # Fetch trip and route details once, unless updated + if not self._departure: + self._trip = None + else: + trip_id = self._departure["trip_id"] + if not self._trip or self._trip.trip_id != trip_id: + _LOGGER.debug("Fetching trip details for %s", trip_id) + self._trip = self._pygtfs.trips_by_id(trip_id)[0] + + route_id = self._departure["route_id"] + if not self._route or self._route.route_id != route_id: + _LOGGER.debug("Fetching route details for %s", route_id) + self._route = self._pygtfs.routes_by_id(route_id)[0] + + # Fetch agency details exactly once + if self._agency is None and self._route: + _LOGGER.debug("Fetching agency details for %s", self._route.agency_id) + try: + self._agency = self._pygtfs.agencies_by_id(self._route.agency_id)[0] + except IndexError: + _LOGGER.warning( + "Agency ID '%s' was not found in agency table, " + "you may want to update the routes database table " + "to fix this missing reference", + self._route.agency_id, + ) + self._agency = False + + # Assign attributes, icon and name + self.update_attributes() + + if self._route: + self._icon = ICONS.get(self._route.route_type, ICON) + else: + self._icon = ICON + + name = "{agency} {origin} to {destination} next departure" + if not self._departure: + name = "{default}" + self._name = self._custom_name or name.format( + agency=getattr(self._agency, "agency_name", DEFAULT_NAME), + default=DEFAULT_NAME, + origin=self.origin, + destination=self.destination, + ) + + def update_attributes(self) -> None: + """Update state attributes.""" + # Add departure information + if self._departure: + self._attributes[ATTR_ARRIVAL] = dt_util.as_utc( + self._departure["arrival_time"] + ).isoformat() + + self._attributes[ATTR_DAY] = self._departure["day"] + + if self._departure[ATTR_FIRST] is not None: + self._attributes[ATTR_FIRST] = self._departure["first"] + elif ATTR_FIRST in self._attributes: + del self._attributes[ATTR_FIRST] + + if self._departure[ATTR_LAST] is not None: + self._attributes[ATTR_LAST] = self._departure["last"] + elif ATTR_LAST in self._attributes: + del self._attributes[ATTR_LAST] + else: + if ATTR_ARRIVAL in self._attributes: + del self._attributes[ATTR_ARRIVAL] + if ATTR_DAY in self._attributes: + del self._attributes[ATTR_DAY] + if ATTR_FIRST in self._attributes: + del self._attributes[ATTR_FIRST] + if ATTR_LAST in self._attributes: + del self._attributes[ATTR_LAST] + + # Add contextual information + self._attributes[ATTR_OFFSET] = self._offset.seconds / 60 + + if self._state is None: + self._attributes[ATTR_INFO] = ( + "No more departures" + if self._include_tomorrow + else "No more departures today" + ) + elif ATTR_INFO in self._attributes: + del self._attributes[ATTR_INFO] + + if self._agency: + self._attributes[ATTR_ATTRIBUTION] = self._agency.agency_name + elif ATTR_ATTRIBUTION in self._attributes: + del self._attributes[ATTR_ATTRIBUTION] + + # Add extra metadata + key = "agency_id" + if self._agency and key not in self._attributes: + self.append_keys(self.dict_for_table(self._agency), "Agency") + + key = "origin_station_stop_id" + if self._origin and key not in self._attributes: + self.append_keys(self.dict_for_table(self._origin), "Origin Station") + self._attributes[ATTR_LOCATION_ORIGIN] = LOCATION_TYPE_OPTIONS.get( + self._origin.location_type, LOCATION_TYPE_DEFAULT + ) + self._attributes[ATTR_WHEELCHAIR_ORIGIN] = WHEELCHAIR_BOARDING_OPTIONS.get( + self._origin.wheelchair_boarding, WHEELCHAIR_BOARDING_DEFAULT + ) + + key = "destination_station_stop_id" + if self._destination and key not in self._attributes: + self.append_keys( + self.dict_for_table(self._destination), "Destination Station" + ) + self._attributes[ATTR_LOCATION_DESTINATION] = LOCATION_TYPE_OPTIONS.get( + self._destination.location_type, LOCATION_TYPE_DEFAULT + ) + self._attributes[ + ATTR_WHEELCHAIR_DESTINATION + ] = WHEELCHAIR_BOARDING_OPTIONS.get( + self._destination.wheelchair_boarding, WHEELCHAIR_BOARDING_DEFAULT + ) + + # Manage Route metadata + key = "route_id" + if not self._route and key in self._attributes: + self.remove_keys("Route") + elif self._route and ( + key not in self._attributes or self._attributes[key] != self._route.route_id + ): + self.append_keys(self.dict_for_table(self._route), "Route") + self._attributes[ATTR_ROUTE_TYPE] = ROUTE_TYPE_OPTIONS[ + self._route.route_type + ] + + # Manage Trip metadata + key = "trip_id" + if not self._trip and key in self._attributes: + self.remove_keys("Trip") + elif self._trip and ( + key not in self._attributes or self._attributes[key] != self._trip.trip_id + ): + self.append_keys(self.dict_for_table(self._trip), "Trip") + self._attributes[ATTR_BICYCLE] = BICYCLE_ALLOWED_OPTIONS.get( + self._trip.bikes_allowed, BICYCLE_ALLOWED_DEFAULT + ) + self._attributes[ATTR_WHEELCHAIR] = WHEELCHAIR_ACCESS_OPTIONS.get( + self._trip.wheelchair_accessible, WHEELCHAIR_ACCESS_DEFAULT + ) + + # Manage Stop Times metadata + prefix = "origin_stop" + if self._departure: + self.append_keys(self._departure["origin_stop_time"], prefix) + self._attributes[ATTR_DROP_OFF_ORIGIN] = DROP_OFF_TYPE_OPTIONS.get( + self._departure["origin_stop_time"]["Drop Off Type"], + DROP_OFF_TYPE_DEFAULT, + ) + self._attributes[ATTR_PICKUP_ORIGIN] = PICKUP_TYPE_OPTIONS.get( + self._departure["origin_stop_time"]["Pickup Type"], PICKUP_TYPE_DEFAULT + ) + self._attributes[ATTR_TIMEPOINT_ORIGIN] = TIMEPOINT_OPTIONS.get( + self._departure["origin_stop_time"]["Timepoint"], TIMEPOINT_DEFAULT + ) + else: + self.remove_keys(prefix) + + prefix = "destination_stop" + if self._departure: + self.append_keys(self._departure["destination_stop_time"], prefix) + self._attributes[ATTR_DROP_OFF_DESTINATION] = DROP_OFF_TYPE_OPTIONS.get( + self._departure["destination_stop_time"]["Drop Off Type"], + DROP_OFF_TYPE_DEFAULT, + ) + self._attributes[ATTR_PICKUP_DESTINATION] = PICKUP_TYPE_OPTIONS.get( + self._departure["destination_stop_time"]["Pickup Type"], + PICKUP_TYPE_DEFAULT, + ) + self._attributes[ATTR_TIMEPOINT_DESTINATION] = TIMEPOINT_OPTIONS.get( + self._departure["destination_stop_time"]["Timepoint"], TIMEPOINT_DEFAULT + ) + else: + self.remove_keys(prefix) + + @staticmethod + def dict_for_table(resource: Any) -> dict: + """Return a dictionary for the SQLAlchemy resource given.""" + return dict( + (col, getattr(resource, col)) for col in resource.__table__.columns.keys() + ) + + def append_keys(self, resource: dict, prefix: Optional[str] = None) -> None: + """Properly format key val pairs to append to attributes.""" + for attr, val in resource.items(): + if val == "" or val is None or attr == "feed_id": + continue + key = attr + if prefix and not key.startswith(prefix): + key = f"{prefix} {key}" + key = slugify(key) + self._attributes[key] = val + + def remove_keys(self, prefix: str) -> None: + """Remove attributes whose key starts with prefix.""" + self._attributes = { + k: v for k, v in self._attributes.items() if not k.startswith(prefix) + } diff --git a/homeassistant/components/habitica/__init__.py b/homeassistant/components/habitica/__init__.py index 44b9e3921..52326555a 100644 --- a/homeassistant/components/habitica/__init__.py +++ b/homeassistant/components/habitica/__init__.py @@ -1,63 +1,60 @@ -""" -The Habitica API component. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/habitica/ -""" - -import logging +"""Support for Habitica devices.""" from collections import namedtuple +import logging +from habitipy.aio import HabitipyAsync import voluptuous as vol -from homeassistant.const import \ - CONF_NAME, CONF_URL, CONF_SENSORS, CONF_PATH, CONF_API_KEY -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers import \ - config_validation as cv, discovery -REQUIREMENTS = ['habitipy==0.2.0'] +from homeassistant.const import ( + CONF_API_KEY, + CONF_NAME, + CONF_PATH, + CONF_SENSORS, + CONF_URL, +) +from homeassistant.helpers import config_validation as cv, discovery +from homeassistant.helpers.aiohttp_client import async_get_clientsession + _LOGGER = logging.getLogger(__name__) -DOMAIN = "habitica" CONF_API_USER = "api_user" -ST = SensorType = namedtuple('SensorType', [ - "name", "icon", "unit", "path" -]) +DEFAULT_URL = "https://habitica.com" +DOMAIN = "habitica" + +ST = SensorType = namedtuple("SensorType", ["name", "icon", "unit", "path"]) SENSORS_TYPES = { - 'name': ST('Name', None, '', ["profile", "name"]), - 'hp': ST('HP', 'mdi:heart', 'HP', ["stats", "hp"]), - 'maxHealth': ST('max HP', 'mdi:heart', 'HP', ["stats", "maxHealth"]), - 'mp': ST('Mana', 'mdi:auto-fix', 'MP', ["stats", "mp"]), - 'maxMP': ST('max Mana', 'mdi:auto-fix', 'MP', ["stats", "maxMP"]), - 'exp': ST('EXP', 'mdi:star', 'EXP', ["stats", "exp"]), - 'toNextLevel': ST( - 'Next Lvl', 'mdi:star', 'EXP', ["stats", "toNextLevel"]), - 'lvl': ST( - 'Lvl', 'mdi:arrow-up-bold-circle-outline', 'Lvl', ["stats", "lvl"]), - 'gp': ST('Gold', 'mdi:coin', 'Gold', ["stats", "gp"]), - 'class': ST('Class', 'mdi:sword', '', ["stats", "class"]) + "name": ST("Name", None, "", ["profile", "name"]), + "hp": ST("HP", "mdi:heart", "HP", ["stats", "hp"]), + "maxHealth": ST("max HP", "mdi:heart", "HP", ["stats", "maxHealth"]), + "mp": ST("Mana", "mdi:auto-fix", "MP", ["stats", "mp"]), + "maxMP": ST("max Mana", "mdi:auto-fix", "MP", ["stats", "maxMP"]), + "exp": ST("EXP", "mdi:star", "EXP", ["stats", "exp"]), + "toNextLevel": ST("Next Lvl", "mdi:star", "EXP", ["stats", "toNextLevel"]), + "lvl": ST("Lvl", "mdi:arrow-up-bold-circle-outline", "Lvl", ["stats", "lvl"]), + "gp": ST("Gold", "mdi:coin", "Gold", ["stats", "gp"]), + "class": ST("Class", "mdi:sword", "", ["stats", "class"]), } -INSTANCE_SCHEMA = vol.Schema({ - vol.Optional(CONF_URL, default='https://habitica.com'): cv.url, - vol.Optional(CONF_NAME): cv.string, - vol.Required(CONF_API_USER): cv.string, - vol.Required(CONF_API_KEY): cv.string, - vol.Optional(CONF_SENSORS, default=list(SENSORS_TYPES)): - vol.All( - cv.ensure_list, - vol.Unique(), - [vol.In(list(SENSORS_TYPES))]) -}) +INSTANCE_SCHEMA = vol.Schema( + { + vol.Optional(CONF_URL, default=DEFAULT_URL): cv.url, + vol.Optional(CONF_NAME): cv.string, + vol.Required(CONF_API_USER): cv.string, + vol.Required(CONF_API_KEY): cv.string, + vol.Optional(CONF_SENSORS, default=list(SENSORS_TYPES)): vol.All( + cv.ensure_list, vol.Unique(), [vol.In(list(SENSORS_TYPES))] + ), + } +) has_unique_values = vol.Schema(vol.Unique()) # pylint: disable=invalid-name # because we want a handy alias def has_all_unique_users(value): - """Validate that all `api_user`s are unique.""" + """Validate that all API users are unique.""" api_users = [user[CONF_API_USER] for user in value] has_unique_values(api_users) return value @@ -67,43 +64,39 @@ def has_all_unique_users_names(value): """Validate that all user's names are unique and set if any is set.""" names = [user.get(CONF_NAME) for user in value] if None in names and any(name is not None for name in names): - raise vol.Invalid( - 'user names of all users must be set if any is set') + raise vol.Invalid("user names of all users must be set if any is set") if not all(name is None for name in names): has_unique_values(names) return value INSTANCE_LIST_SCHEMA = vol.All( - cv.ensure_list, - has_all_unique_users, - has_all_unique_users_names, - [INSTANCE_SCHEMA]) + cv.ensure_list, has_all_unique_users, has_all_unique_users_names, [INSTANCE_SCHEMA] +) -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: INSTANCE_LIST_SCHEMA -}, extra=vol.ALLOW_EXTRA) +CONFIG_SCHEMA = vol.Schema({DOMAIN: INSTANCE_LIST_SCHEMA}, extra=vol.ALLOW_EXTRA) -SERVICE_API_CALL = 'api_call' +SERVICE_API_CALL = "api_call" ATTR_NAME = CONF_NAME ATTR_PATH = CONF_PATH ATTR_ARGS = "args" -EVENT_API_CALL_SUCCESS = "{0}_{1}_{2}".format( - DOMAIN, SERVICE_API_CALL, "success") +EVENT_API_CALL_SUCCESS = "{0}_{1}_{2}".format(DOMAIN, SERVICE_API_CALL, "success") -SERVICE_API_CALL_SCHEMA = vol.Schema({ - vol.Required(ATTR_NAME): str, - vol.Required(ATTR_PATH): vol.All(cv.ensure_list, [str]), - vol.Optional(ATTR_ARGS): dict -}) +SERVICE_API_CALL_SCHEMA = vol.Schema( + { + vol.Required(ATTR_NAME): str, + vol.Required(ATTR_PATH): vol.All(cv.ensure_list, [str]), + vol.Optional(ATTR_ARGS): dict, + } +) async def async_setup(hass, config): - """Set up the habitica service.""" + """Set up the Habitica service.""" + conf = config[DOMAIN] data = hass.data[DOMAIN] = {} websession = async_get_clientsession(hass) - from habitipy.aio import HabitipyAsync class HAHabitipyAsync(HabitipyAsync): """Closure API class to hold session.""" @@ -120,39 +113,41 @@ async def async_setup(hass, config): api = HAHabitipyAsync(config_dict) user = await api.user.get() if name is None: - name = user['profile']['name'] + name = user["profile"]["name"] data[name] = api if CONF_SENSORS in instance: hass.async_create_task( discovery.async_load_platform( - hass, "sensor", DOMAIN, + hass, + "sensor", + DOMAIN, {"name": name, "sensors": instance[CONF_SENSORS]}, - config)) + config, + ) + ) async def handle_api_call(call): name = call.data[ATTR_NAME] path = call.data[ATTR_PATH] api = hass.data[DOMAIN].get(name) if api is None: - _LOGGER.error( - "API_CALL: User '%s' not configured", name) + _LOGGER.error("API_CALL: User '%s' not configured", name) return try: for element in path: api = api[element] except KeyError: _LOGGER.error( - "API_CALL: Path %s is invalid" - " for api on '{%s}' element", path, element) + "API_CALL: Path %s is invalid for API on '{%s}' element", path, element + ) return kwargs = call.data.get(ATTR_ARGS, {}) data = await api(**kwargs) - hass.bus.async_fire(EVENT_API_CALL_SUCCESS, { - "name": name, "path": path, "data": data - }) + hass.bus.async_fire( + EVENT_API_CALL_SUCCESS, {"name": name, "path": path, "data": data} + ) hass.services.async_register( - DOMAIN, SERVICE_API_CALL, - handle_api_call, - schema=SERVICE_API_CALL_SCHEMA) + DOMAIN, SERVICE_API_CALL, handle_api_call, schema=SERVICE_API_CALL_SCHEMA + ) return True diff --git a/homeassistant/components/habitica/manifest.json b/homeassistant/components/habitica/manifest.json new file mode 100644 index 000000000..a3ac10a1c --- /dev/null +++ b/homeassistant/components/habitica/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "habitica", + "name": "Habitica", + "documentation": "https://www.home-assistant.io/integrations/habitica", + "requirements": [ + "habitipy==0.2.0" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/habitica/sensor.py b/homeassistant/components/habitica/sensor.py new file mode 100644 index 000000000..1fa4ad63b --- /dev/null +++ b/homeassistant/components/habitica/sensor.py @@ -0,0 +1,79 @@ +"""Support for Habitica sensors.""" +from datetime import timedelta +import logging + +from homeassistant.components import habitica +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +_LOGGER = logging.getLogger(__name__) + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) + + +async def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the habitica platform.""" + if discovery_info is None: + return + + name = discovery_info[habitica.CONF_NAME] + sensors = discovery_info[habitica.CONF_SENSORS] + sensor_data = HabitipyData(hass.data[habitica.DOMAIN][name]) + await sensor_data.update() + async_add_devices( + [HabitipySensor(name, sensor, sensor_data) for sensor in sensors], True + ) + + +class HabitipyData: + """Habitica API user data cache.""" + + def __init__(self, api): + """Habitica API user data cache.""" + self.api = api + self.data = None + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + async def update(self): + """Get a new fix from Habitica servers.""" + self.data = await self.api.user.get() + + +class HabitipySensor(Entity): + """A generic Habitica sensor.""" + + def __init__(self, name, sensor_name, updater): + """Initialize a generic Habitica sensor.""" + self._name = name + self._sensor_name = sensor_name + self._sensor_type = habitica.SENSORS_TYPES[sensor_name] + self._state = None + self._updater = updater + + async def async_update(self): + """Update Condition and Forecast.""" + await self._updater.update() + data = self._updater.data + for element in self._sensor_type.path: + data = data[element] + self._state = data + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return self._sensor_type.icon + + @property + def name(self): + """Return the name of the sensor.""" + return f"{habitica.DOMAIN}_{self._name}_{self._sensor_name}" + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self._sensor_type.unit diff --git a/homeassistant/components/hangouts/.translations/bg.json b/homeassistant/components/hangouts/.translations/bg.json new file mode 100644 index 000000000..10c166607 --- /dev/null +++ b/homeassistant/components/hangouts/.translations/bg.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Google Hangouts \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d", + "unknown": "\u0412\u044a\u0437\u043d\u0438\u043a\u043d\u0430 \u043d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430." + }, + "error": { + "invalid_2fa": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u0430 2-\u0444\u0430\u043a\u0442\u043e\u0440\u043d\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u043a\u0430\u0446\u0438\u044f, \u043c\u043e\u043b\u044f, \u043e\u043f\u0438\u0442\u0430\u0439\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e.", + "invalid_2fa_method": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u0435\u043d \u043c\u0435\u0442\u043e\u0434 2FA (\u041f\u0440\u043e\u0432\u0435\u0440\u043a\u0430 \u043d\u0430 \u0442\u0435\u043b\u0435\u0444\u043e\u043d\u0430).", + "invalid_login": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0432\u043b\u0438\u0437\u0430\u043d\u0435, \u043c\u043e\u043b\u044f, \u043e\u043f\u0438\u0442\u0430\u0439\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e." + }, + "step": { + "2fa": { + "data": { + "2fa": "2FA PIN" + }, + "title": "\u0414\u0432\u0443-\u0444\u0430\u043a\u0442\u043e\u0440\u043d\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u043a\u0430\u0446\u0438\u044f" + }, + "user": { + "data": { + "authorization_code": "\u041a\u043e\u0434 \u0437\u0430 \u043e\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u044f (\u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c \u0437\u0430 \u0440\u044a\u0447\u043d\u043e \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u043a\u0438\u0440\u0430\u043d\u0435)", + "email": "E-mail \u0430\u0434\u0440\u0435\u0441", + "password": "\u041f\u0430\u0440\u043e\u043b\u0430" + }, + "title": "\u0412\u0445\u043e\u0434 \u0432 Google Hangouts" + } + }, + "title": "Google Hangouts" + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/.translations/ca.json b/homeassistant/components/hangouts/.translations/ca.json index ca15e59ec..ea43c804f 100644 --- a/homeassistant/components/hangouts/.translations/ca.json +++ b/homeassistant/components/hangouts/.translations/ca.json @@ -18,6 +18,7 @@ }, "user": { "data": { + "authorization_code": "Codi d'autoritzaci\u00f3 (necessari per a l'autenticaci\u00f3 manual)", "email": "Correu electr\u00f2nic", "password": "Contrasenya" }, diff --git a/homeassistant/components/hangouts/.translations/cs.json b/homeassistant/components/hangouts/.translations/cs.json new file mode 100644 index 000000000..badd381f2 --- /dev/null +++ b/homeassistant/components/hangouts/.translations/cs.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Slu\u017eba Google Hangouts je ji\u017e nakonfigurov\u00e1na", + "unknown": "Do\u0161lo k nezn\u00e1m\u00e9 chyb\u011b" + }, + "error": { + "invalid_2fa": "Dfoufaktorov\u00e9 ov\u011b\u0159en\u00ed se nezda\u0159ilo. Zkuste to znovu.", + "invalid_2fa_method": "Neplatn\u00e1 metoda 2FA (ov\u011b\u0159en\u00ed na telefonu).", + "invalid_login": "Neplatn\u00e9 p\u0159ihla\u0161ovac\u00ed jm\u00e9no, pros\u00edm zkuste to znovu." + }, + "step": { + "2fa": { + "data": { + "2fa": "Dvoufaktorov\u00fd ov\u011b\u0159ovac\u00ed k\u00f3d" + }, + "title": "Dvoufaktorov\u00e9 ov\u011b\u0159en\u00ed" + }, + "user": { + "data": { + "email": "E-mailov\u00e1 adresa", + "password": "Heslo" + }, + "title": "P\u0159ihl\u00e1\u0161en\u00ed do slu\u017eby Google Hangouts" + } + }, + "title": "Google Hangouts" + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/.translations/da.json b/homeassistant/components/hangouts/.translations/da.json new file mode 100644 index 000000000..4155da38f --- /dev/null +++ b/homeassistant/components/hangouts/.translations/da.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Google Hangouts er allerede konfigureret", + "unknown": "Ukendt fejl opstod" + }, + "error": { + "invalid_2fa": "Ugyldig 2-faktor godkendelse, pr\u00f8v venligst igen.", + "invalid_2fa_method": "Ugyldig 2FA-metode (Bekr\u00e6ft p\u00e5 telefon).", + "invalid_login": "Ugyldig login, pr\u00f8v venligst igen." + }, + "step": { + "2fa": { + "data": { + "2fa": "2FA pin" + }, + "title": "To-faktor autentificering" + }, + "user": { + "data": { + "authorization_code": "Autorisationskode (kr\u00e6ves til manuel godkendelse)", + "email": "Email adresse", + "password": "Adgangskode" + }, + "title": "Google Hangouts login" + } + }, + "title": "Google Hangouts" + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/.translations/de.json b/homeassistant/components/hangouts/.translations/de.json index a2ed8d212..fa96c00f6 100644 --- a/homeassistant/components/hangouts/.translations/de.json +++ b/homeassistant/components/hangouts/.translations/de.json @@ -5,7 +5,7 @@ "unknown": "Ein unbekannter Fehler ist aufgetreten." }, "error": { - "invalid_2fa": "Ung\u00fcltige 2-Faktor Authentifizierung, bitte versuche es erneut.", + "invalid_2fa": "Ung\u00fcltige 2-Faktor Authentifizierung, bitte versuchen Sie es erneut.", "invalid_2fa_method": "Ung\u00fcltige 2FA Methode (mit Telefon verifizieren)", "invalid_login": "Ung\u00fcltige Daten, bitte erneut versuchen." }, @@ -14,13 +14,16 @@ "data": { "2fa": "2FA PIN" }, + "description": "Leer", "title": "2-Faktor-Authentifizierung" }, "user": { "data": { + "authorization_code": "Autorisierungscode (f\u00fcr die manuelle Authentifizierung erforderlich)", "email": "E-Mail-Adresse", "password": "Passwort" }, + "description": "Leer", "title": "Google Hangouts Login" } }, diff --git a/homeassistant/components/hangouts/.translations/en.json b/homeassistant/components/hangouts/.translations/en.json index f526bec4f..31e5f9894 100644 --- a/homeassistant/components/hangouts/.translations/en.json +++ b/homeassistant/components/hangouts/.translations/en.json @@ -18,6 +18,7 @@ }, "user": { "data": { + "authorization_code": "Authorization Code (required for manual authentication)", "email": "E-Mail Address", "password": "Password" }, diff --git a/homeassistant/components/hangouts/.translations/es-419.json b/homeassistant/components/hangouts/.translations/es-419.json index a3699db08..3a297eb15 100644 --- a/homeassistant/components/hangouts/.translations/es-419.json +++ b/homeassistant/components/hangouts/.translations/es-419.json @@ -4,9 +4,19 @@ "already_configured": "Google Hangouts ya est\u00e1 configurado", "unknown": "Se produjo un error desconocido." }, + "error": { + "invalid_login": "Inicio de sesi\u00f3n no v\u00e1lido, por favor, int\u00e9ntalo de nuevo." + }, "step": { + "2fa": { + "data": { + "2fa": "Pin 2FA" + }, + "title": "Autenticaci\u00f3n de 2 factores" + }, "user": { "data": { + "authorization_code": "C\u00f3digo de autorizaci\u00f3n (requerido para la autenticaci\u00f3n manual)", "email": "Direcci\u00f3n de correo electr\u00f3nico", "password": "Contrase\u00f1a" }, diff --git a/homeassistant/components/hangouts/.translations/es.json b/homeassistant/components/hangouts/.translations/es.json new file mode 100644 index 000000000..dfa463fb1 --- /dev/null +++ b/homeassistant/components/hangouts/.translations/es.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured": "Google Hangouts ya est\u00e1 configurado", + "unknown": "Error desconocido" + }, + "error": { + "invalid_2fa": "Autenticaci\u00f3n de 2 factores no v\u00e1lida, por favor, int\u00e9ntelo de nuevo.", + "invalid_2fa_method": "M\u00e9todo 2FA no v\u00e1lido (verificar en el tel\u00e9fono).", + "invalid_login": "Inicio de sesi\u00f3n no v\u00e1lido, por favor, int\u00e9ntalo de nuevo." + }, + "step": { + "2fa": { + "data": { + "2fa": "Pin 2FA" + }, + "description": "Vac\u00edo", + "title": "Autenticaci\u00f3n de 2 factores" + }, + "user": { + "data": { + "authorization_code": "C\u00f3digo de autorizaci\u00f3n (requerido para la autenticaci\u00f3n manual)", + "email": "Correo electr\u00f3nico", + "password": "Contrase\u00f1a" + }, + "description": "Vac\u00edo", + "title": "Iniciar sesi\u00f3n en Google Hangouts" + } + }, + "title": "Google Hangouts" + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/.translations/et.json b/homeassistant/components/hangouts/.translations/et.json new file mode 100644 index 000000000..4bd26876a --- /dev/null +++ b/homeassistant/components/hangouts/.translations/et.json @@ -0,0 +1,22 @@ +{ + "config": { + "error": { + "invalid_login": "Vale Kasutajanimi, palun proovige uuesti." + }, + "step": { + "2fa": { + "data": { + "2fa": "2FA PIN" + }, + "title": "Kaheastmeline autentimine" + }, + "user": { + "data": { + "email": "E-posti aadress", + "password": "Salas\u00f5na" + } + } + }, + "title": "" + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/.translations/fr.json b/homeassistant/components/hangouts/.translations/fr.json index 00a7d5fd8..13142fee5 100644 --- a/homeassistant/components/hangouts/.translations/fr.json +++ b/homeassistant/components/hangouts/.translations/fr.json @@ -14,13 +14,16 @@ "data": { "2fa": "Code PIN d'authentification \u00e0 2 facteurs" }, + "description": "Vide", "title": "Authentification \u00e0 2 facteurs" }, "user": { "data": { + "authorization_code": "Code d'autorisation (requis pour l'authentification manuelle)", "email": "Adresse e-mail", "password": "Mot de passe" }, + "description": "Vide", "title": "Connexion \u00e0 Google Hangouts" } }, diff --git a/homeassistant/components/hangouts/.translations/he.json b/homeassistant/components/hangouts/.translations/he.json new file mode 100644 index 000000000..28326d971 --- /dev/null +++ b/homeassistant/components/hangouts/.translations/he.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Google Hangouts \u05db\u05d1\u05e8 \u05de\u05d5\u05d2\u05d3\u05e8", + "unknown": "\u05d0\u05d9\u05e8\u05e2\u05d4 \u05e9\u05d2\u05d9\u05d0\u05d4 \u05dc\u05d0 \u05d9\u05d3\u05d5\u05e2\u05d4." + }, + "error": { + "invalid_2fa": "\u05d0\u05d9\u05de\u05d5\u05ea \u05d3\u05d5 \u05e9\u05dc\u05d1\u05d9 \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9, \u05d1\u05d1\u05e7\u05e9\u05d4 \u05e0\u05e1\u05d4 \u05e9\u05d5\u05d1.", + "invalid_2fa_method": "\u05d3\u05e8\u05da \u05dc\u05d0\u05d9\u05de\u05d5\u05ea \u05d3\u05d5 \u05e9\u05dc\u05d1\u05d9 \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9\u05ea (\u05d0\u05de\u05ea \u05d1\u05d8\u05dc\u05e4\u05d5\u05df).", + "invalid_login": "\u05db\u05e0\u05d9\u05e1\u05d4 \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9\u05ea, \u05e0\u05e1\u05d4 \u05e9\u05d5\u05d1." + }, + "step": { + "2fa": { + "data": { + "2fa": "\u05e7\u05d5\u05d3 \u05d0\u05d9\u05de\u05d5\u05ea \u05d3\u05d5 \u05e9\u05dc\u05d1\u05d9" + }, + "title": "\u05d0\u05d9\u05de\u05d5\u05ea \u05d3\u05d5 \u05e9\u05dc\u05d1\u05d9" + }, + "user": { + "data": { + "email": "\u05db\u05ea\u05d5\u05d1\u05ea \u05d3\u05d5\u05d0\"\u05dc", + "password": "\u05e1\u05d9\u05e1\u05de\u05d4" + }, + "title": "\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05dc- Google Hangouts" + } + }, + "title": "Google Hangouts" + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/.translations/hu.json b/homeassistant/components/hangouts/.translations/hu.json index 2631843c7..f6e46e259 100644 --- a/homeassistant/components/hangouts/.translations/hu.json +++ b/homeassistant/components/hangouts/.translations/hu.json @@ -14,6 +14,7 @@ "data": { "2fa": "2FA Pin" }, + "description": "\u00dcres", "title": "K\u00e9tfaktoros Hiteles\u00edt\u00e9s" }, "user": { @@ -21,6 +22,7 @@ "email": "E-Mail C\u00edm", "password": "Jelsz\u00f3" }, + "description": "\u00dcres", "title": "Google Hangouts Bejelentkez\u00e9s" } }, diff --git a/homeassistant/components/hangouts/.translations/id.json b/homeassistant/components/hangouts/.translations/id.json new file mode 100644 index 000000000..46a574bdf --- /dev/null +++ b/homeassistant/components/hangouts/.translations/id.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Google Hangouts sudah dikonfigurasikan", + "unknown": "Kesalahan tidak dikenal terjadi." + }, + "error": { + "invalid_2fa": "Autentikasi 2 Faktor Tidak Valid, silakan coba lagi.", + "invalid_2fa_method": "Metode 2FA Tidak Sah (Verifikasi di Ponsel).", + "invalid_login": "Login tidak valid, silahkan coba lagi." + }, + "step": { + "2fa": { + "data": { + "2fa": "Pin 2FA" + }, + "description": "Kosong", + "title": "2-Faktor-Otentikasi" + }, + "user": { + "data": { + "email": "Alamat email", + "password": "Kata sandi" + }, + "description": "Kosong", + "title": "Google Hangouts Login" + } + }, + "title": "Google Hangouts" + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/.translations/it.json b/homeassistant/components/hangouts/.translations/it.json index 76a9adcb4..ff0a8238d 100644 --- a/homeassistant/components/hangouts/.translations/it.json +++ b/homeassistant/components/hangouts/.translations/it.json @@ -14,13 +14,16 @@ "data": { "2fa": "2FA Pin" }, + "description": "Vuoto", "title": "Autenticazione a due fattori" }, "user": { "data": { - "email": "Indirizzo email", + "authorization_code": "Codice di autorizzazione (necessario per l'autenticazione manuale)", + "email": "Indirizzo E-mail", "password": "Password" }, + "description": "Vuoto", "title": "Accesso a Google Hangouts" } }, diff --git a/homeassistant/components/hangouts/.translations/ko.json b/homeassistant/components/hangouts/.translations/ko.json index aabf977a8..385fc128b 100644 --- a/homeassistant/components/hangouts/.translations/ko.json +++ b/homeassistant/components/hangouts/.translations/ko.json @@ -1,11 +1,11 @@ { "config": { "abort": { - "already_configured": "Google Hangouts \uc740 \uc774\ubbf8 \uc124\uc815\ub41c \uc0c1\ud0dc\uc785\ub2c8\ub2e4", - "unknown": "\uc54c \uc218\uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + "already_configured": "Google \ud589\uc544\uc6c3\uc740 \uc774\ubbf8 \uc124\uc815\ub41c \uc0c1\ud0dc\uc785\ub2c8\ub2e4", + "unknown": "\uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, "error": { - "invalid_2fa": "2\ub2e8\uacc4 \uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574 \uc8fc\uc138\uc694.", + "invalid_2fa": "2\ub2e8\uacc4 \uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", "invalid_2fa_method": "2\ub2e8\uacc4 \uc778\uc99d \ubc29\ubc95\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4. (\uc804\ud654\uae30\uc5d0\uc11c \ud655\uc778)", "invalid_login": "\uc798\ubabb\ub41c \ub85c\uadf8\uc778\uc785\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694." }, @@ -19,13 +19,14 @@ }, "user": { "data": { + "authorization_code": "\uc778\uc99d \ucf54\ub4dc (\uc218\ub3d9 \uc778\uc99d\uc5d0 \ud544\uc694)", "email": "\uc774\uba54\uc77c \uc8fc\uc18c", "password": "\ube44\ubc00\ubc88\ud638" }, "description": "\uc8c4\uc1a1\ud569\ub2c8\ub2e4. \uad00\ub828 \ub0b4\uc6a9\uc774 \uc544\uc9c1 \uc5c5\ub370\uc774\ud2b8 \ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. \ucd94\ud6c4\uc5d0 \ubc18\uc601\ub420 \uc608\uc815\uc774\ub2c8 \uc870\uae08\ub9cc \uae30\ub2e4\ub824\uc8fc\uc138\uc694.", - "title": "Google Hangouts \ub85c\uadf8\uc778" + "title": "Google \ud589\uc544\uc6c3 \ub85c\uadf8\uc778" } }, - "title": "Google Hangouts" + "title": "Google \ud589\uc544\uc6c3" } } \ No newline at end of file diff --git a/homeassistant/components/hangouts/.translations/lb.json b/homeassistant/components/hangouts/.translations/lb.json index 426ab6896..c22b02fd7 100644 --- a/homeassistant/components/hangouts/.translations/lb.json +++ b/homeassistant/components/hangouts/.translations/lb.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Google Hangouts ass scho konfigur\u00e9iert", - "unknown": "Onbekannten Fehler opgetrueden" + "unknown": "Onbekannten Feeler opgetrueden" }, "error": { "invalid_2fa": "Ong\u00eblteg 2-Faktor Authentifikatioun, prob\u00e9iert w.e.g. nach emol.", @@ -19,6 +19,7 @@ }, "user": { "data": { + "authorization_code": "Autorisatioun's Code (n\u00e9ideg fir eng manuell Authentifikatioun)", "email": "E-Mail Adress", "password": "Passwuert" }, diff --git a/homeassistant/components/hangouts/.translations/nl.json b/homeassistant/components/hangouts/.translations/nl.json index cf73210aa..9f9b121a7 100644 --- a/homeassistant/components/hangouts/.translations/nl.json +++ b/homeassistant/components/hangouts/.translations/nl.json @@ -14,13 +14,16 @@ "data": { "2fa": "2FA pin" }, + "description": "Leeg", "title": "Twee-factor-authenticatie" }, "user": { "data": { + "authorization_code": "Autorisatiecode (vereist voor handmatige authenticatie)", "email": "E-mailadres", "password": "Wachtwoord" }, + "description": "Leeg", "title": "Google Hangouts inlog" } }, diff --git a/homeassistant/components/hangouts/.translations/nn.json b/homeassistant/components/hangouts/.translations/nn.json new file mode 100644 index 000000000..c8a5fb448 --- /dev/null +++ b/homeassistant/components/hangouts/.translations/nn.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Google Hangouts er allereie konfigurert", + "unknown": "Det hende ein ukjent feil" + }, + "error": { + "invalid_2fa": "Ugyldig to-faktor-autentisering. Ver vennleg og pr\u00f8v igjen.", + "invalid_2fa_method": "Ugyldig 2FA-metode (godkjenn p\u00e5 telefonen).", + "invalid_login": "Ugyldig innlogging. Pr\u00f8v igjen." + }, + "step": { + "2fa": { + "data": { + "2fa": "2FA PIN" + }, + "title": "To-faktor-autentisering" + }, + "user": { + "data": { + "email": "Epostadresse", + "password": "Passord" + }, + "title": "Google Hangouts Login" + } + }, + "title": "Google Hangouts" + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/.translations/no.json b/homeassistant/components/hangouts/.translations/no.json index c2cdb93c0..ab061ee1a 100644 --- a/homeassistant/components/hangouts/.translations/no.json +++ b/homeassistant/components/hangouts/.translations/no.json @@ -14,13 +14,16 @@ "data": { "2fa": "2FA Pin" }, + "description": "Tom", "title": "Tofaktorautentisering" }, "user": { "data": { + "authorization_code": "Autorisasjonskode (kreves for manuell godkjenning)", "email": "E-postadresse", "password": "Passord" }, + "description": "Tom", "title": "Google Hangouts p\u00e5logging" } }, diff --git a/homeassistant/components/hangouts/.translations/pl.json b/homeassistant/components/hangouts/.translations/pl.json index a8314761f..5da1e2197 100644 --- a/homeassistant/components/hangouts/.translations/pl.json +++ b/homeassistant/components/hangouts/.translations/pl.json @@ -14,13 +14,16 @@ "data": { "2fa": "PIN" }, + "description": "Pusty", "title": "Uwierzytelnianie dwusk\u0142adnikowe" }, "user": { "data": { + "authorization_code": "Kod autoryzacji (wymagany do r\u0119cznego uwierzytelnienia)", "email": "Adres e-mail", "password": "Has\u0142o" }, + "description": "Pusty", "title": "Logowanie do Google Hangouts" } }, diff --git a/homeassistant/components/hangouts/.translations/pt-BR.json b/homeassistant/components/hangouts/.translations/pt-BR.json index 516229c38..553360d8d 100644 --- a/homeassistant/components/hangouts/.translations/pt-BR.json +++ b/homeassistant/components/hangouts/.translations/pt-BR.json @@ -5,16 +5,25 @@ "unknown": "Ocorreu um erro desconhecido." }, "error": { - "invalid_2fa": "Autentica\u00e7\u00e3o de 2 fatores inv\u00e1lida, por favor, tente novamente." + "invalid_2fa": "Autentica\u00e7\u00e3o de 2 fatores inv\u00e1lida, por favor, tente novamente.", + "invalid_2fa_method": "M\u00e9todo 2FA inv\u00e1lido (verificar no telefone).", + "invalid_login": "Login inv\u00e1lido, por favor, tente novamente." }, "step": { "2fa": { - "title": "" + "data": { + "2fa": "Pin 2FA" + }, + "description": "Vazio", + "title": "Autentica\u00e7\u00e3o de 2 Fatores" }, "user": { "data": { + "authorization_code": "C\u00f3digo de Autoriza\u00e7\u00e3o (requerido para autentica\u00e7\u00e3o manual)", + "email": "Endere\u00e7o de e-mail", "password": "Senha" }, + "description": "Vazio", "title": "Login do Hangouts do Google" } }, diff --git a/homeassistant/components/hangouts/.translations/pt.json b/homeassistant/components/hangouts/.translations/pt.json index 64c960a12..a16c60128 100644 --- a/homeassistant/components/hangouts/.translations/pt.json +++ b/homeassistant/components/hangouts/.translations/pt.json @@ -5,7 +5,7 @@ "unknown": "Ocorreu um erro desconhecido." }, "error": { - "invalid_2fa": "Autoriza\u00e7\u00e3o por 2 factores inv\u00e1lida, por favor, tente novamente.", + "invalid_2fa": "Autentica\u00e7\u00e3o por 2 fatores inv\u00e1lida, por favor, tente novamente.", "invalid_2fa_method": "M\u00e9todo 2FA inv\u00e1lido (verificar no telefone).", "invalid_login": "Login inv\u00e1lido, por favor, tente novamente." }, @@ -15,7 +15,7 @@ "2fa": "Pin 2FA" }, "description": "Vazio", - "title": "" + "title": "Autentica\u00e7\u00e3o de 2 Fatores" }, "user": { "data": { @@ -26,6 +26,6 @@ "title": "Login Google Hangouts" } }, - "title": "" + "title": "Google Hangouts" } } \ No newline at end of file diff --git a/homeassistant/components/hangouts/.translations/ro.json b/homeassistant/components/hangouts/.translations/ro.json new file mode 100644 index 000000000..d1c3ed767 --- /dev/null +++ b/homeassistant/components/hangouts/.translations/ro.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Google Hangouts este deja configurat", + "unknown": "Sa produs o eroare necunoscut\u0103." + }, + "error": { + "invalid_2fa_method": "Metoda 2FA invalid\u0103 (Verifica\u021bi pe telefon).", + "invalid_login": "Conectare invalid\u0103, \u00eencerca\u021bi din nou." + }, + "step": { + "2fa": { + "data": { + "2fa": "2FA Pin" + } + }, + "user": { + "data": { + "email": "Adresa de email", + "password": "Parol\u0103" + }, + "description": "Gol", + "title": "Conectare Google Hangouts" + } + }, + "title": "Google Hangouts" + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/.translations/ru.json b/homeassistant/components/hangouts/.translations/ru.json index c33632152..5bb98effb 100644 --- a/homeassistant/components/hangouts/.translations/ru.json +++ b/homeassistant/components/hangouts/.translations/ru.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "already_configured": "Google Hangouts \u0443\u0436\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d", - "unknown": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430" + "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.", + "unknown": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." }, "error": { "invalid_2fa": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u044f, \u043f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0441\u043d\u043e\u0432\u0430.", @@ -14,14 +14,17 @@ "data": { "2fa": "\u041f\u0438\u043d-\u043a\u043e\u0434 \u0434\u043b\u044f \u0434\u0432\u0443\u0445\u0444\u0430\u043a\u0442\u043e\u0440\u043d\u043e\u0439 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438" }, + "description": "\u043f\u0443\u0441\u0442\u043e", "title": "\u0414\u0432\u0443\u0445\u0444\u0430\u043a\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f" }, "user": { "data": { + "authorization_code": "\u041a\u043e\u0434 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438 (\u0442\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u0434\u043b\u044f \u0440\u0443\u0447\u043d\u043e\u0439 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438)", "email": "\u0410\u0434\u0440\u0435\u0441 \u044d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0439 \u043f\u043e\u0447\u0442\u044b", "password": "\u041f\u0430\u0440\u043e\u043b\u044c" }, - "title": "\u0412\u0445\u043e\u0434 \u0432 Google Hangouts" + "description": "\u043f\u0443\u0441\u0442\u043e", + "title": "Google Hangouts" } }, "title": "Google Hangouts" diff --git a/homeassistant/components/hangouts/.translations/sl.json b/homeassistant/components/hangouts/.translations/sl.json index d75553358..64ca6da10 100644 --- a/homeassistant/components/hangouts/.translations/sl.json +++ b/homeassistant/components/hangouts/.translations/sl.json @@ -6,7 +6,7 @@ }, "error": { "invalid_2fa": "Neveljavna 2FA avtorizacija, prosimo, poskusite znova.", - "invalid_2fa_method": "Neveljavna 2FA metoda (preveri na telefonu).", + "invalid_2fa_method": "Neveljavna 2FA Metoda (Preverite na Telefonu).", "invalid_login": "Neveljavna Prijava, prosimo, poskusite znova." }, "step": { @@ -15,10 +15,11 @@ "2fa": "2FA Pin" }, "description": "prazno", - "title": "2-faktorska avtorizacija" + "title": "Dvofaktorska avtorizacija" }, "user": { "data": { + "authorization_code": "Koda pooblastila (potrebna za ro\u010dno overjanje)", "email": "E-po\u0161tni naslov", "password": "Geslo" }, diff --git a/homeassistant/components/hangouts/.translations/sv.json b/homeassistant/components/hangouts/.translations/sv.json index 90bf4e977..993a191ef 100644 --- a/homeassistant/components/hangouts/.translations/sv.json +++ b/homeassistant/components/hangouts/.translations/sv.json @@ -14,13 +14,16 @@ "data": { "2fa": "2FA Pinkod" }, + "description": "Missing english translation", "title": "Tv\u00e5faktorsautentisering" }, "user": { "data": { + "authorization_code": "Auktoriseringskod (kr\u00e4vs vid manuell verifiering)", "email": "E-postadress", "password": "L\u00f6senord" }, + "description": "Missing english translation", "title": "Google Hangouts-inloggning" } }, diff --git a/homeassistant/components/hangouts/.translations/th.json b/homeassistant/components/hangouts/.translations/th.json new file mode 100644 index 000000000..bcc59392e --- /dev/null +++ b/homeassistant/components/hangouts/.translations/th.json @@ -0,0 +1,16 @@ +{ + "config": { + "step": { + "2fa": { + "title": "\u0e23\u0e2b\u0e31\u0e2a\u0e23\u0e31\u0e1a\u0e23\u0e2d\u0e07\u0e04\u0e27\u0e32\u0e21\u0e16\u0e39\u0e01\u0e15\u0e49\u0e2d\u0e07\u0e2a\u0e2d\u0e07\u0e1b\u0e31\u0e08\u0e08\u0e31\u0e22" + }, + "user": { + "data": { + "email": "\u0e17\u0e35\u0e48\u0e2d\u0e22\u0e39\u0e48\u0e2d\u0e35\u0e40\u0e21\u0e25", + "password": "\u0e23\u0e2b\u0e31\u0e2a\u0e1c\u0e48\u0e32\u0e19" + }, + "description": "\u0e27\u0e48\u0e32\u0e07\u0e40\u0e1b\u0e25\u0e48\u0e32" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/.translations/vi.json b/homeassistant/components/hangouts/.translations/vi.json new file mode 100644 index 000000000..d794a0b5a --- /dev/null +++ b/homeassistant/components/hangouts/.translations/vi.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "invalid_2fa_method": "Ph\u01b0\u01a1ng ph\u00e1p 2FA kh\u00f4ng h\u1ee3p l\u1ec7 (X\u00e1c minh tr\u00ean \u0111i\u1ec7n tho\u1ea1i)." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/.translations/zh-Hans.json b/homeassistant/components/hangouts/.translations/zh-Hans.json index bee6bf753..d4e6e360c 100644 --- a/homeassistant/components/hangouts/.translations/zh-Hans.json +++ b/homeassistant/components/hangouts/.translations/zh-Hans.json @@ -14,6 +14,7 @@ "data": { "2fa": "2FA Pin" }, + "description": "\u65e0", "title": "\u53cc\u91cd\u8ba4\u8bc1" }, "user": { @@ -21,6 +22,7 @@ "email": "\u7535\u5b50\u90ae\u4ef6\u5730\u5740", "password": "\u5bc6\u7801" }, + "description": "\u65e0", "title": "\u767b\u5f55 Google Hangouts" } }, diff --git a/homeassistant/components/hangouts/.translations/zh-Hant.json b/homeassistant/components/hangouts/.translations/zh-Hant.json index 16234acb1..c8da604e6 100644 --- a/homeassistant/components/hangouts/.translations/zh-Hant.json +++ b/homeassistant/components/hangouts/.translations/zh-Hant.json @@ -19,6 +19,7 @@ }, "user": { "data": { + "authorization_code": "\u9a57\u8b49\u78bc\uff08\u624b\u52d5\u9a57\u8b49\u5fc5\u9808\uff09", "email": "\u96fb\u5b50\u90f5\u4ef6", "password": "\u5bc6\u78bc" }, diff --git a/homeassistant/components/hangouts/__init__.py b/homeassistant/components/hangouts/__init__.py index 8480ae095..d4892c668 100644 --- a/homeassistant/components/hangouts/__init__.py +++ b/homeassistant/components/hangouts/__init__.py @@ -1,53 +1,62 @@ -""" -The hangouts bot component. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/hangouts/ -""" +"""Support for Hangouts.""" import logging +from hangups.auth import GoogleAuthError import voluptuous as vol from homeassistant import config_entries -from homeassistant.components.hangouts.intents import HelpIntent +from homeassistant.components.conversation.util import create_matcher from homeassistant.const import EVENT_HOMEASSISTANT_STOP -from homeassistant.helpers import intent -from homeassistant.helpers import dispatcher +from homeassistant.helpers import dispatcher, intent import homeassistant.helpers.config_validation as cv -from .const import ( - CONF_BOT, CONF_INTENTS, CONF_REFRESH_TOKEN, DOMAIN, - EVENT_HANGOUTS_CONNECTED, EVENT_HANGOUTS_CONVERSATIONS_CHANGED, - MESSAGE_SCHEMA, SERVICE_SEND_MESSAGE, - SERVICE_UPDATE, CONF_SENTENCES, CONF_MATCHERS, - CONF_ERROR_SUPPRESSED_CONVERSATIONS, INTENT_SCHEMA, TARGETS_SCHEMA, - CONF_DEFAULT_CONVERSATIONS, EVENT_HANGOUTS_CONVERSATIONS_RESOLVED, - INTENT_HELP) - # We need an import from .config_flow, without it .config_flow is never loaded. from .config_flow import HangoutsFlowHandler # noqa: F401 - -REQUIREMENTS = ['hangups==0.4.5'] +from .const import ( + CONF_BOT, + CONF_DEFAULT_CONVERSATIONS, + CONF_ERROR_SUPPRESSED_CONVERSATIONS, + CONF_INTENTS, + CONF_MATCHERS, + CONF_REFRESH_TOKEN, + CONF_SENTENCES, + DOMAIN, + EVENT_HANGOUTS_CONNECTED, + EVENT_HANGOUTS_CONVERSATIONS_CHANGED, + EVENT_HANGOUTS_CONVERSATIONS_RESOLVED, + INTENT_HELP, + INTENT_SCHEMA, + MESSAGE_SCHEMA, + SERVICE_RECONNECT, + SERVICE_SEND_MESSAGE, + SERVICE_UPDATE, + TARGETS_SCHEMA, +) +from .hangouts_bot import HangoutsBot +from .intents import HelpIntent _LOGGER = logging.getLogger(__name__) -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - vol.Optional(CONF_INTENTS, default={}): vol.Schema({ - cv.string: INTENT_SCHEMA - }), - vol.Optional(CONF_DEFAULT_CONVERSATIONS, default=[]): - [TARGETS_SCHEMA], - vol.Optional(CONF_ERROR_SUPPRESSED_CONVERSATIONS, default=[]): - [TARGETS_SCHEMA] - }) -}, extra=vol.ALLOW_EXTRA) +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Optional(CONF_INTENTS, default={}): vol.Schema( + {cv.string: INTENT_SCHEMA} + ), + vol.Optional(CONF_DEFAULT_CONVERSATIONS, default=[]): [TARGETS_SCHEMA], + vol.Optional(CONF_ERROR_SUPPRESSED_CONVERSATIONS, default=[]): [ + TARGETS_SCHEMA + ], + } + ) + }, + extra=vol.ALLOW_EXTRA, +) async def async_setup(hass, config): """Set up the Hangouts bot component.""" - from homeassistant.components.conversation import create_matcher - config = config.get(DOMAIN) if config is None: hass.data[DOMAIN] = { @@ -60,14 +69,16 @@ async def async_setup(hass, config): hass.data[DOMAIN] = { CONF_INTENTS: config[CONF_INTENTS], CONF_DEFAULT_CONVERSATIONS: config[CONF_DEFAULT_CONVERSATIONS], - CONF_ERROR_SUPPRESSED_CONVERSATIONS: - config[CONF_ERROR_SUPPRESSED_CONVERSATIONS], + CONF_ERROR_SUPPRESSED_CONVERSATIONS: config[ + CONF_ERROR_SUPPRESSED_CONVERSATIONS + ], } - if (hass.data[DOMAIN][CONF_INTENTS] and - INTENT_HELP not in hass.data[DOMAIN][CONF_INTENTS]): - hass.data[DOMAIN][CONF_INTENTS][INTENT_HELP] = { - CONF_SENTENCES: ['HELP']} + if ( + hass.data[DOMAIN][CONF_INTENTS] + and INTENT_HELP not in hass.data[DOMAIN][CONF_INTENTS] + ): + hass.data[DOMAIN][CONF_INTENTS][INTENT_HELP] = {CONF_SENTENCES: ["HELP"]} for data in hass.data[DOMAIN][CONF_INTENTS].values(): matchers = [] @@ -76,59 +87,64 @@ async def async_setup(hass, config): data[CONF_MATCHERS] = matchers - hass.async_create_task(hass.config_entries.flow.async_init( - DOMAIN, context={'source': config_entries.SOURCE_IMPORT} - )) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT} + ) + ) return True async def async_setup_entry(hass, config): """Set up a config entry.""" - from hangups.auth import GoogleAuthError - try: - from .hangouts_bot import HangoutsBot - bot = HangoutsBot( hass, config.data.get(CONF_REFRESH_TOKEN), hass.data[DOMAIN][CONF_INTENTS], hass.data[DOMAIN][CONF_DEFAULT_CONVERSATIONS], - hass.data[DOMAIN][CONF_ERROR_SUPPRESSED_CONVERSATIONS]) + hass.data[DOMAIN][CONF_ERROR_SUPPRESSED_CONVERSATIONS], + ) hass.data[DOMAIN][CONF_BOT] = bot except GoogleAuthError as exception: _LOGGER.error("Hangouts failed to log in: %s", str(exception)) return False dispatcher.async_dispatcher_connect( - hass, - EVENT_HANGOUTS_CONNECTED, - bot.async_handle_update_users_and_conversations) + hass, EVENT_HANGOUTS_CONNECTED, bot.async_handle_update_users_and_conversations + ) dispatcher.async_dispatcher_connect( - hass, - EVENT_HANGOUTS_CONVERSATIONS_CHANGED, - bot.async_resolve_conversations) + hass, EVENT_HANGOUTS_CONVERSATIONS_CHANGED, bot.async_resolve_conversations + ) dispatcher.async_dispatcher_connect( hass, EVENT_HANGOUTS_CONVERSATIONS_RESOLVED, - bot.async_update_conversation_commands) + bot.async_update_conversation_commands, + ) - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, - bot.async_handle_hass_stop) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, bot.async_handle_hass_stop) await bot.async_connect() - hass.services.async_register(DOMAIN, SERVICE_SEND_MESSAGE, - bot.async_handle_send_message, - schema=MESSAGE_SCHEMA) - hass.services.async_register(DOMAIN, - SERVICE_UPDATE, - bot. - async_handle_update_users_and_conversations, - schema=vol.Schema({})) + hass.services.async_register( + DOMAIN, + SERVICE_SEND_MESSAGE, + bot.async_handle_send_message, + schema=MESSAGE_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_UPDATE, + bot.async_handle_update_users_and_conversations, + schema=vol.Schema({}), + ) + + hass.services.async_register( + DOMAIN, SERVICE_RECONNECT, bot.async_handle_reconnect, schema=vol.Schema({}) + ) intent.async_register(hass, HelpIntent(hass)) diff --git a/homeassistant/components/hangouts/config_flow.py b/homeassistant/components/hangouts/config_flow.py index 9d66338df..f253df493 100644 --- a/homeassistant/components/hangouts/config_flow.py +++ b/homeassistant/components/hangouts/config_flow.py @@ -1,12 +1,25 @@ """Config flow to configure Google Hangouts.""" +import functools + +from hangups import get_auth import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import callback -from .const import CONF_2FA, CONF_REFRESH_TOKEN -from .const import DOMAIN as HANGOUTS_DOMAIN +from .const import ( + CONF_2FA, + CONF_AUTH_CODE, + CONF_REFRESH_TOKEN, + DOMAIN as HANGOUTS_DOMAIN, +) +from .hangups_utils import ( + Google2FAError, + GoogleAuthError, + HangoutsCredentials, + HangoutsRefreshToken, +) @callback @@ -38,35 +51,46 @@ class HangoutsFlowHandler(config_entries.ConfigFlow): return self.async_abort(reason="already_configured") if user_input is not None: - from hangups import get_auth - from .hangups_utils import (HangoutsCredentials, - HangoutsRefreshToken, - GoogleAuthError, Google2FAError) - self._credentials = HangoutsCredentials(user_input[CONF_EMAIL], - user_input[CONF_PASSWORD]) + user_email = user_input[CONF_EMAIL] + user_password = user_input[CONF_PASSWORD] + user_auth_code = user_input.get(CONF_AUTH_CODE) + manual_login = user_auth_code is not None + + user_pin = None + self._credentials = HangoutsCredentials( + user_email, user_password, user_pin, user_auth_code + ) self._refresh_token = HangoutsRefreshToken(None) try: - await self.hass.async_add_executor_job(get_auth, - self._credentials, - self._refresh_token) + await self.hass.async_add_executor_job( + functools.partial( + get_auth, + self._credentials, + self._refresh_token, + manual_login=manual_login, + ) + ) return await self.async_step_final() except GoogleAuthError as err: if isinstance(err, Google2FAError): return await self.async_step_2fa() msg = str(err) - if msg == 'Unknown verification code input': - errors['base'] = 'invalid_2fa_method' + if msg == "Unknown verification code input": + errors["base"] = "invalid_2fa_method" else: - errors['base'] = 'invalid_login' + errors["base"] = "invalid_login" return self.async_show_form( - step_id='user', - data_schema=vol.Schema({ - vol.Required(CONF_EMAIL): str, - vol.Required(CONF_PASSWORD): str - }), - errors=errors + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_EMAIL): str, + vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_AUTH_CODE): str, + } + ), + errors=errors, ) async def async_step_2fa(self, user_input=None): @@ -74,24 +98,20 @@ class HangoutsFlowHandler(config_entries.ConfigFlow): errors = {} if user_input is not None: - from hangups import get_auth - from .hangups_utils import GoogleAuthError self._credentials.set_verification_code(user_input[CONF_2FA]) try: - await self.hass.async_add_executor_job(get_auth, - self._credentials, - self._refresh_token) + await self.hass.async_add_executor_job( + get_auth, self._credentials, self._refresh_token + ) return await self.async_step_final() except GoogleAuthError: - errors['base'] = 'invalid_2fa' + errors["base"] = "invalid_2fa" return self.async_show_form( step_id=CONF_2FA, - data_schema=vol.Schema({ - vol.Required(CONF_2FA): str, - }), - errors=errors + data_schema=vol.Schema({vol.Required(CONF_2FA): str}), + errors=errors, ) async def async_step_final(self): @@ -100,8 +120,9 @@ class HangoutsFlowHandler(config_entries.ConfigFlow): title=self._credentials.get_email(), data={ CONF_EMAIL: self._credentials.get_email(), - CONF_REFRESH_TOKEN: self._refresh_token.get() - }) + CONF_REFRESH_TOKEN: self._refresh_token.get(), + }, + ) async def async_step_import(self, _): """Handle a flow import.""" diff --git a/homeassistant/components/hangouts/const.py b/homeassistant/components/hangouts/const.py index caae0de16..0508bf487 100644 --- a/homeassistant/components/hangouts/const.py +++ b/homeassistant/components/hangouts/const.py @@ -3,69 +3,83 @@ import logging import voluptuous as vol -from homeassistant.components.notify import ATTR_MESSAGE, ATTR_TARGET +from homeassistant.components.notify import ATTR_DATA, ATTR_MESSAGE, ATTR_TARGET import homeassistant.helpers.config_validation as cv -_LOGGER = logging.getLogger('homeassistant.components.hangouts') +_LOGGER = logging.getLogger(".") -DOMAIN = 'hangouts' +DOMAIN = "hangouts" -CONF_2FA = '2fa' -CONF_REFRESH_TOKEN = 'refresh_token' -CONF_BOT = 'bot' +CONF_2FA = "2fa" +CONF_AUTH_CODE = "authorization_code" +CONF_REFRESH_TOKEN = "refresh_token" +CONF_BOT = "bot" -CONF_CONVERSATIONS = 'conversations' -CONF_DEFAULT_CONVERSATIONS = 'default_conversations' -CONF_ERROR_SUPPRESSED_CONVERSATIONS = 'error_suppressed_conversations' +CONF_CONVERSATIONS = "conversations" +CONF_DEFAULT_CONVERSATIONS = "default_conversations" +CONF_ERROR_SUPPRESSED_CONVERSATIONS = "error_suppressed_conversations" -CONF_INTENTS = 'intents' -CONF_INTENT_TYPE = 'intent_type' -CONF_SENTENCES = 'sentences' -CONF_MATCHERS = 'matchers' +CONF_INTENTS = "intents" +CONF_INTENT_TYPE = "intent_type" +CONF_SENTENCES = "sentences" +CONF_MATCHERS = "matchers" -INTENT_HELP = 'HangoutsHelp' +INTENT_HELP = "HangoutsHelp" -EVENT_HANGOUTS_CONNECTED = 'hangouts_connected' -EVENT_HANGOUTS_DISCONNECTED = 'hangouts_disconnected' -EVENT_HANGOUTS_USERS_CHANGED = 'hangouts_users_changed' -EVENT_HANGOUTS_CONVERSATIONS_CHANGED = 'hangouts_conversations_changed' -EVENT_HANGOUTS_CONVERSATIONS_RESOLVED = 'hangouts_conversations_resolved' -EVENT_HANGOUTS_MESSAGE_RECEIVED = 'hangouts_message_received' +EVENT_HANGOUTS_CONNECTED = "hangouts_connected" +EVENT_HANGOUTS_DISCONNECTED = "hangouts_disconnected" +EVENT_HANGOUTS_USERS_CHANGED = "hangouts_users_changed" +EVENT_HANGOUTS_CONVERSATIONS_CHANGED = "hangouts_conversations_changed" +EVENT_HANGOUTS_CONVERSATIONS_RESOLVED = "hangouts_conversations_resolved" +EVENT_HANGOUTS_MESSAGE_RECEIVED = "hangouts_message_received" -CONF_CONVERSATION_ID = 'id' -CONF_CONVERSATION_NAME = 'name' +CONF_CONVERSATION_ID = "id" +CONF_CONVERSATION_NAME = "name" -SERVICE_SEND_MESSAGE = 'send_message' -SERVICE_UPDATE = 'update' +SERVICE_SEND_MESSAGE = "send_message" +SERVICE_UPDATE = "update" +SERVICE_RECONNECT = "reconnect" TARGETS_SCHEMA = vol.All( - vol.Schema({ - vol.Exclusive(CONF_CONVERSATION_ID, 'id or name'): cv.string, - vol.Exclusive(CONF_CONVERSATION_NAME, 'id or name'): cv.string - }), - cv.has_at_least_one_key(CONF_CONVERSATION_ID, CONF_CONVERSATION_NAME) + vol.Schema( + { + vol.Exclusive(CONF_CONVERSATION_ID, "id or name"): cv.string, + vol.Exclusive(CONF_CONVERSATION_NAME, "id or name"): cv.string, + } + ), + cv.has_at_least_one_key(CONF_CONVERSATION_ID, CONF_CONVERSATION_NAME), +) +MESSAGE_SEGMENT_SCHEMA = vol.Schema( + { + vol.Required("text"): cv.string, + vol.Optional("is_bold"): cv.boolean, + vol.Optional("is_italic"): cv.boolean, + vol.Optional("is_strikethrough"): cv.boolean, + vol.Optional("is_underline"): cv.boolean, + vol.Optional("parse_str"): cv.boolean, + vol.Optional("link_target"): cv.string, + } +) +MESSAGE_DATA_SCHEMA = vol.Schema( + {vol.Optional("image_file"): cv.string, vol.Optional("image_url"): cv.string} ) -MESSAGE_SEGMENT_SCHEMA = vol.Schema({ - vol.Required('text'): cv.string, - vol.Optional('is_bold'): cv.boolean, - vol.Optional('is_italic'): cv.boolean, - vol.Optional('is_strikethrough'): cv.boolean, - vol.Optional('is_underline'): cv.boolean, - vol.Optional('parse_str'): cv.boolean, - vol.Optional('link_target'): cv.string -}) -MESSAGE_SCHEMA = vol.Schema({ - vol.Required(ATTR_TARGET): [TARGETS_SCHEMA], - vol.Required(ATTR_MESSAGE): [MESSAGE_SEGMENT_SCHEMA] -}) +MESSAGE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_TARGET): [TARGETS_SCHEMA], + vol.Required(ATTR_MESSAGE): [MESSAGE_SEGMENT_SCHEMA], + vol.Optional(ATTR_DATA): MESSAGE_DATA_SCHEMA, + } +) INTENT_SCHEMA = vol.All( # Basic Schema - vol.Schema({ - vol.Required(CONF_SENTENCES): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_CONVERSATIONS): [TARGETS_SCHEMA] - }), + vol.Schema( + { + vol.Required(CONF_SENTENCES): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_CONVERSATIONS): [TARGETS_SCHEMA], + } + ) ) diff --git a/homeassistant/components/hangouts/hangouts_bot.py b/homeassistant/components/hangouts/hangouts_bot.py index 7edc8898c..8575a547a 100644 --- a/homeassistant/components/hangouts/hangouts_bot.py +++ b/homeassistant/components/hangouts/hangouts_bot.py @@ -1,14 +1,32 @@ """The Hangouts Bot.""" +import asyncio +import io import logging +import aiohttp +import hangups +from hangups import ChatMessageEvent, ChatMessageSegment, Client, get_auth, hangouts_pb2 + from homeassistant.helpers import dispatcher, intent +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ( - ATTR_MESSAGE, ATTR_TARGET, CONF_CONVERSATIONS, DOMAIN, - EVENT_HANGOUTS_CONNECTED, EVENT_HANGOUTS_CONVERSATIONS_CHANGED, - EVENT_HANGOUTS_DISCONNECTED, EVENT_HANGOUTS_MESSAGE_RECEIVED, - CONF_MATCHERS, CONF_CONVERSATION_ID, - CONF_CONVERSATION_NAME, EVENT_HANGOUTS_CONVERSATIONS_RESOLVED, INTENT_HELP) + ATTR_DATA, + ATTR_MESSAGE, + ATTR_TARGET, + CONF_CONVERSATION_ID, + CONF_CONVERSATION_NAME, + CONF_CONVERSATIONS, + CONF_MATCHERS, + DOMAIN, + EVENT_HANGOUTS_CONNECTED, + EVENT_HANGOUTS_CONVERSATIONS_CHANGED, + EVENT_HANGOUTS_CONVERSATIONS_RESOLVED, + EVENT_HANGOUTS_DISCONNECTED, + EVENT_HANGOUTS_MESSAGE_RECEIVED, + INTENT_HELP, +) +from .hangups_utils import HangoutsCredentials, HangoutsRefreshToken _LOGGER = logging.getLogger(__name__) @@ -16,8 +34,9 @@ _LOGGER = logging.getLogger(__name__) class HangoutsBot: """The Hangouts Bot.""" - def __init__(self, hass, refresh_token, intents, - default_convs, error_suppressed_convs): + def __init__( + self, hass, refresh_token, intents, default_convs, error_suppressed_convs + ): """Set up the client.""" self.hass = hass self._connected = False @@ -36,8 +55,10 @@ class HangoutsBot: self._error_suppressed_conv_ids = None dispatcher.async_dispatcher_connect( - self.hass, EVENT_HANGOUTS_MESSAGE_RECEIVED, - self._async_handle_conversation_message) + self.hass, + EVENT_HANGOUTS_MESSAGE_RECEIVED, + self._async_handle_conversation_message, + ) def _resolve_conversation_id(self, obj): if CONF_CONVERSATION_ID in obj: @@ -65,14 +86,15 @@ class HangoutsBot: conv_id = self._resolve_conversation_id(conversation) if conv_id is not None: conversations.append(conv_id) - data['_' + CONF_CONVERSATIONS] = conversations + data["_" + CONF_CONVERSATIONS] = conversations elif self._default_conv_ids: - data['_' + CONF_CONVERSATIONS] = self._default_conv_ids + data["_" + CONF_CONVERSATIONS] = self._default_conv_ids else: - data['_' + CONF_CONVERSATIONS] = \ - [conv.id_ for conv in self._conversation_list.get_all()] + data["_" + CONF_CONVERSATIONS] = [ + conv.id_ for conv in self._conversation_list.get_all() + ] - for conv_id in data['_' + CONF_CONVERSATIONS]: + for conv_id in data["_" + CONF_CONVERSATIONS]: if conv_id not in self._conversation_intents: self._conversation_intents[conv_id] = {} @@ -80,11 +102,13 @@ class HangoutsBot: try: self._conversation_list.on_event.remove_observer( - self._async_handle_conversation_event) + self._async_handle_conversation_event + ) except ValueError: pass self._conversation_list.on_event.add_observer( - self._async_handle_conversation_event) + self._async_handle_conversation_event + ) def async_resolve_conversations(self, _): """Resolve the list of default and error suppressed conversations.""" @@ -100,34 +124,34 @@ class HangoutsBot: conv_id = self._resolve_conversation_id(conversation) if conv_id is not None: self._error_suppressed_conv_ids.append(conv_id) - dispatcher.async_dispatcher_send(self.hass, - EVENT_HANGOUTS_CONVERSATIONS_RESOLVED) + dispatcher.async_dispatcher_send( + self.hass, EVENT_HANGOUTS_CONVERSATIONS_RESOLVED + ) async def _async_handle_conversation_event(self, event): - from hangups import ChatMessageEvent if isinstance(event, ChatMessageEvent): - dispatcher.async_dispatcher_send(self.hass, - EVENT_HANGOUTS_MESSAGE_RECEIVED, - event.conversation_id, - event.user_id, event) + dispatcher.async_dispatcher_send( + self.hass, + EVENT_HANGOUTS_MESSAGE_RECEIVED, + event.conversation_id, + event.user_id, + event, + ) - async def _async_handle_conversation_message(self, - conv_id, user_id, event): + async def _async_handle_conversation_message(self, conv_id, user_id, event): """Handle a message sent to a conversation.""" user = self._user_list.get_user(user_id) if user.is_self: return message = event.text - _LOGGER.debug("Handling message '%s' from %s", - message, user.full_name) + _LOGGER.debug("Handling message '%s' from %s", message, user.full_name) intents = self._conversation_intents.get(conv_id) if intents is not None: is_error = False try: - intent_result = await self._async_process(intents, message, - conv_id) + intent_result = await self._async_process(intents, message, conv_id) except (intent.UnknownIntent, intent.IntentHandleError) as err: is_error = True intent_result = intent.IntentResponse() @@ -136,17 +160,20 @@ class HangoutsBot: if intent_result is None: is_error = True intent_result = intent.IntentResponse() - intent_result.async_set_speech( - "Sorry, I didn't understand that") + intent_result.async_set_speech("Sorry, I didn't understand that") - message = intent_result.as_dict().get('speech', {})\ - .get('plain', {}).get('speech') + message = ( + intent_result.as_dict().get("speech", {}).get("plain", {}).get("speech") + ) if (message is not None) and not ( - is_error and conv_id in self._error_suppressed_conv_ids): + is_error and conv_id in self._error_suppressed_conv_ids + ): await self._async_send_message( - [{'text': message, 'parse_str': True}], - [{CONF_CONVERSATION_ID: conv_id}]) + [{"text": message, "parse_str": True}], + [{CONF_CONVERSATION_ID: conv_id}], + None, + ) async def _async_process(self, intents, text, conv_id): """Detect a matching intent.""" @@ -158,23 +185,23 @@ class HangoutsBot: continue if intent_type == INTENT_HELP: return await self.hass.helpers.intent.async_handle( - DOMAIN, intent_type, - {'conv_id': {'value': conv_id}}, text) + DOMAIN, intent_type, {"conv_id": {"value": conv_id}}, text + ) return await self.hass.helpers.intent.async_handle( - DOMAIN, intent_type, - {key: {'value': value} - for key, value in match.groupdict().items()}, text) + DOMAIN, + intent_type, + {key: {"value": value} for key, value in match.groupdict().items()}, + text, + ) async def async_connect(self): """Login to the Google Hangouts.""" - from .hangups_utils import HangoutsRefreshToken, HangoutsCredentials - - from hangups import Client - from hangups import get_auth session = await self.hass.async_add_executor_job( - get_auth, HangoutsCredentials(None, None, None), - HangoutsRefreshToken(self._refresh_token)) + get_auth, + HangoutsCredentials(None, None, None), + HangoutsRefreshToken(self._refresh_token), + ) self._client = Client(session) self._client.on_connect.add_observer(self._on_connect) @@ -183,90 +210,137 @@ class HangoutsBot: self.hass.loop.create_task(self._client.connect()) def _on_connect(self): - _LOGGER.debug('Connected!') + _LOGGER.debug("Connected!") self._connected = True dispatcher.async_dispatcher_send(self.hass, EVENT_HANGOUTS_CONNECTED) - def _on_disconnect(self): + async def _on_disconnect(self): """Handle disconnecting.""" - _LOGGER.debug('Connection lost!') - self._connected = False - dispatcher.async_dispatcher_send(self.hass, - EVENT_HANGOUTS_DISCONNECTED) + if self._connected: + _LOGGER.debug("Connection lost! Reconnect...") + await self.async_connect() + else: + dispatcher.async_dispatcher_send(self.hass, EVENT_HANGOUTS_DISCONNECTED) async def async_disconnect(self): """Disconnect the client if it is connected.""" if self._connected: + self._connected = False await self._client.disconnect() async def async_handle_hass_stop(self, _): """Run once when Home Assistant stops.""" await self.async_disconnect() - async def _async_send_message(self, message, targets): + async def _async_send_message(self, message, targets, data): conversations = [] for target in targets: conversation = None if CONF_CONVERSATION_ID in target: - conversation = self._conversation_list.get( - target[CONF_CONVERSATION_ID]) + conversation = self._conversation_list.get(target[CONF_CONVERSATION_ID]) elif CONF_CONVERSATION_NAME in target: conversation = self._resolve_conversation_name( - target[CONF_CONVERSATION_NAME]) + target[CONF_CONVERSATION_NAME] + ) if conversation is not None: conversations.append(conversation) if not conversations: return False - from hangups import ChatMessageSegment, hangouts_pb2 messages = [] for segment in message: if messages: - messages.append(ChatMessageSegment('', - segment_type=hangouts_pb2. - SEGMENT_TYPE_LINE_BREAK)) - if 'parse_str' in segment and segment['parse_str']: - messages.extend(ChatMessageSegment.from_str(segment['text'])) + messages.append( + ChatMessageSegment( + "", segment_type=hangouts_pb2.SEGMENT_TYPE_LINE_BREAK + ) + ) + if "parse_str" in segment and segment["parse_str"]: + messages.extend(ChatMessageSegment.from_str(segment["text"])) else: - if 'parse_str' in segment: - del segment['parse_str'] + if "parse_str" in segment: + del segment["parse_str"] messages.append(ChatMessageSegment(**segment)) + image_file = None + if data: + if data.get("image_url"): + uri = data.get("image_url") + try: + websession = async_get_clientsession(self.hass) + async with websession.get(uri, timeout=5) as response: + if response.status != 200: + _LOGGER.error( + "Fetch image failed, %s, %s", response.status, response + ) + image_file = None + else: + image_data = await response.read() + image_file = io.BytesIO(image_data) + image_file.name = "image.png" + except (asyncio.TimeoutError, aiohttp.ClientError) as error: + _LOGGER.error("Failed to fetch image, %s", type(error)) + image_file = None + elif data.get("image_file"): + uri = data.get("image_file") + if self.hass.config.is_allowed_path(uri): + try: + image_file = open(uri, "rb") + except OSError as error: + _LOGGER.error( + "Image file I/O error(%s): %s", error.errno, error.strerror + ) + else: + _LOGGER.error('Path "%s" not allowed', uri) + if not messages: return False for conv in conversations: - await conv.send_message(messages) + await conv.send_message(messages, image_file) async def _async_list_conversations(self): - import hangups - self._user_list, self._conversation_list = \ - (await hangups.build_user_conversation_list(self._client)) + ( + self._user_list, + self._conversation_list, + ) = await hangups.build_user_conversation_list(self._client) conversations = {} for i, conv in enumerate(self._conversation_list.get_all()): users_in_conversation = [] for user in conv.users: users_in_conversation.append(user.full_name) - conversations[str(i)] = {CONF_CONVERSATION_ID: str(conv.id_), - CONF_CONVERSATION_NAME: conv.name, - 'users': users_in_conversation} + conversations[str(i)] = { + CONF_CONVERSATION_ID: str(conv.id_), + CONF_CONVERSATION_NAME: conv.name, + "users": users_in_conversation, + } - self.hass.states.async_set("{}.conversations".format(DOMAIN), - len(self._conversation_list.get_all()), - attributes=conversations) - dispatcher.async_dispatcher_send(self.hass, - EVENT_HANGOUTS_CONVERSATIONS_CHANGED, - conversations) + self.hass.states.async_set( + f"{DOMAIN}.conversations", + len(self._conversation_list.get_all()), + attributes=conversations, + ) + dispatcher.async_dispatcher_send( + self.hass, EVENT_HANGOUTS_CONVERSATIONS_CHANGED, conversations + ) async def async_handle_send_message(self, service): """Handle the send_message service.""" - await self._async_send_message(service.data[ATTR_MESSAGE], - service.data[ATTR_TARGET]) + await self._async_send_message( + service.data[ATTR_MESSAGE], + service.data[ATTR_TARGET], + service.data.get(ATTR_DATA, {}), + ) async def async_handle_update_users_and_conversations(self, _=None): """Handle the update_users_and_conversations service.""" await self._async_list_conversations() + async def async_handle_reconnect(self, _=None): + """Handle the reconnect service.""" + await self.async_disconnect() + await self.async_connect() + def get_intents(self, conv_id): """Return the intents for a specific conversation.""" return self._conversation_intents.get(conv_id) diff --git a/homeassistant/components/hangouts/hangups_utils.py b/homeassistant/components/hangouts/hangups_utils.py index 9aff77302..d2556ac15 100644 --- a/homeassistant/components/hangouts/hangups_utils.py +++ b/homeassistant/components/hangouts/hangups_utils.py @@ -13,7 +13,7 @@ class HangoutsCredentials(CredentialsPrompt): This implementation gets the user data as params. """ - def __init__(self, email, password, pin=None): + def __init__(self, email, password, pin=None, auth_code=None): """Google account credentials. :param email: Google account email address. @@ -23,6 +23,7 @@ class HangoutsCredentials(CredentialsPrompt): self._email = email self._password = password self._pin = pin + self._auth_code = auth_code def get_email(self): """Return email. @@ -54,6 +55,20 @@ class HangoutsCredentials(CredentialsPrompt): """ self._pin = pin + def get_authorization_code(self): + """Return the oauth authorization code. + + :return: Google oauth code. + """ + return self._auth_code + + def set_authorization_code(self, code): + """Set the google oauth authorization code. + + :param code: Oauth code returned after authentication with google. + """ + self._auth_code = code + class HangoutsRefreshToken(RefreshTokenCache): """Memory-based cache for refresh token.""" diff --git a/homeassistant/components/hangouts/intents.py b/homeassistant/components/hangouts/intents.py index be52f0591..5e4c6ff20 100644 --- a/homeassistant/components/hangouts/intents.py +++ b/homeassistant/components/hangouts/intents.py @@ -1,17 +1,15 @@ -"""Intents for the hangouts component.""" +"""Intents for the Hangouts component.""" from homeassistant.helpers import intent import homeassistant.helpers.config_validation as cv -from .const import INTENT_HELP, DOMAIN, CONF_BOT +from .const import CONF_BOT, DOMAIN, INTENT_HELP class HelpIntent(intent.IntentHandler): """Handle Help intents.""" intent_type = INTENT_HELP - slot_schema = { - 'conv_id': cv.string - } + slot_schema = {"conv_id": cv.string} def __init__(self, hass): """Set up the intent.""" @@ -20,14 +18,14 @@ class HelpIntent(intent.IntentHandler): async def async_handle(self, intent_obj): """Handle the intent.""" slots = self.async_validate_slots(intent_obj.slots) - conv_id = slots['conv_id']['value'] + conv_id = slots["conv_id"]["value"] intents = self.hass.data[DOMAIN][CONF_BOT].get_intents(conv_id) response = intent_obj.create_response() help_text = "I understand the following sentences:" for intent_data in intents.values(): - for sentence in intent_data['sentences']: - help_text += "\n'{}'".format(sentence) + for sentence in intent_data["sentences"]: + help_text += f"\n'{sentence}'" response.async_set_speech(help_text) return response diff --git a/homeassistant/components/hangouts/manifest.json b/homeassistant/components/hangouts/manifest.json new file mode 100644 index 000000000..2f222b3c1 --- /dev/null +++ b/homeassistant/components/hangouts/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "hangouts", + "name": "Hangouts", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/hangouts", + "requirements": [ + "hangups==0.4.9" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/hangouts/notify.py b/homeassistant/components/hangouts/notify.py new file mode 100644 index 000000000..01e4208fd --- /dev/null +++ b/homeassistant/components/hangouts/notify.py @@ -0,0 +1,61 @@ +"""Support for Hangouts notifications.""" +import logging + +import voluptuous as vol + +from homeassistant.components.notify import ( + ATTR_DATA, + ATTR_MESSAGE, + ATTR_TARGET, + PLATFORM_SCHEMA, + BaseNotificationService, +) + +from .const import ( + CONF_DEFAULT_CONVERSATIONS, + DOMAIN, + SERVICE_SEND_MESSAGE, + TARGETS_SCHEMA, +) + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + {vol.Required(CONF_DEFAULT_CONVERSATIONS): [TARGETS_SCHEMA]} +) + + +def get_service(hass, config, discovery_info=None): + """Get the Hangouts notification service.""" + return HangoutsNotificationService(config.get(CONF_DEFAULT_CONVERSATIONS)) + + +class HangoutsNotificationService(BaseNotificationService): + """Send Notifications to Hangouts conversations.""" + + def __init__(self, default_conversations): + """Set up the notification service.""" + self._default_conversations = default_conversations + + def send_message(self, message="", **kwargs): + """Send the message to the Google Hangouts server.""" + target_conversations = None + if ATTR_TARGET in kwargs: + target_conversations = [] + for target in kwargs.get(ATTR_TARGET): + target_conversations.append({"id": target}) + else: + target_conversations = self._default_conversations + + messages = [] + if "title" in kwargs: + messages.append({"text": kwargs["title"], "is_bold": True}) + + messages.append({"text": message, "parse_str": True}) + service_data = {ATTR_TARGET: target_conversations, ATTR_MESSAGE: messages} + if kwargs[ATTR_DATA]: + service_data[ATTR_DATA] = kwargs[ATTR_DATA] + + return self.hass.services.call( + DOMAIN, SERVICE_SEND_MESSAGE, service_data=service_data + ) diff --git a/homeassistant/components/hangouts/services.yaml b/homeassistant/components/hangouts/services.yaml index 5d314bc24..26a719349 100644 --- a/homeassistant/components/hangouts/services.yaml +++ b/homeassistant/components/hangouts/services.yaml @@ -1,5 +1,5 @@ update: - description: Updates the list of users and conversations. + description: Updates the list of conversations. send_message: description: Send a notification to a specific target. @@ -9,4 +9,10 @@ send_message: example: '[{"id": "UgxrXzVrARmjx_C6AZx4AaABAagBo-6UCw"}, {"name": "Test Conversation"}]' message: description: List of message segments, only the "text" field is required in every segment. [Required] - example: '[{"text":"test", "is_bold": false, "is_italic": false, "is_strikethrough": false, "is_underline": false, "parse_str": false, "link_target": "http://google.com"}, ...]' \ No newline at end of file + example: '[{"text":"test", "is_bold": false, "is_italic": false, "is_strikethrough": false, "is_underline": false, "parse_str": false, "link_target": "http://google.com"}, ...]' + data: + description: Other options ['image_file' / 'image_url'] + example: '{ "image_file": "file" } or { "image_url": "url" }' + +reconnect: + description: Reconnect the bot. \ No newline at end of file diff --git a/homeassistant/components/hangouts/strings.json b/homeassistant/components/hangouts/strings.json index dd421fee5..8c155784e 100644 --- a/homeassistant/components/hangouts/strings.json +++ b/homeassistant/components/hangouts/strings.json @@ -13,16 +13,15 @@ "user": { "data": { "email": "E-Mail Address", - "password": "Password" + "password": "Password", + "authorization_code": "Authorization Code (required for manual authentication)" }, - "description": "", "title": "Google Hangouts Login" }, "2fa": { "data": { "2fa": "2FA Pin" }, - "description": "", "title": "2-Factor-Authentication" } }, diff --git a/homeassistant/components/harman_kardon_avr/__init__.py b/homeassistant/components/harman_kardon_avr/__init__.py new file mode 100644 index 000000000..c9e3afd6b --- /dev/null +++ b/homeassistant/components/harman_kardon_avr/__init__.py @@ -0,0 +1 @@ +"""The harman_kardon_avr component.""" diff --git a/homeassistant/components/harman_kardon_avr/manifest.json b/homeassistant/components/harman_kardon_avr/manifest.json new file mode 100644 index 000000000..6bd649426 --- /dev/null +++ b/homeassistant/components/harman_kardon_avr/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "harman_kardon_avr", + "name": "Harman kardon avr", + "documentation": "https://www.home-assistant.io/integrations/harman_kardon_avr", + "requirements": [ + "hkavr==0.0.5" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/harman_kardon_avr/media_player.py b/homeassistant/components/harman_kardon_avr/media_player.py new file mode 100644 index 000000000..fd7cddcae --- /dev/null +++ b/homeassistant/components/harman_kardon_avr/media_player.py @@ -0,0 +1,133 @@ +"""Support for interface with an Harman/Kardon or JBL AVR.""" +import logging + +import hkavr +import voluptuous as vol + +from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerDevice +from homeassistant.components.media_player.const import ( + SUPPORT_SELECT_SOURCE, + SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, + SUPPORT_VOLUME_MUTE, + SUPPORT_VOLUME_STEP, +) +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, STATE_OFF, STATE_ON +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = "Harman Kardon AVR" +DEFAULT_PORT = 10025 + +SUPPORT_HARMAN_KARDON_AVR = ( + SUPPORT_VOLUME_STEP + | SUPPORT_VOLUME_MUTE + | SUPPORT_TURN_OFF + | SUPPORT_TURN_ON + | 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, + } +) + + +def setup_platform(hass, config, add_entities, discover_info=None): + """Set up the AVR platform.""" + name = config[CONF_NAME] + host = config[CONF_HOST] + port = config[CONF_PORT] + + avr = hkavr.HkAVR(host, port, name) + avr_device = HkAvrDevice(avr) + + add_entities([avr_device], True) + + +class HkAvrDevice(MediaPlayerDevice): + """Representation of a Harman Kardon AVR / JBL AVR TV.""" + + def __init__(self, avr): + """Initialize a new HarmanKardonAVR.""" + self._avr = avr + + self._name = avr.name + self._host = avr.host + self._port = avr.port + + self._source_list = avr.sources + + self._state = None + self._muted = avr.muted + self._current_source = avr.current_source + + def update(self): + """Update the state of this media_player.""" + if self._avr.is_on(): + self._state = STATE_ON + elif self._avr.is_off(): + self._state = STATE_OFF + else: + self._state = None + + self._muted = self._avr.muted + self._current_source = self._avr.current_source + + @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 is_volume_muted(self): + """Muted status not available.""" + return self._muted + + @property + def source(self): + """Return the current input source.""" + return self._current_source + + @property + def source_list(self): + """Available sources.""" + return self._source_list + + @property + def supported_features(self): + """Flag media player features that are supported.""" + return SUPPORT_HARMAN_KARDON_AVR + + def turn_on(self): + """Turn the AVR on.""" + self._avr.power_on() + + def turn_off(self): + """Turn off the AVR.""" + self._avr.power_off() + + def select_source(self, source): + """Select input source.""" + return self._avr.select_source(source) + + def volume_up(self): + """Volume up the AVR.""" + return self._avr.volume_up() + + def volume_down(self): + """Volume down AVR.""" + return self._avr.volume_down() + + def mute_volume(self, mute): + """Send mute command.""" + return self._avr.mute(mute) diff --git a/homeassistant/components/harmony/__init__.py b/homeassistant/components/harmony/__init__.py new file mode 100644 index 000000000..12ccc7807 --- /dev/null +++ b/homeassistant/components/harmony/__init__.py @@ -0,0 +1 @@ +"""Support for Harmony devices.""" diff --git a/homeassistant/components/harmony/const.py b/homeassistant/components/harmony/const.py new file mode 100644 index 000000000..12e710506 --- /dev/null +++ b/homeassistant/components/harmony/const.py @@ -0,0 +1,4 @@ +"""Constants for the Harmony component.""" +DOMAIN = "harmony" +SERVICE_SYNC = "sync" +SERVICE_CHANGE_CHANNEL = "change_channel" diff --git a/homeassistant/components/harmony/manifest.json b/homeassistant/components/harmony/manifest.json new file mode 100644 index 000000000..af4427c5d --- /dev/null +++ b/homeassistant/components/harmony/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "harmony", + "name": "Harmony", + "documentation": "https://www.home-assistant.io/integrations/harmony", + "requirements": [ + "aioharmony==0.1.13" + ], + "dependencies": [], + "codeowners": [ + "@ehendrix23" + ] +} diff --git a/homeassistant/components/harmony/remote.py b/homeassistant/components/harmony/remote.py new file mode 100644 index 000000000..7f4d03ccb --- /dev/null +++ b/homeassistant/components/harmony/remote.py @@ -0,0 +1,415 @@ +"""Support for Harmony Hub devices.""" +import asyncio +import json +import logging + +import aioharmony.exceptions as aioexc +from aioharmony.harmonyapi import ( + ClientCallbackType, + HarmonyAPI as HarmonyClient, + SendCommandDevice, +) +import voluptuous as vol + +from homeassistant.components import remote +from homeassistant.components.remote import ( + ATTR_ACTIVITY, + ATTR_DELAY_SECS, + ATTR_DEVICE, + ATTR_HOLD_SECS, + ATTR_NUM_REPEATS, + DEFAULT_DELAY_SECS, + PLATFORM_SCHEMA, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_HOST, + CONF_NAME, + CONF_PORT, + EVENT_HOMEASSISTANT_STOP, +) +from homeassistant.exceptions import PlatformNotReady +import homeassistant.helpers.config_validation as cv +from homeassistant.util import slugify + +from .const import DOMAIN, SERVICE_CHANGE_CHANNEL, SERVICE_SYNC + +_LOGGER = logging.getLogger(__name__) + +ATTR_CHANNEL = "channel" +ATTR_CURRENT_ACTIVITY = "current_activity" + +DEFAULT_PORT = 8088 +DEVICES = [] +CONF_DEVICE_CACHE = "harmony_device_cache" + +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, + } +) + +HARMONY_SYNC_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.entity_ids}) + +HARMONY_CHANGE_CHANNEL_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_CHANNEL): cv.positive_int, + } +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the Harmony platform.""" + activity = None + + if CONF_DEVICE_CACHE not in hass.data: + hass.data[CONF_DEVICE_CACHE] = [] + + if discovery_info: + # Find the discovered device in the list of user configurations + override = next( + ( + c + for c in hass.data[CONF_DEVICE_CACHE] + if c.get(CONF_NAME) == discovery_info.get(CONF_NAME) + ), + None, + ) + + port = DEFAULT_PORT + delay_secs = DEFAULT_DELAY_SECS + if override is not None: + activity = override.get(ATTR_ACTIVITY) + delay_secs = override.get(ATTR_DELAY_SECS) + port = override.get(CONF_PORT, DEFAULT_PORT) + + host = (discovery_info.get(CONF_NAME), discovery_info.get(CONF_HOST), port) + + # Ignore hub name when checking if this hub is known - ip and port only + 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: + host = (config.get(CONF_NAME), config.get(CONF_HOST), 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 + + name, address, port = host + _LOGGER.info( + "Loading Harmony Platform: %s at %s:%s, startup activity: %s", + name, + address, + port, + activity, + ) + + harmony_conf_file = hass.config.path( + "{}{}{}".format("harmony_", slugify(name), ".conf") + ) + try: + device = HarmonyRemote( + name, address, port, activity, harmony_conf_file, delay_secs + ) + if not await device.connect(): + raise PlatformNotReady + + DEVICES.append(device) + async_add_entities([device]) + register_services(hass) + except (ValueError, AttributeError): + raise PlatformNotReady + + +def register_services(hass): + """Register all services for harmony devices.""" + hass.services.async_register( + DOMAIN, SERVICE_SYNC, _sync_service, schema=HARMONY_SYNC_SCHEMA + ) + + hass.services.async_register( + DOMAIN, + SERVICE_CHANGE_CHANNEL, + _change_channel_service, + schema=HARMONY_CHANGE_CHANNEL_SCHEMA, + ) + + +async def _apply_service(service, service_func, *service_func_args): + """Handle services to apply.""" + entity_ids = service.data.get("entity_id") + + if entity_ids: + _devices = [device for device in DEVICES if device.entity_id in entity_ids] + else: + _devices = DEVICES + + for device in _devices: + await service_func(device, *service_func_args) + + +async def _sync_service(service): + await _apply_service(service, HarmonyRemote.sync) + + +async def _change_channel_service(service): + channel = service.data.get(ATTR_CHANNEL) + await _apply_service(service, HarmonyRemote.change_channel, channel) + + +class HarmonyRemote(remote.RemoteDevice): + """Remote representation used to control a Harmony device.""" + + def __init__(self, name, host, port, activity, out_path, delay_secs): + """Initialize HarmonyRemote class.""" + self._name = name + self.host = host + self.port = port + self._state = None + self._current_activity = None + self._default_activity = activity + self._client = HarmonyClient(ip_address=host) + self._config_path = out_path + self._delay_secs = delay_secs + self._available = False + + async def async_added_to_hass(self): + """Complete the initialization.""" + _LOGGER.debug("%s: Harmony Hub added", self._name) + # Register the callbacks + self._client.callbacks = ClientCallbackType( + new_activity=self.new_activity, + config_updated=self.new_config, + connect=self.got_connected, + disconnect=self.got_disconnected, + ) + + # Store Harmony HUB config, this will also update our current + # activity + await self.new_config() + + async def shutdown(_): + """Close connection on shutdown.""" + _LOGGER.debug("%s: Closing Harmony Hub", self._name) + try: + await self._client.close() + except aioexc.TimeOut: + _LOGGER.warning("%s: Disconnect timed-out", self._name) + + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown) + + @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.""" + return {ATTR_CURRENT_ACTIVITY: self._current_activity} + + @property + def is_on(self): + """Return False if PowerOff is the current activity, otherwise True.""" + return self._current_activity not in [None, "PowerOff"] + + @property + def available(self): + """Return True if connected to Hub, otherwise False.""" + return self._available + + async def connect(self): + """Connect to the Harmony HUB.""" + _LOGGER.debug("%s: Connecting", self._name) + try: + if not await self._client.connect(): + _LOGGER.warning("%s: Unable to connect to HUB.", self._name) + await self._client.close() + return False + except aioexc.TimeOut: + _LOGGER.warning("%s: Connection timed-out", self._name) + return False + + return True + + def new_activity(self, activity_info: tuple) -> None: + """Call for updating the current activity.""" + activity_id, activity_name = activity_info + _LOGGER.debug("%s: activity reported as: %s", self._name, activity_name) + self._current_activity = activity_name + self._state = bool(activity_id != -1) + self._available = True + self.async_schedule_update_ha_state() + + async def new_config(self, _=None): + """Call for updating the current activity.""" + _LOGGER.debug("%s: configuration has been updated", self._name) + self.new_activity(self._client.current_activity) + await self.hass.async_add_executor_job(self.write_config_file) + + async def got_connected(self, _=None): + """Notification that we're connected to the HUB.""" + _LOGGER.debug("%s: connected to the HUB.", self._name) + if not self._available: + # We were disconnected before. + await self.new_config() + + async def got_disconnected(self, _=None): + """Notification that we're disconnected from the HUB.""" + _LOGGER.debug("%s: disconnected from the HUB.", self._name) + self._available = False + # We're going to wait for 10 seconds before announcing we're + # unavailable, this to allow a reconnection to happen. + await asyncio.sleep(10) + + if not self._available: + # Still disconnected. Let the state engine know. + self.async_schedule_update_ha_state() + + async def async_turn_on(self, **kwargs): + """Start an activity from the Harmony device.""" + _LOGGER.debug("%s: Turn On", self.name) + + activity = kwargs.get(ATTR_ACTIVITY, self._default_activity) + + if activity: + activity_id = None + if activity.isdigit() or activity == "-1": + _LOGGER.debug("%s: Activity is numeric", self.name) + if self._client.get_activity_name(int(activity)): + activity_id = activity + + if activity_id is None: + _LOGGER.debug("%s: Find activity ID based on name", self.name) + activity_id = self._client.get_activity_id(str(activity).strip()) + + if activity_id is None: + _LOGGER.error("%s: Activity %s is invalid", self.name, activity) + return + + try: + await self._client.start_activity(activity_id) + except aioexc.TimeOut: + _LOGGER.error("%s: Starting activity %s timed-out", self.name, activity) + else: + _LOGGER.error("%s: No activity specified with turn_on service", self.name) + + async def async_turn_off(self, **kwargs): + """Start the PowerOff activity.""" + _LOGGER.debug("%s: Turn Off", self.name) + try: + await self._client.power_off() + except aioexc.TimeOut: + _LOGGER.error("%s: Powering off timed-out", self.name) + + async def async_send_command(self, command, **kwargs): + """Send a list of commands to one device.""" + _LOGGER.debug("%s: Send Command", self.name) + device = kwargs.get(ATTR_DEVICE) + if device is None: + _LOGGER.error("%s: Missing required argument: device", self.name) + return + + device_id = None + if device.isdigit(): + _LOGGER.debug("%s: Device %s is numeric", self.name, device) + if self._client.get_device_name(int(device)): + device_id = device + + if device_id is None: + _LOGGER.debug( + "%s: Find device ID %s based on device name", self.name, device + ) + device_id = self._client.get_device_id(str(device).strip()) + + if device_id is None: + _LOGGER.error("%s: Device %s is invalid", self.name, device) + return + + num_repeats = kwargs[ATTR_NUM_REPEATS] + delay_secs = kwargs.get(ATTR_DELAY_SECS, self._delay_secs) + hold_secs = kwargs[ATTR_HOLD_SECS] + _LOGGER.debug( + "Sending commands to device %s holding for %s seconds " + "with a delay of %s seconds", + device, + hold_secs, + delay_secs, + ) + + # Creating list of commands to send. + snd_cmnd_list = [] + for _ in range(num_repeats): + for single_command in command: + send_command = SendCommandDevice( + device=device_id, command=single_command, delay=hold_secs + ) + snd_cmnd_list.append(send_command) + if delay_secs > 0: + snd_cmnd_list.append(float(delay_secs)) + + _LOGGER.debug("%s: Sending commands", self.name) + try: + result_list = await self._client.send_commands(snd_cmnd_list) + except aioexc.TimeOut: + _LOGGER.error("%s: Sending commands timed-out", self.name) + return + + for result in result_list: + _LOGGER.error( + "Sending command %s to device %s failed with code " "%s: %s", + result.command.command, + result.command.device, + result.code, + result.msg, + ) + + async def change_channel(self, channel): + """Change the channel using Harmony remote.""" + _LOGGER.debug("%s: Changing channel to %s", self.name, channel) + try: + await self._client.change_channel(channel) + except aioexc.TimeOut: + _LOGGER.error("%s: Changing channel to %s timed-out", self.name, channel) + + async def sync(self): + """Sync the Harmony device with the web service.""" + _LOGGER.debug("%s: Syncing hub with Harmony cloud", self.name) + try: + await self._client.sync() + except aioexc.TimeOut: + _LOGGER.error("%s: Syncing hub with Harmony cloud timed-out", self.name) + else: + await self.hass.async_add_executor_job(self.write_config_file) + + def write_config_file(self): + """Write Harmony configuration file.""" + _LOGGER.debug( + "%s: Writing hub config to file: %s", self.name, self._config_path + ) + if self._client.config is None: + _LOGGER.warning("%s: No configuration received from hub", self.name) + return + + try: + with open(self._config_path, "w+", encoding="utf-8") as file_out: + json.dump(self._client.json_config, file_out, sort_keys=True, indent=4) + except IOError as exc: + _LOGGER.error( + "%s: Unable to write HUB configuration to %s: %s", + self.name, + self._config_path, + exc, + ) diff --git a/homeassistant/components/harmony/services.yaml b/homeassistant/components/harmony/services.yaml new file mode 100644 index 000000000..1b9ae225c --- /dev/null +++ b/homeassistant/components/harmony/services.yaml @@ -0,0 +1,16 @@ +sync: + description: Syncs the remote's configuration. + fields: + entity_id: + description: Name(s) of entities to sync. + example: 'remote.family_room' + +change_channel: + description: Sends change channel command to the Harmony HUB + fields: + entity_id: + description: Name(s) of Harmony remote entities to send change channel command to + example: 'remote.family_room' + channel: + description: Channel number to change to + example: '200' \ No newline at end of file diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index e0356017e..dab0fadd9 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -1,115 +1,125 @@ -""" -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 +"""Support for Hass.io.""" from datetime import timedelta import logging import os import voluptuous as vol -from homeassistant.components import SERVICE_CHECK_CONFIG +from homeassistant.auth.const import GROUP_ID_ADMIN +from homeassistant.components.homeassistant import SERVICE_CHECK_CONFIG +import homeassistant.config as conf_util 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 + ATTR_NAME, + EVENT_CORE_CONFIG_UPDATE, + SERVICE_HOMEASSISTANT_RESTART, + SERVICE_HOMEASSISTANT_STOP, +) +from homeassistant.core import DOMAIN as HASS_DOMAIN, callback +from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.loader import bind_hass from homeassistant.util.dt import utcnow -from .handler import HassIO +from .addon_panel import async_setup_addon_panel +from .auth import async_setup_auth_view +from .discovery import async_setup_discovery_view +from .handler import HassIO, HassioAPIError from .http import HassIOView +from .ingress import async_setup_ingress_view _LOGGER = logging.getLogger(__name__) -DOMAIN = 'hassio' -DEPENDENCIES = ['http'] +DOMAIN = "hassio" STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 -CONF_FRONTEND_REPO = 'development_repo' +CONF_FRONTEND_REPO = "development_repo" -CONFIG_SCHEMA = vol.Schema({ - vol.Optional(DOMAIN): vol.Schema({ - vol.Optional(CONF_FRONTEND_REPO): cv.isdir, - }), -}, extra=vol.ALLOW_EXTRA) +CONFIG_SCHEMA = vol.Schema( + {vol.Optional(DOMAIN): vol.Schema({vol.Optional(CONF_FRONTEND_REPO): cv.isdir})}, + extra=vol.ALLOW_EXTRA, +) -DATA_HOMEASSISTANT_VERSION = 'hassio_hass_version' +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' +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' +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 = vol.Schema({vol.Required(ATTR_ADDON): cv.slug}) -SCHEMA_ADDON_STDIN = SCHEMA_ADDON.extend({ - vol.Required(ATTR_INPUT): vol.Any(dict, cv.string) -}) +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_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_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_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]), -}) +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), + 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, + ), } @@ -133,92 +143,75 @@ def is_hassio(hass): 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" - if result['result'] == "error": - return result['message'] - return None - - -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Set up the Hass.io component.""" - try: - host = os.environ['HASSIO'] - except KeyError: - _LOGGER.error("Missing HASSIO environment variable.") - return False - - try: - os.environ['HASSIO_TOKEN'] - except KeyError: - _LOGGER.error("Missing HASSIO_TOKEN environment variable.") + # Check local setup + for env in ("HASSIO", "HASSIO_TOKEN"): + if os.environ.get(env): + continue + _LOGGER.error("Missing %s environment variable.", env) return False + host = os.environ["HASSIO"] 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 + if not await hassio.is_connected(): + _LOGGER.warning("Not connected with Hass.io / system to busy!") store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) - data = yield from store.async_load() + data = await store.async_load() if data is None: data = {} refresh_token = None - if 'hassio_user' in data: - user = yield from hass.auth.async_get_user(data['hassio_user']) + if "hassio_user" in data: + user = await hass.auth.async_get_user(data["hassio_user"]) if user and user.refresh_tokens: refresh_token = list(user.refresh_tokens.values())[0] + # Migrate old hass.io users to be admin. + if not user.is_admin: + await hass.auth.async_update_user(user, group_ids=[GROUP_ID_ADMIN]) + if refresh_token is None: - user = yield from hass.auth.async_create_system_user('Hass.io') - refresh_token = yield from hass.auth.async_create_refresh_token(user) - data['hassio_user'] = user.id - yield from store.async_save(data) + user = await hass.auth.async_create_system_user("Hass.io", [GROUP_ID_ADMIN]) + refresh_token = await hass.auth.async_create_refresh_token(user) + data["hassio_user"] = user.id + await store.async_save(data) # This overrides the normal API call that would be forwarded development_repo = config.get(DOMAIN, {}).get(CONF_FRONTEND_REPO) if development_repo is not None: hass.http.register_static_path( - '/api/hassio/app', - os.path.join(development_repo, 'hassio/build'), False) + "/api/hassio/app", os.path.join(development_repo, "hassio/build"), False + ) hass.http.register_view(HassIOView(host, websession)) - if 'frontend' in hass.config.components: - yield from hass.components.panel_custom.async_register_panel( - frontend_url_path='hassio', - webcomponent_name='hassio-main', - sidebar_title='Hass.io', - sidebar_icon='hass:home-assistant', - js_url='/api/hassio/app/entrypoint.js', + if "frontend" in hass.config.components: + await hass.components.panel_custom.async_register_panel( + frontend_url_path="hassio", + webcomponent_name="hassio-main", + sidebar_title="Hass.io", + sidebar_icon="hass:home-assistant", + js_url="/api/hassio/app/entrypoint.js", embed_iframe=True, + require_admin=True, ) - # Temporary. No refresh token tells supervisor to use API password. - if hass.auth.active: - token = refresh_token.token - else: - token = None + await hassio.update_hass_api(config.get("http", {}), refresh_token.token) - yield from hassio.update_hass_api(config.get('http', {}), token) + async def push_config(_): + """Push core config to Hass.io.""" + await hassio.update_hass_timezone(str(hass.config.time_zone)) - if 'homeassistant' in config: - yield from hassio.update_hass_timezone(config['homeassistant']) + hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, push_config) - @asyncio.coroutine - def async_service_handler(service): + await push_config(None) + + async def async_service_handler(service): """Handle service calls for Hass.io.""" api_command = MAP_SERVICE_API[service.service][0] data = service.data.copy() @@ -233,53 +226,76 @@ def async_setup(hass, config): 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']) + try: + await hassio.send_command( + api_command.format(addon=addon, snapshot=snapshot), + payload=payload, + timeout=MAP_SERVICE_API[service.service][2], + ) + except HassioAPIError as err: + _LOGGER.error("Error on Hass.io API: %s", err) for service, settings in MAP_SERVICE_API.items(): hass.services.async_register( - DOMAIN, service, async_service_handler, schema=settings[1]) + DOMAIN, service, async_service_handler, schema=settings[1] + ) - @asyncio.coroutine - def update_homeassistant_version(now): + async 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'] + try: + data = await hassio.get_homeassistant_info() + hass.data[DATA_HOMEASSISTANT_VERSION] = data["last_version"] + except HassioAPIError as err: + _LOGGER.warning("Can't read last version: %s", err) hass.helpers.event.async_track_point_in_utc_time( - update_homeassistant_version, utcnow() + HASSIO_UPDATE_INTERVAL) + update_homeassistant_version, utcnow() + HASSIO_UPDATE_INTERVAL + ) # Fetch last version - yield from update_homeassistant_version(None) + await update_homeassistant_version(None) - @asyncio.coroutine - def async_handle_core_service(call): + async def async_handle_core_service(call): """Service handler for handling core services.""" if call.service == SERVICE_HOMEASSISTANT_STOP: - yield from hassio.stop_homeassistant() + await hassio.stop_homeassistant() return - error = yield from async_check_config(hass) - if error: - _LOGGER.error(error) + try: + errors = await conf_util.async_check_ha_config_file(hass) + except HomeAssistantError: + return + + if errors: + _LOGGER.error(errors) hass.components.persistent_notification.async_create( - "Config error. See dev-info panel for details.", - "Config validating", "{0}.check_config".format(HASS_DOMAIN)) + "Config error. See [the logs](/developer-tools/logs) for details.", + "Config validating", + f"{HASS_DOMAIN}.check_config", + ) return if call.service == SERVICE_HOMEASSISTANT_RESTART: - yield from hassio.restart_homeassistant() + await 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) + for service in ( + SERVICE_HOMEASSISTANT_STOP, + SERVICE_HOMEASSISTANT_RESTART, + SERVICE_CHECK_CONFIG, + ): + hass.services.async_register(HASS_DOMAIN, service, async_handle_core_service) + + # Init discovery Hass.io feature + async_setup_discovery_view(hass, hassio) + + # Init auth Hass.io feature + async_setup_auth_view(hass) + + # Init ingress Hass.io feature + async_setup_ingress_view(hass, host) + + # Init add-on ingress panels + await async_setup_addon_panel(hass, hassio) return True diff --git a/homeassistant/components/hassio/addon_panel.py b/homeassistant/components/hassio/addon_panel.py new file mode 100644 index 000000000..cb509cb19 --- /dev/null +++ b/homeassistant/components/hassio/addon_panel.py @@ -0,0 +1,91 @@ +"""Implement the Ingress Panel feature for Hass.io Add-ons.""" +import asyncio +import logging + +from aiohttp import web + +from homeassistant.components.http import HomeAssistantView +from homeassistant.helpers.typing import HomeAssistantType + +from .const import ATTR_ADMIN, ATTR_ENABLE, ATTR_ICON, ATTR_PANELS, ATTR_TITLE +from .handler import HassioAPIError + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_addon_panel(hass: HomeAssistantType, hassio): + """Add-on Ingress Panel setup.""" + hassio_addon_panel = HassIOAddonPanel(hass, hassio) + hass.http.register_view(hassio_addon_panel) + + # If panels are exists + panels = await hassio_addon_panel.get_panels() + if not panels: + return + + # Register available panels + jobs = [] + for addon, data in panels.items(): + if not data[ATTR_ENABLE]: + continue + jobs.append(_register_panel(hass, addon, data)) + + if jobs: + await asyncio.wait(jobs) + + +class HassIOAddonPanel(HomeAssistantView): + """Hass.io view to handle base part.""" + + name = "api:hassio_push:panel" + url = "/api/hassio_push/panel/{addon}" + + def __init__(self, hass, hassio): + """Initialize WebView.""" + self.hass = hass + self.hassio = hassio + + async def post(self, request, addon): + """Handle new add-on panel requests.""" + panels = await self.get_panels() + + # Panel exists for add-on slug + if addon not in panels or not panels[addon][ATTR_ENABLE]: + _LOGGER.error("Panel is not enable for %s", addon) + return web.Response(status=400) + data = panels[addon] + + # Register panel + await _register_panel(self.hass, addon, data) + return web.Response() + + async def delete(self, request, addon): + """Handle remove add-on panel requests.""" + self.hass.components.frontend.async_remove_panel(addon) + return web.Response() + + async def get_panels(self): + """Return panels add-on info data.""" + try: + data = await self.hassio.get_ingress_panels() + return data[ATTR_PANELS] + except HassioAPIError as err: + _LOGGER.error("Can't read panel info: %s", err) + return {} + + +def _register_panel(hass, addon, data): + """Init coroutine to register the panel. + + Return coroutine. + """ + return hass.components.panel_custom.async_register_panel( + frontend_url_path=addon, + webcomponent_name="hassio-main", + sidebar_title=data[ATTR_TITLE], + sidebar_icon=data[ATTR_ICON], + js_url="/api/hassio/app/entrypoint.js", + embed_iframe=True, + require_admin=data[ATTR_ADMIN], + config={"ingress": addon}, + ) diff --git a/homeassistant/components/hassio/auth.py b/homeassistant/components/hassio/auth.py new file mode 100644 index 000000000..800801b43 --- /dev/null +++ b/homeassistant/components/hassio/auth.py @@ -0,0 +1,77 @@ +"""Implement the auth feature from Hass.io for Add-ons.""" +from ipaddress import ip_address +import logging +import os + +from aiohttp import web +from aiohttp.web_exceptions import HTTPForbidden, HTTPNotFound +import voluptuous as vol + +from homeassistant.components.http import HomeAssistantView +from homeassistant.components.http.const import KEY_REAL_IP +from homeassistant.components.http.data_validator import RequestDataValidator +from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import HomeAssistantType + +from .const import ATTR_ADDON, ATTR_PASSWORD, ATTR_USERNAME + +_LOGGER = logging.getLogger(__name__) + + +SCHEMA_API_AUTH = vol.Schema( + { + vol.Required(ATTR_USERNAME): cv.string, + vol.Required(ATTR_PASSWORD): cv.string, + vol.Required(ATTR_ADDON): cv.string, + }, + extra=vol.ALLOW_EXTRA, +) + + +@callback +def async_setup_auth_view(hass: HomeAssistantType): + """Auth setup.""" + hassio_auth = HassIOAuth(hass) + hass.http.register_view(hassio_auth) + + +class HassIOAuth(HomeAssistantView): + """Hass.io view to handle base part.""" + + name = "api:hassio_auth" + url = "/api/hassio_auth" + + def __init__(self, hass): + """Initialize WebView.""" + self.hass = hass + + @RequestDataValidator(SCHEMA_API_AUTH) + async def post(self, request, data): + """Handle new discovery requests.""" + hassio_ip = os.environ["HASSIO"].split(":")[0] + if request[KEY_REAL_IP] != ip_address(hassio_ip): + _LOGGER.error("Invalid auth request from %s", request[KEY_REAL_IP]) + raise HTTPForbidden() + + await self._check_login(data[ATTR_USERNAME], data[ATTR_PASSWORD]) + return web.Response(status=200) + + def _get_provider(self): + """Return Homeassistant auth provider.""" + prv = self.hass.auth.get_auth_provider("homeassistant", None) + if prv is not None: + return prv + + _LOGGER.error("Can't find Home Assistant auth.") + raise HTTPNotFound() + + async def _check_login(self, username, password): + """Check User credentials.""" + provider = self._get_provider() + + try: + await provider.async_validate_login(username, password) + except HomeAssistantError: + raise HTTPForbidden() from None diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py new file mode 100644 index 000000000..ffccb3253 --- /dev/null +++ b/homeassistant/components/hassio/const.py @@ -0,0 +1,21 @@ +"""Hass.io const variables.""" + +ATTR_ADDONS = "addons" +ATTR_DISCOVERY = "discovery" +ATTR_ADDON = "addon" +ATTR_NAME = "name" +ATTR_SERVICE = "service" +ATTR_CONFIG = "config" +ATTR_UUID = "uuid" +ATTR_USERNAME = "username" +ATTR_PASSWORD = "password" +ATTR_PANELS = "panels" +ATTR_ENABLE = "enable" +ATTR_TITLE = "title" +ATTR_ICON = "icon" +ATTR_ADMIN = "admin" + +X_HASSIO = "X-Hassio-Key" +X_INGRESS_PATH = "X-Ingress-Path" +X_HASS_USER_ID = "X-Hass-User-ID" +X_HASS_IS_ADMIN = "X-Hass-Is-Admin" diff --git a/homeassistant/components/hassio/discovery.py b/homeassistant/components/hassio/discovery.py new file mode 100644 index 000000000..fc6efbe0e --- /dev/null +++ b/homeassistant/components/hassio/discovery.py @@ -0,0 +1,118 @@ +"""Implement the services discovery feature from Hass.io for Add-ons.""" +import asyncio +import logging + +from aiohttp import web +from aiohttp.web_exceptions import HTTPServiceUnavailable + +from homeassistant.components.http import HomeAssistantView +from homeassistant.const import EVENT_HOMEASSISTANT_START +from homeassistant.core import callback + +from .const import ( + ATTR_ADDON, + ATTR_CONFIG, + ATTR_DISCOVERY, + ATTR_NAME, + ATTR_SERVICE, + ATTR_UUID, +) +from .handler import HassioAPIError + +_LOGGER = logging.getLogger(__name__) + + +@callback +def async_setup_discovery_view(hass: HomeAssistantView, hassio): + """Discovery setup.""" + hassio_discovery = HassIODiscovery(hass, hassio) + hass.http.register_view(hassio_discovery) + + # Handle exists discovery messages + async def _async_discovery_start_handler(event): + """Process all exists discovery on startup.""" + try: + data = await hassio.retrieve_discovery_messages() + except HassioAPIError as err: + _LOGGER.error("Can't read discover info: %s", err) + return + + jobs = [ + hassio_discovery.async_process_new(discovery) + for discovery in data[ATTR_DISCOVERY] + ] + if jobs: + await asyncio.wait(jobs) + + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, _async_discovery_start_handler + ) + + +class HassIODiscovery(HomeAssistantView): + """Hass.io view to handle base part.""" + + name = "api:hassio_push:discovery" + url = "/api/hassio_push/discovery/{uuid}" + + def __init__(self, hass: HomeAssistantView, hassio): + """Initialize WebView.""" + self.hass = hass + self.hassio = hassio + + async def post(self, request, uuid): + """Handle new discovery requests.""" + # Fetch discovery data and prevent injections + try: + data = await self.hassio.get_discovery_message(uuid) + except HassioAPIError as err: + _LOGGER.error("Can't read discovey data: %s", err) + raise HTTPServiceUnavailable() from None + + await self.async_process_new(data) + return web.Response() + + async def delete(self, request, uuid): + """Handle remove discovery requests.""" + data = await request.json() + + await self.async_process_del(data) + return web.Response() + + async def async_process_new(self, data): + """Process add discovery entry.""" + service = data[ATTR_SERVICE] + config_data = data[ATTR_CONFIG] + + # Read additional Add-on info + try: + addon_info = await self.hassio.get_addon_info(data[ATTR_ADDON]) + except HassioAPIError as err: + _LOGGER.error("Can't read add-on info: %s", err) + return + config_data[ATTR_ADDON] = addon_info[ATTR_NAME] + + # Use config flow + await self.hass.config_entries.flow.async_init( + service, context={"source": "hassio"}, data=config_data + ) + + async def async_process_del(self, data): + """Process remove discovery entry.""" + service = data[ATTR_SERVICE] + uuid = data[ATTR_UUID] + + # Check if really deletet / prevent injections + try: + data = await self.hassio.get_discovery_message(uuid) + except HassioAPIError: + pass + else: + _LOGGER.warning("Retrieve wrong unload for %s", service) + return + + # Use config flow + for entry in self.hass.config_entries.async_entries(service): + if entry.source != "hassio": + continue + await self.hass.config_entries.async_remove(entry) diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index d75529a99..521344361 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -1,9 +1,4 @@ -""" -Exposes regular REST commands as services. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/hassio/ -""" +"""Handler for Hass.io.""" import asyncio import logging import os @@ -12,33 +7,44 @@ 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 + CONF_SERVER_HOST, + CONF_SERVER_PORT, + CONF_SSL_CERTIFICATE, +) +from homeassistant.const import SERVER_PORT + +from .const import X_HASSIO _LOGGER = logging.getLogger(__name__) -X_HASSIO = 'X-HASSIO-KEY' + +class HassioAPIError(RuntimeError): + """Return if a API trow a error.""" def _api_bool(funct): """Return a boolean.""" + async def _wrapper(*argv, **kwargs): """Wrap function.""" - data = await funct(*argv, **kwargs) - return data and data['result'] == "ok" + try: + data = await funct(*argv, **kwargs) + return data["result"] == "ok" + except HassioAPIError: + return False return _wrapper def _api_data(funct): """Return data of an api.""" + async def _wrapper(*argv, **kwargs): """Wrap function.""" data = await funct(*argv, **kwargs) - if data and data['result'] == "ok": - return data['data'] - return None + if data["result"] == "ok": + return data["data"] + raise HassioAPIError(data["message"]) return _wrapper @@ -58,7 +64,7 @@ class HassIO: This method return a coroutine. """ - return self.send_command("/supervisor/ping", method="get") + return self.send_command("/supervisor/ping", method="get", timeout=15) @_api_data def get_homeassistant_info(self): @@ -68,6 +74,22 @@ class HassIO: """ return self.send_command("/homeassistant/info", method="get") + @_api_data + def get_addon_info(self, addon): + """Return data for a Add-on. + + This method return a coroutine. + """ + return self.send_command(f"/addons/{addon}/info", method="get") + + @_api_data + def get_ingress_panels(self): + """Return data for Add-on ingress panels. + + This method return a coroutine. + """ + return self.send_command("/ingress/panels", method="get") + @_api_bool def restart_homeassistant(self): """Restart Home-Assistant container. @@ -84,62 +106,66 @@ class HassIO: """ return self.send_command("/homeassistant/stop") - def check_homeassistant_config(self): - """Check Home-Assistant config with Hass.io API. + @_api_data + def retrieve_discovery_messages(self): + """Return all discovery data from Hass.io API. This method return a coroutine. """ - return self.send_command("/homeassistant/check", timeout=300) + return self.send_command("/discovery", method="get") + + @_api_data + def get_discovery_message(self, uuid): + """Return a single discovery data message. + + This method return a coroutine. + """ + return self.send_command(f"/discovery/{uuid}", method="get") @_api_bool async def update_hass_api(self, http_config, refresh_token): """Update Home Assistant API data on Hass.io.""" 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, - 'refresh_token': refresh_token, + "ssl": CONF_SSL_CERTIFICATE in http_config, + "port": port, + "watchdog": True, + "refresh_token": refresh_token, } if CONF_SERVER_HOST in http_config: - options['watchdog'] = False + options["watchdog"] = False _LOGGER.warning("Don't use 'server_host' options with Hass.io") - return await self.send_command("/homeassistant/options", - payload=options) + return await self.send_command("/homeassistant/options", payload=options) @_api_bool - def update_hass_timezone(self, core_config): + def update_hass_timezone(self, timezone): """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) - }) + return self.send_command("/supervisor/options", payload={"timezone": timezone}) - @asyncio.coroutine - def send_command(self, command, method="post", payload=None, timeout=10): + async 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', "") - }) + with async_timeout.timeout(timeout): + request = await self.websession.request( + method, + f"http://{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 + _LOGGER.error("%s return code %d.", command, request.status) + raise HassioAPIError() - answer = yield from request.json() + answer = await request.json() return answer except asyncio.TimeoutError: @@ -148,4 +174,4 @@ class HassIO: except aiohttp.ClientError as err: _LOGGER.error("Client error on %s request %s", command, err) - return None + raise HassioAPIError() diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index 55cc7f547..ddb926921 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -1,45 +1,37 @@ -""" -Exposes regular REST commands as services. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/hassio/ -""" +"""HTTP Support for Hass.io.""" import asyncio import logging import os import re +from typing import Dict, Union -import async_timeout import aiohttp from aiohttp import web -from aiohttp.hdrs import CONTENT_TYPE +from aiohttp.hdrs import CONTENT_LENGTH, CONTENT_TYPE from aiohttp.web_exceptions import HTTPBadGateway +import async_timeout -from homeassistant.const import CONTENT_TYPE_TEXT_PLAIN from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView +from .const import X_HASS_IS_ADMIN, X_HASS_USER_ID, X_HASSIO + _LOGGER = logging.getLogger(__name__) -X_HASSIO = 'X-HASSIO-KEY' NO_TIMEOUT = re.compile( - r'^(?:' - r'|homeassistant/update' - r'|host/update' - r'|supervisor/update' - r'|addons/[^/]+/(?:update|install|rebuild)' - r'|snapshots/.+/full' - r'|snapshots/.+/partial' - r'|snapshots/[^/]+/(?:upload|download)' - r')$' + r"^(?:" + r"|homeassistant/update" + r"|hassos/update" + r"|hassos/update/cli" + r"|supervisor/update" + r"|addons/[^/]+/(?:update|install|rebuild)" + r"|snapshots/.+/full" + r"|snapshots/.+/partial" + r"|snapshots/[^/]+/(?:upload|download)" + r")$" ) -NO_AUTH = re.compile( - r'^(?:' - r'|app/.*' - r'|addons/[^/]+/logo' - r')$' -) +NO_AUTH = re.compile(r"^(?:" r"|app/.*" r"|addons/[^/]+/logo" r")$") class HassIOView(HomeAssistantView): @@ -49,53 +41,63 @@ class HassIOView(HomeAssistantView): url = "/api/hassio/{path:.+}" requires_auth = False - def __init__(self, host, websession): + def __init__(self, host: str, websession: aiohttp.ClientSession): """Initialize a Hass.io base view.""" self._host = host self._websession = websession - @asyncio.coroutine - def _handle(self, request, path): + async def _handle( + self, request: web.Request, path: str + ) -> Union[web.Response, web.StreamResponse]: """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) + return await self._command_proxy(path, request) get = _handle post = _handle - @asyncio.coroutine - def _command_proxy(self, path, request): + async def _command_proxy( + self, path: str, request: web.Request + ) -> Union[web.Response, web.StreamResponse]: """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'] + data = None + headers = _init_header(request) 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 + with async_timeout.timeout(10): + data = await request.read() method = getattr(self._websession, request.method.lower()) - client = yield from method( - "http://{}/{}".format(self._host, path), data=data, - headers=headers, timeout=read_timeout + client = await method( + f"http://{self._host}/{path}", + data=data, + headers=headers, + timeout=read_timeout, ) - return client + # Simple request + if int(client.headers.get(CONTENT_LENGTH, 0)) < 4194000: + # Return Response + body = await client.read() + return web.Response( + content_type=client.content_type, status=client.status, body=body + ) + + # Stream response + response = web.StreamResponse(status=client.status) + response.content_type = client.content_type + + await response.prepare(request) + async for data in client.content.iter_chunked(4096): + await response.write(data) + + return response except aiohttp.ClientError as err: _LOGGER.error("Client error on api %s request %s", path, err) @@ -106,35 +108,30 @@ class HassIOView(HomeAssistantView): 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 _init_header(request: web.Request) -> Dict[str, str]: + """Create initial header.""" + headers = { + X_HASSIO: os.environ.get("HASSIO_TOKEN", ""), + CONTENT_TYPE: request.content_type, + } + + # Add user data + user = request.get("hass_user") + if user is not None: + headers[X_HASS_USER_ID] = request["hass_user"].id + headers[X_HASS_IS_ADMIN] = str(int(request["hass_user"].is_admin)) + + return headers -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): +def _get_timeout(path: str) -> int: """Return timeout for a URL path.""" if NO_TIMEOUT.match(path): return 0 return 300 -def _need_auth(path): +def _need_auth(path: str) -> bool: """Return if a path need authentication.""" if NO_AUTH.match(path): return False diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py new file mode 100644 index 000000000..c69d20784 --- /dev/null +++ b/homeassistant/components/hassio/ingress.py @@ -0,0 +1,255 @@ +"""Hass.io Add-on ingress service.""" +import asyncio +from ipaddress import ip_address +import logging +import os +from typing import Dict, Union + +import aiohttp +from aiohttp import hdrs, web +from aiohttp.web_exceptions import HTTPBadGateway +from multidict import CIMultiDict + +from homeassistant.components.http import HomeAssistantView +from homeassistant.core import callback +from homeassistant.helpers.typing import HomeAssistantType + +from .const import X_HASSIO, X_INGRESS_PATH + +_LOGGER = logging.getLogger(__name__) + + +@callback +def async_setup_ingress_view(hass: HomeAssistantType, host: str): + """Auth setup.""" + websession = hass.helpers.aiohttp_client.async_get_clientsession() + + hassio_ingress = HassIOIngress(host, websession) + hass.http.register_view(hassio_ingress) + + +class HassIOIngress(HomeAssistantView): + """Hass.io view to handle base part.""" + + name = "api:hassio:ingress" + url = "/api/hassio_ingress/{token}/{path:.*}" + requires_auth = False + + def __init__(self, host: str, websession: aiohttp.ClientSession): + """Initialize a Hass.io ingress view.""" + self._host = host + self._websession = websession + + def _create_url(self, token: str, path: str) -> str: + """Create URL to service.""" + return f"http://{self._host}/ingress/{token}/{path}" + + async def _handle( + self, request: web.Request, token: str, path: str + ) -> Union[web.Response, web.StreamResponse, web.WebSocketResponse]: + """Route data to Hass.io ingress service.""" + try: + # Websocket + if _is_websocket(request): + return await self._handle_websocket(request, token, path) + + # Request + return await self._handle_request(request, token, path) + + except aiohttp.ClientError as err: + _LOGGER.debug("Ingress error with %s / %s: %s", token, path, err) + + raise HTTPBadGateway() from None + + get = _handle + post = _handle + put = _handle + delete = _handle + patch = _handle + options = _handle + + async def _handle_websocket( + self, request: web.Request, token: str, path: str + ) -> web.WebSocketResponse: + """Ingress route for websocket.""" + if hdrs.SEC_WEBSOCKET_PROTOCOL in request.headers: + req_protocols = [ + str(proto.strip()) + for proto in request.headers[hdrs.SEC_WEBSOCKET_PROTOCOL].split(",") + ] + else: + req_protocols = () + + ws_server = web.WebSocketResponse( + protocols=req_protocols, autoclose=False, autoping=False + ) + await ws_server.prepare(request) + + # Preparing + url = self._create_url(token, path) + source_header = _init_header(request, token) + + # Support GET query + if request.query_string: + url = f"{url}?{request.query_string}" + + # Start proxy + async with self._websession.ws_connect( + url, + headers=source_header, + protocols=req_protocols, + autoclose=False, + autoping=False, + ) as ws_client: + # Proxy requests + await asyncio.wait( + [ + _websocket_forward(ws_server, ws_client), + _websocket_forward(ws_client, ws_server), + ], + return_when=asyncio.FIRST_COMPLETED, + ) + + return ws_server + + async def _handle_request( + self, request: web.Request, token: str, path: str + ) -> Union[web.Response, web.StreamResponse]: + """Ingress route for request.""" + url = self._create_url(token, path) + data = await request.read() + source_header = _init_header(request, token) + + async with self._websession.request( + request.method, + url, + headers=source_header, + params=request.query, + allow_redirects=False, + data=data, + ) as result: + headers = _response_header(result) + + # Simple request + if ( + hdrs.CONTENT_LENGTH in result.headers + and int(result.headers.get(hdrs.CONTENT_LENGTH, 0)) < 4194000 + ): + # Return Response + body = await result.read() + return web.Response( + headers=headers, + status=result.status, + content_type=result.content_type, + body=body, + ) + + # Stream response + response = web.StreamResponse(status=result.status, headers=headers) + response.content_type = result.content_type + + try: + await response.prepare(request) + async for data in result.content.iter_chunked(4096): + await response.write(data) + + except (aiohttp.ClientError, aiohttp.ClientPayloadError) as err: + _LOGGER.debug("Stream error %s / %s: %s", token, path, err) + + return response + + +def _init_header( + request: web.Request, token: str +) -> Union[CIMultiDict, Dict[str, str]]: + """Create initial header.""" + headers = {} + + # filter flags + for name, value in request.headers.items(): + if name in ( + hdrs.CONTENT_LENGTH, + hdrs.CONTENT_ENCODING, + hdrs.SEC_WEBSOCKET_EXTENSIONS, + hdrs.SEC_WEBSOCKET_PROTOCOL, + hdrs.SEC_WEBSOCKET_VERSION, + hdrs.SEC_WEBSOCKET_KEY, + ): + continue + headers[name] = value + + # Inject token / cleanup later on Supervisor + headers[X_HASSIO] = os.environ.get("HASSIO_TOKEN", "") + + # Ingress information + headers[X_INGRESS_PATH] = f"/api/hassio_ingress/{token}" + + # Set X-Forwarded-For + forward_for = request.headers.get(hdrs.X_FORWARDED_FOR) + connected_ip = ip_address(request.transport.get_extra_info("peername")[0]) + if forward_for: + forward_for = f"{forward_for}, {connected_ip!s}" + else: + forward_for = f"{connected_ip!s}" + headers[hdrs.X_FORWARDED_FOR] = forward_for + + # Set X-Forwarded-Host + forward_host = request.headers.get(hdrs.X_FORWARDED_HOST) + if not forward_host: + forward_host = request.host + headers[hdrs.X_FORWARDED_HOST] = forward_host + + # Set X-Forwarded-Proto + forward_proto = request.headers.get(hdrs.X_FORWARDED_PROTO) + if not forward_proto: + forward_proto = request.url.scheme + headers[hdrs.X_FORWARDED_PROTO] = forward_proto + + return headers + + +def _response_header(response: aiohttp.ClientResponse) -> Dict[str, str]: + """Create response header.""" + headers = {} + + for name, value in response.headers.items(): + if name in ( + hdrs.TRANSFER_ENCODING, + hdrs.CONTENT_LENGTH, + hdrs.CONTENT_TYPE, + hdrs.CONTENT_ENCODING, + ): + continue + headers[name] = value + + return headers + + +def _is_websocket(request: web.Request) -> bool: + """Return True if request is a websocket.""" + headers = request.headers + + if ( + "upgrade" in headers.get(hdrs.CONNECTION, "").lower() + and headers.get(hdrs.UPGRADE, "").lower() == "websocket" + ): + return True + return False + + +async def _websocket_forward(ws_from, ws_to): + """Handle websocket message directly.""" + try: + async for msg in ws_from: + if msg.type == aiohttp.WSMsgType.TEXT: + await ws_to.send_str(msg.data) + elif msg.type == aiohttp.WSMsgType.BINARY: + await ws_to.send_bytes(msg.data) + elif msg.type == aiohttp.WSMsgType.PING: + await ws_to.ping() + elif msg.type == aiohttp.WSMsgType.PONG: + await ws_to.pong() + elif ws_to.closed: + await ws_to.close(code=ws_to.close_code, message=msg.extra) + except RuntimeError: + _LOGGER.debug("Ingress Websocket runtime error") diff --git a/homeassistant/components/hassio/manifest.json b/homeassistant/components/hassio/manifest.json new file mode 100644 index 000000000..23095064d --- /dev/null +++ b/homeassistant/components/hassio/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "hassio", + "name": "Hass.io", + "documentation": "https://www.home-assistant.io/hassio", + "requirements": [], + "dependencies": [ + "http", + "panel_custom" + ], + "codeowners": [ + "@home-assistant/hass-io" + ] +} diff --git a/homeassistant/components/hassio/services.yaml b/homeassistant/components/hassio/services.yaml new file mode 100644 index 000000000..30314c646 --- /dev/null +++ b/homeassistant/components/hassio/services.yaml @@ -0,0 +1,84 @@ +addon_install: + description: Install a Hass.io docker add-on. + fields: + addon: + description: The add-on slug. + example: core_ssh + version: + description: Optional or it will be use the latest version. + example: "0.2" + +addon_start: + description: Start a Hass.io docker add-on. + fields: + addon: + description: The add-on slug. + example: core_ssh + +addon_restart: + description: Restart a Hass.io docker add-on. + fields: + addon: + description: The add-on slug. + example: core_ssh + +addon_stdin: + description: Write data to a Hass.io docker add-on stdin . + fields: + addon: + description: The add-on slug. + example: core_ssh + +addon_stop: + description: Stop a Hass.io docker add-on. + fields: + addon: + description: The add-on slug. + example: core_ssh + +addon_uninstall: + description: Uninstall a Hass.io docker add-on. + fields: + addon: + description: The add-on slug. + example: core_ssh + +addon_update: + description: Update a Hass.io docker add-on. + fields: + addon: + description: The add-on slug. + example: core_ssh + version: + description: Optional or it will be use the latest version. + example: "0.2" + +homeassistant_update: + description: Update the Home Assistant docker image. + fields: + version: + description: Optional or it will be use the latest version. + example: 0.40.1 + +host_reboot: + description: Reboot the host system. + +host_shutdown: + description: Poweroff the host system. + +host_update: + description: Update the host system. + fields: + version: + description: Optional or it will be use the latest version. + example: "0.3" + +supervisor_reload: + description: Reload the Hass.io supervisor. + +supervisor_update: + description: Update the Hass.io supervisor. + fields: + version: + description: Optional or it will be use the latest version. + example: "0.3" diff --git a/homeassistant/components/haveibeenpwned/__init__.py b/homeassistant/components/haveibeenpwned/__init__.py new file mode 100644 index 000000000..adead4ec4 --- /dev/null +++ b/homeassistant/components/haveibeenpwned/__init__.py @@ -0,0 +1 @@ +"""The haveibeenpwned component.""" diff --git a/homeassistant/components/haveibeenpwned/manifest.json b/homeassistant/components/haveibeenpwned/manifest.json new file mode 100644 index 000000000..00c7b7a19 --- /dev/null +++ b/homeassistant/components/haveibeenpwned/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "haveibeenpwned", + "name": "Haveibeenpwned", + "documentation": "https://www.home-assistant.io/integrations/haveibeenpwned", + "requirements": [], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/haveibeenpwned/sensor.py b/homeassistant/components/haveibeenpwned/sensor.py new file mode 100644 index 000000000..a0f30dd1a --- /dev/null +++ b/homeassistant/components/haveibeenpwned/sensor.py @@ -0,0 +1,184 @@ +"""Support for haveibeenpwned (email breaches) sensor.""" +from datetime import timedelta +import logging + +from aiohttp.hdrs import USER_AGENT +import requests +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ATTR_ATTRIBUTION, CONF_API_KEY, CONF_EMAIL +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import track_point_in_time +from homeassistant.util import Throttle +import homeassistant.util.dt as dt_util + +_LOGGER = logging.getLogger(__name__) + +ATTRIBUTION = "Data provided by Have I Been Pwned (HIBP)" + +DATE_STR_FORMAT = "%Y-%m-%d %H:%M:%S" + +HA_USER_AGENT = "Home Assistant HaveIBeenPwned Sensor Component" + +MIN_TIME_BETWEEN_FORCED_UPDATES = timedelta(seconds=5) +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) + +URL = "https://haveibeenpwned.com/api/v3/breachedaccount/" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_EMAIL): vol.All(cv.ensure_list, [cv.string]), + vol.Required(CONF_API_KEY): cv.string, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the HaveIBeenPwned sensor.""" + emails = config.get(CONF_EMAIL) + api_key = config[CONF_API_KEY] + data = HaveIBeenPwnedData(emails, api_key) + + devices = [] + for email in emails: + devices.append(HaveIBeenPwnedSensor(data, email)) + + add_entities(devices) + + +class HaveIBeenPwnedSensor(Entity): + """Implementation of a HaveIBeenPwned sensor.""" + + def __init__(self, data, email): + """Initialize the HaveIBeenPwned sensor.""" + self._state = None + self._data = data + self._email = email + self._unit_of_measurement = "Breaches" + + @property + def name(self): + """Return the name of the sensor.""" + return f"Breaches {self._email}" + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self._unit_of_measurement + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @property + def device_state_attributes(self): + """Return the attributes of the sensor.""" + val = {ATTR_ATTRIBUTION: ATTRIBUTION} + if self._email not in self._data.data: + return val + + for idx, value in enumerate(self._data.data[self._email]): + tmpname = "breach {}".format(idx + 1) + tmpvalue = "{} {}".format( + value["Title"], + dt_util.as_local(dt_util.parse_datetime(value["AddedDate"])).strftime( + DATE_STR_FORMAT + ), + ) + val[tmpname] = tmpvalue + + return val + + async def async_added_to_hass(self): + """Get initial data.""" + # To make sure we get initial data for the sensors ignoring the normal + # throttle of 15 minutes but using an update throttle of 5 seconds + self.hass.async_add_executor_job(self.update_nothrottle) + + def update_nothrottle(self, dummy=None): + """Update sensor without throttle.""" + self._data.update_no_throttle() + + # Schedule a forced update 5 seconds in the future if the update above + # returned no data for this sensors email. This is mainly to make sure + # that we don't get HTTP Error "too many requests" and to have initial + # data after hass startup once we have the data it will update as + # normal using update + if self._email not in self._data.data: + track_point_in_time( + self.hass, + self.update_nothrottle, + dt_util.now() + MIN_TIME_BETWEEN_FORCED_UPDATES, + ) + return + + self._state = len(self._data.data[self._email]) + self.schedule_update_ha_state() + + def update(self): + """Update data and see if it contains data for our email.""" + self._data.update() + + if self._email in self._data.data: + self._state = len(self._data.data[self._email]) + + +class HaveIBeenPwnedData: + """Class for handling the data retrieval.""" + + def __init__(self, emails, api_key): + """Initialize the data object.""" + self._email_count = len(emails) + self._current_index = 0 + self.data = {} + self._email = emails[0] + self._emails = emails + self._api_key = api_key + + def set_next_email(self): + """Set the next email to be looked up.""" + self._current_index = (self._current_index + 1) % self._email_count + self._email = self._emails[self._current_index] + + def update_no_throttle(self): + """Get the data for a specific email.""" + self.update(no_throttle=True) + + @Throttle(MIN_TIME_BETWEEN_UPDATES, MIN_TIME_BETWEEN_FORCED_UPDATES) + def update(self, **kwargs): + """Get the latest data for current email from REST service.""" + try: + url = f"{URL}{self._email}?truncateResponse=false" + header = {USER_AGENT: HA_USER_AGENT, "hibp-api-key": self._api_key} + _LOGGER.debug("Checking for breaches for email: %s", self._email) + req = requests.get(url, headers=header, allow_redirects=True, timeout=5) + + except requests.exceptions.RequestException: + _LOGGER.error("Failed fetching data for %s", self._email) + return + + if req.status_code == 200: + self.data[self._email] = sorted( + req.json(), key=lambda k: k["AddedDate"], reverse=True + ) + + # Only goto next email if we had data so that + # the forced updates try this current email again + self.set_next_email() + + elif req.status_code == 404: + self.data[self._email] = [] + + # only goto next email if we had data so that + # the forced updates try this current email again + self.set_next_email() + + else: + _LOGGER.error( + "Failed fetching data for %s" "(HTTP Status_code = %d)", + self._email, + req.status_code, + ) diff --git a/homeassistant/components/hddtemp/__init__.py b/homeassistant/components/hddtemp/__init__.py new file mode 100644 index 000000000..121238df9 --- /dev/null +++ b/homeassistant/components/hddtemp/__init__.py @@ -0,0 +1 @@ +"""The hddtemp component.""" diff --git a/homeassistant/components/hddtemp/manifest.json b/homeassistant/components/hddtemp/manifest.json new file mode 100644 index 000000000..484886aff --- /dev/null +++ b/homeassistant/components/hddtemp/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "hddtemp", + "name": "Hddtemp", + "documentation": "https://www.home-assistant.io/integrations/hddtemp", + "requirements": [], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/hddtemp/sensor.py b/homeassistant/components/hddtemp/sensor.py new file mode 100644 index 000000000..a1052b044 --- /dev/null +++ b/homeassistant/components/hddtemp/sensor.py @@ -0,0 +1,137 @@ +"""Support for getting the disk temperature of a host.""" +from datetime import timedelta +import logging +import socket +from telnetlib import Telnet + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_DISKS, + CONF_HOST, + CONF_NAME, + CONF_PORT, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +ATTR_DEVICE = "device" +ATTR_MODEL = "model" + +DEFAULT_HOST = "localhost" +DEFAULT_PORT = 7634 +DEFAULT_NAME = "HD Temperature" +DEFAULT_TIMEOUT = 5 + +SCAN_INTERVAL = timedelta(minutes=1) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_DISKS, default=[]): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the HDDTemp sensor.""" + name = config.get(CONF_NAME) + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) + disks = config.get(CONF_DISKS) + + hddtemp = HddTempData(host, port) + hddtemp.update() + + if not disks: + disks = [next(iter(hddtemp.data)).split("|")[0]] + + dev = [] + for disk in disks: + dev.append(HddTempSensor(name, disk, hddtemp)) + + add_entities(dev, True) + + +class HddTempSensor(Entity): + """Representation of a HDDTemp sensor.""" + + def __init__(self, name, disk, hddtemp): + """Initialize a HDDTemp sensor.""" + self.hddtemp = hddtemp + self.disk = disk + self._name = f"{name} {disk}" + self._state = None + self._details = None + self._unit = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self._unit + + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + if self._details is not None: + return {ATTR_DEVICE: self._details[0], ATTR_MODEL: self._details[1]} + + def update(self): + """Get the latest data from HDDTemp daemon and updates the state.""" + self.hddtemp.update() + + if self.hddtemp.data and self.disk in self.hddtemp.data: + self._details = self.hddtemp.data[self.disk].split("|") + self._state = self._details[2] + if self._details is not None and self._details[3] == "F": + self._unit = TEMP_FAHRENHEIT + else: + self._unit = TEMP_CELSIUS + else: + self._state = None + + +class HddTempData: + """Get the latest data from HDDTemp and update the states.""" + + def __init__(self, host, port): + """Initialize the data object.""" + self.host = host + self.port = port + self.data = None + + def update(self): + """Get the latest data from HDDTemp running as daemon.""" + try: + connection = Telnet(host=self.host, port=self.port, timeout=DEFAULT_TIMEOUT) + data = ( + connection.read_all() + .decode("ascii") + .lstrip("|") + .rstrip("|") + .split("||") + ) + self.data = {data[i].split("|")[0]: data[i] for i in range(0, len(data), 1)} + except ConnectionRefusedError: + _LOGGER.error("HDDTemp is not available at %s:%s", self.host, self.port) + self.data = None + except socket.gaierror: + _LOGGER.error("HDDTemp host not found %s:%s", self.host, self.port) + self.data = None diff --git a/homeassistant/components/hdmi_cec.py b/homeassistant/components/hdmi_cec.py deleted file mode 100644 index b5d64f48d..000000000 --- a/homeassistant/components/hdmi_cec.py +++ /dev/null @@ -1,412 +0,0 @@ -""" -HDMI CEC component. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/hdmi_cec/ -""" -import logging -import multiprocessing -from collections import defaultdict -from functools import reduce - -import voluptuous as vol - -import homeassistant.helpers.config_validation as cv -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.const import (EVENT_HOMEASSISTANT_START, STATE_UNKNOWN, - EVENT_HOMEASSISTANT_STOP, STATE_ON, - STATE_OFF, CONF_DEVICES, CONF_PLATFORM, - STATE_PLAYING, STATE_IDLE, - STATE_PAUSED, CONF_HOST) -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import Entity - -REQUIREMENTS = ['pyCEC==0.4.13'] - -DOMAIN = 'hdmi_cec' - -_LOGGER = logging.getLogger(__name__) - -DEFAULT_DISPLAY_NAME = "HA" -CONF_TYPES = 'types' - -ICON_UNKNOWN = 'mdi:help' -ICON_AUDIO = 'mdi:speaker' -ICON_PLAYER = 'mdi:play' -ICON_TUNER = 'mdi:radio' -ICON_RECORDER = 'mdi:microphone' -ICON_TV = 'mdi:television' -ICONS_BY_TYPE = { - 0: ICON_TV, - 1: ICON_RECORDER, - 3: ICON_TUNER, - 4: ICON_PLAYER, - 5: ICON_AUDIO -} - -CEC_DEVICES = defaultdict(list) - -CMD_UP = 'up' -CMD_DOWN = 'down' -CMD_MUTE = 'mute' -CMD_UNMUTE = 'unmute' -CMD_MUTE_TOGGLE = 'toggle mute' -CMD_PRESS = 'press' -CMD_RELEASE = 'release' - -EVENT_CEC_COMMAND_RECEIVED = 'cec_command_received' -EVENT_CEC_KEYPRESS_RECEIVED = 'cec_keypress_received' - -ATTR_PHYSICAL_ADDRESS = 'physical_address' -ATTR_TYPE_ID = 'type_id' -ATTR_VENDOR_NAME = 'vendor_name' -ATTR_VENDOR_ID = 'vendor_id' -ATTR_DEVICE = 'device' -ATTR_TYPE = 'type' -ATTR_KEY = 'key' -ATTR_DUR = 'dur' -ATTR_SRC = 'src' -ATTR_DST = 'dst' -ATTR_CMD = 'cmd' -ATTR_ATT = 'att' -ATTR_RAW = 'raw' -ATTR_DIR = 'dir' -ATTR_ABT = 'abt' -ATTR_NEW = 'new' -ATTR_ON = 'on' -ATTR_OFF = 'off' -ATTR_TOGGLE = 'toggle' - -_VOL_HEX = vol.Any(vol.Coerce(int), lambda x: int(x, 16)) - -SERVICE_SEND_COMMAND = 'send_command' -SERVICE_SEND_COMMAND_SCHEMA = vol.Schema({ - vol.Optional(ATTR_CMD): _VOL_HEX, - vol.Optional(ATTR_SRC): _VOL_HEX, - vol.Optional(ATTR_DST): _VOL_HEX, - vol.Optional(ATTR_ATT): _VOL_HEX, - vol.Optional(ATTR_RAW): vol.Coerce(str) -}, extra=vol.PREVENT_EXTRA) - -SERVICE_VOLUME = 'volume' -SERVICE_VOLUME_SCHEMA = vol.Schema({ - vol.Optional(CMD_UP): vol.Any(CMD_PRESS, CMD_RELEASE, vol.Coerce(int)), - vol.Optional(CMD_DOWN): vol.Any(CMD_PRESS, CMD_RELEASE, vol.Coerce(int)), - vol.Optional(CMD_MUTE): vol.Any(ATTR_ON, ATTR_OFF, ATTR_TOGGLE), -}, extra=vol.PREVENT_EXTRA) - -SERVICE_UPDATE_DEVICES = 'update' -SERVICE_UPDATE_DEVICES_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({}) -}, extra=vol.PREVENT_EXTRA) - -SERVICE_SELECT_DEVICE = 'select_device' - -SERVICE_POWER_ON = 'power_on' -SERVICE_STANDBY = 'standby' - -# pylint: disable=unnecessary-lambda -DEVICE_SCHEMA = vol.Schema({ - vol.All(cv.positive_int): - vol.Any(lambda devices: DEVICE_SCHEMA(devices), cv.string) -}) - -CONF_DISPLAY_NAME = 'osd_name' - -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - vol.Optional(CONF_DEVICES): - vol.Any(DEVICE_SCHEMA, vol.Schema({ - vol.All(cv.string): vol.Any(cv.string)})), - vol.Optional(CONF_PLATFORM): vol.Any(SWITCH, MEDIA_PLAYER), - vol.Optional(CONF_HOST): cv.string, - vol.Optional(CONF_DISPLAY_NAME): cv.string, - vol.Optional(CONF_TYPES, default={}): - vol.Schema({cv.entity_id: vol.Any(MEDIA_PLAYER, SWITCH)}) - }) -}, extra=vol.ALLOW_EXTRA) - - -def pad_physical_address(addr): - """Right-pad a physical address.""" - return addr + [0] * (4 - len(addr)) - - -def parse_mapping(mapping, parents=None): - """Parse configuration device mapping.""" - if parents is None: - parents = [] - for addr, val in mapping.items(): - if isinstance(addr, (str,)) and isinstance(val, (str,)): - from pycec.network import PhysicalAddress - yield (addr, PhysicalAddress(val)) - else: - cur = parents + [addr] - if isinstance(val, dict): - yield from parse_mapping(val, cur) - elif isinstance(val, str): - yield (val, pad_physical_address(cur)) - - -def setup(hass: HomeAssistant, base_config): - """Set up the CEC capability.""" - from pycec.network import HDMINetwork - from pycec.commands import CecCommand, KeyReleaseCommand, KeyPressCommand - from pycec.const import KEY_VOLUME_UP, KEY_VOLUME_DOWN, KEY_MUTE_ON, \ - KEY_MUTE_OFF, KEY_MUTE_TOGGLE, ADDR_AUDIOSYSTEM, ADDR_BROADCAST, \ - ADDR_UNREGISTERED - from pycec.cec import CecAdapter - from pycec.tcp import TcpAdapter - - # Parse configuration into a dict of device name to physical address - # represented as a list of four elements. - device_aliases = {} - devices = base_config[DOMAIN].get(CONF_DEVICES, {}) - _LOGGER.debug("Parsing config %s", devices) - device_aliases.update(parse_mapping(devices)) - _LOGGER.debug("Parsed devices: %s", device_aliases) - - platform = base_config[DOMAIN].get(CONF_PLATFORM, SWITCH) - - loop = ( - # Create own thread if more than 1 CPU - hass.loop if multiprocessing.cpu_count() < 2 else None) - host = base_config[DOMAIN].get(CONF_HOST, None) - display_name = base_config[DOMAIN].get( - CONF_DISPLAY_NAME, DEFAULT_DISPLAY_NAME) - if host: - adapter = TcpAdapter(host, name=display_name, activate_source=False) - else: - adapter = CecAdapter(name=display_name[:12], activate_source=False) - hdmi_network = HDMINetwork(adapter, loop=loop) - - def _volume(call): - """Increase/decrease volume and mute/unmute system.""" - mute_key_mapping = {ATTR_TOGGLE: KEY_MUTE_TOGGLE, ATTR_ON: KEY_MUTE_ON, - ATTR_OFF: KEY_MUTE_OFF} - for cmd, att in call.data.items(): - if cmd == CMD_UP: - _process_volume(KEY_VOLUME_UP, att) - elif cmd == CMD_DOWN: - _process_volume(KEY_VOLUME_DOWN, att) - elif cmd == CMD_MUTE: - hdmi_network.send_command( - KeyPressCommand(mute_key_mapping[att], - dst=ADDR_AUDIOSYSTEM)) - hdmi_network.send_command( - KeyReleaseCommand(dst=ADDR_AUDIOSYSTEM)) - _LOGGER.info("Audio muted") - else: - _LOGGER.warning("Unknown command %s", cmd) - - def _process_volume(cmd, att): - if isinstance(att, (str,)): - att = att.strip() - if att == CMD_PRESS: - hdmi_network.send_command( - KeyPressCommand(cmd, dst=ADDR_AUDIOSYSTEM)) - elif att == CMD_RELEASE: - hdmi_network.send_command(KeyReleaseCommand(dst=ADDR_AUDIOSYSTEM)) - else: - att = 1 if att == "" else int(att) - for _ in range(0, att): - hdmi_network.send_command( - KeyPressCommand(cmd, dst=ADDR_AUDIOSYSTEM)) - hdmi_network.send_command( - KeyReleaseCommand(dst=ADDR_AUDIOSYSTEM)) - - def _tx(call): - """Send CEC command.""" - data = call.data - if ATTR_RAW in data: - command = CecCommand(data[ATTR_RAW]) - else: - if ATTR_SRC in data: - src = data[ATTR_SRC] - else: - src = ADDR_UNREGISTERED - if ATTR_DST in data: - dst = data[ATTR_DST] - else: - dst = ADDR_BROADCAST - if ATTR_CMD in data: - cmd = data[ATTR_CMD] - else: - _LOGGER.error("Attribute 'cmd' is missing") - return False - if ATTR_ATT in data: - if isinstance(data[ATTR_ATT], (list,)): - att = data[ATTR_ATT] - else: - att = reduce(lambda x, y: "%s:%x" % (x, y), data[ATTR_ATT]) - else: - att = "" - command = CecCommand(cmd, dst, src, att) - hdmi_network.send_command(command) - - def _standby(call): - hdmi_network.standby() - - def _power_on(call): - hdmi_network.power_on() - - def _select_device(call): - """Select the active device.""" - from pycec.network import PhysicalAddress - - addr = call.data[ATTR_DEVICE] - if not addr: - _LOGGER.error("Device not found: %s", call.data[ATTR_DEVICE]) - return - if addr in device_aliases: - addr = device_aliases[addr] - else: - entity = hass.states.get(addr) - _LOGGER.debug("Selecting entity %s", entity) - if entity is not None: - addr = entity.attributes['physical_address'] - _LOGGER.debug("Address acquired: %s", addr) - if addr is None: - _LOGGER.error("Device %s has not physical address", - call.data[ATTR_DEVICE]) - return - if not isinstance(addr, (PhysicalAddress,)): - addr = PhysicalAddress(addr) - hdmi_network.active_source(addr) - _LOGGER.info("Selected %s (%s)", call.data[ATTR_DEVICE], addr) - - def _update(call): - """ - Update if device update is needed. - - Called by service, requests CEC network to update data. - """ - hdmi_network.scan() - - def _new_device(device): - """Handle new devices which are detected by HDMI network.""" - key = '{}.{}'.format(DOMAIN, device.name) - hass.data[key] = device - ent_platform = base_config[DOMAIN][CONF_TYPES].get(key, platform) - discovery.load_platform( - hass, ent_platform, DOMAIN, discovered={ATTR_NEW: [key]}, - hass_config=base_config) - - def _shutdown(call): - hdmi_network.stop() - - def _start_cec(event): - """Register services and start HDMI network to watch for devices.""" - hass.services.register(DOMAIN, SERVICE_SEND_COMMAND, _tx, - SERVICE_SEND_COMMAND_SCHEMA) - hass.services.register(DOMAIN, SERVICE_VOLUME, _volume, - schema=SERVICE_VOLUME_SCHEMA) - hass.services.register(DOMAIN, SERVICE_UPDATE_DEVICES, _update, - 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) - - hdmi_network.set_new_device_callback(_new_device) - hdmi_network.start() - - hass.bus.listen_once(EVENT_HOMEASSISTANT_START, _start_cec) - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown) - return True - - -class CecDevice(Entity): - """Representation of a HDMI CEC device entity.""" - - def __init__(self, hass: HomeAssistant, device, logical) -> None: - """Initialize the device.""" - self._device = device - self.hass = hass - self._icon = None - self._state = STATE_UNKNOWN - self._logical_address = logical - self.entity_id = "%s.%d" % (DOMAIN, self._logical_address) - device.set_update_callback(self._update) - - def update(self): - """Update device status.""" - self._update() - - def _update(self, device=None): - """Update device status.""" - if device: - from pycec.const import STATUS_PLAY, STATUS_STOP, STATUS_STILL, \ - POWER_OFF, POWER_ON - if device.power_status == POWER_OFF: - self._state = STATE_OFF - elif device.status == STATUS_PLAY: - self._state = STATE_PLAYING - elif device.status == STATUS_STOP: - self._state = STATE_IDLE - elif device.status == STATUS_STILL: - self._state = STATE_PAUSED - elif device.power_status == POWER_ON: - self._state = STATE_ON - else: - _LOGGER.warning("Unknown state: %d", device.power_status) - self.schedule_update_ha_state() - - @property - def name(self): - """Return the name of the device.""" - return ( - "%s %s" % (self.vendor_name, self._device.osd_name) - if (self._device.osd_name is not None and - self.vendor_name is not None and self.vendor_name != 'Unknown') - else "%s %d" % (self._device.type_name, self._logical_address) - if self._device.osd_name is None - else "%s %d (%s)" % (self._device.type_name, self._logical_address, - self._device.osd_name)) - - @property - def vendor_id(self): - """Return the ID of the device's vendor.""" - return self._device.vendor_id - - @property - def vendor_name(self): - """Return the name of the device's vendor.""" - return self._device.vendor - - @property - def physical_address(self): - """Return the physical address of device in HDMI network.""" - return str(self._device.physical_address) - - @property - def type(self): - """Return a string representation of the device's type.""" - return self._device.type_name - - @property - def type_id(self): - """Return the type ID of device.""" - return self._device.type - - @property - def icon(self): - """Return the icon for device by its type.""" - return (self._icon if self._icon is not None else - ICONS_BY_TYPE.get(self._device.type) - if self._device.type in ICONS_BY_TYPE else ICON_UNKNOWN) - - @property - def device_state_attributes(self): - """Return the state attributes.""" - state_attr = {} - if self.vendor_id is not None: - state_attr[ATTR_VENDOR_ID] = self.vendor_id - state_attr[ATTR_VENDOR_NAME] = self.vendor_name - if self.type_id is not None: - state_attr[ATTR_TYPE_ID] = self.type_id - state_attr[ATTR_TYPE] = self.type - if self.physical_address is not None: - state_attr[ATTR_PHYSICAL_ADDRESS] = self.physical_address - return state_attr diff --git a/homeassistant/components/hdmi_cec/__init__.py b/homeassistant/components/hdmi_cec/__init__.py new file mode 100644 index 000000000..b46002054 --- /dev/null +++ b/homeassistant/components/hdmi_cec/__init__.py @@ -0,0 +1,455 @@ +"""Support for HDMI CEC.""" +from collections import defaultdict +from functools import reduce +import logging +import multiprocessing + +from pycec.cec import CecAdapter +from pycec.commands import CecCommand, KeyPressCommand, KeyReleaseCommand +from pycec.const import ( + ADDR_AUDIOSYSTEM, + ADDR_BROADCAST, + ADDR_UNREGISTERED, + KEY_MUTE_OFF, + KEY_MUTE_ON, + KEY_MUTE_TOGGLE, + KEY_VOLUME_DOWN, + KEY_VOLUME_UP, + POWER_OFF, + POWER_ON, + STATUS_PLAY, + STATUS_STILL, + STATUS_STOP, +) +from pycec.network import HDMINetwork, PhysicalAddress +from pycec.tcp import TcpAdapter +import voluptuous as vol + +from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER +from homeassistant.components.switch import DOMAIN as SWITCH +from homeassistant.const import ( + CONF_DEVICES, + CONF_HOST, + CONF_PLATFORM, + EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP, + STATE_IDLE, + STATE_OFF, + STATE_ON, + STATE_PAUSED, + STATE_PLAYING, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +DOMAIN = "hdmi_cec" + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_DISPLAY_NAME = "HA" +CONF_TYPES = "types" + +ICON_UNKNOWN = "mdi:help" +ICON_AUDIO = "mdi:speaker" +ICON_PLAYER = "mdi:play" +ICON_TUNER = "mdi:radio" +ICON_RECORDER = "mdi:microphone" +ICON_TV = "mdi:television" +ICONS_BY_TYPE = { + 0: ICON_TV, + 1: ICON_RECORDER, + 3: ICON_TUNER, + 4: ICON_PLAYER, + 5: ICON_AUDIO, +} + +CEC_DEVICES = defaultdict(list) + +CMD_UP = "up" +CMD_DOWN = "down" +CMD_MUTE = "mute" +CMD_UNMUTE = "unmute" +CMD_MUTE_TOGGLE = "toggle mute" +CMD_PRESS = "press" +CMD_RELEASE = "release" + +EVENT_CEC_COMMAND_RECEIVED = "cec_command_received" +EVENT_CEC_KEYPRESS_RECEIVED = "cec_keypress_received" + +ATTR_PHYSICAL_ADDRESS = "physical_address" +ATTR_TYPE_ID = "type_id" +ATTR_VENDOR_NAME = "vendor_name" +ATTR_VENDOR_ID = "vendor_id" +ATTR_DEVICE = "device" +ATTR_TYPE = "type" +ATTR_KEY = "key" +ATTR_DUR = "dur" +ATTR_SRC = "src" +ATTR_DST = "dst" +ATTR_CMD = "cmd" +ATTR_ATT = "att" +ATTR_RAW = "raw" +ATTR_DIR = "dir" +ATTR_ABT = "abt" +ATTR_NEW = "new" +ATTR_ON = "on" +ATTR_OFF = "off" +ATTR_TOGGLE = "toggle" + +_VOL_HEX = vol.Any(vol.Coerce(int), lambda x: int(x, 16)) + +SERVICE_SEND_COMMAND = "send_command" +SERVICE_SEND_COMMAND_SCHEMA = vol.Schema( + { + vol.Optional(ATTR_CMD): _VOL_HEX, + vol.Optional(ATTR_SRC): _VOL_HEX, + vol.Optional(ATTR_DST): _VOL_HEX, + vol.Optional(ATTR_ATT): _VOL_HEX, + vol.Optional(ATTR_RAW): vol.Coerce(str), + }, + extra=vol.PREVENT_EXTRA, +) + +SERVICE_VOLUME = "volume" +SERVICE_VOLUME_SCHEMA = vol.Schema( + { + vol.Optional(CMD_UP): vol.Any(CMD_PRESS, CMD_RELEASE, vol.Coerce(int)), + vol.Optional(CMD_DOWN): vol.Any(CMD_PRESS, CMD_RELEASE, vol.Coerce(int)), + vol.Optional(CMD_MUTE): vol.Any(ATTR_ON, ATTR_OFF, ATTR_TOGGLE), + }, + extra=vol.PREVENT_EXTRA, +) + +SERVICE_UPDATE_DEVICES = "update" +SERVICE_UPDATE_DEVICES_SCHEMA = vol.Schema( + {DOMAIN: vol.Schema({})}, extra=vol.PREVENT_EXTRA +) + +SERVICE_SELECT_DEVICE = "select_device" + +SERVICE_POWER_ON = "power_on" +SERVICE_STANDBY = "standby" + +# pylint: disable=unnecessary-lambda +DEVICE_SCHEMA = vol.Schema( + { + vol.All(cv.positive_int): vol.Any( + lambda devices: DEVICE_SCHEMA(devices), cv.string + ) + } +) + +CONF_DISPLAY_NAME = "osd_name" + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Optional(CONF_DEVICES): vol.Any( + DEVICE_SCHEMA, vol.Schema({vol.All(cv.string): vol.Any(cv.string)}) + ), + vol.Optional(CONF_PLATFORM): vol.Any(SWITCH, MEDIA_PLAYER), + vol.Optional(CONF_HOST): cv.string, + vol.Optional(CONF_DISPLAY_NAME): cv.string, + vol.Optional(CONF_TYPES, default={}): vol.Schema( + {cv.entity_id: vol.Any(MEDIA_PLAYER, SWITCH)} + ), + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +def pad_physical_address(addr): + """Right-pad a physical address.""" + return addr + [0] * (4 - len(addr)) + + +def parse_mapping(mapping, parents=None): + """Parse configuration device mapping.""" + if parents is None: + parents = [] + for addr, val in mapping.items(): + if isinstance(addr, (str,)) and isinstance(val, (str,)): + yield (addr, PhysicalAddress(val)) + else: + cur = parents + [addr] + if isinstance(val, dict): + yield from parse_mapping(val, cur) + elif isinstance(val, str): + yield (val, pad_physical_address(cur)) + + +def setup(hass: HomeAssistant, base_config): + """Set up the CEC capability.""" + + # Parse configuration into a dict of device name to physical address + # represented as a list of four elements. + device_aliases = {} + devices = base_config[DOMAIN].get(CONF_DEVICES, {}) + _LOGGER.debug("Parsing config %s", devices) + device_aliases.update(parse_mapping(devices)) + _LOGGER.debug("Parsed devices: %s", device_aliases) + + platform = base_config[DOMAIN].get(CONF_PLATFORM, SWITCH) + + loop = ( + # Create own thread if more than 1 CPU + hass.loop + if multiprocessing.cpu_count() < 2 + else None + ) + host = base_config[DOMAIN].get(CONF_HOST, None) + display_name = base_config[DOMAIN].get(CONF_DISPLAY_NAME, DEFAULT_DISPLAY_NAME) + if host: + adapter = TcpAdapter(host, name=display_name, activate_source=False) + else: + adapter = CecAdapter(name=display_name[:12], activate_source=False) + hdmi_network = HDMINetwork(adapter, loop=loop) + + def _volume(call): + """Increase/decrease volume and mute/unmute system.""" + mute_key_mapping = { + ATTR_TOGGLE: KEY_MUTE_TOGGLE, + ATTR_ON: KEY_MUTE_ON, + ATTR_OFF: KEY_MUTE_OFF, + } + for cmd, att in call.data.items(): + if cmd == CMD_UP: + _process_volume(KEY_VOLUME_UP, att) + elif cmd == CMD_DOWN: + _process_volume(KEY_VOLUME_DOWN, att) + elif cmd == CMD_MUTE: + hdmi_network.send_command( + KeyPressCommand(mute_key_mapping[att], dst=ADDR_AUDIOSYSTEM) + ) + hdmi_network.send_command(KeyReleaseCommand(dst=ADDR_AUDIOSYSTEM)) + _LOGGER.info("Audio muted") + else: + _LOGGER.warning("Unknown command %s", cmd) + + def _process_volume(cmd, att): + if isinstance(att, (str,)): + att = att.strip() + if att == CMD_PRESS: + hdmi_network.send_command(KeyPressCommand(cmd, dst=ADDR_AUDIOSYSTEM)) + elif att == CMD_RELEASE: + hdmi_network.send_command(KeyReleaseCommand(dst=ADDR_AUDIOSYSTEM)) + else: + att = 1 if att == "" else int(att) + for _ in range(0, att): + hdmi_network.send_command(KeyPressCommand(cmd, dst=ADDR_AUDIOSYSTEM)) + hdmi_network.send_command(KeyReleaseCommand(dst=ADDR_AUDIOSYSTEM)) + + def _tx(call): + """Send CEC command.""" + data = call.data + if ATTR_RAW in data: + command = CecCommand(data[ATTR_RAW]) + else: + if ATTR_SRC in data: + src = data[ATTR_SRC] + else: + src = ADDR_UNREGISTERED + if ATTR_DST in data: + dst = data[ATTR_DST] + else: + dst = ADDR_BROADCAST + if ATTR_CMD in data: + cmd = data[ATTR_CMD] + else: + _LOGGER.error("Attribute 'cmd' is missing") + return False + if ATTR_ATT in data: + if isinstance(data[ATTR_ATT], (list,)): + att = data[ATTR_ATT] + else: + att = reduce(lambda x, y: f"{x}:{y:x}", data[ATTR_ATT]) + else: + att = "" + command = CecCommand(cmd, dst, src, att) + hdmi_network.send_command(command) + + def _standby(call): + hdmi_network.standby() + + def _power_on(call): + hdmi_network.power_on() + + def _select_device(call): + """Select the active device.""" + addr = call.data[ATTR_DEVICE] + if not addr: + _LOGGER.error("Device not found: %s", call.data[ATTR_DEVICE]) + return + if addr in device_aliases: + addr = device_aliases[addr] + else: + entity = hass.states.get(addr) + _LOGGER.debug("Selecting entity %s", entity) + if entity is not None: + addr = entity.attributes["physical_address"] + _LOGGER.debug("Address acquired: %s", addr) + if addr is None: + _LOGGER.error( + "Device %s has not physical address", call.data[ATTR_DEVICE] + ) + return + if not isinstance(addr, (PhysicalAddress,)): + addr = PhysicalAddress(addr) + hdmi_network.active_source(addr) + _LOGGER.info("Selected %s (%s)", call.data[ATTR_DEVICE], addr) + + def _update(call): + """ + Update if device update is needed. + + Called by service, requests CEC network to update data. + """ + hdmi_network.scan() + + def _new_device(device): + """Handle new devices which are detected by HDMI network.""" + key = f"{DOMAIN}.{device.name}" + hass.data[key] = device + ent_platform = base_config[DOMAIN][CONF_TYPES].get(key, platform) + discovery.load_platform( + hass, + ent_platform, + DOMAIN, + discovered={ATTR_NEW: [key]}, + hass_config=base_config, + ) + + def _shutdown(call): + hdmi_network.stop() + + def _start_cec(event): + """Register services and start HDMI network to watch for devices.""" + hass.services.register( + DOMAIN, SERVICE_SEND_COMMAND, _tx, SERVICE_SEND_COMMAND_SCHEMA + ) + hass.services.register( + DOMAIN, SERVICE_VOLUME, _volume, schema=SERVICE_VOLUME_SCHEMA + ) + hass.services.register( + DOMAIN, + SERVICE_UPDATE_DEVICES, + _update, + 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) + + hdmi_network.set_new_device_callback(_new_device) + hdmi_network.start() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, _start_cec) + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown) + return True + + +class CecDevice(Entity): + """Representation of a HDMI CEC device entity.""" + + def __init__(self, device, logical) -> None: + """Initialize the device.""" + self._device = device + self._icon = None + self._state = None + self._logical_address = logical + self.entity_id = "%s.%d" % (DOMAIN, self._logical_address) + + def update(self): + """Update device status.""" + device = self._device + if device.power_status in [POWER_OFF, 3]: + self._state = STATE_OFF + elif device.status == STATUS_PLAY: + self._state = STATE_PLAYING + elif device.status == STATUS_STOP: + self._state = STATE_IDLE + elif device.status == STATUS_STILL: + self._state = STATE_PAUSED + elif device.power_status in [POWER_ON, 4]: + self._state = STATE_ON + else: + _LOGGER.warning("Unknown state: %d", device.power_status) + + async def async_added_to_hass(self): + """Register HDMI callbacks after initialization.""" + self._device.set_update_callback(self._update) + + def _update(self, device=None): + """Device status changed, schedule an update.""" + self.schedule_update_ha_state(True) + + @property + def name(self): + """Return the name of the device.""" + return ( + f"{self.vendor_name} {self._device.osd_name}" + if ( + self._device.osd_name is not None + and self.vendor_name is not None + and self.vendor_name != "Unknown" + ) + else "%s %d" % (self._device.type_name, self._logical_address) + if self._device.osd_name is None + else "%s %d (%s)" + % (self._device.type_name, self._logical_address, self._device.osd_name) + ) + + @property + def vendor_id(self): + """Return the ID of the device's vendor.""" + return self._device.vendor_id + + @property + def vendor_name(self): + """Return the name of the device's vendor.""" + return self._device.vendor + + @property + def physical_address(self): + """Return the physical address of device in HDMI network.""" + return str(self._device.physical_address) + + @property + def type(self): + """Return a string representation of the device's type.""" + return self._device.type_name + + @property + def type_id(self): + """Return the type ID of device.""" + return self._device.type + + @property + def icon(self): + """Return the icon for device by its type.""" + return ( + self._icon + if self._icon is not None + else ICONS_BY_TYPE.get(self._device.type) + if self._device.type in ICONS_BY_TYPE + else ICON_UNKNOWN + ) + + @property + def device_state_attributes(self): + """Return the state attributes.""" + state_attr = {} + if self.vendor_id is not None: + state_attr[ATTR_VENDOR_ID] = self.vendor_id + state_attr[ATTR_VENDOR_NAME] = self.vendor_name + if self.type_id is not None: + state_attr[ATTR_TYPE_ID] = self.type_id + state_attr[ATTR_TYPE] = self.type + if self.physical_address is not None: + state_attr[ATTR_PHYSICAL_ADDRESS] = self.physical_address + return state_attr diff --git a/homeassistant/components/hdmi_cec/manifest.json b/homeassistant/components/hdmi_cec/manifest.json new file mode 100644 index 000000000..7c877f5c2 --- /dev/null +++ b/homeassistant/components/hdmi_cec/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "hdmi_cec", + "name": "Hdmi cec", + "documentation": "https://www.home-assistant.io/integrations/hdmi_cec", + "requirements": [ + "pyCEC==0.4.13" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/hdmi_cec/media_player.py b/homeassistant/components/hdmi_cec/media_player.py new file mode 100644 index 000000000..42c5f0b45 --- /dev/null +++ b/homeassistant/components/hdmi_cec/media_player.py @@ -0,0 +1,201 @@ +"""Support for HDMI CEC devices as media players.""" +import logging + +from pycec.commands import CecCommand, KeyPressCommand, KeyReleaseCommand +from pycec.const import ( + KEY_BACKWARD, + KEY_FORWARD, + KEY_MUTE_TOGGLE, + KEY_PAUSE, + KEY_PLAY, + KEY_STOP, + KEY_VOLUME_DOWN, + KEY_VOLUME_UP, + POWER_OFF, + POWER_ON, + STATUS_PLAY, + STATUS_STILL, + STATUS_STOP, + TYPE_AUDIO, + TYPE_PLAYBACK, + TYPE_RECORDER, + TYPE_TUNER, +) + +from homeassistant.components.media_player import MediaPlayerDevice +from homeassistant.components.media_player.const import ( + DOMAIN, + SUPPORT_NEXT_TRACK, + SUPPORT_PAUSE, + SUPPORT_PLAY_MEDIA, + SUPPORT_PREVIOUS_TRACK, + SUPPORT_STOP, + SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, + SUPPORT_VOLUME_MUTE, + SUPPORT_VOLUME_STEP, +) +from homeassistant.const import ( + STATE_IDLE, + STATE_OFF, + STATE_ON, + STATE_PAUSED, + STATE_PLAYING, +) + +from . import ATTR_NEW, CecDevice + +_LOGGER = logging.getLogger(__name__) + +ENTITY_ID_FORMAT = DOMAIN + ".{}" + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Find and return HDMI devices as +switches.""" + if ATTR_NEW in discovery_info: + _LOGGER.debug("Setting up HDMI devices %s", discovery_info[ATTR_NEW]) + entities = [] + for device in discovery_info[ATTR_NEW]: + hdmi_device = hass.data.get(device) + entities.append(CecPlayerDevice(hdmi_device, hdmi_device.logical_address)) + add_entities(entities, True) + + +class CecPlayerDevice(CecDevice, MediaPlayerDevice): + """Representation of a HDMI device as a Media player.""" + + def __init__(self, device, logical) -> None: + """Initialize the HDMI device.""" + CecDevice.__init__(self, device, logical) + self.entity_id = "%s.%s_%s" % (DOMAIN, "hdmi", hex(self._logical_address)[2:]) + + def send_keypress(self, key): + """Send keypress to CEC adapter.""" + _LOGGER.debug( + "Sending keypress %s to device %s", hex(key), hex(self._logical_address) + ) + self._device.send_command(KeyPressCommand(key, dst=self._logical_address)) + self._device.send_command(KeyReleaseCommand(dst=self._logical_address)) + + def send_playback(self, key): + """Send playback status to CEC adapter.""" + self._device.async_send_command(CecCommand(key, dst=self._logical_address)) + + def mute_volume(self, mute): + """Mute volume.""" + self.send_keypress(KEY_MUTE_TOGGLE) + + def media_previous_track(self): + """Go to previous track.""" + self.send_keypress(KEY_BACKWARD) + + def turn_on(self): + """Turn device on.""" + self._device.turn_on() + self._state = STATE_ON + + def clear_playlist(self): + """Clear players playlist.""" + raise NotImplementedError() + + def turn_off(self): + """Turn device off.""" + self._device.turn_off() + self._state = STATE_OFF + + def media_stop(self): + """Stop playback.""" + self.send_keypress(KEY_STOP) + self._state = STATE_IDLE + + def play_media(self, media_type, media_id, **kwargs): + """Not supported.""" + raise NotImplementedError() + + def media_next_track(self): + """Skip to next track.""" + self.send_keypress(KEY_FORWARD) + + def media_seek(self, position): + """Not supported.""" + raise NotImplementedError() + + def set_volume_level(self, volume): + """Set volume level, range 0..1.""" + raise NotImplementedError() + + def media_pause(self): + """Pause playback.""" + self.send_keypress(KEY_PAUSE) + self._state = STATE_PAUSED + + def select_source(self, source): + """Not supported.""" + raise NotImplementedError() + + def media_play(self): + """Start playback.""" + self.send_keypress(KEY_PLAY) + self._state = STATE_PLAYING + + def volume_up(self): + """Increase volume.""" + _LOGGER.debug("%s: volume up", self._logical_address) + self.send_keypress(KEY_VOLUME_UP) + + def volume_down(self): + """Decrease volume.""" + _LOGGER.debug("%s: volume down", self._logical_address) + self.send_keypress(KEY_VOLUME_DOWN) + + @property + def state(self) -> str: + """Cache state of device.""" + return self._state + + def update(self): + """Update device status.""" + device = self._device + if device.power_status in [POWER_OFF, 3]: + self._state = STATE_OFF + elif not self.support_pause: + if device.power_status in [POWER_ON, 4]: + self._state = STATE_ON + elif device.status == STATUS_PLAY: + self._state = STATE_PLAYING + elif device.status == STATUS_STOP: + self._state = STATE_IDLE + elif device.status == STATUS_STILL: + self._state = STATE_PAUSED + else: + _LOGGER.warning("Unknown state: %s", device.status) + + @property + def supported_features(self): + """Flag media player features that are supported.""" + if self.type_id == TYPE_RECORDER or self.type == TYPE_PLAYBACK: + return ( + SUPPORT_TURN_ON + | SUPPORT_TURN_OFF + | SUPPORT_PLAY_MEDIA + | SUPPORT_PAUSE + | SUPPORT_STOP + | SUPPORT_PREVIOUS_TRACK + | SUPPORT_NEXT_TRACK + ) + if self.type == TYPE_TUNER: + return ( + SUPPORT_TURN_ON + | SUPPORT_TURN_OFF + | SUPPORT_PLAY_MEDIA + | SUPPORT_PAUSE + | SUPPORT_STOP + ) + if self.type_id == TYPE_AUDIO: + return ( + SUPPORT_TURN_ON + | SUPPORT_TURN_OFF + | SUPPORT_VOLUME_STEP + | SUPPORT_VOLUME_MUTE + ) + return SUPPORT_TURN_ON | SUPPORT_TURN_OFF diff --git a/homeassistant/components/hdmi_cec/services.yaml b/homeassistant/components/hdmi_cec/services.yaml new file mode 100644 index 000000000..f2e5f0b83 --- /dev/null +++ b/homeassistant/components/hdmi_cec/services.yaml @@ -0,0 +1,32 @@ +power_on: {description: Power on all devices which supports it.} +select_device: + description: Select HDMI device. + fields: + device: {description: 'Address of device to select. Can be entity_id, physical + address or alias from confuguration.', example: '"switch.hdmi_1" or "1.1.0.0" + or "01:10"'} +send_command: + description: Sends CEC command into HDMI CEC capable adapter. + fields: + att: + description: Optional parameters. + example: [0, 2] + cmd: {description: 'Command itself. Could be decimal number or string with hexadeximal + notation: "0x10".', example: 144 or "0x90"} + dst: {description: 'Destination for command. Could be decimal number or string + with hexadeximal notation: "0x10".', example: 5 or "0x5"} + raw: {description: 'Raw CEC command in format "00:00:00:00" where first two digits + are source and destination, second byte is command and optional other bytes + are command parameters. If raw command specified, other params are ignored.', + example: '"10:36"'} + src: {description: 'Source of command. Could be decimal number or string with + hexadeximal notation: "0x10".', example: 12 or "0xc"} +standby: {description: Standby all devices which supports it.} +update: {description: Update devices state from network.} +volume: + description: Increase or decrease volume of system. + fields: + down: {description: Decreases volume x levels., example: 3} + mute: {description: 'Mutes audio system. Value should be on, off or toggle.', + example: toggle} + up: {description: Increases volume x levels., example: 3} diff --git a/homeassistant/components/hdmi_cec/switch.py b/homeassistant/components/hdmi_cec/switch.py new file mode 100644 index 000000000..53384397c --- /dev/null +++ b/homeassistant/components/hdmi_cec/switch.py @@ -0,0 +1,64 @@ +"""Support for HDMI CEC devices as switches.""" +import logging + +from homeassistant.components.switch import DOMAIN, SwitchDevice +from homeassistant.const import STATE_OFF, STATE_ON, STATE_STANDBY + +from . import ATTR_NEW, CecDevice + +_LOGGER = logging.getLogger(__name__) + +ENTITY_ID_FORMAT = DOMAIN + ".{}" + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Find and return HDMI devices as switches.""" + if ATTR_NEW in discovery_info: + _LOGGER.info("Setting up HDMI devices %s", discovery_info[ATTR_NEW]) + entities = [] + for device in discovery_info[ATTR_NEW]: + hdmi_device = hass.data.get(device) + entities.append(CecSwitchDevice(hdmi_device, hdmi_device.logical_address)) + add_entities(entities, True) + + +class CecSwitchDevice(CecDevice, SwitchDevice): + """Representation of a HDMI device as a Switch.""" + + def __init__(self, device, logical) -> None: + """Initialize the HDMI device.""" + CecDevice.__init__(self, device, logical) + self.entity_id = "%s.%s_%s" % (DOMAIN, "hdmi", hex(self._logical_address)[2:]) + + def turn_on(self, **kwargs) -> None: + """Turn device on.""" + self._device.turn_on() + self._state = STATE_ON + + def turn_off(self, **kwargs) -> None: + """Turn device off.""" + self._device.turn_off() + self._state = STATE_ON + + def toggle(self, **kwargs): + """Toggle the entity.""" + self._device.toggle() + if self._state == STATE_ON: + self._state = STATE_OFF + else: + self._state = STATE_ON + + @property + def is_on(self) -> bool: + """Return True if entity is on.""" + return self._state == STATE_ON + + @property + def is_standby(self): + """Return true if device is in standby.""" + return self._state == STATE_OFF or self._state == STATE_STANDBY + + @property + def state(self) -> str: + """Return the cached state of device.""" + return self._state diff --git a/homeassistant/components/heatmiser/__init__.py b/homeassistant/components/heatmiser/__init__.py new file mode 100644 index 000000000..bc6313f9e --- /dev/null +++ b/homeassistant/components/heatmiser/__init__.py @@ -0,0 +1 @@ +"""The heatmiser component.""" diff --git a/homeassistant/components/heatmiser/climate.py b/homeassistant/components/heatmiser/climate.py new file mode 100644 index 000000000..553ae8f4b --- /dev/null +++ b/homeassistant/components/heatmiser/climate.py @@ -0,0 +1,147 @@ +"""Support for the PRT Heatmiser themostats using the V3 protocol.""" +import logging +from typing import List + +from heatmiserV3 import connection, heatmiser +import voluptuous as vol + +from homeassistant.components.climate import ( + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + PLATFORM_SCHEMA, + ClimateDevice, +) +from homeassistant.components.climate.const import SUPPORT_TARGET_TEMPERATURE +from homeassistant.const import ( + ATTR_TEMPERATURE, + CONF_HOST, + CONF_ID, + CONF_NAME, + CONF_PORT, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +CONF_THERMOSTATS = "tstats" + +TSTATS_SCHEMA = vol.Schema( + vol.All( + cv.ensure_list, + [{vol.Required(CONF_ID): cv.positive_int, vol.Required(CONF_NAME): cv.string}], + ) +) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PORT): cv.string, + vol.Optional(CONF_THERMOSTATS, default=[]): TSTATS_SCHEMA, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the heatmiser thermostat.""" + + heatmiser_v3_thermostat = heatmiser.HeatmiserThermostat + + host = config[CONF_HOST] + port = config[CONF_PORT] + + thermostats = config[CONF_THERMOSTATS] + + uh1_hub = connection.HeatmiserUH1(host, port) + + add_entities( + [ + HeatmiserV3Thermostat(heatmiser_v3_thermostat, thermostat, uh1_hub) + for thermostat in thermostats + ], + True, + ) + + +class HeatmiserV3Thermostat(ClimateDevice): + """Representation of a HeatmiserV3 thermostat.""" + + def __init__(self, therm, device, uh1): + """Initialize the thermostat.""" + self.therm = therm(device[CONF_ID], "prt", uh1) + self.uh1 = uh1 + self._name = device[CONF_NAME] + self._current_temperature = None + self._target_temperature = None + self._id = device + self.dcb = None + self._hvac_mode = HVAC_MODE_HEAT + self._temperature_unit = None + + @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.""" + return self._name + + @property + def temperature_unit(self): + """Return the unit of measurement which this thermostat uses.""" + return self._temperature_unit + + @property + def hvac_mode(self) -> str: + """Return hvac operation ie. heat, cool mode. + + Need to be one of HVAC_MODE_*. + """ + return self._hvac_mode + + @property + def hvac_modes(self) -> List[str]: + """Return the list of available hvac operation modes. + + Need to be a subset of HVAC_MODES. + """ + return [HVAC_MODE_HEAT, HVAC_MODE_OFF] + + @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.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + self._target_temperature = int(temperature) + self.therm.set_target_temp(self._target_temperature) + + def update(self): + """Get the latest data.""" + self.uh1.reopen() + if not self.uh1.status: + _LOGGER.error("Failed to update device %s", self._name) + return + self.dcb = self.therm.read_dcb() + self._temperature_unit = ( + TEMP_CELSIUS + if (self.therm.get_temperature_format() == "C") + else TEMP_FAHRENHEIT + ) + self._current_temperature = int(self.therm.get_floor_temp()) + self._target_temperature = int(self.therm.get_target_temp()) + self._hvac_mode = ( + HVAC_MODE_OFF + if (int(self.therm.get_current_state()) == 0) + else HVAC_MODE_HEAT + ) diff --git a/homeassistant/components/heatmiser/manifest.json b/homeassistant/components/heatmiser/manifest.json new file mode 100644 index 000000000..89bcec081 --- /dev/null +++ b/homeassistant/components/heatmiser/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "heatmiser", + "name": "Heatmiser", + "documentation": "https://www.home-assistant.io/integrations/heatmiser", + "requirements": [ + "heatmiserV3==1.1.18" + ], + "dependencies": [], + "codeowners": [ + "@andylockran" + ] +} \ No newline at end of file diff --git a/homeassistant/components/heos/.translations/bg.json b/homeassistant/components/heos/.translations/bg.json new file mode 100644 index 000000000..dea7dd9bb --- /dev/null +++ b/homeassistant/components/heos/.translations/bg.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_setup": "\u041c\u043e\u0436\u0435\u0442\u0435 \u0434\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u0442\u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 Heos \u0432\u0440\u044a\u0437\u043a\u0430, \u0442\u044a\u0439 \u043a\u0430\u0442\u043e \u0442\u044f \u0449\u0435 \u043f\u043e\u0434\u0434\u044a\u0440\u0436\u0430 \u0432\u0441\u0438\u0447\u043a\u0438 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0432 \u043c\u0440\u0435\u0436\u0430\u0442\u0430." + }, + "error": { + "connection_failure": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u043d\u0430 \u043f\u043e\u0441\u043e\u0447\u0435\u043d\u0438\u044f \u0430\u0434\u0440\u0435\u0441." + }, + "step": { + "user": { + "data": { + "access_token": "\u0410\u0434\u0440\u0435\u0441", + "host": "\u0410\u0434\u0440\u0435\u0441" + }, + "description": "\u041c\u043e\u043b\u044f, \u0432\u044a\u0432\u0435\u0434\u0435\u0442\u0435 \u0438\u043c\u0435\u0442\u043e \u043d\u0430 \u0445\u043e\u0441\u0442\u0430 \u0438\u043b\u0438 IP \u0430\u0434\u0440\u0435\u0441\u0430 \u043d\u0430 Heos \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e (\u0437\u0430 \u043f\u0440\u0435\u0434\u043f\u043e\u0447\u0438\u0442\u0430\u043d\u0435 \u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0434\u0430 \u0435 \u0441\u0432\u044a\u0440\u0437\u0430\u043d\u043e \u0441 \u043a\u0430\u0431\u0435\u043b \u043a\u044a\u043c \u043c\u0440\u0435\u0436\u0430\u0442\u0430).", + "title": "\u0421\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u0441 Heos" + } + }, + "title": "HEOS" + } +} \ No newline at end of file diff --git a/homeassistant/components/heos/.translations/ca.json b/homeassistant/components/heos/.translations/ca.json new file mode 100644 index 000000000..0987e1143 --- /dev/null +++ b/homeassistant/components/heos/.translations/ca.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_setup": "Nom\u00e9s pots configurar una \u00fanica connexi\u00f3 de Heos tot i que aquesta ja pot controlar tots els dispositius de la xarxa." + }, + "error": { + "connection_failure": "No s'ha pogut connectar amb l'amfitri\u00f3 especificat." + }, + "step": { + "user": { + "data": { + "access_token": "Amfitri\u00f3", + "host": "Amfitri\u00f3" + }, + "description": "Introdueix el nom de l'amfitri\u00f3 o l'adre\u00e7a IP d'un dispositiu Heos (preferiblement un connectat a la xarxa per cable).", + "title": "Connexi\u00f3 amb Heos" + } + }, + "title": "HEOS" + } +} \ No newline at end of file diff --git a/homeassistant/components/heos/.translations/cs.json b/homeassistant/components/heos/.translations/cs.json new file mode 100644 index 000000000..fac6458c5 --- /dev/null +++ b/homeassistant/components/heos/.translations/cs.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "HEOS" + } +} \ No newline at end of file diff --git a/homeassistant/components/heos/.translations/da.json b/homeassistant/components/heos/.translations/da.json new file mode 100644 index 000000000..f2d9441e4 --- /dev/null +++ b/homeassistant/components/heos/.translations/da.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_setup": "Du kan kun konfigurere en enkelt Heos-forbindelse, da den underst\u00f8tter alle enheder p\u00e5 netv\u00e6rket." + }, + "error": { + "connection_failure": "Kunne ikke oprette forbindelse til den angivne v\u00e6rt." + }, + "step": { + "user": { + "data": { + "access_token": "V\u00e6rt", + "host": "V\u00e6rt" + }, + "description": "Indtast v\u00e6rtsnavnet eller IP-adressen p\u00e5 en Heos-enhed (helst en tilsluttet via ledning til netv\u00e6rket).", + "title": "Opret forbindelse til HEOS" + } + }, + "title": "HEOS" + } +} \ No newline at end of file diff --git a/homeassistant/components/heos/.translations/de.json b/homeassistant/components/heos/.translations/de.json new file mode 100644 index 000000000..e98df7466 --- /dev/null +++ b/homeassistant/components/heos/.translations/de.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_setup": "Es kann nur eine einzige Heos-Verbindung konfiguriert werden, da diese alle Ger\u00e4te im Netzwerk unterst\u00fctzt." + }, + "error": { + "connection_failure": "Es kann keine Verbindung zum angegebenen Host hergestellt werden." + }, + "step": { + "user": { + "data": { + "access_token": "Host", + "host": "Host" + }, + "description": "Bitte gib den Hostnamen oder die IP-Adresse eines Heos-Ger\u00e4ts ein (vorzugsweise eines, das per Kabel mit dem Netzwerk verbunden ist).", + "title": "Mit Heos verbinden" + } + }, + "title": "HEOS" + } +} \ No newline at end of file diff --git a/homeassistant/components/heos/.translations/en.json b/homeassistant/components/heos/.translations/en.json new file mode 100644 index 000000000..6d4d83192 --- /dev/null +++ b/homeassistant/components/heos/.translations/en.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_setup": "You can only configure a single Heos connection as it will support all devices on the network." + }, + "error": { + "connection_failure": "Unable to connect to the specified host." + }, + "step": { + "user": { + "data": { + "access_token": "Host", + "host": "Host" + }, + "description": "Please enter the host name or IP address of a Heos device (preferably one connected via wire to the network).", + "title": "Connect to Heos" + } + }, + "title": "HEOS" + } +} \ No newline at end of file diff --git a/homeassistant/components/heos/.translations/es-419.json b/homeassistant/components/heos/.translations/es-419.json new file mode 100644 index 000000000..4d442a454 --- /dev/null +++ b/homeassistant/components/heos/.translations/es-419.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "already_setup": "Solo puede configurar una sola conexi\u00f3n Heos, ya que ser\u00e1 compatible con todos los dispositivos de la red." + }, + "step": { + "user": { + "title": "Con\u00e9ctate a Heos" + } + }, + "title": "Heos" + } +} \ No newline at end of file diff --git a/homeassistant/components/heos/.translations/es.json b/homeassistant/components/heos/.translations/es.json new file mode 100644 index 000000000..da5d5e0ab --- /dev/null +++ b/homeassistant/components/heos/.translations/es.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_setup": "Solo puedes configurar una \u00fanica conexi\u00f3n Heos, ya que admitir\u00e1 todos los dispositivos de la red." + }, + "error": { + "connection_failure": "No se puede conectar al host especificado." + }, + "step": { + "user": { + "data": { + "access_token": "Host", + "host": "Host" + }, + "description": "Introduce el nombre de host o direcci\u00f3n IP de un dispositivo Heos (preferiblemente conectado por cable a la red).", + "title": "Conectar a Heos" + } + }, + "title": "HEOS" + } +} \ No newline at end of file diff --git a/homeassistant/components/heos/.translations/fr.json b/homeassistant/components/heos/.translations/fr.json new file mode 100644 index 000000000..549cd00e8 --- /dev/null +++ b/homeassistant/components/heos/.translations/fr.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_setup": "Vous ne pouvez configurer qu'une seule connexion Heos, car celle-ci supportera tous les p\u00e9riph\u00e9riques du r\u00e9seau." + }, + "error": { + "connection_failure": "Impossible de se connecter \u00e0 l'h\u00f4te sp\u00e9cifi\u00e9." + }, + "step": { + "user": { + "data": { + "access_token": "H\u00f4te", + "host": "H\u00f4te" + }, + "description": "Veuillez saisir le nom d\u2019h\u00f4te ou l\u2019adresse IP d\u2019un p\u00e9riph\u00e9rique Heos (de pr\u00e9f\u00e9rence connect\u00e9 au r\u00e9seau filaire).", + "title": "Se connecter \u00e0 Heos" + } + }, + "title": "Heos" + } +} \ No newline at end of file diff --git a/homeassistant/components/heos/.translations/hu.json b/homeassistant/components/heos/.translations/hu.json new file mode 100644 index 000000000..20ae78ae3 --- /dev/null +++ b/homeassistant/components/heos/.translations/hu.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "user": { + "data": { + "access_token": "Kiszolg\u00e1l\u00f3", + "host": "Kiszolg\u00e1l\u00f3" + } + } + }, + "title": "HEOS" + } +} \ No newline at end of file diff --git a/homeassistant/components/heos/.translations/it.json b/homeassistant/components/heos/.translations/it.json new file mode 100644 index 000000000..824f7c3fb --- /dev/null +++ b/homeassistant/components/heos/.translations/it.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_setup": "\u00c8 possibile configurare una singola connessione Heos poich\u00e9 supporta tutti i dispositivi sulla rete." + }, + "error": { + "connection_failure": "Impossibile connettersi all'host specificato." + }, + "step": { + "user": { + "data": { + "access_token": "Host", + "host": "Host" + }, + "description": "Inserire il nome host o l'indirizzo IP di un dispositivo Heos (preferibilmente uno connesso alla rete tramite cavo).", + "title": "Connetti a Heos" + } + }, + "title": "HEOS" + } +} \ No newline at end of file diff --git a/homeassistant/components/heos/.translations/ko.json b/homeassistant/components/heos/.translations/ko.json new file mode 100644 index 000000000..9237800bf --- /dev/null +++ b/homeassistant/components/heos/.translations/ko.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_setup": "Heos \uc5f0\uacb0\uc740 \ub124\ud2b8\uc6cc\ud06c\uc0c1\uc758 \ubaa8\ub4e0 \uae30\uae30\ub97c \uc9c0\uc6d0\ud558\uae30 \ub54c\ubb38\uc5d0 \ud558\ub098\ub9cc \uad6c\uc131\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4." + }, + "error": { + "connection_failure": "\uc9c0\uc815\ub41c \ud638\uc2a4\ud2b8\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4." + }, + "step": { + "user": { + "data": { + "access_token": "\ud638\uc2a4\ud2b8", + "host": "\ud638\uc2a4\ud2b8" + }, + "description": "Heos \uae30\uae30\uc758 \ud638\uc2a4\ud2b8 \uc774\ub984 \ub610\ub294 IP \uc8fc\uc18c\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694. (\uc720\uc120 \ub124\ud2b8\uc6cc\ud06c\ub85c \uc5f0\uacb0\ud558\ub294 \uac83\uc774 \uc88b\uc2b5\ub2c8\ub2e4)", + "title": "Heos \uc5f0\uacb0" + } + }, + "title": "HEOS" + } +} \ No newline at end of file diff --git a/homeassistant/components/heos/.translations/lb.json b/homeassistant/components/heos/.translations/lb.json new file mode 100644 index 000000000..cfe1d347b --- /dev/null +++ b/homeassistant/components/heos/.translations/lb.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_setup": "Dir k\u00ebnnt n\u00ebmmen eng eenzeg Heos Verbindung konfigur\u00e9ieren, well se all Apparater am Netzwierk \u00ebnnerst\u00ebtzen." + }, + "error": { + "connection_failure": "Kann sech net mat dem spezifiz\u00e9ierten Apparat verbannen." + }, + "step": { + "user": { + "data": { + "access_token": "Apparat", + "host": "Apparat" + }, + "description": "Gitt den Numm oder IP-Adress vun engem Heos-Apparat an (am beschten iwwer Kabel mam Reseau verbonnen).", + "title": "Mat Heos verbannen" + } + }, + "title": "HEOS" + } +} \ No newline at end of file diff --git a/homeassistant/components/heos/.translations/nl.json b/homeassistant/components/heos/.translations/nl.json new file mode 100644 index 000000000..3e7105e8c --- /dev/null +++ b/homeassistant/components/heos/.translations/nl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_setup": "U kunt alleen een enkele Heos-verbinding configureren, omdat deze alle apparaten in het netwerk ondersteunt." + }, + "error": { + "connection_failure": "Kan geen verbinding maken met de opgegeven host." + }, + "step": { + "user": { + "data": { + "access_token": "Host", + "host": "Host" + }, + "description": "Voer de hostnaam of het IP-adres van een Heos-apparaat in (bij voorkeur een die via een kabel is verbonden met het netwerk).", + "title": "Verbinding maken met Heos" + } + }, + "title": "HEOS" + } +} \ No newline at end of file diff --git a/homeassistant/components/heos/.translations/nn.json b/homeassistant/components/heos/.translations/nn.json new file mode 100644 index 000000000..ec2dc2945 --- /dev/null +++ b/homeassistant/components/heos/.translations/nn.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "access_token": "Vert" + } + } + }, + "title": "Heos" + } +} \ No newline at end of file diff --git a/homeassistant/components/heos/.translations/no.json b/homeassistant/components/heos/.translations/no.json new file mode 100644 index 000000000..d41051b66 --- /dev/null +++ b/homeassistant/components/heos/.translations/no.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_setup": "Du kan kun konfigurere en Heos tilkobling, da den st\u00f8tter alle enhetene p\u00e5 nettverket." + }, + "error": { + "connection_failure": "Kan ikke koble til den angitte verten." + }, + "step": { + "user": { + "data": { + "access_token": "Vert", + "host": "Vert" + }, + "description": "Vennligst skriv inn vertsnavnet eller IP-adressen til en Heos-enhet (helst en tilkoblet via kabel til nettverket).", + "title": "Koble til Heos" + } + }, + "title": "HEOS" + } +} \ No newline at end of file diff --git a/homeassistant/components/heos/.translations/pl.json b/homeassistant/components/heos/.translations/pl.json new file mode 100644 index 000000000..d427acc3a --- /dev/null +++ b/homeassistant/components/heos/.translations/pl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_setup": "Mo\u017cesz skonfigurowa\u0107 tylko jedno po\u0142\u0105czenie Heos, poniewa\u017c b\u0119dzie ono obs\u0142ugiwa\u0107 wszystkie urz\u0105dzenia w sieci." + }, + "error": { + "connection_failure": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z okre\u015blonym hostem." + }, + "step": { + "user": { + "data": { + "access_token": "Host", + "host": "Host" + }, + "description": "Wprowad\u017a nazw\u0119 hosta lub adres IP urz\u0105dzenia Heos (najlepiej pod\u0142\u0105czonego przewodowo do sieci).", + "title": "Po\u0142\u0105cz si\u0119 z Heos" + } + }, + "title": "Heos" + } +} \ No newline at end of file diff --git a/homeassistant/components/heos/.translations/pt-BR.json b/homeassistant/components/heos/.translations/pt-BR.json new file mode 100644 index 000000000..5bcd39efd --- /dev/null +++ b/homeassistant/components/heos/.translations/pt-BR.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_setup": "Voc\u00ea s\u00f3 pode configurar uma \u00fanica conex\u00e3o Heos, pois ela suportar\u00e1 todos os dispositivos na rede." + }, + "error": { + "connection_failure": "N\u00e3o \u00e9 poss\u00edvel conectar-se ao host especificado." + }, + "step": { + "user": { + "data": { + "access_token": "Host", + "host": "Host" + }, + "description": "Por favor, digite o nome do host ou o endere\u00e7o IP de um dispositivo Heos (de prefer\u00eancia para conex\u00f5es conectadas por cabo \u00e0 sua rede).", + "title": "Conecte-se a Heos" + } + }, + "title": "HEOS" + } +} \ No newline at end of file diff --git a/homeassistant/components/heos/.translations/pt.json b/homeassistant/components/heos/.translations/pt.json new file mode 100644 index 000000000..099d19784 --- /dev/null +++ b/homeassistant/components/heos/.translations/pt.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "user": { + "data": { + "access_token": "Servidor", + "host": "Servidor" + } + } + }, + "title": "Heos" + } +} \ No newline at end of file diff --git a/homeassistant/components/heos/.translations/ru.json b/homeassistant/components/heos/.translations/ru.json new file mode 100644 index 000000000..8aacc8e16 --- /dev/null +++ b/homeassistant/components/heos/.translations/ru.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_setup": "\u041d\u0443\u0436\u043d\u043e \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c \u0442\u043e\u043b\u044c\u043a\u043e \u043e\u0434\u043d\u043e \u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0435, \u043f\u043e\u0441\u043a\u043e\u043b\u044c\u043a\u0443 \u043e\u043d\u043e \u0431\u0443\u0434\u0435\u0442 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0442\u044c \u0432\u0441\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 HEOS \u0432 \u0441\u0435\u0442\u0438." + }, + "error": { + "connection_failure": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0443\u043a\u0430\u0437\u0430\u043d\u043d\u043e\u043c\u0443 \u0445\u043e\u0441\u0442\u0443." + }, + "step": { + "user": { + "data": { + "access_token": "\u0425\u043e\u0441\u0442", + "host": "\u0425\u043e\u0441\u0442" + }, + "description": "\u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u0432\u0432\u0435\u0434\u0438\u0442\u0435 \u0438\u043c\u044f \u0445\u043e\u0441\u0442\u0430 \u0438\u043b\u0438 IP-\u0430\u0434\u0440\u0435\u0441 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 HEOS (\u043f\u0440\u0435\u0434\u043f\u043e\u0447\u0442\u0438\u0442\u0435\u043b\u044c\u043d\u043e \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043a \u0441\u0435\u0442\u0438 \u0447\u0435\u0440\u0435\u0437 \u043a\u0430\u0431\u0435\u043b\u044c).", + "title": "HEOS" + } + }, + "title": "HEOS" + } +} \ No newline at end of file diff --git a/homeassistant/components/heos/.translations/sl.json b/homeassistant/components/heos/.translations/sl.json new file mode 100644 index 000000000..2978d2bbb --- /dev/null +++ b/homeassistant/components/heos/.translations/sl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_setup": "Konfigurirate lahko samo eno povezavo Heos, le ta bo podpirala vse naprave v omre\u017eju." + }, + "error": { + "connection_failure": "Ni mogo\u010de vzpostaviti povezave z dolo\u010denim gostiteljem." + }, + "step": { + "user": { + "data": { + "access_token": "Gostitelj", + "host": "Gostitelj" + }, + "description": "Vnesite ime gostitelja ali naslov IP naprave Heos (po mo\u017enosti eno, ki je z omre\u017ejem povezana \u017ei\u010dno).", + "title": "Pove\u017eite se z Heos" + } + }, + "title": "HEOS" + } +} \ No newline at end of file diff --git a/homeassistant/components/heos/.translations/sv.json b/homeassistant/components/heos/.translations/sv.json new file mode 100644 index 000000000..96d4991a5 --- /dev/null +++ b/homeassistant/components/heos/.translations/sv.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_setup": "Du kan bara konfigurera en enda Heos-anslutning eftersom den kommer att st\u00f6dja alla enheter i n\u00e4tverket." + }, + "error": { + "connection_failure": "Det gick inte att ansluta till den angivna v\u00e4rden." + }, + "step": { + "user": { + "data": { + "access_token": "V\u00e4rd", + "host": "V\u00e4rd" + }, + "description": "Ange v\u00e4rdnamnet eller IP-adressen f\u00f6r en Heos-enhet (helst en ansluten via kabel till n\u00e4tverket).", + "title": "Anslut till Heos" + } + }, + "title": "HEOS" + } +} \ No newline at end of file diff --git a/homeassistant/components/heos/.translations/zh-Hant.json b/homeassistant/components/heos/.translations/zh-Hant.json new file mode 100644 index 000000000..9efacaf16 --- /dev/null +++ b/homeassistant/components/heos/.translations/zh-Hant.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_setup": "\u50c5\u80fd\u8a2d\u5b9a\u4e00\u7d44 Heos \u9023\u7dda\uff0c\u5c07\u652f\u63f4\u7db2\u8def\u4e2d\u6240\u6709\u5c0d\u61c9\u8a2d\u5099\u3002" + }, + "error": { + "connection_failure": "\u7121\u6cd5\u9023\u7dda\u81f3\u6307\u5b9a\u4e3b\u6a5f\u7aef\u3002" + }, + "step": { + "user": { + "data": { + "access_token": "\u4e3b\u6a5f\u7aef", + "host": "\u4e3b\u6a5f\u7aef" + }, + "description": "\u8acb\u8f38\u5165\u4e3b\u6a5f\u6bb5\u540d\u7a31\u6216 Heos \u8a2d\u5099 IP \u4f4d\u5740\uff08\u5df2\u900f\u904e\u6709\u7dda\u7db2\u8def\u9023\u7dda\uff09\u3002", + "title": "\u9023\u7dda\u81f3 Heos" + } + }, + "title": "HEOS" + } +} \ No newline at end of file diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py new file mode 100644 index 000000000..f7e1ce5bc --- /dev/null +++ b/homeassistant/components/heos/__init__.py @@ -0,0 +1,339 @@ +"""Denon HEOS Media Player.""" +import asyncio +from datetime import timedelta +import logging +from typing import Dict + +from pyheos import Heos, HeosError, const as heos_const +import voluptuous as vol + +from homeassistant.components.media_player.const import DOMAIN as MEDIA_PLAYER_DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP +from homeassistant.exceptions import ConfigEntryNotReady +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType, HomeAssistantType +from homeassistant.util import Throttle + +from . import services +from .config_flow import format_title +from .const import ( + COMMAND_RETRY_ATTEMPTS, + COMMAND_RETRY_DELAY, + DATA_CONTROLLER_MANAGER, + DATA_SOURCE_MANAGER, + DOMAIN, + SIGNAL_HEOS_UPDATED, +) + +CONFIG_SCHEMA = vol.Schema( + {DOMAIN: vol.Schema({vol.Required(CONF_HOST): cv.string})}, extra=vol.ALLOW_EXTRA +) + +MIN_UPDATE_SOURCES = timedelta(seconds=1) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistantType, config: ConfigType): + """Set up the HEOS component.""" + if DOMAIN not in config: + return True + host = config[DOMAIN][CONF_HOST] + entries = hass.config_entries.async_entries(DOMAIN) + if not entries: + # Create new entry based on config + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": "import"}, data={CONF_HOST: host} + ) + ) + else: + # Check if host needs to be updated + entry = entries[0] + if entry.data[CONF_HOST] != host: + entry.data[CONF_HOST] = host + entry.title = format_title(host) + hass.config_entries.async_update_entry(entry) + + return True + + +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): + """Initialize config entry which represents the HEOS controller.""" + host = entry.data[CONF_HOST] + # Setting all_progress_events=False ensures that we only receive a + # media position update upon start of playback or when media changes + controller = Heos(host, all_progress_events=False) + try: + await controller.connect(auto_reconnect=True) + # Auto reconnect only operates if initial connection was successful. + except HeosError as error: + await controller.disconnect() + _LOGGER.debug("Unable to connect to controller %s: %s", host, error) + raise ConfigEntryNotReady + + # Disconnect when shutting down + async def disconnect_controller(event): + await controller.disconnect() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, disconnect_controller) + + # Get players and sources + try: + players = await controller.get_players() + favorites = {} + if controller.is_signed_in: + favorites = await controller.get_favorites() + else: + _LOGGER.warning( + "%s is not logged in to a HEOS account and will be unable to retrieve " + "HEOS favorites: Use the 'heos.sign_in' service to sign-in to a HEOS account", + host, + ) + inputs = await controller.get_input_sources() + except HeosError as error: + await controller.disconnect() + _LOGGER.debug("Unable to retrieve players and sources: %s", error) + raise ConfigEntryNotReady + + controller_manager = ControllerManager(hass, controller) + await controller_manager.connect_listeners() + + source_manager = SourceManager(favorites, inputs) + source_manager.connect_update(hass, controller) + + hass.data[DOMAIN] = { + DATA_CONTROLLER_MANAGER: controller_manager, + DATA_SOURCE_MANAGER: source_manager, + MEDIA_PLAYER_DOMAIN: players, + } + + services.register(hass, controller) + + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, MEDIA_PLAYER_DOMAIN) + ) + return True + + +async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry): + """Unload a config entry.""" + controller_manager = hass.data[DOMAIN][DATA_CONTROLLER_MANAGER] + await controller_manager.disconnect() + hass.data.pop(DOMAIN) + + services.remove(hass) + + return await hass.config_entries.async_forward_entry_unload( + entry, MEDIA_PLAYER_DOMAIN + ) + + +class ControllerManager: + """Class that manages events of the controller.""" + + def __init__(self, hass, controller): + """Init the controller manager.""" + self._hass = hass + self._device_registry = None + self._entity_registry = None + self.controller = controller + self._signals = [] + + async def connect_listeners(self): + """Subscribe to events of interest.""" + self._device_registry, self._entity_registry = await asyncio.gather( + self._hass.helpers.device_registry.async_get_registry(), + self._hass.helpers.entity_registry.async_get_registry(), + ) + # Handle controller events + self._signals.append( + self.controller.dispatcher.connect( + heos_const.SIGNAL_CONTROLLER_EVENT, self._controller_event + ) + ) + # Handle connection-related events + self._signals.append( + self.controller.dispatcher.connect( + heos_const.SIGNAL_HEOS_EVENT, self._heos_event + ) + ) + + async def disconnect(self): + """Disconnect subscriptions.""" + for signal_remove in self._signals: + signal_remove() + self._signals.clear() + self.controller.dispatcher.disconnect_all() + await self.controller.disconnect() + + async def _controller_event(self, event, data): + """Handle controller event.""" + if event == heos_const.EVENT_PLAYERS_CHANGED: + self.update_ids(data[heos_const.DATA_MAPPED_IDS]) + # Update players + self._hass.helpers.dispatcher.async_dispatcher_send(SIGNAL_HEOS_UPDATED) + + async def _heos_event(self, event): + """Handle connection event.""" + if event == heos_const.EVENT_CONNECTED: + try: + # Retrieve latest players and refresh status + data = await self.controller.load_players() + self.update_ids(data[heos_const.DATA_MAPPED_IDS]) + except HeosError as ex: + _LOGGER.error("Unable to refresh players: %s", ex) + # Update players + self._hass.helpers.dispatcher.async_dispatcher_send(SIGNAL_HEOS_UPDATED) + + def update_ids(self, mapped_ids: Dict[int, int]): + """Update the IDs in the device and entity registry.""" + # mapped_ids contains the mapped IDs (new:old) + for new_id, old_id in mapped_ids.items(): + # update device registry + entry = self._device_registry.async_get_device({(DOMAIN, old_id)}, set()) + new_identifiers = {(DOMAIN, new_id)} + if entry: + self._device_registry.async_update_device( + entry.id, new_identifiers=new_identifiers + ) + _LOGGER.debug( + "Updated device %s identifiers to %s", entry.id, new_identifiers + ) + # update entity registry + entity_id = self._entity_registry.async_get_entity_id( + MEDIA_PLAYER_DOMAIN, DOMAIN, str(old_id) + ) + if entity_id: + self._entity_registry.async_update_entity( + entity_id, new_unique_id=str(new_id) + ) + _LOGGER.debug("Updated entity %s unique id to %s", entity_id, new_id) + + +class SourceManager: + """Class that manages sources for players.""" + + def __init__( + self, + favorites, + inputs, + *, + retry_delay: int = COMMAND_RETRY_DELAY, + max_retry_attempts: int = COMMAND_RETRY_ATTEMPTS, + ): + """Init input manager.""" + self.retry_delay = retry_delay + self.max_retry_attempts = max_retry_attempts + self.favorites = favorites + self.inputs = inputs + self.source_list = self._build_source_list() + + def _build_source_list(self): + """Build a single list of inputs from various types.""" + source_list = [] + source_list.extend([favorite.name for favorite in self.favorites.values()]) + source_list.extend([source.name for source in self.inputs]) + return source_list + + async def play_source(self, source: str, player): + """Determine type of source and play it.""" + index = next( + ( + index + for index, favorite in self.favorites.items() + if favorite.name == source + ), + None, + ) + if index is not None: + await player.play_favorite(index) + return + + input_source = next( + ( + input_source + for input_source in self.inputs + if input_source.name == source + ), + None, + ) + if input_source is not None: + await player.play_input_source(input_source) + return + + _LOGGER.error("Unknown source: %s", source) + + def get_current_source(self, now_playing_media): + """Determine current source from now playing media.""" + # Match input by input_name:media_id + if now_playing_media.source_id == heos_const.MUSIC_SOURCE_AUX_INPUT: + return next( + ( + input_source.name + for input_source in self.inputs + if input_source.input_name == now_playing_media.media_id + ), + None, + ) + # Try matching favorite by name:station or media_id:album_id + return next( + ( + source.name + for source in self.favorites.values() + if source.name == now_playing_media.station + or source.media_id == now_playing_media.album_id + ), + None, + ) + + def connect_update(self, hass, controller): + """ + Connect listener for when sources change and signal player update. + + EVENT_SOURCES_CHANGED is often raised multiple times in response to a + physical event therefore throttle it. Retrieving sources immediately + after the event may fail so retry. + """ + + @Throttle(MIN_UPDATE_SOURCES) + async def get_sources(): + retry_attempts = 0 + while True: + try: + favorites = {} + if controller.is_signed_in: + favorites = await controller.get_favorites() + inputs = await controller.get_input_sources() + return favorites, inputs + except HeosError as error: + if retry_attempts < self.max_retry_attempts: + retry_attempts += 1 + _LOGGER.debug( + "Error retrieving sources and will retry: %s", error + ) + await asyncio.sleep(self.retry_delay) + else: + _LOGGER.error("Unable to update sources: %s", error) + return + + async def update_sources(event, data=None): + if event in ( + heos_const.EVENT_SOURCES_CHANGED, + heos_const.EVENT_USER_CHANGED, + heos_const.EVENT_CONNECTED, + ): + sources = await get_sources() + # If throttled, it will return None + if sources: + self.favorites, self.inputs = sources + self.source_list = self._build_source_list() + _LOGGER.debug("Sources updated due to changed event") + # Let players know to update + hass.helpers.dispatcher.async_dispatcher_send(SIGNAL_HEOS_UPDATED) + + controller.dispatcher.connect( + heos_const.SIGNAL_CONTROLLER_EVENT, update_sources + ) + controller.dispatcher.connect(heos_const.SIGNAL_HEOS_EVENT, update_sources) diff --git a/homeassistant/components/heos/config_flow.py b/homeassistant/components/heos/config_flow.py new file mode 100644 index 000000000..7e7fe0678 --- /dev/null +++ b/homeassistant/components/heos/config_flow.py @@ -0,0 +1,79 @@ +"""Config flow to configure Heos.""" +from urllib.parse import urlparse + +from pyheos import Heos, HeosError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components import ssdp +from homeassistant.const import CONF_HOST + +from .const import DATA_DISCOVERED_HOSTS, DOMAIN + + +def format_title(host: str) -> str: + """Format the title for config entries.""" + return f"Controller ({host})" + + +@config_entries.HANDLERS.register(DOMAIN) +class HeosFlowHandler(config_entries.ConfigFlow): + """Define a flow for HEOS.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + + async def async_step_ssdp(self, discovery_info): + """Handle a discovered Heos device.""" + # Store discovered host + hostname = urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION]).hostname + friendly_name = "{} ({})".format( + discovery_info[ssdp.ATTR_UPNP_FRIENDLY_NAME], hostname + ) + self.hass.data.setdefault(DATA_DISCOVERED_HOSTS, {}) + self.hass.data[DATA_DISCOVERED_HOSTS][friendly_name] = hostname + # Abort if other flows in progress or an entry already exists + if self._async_in_progress() or self._async_current_entries(): + return self.async_abort(reason="already_setup") + # Show selection form + return self.async_show_form(step_id="user") + + async def async_step_import(self, user_input=None): + """Occurs when an entry is setup through config.""" + host = user_input[CONF_HOST] + return self.async_create_entry(title=format_title(host), data={CONF_HOST: host}) + + async def async_step_user(self, user_input=None): + """Obtain host and validate connection.""" + self.hass.data.setdefault(DATA_DISCOVERED_HOSTS, {}) + # Only a single entry is needed for all devices + if self._async_current_entries(): + return self.async_abort(reason="already_setup") + # Try connecting to host if provided + errors = {} + host = None + if user_input is not None: + host = user_input[CONF_HOST] + # Map host from friendly name if in discovered hosts + host = self.hass.data[DATA_DISCOVERED_HOSTS].get(host, host) + heos = Heos(host) + try: + await heos.connect() + self.hass.data.pop(DATA_DISCOVERED_HOSTS) + return await self.async_step_import({CONF_HOST: host}) + except HeosError: + errors[CONF_HOST] = "connection_failure" + finally: + await heos.disconnect() + + # Return form + host_type = ( + str + if not self.hass.data[DATA_DISCOVERED_HOSTS] + else vol.In(list(self.hass.data[DATA_DISCOVERED_HOSTS])) + ) + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): host_type}), + errors=errors, + ) diff --git a/homeassistant/components/heos/const.py b/homeassistant/components/heos/const.py new file mode 100644 index 000000000..503df40cc --- /dev/null +++ b/homeassistant/components/heos/const.py @@ -0,0 +1,13 @@ +"""Const for the HEOS integration.""" + +ATTR_PASSWORD = "password" +ATTR_USERNAME = "username" +COMMAND_RETRY_ATTEMPTS = 2 +COMMAND_RETRY_DELAY = 1 +DATA_CONTROLLER_MANAGER = "controller" +DATA_SOURCE_MANAGER = "source_manager" +DATA_DISCOVERED_HOSTS = "heos_discovered_hosts" +DOMAIN = "heos" +SERVICE_SIGN_IN = "sign_in" +SERVICE_SIGN_OUT = "sign_out" +SIGNAL_HEOS_UPDATED = "heos_updated" diff --git a/homeassistant/components/heos/manifest.json b/homeassistant/components/heos/manifest.json new file mode 100644 index 000000000..684127e51 --- /dev/null +++ b/homeassistant/components/heos/manifest.json @@ -0,0 +1,18 @@ +{ + "domain": "heos", + "name": "HEOS", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/heos", + "requirements": [ + "pyheos==0.6.0" + ], + "ssdp": [ + { + "st": "urn:schemas-denon-com:device:ACT-Denon:1" + } + ], + "dependencies": [], + "codeowners": [ + "@andrewsayre" + ] +} diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py new file mode 100644 index 000000000..10ea28ca1 --- /dev/null +++ b/homeassistant/components/heos/media_player.py @@ -0,0 +1,387 @@ +"""Denon HEOS Media Player.""" +from functools import reduce, wraps +import logging +from operator import ior +from typing import Sequence + +from pyheos import HeosError, const as heos_const + +from homeassistant.components.media_player import MediaPlayerDevice +from homeassistant.components.media_player.const import ( + ATTR_MEDIA_ENQUEUE, + DOMAIN, + MEDIA_TYPE_MUSIC, + MEDIA_TYPE_PLAYLIST, + MEDIA_TYPE_URL, + SUPPORT_CLEAR_PLAYLIST, + SUPPORT_NEXT_TRACK, + SUPPORT_PAUSE, + SUPPORT_PLAY, + SUPPORT_PLAY_MEDIA, + SUPPORT_PREVIOUS_TRACK, + SUPPORT_SELECT_SOURCE, + SUPPORT_SHUFFLE_SET, + SUPPORT_STOP, + SUPPORT_VOLUME_MUTE, + SUPPORT_VOLUME_SET, + SUPPORT_VOLUME_STEP, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_IDLE, STATE_PAUSED, STATE_PLAYING +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.util.dt import utcnow + +from .const import DATA_SOURCE_MANAGER, DOMAIN as HEOS_DOMAIN, SIGNAL_HEOS_UPDATED + +BASE_SUPPORTED_FEATURES = ( + SUPPORT_VOLUME_MUTE + | SUPPORT_VOLUME_SET + | SUPPORT_VOLUME_STEP + | SUPPORT_CLEAR_PLAYLIST + | SUPPORT_SHUFFLE_SET + | SUPPORT_SELECT_SOURCE + | SUPPORT_PLAY_MEDIA +) + +PLAY_STATE_TO_STATE = { + heos_const.PLAY_STATE_PLAY: STATE_PLAYING, + heos_const.PLAY_STATE_STOP: STATE_IDLE, + heos_const.PLAY_STATE_PAUSE: STATE_PAUSED, +} + +CONTROL_TO_SUPPORT = { + heos_const.CONTROL_PLAY: SUPPORT_PLAY, + heos_const.CONTROL_PAUSE: SUPPORT_PAUSE, + heos_const.CONTROL_STOP: SUPPORT_STOP, + heos_const.CONTROL_PLAY_PREVIOUS: SUPPORT_PREVIOUS_TRACK, + heos_const.CONTROL_PLAY_NEXT: SUPPORT_NEXT_TRACK, +} + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Platform uses config entry setup.""" + pass + + +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +): + """Add media players for a config entry.""" + players = hass.data[HEOS_DOMAIN][DOMAIN] + devices = [HeosMediaPlayer(player) for player in players.values()] + async_add_entities(devices, True) + + +def log_command_error(command: str): + """Return decorator that logs command failure.""" + + def decorator(func): + @wraps(func) + async def wrapper(*args, **kwargs): + try: + await func(*args, **kwargs) + except (HeosError, ValueError) as ex: + _LOGGER.error("Unable to %s: %s", command, ex) + + return wrapper + + return decorator + + +class HeosMediaPlayer(MediaPlayerDevice): + """The HEOS player.""" + + def __init__(self, player): + """Initialize.""" + self._media_position_updated_at = None + self._player = player + self._signals = [] + self._supported_features = BASE_SUPPORTED_FEATURES + self._source_manager = None + + async def _player_update(self, player_id, event): + """Handle player attribute updated.""" + if self._player.player_id != player_id: + return + if event == heos_const.EVENT_PLAYER_NOW_PLAYING_PROGRESS: + self._media_position_updated_at = utcnow() + await self.async_update_ha_state(True) + + async def _heos_updated(self): + """Handle sources changed.""" + await self.async_update_ha_state(True) + + async def async_added_to_hass(self): + """Device added to hass.""" + self._source_manager = self.hass.data[HEOS_DOMAIN][DATA_SOURCE_MANAGER] + # Update state when attributes of the player change + self._signals.append( + self._player.heos.dispatcher.connect( + heos_const.SIGNAL_PLAYER_EVENT, self._player_update + ) + ) + # Update state when heos changes + self._signals.append( + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_HEOS_UPDATED, self._heos_updated + ) + ) + + @log_command_error("clear playlist") + async def async_clear_playlist(self): + """Clear players playlist.""" + await self._player.clear_queue() + + @log_command_error("pause") + async def async_media_pause(self): + """Send pause command.""" + await self._player.pause() + + @log_command_error("play") + async def async_media_play(self): + """Send play command.""" + await self._player.play() + + @log_command_error("move to previous track") + async def async_media_previous_track(self): + """Send previous track command.""" + await self._player.play_previous() + + @log_command_error("move to next track") + async def async_media_next_track(self): + """Send next track command.""" + await self._player.play_next() + + @log_command_error("stop") + async def async_media_stop(self): + """Send stop command.""" + await self._player.stop() + + @log_command_error("set mute") + async def async_mute_volume(self, mute): + """Mute the volume.""" + await self._player.set_mute(mute) + + @log_command_error("play media") + async def async_play_media(self, media_type, media_id, **kwargs): + """Play a piece of media.""" + if media_type == MEDIA_TYPE_URL: + await self._player.play_url(media_id) + return + + if media_type == "quick_select": + # media_id may be an int or a str + selects = await self._player.get_quick_selects() + try: + index = int(media_id) + except ValueError: + # Try finding index by name + index = next( + (index for index, select in selects.items() if select == media_id), + None, + ) + if index is None: + raise ValueError(f"Invalid quick select '{media_id}'") + await self._player.play_quick_select(index) + return + + if media_type == MEDIA_TYPE_PLAYLIST: + playlists = await self._player.heos.get_playlists() + playlist = next((p for p in playlists if p.name == media_id), None) + if not playlist: + raise ValueError(f"Invalid playlist '{media_id}'") + add_queue_option = ( + heos_const.ADD_QUEUE_ADD_TO_END + if kwargs.get(ATTR_MEDIA_ENQUEUE) + else heos_const.ADD_QUEUE_REPLACE_AND_PLAY + ) + await self._player.add_to_queue(playlist, add_queue_option) + return + + if media_type == "favorite": + # media_id may be an int or str + try: + index = int(media_id) + except ValueError: + # Try finding index by name + index = next( + ( + index + for index, favorite in self._source_manager.favorites.items() + if favorite.name == media_id + ), + None, + ) + if index is None: + raise ValueError(f"Invalid favorite '{media_id}'") + await self._player.play_favorite(index) + return + + raise ValueError(f"Unsupported media type '{media_type}'") + + @log_command_error("select source") + async def async_select_source(self, source): + """Select input source.""" + await self._source_manager.play_source(source, self._player) + + @log_command_error("set shuffle") + async def async_set_shuffle(self, shuffle): + """Enable/disable shuffle mode.""" + await self._player.set_play_mode(self._player.repeat, shuffle) + + @log_command_error("set volume level") + async def async_set_volume_level(self, volume): + """Set volume level, range 0..1.""" + await self._player.set_volume(int(volume * 100)) + + async def async_update(self): + """Update supported features of the player.""" + controls = self._player.now_playing_media.supported_controls + current_support = [CONTROL_TO_SUPPORT[control] for control in controls] + self._supported_features = reduce(ior, current_support, BASE_SUPPORTED_FEATURES) + + async def async_will_remove_from_hass(self): + """Disconnect the device when removed.""" + for signal_remove in self._signals: + signal_remove() + self._signals.clear() + + @property + def available(self) -> bool: + """Return True if the device is available.""" + return self._player.available + + @property + def device_info(self) -> dict: + """Get attributes about the device.""" + return { + "identifiers": {(HEOS_DOMAIN, self._player.player_id)}, + "name": self._player.name, + "model": self._player.model, + "manufacturer": "HEOS", + "sw_version": self._player.version, + } + + @property + def device_state_attributes(self) -> dict: + """Get additional attribute about the state.""" + return { + "media_album_id": self._player.now_playing_media.album_id, + "media_queue_id": self._player.now_playing_media.queue_id, + "media_source_id": self._player.now_playing_media.source_id, + "media_station": self._player.now_playing_media.station, + "media_type": self._player.now_playing_media.type, + } + + @property + def is_volume_muted(self) -> bool: + """Boolean if volume is currently muted.""" + return self._player.is_muted + + @property + def media_album_name(self) -> str: + """Album name of current playing media, music track only.""" + return self._player.now_playing_media.album + + @property + def media_artist(self) -> str: + """Artist of current playing media, music track only.""" + return self._player.now_playing_media.artist + + @property + def media_content_id(self) -> str: + """Content ID of current playing media.""" + return self._player.now_playing_media.media_id + + @property + def media_content_type(self) -> str: + """Content type of current playing media.""" + return MEDIA_TYPE_MUSIC + + @property + def media_duration(self): + """Duration of current playing media in seconds.""" + duration = self._player.now_playing_media.duration + if isinstance(duration, int): + return duration / 1000 + return None + + @property + def media_position(self): + """Position of current playing media in seconds.""" + # Some media doesn't have duration but reports position, return None + if not self._player.now_playing_media.duration: + return None + return self._player.now_playing_media.current_position / 1000 + + @property + def media_position_updated_at(self): + """When was the position of the current playing media valid.""" + # Some media doesn't have duration but reports position, return None + if not self._player.now_playing_media.duration: + return None + return self._media_position_updated_at + + @property + def media_image_remotely_accessible(self) -> bool: + """If the image url is remotely accessible.""" + return True + + @property + def media_image_url(self) -> str: + """Image url of current playing media.""" + # May be an empty string, if so, return None + image_url = self._player.now_playing_media.image_url + return image_url if image_url else None + + @property + def media_title(self) -> str: + """Title of current playing media.""" + return self._player.now_playing_media.song + + @property + def name(self) -> str: + """Return the name of the device.""" + return self._player.name + + @property + def should_poll(self) -> bool: + """No polling needed for this device.""" + return False + + @property + def shuffle(self) -> bool: + """Boolean if shuffle is enabled.""" + return self._player.shuffle + + @property + def source(self) -> str: + """Name of the current input source.""" + return self._source_manager.get_current_source(self._player.now_playing_media) + + @property + def source_list(self) -> Sequence[str]: + """List of available input sources.""" + return self._source_manager.source_list + + @property + def state(self) -> str: + """State of the player.""" + return PLAY_STATE_TO_STATE[self._player.state] + + @property + def supported_features(self) -> int: + """Flag media player features that are supported.""" + return self._supported_features + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return str(self._player.player_id) + + @property + def volume_level(self) -> float: + """Volume level of the media player (0..1).""" + return self._player.volume / 100 diff --git a/homeassistant/components/heos/services.py b/homeassistant/components/heos/services.py new file mode 100644 index 000000000..ee5df1b48 --- /dev/null +++ b/homeassistant/components/heos/services.py @@ -0,0 +1,73 @@ +"""Services for the HEOS integration.""" +import functools +import logging + +from pyheos import CommandFailedError, Heos, HeosError, const +import voluptuous as vol + +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import HomeAssistantType + +from .const import ( + ATTR_PASSWORD, + ATTR_USERNAME, + DOMAIN, + SERVICE_SIGN_IN, + SERVICE_SIGN_OUT, +) + +_LOGGER = logging.getLogger(__name__) + +HEOS_SIGN_IN_SCHEMA = vol.Schema( + {vol.Required(ATTR_USERNAME): cv.string, vol.Required(ATTR_PASSWORD): cv.string} +) + +HEOS_SIGN_OUT_SCHEMA = vol.Schema({}) + + +def register(hass: HomeAssistantType, controller: Heos): + """Register HEOS services.""" + hass.services.async_register( + DOMAIN, + SERVICE_SIGN_IN, + functools.partial(_sign_in_handler, controller), + schema=HEOS_SIGN_IN_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_SIGN_OUT, + functools.partial(_sign_out_handler, controller), + schema=HEOS_SIGN_OUT_SCHEMA, + ) + + +def remove(hass: HomeAssistantType): + """Unregister HEOS services.""" + hass.services.async_remove(DOMAIN, SERVICE_SIGN_IN) + hass.services.async_remove(DOMAIN, SERVICE_SIGN_OUT) + + +async def _sign_in_handler(controller, service): + """Sign in to the HEOS account.""" + if controller.connection_state != const.STATE_CONNECTED: + _LOGGER.error("Unable to sign in because HEOS is not connected") + return + username = service.data[ATTR_USERNAME] + password = service.data[ATTR_PASSWORD] + try: + await controller.sign_in(username, password) + except CommandFailedError as err: + _LOGGER.error("Sign in failed: %s", err) + except HeosError as err: + _LOGGER.error("Unable to sign in: %s", err) + + +async def _sign_out_handler(controller, service): + """Sign out of the HEOS account.""" + if controller.connection_state != const.STATE_CONNECTED: + _LOGGER.error("Unable to sign out because HEOS is not connected") + return + try: + await controller.sign_out() + except HeosError as err: + _LOGGER.error("Unable to sign out: %s", err) diff --git a/homeassistant/components/heos/services.yaml b/homeassistant/components/heos/services.yaml new file mode 100644 index 000000000..827424036 --- /dev/null +++ b/homeassistant/components/heos/services.yaml @@ -0,0 +1,12 @@ +sign_in: + description: Sign the controller in to a HEOS account. + fields: + username: + description: The username or email of the HEOS account. [Required] + example: 'example@example.com' + password: + description: The password of the HEOS account. [Required] + example: 'password' + +sign_out: + description: Sign the controller out of the HEOS account. \ No newline at end of file diff --git a/homeassistant/components/heos/strings.json b/homeassistant/components/heos/strings.json new file mode 100644 index 000000000..9a00ac6a4 --- /dev/null +++ b/homeassistant/components/heos/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "title": "HEOS", + "step": { + "user": { + "title": "Connect to Heos", + "description": "Please enter the host name or IP address of a Heos device (preferably one connected via wire to the network).", + "data": { + "access_token": "Host", + "host": "Host" + } + } + }, + "error": { + "connection_failure": "Unable to connect to the specified host." + }, + "abort": { + "already_setup": "You can only configure a single Heos connection as it will support all devices on the network." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/here_travel_time/__init__.py b/homeassistant/components/here_travel_time/__init__.py new file mode 100644 index 000000000..9a5c8ec32 --- /dev/null +++ b/homeassistant/components/here_travel_time/__init__.py @@ -0,0 +1 @@ +"""The here_travel_time component.""" diff --git a/homeassistant/components/here_travel_time/manifest.json b/homeassistant/components/here_travel_time/manifest.json new file mode 100644 index 000000000..da2c03b1a --- /dev/null +++ b/homeassistant/components/here_travel_time/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "here_travel_time", + "name": "HERE travel time", + "documentation": "https://www.home-assistant.io/integrations/here_travel_time", + "requirements": [ + "herepy==2.0.0" + ], + "dependencies": [], + "codeowners": [ + "@eifinger" + ] + } diff --git a/homeassistant/components/here_travel_time/sensor.py b/homeassistant/components/here_travel_time/sensor.py new file mode 100644 index 000000000..e482943ef --- /dev/null +++ b/homeassistant/components/here_travel_time/sensor.py @@ -0,0 +1,456 @@ +"""Support for HERE travel time sensors.""" +from datetime import timedelta +import logging +from typing import Callable, Dict, Optional, Union + +import herepy +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_LATITUDE, + ATTR_LONGITUDE, + ATTR_MODE, + CONF_MODE, + CONF_NAME, + CONF_UNIT_SYSTEM, + CONF_UNIT_SYSTEM_IMPERIAL, + CONF_UNIT_SYSTEM_METRIC, + EVENT_HOMEASSISTANT_START, +) +from homeassistant.core import HomeAssistant, State, callback +from homeassistant.helpers import location +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +CONF_DESTINATION_LATITUDE = "destination_latitude" +CONF_DESTINATION_LONGITUDE = "destination_longitude" +CONF_DESTINATION_ENTITY_ID = "destination_entity_id" +CONF_ORIGIN_LATITUDE = "origin_latitude" +CONF_ORIGIN_LONGITUDE = "origin_longitude" +CONF_ORIGIN_ENTITY_ID = "origin_entity_id" +CONF_API_KEY = "api_key" +CONF_TRAFFIC_MODE = "traffic_mode" +CONF_ROUTE_MODE = "route_mode" + +DEFAULT_NAME = "HERE Travel Time" + +TRAVEL_MODE_BICYCLE = "bicycle" +TRAVEL_MODE_CAR = "car" +TRAVEL_MODE_PEDESTRIAN = "pedestrian" +TRAVEL_MODE_PUBLIC = "publicTransport" +TRAVEL_MODE_PUBLIC_TIME_TABLE = "publicTransportTimeTable" +TRAVEL_MODE_TRUCK = "truck" +TRAVEL_MODE = [ + TRAVEL_MODE_BICYCLE, + TRAVEL_MODE_CAR, + TRAVEL_MODE_PEDESTRIAN, + TRAVEL_MODE_PUBLIC, + TRAVEL_MODE_PUBLIC_TIME_TABLE, + TRAVEL_MODE_TRUCK, +] + +TRAVEL_MODES_PUBLIC = [TRAVEL_MODE_PUBLIC, TRAVEL_MODE_PUBLIC_TIME_TABLE] +TRAVEL_MODES_VEHICLE = [TRAVEL_MODE_CAR, TRAVEL_MODE_TRUCK] +TRAVEL_MODES_NON_VEHICLE = [TRAVEL_MODE_BICYCLE, TRAVEL_MODE_PEDESTRIAN] + +TRAFFIC_MODE_ENABLED = "traffic_enabled" +TRAFFIC_MODE_DISABLED = "traffic_disabled" + +ROUTE_MODE_FASTEST = "fastest" +ROUTE_MODE_SHORTEST = "shortest" +ROUTE_MODE = [ROUTE_MODE_FASTEST, ROUTE_MODE_SHORTEST] + +ICON_BICYCLE = "mdi:bike" +ICON_CAR = "mdi:car" +ICON_PEDESTRIAN = "mdi:walk" +ICON_PUBLIC = "mdi:bus" +ICON_TRUCK = "mdi:truck" + +UNITS = [CONF_UNIT_SYSTEM_METRIC, CONF_UNIT_SYSTEM_IMPERIAL] + +ATTR_DURATION = "duration" +ATTR_DISTANCE = "distance" +ATTR_ROUTE = "route" +ATTR_ORIGIN = "origin" +ATTR_DESTINATION = "destination" + +ATTR_UNIT_SYSTEM = CONF_UNIT_SYSTEM +ATTR_TRAFFIC_MODE = CONF_TRAFFIC_MODE + +ATTR_DURATION_IN_TRAFFIC = "duration_in_traffic" +ATTR_ORIGIN_NAME = "origin_name" +ATTR_DESTINATION_NAME = "destination_name" + +UNIT_OF_MEASUREMENT = "min" + +SCAN_INTERVAL = timedelta(minutes=5) + +NO_ROUTE_ERROR_MESSAGE = "HERE could not find a route based on the input" + +PLATFORM_SCHEMA = vol.All( + cv.has_at_least_one_key(CONF_DESTINATION_LATITUDE, CONF_DESTINATION_ENTITY_ID), + cv.has_at_least_one_key(CONF_ORIGIN_LATITUDE, CONF_ORIGIN_ENTITY_ID), + PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_API_KEY): cv.string, + vol.Inclusive( + CONF_DESTINATION_LATITUDE, "destination_coordinates" + ): cv.latitude, + vol.Inclusive( + CONF_DESTINATION_LONGITUDE, "destination_coordinates" + ): cv.longitude, + vol.Exclusive(CONF_DESTINATION_LATITUDE, "destination"): cv.latitude, + vol.Exclusive(CONF_DESTINATION_ENTITY_ID, "destination"): cv.entity_id, + vol.Inclusive(CONF_ORIGIN_LATITUDE, "origin_coordinates"): cv.latitude, + vol.Inclusive(CONF_ORIGIN_LONGITUDE, "origin_coordinates"): cv.longitude, + vol.Exclusive(CONF_ORIGIN_LATITUDE, "origin"): cv.latitude, + vol.Exclusive(CONF_ORIGIN_ENTITY_ID, "origin"): cv.entity_id, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_MODE, default=TRAVEL_MODE_CAR): vol.In(TRAVEL_MODE), + vol.Optional(CONF_ROUTE_MODE, default=ROUTE_MODE_FASTEST): vol.In( + ROUTE_MODE + ), + vol.Optional(CONF_TRAFFIC_MODE, default=False): cv.boolean, + vol.Optional(CONF_UNIT_SYSTEM): vol.In(UNITS), + } + ), +) + + +async def async_setup_platform( + hass: HomeAssistant, + config: Dict[str, Union[str, bool]], + async_add_entities: Callable, + discovery_info: None = None, +) -> None: + """Set up the HERE travel time platform.""" + + api_key = config[CONF_API_KEY] + here_client = herepy.RoutingApi(api_key) + + if not await hass.async_add_executor_job( + _are_valid_client_credentials, here_client + ): + _LOGGER.error( + "Invalid credentials. This error is returned if the specified token was invalid or no contract could be found for this token." + ) + return + + if config.get(CONF_ORIGIN_LATITUDE) is not None: + origin = f"{config[CONF_ORIGIN_LATITUDE]},{config[CONF_ORIGIN_LONGITUDE]}" + origin_entity_id = None + else: + origin = None + origin_entity_id = config[CONF_ORIGIN_ENTITY_ID] + + if config.get(CONF_DESTINATION_LATITUDE) is not None: + destination = ( + f"{config[CONF_DESTINATION_LATITUDE]},{config[CONF_DESTINATION_LONGITUDE]}" + ) + destination_entity_id = None + else: + destination = None + destination_entity_id = config[CONF_DESTINATION_ENTITY_ID] + + travel_mode = config[CONF_MODE] + traffic_mode = config[CONF_TRAFFIC_MODE] + route_mode = config[CONF_ROUTE_MODE] + name = config[CONF_NAME] + units = config.get(CONF_UNIT_SYSTEM, hass.config.units.name) + + here_data = HERETravelTimeData( + here_client, travel_mode, traffic_mode, route_mode, units + ) + + sensor = HERETravelTimeSensor( + name, origin, destination, origin_entity_id, destination_entity_id, here_data + ) + + async_add_entities([sensor]) + + +def _are_valid_client_credentials(here_client: herepy.RoutingApi) -> bool: + """Check if the provided credentials are correct using defaults.""" + known_working_origin = [38.9, -77.04833] + known_working_destination = [39.0, -77.1] + try: + here_client.car_route( + known_working_origin, + known_working_destination, + [ + herepy.RouteMode[ROUTE_MODE_FASTEST], + herepy.RouteMode[TRAVEL_MODE_CAR], + herepy.RouteMode[TRAFFIC_MODE_DISABLED], + ], + ) + except herepy.InvalidCredentialsError: + return False + return True + + +class HERETravelTimeSensor(Entity): + """Representation of a HERE travel time sensor.""" + + def __init__( + self, + name: str, + origin: str, + destination: str, + origin_entity_id: str, + destination_entity_id: str, + here_data: "HERETravelTimeData", + ) -> None: + """Initialize the sensor.""" + self._name = name + self._origin_entity_id = origin_entity_id + self._destination_entity_id = destination_entity_id + self._here_data = here_data + self._unit_of_measurement = UNIT_OF_MEASUREMENT + self._attrs = { + ATTR_UNIT_SYSTEM: self._here_data.units, + ATTR_MODE: self._here_data.travel_mode, + ATTR_TRAFFIC_MODE: self._here_data.traffic_mode, + } + if self._origin_entity_id is None: + self._here_data.origin = origin + + if self._destination_entity_id is None: + self._here_data.destination = destination + + async def async_added_to_hass(self) -> None: + """Delay the sensor update to avoid entity not found warnings.""" + + @callback + def delayed_sensor_update(event): + """Update sensor after homeassistant started.""" + self.async_schedule_update_ha_state(True) + + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, delayed_sensor_update + ) + + @property + def state(self) -> Optional[str]: + """Return the state of the sensor.""" + if self._here_data.traffic_mode: + if self._here_data.traffic_time is not None: + return str(round(self._here_data.traffic_time / 60)) + if self._here_data.base_time is not None: + return str(round(self._here_data.base_time / 60)) + + return None + + @property + def name(self) -> str: + """Get the name of the sensor.""" + return self._name + + @property + def device_state_attributes( + self, + ) -> Optional[Dict[str, Union[None, float, str, bool]]]: + """Return the state attributes.""" + if self._here_data.base_time is None: + return None + + res = self._attrs + if self._here_data.attribution is not None: + res[ATTR_ATTRIBUTION] = self._here_data.attribution + res[ATTR_DURATION] = self._here_data.base_time / 60 + res[ATTR_DISTANCE] = self._here_data.distance + res[ATTR_ROUTE] = self._here_data.route + res[ATTR_DURATION_IN_TRAFFIC] = self._here_data.traffic_time / 60 + res[ATTR_ORIGIN] = self._here_data.origin + res[ATTR_DESTINATION] = self._here_data.destination + res[ATTR_ORIGIN_NAME] = self._here_data.origin_name + res[ATTR_DESTINATION_NAME] = self._here_data.destination_name + return res + + @property + def unit_of_measurement(self) -> str: + """Return the unit this state is expressed in.""" + return self._unit_of_measurement + + @property + def icon(self) -> str: + """Icon to use in the frontend depending on travel_mode.""" + if self._here_data.travel_mode == TRAVEL_MODE_BICYCLE: + return ICON_BICYCLE + if self._here_data.travel_mode == TRAVEL_MODE_PEDESTRIAN: + return ICON_PEDESTRIAN + if self._here_data.travel_mode in TRAVEL_MODES_PUBLIC: + return ICON_PUBLIC + if self._here_data.travel_mode == TRAVEL_MODE_TRUCK: + return ICON_TRUCK + return ICON_CAR + + async def async_update(self) -> None: + """Update Sensor Information.""" + # Convert device_trackers to HERE friendly location + if self._origin_entity_id is not None: + self._here_data.origin = await self._get_location_from_entity( + self._origin_entity_id + ) + + if self._destination_entity_id is not None: + self._here_data.destination = await self._get_location_from_entity( + self._destination_entity_id + ) + + await self.hass.async_add_executor_job(self._here_data.update) + + async def _get_location_from_entity(self, entity_id: str) -> Optional[str]: + """Get the location from the entity state or attributes.""" + entity = self.hass.states.get(entity_id) + + if entity is None: + _LOGGER.error("Unable to find entity %s", entity_id) + return None + + # Check if the entity has location attributes + if location.has_location(entity): + return self._get_location_from_attributes(entity) + + # Check if device is in a zone + zone_entity = self.hass.states.get("zone.{}".format(entity.state)) + if location.has_location(zone_entity): + _LOGGER.debug( + "%s is in %s, getting zone location", entity_id, zone_entity.entity_id + ) + return self._get_location_from_attributes(zone_entity) + + # Check if state is valid coordinate set + if self._entity_state_is_valid_coordinate_set(entity.state): + return entity.state + + _LOGGER.error( + "The state of %s is not a valid set of coordinates: %s", + entity_id, + entity.state, + ) + return None + + @staticmethod + def _entity_state_is_valid_coordinate_set(state: str) -> bool: + """Check that the given string is a valid set of coordinates.""" + schema = vol.Schema(cv.gps) + try: + coordinates = state.split(",") + schema(coordinates) + return True + except (vol.MultipleInvalid): + return False + + @staticmethod + def _get_location_from_attributes(entity: State) -> str: + """Get the lat/long string from an entities attributes.""" + attr = entity.attributes + return "{},{}".format(attr.get(ATTR_LATITUDE), attr.get(ATTR_LONGITUDE)) + + +class HERETravelTimeData: + """HERETravelTime data object.""" + + def __init__( + self, + here_client: herepy.RoutingApi, + travel_mode: str, + traffic_mode: bool, + route_mode: str, + units: str, + ) -> None: + """Initialize herepy.""" + self.origin = None + self.destination = None + self.travel_mode = travel_mode + self.traffic_mode = traffic_mode + self.route_mode = route_mode + self.attribution = None + self.traffic_time = None + self.distance = None + self.route = None + self.base_time = None + self.origin_name = None + self.destination_name = None + self.units = units + self._client = here_client + + def update(self) -> None: + """Get the latest data from HERE.""" + if self.traffic_mode: + traffic_mode = TRAFFIC_MODE_ENABLED + else: + traffic_mode = TRAFFIC_MODE_DISABLED + + if self.destination is not None and self.origin is not None: + # Convert location to HERE friendly location + destination = self.destination.split(",") + origin = self.origin.split(",") + + _LOGGER.debug( + "Requesting route for origin: %s, destination: %s, route_mode: %s, mode: %s, traffic_mode: %s", + origin, + destination, + herepy.RouteMode[self.route_mode], + herepy.RouteMode[self.travel_mode], + herepy.RouteMode[traffic_mode], + ) + try: + response = self._client.car_route( + origin, + destination, + [ + herepy.RouteMode[self.route_mode], + herepy.RouteMode[self.travel_mode], + herepy.RouteMode[traffic_mode], + ], + ) + except herepy.NoRouteFoundError: + # Better error message for cryptic no route error codes + _LOGGER.error(NO_ROUTE_ERROR_MESSAGE) + return + + _LOGGER.debug("Raw response is: %s", response.response) + + # pylint: disable=no-member + source_attribution = response.response.get("sourceAttribution") + if source_attribution is not None: + self.attribution = self._build_hass_attribution(source_attribution) + # pylint: disable=no-member + route = response.response["route"] + summary = route[0]["summary"] + waypoint = route[0]["waypoint"] + self.base_time = summary["baseTime"] + if self.travel_mode in TRAVEL_MODES_VEHICLE: + self.traffic_time = summary["trafficTime"] + else: + self.traffic_time = self.base_time + distance = summary["distance"] + if self.units == CONF_UNIT_SYSTEM_IMPERIAL: + # Convert to miles. + self.distance = distance / 1609.344 + else: + # Convert to kilometers + self.distance = distance / 1000 + # pylint: disable=no-member + self.route = response.route_short + self.origin_name = waypoint[0]["mappedRoadName"] + self.destination_name = waypoint[1]["mappedRoadName"] + + @staticmethod + def _build_hass_attribution(source_attribution: Dict) -> Optional[str]: + """Build a hass frontend ready string out of the sourceAttribution.""" + suppliers = source_attribution.get("supplier") + if suppliers is not None: + supplier_titles = [] + for supplier in suppliers: + title = supplier.get("title") + if title is not None: + supplier_titles.append(title) + joined_supplier_titles = ",".join(supplier_titles) + attribution = f"With the support of {joined_supplier_titles}. All information is provided without warranty of any kind." + return attribution diff --git a/homeassistant/components/hikvision/__init__.py b/homeassistant/components/hikvision/__init__.py new file mode 100644 index 000000000..dbf7991b3 --- /dev/null +++ b/homeassistant/components/hikvision/__init__.py @@ -0,0 +1 @@ +"""The hikvision component.""" diff --git a/homeassistant/components/hikvision/binary_sensor.py b/homeassistant/components/hikvision/binary_sensor.py new file mode 100644 index 000000000..9db912173 --- /dev/null +++ b/homeassistant/components/hikvision/binary_sensor.py @@ -0,0 +1,295 @@ +"""Support for Hikvision event stream events represented as binary sensors.""" +from datetime import timedelta +import logging + +from pyhik.hikvision import HikCamera +import voluptuous as vol + +from homeassistant.components.binary_sensor import PLATFORM_SCHEMA, BinarySensorDevice +from homeassistant.const import ( + ATTR_LAST_TRIP_TIME, + CONF_CUSTOMIZE, + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, + EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import track_point_in_utc_time +from homeassistant.util.dt import utcnow + +_LOGGER = logging.getLogger(__name__) + +CONF_IGNORED = "ignored" +CONF_DELAY = "delay" + +DEFAULT_PORT = 80 +DEFAULT_IGNORED = False +DEFAULT_DELAY = 0 + +ATTR_DELAY = "delay" + +DEVICE_CLASS_MAP = { + "Motion": "motion", + "Line Crossing": "motion", + "Field Detection": "motion", + "Video Loss": None, + "Tamper Detection": "motion", + "Shelter Alarm": None, + "Disk Full": None, + "Disk Error": None, + "Net Interface Broken": "connectivity", + "IP Conflict": "connectivity", + "Illegal Access": None, + "Video Mismatch": None, + "Bad Video": None, + "PIR Alarm": "motion", + "Face Detection": "motion", + "Scene Change Detection": "motion", + "I/O": None, + "Unattended Baggage": "motion", + "Attended Baggage": "motion", + "Recording Failure": None, + "Exiting Region": "motion", + "Entering Region": "motion", +} + +CUSTOMIZE_SCHEMA = vol.Schema( + { + vol.Optional(CONF_IGNORED, default=DEFAULT_IGNORED): cv.boolean, + vol.Optional(CONF_DELAY, default=DEFAULT_DELAY): cv.positive_int, + } +) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_NAME): cv.string, + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_SSL, default=False): cv.boolean, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_CUSTOMIZE, default={}): vol.Schema( + {cv.string: CUSTOMIZE_SCHEMA} + ), + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Hikvision binary sensor devices.""" + name = config.get(CONF_NAME) + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + + customize = config.get(CONF_CUSTOMIZE) + + if config.get(CONF_SSL): + protocol = "https" + else: + protocol = "http" + + url = f"{protocol}://{host}" + + data = HikvisionData(hass, url, port, name, username, password) + + if data.sensors is None: + _LOGGER.error("Hikvision event stream has no data, unable to set up") + return False + + entities = [] + + for sensor, channel_list in data.sensors.items(): + for channel in channel_list: + # Build sensor name, then parse customize config. + if data.type == "NVR": + sensor_name = "{}_{}".format(sensor.replace(" ", "_"), channel[1]) + else: + sensor_name = sensor.replace(" ", "_") + + custom = customize.get(sensor_name.lower(), {}) + ignore = custom.get(CONF_IGNORED) + delay = custom.get(CONF_DELAY) + + _LOGGER.debug( + "Entity: %s - %s, Options - Ignore: %s, Delay: %s", + data.name, + sensor_name, + ignore, + delay, + ) + if not ignore: + entities.append( + HikvisionBinarySensor(hass, sensor, channel[1], data, delay) + ) + + add_entities(entities) + + +class HikvisionData: + """Hikvision device event stream object.""" + + def __init__(self, hass, url, port, name, username, password): + """Initialize the data object.""" + + self._url = url + self._port = port + self._name = name + self._username = username + self._password = password + + # Establish camera + self.camdata = HikCamera(self._url, self._port, self._username, self._password) + + if self._name is None: + self._name = self.camdata.get_name + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, self.stop_hik) + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, self.start_hik) + + def stop_hik(self, event): + """Shutdown Hikvision subscriptions and subscription thread on exit.""" + self.camdata.disconnect() + + def start_hik(self, event): + """Start Hikvision event stream thread.""" + self.camdata.start_stream() + + @property + def sensors(self): + """Return list of available sensors and their states.""" + return self.camdata.current_event_states + + @property + def cam_id(self): + """Return device id.""" + return self.camdata.get_id + + @property + def name(self): + """Return device name.""" + return self._name + + @property + def type(self): + """Return device type.""" + return self.camdata.get_type + + def get_attributes(self, sensor, channel): + """Return attribute list for sensor/channel.""" + return self.camdata.fetch_attributes(sensor, channel) + + +class HikvisionBinarySensor(BinarySensorDevice): + """Representation of a Hikvision binary sensor.""" + + def __init__(self, hass, sensor, channel, cam, delay): + """Initialize the binary_sensor.""" + self._hass = hass + self._cam = cam + self._sensor = sensor + self._channel = channel + + if self._cam.type == "NVR": + self._name = f"{self._cam.name} {sensor} {channel}" + else: + self._name = f"{self._cam.name} {sensor}" + + self._id = f"{self._cam.cam_id}.{sensor}.{channel}" + + if delay is None: + self._delay = 0 + else: + self._delay = delay + + self._timer = None + + # Register callback function with pyHik + self._cam.camdata.add_update_callback(self._update_callback, self._id) + + def _sensor_state(self): + """Extract sensor state.""" + return self._cam.get_attributes(self._sensor, self._channel)[0] + + def _sensor_last_update(self): + """Extract sensor last update time.""" + return self._cam.get_attributes(self._sensor, self._channel)[3] + + @property + def name(self): + """Return the name of the Hikvision sensor.""" + return self._name + + @property + def unique_id(self): + """Return a unique ID.""" + return self._id + + @property + def is_on(self): + """Return true if sensor is on.""" + return self._sensor_state() + + @property + def device_class(self): + """Return the class of this sensor, from DEVICE_CLASSES.""" + try: + return DEVICE_CLASS_MAP[self._sensor] + except KeyError: + # Sensor must be unknown to us, add as generic + return None + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def device_state_attributes(self): + """Return the state attributes.""" + attr = {} + attr[ATTR_LAST_TRIP_TIME] = self._sensor_last_update() + + if self._delay != 0: + attr[ATTR_DELAY] = self._delay + + return attr + + def _update_callback(self, msg): + """Update the sensor's state, if needed.""" + _LOGGER.debug("Callback signal from: %s", msg) + + if self._delay > 0 and not self.is_on: + # Set timer to wait until updating the state + def _delay_update(now): + """Timer callback for sensor update.""" + _LOGGER.debug( + "%s Called delayed (%ssec) update", self._name, self._delay + ) + self.schedule_update_ha_state() + self._timer = None + + if self._timer is not None: + self._timer() + self._timer = None + + self._timer = track_point_in_utc_time( + self._hass, _delay_update, utcnow() + timedelta(seconds=self._delay) + ) + + elif self._delay > 0 and self.is_on: + # For delayed sensors kill any callbacks on true events and update + if self._timer is not None: + self._timer() + self._timer = None + + self.schedule_update_ha_state() + + else: + self.schedule_update_ha_state() diff --git a/homeassistant/components/hikvision/manifest.json b/homeassistant/components/hikvision/manifest.json new file mode 100644 index 000000000..d51718964 --- /dev/null +++ b/homeassistant/components/hikvision/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "hikvision", + "name": "Hikvision", + "documentation": "https://www.home-assistant.io/integrations/hikvision", + "requirements": [ + "pyhik==0.2.5" + ], + "dependencies": [], + "codeowners": [ + "@mezz64" + ] +} diff --git a/homeassistant/components/hikvisioncam/__init__.py b/homeassistant/components/hikvisioncam/__init__.py new file mode 100644 index 000000000..32a2a86b2 --- /dev/null +++ b/homeassistant/components/hikvisioncam/__init__.py @@ -0,0 +1 @@ +"""The hikvisioncam component.""" diff --git a/homeassistant/components/hikvisioncam/manifest.json b/homeassistant/components/hikvisioncam/manifest.json new file mode 100644 index 000000000..8dcef17fa --- /dev/null +++ b/homeassistant/components/hikvisioncam/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "hikvisioncam", + "name": "Hikvisioncam", + "documentation": "https://www.home-assistant.io/integrations/hikvisioncam", + "requirements": [ + "hikvision==0.4" + ], + "dependencies": [], + "codeowners": ["@fbradyirl"] +} diff --git a/homeassistant/components/hikvisioncam/switch.py b/homeassistant/components/hikvisioncam/switch.py new file mode 100644 index 000000000..f86853a54 --- /dev/null +++ b/homeassistant/components/hikvisioncam/switch.py @@ -0,0 +1,106 @@ +"""Support turning on/off motion detection on Hikvision cameras.""" +import logging + +import hikvision.api +from hikvision.error import HikvisionError, MissingParamError +import voluptuous as vol + +from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + STATE_OFF, + STATE_ON, +) +import homeassistant.helpers.config_validation as cv + +# This is the last working version, please test before updating + +_LOGGING = logging.getLogger(__name__) + +DEFAULT_NAME = "Hikvision Camera Motion Detection" +DEFAULT_PASSWORD = "12345" +DEFAULT_PORT = 80 +DEFAULT_USERNAME = "admin" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, + vol.Optional(CONF_PORT): cv.port, + vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up Hikvision camera.""" + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) + name = config.get(CONF_NAME) + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + + try: + hikvision_cam = hikvision.api.CreateDevice( + host, port=port, username=username, password=password, is_https=False + ) + except MissingParamError as param_err: + _LOGGING.error("Missing required param: %s", param_err) + return False + except HikvisionError as conn_err: + _LOGGING.error("Unable to connect: %s", conn_err) + return False + + add_entities([HikvisionMotionSwitch(name, hikvision_cam)]) + + +class HikvisionMotionSwitch(SwitchDevice): + """Representation of a switch to toggle on/off motion detection.""" + + def __init__(self, name, hikvision_cam): + """Initialize the switch.""" + self._name = name + self._hikvision_cam = hikvision_cam + self._state = STATE_OFF + + @property + def should_poll(self): + """Poll for status regularly.""" + return True + + @property + def name(self): + """Return the name of the device if any.""" + return self._name + + @property + def state(self): + """Return the state of the device if any.""" + return self._state + + @property + def is_on(self): + """Return true if device is on.""" + return self._state == STATE_ON + + def turn_on(self, **kwargs): + """Turn the device on.""" + _LOGGING.info("Turning on Motion Detection ") + self._hikvision_cam.enable_motion_detection() + + def turn_off(self, **kwargs): + """Turn the device off.""" + _LOGGING.info("Turning off Motion Detection ") + self._hikvision_cam.disable_motion_detection() + + def update(self): + """Update Motion Detection state.""" + enabled = self._hikvision_cam.is_motion_detection_enabled() + _LOGGING.info("enabled: %s", enabled) + + self._state = STATE_ON if enabled else STATE_OFF diff --git a/homeassistant/components/hisense_aehw4a1/.translations/bg.json b/homeassistant/components/hisense_aehw4a1/.translations/bg.json new file mode 100644 index 000000000..c758e9cc2 --- /dev/null +++ b/homeassistant/components/hisense_aehw4a1/.translations/bg.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u0412 \u043c\u0440\u0435\u0436\u0430\u0442\u0430 \u043d\u0435 \u0441\u0430 \u043d\u0430\u043c\u0435\u0440\u0435\u043d\u0438 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 Hisense AEH-W4A1.", + "single_instance_allowed": "\u0412\u044a\u0437\u043c\u043e\u0436\u043d\u0430 \u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f \u043d\u0430 Hisense AEH-W4A1." + }, + "step": { + "confirm": { + "description": "\u0418\u0441\u043a\u0430\u0442\u0435 \u043b\u0438 \u0434\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u0435 Hisense AEH-W4A1?", + "title": "Hisense AEH-W4A1" + } + }, + "title": "Hisense AEH-W4A1" + } +} \ No newline at end of file diff --git a/homeassistant/components/hisense_aehw4a1/.translations/ca.json b/homeassistant/components/hisense_aehw4a1/.translations/ca.json new file mode 100644 index 000000000..7b237aecd --- /dev/null +++ b/homeassistant/components/hisense_aehw4a1/.translations/ca.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "No s'ha trobat cap dispositiu AEH-W4A1 a la xarxa.", + "single_instance_allowed": "Nom\u00e9s \u00e9s possible una \u00fanica configuraci\u00f3 del AEH-W4A1 de Hisense." + }, + "step": { + "confirm": { + "description": "Vols configurar AEH-W4A1 de Hisense?", + "title": "Hisense AEH-W4A1" + } + }, + "title": "Hisense AEH-W4A1" + } +} \ No newline at end of file diff --git a/homeassistant/components/hisense_aehw4a1/.translations/de.json b/homeassistant/components/hisense_aehw4a1/.translations/de.json new file mode 100644 index 000000000..8b474ea04 --- /dev/null +++ b/homeassistant/components/hisense_aehw4a1/.translations/de.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Es wurden keine Hisense AEH-W4A1-Ger\u00e4te im Netzwerk gefunden.", + "single_instance_allowed": "Es ist nur eine einzige Konfiguration von Hisense AEH-W4A1 m\u00f6glich." + }, + "step": { + "confirm": { + "description": "M\u00f6chten Sie Hisense AEH-W4A1 einrichten?", + "title": "Hisense AEH-W4A1" + } + }, + "title": "Hisense AEH-W4A1" + } +} \ No newline at end of file diff --git a/homeassistant/components/hisense_aehw4a1/.translations/en.json b/homeassistant/components/hisense_aehw4a1/.translations/en.json new file mode 100644 index 000000000..b70fc8f05 --- /dev/null +++ b/homeassistant/components/hisense_aehw4a1/.translations/en.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "No Hisense AEH-W4A1 devices found on the network.", + "single_instance_allowed": "Only a single configuration of Hisense AEH-W4A1 is possible." + }, + "step": { + "confirm": { + "description": "Do you want to set up Hisense AEH-W4A1?", + "title": "Hisense AEH-W4A1" + } + }, + "title": "Hisense AEH-W4A1" + } +} \ No newline at end of file diff --git a/homeassistant/components/hisense_aehw4a1/.translations/es.json b/homeassistant/components/hisense_aehw4a1/.translations/es.json new file mode 100644 index 000000000..69f071bf5 --- /dev/null +++ b/homeassistant/components/hisense_aehw4a1/.translations/es.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "No se encontraron dispositivos Hisense AEH-W4A1 en la red.", + "single_instance_allowed": "Solo es posible una \u00fanica configuraci\u00f3n de Hisense AEH-W4A1." + }, + "step": { + "confirm": { + "description": "\u00bfDesea configurar Hisense AEH-W4A1?", + "title": "Hisense AEH-W4A1" + } + }, + "title": "Hisense AEH-W4A1" + } +} \ No newline at end of file diff --git a/homeassistant/components/hisense_aehw4a1/.translations/fr.json b/homeassistant/components/hisense_aehw4a1/.translations/fr.json new file mode 100644 index 000000000..50c753538 --- /dev/null +++ b/homeassistant/components/hisense_aehw4a1/.translations/fr.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Aucun p\u00e9riph\u00e9rique AEH-W4A1 trouv\u00e9 sur le r\u00e9seau.", + "single_instance_allowed": "Une seule configuration de AEH-W4A1 est possible." + }, + "step": { + "confirm": { + "description": "Voulez-vous configurer AEH-W4A1?", + "title": "Hisense AEH-W4A1" + } + }, + "title": "Hisense AEH-W4A1" + } +} \ No newline at end of file diff --git a/homeassistant/components/hisense_aehw4a1/.translations/it.json b/homeassistant/components/hisense_aehw4a1/.translations/it.json new file mode 100644 index 000000000..b584d18e8 --- /dev/null +++ b/homeassistant/components/hisense_aehw4a1/.translations/it.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nessun dispositivo Hisense AEH-W4A1 trovato sulla rete.", + "single_instance_allowed": "\u00c8 consentita solo una configurazione di Hisense AEH-W4A1" + }, + "step": { + "confirm": { + "description": "Voui configurare Hisense AEH-W4A1", + "title": "Hisense AEH-W4A1" + } + }, + "title": "Hisense AEH-W4A1" + } +} \ No newline at end of file diff --git a/homeassistant/components/hisense_aehw4a1/.translations/lb.json b/homeassistant/components/hisense_aehw4a1/.translations/lb.json new file mode 100644 index 000000000..33b933483 --- /dev/null +++ b/homeassistant/components/hisense_aehw4a1/.translations/lb.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Keng Hisense AEH-W4A1 Apparater am Netzwierk fonnt.", + "single_instance_allowed": "N\u00ebmmen eng eenzeg Konfiguratioun vun Hisense AEH-W4A1 ass m\u00e9iglech." + }, + "step": { + "confirm": { + "description": "Soll Hisense AEH-W4A1 konfigur\u00e9iert ginn?", + "title": "Hisense AEH-W4A1" + } + }, + "title": "Hisense AEH-W4A1" + } +} \ No newline at end of file diff --git a/homeassistant/components/hisense_aehw4a1/.translations/no.json b/homeassistant/components/hisense_aehw4a1/.translations/no.json new file mode 100644 index 000000000..e44e818ea --- /dev/null +++ b/homeassistant/components/hisense_aehw4a1/.translations/no.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Ingen Hisense AEH-W4A1-enheter funnet p\u00e5 nettverket.", + "single_instance_allowed": "Bare en enkelt konfigurasjon av Hisense AEH-W4A1 er mulig." + }, + "step": { + "confirm": { + "description": "Vil du konfigurere Hisense AEH-W4A1?", + "title": "Hisense AEH-W4A1" + } + }, + "title": "Hisense AEH-W4A1" + } +} \ No newline at end of file diff --git a/homeassistant/components/hisense_aehw4a1/.translations/pl.json b/homeassistant/components/hisense_aehw4a1/.translations/pl.json new file mode 100644 index 000000000..e0ab5cddb --- /dev/null +++ b/homeassistant/components/hisense_aehw4a1/.translations/pl.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nie znaleziono w sieci urz\u0105dze\u0144 Hisense AEH-W4A1.", + "single_instance_allowed": "Dozwolona jest tylko jedna konfiguracja Hisense AEH-W4A1." + }, + "step": { + "confirm": { + "description": "Chcesz skonfigurowa\u0107 AEH-W4A1?", + "title": "Hisense AEH-W4A1" + } + }, + "title": "Hisense AEH-W4A1" + } +} \ No newline at end of file diff --git a/homeassistant/components/hisense_aehw4a1/.translations/ru.json b/homeassistant/components/hisense_aehw4a1/.translations/ru.json new file mode 100644 index 000000000..c65a5277f --- /dev/null +++ b/homeassistant/components/hisense_aehw4a1/.translations/ru.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 Hisense AEH-W4A1e \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b \u0432 \u0441\u0435\u0442\u0438.", + "single_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430." + }, + "step": { + "confirm": { + "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c Hisense AEH-W4A1?", + "title": "Hisense AEH-W4A1" + } + }, + "title": "Hisense AEH-W4A1" + } +} \ No newline at end of file diff --git a/homeassistant/components/hisense_aehw4a1/.translations/sl.json b/homeassistant/components/hisense_aehw4a1/.translations/sl.json new file mode 100644 index 000000000..3c15eecf6 --- /dev/null +++ b/homeassistant/components/hisense_aehw4a1/.translations/sl.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "V omre\u017eju ni bilo najdenih naprav Hisense AEH-W4A1.", + "single_instance_allowed": "Mo\u017ena je samo ena konfiguracija Hisense AEH-W4A1." + }, + "step": { + "confirm": { + "description": "Ali \u017eelite nastaviti Hisense AEH-W4A1?", + "title": "Hisense AEH-W4A1" + } + }, + "title": "Hisense AEH-W4A1" + } +} \ No newline at end of file diff --git a/homeassistant/components/hisense_aehw4a1/.translations/zh-Hant.json b/homeassistant/components/hisense_aehw4a1/.translations/zh-Hant.json new file mode 100644 index 000000000..d4f87905d --- /dev/null +++ b/homeassistant/components/hisense_aehw4a1/.translations/zh-Hant.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u5728\u7db2\u8def\u4e0a\u627e\u4e0d\u5230\u6d77\u4fe1 AEH-W4A1 \u8a2d\u5099\u3002", + "single_instance_allowed": "\u50c5\u5141\u8a31\u8a2d\u5b9a\u4e00\u7d44\u6d77\u4fe1 AEH-W4A1\u3002" + }, + "step": { + "confirm": { + "description": "\u662f\u5426\u8981\u8a2d\u5b9a\u6d77\u4fe1 AEH-W4A1\uff1f", + "title": "\u6d77\u4fe1 AEH-W4A1" + } + }, + "title": "\u6d77\u4fe1 AEH-W4A1" + } +} \ No newline at end of file diff --git a/homeassistant/components/hisense_aehw4a1/__init__.py b/homeassistant/components/hisense_aehw4a1/__init__.py new file mode 100644 index 000000000..721039d0e --- /dev/null +++ b/homeassistant/components/hisense_aehw4a1/__init__.py @@ -0,0 +1,81 @@ +"""The Hisense AEH-W4A1 integration.""" +import ipaddress +import logging + +from pyaehw4a1.aehw4a1 import AehW4a1 +import pyaehw4a1.exceptions +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN +from homeassistant.const import CONF_IP_ADDRESS +import homeassistant.helpers.config_validation as cv + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +def coerce_ip(value): + """Validate that provided value is a valid IP address.""" + if not value: + raise vol.Invalid("Must define an IP address") + try: + ipaddress.IPv4Network(value) + except ValueError: + raise vol.Invalid("Not a valid IP address") + return value + + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: { + CLIMATE_DOMAIN: vol.Schema( + { + vol.Optional(CONF_IP_ADDRESS, default=[]): vol.All( + cv.ensure_list, [vol.All(cv.string, coerce_ip)] + ) + } + ) + } + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, config): + """Set up the Hisense AEH-W4A1 integration.""" + conf = config.get(DOMAIN) + hass.data[DOMAIN] = {} + + if conf is not None: + devices = conf[CONF_IP_ADDRESS][:] + for device in devices: + try: + await AehW4a1(device).check() + except pyaehw4a1.exceptions.ConnectionError: + conf[CONF_IP_ADDRESS].remove(device) + _LOGGER.warning("Hisense AEH-W4A1 at %s not found", device) + if conf[CONF_IP_ADDRESS]: + hass.data[DOMAIN] = conf + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, + ) + ) + + return True + + +async def async_setup_entry(hass, entry): + """Set up a config entry for Hisense AEH-W4A1.""" + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, CLIMATE_DOMAIN) + ) + + return True + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + return await hass.config_entries.async_forward_entry_unload(entry, CLIMATE_DOMAIN) diff --git a/homeassistant/components/hisense_aehw4a1/climate.py b/homeassistant/components/hisense_aehw4a1/climate.py new file mode 100644 index 000000000..da18419c2 --- /dev/null +++ b/homeassistant/components/hisense_aehw4a1/climate.py @@ -0,0 +1,438 @@ +"""Pyaehw4a1 platform to control of Hisense AEH-W4A1 Climate Devices.""" + +import logging + +from pyaehw4a1.aehw4a1 import AehW4a1 +import pyaehw4a1.exceptions + +from homeassistant.components.climate import ClimateDevice +from homeassistant.components.climate.const import ( + FAN_AUTO, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + HVAC_MODE_COOL, + HVAC_MODE_DRY, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + PRESET_BOOST, + PRESET_ECO, + PRESET_NONE, + PRESET_SLEEP, + SUPPORT_FAN_MODE, + SUPPORT_PRESET_MODE, + SUPPORT_SWING_MODE, + SUPPORT_TARGET_TEMPERATURE, + SWING_BOTH, + SWING_HORIZONTAL, + SWING_OFF, + SWING_VERTICAL, +) +from homeassistant.const import ( + ATTR_TEMPERATURE, + PRECISION_WHOLE, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) + +from . import CONF_IP_ADDRESS, DOMAIN + +SUPPORT_FLAGS = ( + SUPPORT_TARGET_TEMPERATURE + | SUPPORT_FAN_MODE + | SUPPORT_SWING_MODE + | SUPPORT_PRESET_MODE +) + +MIN_TEMP_C = 16 +MAX_TEMP_C = 32 + +MIN_TEMP_F = 61 +MAX_TEMP_F = 90 + +HVAC_MODES = [ + HVAC_MODE_OFF, + HVAC_MODE_HEAT, + HVAC_MODE_COOL, + HVAC_MODE_DRY, + HVAC_MODE_FAN_ONLY, +] + +FAN_MODES = [ + "mute", + FAN_LOW, + FAN_MEDIUM, + FAN_HIGH, + FAN_AUTO, +] + +SWING_MODES = [ + SWING_OFF, + SWING_VERTICAL, + SWING_HORIZONTAL, + SWING_BOTH, +] + +PRESET_MODES = [ + PRESET_NONE, + PRESET_ECO, + PRESET_BOOST, + PRESET_SLEEP, + "sleep_2", + "sleep_3", + "sleep_4", +] + +AC_TO_HA_STATE = { + "0001": HVAC_MODE_HEAT, + "0010": HVAC_MODE_COOL, + "0011": HVAC_MODE_DRY, + "0000": HVAC_MODE_FAN_ONLY, +} + +HA_STATE_TO_AC = { + HVAC_MODE_OFF: "off", + HVAC_MODE_HEAT: "mode_heat", + HVAC_MODE_COOL: "mode_cool", + HVAC_MODE_DRY: "mode_dry", + HVAC_MODE_FAN_ONLY: "mode_fan", +} + +AC_TO_HA_FAN_MODES = { + "00000000": FAN_AUTO, # fan value for heat mode + "00000001": FAN_AUTO, + "00000010": "mute", + "00000100": FAN_LOW, + "00000110": FAN_MEDIUM, + "00001000": FAN_HIGH, +} + +HA_FAN_MODES_TO_AC = { + "mute": "speed_mute", + FAN_LOW: "speed_low", + FAN_MEDIUM: "speed_med", + FAN_HIGH: "speed_max", + FAN_AUTO: "speed_auto", +} + +AC_TO_HA_SWING = { + "00": SWING_OFF, + "10": SWING_VERTICAL, + "01": SWING_HORIZONTAL, + "11": SWING_BOTH, +} + +_LOGGER = logging.getLogger(__name__) + + +def _build_entity(device): + _LOGGER.debug("Found device at %s", device) + return ClimateAehW4a1(device) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the AEH-W4A1 climate platform.""" + # Priority 1: manual config + if hass.data[DOMAIN].get(CONF_IP_ADDRESS): + devices = hass.data[DOMAIN][CONF_IP_ADDRESS] + else: + # Priority 2: scanned interfaces + devices = await AehW4a1().discovery() + + entities = [_build_entity(device) for device in devices] + async_add_entities(entities, True) + + +class ClimateAehW4a1(ClimateDevice): + """Representation of a Hisense AEH-W4A1 module for climate device.""" + + def __init__(self, device): + """Initialize the climate device.""" + self._unique_id = device + self._device = AehW4a1(device) + self._hvac_modes = HVAC_MODES + self._fan_modes = FAN_MODES + self._swing_modes = SWING_MODES + self._preset_modes = PRESET_MODES + self._available = None + self._on = None + self._temperature_unit = None + self._current_temperature = None + self._target_temperature = None + self._hvac_mode = None + self._fan_mode = None + self._swing_mode = None + self._preset_mode = None + self._previous_state = None + + async def async_update(self): + """Pull state from AEH-W4A1.""" + try: + status = await self._device.command("status_102_0") + except pyaehw4a1.exceptions.ConnectionError as library_error: + _LOGGER.warning( + "Unexpected error of %s: %s", self._unique_id, library_error + ) + self._available = False + return + + self._available = True + + self._on = status["run_status"] + + if status["temperature_Fahrenheit"] == "0": + self._temperature_unit = TEMP_CELSIUS + else: + self._temperature_unit = TEMP_FAHRENHEIT + + self._current_temperature = int(status["indoor_temperature_status"], 2) + + if self._on == "1": + device_mode = status["mode_status"] + self._hvac_mode = AC_TO_HA_STATE[device_mode] + + fan_mode = status["wind_status"] + self._fan_mode = AC_TO_HA_FAN_MODES[fan_mode] + + swing_mode = f'{status["up_down"]}{status["left_right"]}' + self._swing_mode = AC_TO_HA_SWING[swing_mode] + + if self._hvac_mode in (HVAC_MODE_COOL, HVAC_MODE_HEAT): + self._target_temperature = int(status["indoor_temperature_setting"], 2) + else: + self._target_temperature = None + + if status["efficient"] == "1": + self._preset_mode = PRESET_BOOST + elif status["low_electricity"] == "1": + self._preset_mode = PRESET_ECO + elif status["sleep_status"] == "0000001": + self._preset_mode = PRESET_SLEEP + elif status["sleep_status"] == "0000010": + self._preset_mode = "sleep_2" + elif status["sleep_status"] == "0000011": + self._preset_mode = "sleep_3" + elif status["sleep_status"] == "0000100": + self._preset_mode = "sleep_4" + else: + self._preset_mode = PRESET_NONE + else: + self._hvac_mode = HVAC_MODE_OFF + self._fan_mode = None + self._swing_mode = None + self._target_temperature = None + self._preset_mode = None + + @property + def available(self): + """Return True if entity is available.""" + return self._available + + @property + def name(self): + """Return the name of the climate device.""" + return self._unique_id + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return self._temperature_unit + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._current_temperature + + @property + def target_temperature(self): + """Return the temperature we are trying to reach.""" + return self._target_temperature + + @property + def hvac_mode(self): + """Return hvac target hvac state.""" + return self._hvac_mode + + @property + def hvac_modes(self): + """Return the list of available operation modes.""" + return self._hvac_modes + + @property + def fan_mode(self): + """Return the fan setting.""" + return self._fan_mode + + @property + def fan_modes(self): + """Return the list of available fan modes.""" + return self._fan_modes + + @property + def preset_mode(self): + """Return the preset mode if on.""" + return self._preset_mode + + @property + def preset_modes(self): + """Return the list of available preset modes.""" + return self._preset_modes + + @property + def swing_mode(self): + """Return swing operation.""" + return self._swing_mode + + @property + def swing_modes(self): + """Return the list of available fan modes.""" + return self._swing_modes + + @property + def min_temp(self): + """Return the minimum temperature.""" + if self._temperature_unit == TEMP_CELSIUS: + return MIN_TEMP_C + return MIN_TEMP_F + + @property + def max_temp(self): + """Return the maximum temperature.""" + if self._temperature_unit == TEMP_CELSIUS: + return MAX_TEMP_C + return MAX_TEMP_F + + @property + def precision(self): + """Return the precision of the system.""" + return PRECISION_WHOLE + + @property + def target_temperature_step(self): + """Return the supported step of target temperature.""" + return 1 + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS + + async def async_set_temperature(self, **kwargs): + """Set new target temperatures.""" + if self._on != "1": + _LOGGER.warning( + "AC at %s is off, could not set temperature", self._unique_id + ) + return + temp = kwargs.get(ATTR_TEMPERATURE) + if temp is not None: + _LOGGER.debug("Setting temp of %s to %s", self._unique_id, temp) + if self._preset_mode != PRESET_NONE: + await self.async_set_preset_mode(PRESET_NONE) + if self._temperature_unit == TEMP_CELSIUS: + await self._device.command(f"temp_{int(temp)}_C") + else: + await self._device.command(f"temp_{int(temp)}_F") + + async def async_set_fan_mode(self, fan_mode): + """Set new fan mode.""" + if self._on != "1": + _LOGGER.warning("AC at %s is off, could not set fan mode", self._unique_id) + return + if self._hvac_mode in (HVAC_MODE_COOL, HVAC_MODE_FAN_ONLY) and ( + self._hvac_mode != HVAC_MODE_FAN_ONLY or fan_mode != FAN_AUTO + ): + _LOGGER.debug("Setting fan mode of %s to %s", self._unique_id, fan_mode) + await self._device.command(HA_FAN_MODES_TO_AC[fan_mode]) + + async def async_set_swing_mode(self, swing_mode): + """Set new target swing operation.""" + if self._on != "1": + _LOGGER.warning( + "AC at %s is off, could not set swing mode", self._unique_id + ) + return + + _LOGGER.debug("Setting swing mode of %s to %s", self._unique_id, swing_mode) + swing_act = self._swing_mode + + if swing_mode == SWING_OFF and swing_act != SWING_OFF: + if swing_act in (SWING_HORIZONTAL, SWING_BOTH): + await self._device.command("hor_dir") + if swing_act in (SWING_VERTICAL, SWING_BOTH): + await self._device.command("vert_dir") + + if swing_mode == SWING_BOTH and swing_act != SWING_BOTH: + if swing_act in (SWING_OFF, SWING_HORIZONTAL): + await self._device.command("vert_swing") + if swing_act in (SWING_OFF, SWING_VERTICAL): + await self._device.command("hor_swing") + + if swing_mode == SWING_VERTICAL and swing_act != SWING_VERTICAL: + if swing_act in (SWING_OFF, SWING_HORIZONTAL): + await self._device.command("vert_swing") + if swing_act in (SWING_BOTH, SWING_HORIZONTAL): + await self._device.command("hor_dir") + + if swing_mode == SWING_HORIZONTAL and swing_act != SWING_HORIZONTAL: + if swing_act in (SWING_BOTH, SWING_VERTICAL): + await self._device.command("vert_dir") + if swing_act in (SWING_OFF, SWING_VERTICAL): + await self._device.command("hor_swing") + + async def async_set_preset_mode(self, preset_mode): + """Set new preset mode.""" + if self._on != "1": + if preset_mode == PRESET_NONE: + return + await self.async_turn_on() + + _LOGGER.debug("Setting preset mode of %s to %s", self._unique_id, preset_mode) + + if preset_mode == PRESET_ECO: + await self._device.command("energysave_on") + self._previous_state = preset_mode + elif preset_mode == PRESET_BOOST: + await self._device.command("turbo_on") + self._previous_state = preset_mode + elif preset_mode == PRESET_SLEEP: + await self._device.command("sleep_1") + self._previous_state = self._hvac_mode + elif preset_mode == "sleep_2": + await self._device.command("sleep_2") + self._previous_state = self._hvac_mode + elif preset_mode == "sleep_3": + await self._device.command("sleep_3") + self._previous_state = self._hvac_mode + elif preset_mode == "sleep_4": + await self._device.command("sleep_4") + self._previous_state = self._hvac_mode + elif self._previous_state is not None: + if self._previous_state == PRESET_ECO: + await self._device.command("energysave_off") + elif self._previous_state == PRESET_BOOST: + await self._device.command("turbo_off") + elif self._previous_state in HA_STATE_TO_AC: + await self._device.command(HA_STATE_TO_AC[self._previous_state]) + self._previous_state = None + + async def async_set_hvac_mode(self, hvac_mode): + """Set new operation mode.""" + _LOGGER.debug("Setting operation mode of %s to %s", self._unique_id, hvac_mode) + if hvac_mode == HVAC_MODE_OFF: + await self.async_turn_off() + else: + await self._device.command(HA_STATE_TO_AC[hvac_mode]) + if self._on != "1": + await self.async_turn_on() + + async def async_turn_on(self): + """Turn on.""" + _LOGGER.debug("Turning %s on", self._unique_id) + await self._device.command("on") + + async def async_turn_off(self): + """Turn off.""" + _LOGGER.debug("Turning %s off", self._unique_id) + await self._device.command("off") diff --git a/homeassistant/components/hisense_aehw4a1/config_flow.py b/homeassistant/components/hisense_aehw4a1/config_flow.py new file mode 100644 index 000000000..52926ba79 --- /dev/null +++ b/homeassistant/components/hisense_aehw4a1/config_flow.py @@ -0,0 +1,22 @@ +"""Config flow for Hisense AEH-W4A1 integration.""" +import logging + +from pyaehw4a1.aehw4a1 import AehW4a1 + +from homeassistant import config_entries +from homeassistant.helpers import config_entry_flow + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def _async_has_devices(hass): + """Return if there are devices that can be discovered.""" + aehw4a1_ip_addresses = await AehW4a1().discovery() + return len(aehw4a1_ip_addresses) > 0 + + +config_entry_flow.register_discovery_flow( + DOMAIN, "Hisense AEH-W4A1", _async_has_devices, config_entries.CONN_CLASS_LOCAL_POLL +) diff --git a/homeassistant/components/hisense_aehw4a1/const.py b/homeassistant/components/hisense_aehw4a1/const.py new file mode 100644 index 000000000..8f381492b --- /dev/null +++ b/homeassistant/components/hisense_aehw4a1/const.py @@ -0,0 +1,3 @@ +"""Constants for the Hisense AEH-W4A1 integration.""" + +DOMAIN = "hisense_aehw4a1" diff --git a/homeassistant/components/hisense_aehw4a1/manifest.json b/homeassistant/components/hisense_aehw4a1/manifest.json new file mode 100644 index 000000000..e4bdf581f --- /dev/null +++ b/homeassistant/components/hisense_aehw4a1/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "hisense_aehw4a1", + "name": "Hisense AEH-W4A1", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/hisense_aehw4a1", + "requirements": [ + "pyaehw4a1==0.3.1" + ], + "dependencies": [], + "codeowners": [ + "@bannhead" + ] +} diff --git a/homeassistant/components/hisense_aehw4a1/strings.json b/homeassistant/components/hisense_aehw4a1/strings.json new file mode 100644 index 000000000..67031c417 --- /dev/null +++ b/homeassistant/components/hisense_aehw4a1/strings.json @@ -0,0 +1,15 @@ +{ + "config": { + "title": "Hisense AEH-W4A1", + "step": { + "confirm": { + "title": "Hisense AEH-W4A1", + "description": "Do you want to set up Hisense AEH-W4A1?" + } + }, + "abort": { + "single_instance_allowed": "Only a single configuration of Hisense AEH-W4A1 is possible.", + "no_devices_found": "No Hisense AEH-W4A1 devices found on the network." + } + } +} diff --git a/homeassistant/components/history.py b/homeassistant/components/history.py deleted file mode 100644 index 21d4cdc6e..000000000 --- a/homeassistant/components/history.py +++ /dev/null @@ -1,423 +0,0 @@ -""" -Provide pre-made queries on top of the recorder component. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/history/ -""" -from collections import defaultdict -from datetime import timedelta -from itertools import groupby -import logging -import time - -import voluptuous as vol - -from homeassistant.const import ( - 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.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.extend({ - vol.Optional(CONF_ORDER, default=False): cv.boolean, - }) -}, extra=vol.ALLOW_EXTRA) - -SIGNIFICANT_DOMAINS = ('thermostat', 'climate') -IGNORE_DOMAINS = ('zone', 'scene',) - - -def last_recorder_run(hass): - """Retrieve the last closed recorder run from the database.""" - from homeassistant.components.recorder.models import RecorderRuns - - 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 - session.expunge(res) - return res - - -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. - - Significant states are all states where there is a state change, - as well as all states from certain domains (for instance - thermostat so that we get current temperature in our graphs). - """ - timer_start = time.perf_counter() - from homeassistant.components.recorder.models import States - - with session_scope(hass=hass) as session: - query = session.query(States).filter( - (States.domain.in_(SIGNIFICANT_DOMAINS) | - (States.last_changed == States.last_updated)) & - (States.last_updated > start_time)) - - if filters: - query = filters.apply(query, entity_ids) - - if end_time is not None: - query = query.filter(States.last_updated < end_time) - - query = query.order_by(States.last_updated) - - states = ( - state for state in execute(query) - if (_is_significant(state) and - not state.attributes.get(ATTR_HIDDEN, False))) - - if _LOGGER.isEnabledFor(logging.DEBUG): - elapsed = time.perf_counter() - timer_start - _LOGGER.debug( - 'get_significant_states took %fs', elapsed) - - 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, - entity_id=None): - """Return states changes during UTC period start_time - end_time.""" - from homeassistant.components.recorder.models import States - - with session_scope(hass=hass) as session: - query = session.query(States).filter( - (States.last_changed == States.last_updated) & - (States.last_updated > start_time)) - - if end_time is not None: - query = query.filter(States.last_updated < end_time) - - 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_ids) - - -def get_last_state_changes(hass, number_of_states, entity_id): - """Return the last number_of_states.""" - from homeassistant.components.recorder.models import States - - start_time = dt_util.utcnow() - - with session_scope(hass=hass) as session: - query = session.query(States).filter( - (States.last_changed == States.last_updated)) - - if entity_id is not None: - query = query.filter_by(entity_id=entity_id.lower()) - - entity_ids = [entity_id] if entity_id is not None else None - - states = execute( - query.order_by(States.last_updated.desc()).limit(number_of_states)) - - return states_to_json(hass, reversed(states), - start_time, - entity_ids, - include_start_time_state=False) - - -def get_states(hass, utc_point_in_time, entity_ids=None, run=None, - filters=None): - """Return the states at a specific point in time.""" - from homeassistant.components.recorder.models import States - - if run is None: - run = recorder.run_information(hass, utc_point_in_time) - - # History did not run before utc_point_in_time - if run is None: - return [] - - from sqlalchemy import and_, func - - with session_scope(hass=hass) as session: - if entity_ids and len(entity_ids) == 1: - # Use an entirely different (and extremely fast) query if we only - # have a single entity id - most_recent_state_ids = session.query( - States.state_id.label('max_state_id') - ).filter( - (States.last_updated < utc_point_in_time) & - (States.entity_id.in_(entity_ids)) - ).order_by( - States.last_updated.desc()) - - most_recent_state_ids = most_recent_state_ids.limit(1) - - else: - # We have more than one entity to look at (most commonly we want - # all entities,) so we need to do a search on all states since the - # last recorder run started. - - most_recent_states_by_date = session.query( - States.entity_id.label('max_entity_id'), - func.max(States.last_updated).label('max_last_updated') - ).filter( - (States.last_updated >= run.start) & - (States.last_updated < utc_point_in_time) - ) - - if entity_ids: - most_recent_states_by_date.filter( - States.entity_id.in_(entity_ids)) - - most_recent_states_by_date = most_recent_states_by_date.group_by( - States.entity_id) - - most_recent_states_by_date = most_recent_states_by_date.subquery() - - most_recent_state_ids = session.query( - func.max(States.state_id).label('max_state_id') - ).join(most_recent_states_by_date, and_( - States.entity_id == most_recent_states_by_date.c.max_entity_id, - States.last_updated == most_recent_states_by_date.c. - max_last_updated)) - - most_recent_state_ids = most_recent_state_ids.group_by( - States.entity_id) - - most_recent_state_ids = most_recent_state_ids.subquery() - - query = session.query(States).join( - most_recent_state_ids, - States.state_id == most_recent_state_ids.c.max_state_id - ).filter((~States.domain.in_(IGNORE_DOMAINS))) - - if filters: - query = filters.apply(query, entity_ids) - - return [state for state in execute(query) - if not state.attributes.get(ATTR_HIDDEN, False)] - - -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 - structure {'entity_id': [list of states], 'entity_id2': [list of states]} - - We also need to go back and create a synthetic zero data point for - each list of states, otherwise our graphs won't start on the Y - axis correctly. - """ - result = defaultdict(list) - - # Get the states at the start time - timer_start = time.perf_counter() - 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 - _LOGGER.debug( - 'getting %d first datapoints took %fs', len(result), elapsed) - - # Append all changes to it - for ent_id, group in groupby(states, lambda state: state.entity_id): - result[ent_id].extend(group) - return result - - -def get_state(hass, utc_point_in_time, entity_id, run=None): - """Return a state at a specific point in time.""" - states = list(get_states(hass, utc_point_in_time, (entity_id,), run)) - return states[0] if states else None - - -async def async_setup(hass, config): - """Set up the history hooks.""" - filters = Filters() - conf = config.get(DOMAIN, {}) - exclude = conf.get(CONF_EXCLUDE) - if exclude: - filters.excluded_entities = exclude.get(CONF_ENTITIES, []) - filters.excluded_domains = exclude.get(CONF_DOMAINS, []) - include = conf.get(CONF_INCLUDE) - if include: - 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, use_include_order)) - await hass.components.frontend.async_register_built_in_panel( - 'history', 'history', 'hass:poll-box') - - return True - - -class HistoryPeriodView(HomeAssistantView): - """Handle history period requests.""" - - url = '/api/history/period' - name = 'api:history:view-period' - extra_urls = ['/api/history/period/{datetime}'] - - def __init__(self, filters, use_include_order): - """Initialize the history period view.""" - self.filters = filters - self.use_include_order = use_include_order - - async def get(self, request, datetime=None): - """Return history over a period of time.""" - timer_start = time.perf_counter() - if datetime: - datetime = dt_util.parse_datetime(datetime) - - if datetime is None: - return self.json_message('Invalid datetime', HTTP_BAD_REQUEST) - - now = dt_util.utcnow() - - one_day = timedelta(days=1) - if datetime: - start_time = dt_util.as_utc(datetime) - else: - start_time = now - one_day - - if start_time > now: - return self.json([]) - - end_time = request.query.get('end_time') - if end_time: - 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_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 - - 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) - - # 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: - """Container for the configured include and exclude filters.""" - - def __init__(self): - """Initialise the include and exclude filters.""" - self.excluded_entities = [] - self.excluded_domains = [] - self.included_entities = [] - self.included_domains = [] - - def apply(self, query, entity_ids=None): - """Apply the include/exclude filter on domains and entities on query. - - Following rules apply: - * only the include section is configured - just query the specified - entities or domains. - * only the exclude section is configured - filter the specified - entities and domains from all the entities in the system. - * if include and exclude is defined - select the entities specified in - the include and filter out the ones from the exclude list. - """ - from homeassistant.components.recorder.models import States - - # specific entities requested - do not in/exclude anything - if entity_ids is not None: - return query.filter(States.entity_id.in_(entity_ids)) - query = query.filter(~States.domain.in_(IGNORE_DOMAINS)) - - filter_query = None - # filter if only excluded domain is configured - if self.excluded_domains and not self.included_domains: - filter_query = ~States.domain.in_(self.excluded_domains) - if self.included_entities: - filter_query &= States.entity_id.in_(self.included_entities) - # filter if only included domain is configured - elif not self.excluded_domains and self.included_domains: - filter_query = States.domain.in_(self.included_domains) - if self.included_entities: - filter_query |= States.entity_id.in_(self.included_entities) - # filter if included and excluded domain is configured - elif self.excluded_domains and self.included_domains: - filter_query = ~States.domain.in_(self.excluded_domains) - if self.included_entities: - filter_query &= (States.domain.in_(self.included_domains) | - States.entity_id.in_(self.included_entities)) - else: - filter_query &= (States.domain.in_(self.included_domains) & ~ - States.domain.in_(self.excluded_domains)) - # no domain filter just included entities - elif not self.excluded_domains and not self.included_domains and \ - self.included_entities: - filter_query = States.entity_id.in_(self.included_entities) - if filter_query is not None: - query = query.filter(filter_query) - # finally apply excluded entities filter if configured - if self.excluded_entities: - query = query.filter(~States.entity_id.in_(self.excluded_entities)) - return query - - -def _is_significant(state): - """Test if state is significant for history charts. - - Will only test for things that are not filtered out in SQL. - """ - # scripts that are not cancellable will never change state - return (state.domain != 'script' or - state.attributes.get(script.ATTR_CAN_CANCEL)) diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py new file mode 100644 index 000000000..7fcbf519b --- /dev/null +++ b/homeassistant/components/history/__init__.py @@ -0,0 +1,433 @@ +"""Provide pre-made queries on top of the recorder component.""" +from collections import defaultdict +from datetime import timedelta +from itertools import groupby +import logging +import time + +from sqlalchemy import and_, func +import voluptuous as vol + +from homeassistant.components import recorder +from homeassistant.components.http import HomeAssistantView +from homeassistant.components.recorder.models import States +from homeassistant.components.recorder.util import execute, session_scope +from homeassistant.const import ( + ATTR_HIDDEN, + CONF_DOMAINS, + CONF_ENTITIES, + CONF_EXCLUDE, + CONF_INCLUDE, + HTTP_BAD_REQUEST, +) +import homeassistant.helpers.config_validation as cv +import homeassistant.util.dt as dt_util + +# mypy: allow-untyped-defs, no-check-untyped-defs + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "history" +CONF_ORDER = "use_include_order" + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: recorder.FILTER_SCHEMA.extend( + {vol.Optional(CONF_ORDER, default=False): cv.boolean} + ) + }, + extra=vol.ALLOW_EXTRA, +) + +SIGNIFICANT_DOMAINS = ("thermostat", "climate", "water_heater") +IGNORE_DOMAINS = ("zone", "scene") + + +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. + + Significant states are all states where there is a state change, + as well as all states from certain domains (for instance + thermostat so that we get current temperature in our graphs). + """ + timer_start = time.perf_counter() + + with session_scope(hass=hass) as session: + query = session.query(States).filter( + ( + States.domain.in_(SIGNIFICANT_DOMAINS) + | (States.last_changed == States.last_updated) + ) + & (States.last_updated > start_time) + ) + + if filters: + query = filters.apply(query, entity_ids) + + if end_time is not None: + query = query.filter(States.last_updated < end_time) + + query = query.order_by(States.last_updated) + + states = ( + state + for state in execute(query) + if (_is_significant(state) and not state.attributes.get(ATTR_HIDDEN, False)) + ) + + if _LOGGER.isEnabledFor(logging.DEBUG): + elapsed = time.perf_counter() - timer_start + _LOGGER.debug("get_significant_states took %fs", elapsed) + + 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, entity_id=None): + """Return states changes during UTC period start_time - end_time.""" + + with session_scope(hass=hass) as session: + query = session.query(States).filter( + (States.last_changed == States.last_updated) + & (States.last_updated > start_time) + ) + + if end_time is not None: + query = query.filter(States.last_updated < end_time) + + 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_ids) + + +def get_last_state_changes(hass, number_of_states, entity_id): + """Return the last number_of_states.""" + + start_time = dt_util.utcnow() + + with session_scope(hass=hass) as session: + query = session.query(States).filter( + (States.last_changed == States.last_updated) + ) + + if entity_id is not None: + query = query.filter_by(entity_id=entity_id.lower()) + + entity_ids = [entity_id] if entity_id is not None else None + + states = execute( + query.order_by(States.last_updated.desc()).limit(number_of_states) + ) + + return states_to_json( + hass, reversed(states), start_time, entity_ids, include_start_time_state=False + ) + + +def get_states(hass, utc_point_in_time, entity_ids=None, run=None, filters=None): + """Return the states at a specific point in time.""" + + if run is None: + run = recorder.run_information(hass, utc_point_in_time) + + # History did not run before utc_point_in_time + if run is None: + return [] + + with session_scope(hass=hass) as session: + query = session.query(States) + + if entity_ids and len(entity_ids) == 1: + # Use an entirely different (and extremely fast) query if we only + # have a single entity id + query = ( + query.filter( + States.last_updated >= run.start, + States.last_updated < utc_point_in_time, + States.entity_id.in_(entity_ids), + ) + .order_by(States.last_updated.desc()) + .limit(1) + ) + + else: + # We have more than one entity to look at (most commonly we want + # all entities,) so we need to do a search on all states since the + # last recorder run started. + + most_recent_states_by_date = session.query( + States.entity_id.label("max_entity_id"), + func.max(States.last_updated).label("max_last_updated"), + ).filter( + (States.last_updated >= run.start) + & (States.last_updated < utc_point_in_time) + ) + + if entity_ids: + most_recent_states_by_date.filter(States.entity_id.in_(entity_ids)) + + most_recent_states_by_date = most_recent_states_by_date.group_by( + States.entity_id + ) + + most_recent_states_by_date = most_recent_states_by_date.subquery() + + most_recent_state_ids = session.query( + func.max(States.state_id).label("max_state_id") + ).join( + most_recent_states_by_date, + and_( + States.entity_id == most_recent_states_by_date.c.max_entity_id, + States.last_updated + == most_recent_states_by_date.c.max_last_updated, + ), + ) + + most_recent_state_ids = most_recent_state_ids.group_by(States.entity_id) + + most_recent_state_ids = most_recent_state_ids.subquery() + + query = query.join( + most_recent_state_ids, + States.state_id == most_recent_state_ids.c.max_state_id, + ).filter(~States.domain.in_(IGNORE_DOMAINS)) + + if filters: + query = filters.apply(query, entity_ids) + + return [ + state + for state in execute(query) + if not state.attributes.get(ATTR_HIDDEN, False) + ] + + +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 + structure {'entity_id': [list of states], 'entity_id2': [list of states]} + + We also need to go back and create a synthetic zero data point for + each list of states, otherwise our graphs won't start on the Y + axis correctly. + """ + result = defaultdict(list) + # Set all entity IDs to empty lists in result set to maintain the order + if entity_ids is not None: + for ent_id in entity_ids: + result[ent_id] = [] + + # Get the states at the start time + timer_start = time.perf_counter() + 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 + _LOGGER.debug("getting %d first datapoints took %fs", len(result), elapsed) + + # Append all changes to it + for ent_id, group in groupby(states, lambda state: state.entity_id): + result[ent_id].extend(group) + + # Filter out the empty lists if some states had 0 results. + return {key: val for key, val in result.items() if val} + + +def get_state(hass, utc_point_in_time, entity_id, run=None): + """Return a state at a specific point in time.""" + states = list(get_states(hass, utc_point_in_time, (entity_id,), run)) + return states[0] if states else None + + +async def async_setup(hass, config): + """Set up the history hooks.""" + filters = Filters() + conf = config.get(DOMAIN, {}) + exclude = conf.get(CONF_EXCLUDE) + if exclude: + filters.excluded_entities = exclude.get(CONF_ENTITIES, []) + filters.excluded_domains = exclude.get(CONF_DOMAINS, []) + include = conf.get(CONF_INCLUDE) + if include: + 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, use_include_order)) + hass.components.frontend.async_register_built_in_panel( + "history", "history", "hass:poll-box" + ) + + return True + + +class HistoryPeriodView(HomeAssistantView): + """Handle history period requests.""" + + url = "/api/history/period" + name = "api:history:view-period" + extra_urls = ["/api/history/period/{datetime}"] + + def __init__(self, filters, use_include_order): + """Initialize the history period view.""" + self.filters = filters + self.use_include_order = use_include_order + + async def get(self, request, datetime=None): + """Return history over a period of time.""" + timer_start = time.perf_counter() + if datetime: + datetime = dt_util.parse_datetime(datetime) + + if datetime is None: + return self.json_message("Invalid datetime", HTTP_BAD_REQUEST) + + now = dt_util.utcnow() + + one_day = timedelta(days=1) + if datetime: + start_time = dt_util.as_utc(datetime) + else: + start_time = now - one_day + + if start_time > now: + return self.json([]) + + end_time = request.query.get("end_time") + if end_time: + 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_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 + + 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) + + # 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: + """Container for the configured include and exclude filters.""" + + def __init__(self): + """Initialise the include and exclude filters.""" + self.excluded_entities = [] + self.excluded_domains = [] + self.included_entities = [] + self.included_domains = [] + + def apply(self, query, entity_ids=None): + """Apply the include/exclude filter on domains and entities on query. + + Following rules apply: + * only the include section is configured - just query the specified + entities or domains. + * only the exclude section is configured - filter the specified + entities and domains from all the entities in the system. + * if include and exclude is defined - select the entities specified in + the include and filter out the ones from the exclude list. + """ + + # specific entities requested - do not in/exclude anything + if entity_ids is not None: + return query.filter(States.entity_id.in_(entity_ids)) + query = query.filter(~States.domain.in_(IGNORE_DOMAINS)) + + filter_query = None + # filter if only excluded domain is configured + if self.excluded_domains and not self.included_domains: + filter_query = ~States.domain.in_(self.excluded_domains) + if self.included_entities: + filter_query &= States.entity_id.in_(self.included_entities) + # filter if only included domain is configured + elif not self.excluded_domains and self.included_domains: + filter_query = States.domain.in_(self.included_domains) + if self.included_entities: + filter_query |= States.entity_id.in_(self.included_entities) + # filter if included and excluded domain is configured + elif self.excluded_domains and self.included_domains: + filter_query = ~States.domain.in_(self.excluded_domains) + if self.included_entities: + filter_query &= States.domain.in_( + self.included_domains + ) | States.entity_id.in_(self.included_entities) + else: + filter_query &= States.domain.in_( + self.included_domains + ) & ~States.domain.in_(self.excluded_domains) + # no domain filter just included entities + elif ( + not self.excluded_domains + and not self.included_domains + and self.included_entities + ): + filter_query = States.entity_id.in_(self.included_entities) + if filter_query is not None: + query = query.filter(filter_query) + # finally apply excluded entities filter if configured + if self.excluded_entities: + query = query.filter(~States.entity_id.in_(self.excluded_entities)) + return query + + +def _is_significant(state): + """Test if state is significant for history charts. + + Will only test for things that are not filtered out in SQL. + """ + # scripts that are not cancellable will never change state + return state.domain != "script" or state.attributes.get("can_cancel") diff --git a/homeassistant/components/history/manifest.json b/homeassistant/components/history/manifest.json new file mode 100644 index 000000000..00789c905 --- /dev/null +++ b/homeassistant/components/history/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "history", + "name": "History", + "documentation": "https://www.home-assistant.io/integrations/history", + "requirements": [], + "dependencies": [ + "http", + "recorder" + ], + "codeowners": [ + "@home-assistant/core" + ] +} diff --git a/homeassistant/components/history_graph.py b/homeassistant/components/history_graph.py deleted file mode 100644 index fa7d615dc..000000000 --- a/homeassistant/components/history_graph.py +++ /dev/null @@ -1,85 +0,0 @@ -""" -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/history_graph/__init__.py b/homeassistant/components/history_graph/__init__.py new file mode 100644 index 000000000..2b8955681 --- /dev/null +++ b/homeassistant/components/history_graph/__init__.py @@ -0,0 +1,79 @@ +"""Support to graphs card in the UI.""" +import logging + +import voluptuous as vol + +from homeassistant.const import ATTR_ENTITY_ID, CONF_ENTITIES, CONF_NAME +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent + +_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: cv.schema_with_slug_keys(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/history_graph/manifest.json b/homeassistant/components/history_graph/manifest.json new file mode 100644 index 000000000..a4a0eb4d3 --- /dev/null +++ b/homeassistant/components/history_graph/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "history_graph", + "name": "History graph", + "documentation": "https://www.home-assistant.io/integrations/history_graph", + "requirements": [], + "dependencies": [ + "history" + ], + "codeowners": [ + "@andrey-git" + ] +} diff --git a/homeassistant/components/history_stats/__init__.py b/homeassistant/components/history_stats/__init__.py new file mode 100644 index 000000000..3c5385be6 --- /dev/null +++ b/homeassistant/components/history_stats/__init__.py @@ -0,0 +1 @@ +"""The history_stats component.""" diff --git a/homeassistant/components/history_stats/manifest.json b/homeassistant/components/history_stats/manifest.json new file mode 100644 index 000000000..55a3449f4 --- /dev/null +++ b/homeassistant/components/history_stats/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "history_stats", + "name": "History stats", + "documentation": "https://www.home-assistant.io/integrations/history_stats", + "requirements": [], + "dependencies": [ + "history" + ], + "codeowners": [] +} diff --git a/homeassistant/components/history_stats/sensor.py b/homeassistant/components/history_stats/sensor.py new file mode 100644 index 000000000..0bded03a2 --- /dev/null +++ b/homeassistant/components/history_stats/sensor.py @@ -0,0 +1,337 @@ +"""Component to make instant statistics about your history.""" +import datetime +import logging +import math + +import voluptuous as vol + +from homeassistant.components import history +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_ENTITY_ID, + CONF_NAME, + CONF_STATE, + CONF_TYPE, + EVENT_HOMEASSISTANT_START, +) +from homeassistant.core import callback +from homeassistant.exceptions import TemplateError +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_track_state_change +import homeassistant.util.dt as dt_util + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "history_stats" +CONF_START = "start" +CONF_END = "end" +CONF_DURATION = "duration" +CONF_PERIOD_KEYS = [CONF_START, CONF_END, CONF_DURATION] + +CONF_TYPE_TIME = "time" +CONF_TYPE_RATIO = "ratio" +CONF_TYPE_COUNT = "count" +CONF_TYPE_KEYS = [CONF_TYPE_TIME, CONF_TYPE_RATIO, CONF_TYPE_COUNT] + +DEFAULT_NAME = "unnamed statistics" +UNITS = {CONF_TYPE_TIME: "h", CONF_TYPE_RATIO: "%", CONF_TYPE_COUNT: ""} +ICON = "mdi:chart-line" + +ATTR_VALUE = "value" + + +def exactly_two_period_keys(conf): + """Ensure exactly 2 of CONF_PERIOD_KEYS are provided.""" + if sum(param in conf for param in CONF_PERIOD_KEYS) != 2: + raise vol.Invalid( + "You must provide exactly 2 of the following:" " start, end, duration" + ) + return conf + + +PLATFORM_SCHEMA = vol.All( + PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_STATE): cv.string, + vol.Optional(CONF_START): cv.template, + vol.Optional(CONF_END): cv.template, + vol.Optional(CONF_DURATION): cv.time_period, + vol.Optional(CONF_TYPE, default=CONF_TYPE_TIME): vol.In(CONF_TYPE_KEYS), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + } + ), + exactly_two_period_keys, +) + + +# noinspection PyUnusedLocal +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the History Stats sensor.""" + entity_id = config.get(CONF_ENTITY_ID) + entity_state = config.get(CONF_STATE) + start = config.get(CONF_START) + end = config.get(CONF_END) + duration = config.get(CONF_DURATION) + sensor_type = config.get(CONF_TYPE) + name = config.get(CONF_NAME) + + for template in [start, end]: + if template is not None: + template.hass = hass + + add_entities( + [ + HistoryStatsSensor( + hass, entity_id, entity_state, start, end, duration, sensor_type, name + ) + ] + ) + + return True + + +class HistoryStatsSensor(Entity): + """Representation of a HistoryStats sensor.""" + + def __init__( + self, hass, entity_id, entity_state, start, end, duration, sensor_type, name + ): + """Initialize the HistoryStats sensor.""" + self._entity_id = entity_id + self._entity_state = entity_state + self._duration = duration + self._start = start + self._end = end + self._type = sensor_type + self._name = name + self._unit_of_measurement = UNITS[sensor_type] + + self._period = (datetime.datetime.now(), datetime.datetime.now()) + self.value = None + self.count = None + + @callback + def start_refresh(*args): + """Register state tracking.""" + + @callback + def force_refresh(*args): + """Force the component to refresh.""" + self.async_schedule_update_ha_state(True) + + force_refresh() + async_track_state_change(self.hass, self._entity_id, force_refresh) + + # Delay first refresh to keep startup fast + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_refresh) + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + if self.value is None or self.count is None: + return None + + if self._type == CONF_TYPE_TIME: + return round(self.value, 2) + + if self._type == CONF_TYPE_RATIO: + return HistoryStatsHelper.pretty_ratio(self.value, self._period) + + if self._type == CONF_TYPE_COUNT: + return self.count + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self._unit_of_measurement + + @property + def should_poll(self): + """Return the polling state.""" + return True + + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + if self.value is None: + return {} + + hsh = HistoryStatsHelper + return {ATTR_VALUE: hsh.pretty_duration(self.value)} + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return ICON + + def update(self): + """Get the latest data and updates the states.""" + # Get previous values of start and end + p_start, p_end = self._period + + # Parse templates + self.update_period() + start, end = self._period + + # Convert times to UTC + start = dt_util.as_utc(start) + end = dt_util.as_utc(end) + p_start = dt_util.as_utc(p_start) + p_end = dt_util.as_utc(p_end) + now = datetime.datetime.now() + + # Compute integer timestamps + start_timestamp = math.floor(dt_util.as_timestamp(start)) + end_timestamp = math.floor(dt_util.as_timestamp(end)) + p_start_timestamp = math.floor(dt_util.as_timestamp(p_start)) + p_end_timestamp = math.floor(dt_util.as_timestamp(p_end)) + now_timestamp = math.floor(dt_util.as_timestamp(now)) + + # If period has not changed and current time after the period end... + if ( + start_timestamp == p_start_timestamp + and end_timestamp == p_end_timestamp + and end_timestamp <= now_timestamp + ): + # Don't compute anything as the value cannot have changed + return + + # Get history between start and end + history_list = history.state_changes_during_period( + self.hass, start, end, str(self._entity_id) + ) + + if self._entity_id not in history_list.keys(): + return + + # Get the first state + last_state = history.get_state(self.hass, start, self._entity_id) + last_state = last_state is not None and last_state == self._entity_state + last_time = start_timestamp + elapsed = 0 + count = 0 + + # Make calculations + for item in history_list.get(self._entity_id): + current_state = item.state == self._entity_state + current_time = item.last_changed.timestamp() + + if last_state: + elapsed += current_time - last_time + if current_state and not last_state: + count += 1 + + last_state = current_state + last_time = current_time + + # Count time elapsed between last history state and end of measure + if last_state: + measure_end = min(end_timestamp, now_timestamp) + elapsed += measure_end - last_time + + # Save value in hours + self.value = elapsed / 3600 + + # Save counter + self.count = count + + def update_period(self): + """Parse the templates and store a datetime tuple in _period.""" + start = None + end = None + + # Parse start + if self._start is not None: + try: + start_rendered = self._start.render() + except (TemplateError, TypeError) as ex: + HistoryStatsHelper.handle_template_exception(ex, "start") + return + start = dt_util.parse_datetime(start_rendered) + if start is None: + try: + start = dt_util.as_local( + dt_util.utc_from_timestamp(math.floor(float(start_rendered))) + ) + except ValueError: + _LOGGER.error( + "Parsing error: start must be a datetime" "or a timestamp" + ) + return + + # Parse end + if self._end is not None: + try: + end_rendered = self._end.render() + except (TemplateError, TypeError) as ex: + HistoryStatsHelper.handle_template_exception(ex, "end") + return + end = dt_util.parse_datetime(end_rendered) + if end is None: + try: + end = dt_util.as_local( + dt_util.utc_from_timestamp(math.floor(float(end_rendered))) + ) + except ValueError: + _LOGGER.error( + "Parsing error: end must be a datetime " "or a timestamp" + ) + return + + # Calculate start or end using the duration + if start is None: + start = end - self._duration + if end is None: + end = start + self._duration + + if start > dt_util.now(): + # History hasn't been written yet for this period + return + if dt_util.now() < end: + # No point in making stats of the future + end = dt_util.now() + + self._period = start, end + + +class HistoryStatsHelper: + """Static methods to make the HistoryStatsSensor code lighter.""" + + @staticmethod + def pretty_duration(hours): + """Format a duration in days, hours, minutes, seconds.""" + seconds = int(3600 * hours) + days, seconds = divmod(seconds, 86400) + hours, seconds = divmod(seconds, 3600) + minutes, seconds = divmod(seconds, 60) + if days > 0: + return "%dd %dh %dm" % (days, hours, minutes) + if hours > 0: + return "%dh %dm" % (hours, minutes) + return "%dm" % minutes + + @staticmethod + def pretty_ratio(value, period): + """Format the ratio of value / period duration.""" + if len(period) != 2 or period[0] == period[1]: + return 0.0 + + ratio = 100 * 3600 * value / (period[1] - period[0]).total_seconds() + return round(ratio, 1) + + @staticmethod + def handle_template_exception(ex, field): + """Log an error nicely if the template cannot be interpreted.""" + if ex.args and ex.args[0].startswith("UndefinedError: 'None' has no attribute"): + # Common during HA startup - so just a warning + _LOGGER.warning(ex) + return + _LOGGER.error("Error parsing template for field %s", field) + _LOGGER.error(ex) diff --git a/homeassistant/components/hitron_coda/__init__.py b/homeassistant/components/hitron_coda/__init__.py new file mode 100644 index 000000000..de65a34f3 --- /dev/null +++ b/homeassistant/components/hitron_coda/__init__.py @@ -0,0 +1 @@ +"""The hitron_coda component.""" diff --git a/homeassistant/components/hitron_coda/device_tracker.py b/homeassistant/components/hitron_coda/device_tracker.py new file mode 100644 index 000000000..12b03acbc --- /dev/null +++ b/homeassistant/components/hitron_coda/device_tracker.py @@ -0,0 +1,135 @@ +"""Support for the Hitron CODA-4582U, provided by Rogers.""" +from collections import namedtuple +import logging + +import requests +import voluptuous as vol + +from homeassistant.components.device_tracker import ( + DOMAIN, + PLATFORM_SCHEMA, + DeviceScanner, +) +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_TYPE, CONF_USERNAME +import homeassistant.helpers.config_validation as cv + +_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 = f"http://{host}/data/getConnectInfo.asp" + self._loginurl = f"http://{host}/goform/login" + + 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/hitron_coda/manifest.json b/homeassistant/components/hitron_coda/manifest.json new file mode 100644 index 000000000..6b0492881 --- /dev/null +++ b/homeassistant/components/hitron_coda/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "hitron_coda", + "name": "Hitron coda", + "documentation": "https://www.home-assistant.io/integrations/hitron_coda", + "requirements": [], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/hive.py b/homeassistant/components/hive.py deleted file mode 100644 index aa662fc2f..000000000 --- a/homeassistant/components/hive.py +++ /dev/null @@ -1,84 +0,0 @@ -""" -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/hive/__init__.py b/homeassistant/components/hive/__init__.py new file mode 100644 index 000000000..976821513 --- /dev/null +++ b/homeassistant/components/hive/__init__.py @@ -0,0 +1,196 @@ +"""Support for the Hive devices and services.""" +from functools import wraps +import logging + +from pyhiveapi import Pyhiveapi +import voluptuous as vol + +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_TEMPERATURE, + CONF_PASSWORD, + CONF_SCAN_INTERVAL, + CONF_USERNAME, +) +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.discovery import load_platform +from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "hive" +DATA_HIVE = "data_hive" +SERVICES = ["Heating", "HotWater"] +SERVICE_BOOST_HOT_WATER = "boost_hot_water" +SERVICE_BOOST_HEATING = "boost_heating" +ATTR_TIME_PERIOD = "time_period" +ATTR_MODE = "on_off" +DEVICETYPES = { + "binary_sensor": "device_list_binary_sensor", + "climate": "device_list_climate", + "water_heater": "device_list_water_heater", + "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, +) + +BOOST_HEATING_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_TIME_PERIOD): vol.All( + cv.time_period, cv.positive_timedelta, lambda td: td.total_seconds() // 60 + ), + vol.Optional(ATTR_TEMPERATURE, default="25.0"): vol.Coerce(float), + } +) + +BOOST_HOT_WATER_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Optional(ATTR_TIME_PERIOD, default="00:30:00"): vol.All( + cv.time_period, cv.positive_timedelta, lambda td: td.total_seconds() // 60 + ), + vol.Required(ATTR_MODE): cv.string, + } +) + + +class HiveSession: + """Initiate Hive Session Class.""" + + entity_lookup = {} + core = None + heating = None + hotwater = None + light = None + sensor = None + switch = None + weather = None + attributes = None + trv = None + + +def setup(hass, config): + """Set up the Hive Component.""" + + def heating_boost(service): + """Handle the service call.""" + node_id = HiveSession.entity_lookup.get(service.data[ATTR_ENTITY_ID]) + if not node_id: + # log or raise error + _LOGGER.error("Cannot boost entity id entered") + return + + minutes = service.data[ATTR_TIME_PERIOD] + temperature = service.data[ATTR_TEMPERATURE] + + session.heating.turn_boost_on(node_id, minutes, temperature) + + def hot_water_boost(service): + """Handle the service call.""" + node_id = HiveSession.entity_lookup.get(service.data[ATTR_ENTITY_ID]) + if not node_id: + # log or raise error + _LOGGER.error("Cannot boost entity id entered") + return + minutes = service.data[ATTR_TIME_PERIOD] + mode = service.data[ATTR_MODE] + + if mode == "on": + session.hotwater.turn_boost_on(node_id, minutes) + elif mode == "off": + session.hotwater.turn_boost_off(node_id) + + session = HiveSession() + session.core = Pyhiveapi() + + username = config[DOMAIN][CONF_USERNAME] + password = config[DOMAIN][CONF_PASSWORD] + update_interval = config[DOMAIN][CONF_SCAN_INTERVAL] + + devices = session.core.initialise_api(username, password, update_interval) + + if devices 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 in DEVICETYPES: + devicelist = devices.get(DEVICETYPES[ha_type]) + if devicelist: + load_platform(hass, ha_type, DOMAIN, devicelist, config) + if ha_type == "climate": + hass.services.register( + DOMAIN, + SERVICE_BOOST_HEATING, + heating_boost, + schema=BOOST_HEATING_SCHEMA, + ) + if ha_type == "water_heater": + hass.services.register( + DOMAIN, + SERVICE_BOOST_HOT_WATER, + hot_water_boost, + schema=BOOST_HOT_WATER_SCHEMA, + ) + + return True + + +def refresh_system(func): + """Force update all entities after state change.""" + + @wraps(func) + def wrapper(self, *args, **kwargs): + func(self, *args, **kwargs) + dispatcher_send(self.hass, DOMAIN) + + return wrapper + + +class HiveEntity(Entity): + """Initiate Hive Base Class.""" + + def __init__(self, session, hive_device): + """Initialize the instance.""" + self.node_id = hive_device["Hive_NodeID"] + self.node_name = hive_device["Hive_NodeName"] + self.device_type = hive_device["HA_DeviceType"] + self.node_device_type = hive_device["Hive_DeviceType"] + self.session = session + self.attributes = {} + self._unique_id = f"{self.node_id}-{self.device_type}" + + async def async_added_to_hass(self): + """When entity is added to Home Assistant.""" + async_dispatcher_connect(self.hass, DOMAIN, self._update_callback) + if self.device_type in SERVICES: + self.session.entity_lookup[self.entity_id] = self.node_id + + @callback + def _update_callback(self): + """Call update method.""" + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/hive/binary_sensor.py b/homeassistant/components/hive/binary_sensor.py new file mode 100644 index 000000000..fa91d6862 --- /dev/null +++ b/homeassistant/components/hive/binary_sensor.py @@ -0,0 +1,57 @@ +"""Support for the Hive binary sensors.""" +from homeassistant.components.binary_sensor import BinarySensorDevice + +from . import DATA_HIVE, DOMAIN, HiveEntity + +DEVICETYPE_DEVICE_CLASS = {"motionsensor": "motion", "contactsensor": "opening"} + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up Hive sensor devices.""" + if discovery_info is None: + return + + session = hass.data.get(DATA_HIVE) + devs = [] + for dev in discovery_info: + devs.append(HiveBinarySensorEntity(session, dev)) + add_entities(devs) + + +class HiveBinarySensorEntity(HiveEntity, BinarySensorDevice): + """Representation of a Hive binary sensor.""" + + @property + def unique_id(self): + """Return unique ID of entity.""" + return self._unique_id + + @property + def device_info(self): + """Return device information.""" + return {"identifiers": {(DOMAIN, self.unique_id)}, "name": self.name} + + @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/hive/climate.py b/homeassistant/components/hive/climate.py new file mode 100644 index 000000000..202cea7bf --- /dev/null +++ b/homeassistant/components/hive/climate.py @@ -0,0 +1,185 @@ +"""Support for the Hive climate devices.""" +from homeassistant.components.climate import ClimateDevice +from homeassistant.components.climate.const import ( + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, + CURRENT_HVAC_OFF, + HVAC_MODE_AUTO, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + PRESET_BOOST, + PRESET_NONE, + SUPPORT_PRESET_MODE, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS + +from . import DATA_HIVE, DOMAIN, HiveEntity, refresh_system + +HIVE_TO_HASS_STATE = { + "SCHEDULE": HVAC_MODE_AUTO, + "MANUAL": HVAC_MODE_HEAT, + "OFF": HVAC_MODE_OFF, +} + +HASS_TO_HIVE_STATE = { + HVAC_MODE_AUTO: "SCHEDULE", + HVAC_MODE_HEAT: "MANUAL", + HVAC_MODE_OFF: "OFF", +} + +HIVE_TO_HASS_HVAC_ACTION = { + "UNKNOWN": CURRENT_HVAC_OFF, + False: CURRENT_HVAC_IDLE, + True: CURRENT_HVAC_HEAT, +} + +SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE +SUPPORT_HVAC = [HVAC_MODE_AUTO, HVAC_MODE_HEAT, HVAC_MODE_OFF] +SUPPORT_PRESET = [PRESET_NONE, PRESET_BOOST] + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up Hive climate devices.""" + if discovery_info is None: + return + + session = hass.data.get(DATA_HIVE) + devs = [] + for dev in discovery_info: + devs.append(HiveClimateEntity(session, dev)) + add_entities(devs) + + +class HiveClimateEntity(HiveEntity, ClimateDevice): + """Hive Climate Device.""" + + def __init__(self, hive_session, hive_device): + """Initialize the Climate device.""" + super().__init__(hive_session, hive_device) + self.thermostat_node_id = hive_device["Thermostat_NodeID"] + + @property + def unique_id(self): + """Return unique ID of entity.""" + return self._unique_id + + @property + def device_info(self): + """Return device information.""" + return {"identifiers": {(DOMAIN, self.unique_id)}, "name": self.name} + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS + + @property + def name(self): + """Return the name of the Climate device.""" + friendly_name = "Heating" + if self.node_name is not None: + if self.device_type == "TRV": + friendly_name = self.node_name + else: + friendly_name = f"{self.node_name} {friendly_name}" + + return friendly_name + + @property + def device_state_attributes(self): + """Show Device Attributes.""" + return self.attributes + + @property + def hvac_modes(self): + """Return the list of available hvac operation modes. + + Need to be a subset of HVAC_MODES. + """ + return SUPPORT_HVAC + + @property + def hvac_mode(self): + """Return hvac operation ie. heat, cool mode. + + Need to be one of HVAC_MODE_*. + """ + return HIVE_TO_HASS_STATE[self.session.heating.get_mode(self.node_id)] + + @property + def hvac_action(self): + """Return current HVAC action.""" + return HIVE_TO_HASS_HVAC_ACTION[ + self.session.heating.operational_status(self.node_id, self.device_type) + ] + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def current_temperature(self): + """Return the current temperature.""" + return self.session.heating.current_temperature(self.node_id) + + @property + def target_temperature(self): + """Return the target temperature.""" + return self.session.heating.get_target_temperature(self.node_id) + + @property + def min_temp(self): + """Return minimum temperature.""" + return self.session.heating.min_temperature(self.node_id) + + @property + def max_temp(self): + """Return the maximum temperature.""" + return self.session.heating.max_temperature(self.node_id) + + @property + def preset_mode(self): + """Return the current preset mode, e.g., home, away, temp.""" + if ( + self.device_type == "Heating" + and self.session.heating.get_boost(self.node_id) == "ON" + ): + return PRESET_BOOST + return None + + @property + def preset_modes(self): + """Return a list of available preset modes.""" + return SUPPORT_PRESET + + @refresh_system + def set_hvac_mode(self, hvac_mode): + """Set new target hvac mode.""" + new_mode = HASS_TO_HIVE_STATE[hvac_mode] + self.session.heating.set_mode(self.node_id, new_mode) + + @refresh_system + def set_temperature(self, **kwargs): + """Set new target temperature.""" + new_temperature = kwargs.get(ATTR_TEMPERATURE) + if new_temperature is not None: + self.session.heating.set_target_temperature(self.node_id, new_temperature) + + @refresh_system + def set_preset_mode(self, preset_mode): + """Set new preset mode.""" + if preset_mode == PRESET_NONE and self.preset_mode == PRESET_BOOST: + self.session.heating.turn_boost_off(self.node_id) + elif preset_mode == PRESET_BOOST: + curtemp = round(self.current_temperature * 2) / 2 + temperature = curtemp + 0.5 + self.session.heating.turn_boost_on(self.node_id, 30, temperature) + + 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.thermostat_node_id + ) diff --git a/homeassistant/components/hive/light.py b/homeassistant/components/hive/light.py new file mode 100644 index 000000000..33175de54 --- /dev/null +++ b/homeassistant/components/hive/light.py @@ -0,0 +1,150 @@ +"""Support for the Hive lights.""" +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP, + ATTR_HS_COLOR, + SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, + SUPPORT_COLOR_TEMP, + Light, +) +import homeassistant.util.color as color_util + +from . import DATA_HIVE, DOMAIN, HiveEntity, refresh_system + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up Hive light devices.""" + if discovery_info is None: + return + + session = hass.data.get(DATA_HIVE) + devs = [] + for dev in discovery_info: + devs.append(HiveDeviceLight(session, dev)) + add_entities(devs) + + +class HiveDeviceLight(HiveEntity, Light): + """Hive Active Light Device.""" + + def __init__(self, hive_session, hive_device): + """Initialize the Light device.""" + super().__init__(hive_session, hive_device) + self.light_device_type = hive_device["Hive_Light_DeviceType"] + + @property + def unique_id(self): + """Return unique ID of entity.""" + return self._unique_id + + @property + def device_info(self): + """Return device information.""" + return {"identifiers": {(DOMAIN, self.unique_id)}, "name": self.name} + + @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) + + @refresh_system + 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, + ) + + @refresh_system + def turn_off(self, **kwargs): + """Instruct the light to turn off.""" + self.session.light.turn_off(self.node_id) + + @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/hive/manifest.json b/homeassistant/components/hive/manifest.json new file mode 100644 index 000000000..e87e3387a --- /dev/null +++ b/homeassistant/components/hive/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "hive", + "name": "Hive", + "documentation": "https://www.home-assistant.io/integrations/hive", + "requirements": [ + "pyhiveapi==0.2.19.3" + ], + "dependencies": [], + "codeowners": [ + "@Rendili", + "@KJonline" + ] +} \ No newline at end of file diff --git a/homeassistant/components/hive/sensor.py b/homeassistant/components/hive/sensor.py new file mode 100644 index 000000000..360fb61bf --- /dev/null +++ b/homeassistant/components/hive/sensor.py @@ -0,0 +1,70 @@ +"""Support for the Hive sensors.""" +from homeassistant.const import TEMP_CELSIUS +from homeassistant.helpers.entity import Entity + +from . import DATA_HIVE, DOMAIN, HiveEntity + +FRIENDLY_NAMES = { + "Hub_OnlineStatus": "Hive Hub Status", + "Hive_OutsideTemperature": "Outside Temperature", +} + +DEVICETYPE_ICONS = { + "Hub_OnlineStatus": "mdi:switch", + "Hive_OutsideTemperature": "mdi:thermometer", +} + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up Hive sensor devices.""" + if discovery_info is None: + return + + session = hass.data.get(DATA_HIVE) + devs = [] + for dev in discovery_info: + if dev["HA_DeviceType"] in FRIENDLY_NAMES: + devs.append(HiveSensorEntity(session, dev)) + add_entities(devs) + + +class HiveSensorEntity(HiveEntity, Entity): + """Hive Sensor Entity.""" + + @property + def unique_id(self): + """Return unique ID of entity.""" + return self._unique_id + + @property + def device_info(self): + """Return device information.""" + return {"identifiers": {(DOMAIN, self.unique_id)}, "name": self.name} + + @property + def name(self): + """Return the name of the sensor.""" + return FRIENDLY_NAMES.get(self.device_type) + + @property + def state(self): + """Return the state of the sensor.""" + if self.device_type == "Hub_OnlineStatus": + return self.session.sensor.hub_online_status(self.node_id) + if self.device_type == "Hive_OutsideTemperature": + return self.session.weather.temperature() + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + if self.device_type == "Hive_OutsideTemperature": + return TEMP_CELSIUS + + @property + def icon(self): + """Return the icon to use.""" + return DEVICETYPE_ICONS.get(self.device_type) + + def update(self): + """Update all Node data from Hive.""" + self.session.core.update_data(self.node_id) diff --git a/homeassistant/components/hive/services.yaml b/homeassistant/components/hive/services.yaml new file mode 100644 index 000000000..6513d76ca --- /dev/null +++ b/homeassistant/components/hive/services.yaml @@ -0,0 +1,27 @@ +boost_heating: + description: "Set the boost mode ON defining the period of time and the desired target temperature + for the boost." + fields: + entity_id: + { + description: Enter the entity_id for the device required to set the boost mode., + example: "climate.heating", + } + time_period: + { description: Set the time period for the boost., example: "01:30:00" } + temperature: + { + description: Set the target temperature for the boost period., + example: "20.5", + } +boost_hot_water: + description: + "Set the boost mode ON or OFF defining the period of time for the boost." + fields: + entity_id: + { + description: Enter the entity_id for the device reuired to set the boost mode., + example: "water_heater.hot_water", + } + time_period: { description: Set the time period for the boost., example: "01:30:00" } + on_off: { description: Set the boost function on or off., example: "on" } diff --git a/homeassistant/components/hive/switch.py b/homeassistant/components/hive/switch.py new file mode 100644 index 000000000..53e1ec6a0 --- /dev/null +++ b/homeassistant/components/hive/switch.py @@ -0,0 +1,65 @@ +"""Support for the Hive switches.""" +from homeassistant.components.switch import SwitchDevice + +from . import DATA_HIVE, DOMAIN, HiveEntity, refresh_system + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up Hive switches.""" + if discovery_info is None: + return + + session = hass.data.get(DATA_HIVE) + devs = [] + for dev in discovery_info: + devs.append(HiveDevicePlug(session, dev)) + add_entities(devs) + + +class HiveDevicePlug(HiveEntity, SwitchDevice): + """Hive Active Plug.""" + + @property + def unique_id(self): + """Return unique ID of entity.""" + return self._unique_id + + @property + def device_info(self): + """Return device information.""" + return {"identifiers": {(DOMAIN, self.unique_id)}, "name": self.name} + + @property + def name(self): + """Return the name of this Switch device if any.""" + return self.node_name + + @property + def device_state_attributes(self): + """Show Device Attributes.""" + return self.attributes + + @property + def current_power_w(self): + """Return the current power usage in W.""" + return self.session.switch.get_power_usage(self.node_id) + + @property + def is_on(self): + """Return true if switch is on.""" + return self.session.switch.get_state(self.node_id) + + @refresh_system + def turn_on(self, **kwargs): + """Turn the switch on.""" + self.session.switch.turn_on(self.node_id) + + @refresh_system + def turn_off(self, **kwargs): + """Turn the device off.""" + self.session.switch.turn_off(self.node_id) + + 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/hive/water_heater.py b/homeassistant/components/hive/water_heater.py new file mode 100644 index 000000000..d7d98426d --- /dev/null +++ b/homeassistant/components/hive/water_heater.py @@ -0,0 +1,80 @@ +"""Support for hive water heaters.""" +from homeassistant.components.water_heater import ( + STATE_ECO, + STATE_OFF, + STATE_ON, + SUPPORT_OPERATION_MODE, + WaterHeaterDevice, +) +from homeassistant.const import TEMP_CELSIUS + +from . import DATA_HIVE, DOMAIN, HiveEntity, refresh_system + +SUPPORT_FLAGS_HEATER = SUPPORT_OPERATION_MODE + +HIVE_TO_HASS_STATE = {"SCHEDULE": STATE_ECO, "ON": STATE_ON, "OFF": STATE_OFF} +HASS_TO_HIVE_STATE = {STATE_ECO: "SCHEDULE", STATE_ON: "ON", STATE_OFF: "OFF"} +SUPPORT_WATER_HEATER = [STATE_ECO, STATE_ON, STATE_OFF] + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Hive water heater devices.""" + if discovery_info is None: + return + + session = hass.data.get(DATA_HIVE) + devs = [] + for dev in discovery_info: + devs.append(HiveWaterHeater(session, dev)) + add_entities(devs) + + +class HiveWaterHeater(HiveEntity, WaterHeaterDevice): + """Hive Water Heater Device.""" + + @property + def unique_id(self): + """Return unique ID of entity.""" + return self._unique_id + + @property + def device_info(self): + """Return device information.""" + return {"identifiers": {(DOMAIN, self.unique_id)}, "name": self.name} + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS_HEATER + + @property + def name(self): + """Return the name of the water heater.""" + if self.node_name is None: + self.node_name = "Hot Water" + return self.node_name + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def current_operation(self): + """Return current operation.""" + return HIVE_TO_HASS_STATE[self.session.hotwater.get_mode(self.node_id)] + + @property + def operation_list(self): + """List of available operation modes.""" + return SUPPORT_WATER_HEATER + + @refresh_system + def set_operation_mode(self, operation_mode): + """Set operation mode.""" + new_mode = HASS_TO_HIVE_STATE[operation_mode] + self.session.hotwater.set_mode(self.node_id, new_mode) + + def update(self): + """Update all Node data from Hive.""" + self.session.core.update_data(self.node_id) diff --git a/homeassistant/components/hlk_sw16/__init__.py b/homeassistant/components/hlk_sw16/__init__.py new file mode 100644 index 000000000..e7264c4e0 --- /dev/null +++ b/homeassistant/components/hlk_sw16/__init__.py @@ -0,0 +1,171 @@ +"""Support for HLK-SW16 relay switches.""" +import logging + +from hlk_sw16 import create_hlk_sw16_connection +import voluptuous as vol + +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PORT, + CONF_SWITCHES, + EVENT_HOMEASSISTANT_STOP, +) +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.discovery import async_load_platform +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +DATA_DEVICE_REGISTER = "hlk_sw16_device_register" +DEFAULT_RECONNECT_INTERVAL = 10 +CONNECTION_TIMEOUT = 10 +DEFAULT_PORT = 8080 + +DOMAIN = "hlk_sw16" + +SIGNAL_AVAILABILITY = "hlk_sw16_device_available_{}" + +SWITCH_SCHEMA = vol.Schema({vol.Optional(CONF_NAME): cv.string}) + +RELAY_ID = vol.All( + vol.Any(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, "a", "b", "c", "d", "e", "f"), vol.Coerce(str) +) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + cv.string: vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Required(CONF_SWITCHES): vol.Schema( + {RELAY_ID: SWITCH_SCHEMA} + ), + } + ) + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, config): + """Set up the HLK-SW16 switch.""" + # Allow platform to specify function to register new unknown devices + + hass.data[DATA_DEVICE_REGISTER] = {} + + def add_device(device): + switches = config[DOMAIN][device][CONF_SWITCHES] + + host = config[DOMAIN][device][CONF_HOST] + port = config[DOMAIN][device][CONF_PORT] + + @callback + def disconnected(): + """Schedule reconnect after connection has been lost.""" + _LOGGER.warning("HLK-SW16 %s disconnected", device) + async_dispatcher_send(hass, SIGNAL_AVAILABILITY.format(device), False) + + @callback + def reconnected(): + """Schedule reconnect after connection has been lost.""" + _LOGGER.warning("HLK-SW16 %s connected", device) + async_dispatcher_send(hass, SIGNAL_AVAILABILITY.format(device), True) + + async def connect(): + """Set up connection and hook it into HA for reconnect/shutdown.""" + _LOGGER.info("Initiating HLK-SW16 connection to %s", device) + + client = await create_hlk_sw16_connection( + host=host, + port=port, + disconnect_callback=disconnected, + reconnect_callback=reconnected, + loop=hass.loop, + timeout=CONNECTION_TIMEOUT, + reconnect_interval=DEFAULT_RECONNECT_INTERVAL, + ) + + hass.data[DATA_DEVICE_REGISTER][device] = client + + # Load platforms + hass.async_create_task( + async_load_platform(hass, "switch", DOMAIN, (switches, device), config) + ) + + # handle shutdown of HLK-SW16 asyncio transport + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, lambda x: client.stop() + ) + + _LOGGER.info("Connected to HLK-SW16 device: %s", device) + + hass.loop.create_task(connect()) + + for device in config[DOMAIN]: + add_device(device) + return True + + +class SW16Device(Entity): + """Representation of a HLK-SW16 device. + + Contains the common logic for HLK-SW16 entities. + """ + + def __init__(self, relay_name, device_port, device_id, client): + """Initialize the device.""" + # HLK-SW16 specific attributes for every component type + self._device_id = device_id + self._device_port = device_port + self._is_on = None + self._client = client + self._name = relay_name + + @callback + def handle_event_callback(self, event): + """Propagate changes through ha.""" + _LOGGER.debug("Relay %s new state callback: %r", self._device_port, event) + self._is_on = event + self.async_schedule_update_ha_state() + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def name(self): + """Return a name for the device.""" + return self._name + + @property + def available(self): + """Return True if entity is available.""" + return bool(self._client.is_connected) + + @callback + def _availability_callback(self, availability): + """Update availability state.""" + self.async_schedule_update_ha_state() + + async def async_added_to_hass(self): + """Register update callback.""" + self._client.register_status_callback( + self.handle_event_callback, self._device_port + ) + self._is_on = await self._client.status(self._device_port) + async_dispatcher_connect( + self.hass, + SIGNAL_AVAILABILITY.format(self._device_id), + self._availability_callback, + ) diff --git a/homeassistant/components/hlk_sw16/manifest.json b/homeassistant/components/hlk_sw16/manifest.json new file mode 100644 index 000000000..037df20b3 --- /dev/null +++ b/homeassistant/components/hlk_sw16/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "hlk_sw16", + "name": "Hlk sw16", + "documentation": "https://www.home-assistant.io/integrations/hlk_sw16", + "requirements": [ + "hlk-sw16==0.0.7" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/hlk_sw16/switch.py b/homeassistant/components/hlk_sw16/switch.py new file mode 100644 index 000000000..e9c190678 --- /dev/null +++ b/homeassistant/components/hlk_sw16/switch.py @@ -0,0 +1,44 @@ +"""Support for HLK-SW16 switches.""" +import logging + +from homeassistant.components.switch import ToggleEntity +from homeassistant.const import CONF_NAME + +from . import DATA_DEVICE_REGISTER, SW16Device + +_LOGGER = logging.getLogger(__name__) + + +def devices_from_config(hass, domain_config): + """Parse configuration and add HLK-SW16 switch devices.""" + switches = domain_config[0] + device_id = domain_config[1] + device_client = hass.data[DATA_DEVICE_REGISTER][device_id] + devices = [] + for device_port, device_config in switches.items(): + device_name = device_config.get(CONF_NAME, device_port) + device = SW16Switch(device_name, device_port, device_id, device_client) + devices.append(device) + return devices + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the HLK-SW16 platform.""" + async_add_entities(devices_from_config(hass, discovery_info)) + + +class SW16Switch(SW16Device, ToggleEntity): + """Representation of a HLK-SW16 switch.""" + + @property + def is_on(self): + """Return true if device is on.""" + return self._is_on + + async def async_turn_on(self, **kwargs): + """Turn the device on.""" + await self._client.turn_on(self._device_port) + + async def async_turn_off(self, **kwargs): + """Turn the device off.""" + await self._client.turn_off(self._device_port) diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py new file mode 100644 index 000000000..8aa1d7e02 --- /dev/null +++ b/homeassistant/components/homeassistant/__init__.py @@ -0,0 +1,173 @@ +"""Integration providing core pieces of infrastructure.""" +import asyncio +import itertools as it +import logging +from typing import Awaitable + +import voluptuous as vol + +import homeassistant.config as conf_util +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_LATITUDE, + ATTR_LONGITUDE, + RESTART_EXIT_CODE, + SERVICE_HOMEASSISTANT_RESTART, + SERVICE_HOMEASSISTANT_STOP, + SERVICE_TOGGLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +import homeassistant.core as ha +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv, intent +from homeassistant.helpers.service import async_extract_entity_ids + +_LOGGER = logging.getLogger(__name__) +DOMAIN = ha.DOMAIN +SERVICE_RELOAD_CORE_CONFIG = "reload_core_config" +SERVICE_CHECK_CONFIG = "check_config" +SERVICE_UPDATE_ENTITY = "update_entity" +SERVICE_SET_LOCATION = "set_location" +SCHEMA_UPDATE_ENTITY = vol.Schema({ATTR_ENTITY_ID: cv.entity_ids}) + + +async def async_setup(hass: ha.HomeAssistant, config: dict) -> Awaitable[bool]: + """Set up general services related to Home Assistant.""" + + async def async_handle_turn_service(service): + """Handle calls to homeassistant.turn_on/off.""" + entity_ids = await async_extract_entity_ids(hass, service) + + # Generic turn on/off method requires entity id + if not entity_ids: + _LOGGER.error( + "homeassistant/%s cannot be called without entity_id", service.service + ) + return + + # Group entity_ids by domain. groupby requires sorted data. + by_domain = it.groupby( + sorted(entity_ids), lambda item: ha.split_entity_id(item)[0] + ) + + tasks = [] + + for domain, ent_ids in by_domain: + # We want to block for all calls and only return when all calls + # 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 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) + + # Create a new dict for this call + data = dict(service.data) + + # ent_ids is a generator, convert it to a list. + data[ATTR_ENTITY_ID] = list(ent_ids) + + tasks.append( + hass.services.async_call(domain, service.service, data, blocking) + ) + + await asyncio.wait(tasks) + + hass.services.async_register(ha.DOMAIN, SERVICE_TURN_OFF, async_handle_turn_service) + hass.services.async_register(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 {}" + ) + ) + + async def async_handle_core_service(call): + """Service handler for handling core services.""" + if call.service == SERVICE_HOMEASSISTANT_STOP: + hass.async_create_task(hass.async_stop()) + return + + try: + errors = await conf_util.async_check_ha_config_file(hass) + except HomeAssistantError: + return + + if errors: + _LOGGER.error(errors) + hass.components.persistent_notification.async_create( + "Config error. See [the logs](/developer-tools/logs) for details.", + "Config validating", + f"{ha.DOMAIN}.check_config", + ) + return + + if call.service == SERVICE_HOMEASSISTANT_RESTART: + hass.async_create_task(hass.async_stop(RESTART_EXIT_CODE)) + + async def async_handle_update_service(call): + """Service handler for updating an entity.""" + tasks = [ + hass.helpers.entity_component.async_update_entity(entity) + for entity in call.data[ATTR_ENTITY_ID] + ] + + if tasks: + await asyncio.wait(tasks) + + hass.services.async_register( + ha.DOMAIN, SERVICE_HOMEASSISTANT_STOP, async_handle_core_service + ) + hass.services.async_register( + ha.DOMAIN, SERVICE_HOMEASSISTANT_RESTART, async_handle_core_service + ) + hass.services.async_register( + ha.DOMAIN, SERVICE_CHECK_CONFIG, async_handle_core_service + ) + hass.services.async_register( + ha.DOMAIN, + SERVICE_UPDATE_ENTITY, + async_handle_update_service, + schema=SCHEMA_UPDATE_ENTITY, + ) + + async def async_handle_reload_config(call): + """Service handler for reloading core config.""" + try: + conf = await conf_util.async_hass_config_yaml(hass) + except HomeAssistantError as err: + _LOGGER.error(err) + return + + # auth only processed during startup + await conf_util.async_process_ha_core_config(hass, conf.get(ha.DOMAIN) or {}) + + hass.helpers.service.async_register_admin_service( + ha.DOMAIN, SERVICE_RELOAD_CORE_CONFIG, async_handle_reload_config + ) + + async def async_set_location(call): + """Service handler to set location.""" + await hass.config.async_update( + latitude=call.data[ATTR_LATITUDE], longitude=call.data[ATTR_LONGITUDE] + ) + + hass.helpers.service.async_register_admin_service( + ha.DOMAIN, + SERVICE_SET_LOCATION, + async_set_location, + vol.Schema({ATTR_LATITUDE: cv.latitude, ATTR_LONGITUDE: cv.longitude}), + ) + + return True diff --git a/homeassistant/components/homeassistant/manifest.json b/homeassistant/components/homeassistant/manifest.json new file mode 100644 index 000000000..b4c03047a --- /dev/null +++ b/homeassistant/components/homeassistant/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "homeassistant", + "name": "Home Assistant Core Integration", + "documentation": "https://www.home-assistant.io/integrations/homeassistant", + "requirements": [], + "dependencies": [], + "codeowners": [ + "@home-assistant/core" + ] +} diff --git a/homeassistant/components/homeassistant/scene.py b/homeassistant/components/homeassistant/scene.py new file mode 100644 index 000000000..af271a069 --- /dev/null +++ b/homeassistant/components/homeassistant/scene.py @@ -0,0 +1,245 @@ +"""Allow users to set and activate scenes.""" +from collections import namedtuple +import logging + +import voluptuous as vol + +from homeassistant import config as conf_util +from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN, STATES, Scene +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_STATE, + CONF_ENTITIES, + CONF_ID, + CONF_NAME, + CONF_PLATFORM, + SERVICE_RELOAD, + STATE_OFF, + STATE_ON, +) +from homeassistant.core import DOMAIN as HA_DOMAIN, State +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import ( + config_per_platform, + config_validation as cv, + entity_platform, +) +from homeassistant.helpers.state import async_reproduce_state +from homeassistant.loader import async_get_integration + + +def _convert_states(states): + """Convert state definitions to State objects.""" + result = {} + + for entity_id in states: + entity_id = cv.entity_id(entity_id) + + if isinstance(states[entity_id], dict): + entity_attrs = states[entity_id].copy() + state = entity_attrs.pop(ATTR_STATE, None) + attributes = entity_attrs + else: + state = states[entity_id] + attributes = {} + + # YAML translates 'on' to a boolean + # http://yaml.org/type/bool.html + if isinstance(state, bool): + state = STATE_ON if state else STATE_OFF + elif not isinstance(state, str): + raise vol.Invalid(f"State for {entity_id} should be a string") + + result[entity_id] = State(entity_id, state, attributes) + + return result + + +def _ensure_no_intersection(value): + """Validate that entities and snapshot_entities do not overlap.""" + if ( + CONF_SNAPSHOT not in value + or CONF_ENTITIES not in value + or not any( + entity_id in value[CONF_SNAPSHOT] for entity_id in value[CONF_ENTITIES] + ) + ): + return value + + raise vol.Invalid("entities and snapshot_entities must not overlap") + + +CONF_SCENE_ID = "scene_id" +CONF_SNAPSHOT = "snapshot_entities" + +STATES_SCHEMA = vol.All(dict, _convert_states) + +PLATFORM_SCHEMA = vol.Schema( + { + vol.Required(CONF_PLATFORM): HA_DOMAIN, + vol.Required(STATES): vol.All( + cv.ensure_list, + [ + { + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_ENTITIES): STATES_SCHEMA, + } + ], + ), + }, + extra=vol.ALLOW_EXTRA, +) + +CREATE_SCENE_SCHEMA = vol.All( + cv.has_at_least_one_key(CONF_ENTITIES, CONF_SNAPSHOT), + _ensure_no_intersection, + vol.Schema( + { + vol.Required(CONF_SCENE_ID): cv.slug, + vol.Optional(CONF_ENTITIES, default={}): STATES_SCHEMA, + vol.Optional(CONF_SNAPSHOT, default=[]): cv.entity_ids, + } + ), +) + +SERVICE_APPLY = "apply" +SERVICE_CREATE = "create" +SCENECONFIG = namedtuple("SceneConfig", [CONF_NAME, STATES]) +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up home assistant scene entries.""" + _process_scenes_config(hass, async_add_entities, config) + + # This platform can be loaded multiple times. Only first time register the service. + if hass.services.has_service(SCENE_DOMAIN, SERVICE_RELOAD): + return + + # Store platform for later. + platform = entity_platform.current_platform.get() + + async def reload_config(call): + """Reload the scene config.""" + try: + conf = await conf_util.async_hass_config_yaml(hass) + except HomeAssistantError as err: + _LOGGER.error(err) + return + + integration = await async_get_integration(hass, SCENE_DOMAIN) + + conf = await conf_util.async_process_component_config(hass, conf, integration) + + if not conf or not platform: + return + + await platform.async_reset() + + # Extract only the config for the Home Assistant platform, ignore the rest. + for p_type, p_config in config_per_platform(conf, SCENE_DOMAIN): + if p_type != HA_DOMAIN: + continue + + _process_scenes_config(hass, async_add_entities, p_config) + + hass.helpers.service.async_register_admin_service( + SCENE_DOMAIN, SERVICE_RELOAD, reload_config + ) + + async def apply_service(call): + """Apply a scene.""" + await async_reproduce_state( + hass, call.data[CONF_ENTITIES].values(), blocking=True, context=call.context + ) + + hass.services.async_register( + SCENE_DOMAIN, + SERVICE_APPLY, + apply_service, + vol.Schema({vol.Required(CONF_ENTITIES): STATES_SCHEMA}), + ) + + async def create_service(call): + """Create a scene.""" + snapshot = call.data[CONF_SNAPSHOT] + entities = call.data[CONF_ENTITIES] + + for entity_id in snapshot: + state = hass.states.get(entity_id) + if state is None: + _LOGGER.warning( + "Entity %s does not exist and therefore cannot be snapshotted", + entity_id, + ) + continue + entities[entity_id] = State(entity_id, state.state, state.attributes) + + if not entities: + _LOGGER.warning("Empty scenes are not allowed") + return + + scene_config = SCENECONFIG(call.data[CONF_SCENE_ID], entities) + entity_id = f"{SCENE_DOMAIN}.{scene_config.name}" + old = platform.entities.get(entity_id) + if old is not None: + if not old.from_service: + _LOGGER.warning("The scene %s already exists", entity_id) + return + await platform.async_remove_entity(entity_id) + async_add_entities([HomeAssistantScene(hass, scene_config, from_service=True)]) + + hass.services.async_register( + SCENE_DOMAIN, SERVICE_CREATE, create_service, CREATE_SCENE_SCHEMA + ) + + +def _process_scenes_config(hass, async_add_entities, config): + """Process multiple scenes and add them.""" + scene_config = config[STATES] + + # Check empty list + if not scene_config: + return + + async_add_entities( + HomeAssistantScene( + hass, + SCENECONFIG(scene[CONF_NAME], scene[CONF_ENTITIES]), + scene.get(CONF_ID), + ) + for scene in scene_config + ) + + +class HomeAssistantScene(Scene): + """A scene is a group of entities and the states we want them to be.""" + + def __init__(self, hass, scene_config, scene_id=None, from_service=False): + """Initialize the scene.""" + self._id = scene_id + self.hass = hass + self.scene_config = scene_config + self.from_service = from_service + + @property + def name(self): + """Return the name of the scene.""" + return self.scene_config.name + + @property + def device_state_attributes(self): + """Return the scene state attributes.""" + attributes = {ATTR_ENTITY_ID: list(self.scene_config.states)} + if self._id is not None: + attributes[CONF_ID] = self._id + return attributes + + async def async_activate(self): + """Activate scene. Try to get entities into requested state.""" + await async_reproduce_state( + self.hass, + self.scene_config.states.values(), + blocking=True, + context=self._context, + ) diff --git a/homeassistant/components/homeassistant/services.yaml b/homeassistant/components/homeassistant/services.yaml new file mode 100644 index 000000000..cb3efb0d5 --- /dev/null +++ b/homeassistant/components/homeassistant/services.yaml @@ -0,0 +1,49 @@ +check_config: + description: Check the Home Assistant configuration files for errors. Errors will be displayed in the Home Assistant log. + +reload_core_config: + description: Reload the core configuration. + +restart: + description: Restart the Home Assistant service. + +set_location: + description: Update the Home Assistant location. + fields: + latitude: + description: Latitude of your location + example: 32.87336 + longitude: + description: Longitude of your location + example: 117.22743 + +stop: + description: Stop the Home Assistant service. + +toggle: + description: Generic service to toggle devices on/off under any domain. Same usage as the light.turn_on, switch.turn_on, etc. services. + fields: + entity_id: + description: The entity_id of the device to toggle on/off. + example: light.living_room + +turn_on: + description: Generic service to turn devices on under any domain. Same usage as the light.turn_on, switch.turn_on, etc. services. + fields: + entity_id: + description: The entity_id of the device to turn on. + example: light.living_room + +turn_off: + description: Generic service to turn devices off under any domain. Same usage as the light.turn_on, switch.turn_on, etc. services. + fields: + entity_id: + description: The entity_id of the device to turn off. + example: light.living_room + +update_entity: + description: Force one or more entities to update its data + fields: + entity_id: + description: One or multiple entity_ids to update. Can be a list. + example: light.living_room diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index eac02855b..ea2c46609 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -1,8 +1,4 @@ -"""Support for Apple HomeKit. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/homekit/ -""" +"""Support for Apple HomeKit.""" import ipaddress import logging from zlib import adler32 @@ -10,28 +6,64 @@ from zlib import adler32 import voluptuous as vol from homeassistant.components import cover +from homeassistant.components.media_player import DEVICE_CLASS_TV from homeassistant.const import ( - ATTR_DEVICE_CLASS, ATTR_SUPPORTED_FEATURES, ATTR_UNIT_OF_MEASUREMENT, - CONF_IP_ADDRESS, CONF_NAME, CONF_PORT, CONF_TYPE, DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_TEMPERATURE, - EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, - TEMP_CELSIUS, TEMP_FAHRENHEIT) + ATTR_DEVICE_CLASS, + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + ATTR_UNIT_OF_MEASUREMENT, + CONF_IP_ADDRESS, + CONF_NAME, + CONF_PORT, + CONF_TYPE, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_TEMPERATURE, + EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entityfilter import FILTER_SCHEMA from homeassistant.util import get_local_ip from homeassistant.util.decorator import Registry -from .const import ( - BRIDGE_NAME, CONF_AUTO_START, CONF_ENTITY_CONFIG, CONF_FEATURE_LIST, - CONF_FILTER, DEFAULT_AUTO_START, DEFAULT_PORT, DEVICE_CLASS_CO2, - DEVICE_CLASS_PM25, DOMAIN, HOMEKIT_FILE, SERVICE_HOMEKIT_START, - TYPE_OUTLET, TYPE_SWITCH) -from .util import ( - show_setup_message, validate_entity_config, validate_media_player_features) -TYPES = Registry() +from .const import ( + BRIDGE_NAME, + CONF_ADVERTISE_IP, + CONF_AUTO_START, + CONF_ENTITY_CONFIG, + CONF_FEATURE_LIST, + CONF_FILTER, + CONF_SAFE_MODE, + DEFAULT_AUTO_START, + DEFAULT_PORT, + DEFAULT_SAFE_MODE, + DEVICE_CLASS_CO, + DEVICE_CLASS_CO2, + DEVICE_CLASS_PM25, + DOMAIN, + HOMEKIT_FILE, + SERVICE_HOMEKIT_RESET_ACCESSORY, + SERVICE_HOMEKIT_START, + TYPE_FAUCET, + TYPE_OUTLET, + TYPE_SHOWER, + TYPE_SPRINKLER, + TYPE_SWITCH, + TYPE_VALVE, +) +from .util import ( + show_setup_message, + validate_entity_config, + validate_media_player_features, +) + _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['HAP-python==2.2.2'] +MAX_DEVICES = 100 +TYPES = Registry() # #### Driver Status #### STATUS_READY = 0 @@ -39,38 +71,86 @@ STATUS_RUNNING = 1 STATUS_STOPPED = 2 STATUS_WAIT = 3 -SWITCH_TYPES = {TYPE_OUTLET: 'Outlet', - TYPE_SWITCH: 'Switch'} +SWITCH_TYPES = { + TYPE_FAUCET: "Valve", + TYPE_OUTLET: "Outlet", + TYPE_SHOWER: "Valve", + TYPE_SPRINKLER: "Valve", + TYPE_SWITCH: "Switch", + TYPE_VALVE: "Valve", +} -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.All({ - vol.Optional(CONF_NAME, default=BRIDGE_NAME): - vol.All(cv.string, vol.Length(min=3, max=25)), - 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) +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.All( + { + vol.Optional(CONF_NAME, default=BRIDGE_NAME): vol.All( + cv.string, vol.Length(min=3, max=25) + ), + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_IP_ADDRESS): vol.All(ipaddress.ip_address, cv.string), + vol.Optional(CONF_ADVERTISE_IP): vol.All( + ipaddress.ip_address, cv.string + ), + vol.Optional(CONF_AUTO_START, default=DEFAULT_AUTO_START): cv.boolean, + vol.Optional(CONF_SAFE_MODE, default=DEFAULT_SAFE_MODE): cv.boolean, + vol.Optional(CONF_FILTER, default={}): FILTER_SCHEMA, + vol.Optional(CONF_ENTITY_CONFIG, default={}): validate_entity_config, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + +RESET_ACCESSORY_SERVICE_SCHEMA = vol.Schema( + {vol.Required(ATTR_ENTITY_ID): cv.entity_ids} +) async def async_setup(hass, config): """Set up the HomeKit component.""" - _LOGGER.debug('Begin setup HomeKit') + _LOGGER.debug("Begin setup HomeKit") conf = config[DOMAIN] name = conf[CONF_NAME] port = conf[CONF_PORT] ip_address = conf.get(CONF_IP_ADDRESS) + advertise_ip = conf.get(CONF_ADVERTISE_IP) auto_start = conf[CONF_AUTO_START] + safe_mode = conf[CONF_SAFE_MODE] entity_filter = conf[CONF_FILTER] entity_config = conf[CONF_ENTITY_CONFIG] - homekit = HomeKit(hass, name, port, ip_address, entity_filter, - entity_config) - await hass.async_add_job(homekit.setup) + homekit = HomeKit( + hass, + name, + port, + ip_address, + entity_filter, + entity_config, + safe_mode, + advertise_ip, + ) + await hass.async_add_executor_job(homekit.setup) + + def handle_homekit_reset_accessory(service): + """Handle start HomeKit service call.""" + if homekit.status != STATUS_RUNNING: + _LOGGER.warning( + "HomeKit is not running. Either it is waiting to be " + "started or has been stopped." + ) + return + + entity_ids = service.data.get("entity_id") + homekit.reset_accessories(entity_ids) + + hass.services.async_register( + DOMAIN, + SERVICE_HOMEKIT_RESET_ACCESSORY, + handle_homekit_reset_accessory, + schema=RESET_ACCESSORY_SERVICE_SCHEMA, + ) if auto_start: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, homekit.start) @@ -80,13 +160,15 @@ async def async_setup(hass, config): """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.') + "HomeKit is not ready. Either it is already running or has " + "been stopped." + ) return homekit.start() - hass.services.async_register(DOMAIN, SERVICE_HOMEKIT_START, - handle_homekit_service_start) + hass.services.async_register( + DOMAIN, SERVICE_HOMEKIT_START, handle_homekit_service_start + ) return True @@ -94,74 +176,86 @@ async def async_setup(hass, config): def get_accessory(hass, driver, 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) + _LOGGER.warning( + 'The entity "%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' + 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 in ("binary_sensor", "device_tracker", "person"): + a_type = "BinarySensor" - elif state.domain == 'climate': - a_type = 'Thermostat' + elif state.domain == "climate": + a_type = "Thermostat" - elif state.domain == 'cover': + elif state.domain == "cover": device_class = state.attributes.get(ATTR_DEVICE_CLASS) features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - if device_class == 'garage' and \ - features & (cover.SUPPORT_OPEN | cover.SUPPORT_CLOSE): - a_type = 'GarageDoorOpener' + if device_class == "garage" and features & ( + cover.SUPPORT_OPEN | cover.SUPPORT_CLOSE + ): + a_type = "GarageDoorOpener" elif features & cover.SUPPORT_SET_POSITION: - a_type = 'WindowCovering' + a_type = "WindowCovering" elif features & (cover.SUPPORT_OPEN | cover.SUPPORT_CLOSE): - a_type = 'WindowCoveringBasic' + a_type = "WindowCoveringBasic" - elif state.domain == 'fan': - a_type = 'Fan' + elif state.domain == "fan": + a_type = "Fan" - elif state.domain == 'light': - a_type = 'Light' + elif state.domain == "light": + a_type = "Light" - elif state.domain == 'lock': - a_type = 'Lock' + elif state.domain == "lock": + a_type = "Lock" - elif state.domain == 'media_player': + elif state.domain == "media_player": + device_class = state.attributes.get(ATTR_DEVICE_CLASS) feature_list = config.get(CONF_FEATURE_LIST) - if feature_list and \ - validate_media_player_features(state, feature_list): - a_type = 'MediaPlayer' - elif state.domain == 'sensor': + if device_class == DEVICE_CLASS_TV: + a_type = "TelevisionMediaPlayer" + else: + if feature_list and validate_media_player_features(state, feature_list): + a_type = "MediaPlayer" + + elif state.domain == "sensor": device_class = state.attributes.get(ATTR_DEVICE_CLASS) unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - 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' + 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_CO: + a_type = "CarbonMonoxideSensor" + elif device_class == DEVICE_CLASS_CO2 or DEVICE_CLASS_CO2 in state.entity_id: + a_type = "CarbonDioxideSensor" + elif device_class == DEVICE_CLASS_ILLUMINANCE or unit in ("lm", "lx"): + a_type = "LightSensor" - elif state.domain == 'switch': + elif state.domain == "switch": switch_type = config.get(CONF_TYPE, TYPE_SWITCH) a_type = SWITCH_TYPES[switch_type] - elif state.domain in ('automation', 'input_boolean', 'remote', 'script'): - a_type = 'Switch' + elif state.domain in ("automation", "input_boolean", "remote", "scene", "script"): + a_type = "Switch" + + elif state.domain == "water_heater": + a_type = "WaterHeater" if a_type is None: return None @@ -172,17 +266,26 @@ def get_accessory(hass, driver, state, aid, config): def generate_aid(entity_id): """Generate accessory aid with zlib adler32.""" - aid = adler32(entity_id.encode('utf-8')) + aid = adler32(entity_id.encode("utf-8")) if aid in (0, 1): return None return aid -class HomeKit(): +class HomeKit: """Class to handle all actions between HomeKit and Home Assistant.""" - def __init__(self, hass, name, port, ip_address, entity_filter, - entity_config): + def __init__( + self, + hass, + name, + port, + ip_address, + entity_filter, + entity_config, + safe_mode, + advertise_ip=None, + ): """Initialize a HomeKit object.""" self.hass = hass self._name = name @@ -190,6 +293,8 @@ class HomeKit(): self._ip_address = ip_address self._filter = entity_filter self._config = entity_config + self._safe_mode = safe_mode + self._advertise_ip = advertise_ip self.status = STATUS_READY self.bridge = None @@ -197,16 +302,42 @@ class HomeKit(): def setup(self): """Set up bridge and accessory driver.""" + # pylint: disable=import-outside-toplevel from .accessories import HomeBridge, HomeDriver - self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, self.stop) + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.stop) ip_addr = self._ip_address or get_local_ip() path = self.hass.config.path(HOMEKIT_FILE) - self.driver = HomeDriver(self.hass, address=ip_addr, - port=self._port, persist_file=path) + self.driver = HomeDriver( + self.hass, + address=ip_addr, + port=self._port, + persist_file=path, + advertised_address=self._advertise_ip, + ) self.bridge = HomeBridge(self.hass, self.driver, self._name) + if self._safe_mode: + _LOGGER.debug("Safe_mode selected") + self.driver.safe_mode = True + + def reset_accessories(self, entity_ids): + """Reset the accessory to load the latest configuration.""" + removed = [] + for entity_id in entity_ids: + aid = generate_aid(entity_id) + if aid not in self.bridge.accessories: + _LOGGER.warning( + "Could not reset accessory. entity_id " "not found %s", entity_id + ) + continue + acc = self.remove_bridge_accessory(aid) + removed.append(acc) + self.driver.config_changed() + + for acc in removed: + self.bridge.add_accessory(acc) + self.driver.config_changed() def add_bridge_accessory(self, state): """Try adding accessory to bridge if configured beforehand.""" @@ -218,17 +349,30 @@ class HomeKit(): if acc is not None: self.bridge.add_accessory(acc) + def remove_bridge_accessory(self, aid): + """Try adding accessory to bridge if configured beforehand.""" + acc = None + if aid in self.bridge.accessories: + acc = self.bridge.accessories.pop(aid) + return 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_media_players, type_security_systems, type_sensors, - type_switches, type_thermostats) + from . import ( # noqa: F401 pylint: disable=unused-import, import-outside-toplevel + type_covers, + type_fans, + type_lights, + type_locks, + type_media_players, + type_security_systems, + type_sensors, + type_switches, + type_thermostats, + ) for state in self.hass.states.all(): self.add_bridge_accessory(state) @@ -237,7 +381,13 @@ class HomeKit(): if not self.driver.state.paired: show_setup_message(self.hass, self.driver.state.pincode) - _LOGGER.debug('Driver start') + if len(self.bridge.accessories) > MAX_DEVICES: + _LOGGER.warning( + "You have exceeded the device limit, which might " + "cause issues. Consider using the filter option." + ) + + _LOGGER.debug("Driver start") self.hass.add_job(self.driver.start) self.status = STATUS_RUNNING @@ -247,5 +397,5 @@ class HomeKit(): return self.status = STATUS_STOPPED - _LOGGER.debug('Driver stop') + _LOGGER.debug("Driver stop") self.hass.add_job(self.driver.stop) diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index adf5273b6..ddcc795d2 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -9,31 +9,49 @@ from pyhap.accessory_driver import AccessoryDriver from pyhap.const import CATEGORY_OTHER from homeassistant.const import ( - __version__, ATTR_BATTERY_CHARGING, ATTR_BATTERY_LEVEL) -from homeassistant.core import callback as ha_callback -from homeassistant.core import split_entity_id + ATTR_BATTERY_CHARGING, + ATTR_BATTERY_LEVEL, + ATTR_ENTITY_ID, + ATTR_SERVICE, + __version__, +) +from homeassistant.core import callback as ha_callback, split_entity_id from homeassistant.helpers.event import ( - async_track_state_change, track_point_in_utc_time) + async_track_state_change, + track_point_in_utc_time, +) from homeassistant.util import dt as dt_util from .const import ( - BRIDGE_MODEL, BRIDGE_SERIAL_NUMBER, CHAR_BATTERY_LEVEL, - CHAR_CHARGING_STATE, CHAR_STATUS_LOW_BATTERY, DEBOUNCE_TIMEOUT, - MANUFACTURER, SERV_BATTERY_SERVICE) -from .util import ( - convert_to_float, show_setup_message, dismiss_setup_message) + ATTR_DISPLAY_NAME, + ATTR_VALUE, + BRIDGE_MODEL, + BRIDGE_SERIAL_NUMBER, + CHAR_BATTERY_LEVEL, + CHAR_CHARGING_STATE, + CHAR_STATUS_LOW_BATTERY, + CONF_LINKED_BATTERY_SENSOR, + CONF_LOW_BATTERY_THRESHOLD, + DEBOUNCE_TIMEOUT, + DEFAULT_LOW_BATTERY_THRESHOLD, + EVENT_HOMEKIT_CHANGED, + MANUFACTURER, + SERV_BATTERY_SERVICE, +) +from .util import convert_to_float, dismiss_setup_message, show_setup_message _LOGGER = logging.getLogger(__name__) def debounce(func): """Decorate function to debounce callbacks from HomeKit.""" + @ha_callback def call_later_listener(self, *args): """Handle call_later callback.""" debounce_params = self.debounce.pop(func.__name__, None) if debounce_params: - self.hass.async_add_job(func, self, *debounce_params[1:]) + self.hass.async_add_executor_job(func, self, *debounce_params[1:]) @wraps(func) def wrapper(self, *args): @@ -42,11 +60,14 @@ def debounce(func): if debounce_params: debounce_params[0]() # remove listener remove_listener = track_point_in_utc_time( - self.hass, partial(call_later_listener, self), - dt_util.utcnow() + timedelta(seconds=DEBOUNCE_TIMEOUT)) + self.hass, + partial(call_later_listener, self), + dt_util.utcnow() + timedelta(seconds=DEBOUNCE_TIMEOUT), + ) self.debounce[func.__name__] = (remove_listener, *args) - logger.debug('%s: Start %s timeout', self.entity_id, - func.__name__.replace('set_', '')) + logger.debug( + "%s: Start %s timeout", self.entity_id, func.__name__.replace("set_", "") + ) name = getmodule(func).__name__ logger = logging.getLogger(name) @@ -56,69 +77,111 @@ def debounce(func): class HomeAccessory(Accessory): """Adapter class for Accessory.""" - def __init__(self, hass, driver, name, entity_id, aid, config, - category=CATEGORY_OTHER): + def __init__( + self, hass, driver, name, entity_id, aid, config, category=CATEGORY_OTHER + ): """Initialize a Accessory object.""" super().__init__(driver, name, aid=aid) model = split_entity_id(entity_id)[0].replace("_", " ").title() self.set_info_service( - firmware_revision=__version__, manufacturer=MANUFACTURER, - model=model, serial_number=entity_id) + firmware_revision=__version__, + manufacturer=MANUFACTURER, + model=model, + serial_number=entity_id, + ) self.category = category - self.config = config + self.config = config or {} self.entity_id = entity_id self.hass = hass self.debounce = {} self._support_battery_level = False self._support_battery_charging = True + self.linked_battery_sensor = self.config.get(CONF_LINKED_BATTERY_SENSOR) + self.low_battery_threshold = self.config.get( + CONF_LOW_BATTERY_THRESHOLD, DEFAULT_LOW_BATTERY_THRESHOLD + ) """Add battery service if available""" - battery_level = self.hass.states.get(self.entity_id).attributes \ - .get(ATTR_BATTERY_LEVEL) - if battery_level is None: + battery_found = self.hass.states.get(self.entity_id).attributes.get( + ATTR_BATTERY_LEVEL + ) + + if self.linked_battery_sensor: + state = self.hass.states.get(self.linked_battery_sensor) + if state is not None: + battery_found = state.state + else: + self.linked_battery_sensor = None + _LOGGER.warning( + "%s: Battery sensor state missing: %s", + self.entity_id, + self.linked_battery_sensor, + ) + + if battery_found is None: return - _LOGGER.debug('%s: Found battery level attribute', self.entity_id) + _LOGGER.debug("%s: Found battery level", self.entity_id) self._support_battery_level = True serv_battery = self.add_preload_service(SERV_BATTERY_SERVICE) - self._char_battery = serv_battery.configure_char( - CHAR_BATTERY_LEVEL, value=0) - self._char_charging = serv_battery.configure_char( - CHAR_CHARGING_STATE, value=2) + self._char_battery = serv_battery.configure_char(CHAR_BATTERY_LEVEL, value=0) + self._char_charging = serv_battery.configure_char(CHAR_CHARGING_STATE, value=2) self._char_low_battery = serv_battery.configure_char( - CHAR_STATUS_LOW_BATTERY, value=0) + CHAR_STATUS_LOW_BATTERY, value=0 + ) async def run(self): """Handle accessory driver started event. Run inside the HAP-python event loop. """ + self.hass.add_job(self.run_handler) + + async def run_handler(self): + """Handle accessory driver started event. + + Run inside the Home Assistant event loop. + """ state = self.hass.states.get(self.entity_id) - self.hass.add_job(self.update_state_callback, None, None, state) - async_track_state_change( - self.hass, self.entity_id, self.update_state_callback) + self.hass.async_add_job(self.update_state_callback, None, None, state) + async_track_state_change(self.hass, self.entity_id, self.update_state_callback) + + if self.linked_battery_sensor: + battery_state = self.hass.states.get(self.linked_battery_sensor) + self.hass.async_add_job( + self.update_linked_battery, None, None, battery_state + ) + async_track_state_change( + self.hass, self.linked_battery_sensor, self.update_linked_battery + ) @ha_callback - def update_state_callback(self, entity_id=None, old_state=None, - new_state=None): + def update_state_callback(self, entity_id=None, old_state=None, new_state=None): """Handle state change listener callback.""" - _LOGGER.debug('New_state: %s', new_state) + _LOGGER.debug("New_state: %s", new_state) if new_state is None: return - if self._support_battery_level: - self.hass.async_add_job(self.update_battery, new_state) - self.hass.async_add_job(self.update_state, new_state) + if self._support_battery_level and not self.linked_battery_sensor: + self.hass.async_add_executor_job(self.update_battery, new_state) + self.hass.async_add_executor_job(self.update_state, new_state) + + @ha_callback + def update_linked_battery(self, entity_id=None, old_state=None, new_state=None): + """Handle linked battery sensor state change listener callback.""" + self.hass.async_add_executor_job(self.update_battery, new_state) def update_battery(self, new_state): """Update battery service if available. Only call this function if self._support_battery_level is True. """ - battery_level = convert_to_float( - new_state.attributes.get(ATTR_BATTERY_LEVEL)) + battery_level = convert_to_float(new_state.attributes.get(ATTR_BATTERY_LEVEL)) + if self.linked_battery_sensor: + battery_level = convert_to_float(new_state.state) + if battery_level is None: + return self._char_battery.set_value(battery_level) - self._char_low_battery.set_value(battery_level < 20) - _LOGGER.debug('%s: Updated battery level to %d', self.entity_id, - battery_level) + self._char_low_battery.set_value(battery_level < self.low_battery_threshold) + _LOGGER.debug("%s: Updated battery level to %d", self.entity_id, battery_level) if not self._support_battery_charging: return charging = new_state.attributes.get(ATTR_BATTERY_CHARGING) @@ -127,8 +190,7 @@ class HomeAccessory(Accessory): return hk_charging = 1 if charging is True else 0 self._char_charging.set_value(hk_charging) - _LOGGER.debug('%s: Updated battery charging to %d', self.entity_id, - hk_charging) + _LOGGER.debug("%s: Updated battery charging to %d", self.entity_id, hk_charging) def update_state(self, new_state): """Handle state change to update HomeKit value. @@ -137,6 +199,25 @@ class HomeAccessory(Accessory): """ raise NotImplementedError() + def call_service(self, domain, service, service_data, value=None): + """Fire event and call service for changes from HomeKit.""" + self.hass.add_job(self.async_call_service, domain, service, service_data, value) + + async def async_call_service(self, domain, service, service_data, value=None): + """Fire event and call service for changes from HomeKit. + + This method must be run in the event loop. + """ + event_data = { + ATTR_ENTITY_ID: self.entity_id, + ATTR_DISPLAY_NAME: self.display_name, + ATTR_SERVICE: service, + ATTR_VALUE: value, + } + + self.hass.bus.async_fire(EVENT_HOMEKIT_CHANGED, event_data) + await self.hass.services.async_call(domain, service, service_data) + class HomeBridge(Bridge): """Adapter class for Bridge.""" @@ -145,8 +226,11 @@ class HomeBridge(Bridge): """Initialize a Bridge object.""" super().__init__(driver, name) self.set_info_service( - firmware_revision=__version__, manufacturer=MANUFACTURER, - model=BRIDGE_MODEL, serial_number=BRIDGE_SERIAL_NUMBER) + firmware_revision=__version__, + manufacturer=MANUFACTURER, + model=BRIDGE_MODEL, + serial_number=BRIDGE_SERIAL_NUMBER, + ) self.hass = hass def setup_message(self): diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 33d2c0bfb..82ec296da 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -1,127 +1,177 @@ """Constants used be the HomeKit component.""" # #### Misc #### DEBOUNCE_TIMEOUT = 0.5 -DOMAIN = 'homekit' -HOMEKIT_FILE = '.homekit.state' +DOMAIN = "homekit" +HOMEKIT_FILE = ".homekit.state" HOMEKIT_NOTIFY_ID = 4663548 +# #### Attributes #### +ATTR_DISPLAY_NAME = "display_name" +ATTR_VALUE = "value" + # #### Config #### -CONF_AUTO_START = 'auto_start' -CONF_ENTITY_CONFIG = 'entity_config' -CONF_FEATURE = 'feature' -CONF_FEATURE_LIST = 'feature_list' -CONF_FILTER = 'filter' +CONF_ADVERTISE_IP = "advertise_ip" +CONF_AUTO_START = "auto_start" +CONF_ENTITY_CONFIG = "entity_config" +CONF_FEATURE = "feature" +CONF_FEATURE_LIST = "feature_list" +CONF_FILTER = "filter" +CONF_LINKED_BATTERY_SENSOR = "linked_battery_sensor" +CONF_LOW_BATTERY_THRESHOLD = "low_battery_threshold" +CONF_SAFE_MODE = "safe_mode" # #### Config Defaults #### DEFAULT_AUTO_START = True +DEFAULT_LOW_BATTERY_THRESHOLD = 20 DEFAULT_PORT = 51827 +DEFAULT_SAFE_MODE = False # #### Features #### -FEATURE_ON_OFF = 'on_off' -FEATURE_PLAY_PAUSE = 'play_pause' -FEATURE_PLAY_STOP = 'play_stop' -FEATURE_TOGGLE_MUTE = 'toggle_mute' +FEATURE_ON_OFF = "on_off" +FEATURE_PLAY_PAUSE = "play_pause" +FEATURE_PLAY_STOP = "play_stop" +FEATURE_TOGGLE_MUTE = "toggle_mute" + +# #### HomeKit Component Event #### +EVENT_HOMEKIT_CHANGED = "homekit_state_change" # #### HomeKit Component Services #### -SERVICE_HOMEKIT_START = 'start' +SERVICE_HOMEKIT_START = "start" +SERVICE_HOMEKIT_RESET_ACCESSORY = "reset_accessory" # #### String Constants #### -BRIDGE_MODEL = 'Bridge' -BRIDGE_NAME = 'Home Assistant Bridge' -BRIDGE_SERIAL_NUMBER = 'homekit.bridge' -MANUFACTURER = 'Home Assistant' +BRIDGE_MODEL = "Bridge" +BRIDGE_NAME = "Home Assistant Bridge" +BRIDGE_SERIAL_NUMBER = "homekit.bridge" +MANUFACTURER = "Home Assistant" # #### Switch Types #### -TYPE_OUTLET = 'outlet' -TYPE_SWITCH = 'switch' +TYPE_FAUCET = "faucet" +TYPE_OUTLET = "outlet" +TYPE_SHOWER = "shower" +TYPE_SPRINKLER = "sprinkler" +TYPE_SWITCH = "switch" +TYPE_VALVE = "valve" # #### Services #### -SERV_ACCESSORY_INFO = 'AccessoryInformation' -SERV_AIR_QUALITY_SENSOR = 'AirQualitySensor' -SERV_BATTERY_SERVICE = 'BatteryService' -SERV_CARBON_DIOXIDE_SENSOR = 'CarbonDioxideSensor' -SERV_CARBON_MONOXIDE_SENSOR = 'CarbonMonoxideSensor' -SERV_CONTACT_SENSOR = 'ContactSensor' -SERV_FANV2 = 'Fanv2' -SERV_GARAGE_DOOR_OPENER = 'GarageDoorOpener' -SERV_HUMIDITY_SENSOR = 'HumiditySensor' -SERV_LEAK_SENSOR = 'LeakSensor' -SERV_LIGHT_SENSOR = 'LightSensor' -SERV_LIGHTBULB = 'Lightbulb' -SERV_LOCK = 'LockMechanism' -SERV_MOTION_SENSOR = 'MotionSensor' -SERV_OCCUPANCY_SENSOR = 'OccupancySensor' -SERV_OUTLET = 'Outlet' -SERV_SECURITY_SYSTEM = 'SecuritySystem' -SERV_SMOKE_SENSOR = 'SmokeSensor' -SERV_SWITCH = 'Switch' -SERV_TEMPERATURE_SENSOR = 'TemperatureSensor' -SERV_THERMOSTAT = 'Thermostat' -SERV_WINDOW_COVERING = 'WindowCovering' +SERV_ACCESSORY_INFO = "AccessoryInformation" +SERV_AIR_QUALITY_SENSOR = "AirQualitySensor" +SERV_BATTERY_SERVICE = "BatteryService" +SERV_CARBON_DIOXIDE_SENSOR = "CarbonDioxideSensor" +SERV_CARBON_MONOXIDE_SENSOR = "CarbonMonoxideSensor" +SERV_CONTACT_SENSOR = "ContactSensor" +SERV_FANV2 = "Fanv2" +SERV_GARAGE_DOOR_OPENER = "GarageDoorOpener" +SERV_HUMIDITY_SENSOR = "HumiditySensor" +SERV_INPUT_SOURCE = "InputSource" +SERV_LEAK_SENSOR = "LeakSensor" +SERV_LIGHT_SENSOR = "LightSensor" +SERV_LIGHTBULB = "Lightbulb" +SERV_LOCK = "LockMechanism" +SERV_MOTION_SENSOR = "MotionSensor" +SERV_OCCUPANCY_SENSOR = "OccupancySensor" +SERV_OUTLET = "Outlet" +SERV_SECURITY_SYSTEM = "SecuritySystem" +SERV_SMOKE_SENSOR = "SmokeSensor" +SERV_SWITCH = "Switch" +SERV_TELEVISION = "Television" +SERV_TELEVISION_SPEAKER = "TelevisionSpeaker" +SERV_TEMPERATURE_SENSOR = "TemperatureSensor" +SERV_THERMOSTAT = "Thermostat" +SERV_VALVE = "Valve" +SERV_WINDOW_COVERING = "WindowCovering" # #### Characteristics #### -CHAR_ACTIVE = 'Active' -CHAR_AIR_PARTICULATE_DENSITY = 'AirParticulateDensity' -CHAR_AIR_QUALITY = 'AirQuality' -CHAR_BATTERY_LEVEL = 'BatteryLevel' -CHAR_BRIGHTNESS = 'Brightness' -CHAR_CARBON_DIOXIDE_DETECTED = 'CarbonDioxideDetected' -CHAR_CARBON_DIOXIDE_LEVEL = 'CarbonDioxideLevel' -CHAR_CARBON_DIOXIDE_PEAK_LEVEL = 'CarbonDioxidePeakLevel' -CHAR_CARBON_MONOXIDE_DETECTED = 'CarbonMonoxideDetected' -CHAR_CHARGING_STATE = 'ChargingState' -CHAR_COLOR_TEMPERATURE = 'ColorTemperature' -CHAR_CONTACT_SENSOR_STATE = 'ContactSensorState' -CHAR_COOLING_THRESHOLD_TEMPERATURE = 'CoolingThresholdTemperature' -CHAR_CURRENT_AMBIENT_LIGHT_LEVEL = 'CurrentAmbientLightLevel' -CHAR_CURRENT_DOOR_STATE = 'CurrentDoorState' -CHAR_CURRENT_HEATING_COOLING = 'CurrentHeatingCoolingState' -CHAR_CURRENT_POSITION = 'CurrentPosition' -CHAR_CURRENT_HUMIDITY = 'CurrentRelativeHumidity' -CHAR_CURRENT_SECURITY_STATE = 'SecuritySystemCurrentState' -CHAR_CURRENT_TEMPERATURE = 'CurrentTemperature' -CHAR_FIRMWARE_REVISION = 'FirmwareRevision' -CHAR_HEATING_THRESHOLD_TEMPERATURE = 'HeatingThresholdTemperature' -CHAR_HUE = 'Hue' -CHAR_LEAK_DETECTED = 'LeakDetected' -CHAR_LOCK_CURRENT_STATE = 'LockCurrentState' -CHAR_LOCK_TARGET_STATE = 'LockTargetState' -CHAR_LINK_QUALITY = 'LinkQuality' -CHAR_MANUFACTURER = 'Manufacturer' -CHAR_MODEL = 'Model' -CHAR_MOTION_DETECTED = 'MotionDetected' -CHAR_NAME = 'Name' -CHAR_OCCUPANCY_DETECTED = 'OccupancyDetected' -CHAR_OUTLET_IN_USE = 'OutletInUse' -CHAR_ON = 'On' -CHAR_POSITION_STATE = 'PositionState' -CHAR_ROTATION_DIRECTION = 'RotationDirection' -CHAR_SATURATION = 'Saturation' -CHAR_SERIAL_NUMBER = 'SerialNumber' -CHAR_SMOKE_DETECTED = 'SmokeDetected' -CHAR_STATUS_LOW_BATTERY = 'StatusLowBattery' -CHAR_SWING_MODE = 'SwingMode' -CHAR_TARGET_DOOR_STATE = 'TargetDoorState' -CHAR_TARGET_HEATING_COOLING = 'TargetHeatingCoolingState' -CHAR_TARGET_POSITION = 'TargetPosition' -CHAR_TARGET_SECURITY_STATE = 'SecuritySystemTargetState' -CHAR_TARGET_TEMPERATURE = 'TargetTemperature' -CHAR_TEMP_DISPLAY_UNITS = 'TemperatureDisplayUnits' +CHAR_ACTIVE = "Active" +CHAR_ACTIVE_IDENTIFIER = "ActiveIdentifier" +CHAR_AIR_PARTICULATE_DENSITY = "AirParticulateDensity" +CHAR_AIR_QUALITY = "AirQuality" +CHAR_BATTERY_LEVEL = "BatteryLevel" +CHAR_BRIGHTNESS = "Brightness" +CHAR_CARBON_DIOXIDE_DETECTED = "CarbonDioxideDetected" +CHAR_CARBON_DIOXIDE_LEVEL = "CarbonDioxideLevel" +CHAR_CARBON_DIOXIDE_PEAK_LEVEL = "CarbonDioxidePeakLevel" +CHAR_CARBON_MONOXIDE_DETECTED = "CarbonMonoxideDetected" +CHAR_CARBON_MONOXIDE_LEVEL = "CarbonMonoxideLevel" +CHAR_CARBON_MONOXIDE_PEAK_LEVEL = "CarbonMonoxidePeakLevel" +CHAR_CHARGING_STATE = "ChargingState" +CHAR_COLOR_TEMPERATURE = "ColorTemperature" +CHAR_CONFIGURED_NAME = "ConfiguredName" +CHAR_CONTACT_SENSOR_STATE = "ContactSensorState" +CHAR_COOLING_THRESHOLD_TEMPERATURE = "CoolingThresholdTemperature" +CHAR_CURRENT_AMBIENT_LIGHT_LEVEL = "CurrentAmbientLightLevel" +CHAR_CURRENT_DOOR_STATE = "CurrentDoorState" +CHAR_CURRENT_HEATING_COOLING = "CurrentHeatingCoolingState" +CHAR_CURRENT_POSITION = "CurrentPosition" +CHAR_CURRENT_HUMIDITY = "CurrentRelativeHumidity" +CHAR_CURRENT_SECURITY_STATE = "SecuritySystemCurrentState" +CHAR_CURRENT_TEMPERATURE = "CurrentTemperature" +CHAR_CURRENT_VISIBILITY_STATE = "CurrentVisibilityState" +CHAR_FIRMWARE_REVISION = "FirmwareRevision" +CHAR_HEATING_THRESHOLD_TEMPERATURE = "HeatingThresholdTemperature" +CHAR_HUE = "Hue" +CHAR_IDENTIFIER = "Identifier" +CHAR_IN_USE = "InUse" +CHAR_INPUT_SOURCE_TYPE = "InputSourceType" +CHAR_IS_CONFIGURED = "IsConfigured" +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_MUTE = "Mute" +CHAR_NAME = "Name" +CHAR_OCCUPANCY_DETECTED = "OccupancyDetected" +CHAR_ON = "On" +CHAR_OUTLET_IN_USE = "OutletInUse" +CHAR_POSITION_STATE = "PositionState" +CHAR_REMOTE_KEY = "RemoteKey" +CHAR_ROTATION_DIRECTION = "RotationDirection" +CHAR_ROTATION_SPEED = "RotationSpeed" +CHAR_SATURATION = "Saturation" +CHAR_SERIAL_NUMBER = "SerialNumber" +CHAR_SLEEP_DISCOVER_MODE = "SleepDiscoveryMode" +CHAR_SMOKE_DETECTED = "SmokeDetected" +CHAR_STATUS_LOW_BATTERY = "StatusLowBattery" +CHAR_SWING_MODE = "SwingMode" +CHAR_TARGET_DOOR_STATE = "TargetDoorState" +CHAR_TARGET_HEATING_COOLING = "TargetHeatingCoolingState" +CHAR_TARGET_POSITION = "TargetPosition" +CHAR_TARGET_SECURITY_STATE = "SecuritySystemTargetState" +CHAR_TARGET_TEMPERATURE = "TargetTemperature" +CHAR_TEMP_DISPLAY_UNITS = "TemperatureDisplayUnits" +CHAR_VALVE_TYPE = "ValveType" +CHAR_VOLUME = "Volume" +CHAR_VOLUME_SELECTOR = "VolumeSelector" +CHAR_VOLUME_CONTROL_TYPE = "VolumeControlType" + # #### Properties #### -PROP_MAX_VALUE = 'maxValue' -PROP_MIN_VALUE = 'minValue' -PROP_CELSIUS = {'minValue': -273, 'maxValue': 999} +PROP_MAX_VALUE = "maxValue" +PROP_MIN_VALUE = "minValue" +PROP_MIN_STEP = "minStep" +PROP_CELSIUS = {"minValue": -273, "maxValue": 999} # #### Device Classes #### -DEVICE_CLASS_CO2 = 'co2' -DEVICE_CLASS_DOOR = 'door' -DEVICE_CLASS_GARAGE_DOOR = 'garage_door' -DEVICE_CLASS_GAS = 'gas' -DEVICE_CLASS_MOISTURE = 'moisture' -DEVICE_CLASS_MOTION = 'motion' -DEVICE_CLASS_OCCUPANCY = 'occupancy' -DEVICE_CLASS_OPENING = 'opening' -DEVICE_CLASS_PM25 = 'pm25' -DEVICE_CLASS_SMOKE = 'smoke' -DEVICE_CLASS_WINDOW = 'window' +DEVICE_CLASS_CO = "co" +DEVICE_CLASS_CO2 = "co2" +DEVICE_CLASS_DOOR = "door" +DEVICE_CLASS_GARAGE_DOOR = "garage_door" +DEVICE_CLASS_GAS = "gas" +DEVICE_CLASS_MOISTURE = "moisture" +DEVICE_CLASS_MOTION = "motion" +DEVICE_CLASS_OCCUPANCY = "occupancy" +DEVICE_CLASS_OPENING = "opening" +DEVICE_CLASS_PM25 = "pm25" +DEVICE_CLASS_SMOKE = "smoke" +DEVICE_CLASS_WINDOW = "window" + +# #### Thresholds #### +THRESHOLD_CO = 25 +THRESHOLD_CO2 = 1000 + +# #### Default values #### +DEFAULT_MIN_TEMP_WATER_HEATER = 40 # °C +DEFAULT_MAX_TEMP_WATER_HEATER = 60 # °C diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json new file mode 100644 index 000000000..c0ab61d85 --- /dev/null +++ b/homeassistant/components/homekit/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "homekit", + "name": "Homekit", + "documentation": "https://www.home-assistant.io/integrations/homekit", + "requirements": [ + "HAP-python==2.6.0" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/homekit/type_covers.py b/homeassistant/components/homekit/type_covers.py index cf0620a4e..3a33207e7 100644 --- a/homeassistant/components/homekit/type_covers.py +++ b/homeassistant/components/homekit/type_covers.py @@ -4,23 +4,38 @@ import logging from pyhap.const import CATEGORY_GARAGE_DOOR_OPENER, CATEGORY_WINDOW_COVERING from homeassistant.components.cover import ( - ATTR_CURRENT_POSITION, ATTR_POSITION, DOMAIN, SUPPORT_STOP) + ATTR_CURRENT_POSITION, + ATTR_POSITION, + DOMAIN, + SUPPORT_STOP, +) from homeassistant.const import ( - ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, SERVICE_CLOSE_COVER, - SERVICE_OPEN_COVER, SERVICE_SET_COVER_POSITION, SERVICE_STOP_COVER, - STATE_CLOSED, STATE_OPEN) + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + SERVICE_SET_COVER_POSITION, + SERVICE_STOP_COVER, + STATE_CLOSED, + STATE_OPEN, +) from . import TYPES -from .accessories import debounce, HomeAccessory +from .accessories import HomeAccessory, debounce from .const import ( - CHAR_CURRENT_DOOR_STATE, CHAR_CURRENT_POSITION, CHAR_POSITION_STATE, - CHAR_TARGET_DOOR_STATE, CHAR_TARGET_POSITION, - SERV_GARAGE_DOOR_OPENER, SERV_WINDOW_COVERING) + CHAR_CURRENT_DOOR_STATE, + CHAR_CURRENT_POSITION, + CHAR_POSITION_STATE, + CHAR_TARGET_DOOR_STATE, + CHAR_TARGET_POSITION, + SERV_GARAGE_DOOR_OPENER, + SERV_WINDOW_COVERING, +) _LOGGER = logging.getLogger(__name__) -@TYPES.register('GarageDoorOpener') +@TYPES.register("GarageDoorOpener") class GarageDoorOpener(HomeAccessory): """Generate a Garage Door Opener accessory for a cover entity. @@ -31,26 +46,30 @@ class GarageDoorOpener(HomeAccessory): def __init__(self, *args): """Initialize a GarageDoorOpener accessory object.""" super().__init__(*args, category=CATEGORY_GARAGE_DOOR_OPENER) - self.flag_target_state = False + self._flag_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) + 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) + 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 + _LOGGER.debug("%s: Set state to %d", self.entity_id, value) + self._flag_state = True params = {ATTR_ENTITY_ID: self.entity_id} if value == 0: - self.char_current_state.set_value(3) - self.hass.services.call(DOMAIN, SERVICE_OPEN_COVER, params) + if self.char_current_state.value != value: + self.char_current_state.set_value(3) + self.call_service(DOMAIN, SERVICE_OPEN_COVER, params) elif value == 1: - self.char_current_state.set_value(2) - self.hass.services.call(DOMAIN, SERVICE_CLOSE_COVER, params) + if self.char_current_state.value != value: + self.char_current_state.set_value(2) + self.call_service(DOMAIN, SERVICE_CLOSE_COVER, params) def update_state(self, new_state): """Update cover state after state changed.""" @@ -58,12 +77,12 @@ class GarageDoorOpener(HomeAccessory): 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: + if not self._flag_state: self.char_target_state.set_value(current_state) - self.flag_target_state = False + self._flag_state = False -@TYPES.register('WindowCovering') +@TYPES.register("WindowCovering") class WindowCovering(HomeAccessory): """Generate a Window accessory for a cover entity. @@ -73,35 +92,39 @@ class WindowCovering(HomeAccessory): def __init__(self, *args): """Initialize a WindowCovering accessory object.""" super().__init__(*args, category=CATEGORY_WINDOW_COVERING) - self.homekit_target = None + 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) + CHAR_CURRENT_POSITION, value=0 + ) self.char_target_position = serv_cover.configure_char( - CHAR_TARGET_POSITION, value=0, setter_callback=self.move_cover) + 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 + _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) + self.call_service(DOMAIN, SERVICE_SET_COVER_POSITION, params, value) 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: + 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 + self._homekit_target = None -@TYPES.register('WindowCoveringBasic') +@TYPES.register("WindowCoveringBasic") class WindowCoveringBasic(HomeAccessory): """Generate a Window accessory for a cover entity. @@ -112,24 +135,28 @@ class WindowCoveringBasic(HomeAccessory): 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 + 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) + CHAR_CURRENT_POSITION, value=0 + ) self.char_target_position = serv_cover.configure_char( - CHAR_TARGET_POSITION, value=0, setter_callback=self.move_cover) + CHAR_TARGET_POSITION, value=0, setter_callback=self.move_cover + ) self.char_position_state = serv_cover.configure_char( - CHAR_POSITION_STATE, value=2) + 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) + _LOGGER.debug("%s: Set position to %d", self.entity_id, value) - if self.supports_stop: + if self._supports_stop: if value > 70: service, position = (SERVICE_OPEN_COVER, 100) elif value < 30: @@ -143,7 +170,7 @@ class WindowCoveringBasic(HomeAccessory): service, position = (SERVICE_CLOSE_COVER, 0) params = {ATTR_ENTITY_ID: self.entity_id} - self.hass.services.call(DOMAIN, service, params) + self.call_service(DOMAIN, service, params) # Snap the current/target position to the expected final position. self.char_current_position.set_value(position) diff --git a/homeassistant/components/homekit/type_fans.py b/homeassistant/components/homekit/type_fans.py index aa44b11fe..e6d128d1e 100644 --- a/homeassistant/components/homekit/type_fans.py +++ b/homeassistant/components/homekit/type_fans.py @@ -4,22 +4,44 @@ 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) + ATTR_DIRECTION, + ATTR_OSCILLATING, + ATTR_SPEED, + ATTR_SPEED_LIST, + DIRECTION_FORWARD, + DIRECTION_REVERSE, + DOMAIN, + SERVICE_OSCILLATE, + SERVICE_SET_DIRECTION, + SERVICE_SET_SPEED, + SUPPORT_DIRECTION, + SUPPORT_OSCILLATE, + SUPPORT_SET_SPEED, +) from homeassistant.const import ( - ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, SERVICE_TURN_OFF, - SERVICE_TURN_ON, STATE_OFF, STATE_ON) + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, +) from . import TYPES -from .accessories import HomeAccessory +from .accessories import HomeAccessory, debounce from .const import ( - CHAR_ACTIVE, CHAR_ROTATION_DIRECTION, CHAR_SWING_MODE, SERV_FANV2) + CHAR_ACTIVE, + CHAR_ROTATION_DIRECTION, + CHAR_ROTATION_SPEED, + CHAR_SWING_MODE, + SERV_FANV2, +) +from .util import HomeKitSpeedMapping _LOGGER = logging.getLogger(__name__) -@TYPES.register('Fan') +@TYPES.register("Fan") class Fan(HomeAccessory): """Generate a Fan accessory for a fan entity. @@ -29,56 +51,86 @@ class Fan(HomeAccessory): 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._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) + chars = [] + features = self.hass.states.get(self.entity_id).attributes.get( + ATTR_SUPPORTED_FEATURES + ) if features & SUPPORT_DIRECTION: - self.chars.append(CHAR_ROTATION_DIRECTION) + chars.append(CHAR_ROTATION_DIRECTION) if features & SUPPORT_OSCILLATE: - self.chars.append(CHAR_SWING_MODE) + chars.append(CHAR_SWING_MODE) + if features & SUPPORT_SET_SPEED: + speed_list = self.hass.states.get(self.entity_id).attributes.get( + ATTR_SPEED_LIST + ) + self.speed_mapping = HomeKitSpeedMapping(speed_list) + chars.append(CHAR_ROTATION_SPEED) - serv_fan = self.add_preload_service(SERV_FANV2, self.chars) + serv_fan = self.add_preload_service(SERV_FANV2, chars) self.char_active = serv_fan.configure_char( - CHAR_ACTIVE, value=0, setter_callback=self.set_state) + CHAR_ACTIVE, value=0, setter_callback=self.set_state + ) - if CHAR_ROTATION_DIRECTION in self.chars: + self.char_direction = None + self.char_speed = None + self.char_swing = None + + if CHAR_ROTATION_DIRECTION in chars: self.char_direction = serv_fan.configure_char( - CHAR_ROTATION_DIRECTION, value=0, - setter_callback=self.set_direction) + CHAR_ROTATION_DIRECTION, value=0, setter_callback=self.set_direction + ) - if CHAR_SWING_MODE in self.chars: + if CHAR_ROTATION_SPEED in chars: + # Initial value is set to 100 because 0 is a special value (off). 100 is + # an arbitrary non-zero value. It is updated immediately by update_state + # to set to the correct initial value. + self.char_speed = serv_fan.configure_char( + CHAR_ROTATION_SPEED, value=100, setter_callback=self.set_speed + ) + + if CHAR_SWING_MODE in chars: self.char_swing = serv_fan.configure_char( - CHAR_SWING_MODE, value=0, setter_callback=self.set_oscillating) + CHAR_SWING_MODE, value=0, setter_callback=self.set_oscillating + ) def set_state(self, value): """Set state if call came from HomeKit.""" - _LOGGER.debug('%s: Set state to %d', self.entity_id, value) + _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) + self.call_service(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) + _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) + self.call_service(DOMAIN, SERVICE_SET_DIRECTION, params, direction) def set_oscillating(self, value): """Set state if call came from HomeKit.""" - _LOGGER.debug('%s: Set oscillating to %d', self.entity_id, value) + _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) + oscillating = value == 1 + params = {ATTR_ENTITY_ID: self.entity_id, ATTR_OSCILLATING: oscillating} + self.call_service(DOMAIN, SERVICE_OSCILLATE, params, oscillating) + + @debounce + def set_speed(self, value): + """Set state if call came from HomeKit.""" + _LOGGER.debug("%s: Set speed to %d", self.entity_id, value) + speed = self.speed_mapping.speed_to_states(value) + params = {ATTR_ENTITY_ID: self.entity_id, ATTR_SPEED: speed} + self.call_service(DOMAIN, SERVICE_SET_SPEED, params, speed) def update_state(self, new_state): """Update fan after state change.""" @@ -86,26 +138,48 @@ class Fan(HomeAccessory): 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: + 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: + if self.char_direction is not None: direction = new_state.attributes.get(ATTR_DIRECTION) - if not self._flag[CHAR_ROTATION_DIRECTION] and \ - direction in (DIRECTION_FORWARD, DIRECTION_REVERSE): + 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 Speed + if self.char_speed is not None: + speed = new_state.attributes.get(ATTR_SPEED) + hk_speed_value = self.speed_mapping.speed_to_homekit(speed) + if hk_speed_value is not None and self.char_speed.value != hk_speed_value: + # If the homeassistant component reports its speed as the first entry + # in its speed list but is not off, the hk_speed_value is 0. But 0 + # is a special value in homekit. When you turn on a homekit accessory + # it will try to restore the last rotation speed state which will be + # the last value saved by char_speed.set_value. But if it is set to + # 0, HomeKit will update the rotation speed to 100 as it thinks 0 is + # off. + # + # Therefore, if the hk_speed_value is 0 and the device is still on, + # the rotation speed is mapped to 1 otherwise the update is ignored + # in order to avoid this incorrect behavior. + if hk_speed_value == 0: + if state == STATE_ON: + self.char_speed.set_value(1) + else: + self.char_speed.set_value(hk_speed_value) + # Handle Oscillating - if CHAR_SWING_MODE in self.chars: + if self.char_swing is not None: oscillating = new_state.attributes.get(ATTR_OSCILLATING) - if not self._flag[CHAR_SWING_MODE] and \ - oscillating in (True, False): + 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) diff --git a/homeassistant/components/homekit/type_lights.py b/homeassistant/components/homekit/type_lights.py index da0127996..7f195b276 100644 --- a/homeassistant/components/homekit/type_lights.py +++ b/homeassistant/components/homekit/type_lights.py @@ -4,25 +4,45 @@ import logging from pyhap.const import CATEGORY_LIGHTBULB from homeassistant.components.light import ( - ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, ATTR_COLOR_TEMP, ATTR_HS_COLOR, - ATTR_MAX_MIREDS, ATTR_MIN_MIREDS, DOMAIN, - SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_COLOR_TEMP) + ATTR_BRIGHTNESS, + ATTR_BRIGHTNESS_PCT, + ATTR_COLOR_TEMP, + ATTR_HS_COLOR, + ATTR_MAX_MIREDS, + ATTR_MIN_MIREDS, + DOMAIN, + SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, + SUPPORT_COLOR_TEMP, +) from homeassistant.const import ( - ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, SERVICE_TURN_ON, - SERVICE_TURN_OFF, STATE_OFF, STATE_ON) + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, +) from . import TYPES -from .accessories import debounce, HomeAccessory +from .accessories import HomeAccessory, debounce from .const import ( - CHAR_BRIGHTNESS, CHAR_COLOR_TEMPERATURE, CHAR_HUE, CHAR_ON, - CHAR_SATURATION, SERV_LIGHTBULB, PROP_MAX_VALUE, PROP_MIN_VALUE) + CHAR_BRIGHTNESS, + CHAR_COLOR_TEMPERATURE, + CHAR_HUE, + CHAR_ON, + CHAR_SATURATION, + PROP_MAX_VALUE, + PROP_MIN_VALUE, + SERV_LIGHTBULB, +) _LOGGER = logging.getLogger(__name__) -RGB_COLOR = 'rgb_color' +RGB_COLOR = "rgb_color" -@TYPES.register('Light') +@TYPES.register("Light") class Light(HomeAccessory): """Generate a Light accessory for a light entity. @@ -32,14 +52,20 @@ class Light(HomeAccessory): 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._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) + 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: @@ -52,81 +78,97 @@ class Light(HomeAccessory): 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) + CHAR_ON, value=self._state, setter_callback=self.set_state + ) if CHAR_BRIGHTNESS in self.chars: + # Initial value is set to 100 because 0 is a special value (off). 100 is + # an arbitrary non-zero value. It is updated immediately by update_state + # to set to the correct initial value. self.char_brightness = serv_light.configure_char( - CHAR_BRIGHTNESS, value=0, setter_callback=self.set_brightness) + CHAR_BRIGHTNESS, value=100, 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) + 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) + 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) + 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) + 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) + _LOGGER.debug("%s: Set state to %d", self.entity_id, value) self._flag[CHAR_ON] = True params = {ATTR_ENTITY_ID: self.entity_id} service = SERVICE_TURN_ON if value == 1 else SERVICE_TURN_OFF - self.hass.services.call(DOMAIN, service, params) + self.call_service(DOMAIN, service, params) @debounce def set_brightness(self, value): """Set brightness if call came from HomeKit.""" - _LOGGER.debug('%s: Set brightness to %d', self.entity_id, value) + _LOGGER.debug("%s: Set brightness to %d", self.entity_id, value) self._flag[CHAR_BRIGHTNESS] = True if value == 0: self.set_state(0) # Turn off light return params = {ATTR_ENTITY_ID: self.entity_id, ATTR_BRIGHTNESS_PCT: value} - self.hass.services.call(DOMAIN, SERVICE_TURN_ON, params) + self.call_service(DOMAIN, SERVICE_TURN_ON, params, f"brightness at {value}%") 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) + _LOGGER.debug("%s: Set color temp to %s", self.entity_id, value) self._flag[CHAR_COLOR_TEMPERATURE] = True params = {ATTR_ENTITY_ID: self.entity_id, ATTR_COLOR_TEMP: value} - self.hass.services.call(DOMAIN, SERVICE_TURN_ON, params) + self.call_service( + DOMAIN, SERVICE_TURN_ON, params, f"color temperature at {value}" + ) def set_saturation(self, value): """Set saturation if call came from HomeKit.""" - _LOGGER.debug('%s: Set saturation to %d', self.entity_id, value) + _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) + _LOGGER.debug("%s: Set hue to %d", self.entity_id, value) self._flag[CHAR_HUE] = True self._hue = value self.set_color() def set_color(self): """Set color if call came from HomeKit.""" - if self._features & SUPPORT_COLOR and self._flag[CHAR_HUE] and \ - self._flag[CHAR_SATURATION]: + 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}) + _LOGGER.debug("%s: Set hs_color to %s", self.entity_id, color) + self._flag.update( + {CHAR_HUE: False, CHAR_SATURATION: False, RGB_COLOR: True} + ) params = {ATTR_ENTITY_ID: self.entity_id, ATTR_HS_COLOR: color} - self.hass.services.call(DOMAIN, SERVICE_TURN_ON, params) + self.call_service(DOMAIN, SERVICE_TURN_ON, params, f"set color at {color}") def update_state(self, new_state): """Update light after state change.""" @@ -144,26 +186,43 @@ class Light(HomeAccessory): 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) + # The homeassistant component might report its brightness as 0 but is + # not off. But 0 is a special value in homekit. When you turn on a + # homekit accessory it will try to restore the last brightness state + # which will be the last value saved by char_brightness.set_value. + # But if it is set to 0, HomeKit will update the brightness to 100 as + # it thinks 0 is off. + # + # Therefore, if the the brighness is 0 and the device is still on, + # the brightness is mapped to 1 otherwise the update is ignored in + # order to avoid this incorrect behavior. + if brightness == 0: + if state == STATE_ON: + self.char_brightness.set_value(1) + else: + 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: + 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)): + 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) diff --git a/homeassistant/components/homekit/type_locks.py b/homeassistant/components/homekit/type_locks.py index 05ab6c6f8..3a7211ab2 100644 --- a/homeassistant/components/homekit/type_locks.py +++ b/homeassistant/components/homekit/type_locks.py @@ -3,9 +3,8 @@ import logging from pyhap.const import CATEGORY_DOOR_LOCK -from homeassistant.components.lock import ( - ATTR_ENTITY_ID, DOMAIN, STATE_LOCKED, STATE_UNLOCKED, STATE_UNKNOWN) -from homeassistant.const import ATTR_CODE +from homeassistant.components.lock import DOMAIN, STATE_LOCKED, STATE_UNLOCKED +from homeassistant.const import ATTR_CODE, ATTR_ENTITY_ID, STATE_UNKNOWN from . import TYPES from .accessories import HomeAccessory @@ -13,16 +12,19 @@ from .const import CHAR_LOCK_CURRENT_STATE, CHAR_LOCK_TARGET_STATE, SERV_LOCK _LOGGER = logging.getLogger(__name__) -HASS_TO_HOMEKIT = {STATE_UNLOCKED: 0, - STATE_LOCKED: 1, - # value 2 is Jammed which hass doesn't have a state for - STATE_UNKNOWN: 3} +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'} + +STATE_TO_SERVICE = {STATE_LOCKED: "lock", STATE_UNLOCKED: "unlock"} -@TYPES.register('Lock') +@TYPES.register("Lock") class Lock(HomeAccessory): """Generate a Lock accessory for a lock entity. @@ -33,20 +35,22 @@ class Lock(HomeAccessory): """Initialize a Lock accessory object.""" super().__init__(*args, category=CATEGORY_DOOR_LOCK) self._code = self.config.get(ATTR_CODE) - self.flag_target_state = False + self._flag_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]) + 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) + 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 + self._flag_state = True hass_value = HOMEKIT_TO_HASS.get(value) service = STATE_TO_SERVICE[hass_value] @@ -54,7 +58,7 @@ class Lock(HomeAccessory): params = {ATTR_ENTITY_ID: self.entity_id} if self._code: params[ATTR_CODE] = self._code - self.hass.services.call(DOMAIN, service, params) + self.call_service(DOMAIN, service, params) def update_state(self, new_state): """Update lock after state changed.""" @@ -62,11 +66,15 @@ class Lock(HomeAccessory): 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) + _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: + if not self._flag_state: self.char_target_state.set_value(current_lock_state) - self.flag_target_state = False + self._flag_state = False diff --git a/homeassistant/components/homekit/type_media_players.py b/homeassistant/components/homekit/type_media_players.py index ec41b9fd6..450ae818e 100644 --- a/homeassistant/components/homekit/type_media_players.py +++ b/homeassistant/components/homekit/type_media_players.py @@ -1,40 +1,115 @@ """Class to hold all media player accessories.""" import logging -from pyhap.const import CATEGORY_SWITCH +from pyhap.const import CATEGORY_SWITCH, CATEGORY_TELEVISION -from homeassistant.const import ( - ATTR_ENTITY_ID, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, - SERVICE_MEDIA_STOP, SERVICE_TURN_OFF, SERVICE_TURN_ON, SERVICE_VOLUME_MUTE, - STATE_OFF, STATE_PLAYING, STATE_UNKNOWN) from homeassistant.components.media_player import ( - ATTR_MEDIA_VOLUME_MUTED, DOMAIN) + ATTR_INPUT_SOURCE, + ATTR_INPUT_SOURCE_LIST, + ATTR_MEDIA_VOLUME_LEVEL, + ATTR_MEDIA_VOLUME_MUTED, + DOMAIN, + SERVICE_SELECT_SOURCE, + SUPPORT_PAUSE, + SUPPORT_PLAY, + SUPPORT_SELECT_SOURCE, + SUPPORT_VOLUME_MUTE, + SUPPORT_VOLUME_SET, + SUPPORT_VOLUME_STEP, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + SERVICE_MEDIA_PAUSE, + SERVICE_MEDIA_PLAY, + SERVICE_MEDIA_PLAY_PAUSE, + SERVICE_MEDIA_STOP, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + SERVICE_VOLUME_DOWN, + SERVICE_VOLUME_MUTE, + SERVICE_VOLUME_SET, + SERVICE_VOLUME_UP, + STATE_OFF, + STATE_PAUSED, + STATE_PLAYING, + STATE_UNKNOWN, +) from . import TYPES from .accessories import HomeAccessory from .const import ( - CHAR_NAME, CHAR_ON, CONF_FEATURE_LIST, FEATURE_ON_OFF, FEATURE_PLAY_PAUSE, - FEATURE_PLAY_STOP, FEATURE_TOGGLE_MUTE, SERV_SWITCH) + CHAR_ACTIVE, + CHAR_ACTIVE_IDENTIFIER, + CHAR_CONFIGURED_NAME, + CHAR_CURRENT_VISIBILITY_STATE, + CHAR_IDENTIFIER, + CHAR_INPUT_SOURCE_TYPE, + CHAR_IS_CONFIGURED, + CHAR_MUTE, + CHAR_NAME, + CHAR_ON, + CHAR_REMOTE_KEY, + CHAR_SLEEP_DISCOVER_MODE, + CHAR_VOLUME, + CHAR_VOLUME_CONTROL_TYPE, + CHAR_VOLUME_SELECTOR, + CONF_FEATURE_LIST, + FEATURE_ON_OFF, + FEATURE_PLAY_PAUSE, + FEATURE_PLAY_STOP, + FEATURE_TOGGLE_MUTE, + SERV_INPUT_SOURCE, + SERV_SWITCH, + SERV_TELEVISION, + SERV_TELEVISION_SPEAKER, +) _LOGGER = logging.getLogger(__name__) -MODE_FRIENDLY_NAME = {FEATURE_ON_OFF: 'Power', - FEATURE_PLAY_PAUSE: 'Play/Pause', - FEATURE_PLAY_STOP: 'Play/Stop', - FEATURE_TOGGLE_MUTE: 'Mute'} +MEDIA_PLAYER_KEYS = { + # 0: "Rewind", + # 1: "FastForward", + # 2: "NextTrack", + # 3: "PreviousTrack", + # 4: "ArrowUp", + # 5: "ArrowDown", + # 6: "ArrowLeft", + # 7: "ArrowRight", + # 8: "Select", + # 9: "Back", + # 10: "Exit", + 11: SERVICE_MEDIA_PLAY_PAUSE, + # 15: "Information", +} + +MODE_FRIENDLY_NAME = { + FEATURE_ON_OFF: "Power", + FEATURE_PLAY_PAUSE: "Play/Pause", + FEATURE_PLAY_STOP: "Play/Stop", + FEATURE_TOGGLE_MUTE: "Mute", +} -@TYPES.register('MediaPlayer') +@TYPES.register("MediaPlayer") class MediaPlayer(HomeAccessory): """Generate a Media Player accessory.""" def __init__(self, *args): """Initialize a Switch accessory object.""" super().__init__(*args, category=CATEGORY_SWITCH) - self._flag = {FEATURE_ON_OFF: False, FEATURE_PLAY_PAUSE: False, - FEATURE_PLAY_STOP: False, FEATURE_TOGGLE_MUTE: False} - self.chars = {FEATURE_ON_OFF: None, FEATURE_PLAY_PAUSE: None, - FEATURE_PLAY_STOP: None, FEATURE_TOGGLE_MUTE: None} + self._flag = { + FEATURE_ON_OFF: False, + FEATURE_PLAY_PAUSE: False, + FEATURE_PLAY_STOP: False, + FEATURE_TOGGLE_MUTE: False, + } + self.chars = { + FEATURE_ON_OFF: None, + FEATURE_PLAY_PAUSE: None, + FEATURE_PLAY_STOP: None, + FEATURE_TOGGLE_MUTE: None, + } feature_list = self.config[CONF_FEATURE_LIST] if FEATURE_ON_OFF in feature_list: @@ -42,101 +117,313 @@ class MediaPlayer(HomeAccessory): serv_on_off = self.add_preload_service(SERV_SWITCH, CHAR_NAME) serv_on_off.configure_char(CHAR_NAME, value=name) self.chars[FEATURE_ON_OFF] = serv_on_off.configure_char( - CHAR_ON, value=False, setter_callback=self.set_on_off) + CHAR_ON, value=False, setter_callback=self.set_on_off + ) if FEATURE_PLAY_PAUSE in feature_list: name = self.generate_service_name(FEATURE_PLAY_PAUSE) serv_play_pause = self.add_preload_service(SERV_SWITCH, CHAR_NAME) serv_play_pause.configure_char(CHAR_NAME, value=name) self.chars[FEATURE_PLAY_PAUSE] = serv_play_pause.configure_char( - CHAR_ON, value=False, setter_callback=self.set_play_pause) + CHAR_ON, value=False, setter_callback=self.set_play_pause + ) if FEATURE_PLAY_STOP in feature_list: name = self.generate_service_name(FEATURE_PLAY_STOP) serv_play_stop = self.add_preload_service(SERV_SWITCH, CHAR_NAME) serv_play_stop.configure_char(CHAR_NAME, value=name) self.chars[FEATURE_PLAY_STOP] = serv_play_stop.configure_char( - CHAR_ON, value=False, setter_callback=self.set_play_stop) + CHAR_ON, value=False, setter_callback=self.set_play_stop + ) if FEATURE_TOGGLE_MUTE in feature_list: name = self.generate_service_name(FEATURE_TOGGLE_MUTE) serv_toggle_mute = self.add_preload_service(SERV_SWITCH, CHAR_NAME) serv_toggle_mute.configure_char(CHAR_NAME, value=name) self.chars[FEATURE_TOGGLE_MUTE] = serv_toggle_mute.configure_char( - CHAR_ON, value=False, setter_callback=self.set_toggle_mute) + CHAR_ON, value=False, setter_callback=self.set_toggle_mute + ) def generate_service_name(self, mode): """Generate name for individual service.""" - return '{} {}'.format(self.display_name, MODE_FRIENDLY_NAME[mode]) + return "{} {}".format(self.display_name, MODE_FRIENDLY_NAME[mode]) def set_on_off(self, value): """Move switch state to value if call came from HomeKit.""" - _LOGGER.debug('%s: Set switch state for "on_off" to %s', - self.entity_id, value) + _LOGGER.debug('%s: Set switch state for "on_off" to %s', self.entity_id, value) self._flag[FEATURE_ON_OFF] = True service = SERVICE_TURN_ON if value else SERVICE_TURN_OFF params = {ATTR_ENTITY_ID: self.entity_id} - self.hass.services.call(DOMAIN, service, params) + self.call_service(DOMAIN, service, params) def set_play_pause(self, value): """Move switch state to value if call came from HomeKit.""" - _LOGGER.debug('%s: Set switch state for "play_pause" to %s', - self.entity_id, value) + _LOGGER.debug( + '%s: Set switch state for "play_pause" to %s', self.entity_id, value + ) self._flag[FEATURE_PLAY_PAUSE] = True service = SERVICE_MEDIA_PLAY if value else SERVICE_MEDIA_PAUSE params = {ATTR_ENTITY_ID: self.entity_id} - self.hass.services.call(DOMAIN, service, params) + self.call_service(DOMAIN, service, params) def set_play_stop(self, value): """Move switch state to value if call came from HomeKit.""" - _LOGGER.debug('%s: Set switch state for "play_stop" to %s', - self.entity_id, value) + _LOGGER.debug( + '%s: Set switch state for "play_stop" to %s', self.entity_id, value + ) self._flag[FEATURE_PLAY_STOP] = True service = SERVICE_MEDIA_PLAY if value else SERVICE_MEDIA_STOP params = {ATTR_ENTITY_ID: self.entity_id} - self.hass.services.call(DOMAIN, service, params) + self.call_service(DOMAIN, service, params) def set_toggle_mute(self, value): """Move switch state to value if call came from HomeKit.""" - _LOGGER.debug('%s: Set switch state for "toggle_mute" to %s', - self.entity_id, value) + _LOGGER.debug( + '%s: Set switch state for "toggle_mute" to %s', self.entity_id, value + ) self._flag[FEATURE_TOGGLE_MUTE] = True - params = {ATTR_ENTITY_ID: self.entity_id, - ATTR_MEDIA_VOLUME_MUTED: value} - self.hass.services.call(DOMAIN, SERVICE_VOLUME_MUTE, params) + params = {ATTR_ENTITY_ID: self.entity_id, ATTR_MEDIA_VOLUME_MUTED: value} + self.call_service(DOMAIN, SERVICE_VOLUME_MUTE, params) def update_state(self, new_state): """Update switch state after state changed.""" current_state = new_state.state if self.chars[FEATURE_ON_OFF]: - hk_state = current_state not in (STATE_OFF, STATE_UNKNOWN, 'None') + hk_state = current_state not in (STATE_OFF, STATE_UNKNOWN, "None") if not self._flag[FEATURE_ON_OFF]: - _LOGGER.debug('%s: Set current state for "on_off" to %s', - self.entity_id, hk_state) + _LOGGER.debug( + '%s: Set current state for "on_off" to %s', self.entity_id, hk_state + ) self.chars[FEATURE_ON_OFF].set_value(hk_state) self._flag[FEATURE_ON_OFF] = False if self.chars[FEATURE_PLAY_PAUSE]: hk_state = current_state == STATE_PLAYING if not self._flag[FEATURE_PLAY_PAUSE]: - _LOGGER.debug('%s: Set current state for "play_pause" to %s', - self.entity_id, hk_state) + _LOGGER.debug( + '%s: Set current state for "play_pause" to %s', + self.entity_id, + hk_state, + ) self.chars[FEATURE_PLAY_PAUSE].set_value(hk_state) self._flag[FEATURE_PLAY_PAUSE] = False if self.chars[FEATURE_PLAY_STOP]: hk_state = current_state == STATE_PLAYING if not self._flag[FEATURE_PLAY_STOP]: - _LOGGER.debug('%s: Set current state for "play_stop" to %s', - self.entity_id, hk_state) + _LOGGER.debug( + '%s: Set current state for "play_stop" to %s', + self.entity_id, + hk_state, + ) self.chars[FEATURE_PLAY_STOP].set_value(hk_state) self._flag[FEATURE_PLAY_STOP] = False if self.chars[FEATURE_TOGGLE_MUTE]: current_state = new_state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) if not self._flag[FEATURE_TOGGLE_MUTE]: - _LOGGER.debug('%s: Set current state for "toggle_mute" to %s', - self.entity_id, current_state) + _LOGGER.debug( + '%s: Set current state for "toggle_mute" to %s', + self.entity_id, + current_state, + ) self.chars[FEATURE_TOGGLE_MUTE].set_value(current_state) self._flag[FEATURE_TOGGLE_MUTE] = False + + +@TYPES.register("TelevisionMediaPlayer") +class TelevisionMediaPlayer(HomeAccessory): + """Generate a Television Media Player accessory.""" + + def __init__(self, *args): + """Initialize a Switch accessory object.""" + super().__init__(*args, category=CATEGORY_TELEVISION) + + self._flag = { + CHAR_ACTIVE: False, + CHAR_ACTIVE_IDENTIFIER: False, + CHAR_MUTE: False, + } + self.support_select_source = False + + self.sources = [] + + # Add additional characteristics if volume or input selection supported + self.chars_tv = [] + self.chars_speaker = [] + features = self.hass.states.get(self.entity_id).attributes.get( + ATTR_SUPPORTED_FEATURES, 0 + ) + + if features & (SUPPORT_PLAY | SUPPORT_PAUSE): + self.chars_tv.append(CHAR_REMOTE_KEY) + if features & SUPPORT_VOLUME_MUTE or features & SUPPORT_VOLUME_STEP: + self.chars_speaker.extend( + (CHAR_NAME, CHAR_ACTIVE, CHAR_VOLUME_CONTROL_TYPE, CHAR_VOLUME_SELECTOR) + ) + if features & SUPPORT_VOLUME_SET: + self.chars_speaker.append(CHAR_VOLUME) + + if features & SUPPORT_SELECT_SOURCE: + self.support_select_source = True + + serv_tv = self.add_preload_service(SERV_TELEVISION, self.chars_tv) + self.set_primary_service(serv_tv) + serv_tv.configure_char(CHAR_CONFIGURED_NAME, value=self.display_name) + serv_tv.configure_char(CHAR_SLEEP_DISCOVER_MODE, value=True) + self.char_active = serv_tv.configure_char( + CHAR_ACTIVE, setter_callback=self.set_on_off + ) + + if CHAR_REMOTE_KEY in self.chars_tv: + self.char_remote_key = serv_tv.configure_char( + CHAR_REMOTE_KEY, setter_callback=self.set_remote_key + ) + + if CHAR_VOLUME_SELECTOR in self.chars_speaker: + serv_speaker = self.add_preload_service( + SERV_TELEVISION_SPEAKER, self.chars_speaker + ) + serv_tv.add_linked_service(serv_speaker) + + name = "{} {}".format(self.display_name, "Volume") + serv_speaker.configure_char(CHAR_NAME, value=name) + serv_speaker.configure_char(CHAR_ACTIVE, value=1) + + self.char_mute = serv_speaker.configure_char( + CHAR_MUTE, value=False, setter_callback=self.set_mute + ) + + volume_control_type = 1 if CHAR_VOLUME in self.chars_speaker else 2 + serv_speaker.configure_char( + CHAR_VOLUME_CONTROL_TYPE, value=volume_control_type + ) + + self.char_volume_selector = serv_speaker.configure_char( + CHAR_VOLUME_SELECTOR, setter_callback=self.set_volume_step + ) + + if CHAR_VOLUME in self.chars_speaker: + self.char_volume = serv_speaker.configure_char( + CHAR_VOLUME, setter_callback=self.set_volume + ) + + if self.support_select_source: + self.sources = self.hass.states.get(self.entity_id).attributes.get( + ATTR_INPUT_SOURCE_LIST, [] + ) + self.char_input_source = serv_tv.configure_char( + CHAR_ACTIVE_IDENTIFIER, setter_callback=self.set_input_source + ) + for index, source in enumerate(self.sources): + serv_input = self.add_preload_service( + SERV_INPUT_SOURCE, [CHAR_IDENTIFIER, CHAR_NAME] + ) + serv_tv.add_linked_service(serv_input) + serv_input.configure_char(CHAR_CONFIGURED_NAME, value=source) + serv_input.configure_char(CHAR_NAME, value=source) + serv_input.configure_char(CHAR_IDENTIFIER, value=index) + serv_input.configure_char(CHAR_IS_CONFIGURED, value=True) + input_type = 3 if "hdmi" in source.lower() else 0 + serv_input.configure_char(CHAR_INPUT_SOURCE_TYPE, value=input_type) + serv_input.configure_char(CHAR_CURRENT_VISIBILITY_STATE, value=False) + _LOGGER.debug("%s: Added source %s.", self.entity_id, source) + + def set_on_off(self, value): + """Move switch state to value if call came from HomeKit.""" + _LOGGER.debug('%s: Set switch state for "on_off" to %s', self.entity_id, value) + self._flag[CHAR_ACTIVE] = True + service = SERVICE_TURN_ON if value else SERVICE_TURN_OFF + params = {ATTR_ENTITY_ID: self.entity_id} + self.call_service(DOMAIN, service, params) + + def set_mute(self, value): + """Move switch state to value if call came from HomeKit.""" + _LOGGER.debug( + '%s: Set switch state for "toggle_mute" to %s', self.entity_id, value + ) + self._flag[CHAR_MUTE] = True + params = {ATTR_ENTITY_ID: self.entity_id, ATTR_MEDIA_VOLUME_MUTED: value} + self.call_service(DOMAIN, SERVICE_VOLUME_MUTE, params) + + def set_volume(self, value): + """Send volume step value if call came from HomeKit.""" + _LOGGER.debug("%s: Set volume to %s", self.entity_id, value) + params = {ATTR_ENTITY_ID: self.entity_id, ATTR_MEDIA_VOLUME_LEVEL: value} + self.call_service(DOMAIN, SERVICE_VOLUME_SET, params) + + def set_volume_step(self, value): + """Send volume step value if call came from HomeKit.""" + _LOGGER.debug("%s: Step volume by %s", self.entity_id, value) + service = SERVICE_VOLUME_DOWN if value else SERVICE_VOLUME_UP + params = {ATTR_ENTITY_ID: self.entity_id} + self.call_service(DOMAIN, service, params) + + def set_input_source(self, value): + """Send input set value if call came from HomeKit.""" + _LOGGER.debug("%s: Set current input to %s", self.entity_id, value) + source = self.sources[value] + self._flag[CHAR_ACTIVE_IDENTIFIER] = True + params = {ATTR_ENTITY_ID: self.entity_id, ATTR_INPUT_SOURCE: source} + self.call_service(DOMAIN, SERVICE_SELECT_SOURCE, params) + + def set_remote_key(self, value): + """Send remote key value if call came from HomeKit.""" + _LOGGER.debug("%s: Set remote key to %s", self.entity_id, value) + service = MEDIA_PLAYER_KEYS.get(value) + if service: + # Handle Play Pause + if service == SERVICE_MEDIA_PLAY_PAUSE: + state = self.hass.states.get(self.entity_id).state + if state in (STATE_PLAYING, STATE_PAUSED): + service = ( + SERVICE_MEDIA_PLAY + if state == STATE_PAUSED + else SERVICE_MEDIA_PAUSE + ) + params = {ATTR_ENTITY_ID: self.entity_id} + self.call_service(DOMAIN, service, params) + + def update_state(self, new_state): + """Update Television state after state changed.""" + current_state = new_state.state + + # Power state television + hk_state = current_state not in (STATE_OFF, STATE_UNKNOWN) + if not self._flag[CHAR_ACTIVE]: + _LOGGER.debug( + "%s: Set current active state to %s", self.entity_id, hk_state + ) + self.char_active.set_value(hk_state) + self._flag[CHAR_ACTIVE] = False + + # Set mute state + if CHAR_VOLUME_SELECTOR in self.chars_speaker: + current_mute_state = new_state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) + if not self._flag[CHAR_MUTE]: + _LOGGER.debug( + "%s: Set current mute state to %s", + self.entity_id, + current_mute_state, + ) + self.char_mute.set_value(current_mute_state) + self._flag[CHAR_MUTE] = False + + # Set active input + if self.support_select_source: + source_name = new_state.attributes.get(ATTR_INPUT_SOURCE) + if self.sources and not self._flag[CHAR_ACTIVE_IDENTIFIER]: + _LOGGER.debug( + "%s: Set current input to %s", self.entity_id, source_name + ) + if source_name in self.sources: + index = self.sources.index(source_name) + self.char_input_source.set_value(index) + else: + _LOGGER.warning( + "%s: Sources out of sync. " "Restart HomeAssistant", + self.entity_id, + ) + self.char_input_source.set_value(0) + self._flag[CHAR_ACTIVE_IDENTIFIER] = False diff --git a/homeassistant/components/homekit/type_security_systems.py b/homeassistant/components/homekit/type_security_systems.py index a7d36720c..345709eb7 100644 --- a/homeassistant/components/homekit/type_security_systems.py +++ b/homeassistant/components/homekit/type_security_systems.py @@ -5,33 +5,48 @@ from pyhap.const import CATEGORY_ALARM_SYSTEM from homeassistant.components.alarm_control_panel import DOMAIN from homeassistant.const import ( - ATTR_ENTITY_ID, ATTR_CODE, SERVICE_ALARM_ARM_AWAY, SERVICE_ALARM_ARM_HOME, - SERVICE_ALARM_ARM_NIGHT, SERVICE_ALARM_DISARM, STATE_ALARM_ARMED_AWAY, - STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, STATE_ALARM_TRIGGERED, - STATE_ALARM_DISARMED) + ATTR_CODE, + ATTR_ENTITY_ID, + SERVICE_ALARM_ARM_AWAY, + SERVICE_ALARM_ARM_HOME, + SERVICE_ALARM_ARM_NIGHT, + SERVICE_ALARM_DISARM, + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_DISARMED, + STATE_ALARM_TRIGGERED, +) from . import TYPES from .accessories import HomeAccessory from .const import ( - CHAR_CURRENT_SECURITY_STATE, CHAR_TARGET_SECURITY_STATE, - SERV_SECURITY_SYSTEM) + CHAR_CURRENT_SECURITY_STATE, + CHAR_TARGET_SECURITY_STATE, + SERV_SECURITY_SYSTEM, +) _LOGGER = logging.getLogger(__name__) -HASS_TO_HOMEKIT = {STATE_ALARM_ARMED_HOME: 0, - STATE_ALARM_ARMED_AWAY: 1, - STATE_ALARM_ARMED_NIGHT: 2, - STATE_ALARM_DISARMED: 3, - STATE_ALARM_TRIGGERED: 4} +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_AWAY: SERVICE_ALARM_ARM_AWAY, STATE_ALARM_ARMED_HOME: SERVICE_ALARM_ARM_HOME, STATE_ALARM_ARMED_NIGHT: SERVICE_ALARM_ARM_NIGHT, - STATE_ALARM_DISARMED: SERVICE_ALARM_DISARM} + STATE_ALARM_DISARMED: SERVICE_ALARM_DISARM, +} -@TYPES.register('SecuritySystem') +@TYPES.register("SecuritySystem") class SecuritySystem(HomeAccessory): """Generate an SecuritySystem accessory for an alarm control panel.""" @@ -39,27 +54,27 @@ class SecuritySystem(HomeAccessory): """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 + self._flag_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) + 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) + 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 + _LOGGER.debug("%s: Set security state to %d", self.entity_id, value) + self._flag_state = True hass_value = HOMEKIT_TO_HASS[value] service = STATE_TO_SERVICE[hass_value] params = {ATTR_ENTITY_ID: self.entity_id} if self._alarm_code: params[ATTR_CODE] = self._alarm_code - self.hass.services.call(DOMAIN, service, params) + self.call_service(DOMAIN, service, params) def update_state(self, new_state): """Update security state after state changed.""" @@ -67,11 +82,14 @@ class SecuritySystem(HomeAccessory): 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) + _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: + if not self._flag_state and hass_state != STATE_ALARM_TRIGGERED: self.char_target_state.set_value(current_security_state) - self.flag_target_state = False + self._flag_state = False diff --git a/homeassistant/components/homekit/type_sensors.py b/homeassistant/components/homekit/type_sensors.py index d4c2cb582..a1450518e 100644 --- a/homeassistant/components/homekit/type_sensors.py +++ b/homeassistant/components/homekit/type_sensors.py @@ -4,47 +4,76 @@ import logging from pyhap.const import CATEGORY_SENSOR from homeassistant.const import ( - ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, STATE_ON, STATE_HOME, - TEMP_CELSIUS) + ATTR_DEVICE_CLASS, + ATTR_UNIT_OF_MEASUREMENT, + STATE_HOME, + STATE_ON, + TEMP_CELSIUS, +) from . import TYPES from .accessories import HomeAccessory from .const import ( - CHAR_AIR_PARTICULATE_DENSITY, CHAR_AIR_QUALITY, - CHAR_CARBON_DIOXIDE_DETECTED, CHAR_CARBON_DIOXIDE_LEVEL, - CHAR_CARBON_DIOXIDE_PEAK_LEVEL, CHAR_CARBON_MONOXIDE_DETECTED, - CHAR_CONTACT_SENSOR_STATE, CHAR_CURRENT_AMBIENT_LIGHT_LEVEL, - CHAR_CURRENT_HUMIDITY, CHAR_CURRENT_TEMPERATURE, CHAR_LEAK_DETECTED, - CHAR_MOTION_DETECTED, CHAR_OCCUPANCY_DETECTED, CHAR_SMOKE_DETECTED, - DEVICE_CLASS_CO2, DEVICE_CLASS_DOOR, DEVICE_CLASS_GARAGE_DOOR, - DEVICE_CLASS_GAS, DEVICE_CLASS_MOISTURE, DEVICE_CLASS_MOTION, - DEVICE_CLASS_OCCUPANCY, DEVICE_CLASS_OPENING, DEVICE_CLASS_SMOKE, - DEVICE_CLASS_WINDOW, PROP_CELSIUS, SERV_AIR_QUALITY_SENSOR, - SERV_CARBON_DIOXIDE_SENSOR, SERV_CARBON_MONOXIDE_SENSOR, - SERV_CONTACT_SENSOR, SERV_HUMIDITY_SENSOR, SERV_LEAK_SENSOR, - SERV_LIGHT_SENSOR, SERV_MOTION_SENSOR, SERV_OCCUPANCY_SENSOR, - SERV_SMOKE_SENSOR, SERV_TEMPERATURE_SENSOR) -from .util import ( - convert_to_float, temperature_to_homekit, density_to_air_quality) + CHAR_AIR_PARTICULATE_DENSITY, + CHAR_AIR_QUALITY, + CHAR_CARBON_DIOXIDE_DETECTED, + CHAR_CARBON_DIOXIDE_LEVEL, + CHAR_CARBON_DIOXIDE_PEAK_LEVEL, + CHAR_CARBON_MONOXIDE_DETECTED, + CHAR_CARBON_MONOXIDE_LEVEL, + CHAR_CARBON_MONOXIDE_PEAK_LEVEL, + CHAR_CONTACT_SENSOR_STATE, + CHAR_CURRENT_AMBIENT_LIGHT_LEVEL, + CHAR_CURRENT_HUMIDITY, + CHAR_CURRENT_TEMPERATURE, + CHAR_LEAK_DETECTED, + CHAR_MOTION_DETECTED, + CHAR_OCCUPANCY_DETECTED, + CHAR_SMOKE_DETECTED, + DEVICE_CLASS_CO2, + DEVICE_CLASS_DOOR, + DEVICE_CLASS_GARAGE_DOOR, + DEVICE_CLASS_GAS, + DEVICE_CLASS_MOISTURE, + DEVICE_CLASS_MOTION, + DEVICE_CLASS_OCCUPANCY, + DEVICE_CLASS_OPENING, + DEVICE_CLASS_SMOKE, + DEVICE_CLASS_WINDOW, + PROP_CELSIUS, + SERV_AIR_QUALITY_SENSOR, + SERV_CARBON_DIOXIDE_SENSOR, + SERV_CARBON_MONOXIDE_SENSOR, + SERV_CONTACT_SENSOR, + SERV_HUMIDITY_SENSOR, + SERV_LEAK_SENSOR, + SERV_LIGHT_SENSOR, + SERV_MOTION_SENSOR, + SERV_OCCUPANCY_SENSOR, + SERV_SMOKE_SENSOR, + SERV_TEMPERATURE_SENSOR, + THRESHOLD_CO, + THRESHOLD_CO2, +) +from .util import convert_to_float, density_to_air_quality, temperature_to_homekit _LOGGER = logging.getLogger(__name__) BINARY_SENSOR_SERVICE_MAP = { - DEVICE_CLASS_CO2: (SERV_CARBON_DIOXIDE_SENSOR, - CHAR_CARBON_DIOXIDE_DETECTED), + 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_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)} + DEVICE_CLASS_WINDOW: (SERV_CONTACT_SENSOR, CHAR_CONTACT_SENSOR_STATE), +} -@TYPES.register('TemperatureSensor') +@TYPES.register("TemperatureSensor") class TemperatureSensor(HomeAccessory): """Generate a TemperatureSensor accessory for a temperature sensor. @@ -56,8 +85,8 @@ class TemperatureSensor(HomeAccessory): 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 + CHAR_CURRENT_TEMPERATURE, value=0, properties=PROP_CELSIUS + ) def update_state(self, new_state): """Update temperature after state changed.""" @@ -66,11 +95,12 @@ class TemperatureSensor(HomeAccessory): 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) + _LOGGER.debug( + "%s: Current temperature set to %.1f°C", self.entity_id, temperature + ) -@TYPES.register('HumiditySensor') +@TYPES.register("HumiditySensor") class HumiditySensor(HomeAccessory): """Generate a HumiditySensor accessory as humidity sensor.""" @@ -79,18 +109,18 @@ class HumiditySensor(HomeAccessory): 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) + 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) + _LOGGER.debug("%s: Percent set to %d%%", self.entity_id, humidity) -@TYPES.register('AirQualitySensor') +@TYPES.register("AirQualitySensor") class AirQualitySensor(HomeAccessory): """Generate a AirQualitySensor accessory as air quality sensor.""" @@ -99,11 +129,12 @@ class AirQualitySensor(HomeAccessory): 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) + 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) + CHAR_AIR_PARTICULATE_DENSITY, value=0 + ) def update_state(self, new_state): """Update accessory after state change.""" @@ -111,10 +142,41 @@ class AirQualitySensor(HomeAccessory): if density: self.char_density.set_value(density) self.char_quality.set_value(density_to_air_quality(density)) - _LOGGER.debug('%s: Set to %d', self.entity_id, density) + _LOGGER.debug("%s: Set to %d", self.entity_id, density) -@TYPES.register('CarbonDioxideSensor') +@TYPES.register("CarbonMonoxideSensor") +class CarbonMonoxideSensor(HomeAccessory): + """Generate a CarbonMonoxidSensor accessory as CO sensor.""" + + def __init__(self, *args): + """Initialize a CarbonMonoxideSensor accessory object.""" + super().__init__(*args, category=CATEGORY_SENSOR) + + serv_co = self.add_preload_service( + SERV_CARBON_MONOXIDE_SENSOR, + [CHAR_CARBON_MONOXIDE_LEVEL, CHAR_CARBON_MONOXIDE_PEAK_LEVEL], + ) + self.char_level = serv_co.configure_char(CHAR_CARBON_MONOXIDE_LEVEL, value=0) + self.char_peak = serv_co.configure_char( + CHAR_CARBON_MONOXIDE_PEAK_LEVEL, value=0 + ) + self.char_detected = serv_co.configure_char( + CHAR_CARBON_MONOXIDE_DETECTED, value=0 + ) + + def update_state(self, new_state): + """Update accessory after state change.""" + value = convert_to_float(new_state.state) + if value: + self.char_level.set_value(value) + if value > self.char_peak.value: + self.char_peak.set_value(value) + self.char_detected.set_value(value > THRESHOLD_CO) + _LOGGER.debug("%s: Set to %d", self.entity_id, value) + + +@TYPES.register("CarbonDioxideSensor") class CarbonDioxideSensor(HomeAccessory): """Generate a CarbonDioxideSensor accessory as CO2 sensor.""" @@ -122,27 +184,30 @@ class CarbonDioxideSensor(HomeAccessory): """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) + serv_co2 = self.add_preload_service( + SERV_CARBON_DIOXIDE_SENSOR, + [CHAR_CARBON_DIOXIDE_LEVEL, CHAR_CARBON_DIOXIDE_PEAK_LEVEL], + ) + self.char_level = serv_co2.configure_char(CHAR_CARBON_DIOXIDE_LEVEL, value=0) self.char_peak = serv_co2.configure_char( - CHAR_CARBON_DIOXIDE_PEAK_LEVEL, value=0) + CHAR_CARBON_DIOXIDE_PEAK_LEVEL, value=0 + ) self.char_detected = serv_co2.configure_char( - CHAR_CARBON_DIOXIDE_DETECTED, value=0) + CHAR_CARBON_DIOXIDE_DETECTED, value=0 + ) def update_state(self, new_state): """Update accessory after state change.""" - co2 = convert_to_float(new_state.state) - if co2: - self.char_co2.set_value(co2) - if co2 > self.char_peak.value: - self.char_peak.set_value(co2) - self.char_detected.set_value(co2 > 1000) - _LOGGER.debug('%s: Set to %d', self.entity_id, co2) + value = convert_to_float(new_state.state) + if value: + self.char_level.set_value(value) + if value > self.char_peak.value: + self.char_peak.set_value(value) + self.char_detected.set_value(value > THRESHOLD_CO2) + _LOGGER.debug("%s: Set to %d", self.entity_id, value) -@TYPES.register('LightSensor') +@TYPES.register("LightSensor") class LightSensor(HomeAccessory): """Generate a LightSensor accessory as light sensor.""" @@ -152,28 +217,32 @@ class LightSensor(HomeAccessory): serv_light = self.add_preload_service(SERV_LIGHT_SENSOR) self.char_light = serv_light.configure_char( - CHAR_CURRENT_AMBIENT_LIGHT_LEVEL, value=0) + CHAR_CURRENT_AMBIENT_LIGHT_LEVEL, value=0 + ) def update_state(self, new_state): """Update accessory after state change.""" luminance = convert_to_float(new_state.state) if luminance: self.char_light.set_value(luminance) - _LOGGER.debug('%s: Set to %d', self.entity_id, luminance) + _LOGGER.debug("%s: Set to %d", self.entity_id, luminance) -@TYPES.register('BinarySensor') +@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 \ + 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) @@ -183,4 +252,4 @@ class BinarySensor(HomeAccessory): state = new_state.state detected = state in (STATE_ON, STATE_HOME) self.char_detected.set_value(detected) - _LOGGER.debug('%s: Set to %d', self.entity_id, 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 index a5724057e..66d3037b8 100644 --- a/homeassistant/components/homekit/type_switches.py +++ b/homeassistant/components/homekit/type_switches.py @@ -1,55 +1,88 @@ """Class to hold all switch accessories.""" import logging -from pyhap.const import CATEGORY_OUTLET, CATEGORY_SWITCH +from pyhap.const import ( + CATEGORY_FAUCET, + CATEGORY_OUTLET, + CATEGORY_SHOWER_HEAD, + CATEGORY_SPRINKLER, + CATEGORY_SWITCH, +) +from homeassistant.components.script import ATTR_CAN_CANCEL from homeassistant.components.switch import DOMAIN from homeassistant.const import ( - ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF, STATE_ON) + ATTR_ENTITY_ID, + CONF_TYPE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_ON, +) from homeassistant.core import split_entity_id +from homeassistant.helpers.event import call_later from . import TYPES from .accessories import HomeAccessory -from .const import CHAR_ON, CHAR_OUTLET_IN_USE, SERV_OUTLET, SERV_SWITCH +from .const import ( + CHAR_ACTIVE, + CHAR_IN_USE, + CHAR_ON, + CHAR_OUTLET_IN_USE, + CHAR_VALVE_TYPE, + SERV_OUTLET, + SERV_SWITCH, + SERV_VALVE, + TYPE_FAUCET, + TYPE_SHOWER, + TYPE_SPRINKLER, + TYPE_VALVE, +) _LOGGER = logging.getLogger(__name__) +VALVE_TYPE = { + TYPE_FAUCET: (CATEGORY_FAUCET, 3), + TYPE_SHOWER: (CATEGORY_SHOWER_HEAD, 2), + TYPE_SPRINKLER: (CATEGORY_SPRINKLER, 1), + TYPE_VALVE: (CATEGORY_FAUCET, 0), +} -@TYPES.register('Outlet') + +@TYPES.register("Outlet") class Outlet(HomeAccessory): """Generate an Outlet accessory.""" def __init__(self, *args): """Initialize an Outlet accessory object.""" super().__init__(*args, category=CATEGORY_OUTLET) - self.flag_target_state = False + self._flag_state = False serv_outlet = self.add_preload_service(SERV_OUTLET) self.char_on = serv_outlet.configure_char( - CHAR_ON, value=False, setter_callback=self.set_state) + CHAR_ON, value=False, setter_callback=self.set_state + ) self.char_outlet_in_use = serv_outlet.configure_char( - CHAR_OUTLET_IN_USE, value=True) + CHAR_OUTLET_IN_USE, value=True + ) def set_state(self, value): """Move switch state to value if call came from HomeKit.""" - _LOGGER.debug('%s: Set switch state to %s', - self.entity_id, value) - self.flag_target_state = True + _LOGGER.debug("%s: Set switch state to %s", self.entity_id, value) + self._flag_state = True params = {ATTR_ENTITY_ID: self.entity_id} service = SERVICE_TURN_ON if value else SERVICE_TURN_OFF - self.hass.services.call(DOMAIN, service, params) + self.call_service(DOMAIN, service, params) def update_state(self, new_state): """Update switch state after state changed.""" - current_state = (new_state.state == STATE_ON) - if not self.flag_target_state: - _LOGGER.debug('%s: Set current state to %s', - self.entity_id, current_state) + current_state = new_state.state == STATE_ON + if not self._flag_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 + self._flag_state = False -@TYPES.register('Switch') +@TYPES.register("Switch") class Switch(HomeAccessory): """Generate a Switch accessory.""" @@ -57,26 +90,93 @@ class Switch(HomeAccessory): """Initialize a Switch accessory object.""" super().__init__(*args, category=CATEGORY_SWITCH) self._domain = split_entity_id(self.entity_id)[0] - self.flag_target_state = False + self._flag_state = False + + self.activate_only = self.is_activate(self.hass.states.get(self.entity_id)) serv_switch = self.add_preload_service(SERV_SWITCH) self.char_on = serv_switch.configure_char( - CHAR_ON, value=False, setter_callback=self.set_state) + CHAR_ON, value=False, setter_callback=self.set_state + ) + + def is_activate(self, state): + """Check if entity is activate only.""" + can_cancel = state.attributes.get(ATTR_CAN_CANCEL) + if self._domain == "scene": + return True + if self._domain == "script" and not can_cancel: + return True + return False + + def reset_switch(self, *args): + """Reset switch to emulate activate click.""" + _LOGGER.debug("%s: Reset switch to off", self.entity_id) + self.char_on.set_value(0) 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 + _LOGGER.debug("%s: Set switch state to %s", self.entity_id, value) + if self.activate_only and value == 0: + _LOGGER.debug("%s: Ignoring turn_off call", self.entity_id) + return + self._flag_state = True params = {ATTR_ENTITY_ID: self.entity_id} service = SERVICE_TURN_ON if value else SERVICE_TURN_OFF - self.hass.services.call(self._domain, service, params) + self.call_service(self._domain, service, params) + + if self.activate_only: + call_later(self.hass, 1, self.reset_switch) 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.activate_only = self.is_activate(new_state) + if self.activate_only: + _LOGGER.debug( + "%s: Ignore state change, entity is activate only", self.entity_id + ) + return + + current_state = new_state.state == STATE_ON + if not self._flag_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 + self._flag_state = False + + +@TYPES.register("Valve") +class Valve(HomeAccessory): + """Generate a Valve accessory.""" + + def __init__(self, *args): + """Initialize a Valve accessory object.""" + super().__init__(*args) + self._flag_state = False + valve_type = self.config[CONF_TYPE] + self.category = VALVE_TYPE[valve_type][0] + + serv_valve = self.add_preload_service(SERV_VALVE) + self.char_active = serv_valve.configure_char( + CHAR_ACTIVE, value=False, setter_callback=self.set_state + ) + self.char_in_use = serv_valve.configure_char(CHAR_IN_USE, value=False) + self.char_valve_type = serv_valve.configure_char( + CHAR_VALVE_TYPE, value=VALVE_TYPE[valve_type][1] + ) + + def set_state(self, value): + """Move value state to value if call came from HomeKit.""" + _LOGGER.debug("%s: Set switch state to %s", self.entity_id, value) + self._flag_state = True + self.char_in_use.set_value(value) + params = {ATTR_ENTITY_ID: self.entity_id} + service = SERVICE_TURN_ON if value else SERVICE_TURN_OFF + self.call_service(DOMAIN, service, params) + + def update_state(self, new_state): + """Update switch state after state changed.""" + current_state = new_state.state == STATE_ON + if not self._flag_state: + _LOGGER.debug("%s: Set current state to %s", self.entity_id, current_state) + self.char_active.set_value(current_state) + self.char_in_use.set_value(current_state) + self._flag_state = False diff --git a/homeassistant/components/homekit/type_thermostats.py b/homeassistant/components/homekit/type_thermostats.py index 8517122f6..79a9d156f 100644 --- a/homeassistant/components/homekit/type_thermostats.py +++ b/homeassistant/components/homekit/type_thermostats.py @@ -3,40 +3,90 @@ import logging from pyhap.const import CATEGORY_THERMOSTAT -from homeassistant.components.climate import ( - ATTR_CURRENT_TEMPERATURE, ATTR_MAX_TEMP, ATTR_MIN_TEMP, - ATTR_OPERATION_LIST, ATTR_OPERATION_MODE, - ATTR_TEMPERATURE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, - DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP, - DOMAIN, SERVICE_SET_TEMPERATURE, SERVICE_SET_OPERATION_MODE, STATE_AUTO, - STATE_COOL, STATE_HEAT, SUPPORT_ON_OFF, SUPPORT_TARGET_TEMPERATURE_HIGH, - SUPPORT_TARGET_TEMPERATURE_LOW) +from homeassistant.components.climate.const import ( + ATTR_CURRENT_TEMPERATURE, + ATTR_HVAC_ACTION, + ATTR_HVAC_MODE, + ATTR_HVAC_MODES, + ATTR_MAX_TEMP, + ATTR_MIN_TEMP, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + ATTR_TARGET_TEMP_STEP, + CURRENT_HVAC_COOL, + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, + CURRENT_HVAC_OFF, + DEFAULT_MAX_TEMP, + DEFAULT_MIN_TEMP, + DOMAIN as DOMAIN_CLIMATE, + HVAC_MODE_AUTO, + HVAC_MODE_COOL, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_OFF, + SERVICE_SET_HVAC_MODE as SERVICE_SET_HVAC_MODE_THERMOSTAT, + SERVICE_SET_TEMPERATURE as SERVICE_SET_TEMPERATURE_THERMOSTAT, + SUPPORT_TARGET_TEMPERATURE_RANGE, +) +from homeassistant.components.water_heater import ( + DOMAIN as DOMAIN_WATER_HEATER, + SERVICE_SET_TEMPERATURE as SERVICE_SET_TEMPERATURE_WATER_HEATER, +) from homeassistant.const import ( - ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, SERVICE_TURN_OFF, SERVICE_TURN_ON, - STATE_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT) + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + ATTR_TEMPERATURE, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) from . import TYPES -from .accessories import debounce, HomeAccessory +from .accessories import HomeAccessory, debounce from .const import ( - CHAR_COOLING_THRESHOLD_TEMPERATURE, CHAR_CURRENT_HEATING_COOLING, - CHAR_CURRENT_TEMPERATURE, CHAR_TARGET_HEATING_COOLING, - CHAR_HEATING_THRESHOLD_TEMPERATURE, CHAR_TARGET_TEMPERATURE, - CHAR_TEMP_DISPLAY_UNITS, PROP_MAX_VALUE, PROP_MIN_VALUE, SERV_THERMOSTAT) + CHAR_COOLING_THRESHOLD_TEMPERATURE, + CHAR_CURRENT_HEATING_COOLING, + CHAR_CURRENT_TEMPERATURE, + CHAR_HEATING_THRESHOLD_TEMPERATURE, + CHAR_TARGET_HEATING_COOLING, + CHAR_TARGET_TEMPERATURE, + CHAR_TEMP_DISPLAY_UNITS, + DEFAULT_MAX_TEMP_WATER_HEATER, + DEFAULT_MIN_TEMP_WATER_HEATER, + PROP_MAX_VALUE, + PROP_MIN_STEP, + PROP_MIN_VALUE, + SERV_THERMOSTAT, +) from .util import temperature_to_homekit, temperature_to_states _LOGGER = logging.getLogger(__name__) +HC_HOMEKIT_VALID_MODES_WATER_HEATER = { + "Heat": 1, +} 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_HASS_TO_HOMEKIT = { + HVAC_MODE_OFF: 0, + HVAC_MODE_HEAT: 1, + HVAC_MODE_COOL: 2, + HVAC_MODE_AUTO: 3, + HVAC_MODE_HEAT_COOL: 3, + HVAC_MODE_FAN_ONLY: 2, +} 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 +HC_HASS_TO_HOMEKIT_ACTION = { + CURRENT_HVAC_OFF: 0, + CURRENT_HVAC_IDLE: 0, + CURRENT_HVAC_HEAT: 1, + CURRENT_HVAC_COOL: 2, +} -@TYPES.register('Thermostat') +@TYPES.register("Thermostat") class Thermostat(HomeAccessory): """Generate a Thermostat accessory for a climate.""" @@ -44,130 +94,201 @@ class Thermostat(HomeAccessory): """Initialize a Thermostat accessory object.""" super().__init__(*args, category=CATEGORY_THERMOSTAT) self._unit = self.hass.config.units.temperature_unit - self.support_power_state = False - self.heat_cool_flag_target_state = False - self.temperature_flag_target_state = False - self.coolingthresh_flag_target_state = False - self.heatingthresh_flag_target_state = False + self._flag_heat_cool = False + self._flag_temperature = False + self._flag_coolingthresh = False + self._flag_heatingthresh = False min_temp, max_temp = self.get_temperature_range() + temp_step = self.hass.states.get(self.entity_id).attributes.get( + ATTR_TARGET_TEMP_STEP, 0.5 + ) # Add additional characteristics if auto mode is supported self.chars = [] - features = self.hass.states.get(self.entity_id) \ - .attributes.get(ATTR_SUPPORTED_FEATURES, 0) - if features & SUPPORT_ON_OFF: - self.support_power_state = True - if features & SUPPORT_TEMP_RANGE: - self.chars.extend((CHAR_COOLING_THRESHOLD_TEMPERATURE, - CHAR_HEATING_THRESHOLD_TEMPERATURE)) + state = self.hass.states.get(self.entity_id) + features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + + if features & SUPPORT_TARGET_TEMPERATURE_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 + # Current mode characteristics self.char_current_heat_cool = serv_thermostat.configure_char( - CHAR_CURRENT_HEATING_COOLING, value=0) + CHAR_CURRENT_HEATING_COOLING, value=0 + ) + + # Target mode characteristics + hc_modes = state.attributes.get(ATTR_HVAC_MODES, None) + if hc_modes is None: + _LOGGER.error( + "%s: HVAC modes not yet available. Please disable auto start for homekit.", + self.entity_id, + ) + hc_modes = ( + HVAC_MODE_HEAT, + HVAC_MODE_COOL, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_OFF, + ) + + # determine available modes for this entity, prefer AUTO over HEAT_COOL and COOL over FAN_ONLY + self.hc_homekit_to_hass = { + c: s + for s, c in HC_HASS_TO_HOMEKIT.items() + if ( + s in hc_modes + and not ( + (s == HVAC_MODE_HEAT_COOL and HVAC_MODE_AUTO in hc_modes) + or (s == HVAC_MODE_FAN_ONLY and HVAC_MODE_COOL in hc_modes) + ) + ) + } + hc_valid_values = {k: v for v, k in self.hc_homekit_to_hass.items()} + self.char_target_heat_cool = serv_thermostat.configure_char( - CHAR_TARGET_HEATING_COOLING, value=0, - setter_callback=self.set_heat_cool) + CHAR_TARGET_HEATING_COOLING, + value=0, + setter_callback=self.set_heat_cool, + valid_values=hc_valid_values, + ) # Current and target temperature characteristics self.char_current_temp = serv_thermostat.configure_char( - CHAR_CURRENT_TEMPERATURE, value=21.0) + CHAR_CURRENT_TEMPERATURE, value=21.0 + ) self.char_target_temp = serv_thermostat.configure_char( - CHAR_TARGET_TEMPERATURE, value=21.0, - properties={PROP_MIN_VALUE: min_temp, - PROP_MAX_VALUE: max_temp}, - setter_callback=self.set_target_temperature) + CHAR_TARGET_TEMPERATURE, + value=21.0, + properties={ + PROP_MIN_VALUE: min_temp, + PROP_MAX_VALUE: max_temp, + PROP_MIN_STEP: temp_step, + }, + setter_callback=self.set_target_temperature, + ) # Display units characteristic self.char_display_units = serv_thermostat.configure_char( - CHAR_TEMP_DISPLAY_UNITS, value=0) + CHAR_TEMP_DISPLAY_UNITS, value=0 + ) # If the device supports it: high and low temperature characteristics self.char_cooling_thresh_temp = None self.char_heating_thresh_temp = None if CHAR_COOLING_THRESHOLD_TEMPERATURE in self.chars: self.char_cooling_thresh_temp = serv_thermostat.configure_char( - CHAR_COOLING_THRESHOLD_TEMPERATURE, value=23.0, - properties={PROP_MIN_VALUE: min_temp, - PROP_MAX_VALUE: max_temp}, - setter_callback=self.set_cooling_threshold) + CHAR_COOLING_THRESHOLD_TEMPERATURE, + value=23.0, + properties={ + PROP_MIN_VALUE: min_temp, + PROP_MAX_VALUE: max_temp, + PROP_MIN_STEP: temp_step, + }, + setter_callback=self.set_cooling_threshold, + ) if CHAR_HEATING_THRESHOLD_TEMPERATURE in self.chars: self.char_heating_thresh_temp = serv_thermostat.configure_char( - CHAR_HEATING_THRESHOLD_TEMPERATURE, value=19.0, - properties={PROP_MIN_VALUE: min_temp, - PROP_MAX_VALUE: max_temp}, - setter_callback=self.set_heating_threshold) + CHAR_HEATING_THRESHOLD_TEMPERATURE, + value=19.0, + properties={ + PROP_MIN_VALUE: min_temp, + PROP_MAX_VALUE: max_temp, + PROP_MIN_STEP: temp_step, + }, + setter_callback=self.set_heating_threshold, + ) def get_temperature_range(self): """Return min and max temperature range.""" - max_temp = self.hass.states.get(self.entity_id) \ - .attributes.get(ATTR_MAX_TEMP) - max_temp = temperature_to_homekit(max_temp, self._unit) if max_temp \ + max_temp = self.hass.states.get(self.entity_id).attributes.get(ATTR_MAX_TEMP) + max_temp = ( + temperature_to_homekit(max_temp, self._unit) + if max_temp else DEFAULT_MAX_TEMP + ) + max_temp = round(max_temp * 2) / 2 - min_temp = self.hass.states.get(self.entity_id) \ - .attributes.get(ATTR_MIN_TEMP) - min_temp = temperature_to_homekit(min_temp, self._unit) if min_temp \ + min_temp = self.hass.states.get(self.entity_id).attributes.get(ATTR_MIN_TEMP) + min_temp = ( + temperature_to_homekit(min_temp, self._unit) + if min_temp else DEFAULT_MIN_TEMP + ) + min_temp = round(min_temp * 2) / 2 return min_temp, max_temp def set_heat_cool(self, value): - """Move operation mode to value if call came from HomeKit.""" - if value in HC_HOMEKIT_TO_HASS: - _LOGGER.debug('%s: Set heat-cool to %d', self.entity_id, value) - self.heat_cool_flag_target_state = True - hass_value = HC_HOMEKIT_TO_HASS[value] - if self.support_power_state is True: - params = {ATTR_ENTITY_ID: self.entity_id} - if hass_value == STATE_OFF: - self.hass.services.call(DOMAIN, SERVICE_TURN_OFF, params) - return - self.hass.services.call(DOMAIN, SERVICE_TURN_ON, params) - params = {ATTR_ENTITY_ID: self.entity_id, - ATTR_OPERATION_MODE: hass_value} - self.hass.services.call(DOMAIN, SERVICE_SET_OPERATION_MODE, params) + """Change operation mode to value if call came from HomeKit.""" + _LOGGER.debug("%s: Set heat-cool to %d", self.entity_id, value) + self._flag_heat_cool = True + hass_value = self.hc_homekit_to_hass[value] + params = {ATTR_ENTITY_ID: self.entity_id, ATTR_HVAC_MODE: hass_value} + self.call_service( + DOMAIN_CLIMATE, SERVICE_SET_HVAC_MODE_THERMOSTAT, params, hass_value + ) @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 + _LOGGER.debug( + "%s: Set cooling threshold temperature to %.1f°C", self.entity_id, value + ) + self._flag_coolingthresh = True low = self.char_heating_thresh_temp.value + temperature = temperature_to_states(value, self._unit) params = { ATTR_ENTITY_ID: self.entity_id, - ATTR_TARGET_TEMP_HIGH: temperature_to_states(value, self._unit), - ATTR_TARGET_TEMP_LOW: temperature_to_states(low, self._unit)} - self.hass.services.call(DOMAIN, SERVICE_SET_TEMPERATURE, params) + ATTR_TARGET_TEMP_HIGH: temperature, + ATTR_TARGET_TEMP_LOW: temperature_to_states(low, self._unit), + } + self.call_service( + DOMAIN_CLIMATE, + SERVICE_SET_TEMPERATURE_THERMOSTAT, + params, + f"cooling threshold {temperature}{self._unit}", + ) @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 + _LOGGER.debug( + "%s: Set heating threshold temperature to %.1f°C", self.entity_id, value + ) + self._flag_heatingthresh = True high = self.char_cooling_thresh_temp.value + temperature = temperature_to_states(value, self._unit) params = { ATTR_ENTITY_ID: self.entity_id, ATTR_TARGET_TEMP_HIGH: temperature_to_states(high, self._unit), - ATTR_TARGET_TEMP_LOW: temperature_to_states(value, self._unit)} - self.hass.services.call(DOMAIN, SERVICE_SET_TEMPERATURE, params) + ATTR_TARGET_TEMP_LOW: temperature, + } + self.call_service( + DOMAIN_CLIMATE, + SERVICE_SET_TEMPERATURE_THERMOSTAT, + params, + f"heating threshold {temperature}{self._unit}", + ) @debounce def set_target_temperature(self, value): """Set target temperature to value if call came from HomeKit.""" - _LOGGER.debug('%s: Set target temperature to %.2f°C', - self.entity_id, value) - self.temperature_flag_target_state = True - params = { - ATTR_ENTITY_ID: self.entity_id, - ATTR_TEMPERATURE: temperature_to_states(value, self._unit)} - self.hass.services.call(DOMAIN, SERVICE_SET_TEMPERATURE, params) + _LOGGER.debug("%s: Set target temperature to %.1f°C", self.entity_id, value) + self._flag_temperature = True + temperature = temperature_to_states(value, self._unit) + params = {ATTR_ENTITY_ID: self.entity_id, ATTR_TEMPERATURE: temperature} + self.call_service( + DOMAIN_CLIMATE, + SERVICE_SET_TEMPERATURE_THERMOSTAT, + params, + f"{temperature}{self._unit}", + ) def update_state(self, new_state): - """Update security state after state changed.""" + """Update thermostat state after state changed.""" # Update current temperature current_temp = new_state.attributes.get(ATTR_CURRENT_TEMPERATURE) if isinstance(current_temp, (int, float)): @@ -178,83 +299,148 @@ class Thermostat(HomeAccessory): 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: + if not self._flag_temperature: self.char_target_temp.set_value(target_temp) - self.temperature_flag_target_state = False + self._flag_temperature = 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: + cooling_thresh = temperature_to_homekit(cooling_thresh, self._unit) + if not self._flag_coolingthresh: self.char_cooling_thresh_temp.set_value(cooling_thresh) - self.coolingthresh_flag_target_state = False + self._flag_coolingthresh = 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: + heating_thresh = temperature_to_homekit(heating_thresh, self._unit) + if not self._flag_heatingthresh: self.char_heating_thresh_temp.set_value(heating_thresh) - self.heatingthresh_flag_target_state = False + self._flag_heatingthresh = 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 + hvac_mode = new_state.state + if hvac_mode and hvac_mode in HC_HASS_TO_HOMEKIT: + if not self._flag_heat_cool: + self.char_target_heat_cool.set_value(HC_HASS_TO_HOMEKIT[hvac_mode]) + self._flag_heat_cool = 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 + # Set current operation mode for supported thermostats + hvac_action = new_state.attributes.get(ATTR_HVAC_ACTION) + if hvac_action: + self.char_current_heat_cool.set_value( + HC_HASS_TO_HOMEKIT_ACTION[hvac_action] + ) - self.char_current_heat_cool.set_value( - HC_HASS_TO_HOMEKIT[current_operation_mode]) + +@TYPES.register("WaterHeater") +class WaterHeater(HomeAccessory): + """Generate a WaterHeater accessory for a water_heater.""" + + def __init__(self, *args): + """Initialize a WaterHeater accessory object.""" + super().__init__(*args, category=CATEGORY_THERMOSTAT) + self._unit = self.hass.config.units.temperature_unit + self._flag_heat_cool = False + self._flag_temperature = False + min_temp, max_temp = self.get_temperature_range() + + serv_thermostat = self.add_preload_service(SERV_THERMOSTAT) + + self.char_current_heat_cool = serv_thermostat.configure_char( + CHAR_CURRENT_HEATING_COOLING, value=1 + ) + self.char_target_heat_cool = serv_thermostat.configure_char( + CHAR_TARGET_HEATING_COOLING, + value=1, + setter_callback=self.set_heat_cool, + valid_values=HC_HOMEKIT_VALID_MODES_WATER_HEATER, + ) + + self.char_current_temp = serv_thermostat.configure_char( + CHAR_CURRENT_TEMPERATURE, value=50.0 + ) + self.char_target_temp = serv_thermostat.configure_char( + CHAR_TARGET_TEMPERATURE, + value=50.0, + properties={ + PROP_MIN_VALUE: min_temp, + PROP_MAX_VALUE: max_temp, + PROP_MIN_STEP: 0.5, + }, + setter_callback=self.set_target_temperature, + ) + + self.char_display_units = serv_thermostat.configure_char( + CHAR_TEMP_DISPLAY_UNITS, value=0 + ) + + def get_temperature_range(self): + """Return min and max temperature range.""" + max_temp = self.hass.states.get(self.entity_id).attributes.get(ATTR_MAX_TEMP) + max_temp = ( + temperature_to_homekit(max_temp, self._unit) + if max_temp + else DEFAULT_MAX_TEMP_WATER_HEATER + ) + max_temp = round(max_temp * 2) / 2 + + min_temp = self.hass.states.get(self.entity_id).attributes.get(ATTR_MIN_TEMP) + min_temp = ( + temperature_to_homekit(min_temp, self._unit) + if min_temp + else DEFAULT_MIN_TEMP_WATER_HEATER + ) + min_temp = round(min_temp * 2) / 2 + + return min_temp, max_temp + + def set_heat_cool(self, value): + """Change operation mode to value if call came from HomeKit.""" + _LOGGER.debug("%s: Set heat-cool to %d", self.entity_id, value) + self._flag_heat_cool = True + hass_value = HC_HOMEKIT_TO_HASS[value] + if hass_value != HVAC_MODE_HEAT: + self.char_target_heat_cool.set_value(1) # Heat + + @debounce + def set_target_temperature(self, value): + """Set target temperature to value if call came from HomeKit.""" + _LOGGER.debug("%s: Set target temperature to %.1f°C", self.entity_id, value) + self._flag_temperature = True + temperature = temperature_to_states(value, self._unit) + params = {ATTR_ENTITY_ID: self.entity_id, ATTR_TEMPERATURE: temperature} + self.call_service( + DOMAIN_WATER_HEATER, + SERVICE_SET_TEMPERATURE_WATER_HEATER, + params, + f"{temperature}{self._unit}", + ) + + def update_state(self, new_state): + """Update water_heater state after state change.""" + # Update current and target temperature + temperature = new_state.attributes.get(ATTR_TEMPERATURE) + if isinstance(temperature, (int, float)): + temperature = temperature_to_homekit(temperature, self._unit) + self.char_current_temp.set_value(temperature) + if not self._flag_temperature: + self.char_target_temp.set_value(temperature) + self._flag_temperature = 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.state + if operation_mode and not self._flag_heat_cool: + self.char_target_heat_cool.set_value(1) # Heat + self._flag_heat_cool = False diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index 9d60530ed..608c9a974 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -1,74 +1,126 @@ """Collection of useful functions for the HomeKit component.""" +from collections import OrderedDict, namedtuple import logging import voluptuous as vol -from homeassistant.components import media_player -from homeassistant.core import split_entity_id +from homeassistant.components import fan, media_player, sensor from homeassistant.const import ( - ATTR_CODE, ATTR_SUPPORTED_FEATURES, CONF_NAME, CONF_TYPE, TEMP_CELSIUS) + ATTR_CODE, + ATTR_SUPPORTED_FEATURES, + CONF_NAME, + CONF_TYPE, + TEMP_CELSIUS, +) +from homeassistant.core import split_entity_id import homeassistant.helpers.config_validation as cv import homeassistant.util.temperature as temp_util + from .const import ( - CONF_FEATURE, CONF_FEATURE_LIST, HOMEKIT_NOTIFY_ID, FEATURE_ON_OFF, - FEATURE_PLAY_PAUSE, FEATURE_PLAY_STOP, FEATURE_TOGGLE_MUTE, TYPE_OUTLET, - TYPE_SWITCH) + CONF_FEATURE, + CONF_FEATURE_LIST, + CONF_LINKED_BATTERY_SENSOR, + CONF_LOW_BATTERY_THRESHOLD, + DEFAULT_LOW_BATTERY_THRESHOLD, + FEATURE_ON_OFF, + FEATURE_PLAY_PAUSE, + FEATURE_PLAY_STOP, + FEATURE_TOGGLE_MUTE, + HOMEKIT_NOTIFY_ID, + TYPE_FAUCET, + TYPE_OUTLET, + TYPE_SHOWER, + TYPE_SPRINKLER, + TYPE_SWITCH, + TYPE_VALVE, +) _LOGGER = logging.getLogger(__name__) -BASIC_INFO_SCHEMA = vol.Schema({ - vol.Optional(CONF_NAME): cv.string, -}) +BASIC_INFO_SCHEMA = vol.Schema( + { + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_LINKED_BATTERY_SENSOR): cv.entity_domain(sensor.DOMAIN), + vol.Optional( + CONF_LOW_BATTERY_THRESHOLD, default=DEFAULT_LOW_BATTERY_THRESHOLD + ): cv.positive_int, + } +) -FEATURE_SCHEMA = BASIC_INFO_SCHEMA.extend({ - vol.Optional(CONF_FEATURE_LIST, default=None): cv.ensure_list, -}) +FEATURE_SCHEMA = BASIC_INFO_SCHEMA.extend( + {vol.Optional(CONF_FEATURE_LIST, default=None): cv.ensure_list} +) +CODE_SCHEMA = BASIC_INFO_SCHEMA.extend( + {vol.Optional(ATTR_CODE, default=None): vol.Any(None, cv.string)} +) -CODE_SCHEMA = BASIC_INFO_SCHEMA.extend({ - vol.Optional(ATTR_CODE, default=None): vol.Any(None, cv.string), -}) +MEDIA_PLAYER_SCHEMA = vol.Schema( + { + vol.Required(CONF_FEATURE): vol.All( + cv.string, + vol.In( + ( + FEATURE_ON_OFF, + FEATURE_PLAY_PAUSE, + FEATURE_PLAY_STOP, + FEATURE_TOGGLE_MUTE, + ) + ), + ) + } +) -MEDIA_PLAYER_SCHEMA = vol.Schema({ - vol.Required(CONF_FEATURE): vol.All( - cv.string, vol.In((FEATURE_ON_OFF, FEATURE_PLAY_PAUSE, - FEATURE_PLAY_STOP, FEATURE_TOGGLE_MUTE))), -}) - -SWITCH_TYPE_SCHEMA = BASIC_INFO_SCHEMA.extend({ - vol.Optional(CONF_TYPE, default=TYPE_SWITCH): vol.All( - cv.string, vol.In((TYPE_OUTLET, TYPE_SWITCH))), -}) +SWITCH_TYPE_SCHEMA = BASIC_INFO_SCHEMA.extend( + { + vol.Optional(CONF_TYPE, default=TYPE_SWITCH): vol.All( + cv.string, + vol.In( + ( + TYPE_FAUCET, + TYPE_OUTLET, + TYPE_SHOWER, + TYPE_SPRINKLER, + TYPE_SWITCH, + TYPE_VALVE, + ) + ), + ) + } +) def validate_entity_config(values): """Validate config entry for CONF_ENTITY.""" + if not isinstance(values, dict): + raise vol.Invalid("expected a dictionary") + entities = {} for entity_id, config in values.items(): entity = cv.entity_id(entity_id) domain, _ = split_entity_id(entity) if not isinstance(config, dict): - raise vol.Invalid('The configuration for {} must be ' - ' a dictionary.'.format(entity)) + raise vol.Invalid( + "The configuration for {} must be " " a dictionary.".format(entity) + ) - if domain in ('alarm_control_panel', 'lock'): + if domain in ("alarm_control_panel", "lock"): config = CODE_SCHEMA(config) - elif domain == media_player.DOMAIN: + elif domain == media_player.const.DOMAIN: config = FEATURE_SCHEMA(config) feature_list = {} for feature in config[CONF_FEATURE_LIST]: params = MEDIA_PLAYER_SCHEMA(feature) key = params.pop(CONF_FEATURE) if key in feature_list: - raise vol.Invalid('A feature can be added only once for {}' - .format(entity)) + raise vol.Invalid(f"A feature can be added only once for {entity}") feature_list[key] = params config[CONF_FEATURE_LIST] = feature_list - elif domain == 'switch': + elif domain == "switch": config = SWITCH_TYPE_SCHEMA(config) else: @@ -83,14 +135,15 @@ def validate_media_player_features(state, feature_list): features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) supported_modes = [] - if features & (media_player.SUPPORT_TURN_ON | - media_player.SUPPORT_TURN_OFF): + if features & ( + media_player.const.SUPPORT_TURN_ON | media_player.const.SUPPORT_TURN_OFF + ): supported_modes.append(FEATURE_ON_OFF) - if features & (media_player.SUPPORT_PLAY | media_player.SUPPORT_PAUSE): + if features & (media_player.const.SUPPORT_PLAY | media_player.const.SUPPORT_PAUSE): supported_modes.append(FEATURE_PLAY_PAUSE) - if features & (media_player.SUPPORT_PLAY | media_player.SUPPORT_STOP): + if features & (media_player.const.SUPPORT_PLAY | media_player.const.SUPPORT_STOP): supported_modes.append(FEATURE_PLAY_STOP) - if features & media_player.SUPPORT_VOLUME_MUTE: + if features & media_player.const.SUPPORT_VOLUME_MUTE: supported_modes.append(FEATURE_TOGGLE_MUTE) error_list = [] @@ -99,20 +152,72 @@ def validate_media_player_features(state, feature_list): error_list.append(feature) if error_list: - _LOGGER.error("%s does not support features: %s", - state.entity_id, error_list) + _LOGGER.error("%s does not support features: %s", state.entity_id, error_list) return False return True +SpeedRange = namedtuple("SpeedRange", ("start", "target")) +SpeedRange.__doc__ += """ Maps Home Assistant speed \ +values to percentage based HomeKit speeds. +start: Start of the range (inclusive). +target: Percentage to use to determine HomeKit percentages \ +from HomeAssistant speed. +""" + + +class HomeKitSpeedMapping: + """Supports conversion between Home Assistant and HomeKit fan speeds.""" + + def __init__(self, speed_list): + """Initialize a new SpeedMapping object.""" + if speed_list[0] != fan.SPEED_OFF: + _LOGGER.warning( + "%s does not contain the speed setting " + "%s as its first element. " + "Assuming that %s is equivalent to 'off'.", + speed_list, + fan.SPEED_OFF, + speed_list[0], + ) + self.speed_ranges = OrderedDict() + list_size = len(speed_list) + for index, speed in enumerate(speed_list): + # By dividing by list_size -1 the following + # desired attributes hold true: + # * index = 0 => 0%, equal to "off" + # * index = len(speed_list) - 1 => 100 % + # * all other indices are equally distributed + target = index * 100 / (list_size - 1) + start = index * 100 / list_size + self.speed_ranges[speed] = SpeedRange(start, target) + + def speed_to_homekit(self, speed): + """Map Home Assistant speed state to HomeKit speed.""" + if speed is None: + return None + speed_range = self.speed_ranges[speed] + return speed_range.target + + def speed_to_states(self, speed): + """Map HomeKit speed to Home Assistant speed state.""" + for state, speed_range in reversed(self.speed_ranges.items()): + if speed_range.start <= speed: + return state + return list(self.speed_ranges.keys())[0] + + def show_setup_message(hass, pincode): """Display persistent notification with setup information.""" pin = pincode.decode() - _LOGGER.info('Pincode: %s', pin) - message = 'To set up Home Assistant in the Home App, enter the ' \ - 'following code:\n### {}'.format(pin) + _LOGGER.info("Pincode: %s", pin) + message = ( + "To set up Home Assistant in the Home App, enter the " + "following code:\n### {}".format(pin) + ) hass.components.persistent_notification.create( - message, 'HomeKit Setup', HOMEKIT_NOTIFY_ID) + message, "HomeKit Setup", HOMEKIT_NOTIFY_ID + ) def dismiss_setup_message(hass): @@ -135,7 +240,7 @@ def temperature_to_homekit(temperature, unit): 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) + return round(temp_util.convert(temperature, TEMP_CELSIUS, unit) * 2) / 2 def density_to_air_quality(density): diff --git a/homeassistant/components/homekit_controller/.translations/bg.json b/homeassistant/components/homekit_controller/.translations/bg.json new file mode 100644 index 000000000..b1909ca2e --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/bg.json @@ -0,0 +1,40 @@ +{ + "config": { + "abort": { + "accessory_not_found_error": "\u0421\u0434\u0432\u043e\u044f\u0432\u0430\u043d\u0435\u0442\u043e \u043d\u0435 \u043c\u043e\u0436\u0435 \u0434\u0430 \u0431\u044a\u0434\u0435 \u0434\u043e\u0431\u0430\u0432\u0435\u043d\u043e, \u0442\u044a\u0439 \u043a\u0430\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u043d\u0435 \u043c\u043e\u0436\u0435 \u0434\u0430 \u0431\u044a\u0434\u0435 \u043d\u0430\u043c\u0435\u0440\u0435\u043d\u043e.", + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0435 \u0432\u0435\u0447\u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e \u0441 \u0442\u043e\u0437\u0438 \u043a\u043e\u043d\u0442\u0440\u043e\u043b\u0435\u0440.", + "already_in_progress": "\u0412 \u043c\u043e\u043c\u0435\u043d\u0442\u0430 \u0442\u0435\u0447\u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e.", + "already_paired": "\u0422\u043e\u0437\u0438 \u0430\u043a\u0441\u0435\u0441\u043e\u0430\u0440 \u0432\u0435\u0447\u0435 \u0435 \u0441\u0432\u044a\u0440\u0437\u0430\u043d \u0441 \u0434\u0440\u0443\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e. \u041c\u043e\u043b\u044f, \u0432\u044a\u0437\u0441\u0442\u0430\u043d\u043e\u0432\u0435\u0442\u0435 \u0437\u0430\u0432\u043e\u0434\u0441\u043a\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u043d\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0438 \u043e\u043f\u0438\u0442\u0430\u0439\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e.", + "ignored_model": "\u041f\u043e\u0434\u0434\u0440\u044a\u0436\u043a\u0430\u0442\u0430 \u043d\u0430 HomeKit \u0437\u0430 \u0442\u043e\u0437\u0438 \u043c\u043e\u0434\u0435\u043b \u0435 \u0431\u043b\u043e\u043a\u0438\u0440\u0430\u043d\u0430, \u0442\u044a\u0439 \u043a\u0430\u0442\u043e \u0435 \u043d\u0430\u043b\u0438\u0446\u0435 \u043f\u043e-\u043f\u044a\u043b\u043d\u043e \u0438\u043d\u0442\u0435\u0433\u0440\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u0444\u0443\u043d\u043a\u0446\u0438\u044f\u0442\u0430.", + "invalid_config_entry": "\u0422\u043e\u0432\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0441\u0435 \u043f\u043e\u043a\u0430\u0437\u0432\u0430 \u043a\u0430\u0442\u043e \u0433\u043e\u0442\u043e\u0432\u043e \u0437\u0430 \u0441\u0434\u0432\u043e\u044f\u0432\u0430\u043d\u0435, \u043d\u043e \u0432\u0435\u0447\u0435 \u0438\u043c\u0430 \u0441\u044a\u0449\u0435\u0441\u0442\u0432\u0443\u0432\u0430\u0449\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f \u0437\u0430 \u043d\u0435\u0433\u043e \u0432 Home Assistant, \u043a\u043e\u044f\u0442\u043e \u043f\u044a\u0440\u0432\u043e \u0442\u0440\u044f\u0431\u0432\u0430 \u0434\u0430 \u0431\u044a\u0434\u0435 \u043f\u0440\u0435\u043c\u0430\u0445\u043d\u0430\u0442\u0430.", + "no_devices": "\u041d\u0435 \u043c\u043e\u0433\u0430\u0442 \u0434\u0430 \u0431\u044a\u0434\u0430\u0442 \u043d\u0430\u043c\u0435\u0440\u0435\u043d\u0438 \u043d\u0435\u0441\u0432\u044a\u0440\u0437\u0430\u043d\u0438 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430" + }, + "error": { + "authentication_error": "\u0413\u0440\u0435\u0448\u0435\u043d HomeKit \u043a\u043e\u0434. \u041c\u043e\u043b\u044f, \u043f\u0440\u043e\u0432\u0435\u0440\u0435\u0442\u0435 \u0433\u043e \u0438 \u043e\u043f\u0438\u0442\u0430\u0439\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e.", + "busy_error": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u043e\u0442\u043a\u0430\u0437\u0432\u0430 \u0434\u0430 \u0434\u043e\u0431\u0430\u0432\u0438 \u0441\u0434\u0432\u043e\u044f\u0432\u0430\u043d\u0435, \u0442\u044a\u0439 \u043a\u0430\u0442\u043e \u0432\u0435\u0447\u0435 \u0441\u0435 \u0441\u0434\u0432\u043e\u044f \u0441 \u0434\u0440\u0443\u0433 \u043a\u043e\u043d\u0442\u0440\u043e\u043b\u0435\u0440.", + "max_peers_error": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u043e\u0442\u043a\u0430\u0437\u0430 \u0434\u0430 \u0434\u043e\u0431\u0430\u0432\u0438 \u0441\u0434\u0432\u043e\u044f\u0432\u0430\u043d\u0435, \u0442\u044a\u0439 \u043a\u0430\u0442\u043e \u043d\u044f\u043c\u0430 \u0441\u0432\u043e\u0431\u043e\u0434\u043d\u043e \u043f\u0440\u043e\u0441\u0442\u0440\u0430\u043d\u0441\u0442\u0432\u043e \u0437\u0430 \u0441\u0434\u0432\u043e\u044f\u0432\u0430\u043d\u0435.", + "max_tries_error": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u043e\u0442\u043a\u0430\u0437\u0432\u0430 \u0434\u0430 \u0434\u043e\u0431\u0430\u0432\u0438 \u0441\u0434\u0432\u043e\u044f\u0432\u0430\u043d\u0435, \u0442\u044a\u0439 \u043a\u0430\u0442\u043e \u0435 \u043f\u043e\u043b\u0443\u0447\u0438\u043b\u043e \u043f\u043e\u0432\u0435\u0447\u0435 \u043e\u0442 100 \u043d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u0438 \u043e\u043f\u0438\u0442\u0430 \u0437\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u043a\u0430\u0446\u0438\u044f.", + "pairing_failed": "\u0412\u044a\u0437\u043d\u0438\u043a\u043d\u0430 \u043d\u0435\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u0435\u043d\u043e \u0433\u0440\u0435\u0448\u043a\u0430 \u043f\u0440\u0438 \u043e\u043f\u0438\u0442 \u0437\u0430 \u0441\u0434\u0432\u043e\u044f\u0432\u0430\u043d\u0435 \u0441 \u0442\u043e\u0432\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e. \u0422\u043e\u0432\u0430 \u043c\u043e\u0436\u0435 \u0434\u0430 \u0435 \u0432\u0440\u0435\u043c\u0435\u043d\u0435\u043d \u043f\u0440\u043e\u0431\u043b\u0435\u043c \u0438\u043b\u0438 \u0432\u0430\u0448\u0435\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043c\u043e\u0436\u0435 \u0434\u0430 \u043d\u0435 \u0441\u0435 \u043f\u043e\u0434\u0434\u044a\u0440\u0436\u0430 \u0432 \u043c\u043e\u043c\u0435\u043d\u0442\u0430.", + "unable_to_pair": "\u041d\u0435\u0432\u044a\u0437\u043c\u043e\u0436\u043d\u043e\u0441\u0442 \u0437\u0430 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435, \u043c\u043e\u043b\u044f \u043e\u043f\u0438\u0442\u0430\u0439\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e.", + "unknown_error": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0441\u044a\u043e\u0431\u0449\u0438 \u043d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430. \u0421\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435\u0442\u043e \u0431\u0435 \u043d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e." + }, + "flow_title": "HomeKit \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e: {name}", + "step": { + "pair": { + "data": { + "pairing_code": "\u041a\u043e\u0434 \u0437\u0430 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + }, + "description": "\u0412\u044a\u0432\u0435\u0434\u0435\u0442\u0435 HomeKit \u043a\u043e\u0434\u0430 \u0437\u0430 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 (\u0432\u044a\u0432 \u0444\u043e\u0440\u043c\u0430\u0442 XXX-XX-XXX) \u0437\u0430 \u0434\u0430 \u0438\u0437\u043f\u043e\u043b\u0437\u0432\u0430\u0442\u0435 \u0442\u043e\u0432\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e", + "title": "\u0421\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u0441 HomeKit \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e" + }, + "user": { + "data": { + "device": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e" + }, + "description": "\u0418\u0437\u0431\u0435\u0440\u0435\u0442\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e, \u0441 \u043a\u043e\u0435\u0442\u043e \u0438\u0441\u043a\u0430\u0442\u0435 \u0434\u0430 \u0441\u0435 \u0441\u0432\u044a\u0440\u0436\u0435\u0442\u0435", + "title": "\u0421\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u0441 HomeKit \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e" + } + }, + "title": "HomeKit \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/ca.json b/homeassistant/components/homekit_controller/.translations/ca.json new file mode 100644 index 000000000..f2ed4bd0c --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/ca.json @@ -0,0 +1,40 @@ +{ + "config": { + "abort": { + "accessory_not_found_error": "No s'ha pogut vincular, no s'ha trobat el dispositiu.", + "already_configured": "Accessori ja configurat amb aquest controlador.", + "already_in_progress": "El flux de dades pel dispositiu ja est\u00e0 en curs.", + "already_paired": "Aquest accessori ja est\u00e0 vinculat amb un altre dispositiu. Reinicia l'accessori i torna-ho a provar.", + "ignored_model": "La disponibilitat de HomeKit per aquest model est\u00e0 bloquejada ja que, de moment, no hi ha una integraci\u00f3 nativa completa.", + "invalid_config_entry": "Aquest dispositiu s'est\u00e0 mostrant com a llest per a ser vinculat per\u00f2, hi ha una entrada de configuraci\u00f3 conflictiva a Home Assistant que s'ha d'eliminar primer.", + "no_devices": "No s'han trobat dispositius desvinculats." + }, + "error": { + "authentication_error": "Codi HomeKit incorrecte. Verifica'l i torna-ho a provar.", + "busy_error": "El dispositiu ha refusat la vinculaci\u00f3 perqu\u00e8 actualment ho est\u00e0 intentant amb un altre controlador diferent.", + "max_peers_error": "El dispositiu ha refusat la vinculaci\u00f3 perqu\u00e8 no t\u00e9 suficient espai lliure.", + "max_tries_error": "El dispositiu ha refusat la vinculaci\u00f3 perqu\u00e8 ha rebut m\u00e9s de 100 intents d\u2019autenticaci\u00f3 fallits.", + "pairing_failed": "S'ha produ\u00eft un error mentre s'intentava la vinculaci\u00f3 amb el dispositiu. Pot ser que sigui un error temporal o pot ser que el teu dispositiu encara no estigui suportat.", + "unable_to_pair": "No s'ha pogut vincular, torna-ho a provar.", + "unknown_error": "El dispositiu ha em\u00e8s un error desconegut. Vinculaci\u00f3 fallida." + }, + "flow_title": "Accessori HomeKit: {name}", + "step": { + "pair": { + "data": { + "pairing_code": "Codi de vinculaci\u00f3" + }, + "description": "Introdueix el codi de vinculaci\u00f3 de HomeKit per utilitzar aquest accessori (format XXX-XX-XXX)", + "title": "Vinculaci\u00f3 amb" + }, + "user": { + "data": { + "device": "Dispositiu" + }, + "description": "Selecciona el dispositiu amb el qual et vols vincular", + "title": "Vinculaci\u00f3 amb un accessori HomeKit" + } + }, + "title": "Accessori HomeKit" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/cs.json b/homeassistant/components/homekit_controller/.translations/cs.json new file mode 100644 index 000000000..e70ed3973 --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/cs.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "accessory_not_found_error": "P\u00e1rov\u00e1n\u00ed nelze p\u0159idat, proto\u017ee za\u0159\u00edzen\u00ed ji\u017e nelze nal\u00e9zt." + }, + "error": { + "busy_error": "Za\u0159\u00edzen\u00ed odm\u00edtlo p\u0159idat p\u00e1rov\u00e1n\u00ed, proto\u017ee je ji\u017e sp\u00e1rov\u00e1no s jin\u00fdm \u0159adi\u010dem.", + "max_peers_error": "Za\u0159\u00edzen\u00ed odm\u00edtlo p\u0159idat p\u00e1rov\u00e1n\u00ed, proto\u017ee nem\u00e1 voln\u00e9 \u00falo\u017ei\u0161t\u011b pro p\u00e1rov\u00e1n\u00ed.", + "max_tries_error": "Za\u0159\u00edzen\u00ed odm\u00edtlo p\u0159idat p\u00e1rov\u00e1n\u00ed, proto\u017ee p\u0159ijalo v\u00edce ne\u017e 100 ne\u00fasp\u011b\u0161n\u00fdch pokus\u016f o ov\u011b\u0159en\u00ed.", + "pairing_failed": "P\u0159i pokusu o sp\u00e1rov\u00e1n\u00ed s t\u00edmto za\u0159\u00edzen\u00edm do\u0161lo k neo\u0161et\u0159en\u00e9 chyb\u011b. M\u016f\u017ee se jednat o do\u010dasn\u00e9 selh\u00e1n\u00ed nebo za\u0159\u00edzen\u00ed nen\u00ed aktu\u00e1ln\u011b podporov\u00e1no." + }, + "step": { + "pair": { + "description": "Chcete-li pou\u017e\u00edt toto p\u0159\u00edslu\u0161enstv\u00ed, zadejte k\u00f3d p\u00e1rov\u00e1n\u00ed HomeKit (ve form\u00e1tu XXX-XX-XXX)", + "title": "P\u00e1rov\u00e1n\u00ed s dopl\u0148kem HomeKit" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/cy.json b/homeassistant/components/homekit_controller/.translations/cy.json new file mode 100644 index 000000000..59e402080 --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/cy.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "ignored_model": "Mae cymorth HomeKit ar gyfer y model hwn wedi'i rwystro gan fod integreiddiad cynhenid mwy cyflawn ar gael.", + "invalid_config_entry": "Mae'r ddyfais yn dangos bod eisoes wedi paru ond mae cofnod ffurwedd groes amdano yn Home Assistant sydd angen ei diddymu", + "no_devices": "Ni ellir ddod o hyd i ddyfeisiau heb eu paru" + }, + "error": { + "authentication_error": "Cod HomeKit anghywir. Gwiriwch a cheisiwch eto.", + "unable_to_pair": "Methu paru, pl\u00eds ceisiwch eto", + "unknown_error": "Dyfeis wedi adrodd gwall anhysbys. Methodd paru." + }, + "step": { + "pair": { + "data": { + "pairing_code": "Cod Paru" + }, + "description": "Rhowch eich cod paru HomeKit i ddefnyddio'r ategolyn hwn", + "title": "Paru gyda ategolyn HomeKit" + }, + "user": { + "data": { + "device": "Dyfais" + }, + "description": "Dewiswch y ddyfais rydych eisiau paru efo", + "title": "Paru gyda ategolyn HomeKit" + } + }, + "title": "Ategolyn HomeKit" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/da.json b/homeassistant/components/homekit_controller/.translations/da.json new file mode 100644 index 000000000..2bcda4fb1 --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/da.json @@ -0,0 +1,40 @@ +{ + "config": { + "abort": { + "accessory_not_found_error": "Parring kan ikke tilf\u00f8jes da enheden ikke l\u00e6ngere findes.", + "already_configured": "Tilbeh\u00f8ret er allerede konfigureret med denne controller.", + "already_in_progress": "Enheds konfiguration er allerede i gang.", + "already_paired": "Dette tilbeh\u00f8r er allerede parret med en anden enhed. Nulstil tilbeh\u00f8ret og pr\u00f8v igen.", + "ignored_model": "HomeKit underst\u00f8ttelse til denne model er blokeret da en mere komplet native integration er til r\u00e5dighed.", + "invalid_config_entry": "Denne enhed vises som klar til parring, men der er allerede en modstridende konfigurationspost for den i Home Assistant, som f\u00f8rst skal fjernes.", + "no_devices": "Der blev ikke fundet nogen uparrede enheder" + }, + "error": { + "authentication_error": "Forkert HomeKit kode. Kontroller den og pr\u00f8v igen.", + "busy_error": "Enheden n\u00e6gtede at tilf\u00f8je parring da den allerede parrer med en anden controller.", + "max_peers_error": "Enheden n\u00e6gtede at tilf\u00f8je parring da den ikke har nok frit parrings lager.", + "max_tries_error": "Enheden n\u00e6gtede at tilf\u00f8je parring da den har modtaget mere end 100 mislykkede godkendelsesfors\u00f8g.", + "pairing_failed": "En uh\u00e5ndteret fejl opstod under fors\u00f8g p\u00e5 at parre med denne enhed. Dette kan v\u00e6re en midlertidig fejl eller din enhed muligvis ikke underst\u00f8ttes i \u00f8jeblikket.", + "unable_to_pair": "Kunne ikke parre, pr\u00f8v venligst igen.", + "unknown_error": "Enhed rapporterede en ukendt fejl. Parring mislykkedes." + }, + "flow_title": "HomeKit tilbeh\u00f8r: {name}", + "step": { + "pair": { + "data": { + "pairing_code": "Parringskode" + }, + "description": "Indtast din HomeKit parringskode (i formatet XXX-XX-XXX) for at bruge dette tilbeh\u00f8r", + "title": "Par med HomeKit tilbeh\u00f8r" + }, + "user": { + "data": { + "device": "Enhed" + }, + "description": "V\u00e6lg den enhed du vil parre med", + "title": "Par med HomeKit tilbeh\u00f8r" + } + }, + "title": "HomeKit tilbeh\u00f8r" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/de.json b/homeassistant/components/homekit_controller/.translations/de.json new file mode 100644 index 000000000..22420b796 --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/de.json @@ -0,0 +1,40 @@ +{ + "config": { + "abort": { + "accessory_not_found_error": "Die Kopplung kann nicht durchgef\u00fchrt werden, da das Ger\u00e4t nicht mehr gefunden werden kann.", + "already_configured": "Das Zubeh\u00f6r ist mit diesem Controller bereits konfiguriert.", + "already_in_progress": "Der Konfigurationsablauf f\u00fcr das Ger\u00e4t wird bereits ausgef\u00fchrt.", + "already_paired": "Dieses Zubeh\u00f6r ist bereits mit einem anderen Ger\u00e4t gekoppelt. Setzen Sie das Zubeh\u00f6r zur\u00fcck und versuchen Sie es erneut.", + "ignored_model": "Die Unterst\u00fctzung von HomeKit f\u00fcr dieses Modell ist blockiert, da eine vollst\u00e4ndige native Integration verf\u00fcgbar ist.", + "invalid_config_entry": "Dieses Ger\u00e4t wird als bereit zum Koppeln angezeigt, es gibt jedoch bereits einen widerspr\u00fcchlichen Konfigurationseintrag in Home Assistant, der zuerst entfernt werden muss.", + "no_devices": "Keine ungekoppelten Ger\u00e4te gefunden" + }, + "error": { + "authentication_error": "Ung\u00fcltiger HomeKit Code, \u00fcberpr\u00fcfe bitte den Code und versuche es erneut.", + "busy_error": "Das Ger\u00e4t weigerte sich, das Kopplung durchzuf\u00fchren, da es bereits mit einem anderen Controller gekoppelt ist.", + "max_peers_error": "Das Ger\u00e4t weigerte sich, die Kopplung durchzuf\u00fchren, da es keinen freien Kopplungs-Speicher hat.", + "max_tries_error": "Das Ger\u00e4t hat sich geweigert die Kopplung durchzuf\u00fchren, da es mehr als 100 erfolglose Authentifizierungsversuche erhalten hat.", + "pairing_failed": "Beim Versuch dieses Ger\u00e4t zu koppeln ist ein Fehler aufgetreten. Dies kann ein vor\u00fcbergehender Fehler sein oder das Ger\u00e4t wird derzeit m\u00f6glicherweise nicht unterst\u00fctzt.", + "unable_to_pair": "Koppeln fehltgeschlagen, bitte versuche es erneut", + "unknown_error": "Das Ger\u00e4t meldete einen unbekannten Fehler. Die Kopplung ist fehlgeschlagen." + }, + "flow_title": "HomeKit-Zubeh\u00f6r: {name}", + "step": { + "pair": { + "data": { + "pairing_code": "Kopplungscode" + }, + "description": "Geben Sie Ihren HomeKit-Kopplungscode ein, um dieses Zubeh\u00f6r zu verwenden", + "title": "Mit HomeKit Zubeh\u00f6r koppeln" + }, + "user": { + "data": { + "device": "Ger\u00e4t" + }, + "description": "W\u00e4hle das Ger\u00e4t aus, mit dem du die Kopplung herstellen m\u00f6chtest", + "title": "Mit HomeKit Zubeh\u00f6r koppeln" + } + }, + "title": "HomeKit Zubeh\u00f6r" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/en.json b/homeassistant/components/homekit_controller/.translations/en.json new file mode 100644 index 000000000..31731a522 --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/en.json @@ -0,0 +1,40 @@ +{ + "config": { + "abort": { + "accessory_not_found_error": "Cannot add pairing as device can no longer be found.", + "already_configured": "Accessory is already configured with this controller.", + "already_in_progress": "Config flow for device is already in progress.", + "already_paired": "This accessory is already paired to another device. Please reset the accessory and try again.", + "ignored_model": "HomeKit support for this model is blocked as a more feature complete native integration is available.", + "invalid_config_entry": "This device is showing as ready to pair but there is already a conflicting config entry for it in Home Assistant that must first be removed.", + "no_devices": "No unpaired devices could be found" + }, + "error": { + "authentication_error": "Incorrect HomeKit code. Please check it and try again.", + "busy_error": "Device refused to add pairing as it is already pairing with another controller.", + "max_peers_error": "Device refused to add pairing as it has no free pairing storage.", + "max_tries_error": "Device refused to add pairing as it has received more than 100 unsuccessful authentication attempts.", + "pairing_failed": "An unhandled error occured while attempting to pair with this device. This may be a temporary failure or your device may not be supported currently.", + "unable_to_pair": "Unable to pair, please try again.", + "unknown_error": "Device reported an unknown error. Pairing failed." + }, + "flow_title": "HomeKit Accessory: {name}", + "step": { + "pair": { + "data": { + "pairing_code": "Pairing Code" + }, + "description": "Enter your HomeKit pairing code (in the format XXX-XX-XXX) to use this accessory", + "title": "Pair with HomeKit Accessory" + }, + "user": { + "data": { + "device": "Device" + }, + "description": "Select the device you want to pair with", + "title": "Pair with HomeKit Accessory" + } + }, + "title": "HomeKit Accessory" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/es-419.json b/homeassistant/components/homekit_controller/.translations/es-419.json new file mode 100644 index 000000000..67a65f752 --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/es-419.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "El accesorio ya est\u00e1 configurado con este controlador.", + "already_paired": "Este accesorio ya est\u00e1 emparejado con otro dispositivo. Por favor, reinicie el accesorio y vuelva a intentarlo." + }, + "flow_title": "Accesorio HomeKit: {name}", + "step": { + "pair": { + "data": { + "pairing_code": "C\u00f3digo de emparejamiento" + } + }, + "user": { + "data": { + "device": "Dispositivo" + } + } + }, + "title": "Accesorio HomeKit" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/es.json b/homeassistant/components/homekit_controller/.translations/es.json new file mode 100644 index 000000000..67f6daa84 --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/es.json @@ -0,0 +1,40 @@ +{ + "config": { + "abort": { + "accessory_not_found_error": "No se puede a\u00f1adir el emparejamiento porque ya no se puede encontrar el dispositivo.", + "already_configured": "El accesorio ya est\u00e1 configurado con este controlador.", + "already_in_progress": "El flujo de configuraci\u00f3n del dispositivo ya est\u00e1 en curso.", + "already_paired": "Este accesorio ya est\u00e1 emparejado con otro dispositivo. Por favor, reinicia el accesorio e int\u00e9ntalo de nuevo.", + "ignored_model": "El soporte de HomeKit para este modelo est\u00e1 bloqueado ya que est\u00e1 disponible una integraci\u00f3n nativa m\u00e1s completa.", + "invalid_config_entry": "Este dispositivo se muestra como listo para vincular, pero ya existe una entrada que causa conflicto en Home Assistant y se debe eliminar primero.", + "no_devices": "No se encontraron dispositivos no emparejados" + }, + "error": { + "authentication_error": "C\u00f3digo HomeKit incorrecto. Por favor, compru\u00e9belo e int\u00e9ntelo de nuevo.", + "busy_error": "El dispositivo rechaz\u00f3 el emparejamiento porque ya est\u00e1 emparejado con otro controlador.", + "max_peers_error": "El dispositivo rechaz\u00f3 el emparejamiento ya que no tiene almacenamiento de emparejamientos libres.", + "max_tries_error": "El dispositivo rechaz\u00f3 el emparejamiento ya que ha recibido m\u00e1s de 100 intentos de autenticaci\u00f3n fallidos.", + "pairing_failed": "Se ha producido un error no controlado al intentar emparejarse con este dispositivo. Esto puede ser un fallo temporal o que tu dispositivo no est\u00e9 admitido en este momento.", + "unable_to_pair": "No se ha podido emparejar, por favor int\u00e9ntelo de nuevo.", + "unknown_error": "El dispositivo report\u00f3 un error desconocido. La vinculaci\u00f3n ha fallado." + }, + "flow_title": "Accesorio HomeKit: {name}", + "step": { + "pair": { + "data": { + "pairing_code": "C\u00f3digo de vinculaci\u00f3n" + }, + "description": "Introduce tu c\u00f3digo de vinculaci\u00f3n de HomeKit (en este formato XXX-XX-XXX) para usar este accesorio", + "title": "Vincular con accesorio HomeKit" + }, + "user": { + "data": { + "device": "Dispositivo" + }, + "description": "Selecciona el dispositivo que quieres vincular", + "title": "Vincular con accesorio HomeKit" + } + }, + "title": "Accesorio HomeKit" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/fr.json b/homeassistant/components/homekit_controller/.translations/fr.json new file mode 100644 index 000000000..7f0566ddd --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/fr.json @@ -0,0 +1,40 @@ +{ + "config": { + "abort": { + "accessory_not_found_error": "Impossible d'ajouter le couplage car l'appareil est introuvable.", + "already_configured": "L'accessoire est d\u00e9j\u00e0 configur\u00e9 avec ce contr\u00f4leur.", + "already_in_progress": "Le flux de configuration de l'appareil est d\u00e9j\u00e0 en cours.", + "already_paired": "Cet accessoire est d\u00e9j\u00e0 associ\u00e9 \u00e0 un autre appareil. R\u00e9initialisez l\u2019accessoire et r\u00e9essayez.", + "ignored_model": "La prise en charge de HomeKit pour ce mod\u00e8le est bloqu\u00e9e car une int\u00e9gration native plus compl\u00e8te est disponible.", + "invalid_config_entry": "Cet appareil est pr\u00eat \u00e0 \u00eatre coupl\u00e9, mais il existe d\u00e9j\u00e0 une entr\u00e9e de configuration en conflit dans Home Assistant \u00e0 supprimer.", + "no_devices": "Aucun appareil non appair\u00e9 n'a pu \u00eatre trouv\u00e9" + }, + "error": { + "authentication_error": "Code HomeKit incorrect. S'il vous pla\u00eet v\u00e9rifier et essayez \u00e0 nouveau.", + "busy_error": "L'appareil a refus\u00e9 d'ajouter le couplage car il est d\u00e9j\u00e0 coupl\u00e9 avec un autre contr\u00f4leur.", + "max_peers_error": "L'appareil a refus\u00e9 d'ajouter le couplage car il ne dispose pas de stockage de couplage libre.", + "max_tries_error": "Le p\u00e9riph\u00e9rique a refus\u00e9 d'ajouter le couplage car il a re\u00e7u plus de 100 tentatives d'authentification infructueuses.", + "pairing_failed": "Une erreur non g\u00e9r\u00e9e s'est produite lors de la tentative d'appairage avec cet appareil. Il se peut qu'il s'agisse d'une panne temporaire ou que votre appareil ne soit pas pris en charge actuellement.", + "unable_to_pair": "Impossible d'appairer, veuillez r\u00e9essayer.", + "unknown_error": "L'appareil a signal\u00e9 une erreur inconnue. L'appairage a \u00e9chou\u00e9." + }, + "flow_title": "Accessoire HomeKit: {name}", + "step": { + "pair": { + "data": { + "pairing_code": "Code d\u2019appairage" + }, + "description": "Entrez votre code de jumelage HomeKit (au format XXX-XX-XXX) pour utiliser cet accessoire.", + "title": "Appairer avec l'accessoire HomeKit" + }, + "user": { + "data": { + "device": "Appareil" + }, + "description": "S\u00e9lectionnez l'appareil avec lequel vous voulez appairer", + "title": "Appairer avec l'accessoire HomeKit" + } + }, + "title": "Accessoire HomeKit" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/hu.json b/homeassistant/components/homekit_controller/.translations/hu.json new file mode 100644 index 000000000..60bd173dc --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/hu.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "device": "Eszk\u00f6z" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/it.json b/homeassistant/components/homekit_controller/.translations/it.json new file mode 100644 index 000000000..7ed026a52 --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/it.json @@ -0,0 +1,40 @@ +{ + "config": { + "abort": { + "accessory_not_found_error": "Impossibile aggiungere l'abbinamento in quanto non \u00e8 pi\u00f9 possibile trovare il dispositivo.", + "already_configured": "L'accessorio \u00e8 gi\u00e0 configurato con questo controller.", + "already_in_progress": "Il flusso di configurazione per il dispositivo \u00e8 gi\u00e0 in corso.", + "already_paired": "Questo accessorio \u00e8 gi\u00e0 associato a un altro dispositivo. Si prega di resettare l'accessorio e riprovare.", + "ignored_model": "Il supporto di HomeKit per questo modello \u00e8 bloccato poich\u00e9 \u00e8 disponibile un'integrazione nativa con pi\u00f9 funzionalit\u00e0.", + "invalid_config_entry": "Questo dispositivo viene visualizzato come pronto per l'associazione, ma c'\u00e8 gi\u00e0 una voce di configurazione in conflitto in Home Assistant che deve prima essere rimossa.", + "no_devices": "Non \u00e8 stato possibile trovare dispositivi non associati" + }, + "error": { + "authentication_error": "Codice HomeKit errato. Per favore, controllate e riprovate.", + "busy_error": "Il dispositivo ha rifiutato di aggiungere l'abbinamento in quanto \u00e8 gi\u00e0 associato a un altro controller.", + "max_peers_error": "Il dispositivo ha rifiutato di aggiungere l'abbinamento in quanto non dispone di una memoria libera per esso.", + "max_tries_error": "Il dispositivo ha rifiutato di aggiungere l'abbinamento poich\u00e9 ha ricevuto pi\u00f9 di 100 tentativi di autenticazione non riusciti.", + "pairing_failed": "Si \u00e8 verificato un errore non gestito durante il tentativo di abbinamento con questo dispositivo. Potrebbe trattarsi di un errore temporaneo o il dispositivo potrebbe non essere attualmente supportato.", + "unable_to_pair": "Impossibile abbinare, per favore riprova.", + "unknown_error": "Il dispositivo ha riportato un errore sconosciuto. L'abbinamento non \u00e8 riuscito." + }, + "flow_title": "Accessorio HomeKit: {name}", + "step": { + "pair": { + "data": { + "pairing_code": "Codice di abbinamento" + }, + "description": "Immettere il codice di abbinamento HomeKit (nel formato XXX-XX-XXX) per utilizzare questo accessorio", + "title": "Abbina con accessorio HomeKit" + }, + "user": { + "data": { + "device": "Dispositivo" + }, + "description": "Selezionare il dispositivo che si desidera abbinare", + "title": "Abbina con accessorio HomeKit" + } + }, + "title": "Accessorio HomeKit" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/ko.json b/homeassistant/components/homekit_controller/.translations/ko.json new file mode 100644 index 000000000..8837e501a --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/ko.json @@ -0,0 +1,40 @@ +{ + "config": { + "abort": { + "accessory_not_found_error": "\uae30\uae30\ub97c \ub354 \uc774\uc0c1 \ucc3e\uc744 \uc218 \uc5c6\uc73c\ubbc0\ub85c \ud398\uc5b4\ub9c1\uc744 \ucd94\uac00 \ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.", + "already_configured": "\uc561\uc138\uc11c\ub9ac\uac00 \ucee8\ud2b8\ub864\ub7ec\uc5d0 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", + "already_in_progress": "\uae30\uae30 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589\uc911\uc785\ub2c8\ub2e4.", + "already_paired": "\uc774 \uc561\uc138\uc11c\ub9ac\ub294 \uc774\ubbf8 \ub2e4\ub978 \uae30\uae30\uc640 \ud398\uc5b4\ub9c1\ub418\uc5b4 \uc788\uc2b5\ub2c8\ub2e4. \uc561\uc138\uc11c\ub9ac\ub97c \uc7ac\uc124\uc815\ud558\uace0 \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "ignored_model": "\uc774 \ubaa8\ub378\uc5d0 \ub300\ud55c HomeKit \uc9c0\uc6d0\uc740 \ub354 \ub9ce\uc740 \uae30\ub2a5\uc744 \uc81c\uacf5\ud558\ub294 \uae30\ubcf8 \uad6c\uc131\uc694\uc18c\ub85c \uc778\ud574 \ucc28\ub2e8\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", + "invalid_config_entry": "\uc774 \uae30\uae30\ub294 \ud398\uc5b4\ub9c1 \ud560 \uc900\ube44\uac00 \ub418\uc5c8\uc9c0\ub9cc Home Assistant \uc5d0 \uc774\ubbf8 \uad6c\uc131\ub418\uc5b4 \ucda9\ub3cc\ud558\ub294 \uad6c\uc131\uc694\uc18c\uac00 \uc788\uc2b5\ub2c8\ub2e4. \uba3c\uc800 \ud574\ub2f9 \uad6c\uc131\uc694\uc18c\ub97c \uc81c\uac70\ud574\uc8fc\uc138\uc694.", + "no_devices": "\ud398\uc5b4\ub9c1\uc774 \ud544\uc694\ud55c \uae30\uae30\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4" + }, + "error": { + "authentication_error": "HomeKit \ucf54\ub4dc\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud655\uc778 \ud6c4 \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "busy_error": "\uae30\uae30\uac00 \uc774\ubbf8 \ub2e4\ub978 \ucee8\ud2b8\ub864\ub7ec\uc640 \ud398\uc5b4\ub9c1 \uc911\uc774\ubbc0\ub85c \ud398\uc5b4\ub9c1 \ucd94\uac00\ub97c \ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.", + "max_peers_error": "\uae30\uae30\uc5d0 \ube44\uc5b4\uc788\ub294 \ud398\uc5b4\ub9c1 \uc7a5\uc18c\uac00 \uc5c6\uc5b4 \ud398\uc5b4\ub9c1 \ucd94\uac00\ub97c \ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.", + "max_tries_error": "\uae30\uae30\uac00 \uc2e4\ud328\ud55c \uc778\uc99d \uc2dc\ub3c4 \ud69f\uc218\uac00 100 \ud68c\ub97c \ucd08\uacfc\ud558\uc5ec \ud398\uc5b4\ub9c1\uc744 \ucd94\uac00\ub97c \ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.", + "pairing_failed": "\uc774 \uae30\uae30\uc640 \ud398\uc5b4\ub9c1\uc744 \uc2dc\ub3c4\ud558\ub294 \uc911 \ucc98\ub9ac\ub418\uc9c0 \uc54a\uc740 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4. \uc77c\uc2dc\uc801\uc778 \uc624\ub958\uc774\uac70\ub098 \ud604\uc7ac \uc9c0\uc6d0\ub418\uc9c0 \uc54a\ub294 \uae30\uae30 \uc77c \uc218 \uc788\uc2b5\ub2c8\ub2e4.", + "unable_to_pair": "\ud398\uc5b4\ub9c1 \ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "unknown_error": "\uae30\uae30\uc5d0\uc11c \uc54c \uc218\uc5c6\ub294 \uc624\ub958\ub97c \ubcf4\uace0\ud588\uc2b5\ub2c8\ub2e4. \ud398\uc5b4\ub9c1\uc5d0 \uc2e4\ud328\ud588\uc2b5\ub2c8\ub2e4." + }, + "flow_title": "HomeKit \uc561\uc138\uc11c\ub9ac: {name}", + "step": { + "pair": { + "data": { + "pairing_code": "\ud398\uc5b4\ub9c1 \ucf54\ub4dc" + }, + "description": "\uc774 \uc561\uc138\uc11c\ub9ac\ub97c \uc0ac\uc6a9\ud558\ub824\uba74 HomeKit \ud398\uc5b4\ub9c1 \ucf54\ub4dc (XXX-XX-XXX \ud615\uc2dd) \ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694", + "title": "HomeKit \uc561\uc138\uc11c\ub9ac \ud398\uc5b4\ub9c1" + }, + "user": { + "data": { + "device": "\uae30\uae30" + }, + "description": "\ud398\uc5b4\ub9c1 \ud560 \uae30\uae30\ub97c \uc120\ud0dd\ud574\uc8fc\uc138\uc694", + "title": "HomeKit \uc561\uc138\uc11c\ub9ac \ud398\uc5b4\ub9c1" + } + }, + "title": "HomeKit \uc561\uc138\uc11c\ub9ac" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/lb.json b/homeassistant/components/homekit_controller/.translations/lb.json new file mode 100644 index 000000000..ca7bce445 --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/lb.json @@ -0,0 +1,40 @@ +{ + "config": { + "abort": { + "accessory_not_found_error": "D'Kupplung kann net dob\u00e4igesat ginn, well den Apparat net m\u00e9i siichtbar ass", + "already_configured": "Accessoire ass schon mat d\u00ebsem Kontroller konfigur\u00e9iert.", + "already_in_progress": "Konfiguratioun fir d\u00ebsen Apparat ass schonn am gaang.", + "already_paired": "D\u00ebsen Accessoire ass schonn mat engem aneren Apparat verbonnen. S\u00ebtzt den Apparat op Wierksastellungen zer\u00e9ck an prob\u00e9iert nach emol w.e.g.", + "ignored_model": "HomeKit Support fir d\u00ebse Modell ass block\u00e9iert well eng m\u00e9i komplett nativ Integratioun disponibel ass.", + "invalid_config_entry": "D\u00ebsen Apparat mellt sech prett fir ze verbanne mee et g\u00ebtt schonn eng Entr\u00e9e am Home Assistant d\u00e9i ee Konflikt duerstellt welch fir d'\u00e9ischt muss erausgeholl ginn.", + "no_devices": "Keng net verbonnen Apparater fonnt" + }, + "error": { + "authentication_error": "Ong\u00ebltege HomeKit Code. Iwwerpr\u00e9ift d\u00ebsen an prob\u00e9iert w.e.g. nach emol.", + "busy_error": "Den Apparat huet en Kupplungs Versuch refus\u00e9iert, well en scho mat engem anere Kontroller verbonnen ass.", + "max_peers_error": "Den Apparat huet den Kupplungs Versuch refus\u00e9iert well et keng fr\u00e4i Pairing Memoire huet.", + "max_tries_error": "Den Apparat huet den Kupplungs Versuch refus\u00e9iert well et m\u00e9i w\u00e9i 100 net erfollegr\u00e4ich Authentifikatioun's Versich erhalen huet.", + "pairing_failed": "Eng onerwaarte Feeler ass opgetruede beim Kupplung's Versuch mat d\u00ebsem Apparat. D\u00ebst kann e tempor\u00e4re Feeler sinn oder \u00c4ren Apparat g\u00ebtt aktuell net \u00ebnnerst\u00ebtzt.", + "unable_to_pair": "Feeler beim verbannen, prob\u00e9iert w.e.g. nach emol.", + "unknown_error": "Apparat mellt een onbekannte Feeler. Verbindung net m\u00e9iglech." + }, + "flow_title": "HomeKit Accessoire: {name}", + "step": { + "pair": { + "data": { + "pairing_code": "Pairing Code" + }, + "description": "Gitt \u00e4ren HomeKit pairing Code (am Format XXX-XX-XXX) an fir d\u00ebsen Accessoire ze benotzen", + "title": "Mam HomeKit Accessoire verbannen" + }, + "user": { + "data": { + "device": "Apparat" + }, + "description": "Wielt den Apparat aus dee soll verbonne ginn", + "title": "Mam HomeKit Accessoire verbannen" + } + }, + "title": "HomeKit Accessoire" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/nl.json b/homeassistant/components/homekit_controller/.translations/nl.json new file mode 100644 index 000000000..30494295f --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/nl.json @@ -0,0 +1,40 @@ +{ + "config": { + "abort": { + "accessory_not_found_error": "Kan geen koppeling toevoegen omdat het apparaat niet langer kan worden gevonden.", + "already_configured": "Accessoire is al geconfigureerd met deze controller.", + "already_in_progress": "De configuratiestroom voor het apparaat is al in volle gang.", + "already_paired": "Dit accessoire is al gekoppeld aan een ander apparaat. Reset het accessoire en probeer het opnieuw.", + "ignored_model": "HomeKit-ondersteuning voor dit model is geblokkeerd omdat er een meer functie volledige native integratie beschikbaar is.", + "invalid_config_entry": "Dit apparaat geeft aan dat het gereed is om te koppelen, maar er is al een conflicterend configuratie-item voor in de Home Assistant dat eerst moet worden verwijderd.", + "no_devices": "Er zijn geen gekoppelde apparaten gevonden" + }, + "error": { + "authentication_error": "Onjuiste HomeKit-code. Controleer het en probeer het opnieuw.", + "busy_error": "Het apparaat weigerde om koppelingen toe te voegen, omdat het al gekoppeld is met een andere controller.", + "max_peers_error": "Apparaat heeft geweigerd om koppelingen toe te voegen omdat het geen vrije koppelingsopslag heeft.", + "max_tries_error": "Apparaat weigerde pairing toe te voegen omdat het meer dan 100 niet-succesvolle authenticatiepogingen heeft ontvangen.", + "pairing_failed": "Er deed zich een fout voor tijdens het koppelen met dit apparaat. Dit kan een tijdelijke storing zijn of uw apparaat wordt mogelijk momenteel niet ondersteund.", + "unable_to_pair": "Kan niet koppelen, probeer het opnieuw.", + "unknown_error": "Apparaat meldde een onbekende fout. Koppelen mislukt." + }, + "flow_title": "HomeKit-accessoire: {name}", + "step": { + "pair": { + "data": { + "pairing_code": "Koppelingscode" + }, + "description": "Voer uw HomeKit pairing code (in het formaat XXX-XX-XXX) om dit accessoire te gebruiken", + "title": "Koppel met HomeKit accessoire" + }, + "user": { + "data": { + "device": "Apparaat" + }, + "description": "Selecteer het apparaat waarmee u wilt koppelen", + "title": "Koppel met HomeKit accessoire" + } + }, + "title": "HomeKit Accessoires" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/nn.json b/homeassistant/components/homekit_controller/.translations/nn.json new file mode 100644 index 000000000..995d67792 --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/nn.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "pair": { + "data": { + "pairing_code": "Paringskode" + } + } + }, + "title": "HomeKit tilbeh\u00f8r" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/no.json b/homeassistant/components/homekit_controller/.translations/no.json new file mode 100644 index 000000000..db8b8b035 --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/no.json @@ -0,0 +1,40 @@ +{ + "config": { + "abort": { + "accessory_not_found_error": "Kan ikke legge til sammenkobling da enheten ikke lenger kan bli funnet.", + "already_configured": "Tilbeh\u00f8r er allerede konfigurert med denne kontrolleren.", + "already_in_progress": "Konfigurasjonsflyt for enhet p\u00e5g\u00e5r allerede.", + "already_paired": "Dette tilbeh\u00f8ret er allerede sammenkoblet med en annen enhet. Vennligst tilbakestill tilbeh\u00f8ret og pr\u00f8v igjen.", + "ignored_model": "HomeKit st\u00f8tte for denne modellen er blokkert da en mer funksjonsrik standard integrering er tilgjengelig.", + "invalid_config_entry": "Denne enheten vises som klar til \u00e5 sammenkoble, men det er allerede en motstridende konfigurasjonsoppf\u00f8ring for den i Home Assistant som m\u00e5 fjernes f\u00f8rst.", + "no_devices": "Ingen ukoblede enheter ble funnet" + }, + "error": { + "authentication_error": "Ugyldig HomeKit kode. Vennligst sjekk den og pr\u00f8v igjen.", + "busy_error": "Enheten nekter \u00e5 sammenkoble da den allerede er sammenkoblet med en annen kontroller.", + "max_peers_error": "Enheten nekter \u00e5 sammenkoble da den ikke har ledig sammenkoblingslagring.", + "max_tries_error": "Enheten nekter \u00e5 sammenkoble da den har mottatt mer enn 100 mislykkede godkjenningsfors\u00f8k.", + "pairing_failed": "En uh\u00e5ndtert feil oppstod under fors\u00f8k p\u00e5 \u00e5 koble til denne enheten. Dette kan v\u00e6re en midlertidig feil, eller at enheten din kan ikke st\u00f8ttes for \u00f8yeblikket.", + "unable_to_pair": "Kunne ikke koble til, vennligst pr\u00f8v igjen.", + "unknown_error": "Enheten rapporterte en ukjent feil. Sammenkobling mislyktes." + }, + "flow_title": "HomeKit Tilbeh\u00f8r: {name}", + "step": { + "pair": { + "data": { + "pairing_code": "Sammenkoblingskode" + }, + "description": "Skriv inn din HomeKit-sammenkoblingskode (i formatet XXX-XX-XXX) for \u00e5 bruke dette tilbeh\u00f8ret", + "title": "Koble til HomeKit tilbeh\u00f8r" + }, + "user": { + "data": { + "device": "Enhet" + }, + "description": "Velg enheten du vil koble til", + "title": "Koble til HomeKit tilbeh\u00f8r" + } + }, + "title": "HomeKit tilbeh\u00f8r" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/pl.json b/homeassistant/components/homekit_controller/.translations/pl.json new file mode 100644 index 000000000..e66353c50 --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/pl.json @@ -0,0 +1,40 @@ +{ + "config": { + "abort": { + "accessory_not_found_error": "Nie mo\u017cna rozpocz\u0105\u0107 parowania, poniewa\u017c nie znaleziono urz\u0105dzenia.", + "already_configured": "Akcesorium jest ju\u017c skonfigurowane z tym kontrolerem.", + "already_in_progress": "Konfigurowanie urz\u0105dzenia jest ju\u017c w toku.", + "already_paired": "To akcesorium jest ju\u017c sparowane z innym urz\u0105dzeniem. Zresetuj akcesorium i spr\u00f3buj ponownie.", + "ignored_model": "Obs\u0142uga HomeKit dla tego modelu jest zablokowana, poniewa\u017c dost\u0119pna jest pe\u0142niejsza integracja natywna.", + "invalid_config_entry": "To urz\u0105dzenie jest wy\u015bwietlane jako gotowe do sparowania, ale istnieje ju\u017c konfliktowy wpis konfiguracyjny dla niego w Home Assistant, kt\u00f3ry musi zosta\u0107 najpierw usuni\u0119ty.", + "no_devices": "Nie znaleziono niesparowanych urz\u0105dze\u0144" + }, + "error": { + "authentication_error": "Niepoprawny kod parowania HomeKit. Sprawd\u017a go i spr\u00f3buj ponownie.", + "busy_error": "Urz\u0105dzenie odm\u00f3wi\u0142o parowania, poniewa\u017c jest ju\u017c powi\u0105zane z innym kontrolerem.", + "max_peers_error": "Urz\u0105dzenie odm\u00f3wi\u0142o parowania, poniewa\u017c nie ma wolnej pami\u0119ci parowania.", + "max_tries_error": "Urz\u0105dzenie odm\u00f3wi\u0142o dodania parowania, poniewa\u017c otrzyma\u0142o ponad 100 nieudanych pr\u00f3b uwierzytelnienia.", + "pairing_failed": "Wyst\u0105pi\u0142 nieobs\u0142ugiwany b\u0142\u0105d podczas pr\u00f3by sparowania z tym urz\u0105dzeniem. Mo\u017ce to by\u0107 tymczasowa awaria lub urz\u0105dzenie mo\u017ce nie by\u0107 obecnie obs\u0142ugiwane.", + "unable_to_pair": "Nie mo\u017cna sparowa\u0107, spr\u00f3buj ponownie.", + "unknown_error": "Urz\u0105dzenie zg\u0142osi\u0142o nieznany b\u0142\u0105d. Parowanie nie powiod\u0142o si\u0119." + }, + "flow_title": "Akcesoria HomeKit: {name}", + "step": { + "pair": { + "data": { + "pairing_code": "Kod parowania" + }, + "description": "Wprowad\u017a kod parowania HomeKit, aby u\u017cy\u0107 tego akcesorium", + "title": "Sparuj z akcesorium HomeKit" + }, + "user": { + "data": { + "device": "Urz\u0105dzenie" + }, + "description": "Wybierz urz\u0105dzenie, kt\u00f3re chcesz sparowa\u0107", + "title": "Sparuj z akcesorium HomeKit" + } + }, + "title": "Akcesorium HomeKit" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/pt-BR.json b/homeassistant/components/homekit_controller/.translations/pt-BR.json new file mode 100644 index 000000000..479f6c6a9 --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/pt-BR.json @@ -0,0 +1,40 @@ +{ + "config": { + "abort": { + "accessory_not_found_error": "N\u00e3o \u00e9 poss\u00edvel adicionar o emparelhamento, pois o dispositivo n\u00e3o pode mais ser encontrado.", + "already_configured": "O acess\u00f3rio j\u00e1 est\u00e1 configurado com este controlador.", + "already_in_progress": "O fluxo de configura\u00e7\u00e3o para o dispositivo j\u00e1 est\u00e1 em andamento.", + "already_paired": "Este acess\u00f3rio j\u00e1 est\u00e1 pareado com outro dispositivo. Por favor, redefina o acess\u00f3rio e tente novamente.", + "ignored_model": "O suporte do HomeKit para este modelo est\u00e1 bloqueado, j\u00e1 que uma integra\u00e7\u00e3o nativa mais completa est\u00e1 dispon\u00edvel.", + "invalid_config_entry": "Este dispositivo est\u00e1 mostrando como pronto para parear, mas existe um conflito na configura\u00e7\u00e3o de entrada para ele no Home Assistant que deve ser removida primeiro.", + "no_devices": "N\u00e3o foi poss\u00edvel encontrar dispositivos n\u00e3o pareados" + }, + "error": { + "authentication_error": "C\u00f3digo HomeKit incorreto. Por favor verifique e tente novamente.", + "busy_error": "O dispositivo recusou-se a adicionar o emparelhamento, uma vez que j\u00e1 est\u00e1 emparelhando com outro controlador.", + "max_peers_error": "O dispositivo recusou-se a adicionar o emparelhamento, pois n\u00e3o tem armazenamento de emparelhamento gratuito.", + "max_tries_error": "O dispositivo recusou-se a adicionar o emparelhamento, uma vez que recebeu mais de 100 tentativas de autentica\u00e7\u00e3o malsucedidas.", + "pairing_failed": "Ocorreu um erro sem tratamento ao tentar emparelhar com este dispositivo. Isso pode ser uma falha tempor\u00e1ria ou o dispositivo pode n\u00e3o ser suportado no momento.", + "unable_to_pair": "N\u00e3o \u00e9 poss\u00edvel parear, tente novamente.", + "unknown_error": "O dispositivo relatou um erro desconhecido. O pareamento falhou." + }, + "flow_title": "Acess\u00f3rio HomeKit: {name}", + "step": { + "pair": { + "data": { + "pairing_code": "C\u00f3digo de pareamento" + }, + "description": "Digite seu c\u00f3digo de pareamento do HomeKit (no formato XXX-XX-XXX) para usar este acess\u00f3rio", + "title": "Parear com o acess\u00f3rio HomeKit" + }, + "user": { + "data": { + "device": "Dispositivo" + }, + "description": "Selecione o dispositivo com o qual voc\u00ea deseja parear", + "title": "Parear com o acess\u00f3rio HomeKit" + } + }, + "title": "Acess\u00f3rio HomeKit" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/pt.json b/homeassistant/components/homekit_controller/.translations/pt.json new file mode 100644 index 000000000..c60ed1555 --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/pt.json @@ -0,0 +1,17 @@ +{ + "config": { + "step": { + "pair": { + "data": { + "pairing_code": "C\u00f3digo de emparelhamento" + }, + "title": "Emparelhar com o acess\u00f3rio HomeKit" + }, + "user": { + "data": { + "device": "Dispositivo" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/ru.json b/homeassistant/components/homekit_controller/.translations/ru.json new file mode 100644 index 000000000..44a57a1eb --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/ru.json @@ -0,0 +1,40 @@ +{ + "config": { + "abort": { + "accessory_not_found_error": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435, \u0442\u0430\u043a \u043a\u0430\u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0431\u043e\u043b\u044c\u0448\u0435 \u043d\u0435 \u043c\u043e\u0436\u0435\u0442 \u0431\u044b\u0442\u044c \u043d\u0430\u0439\u0434\u0435\u043d\u043e.", + "already_configured": "\u0410\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440 \u0443\u0436\u0435 \u0441\u0432\u044f\u0437\u0430\u043d \u0441 \u044d\u0442\u0438\u043c \u043a\u043e\u043d\u0442\u0440\u043e\u043b\u043b\u0435\u0440\u043e\u043c.", + "already_in_progress": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0443\u0436\u0435 \u043d\u0430\u0447\u0430\u0442\u0430.", + "already_paired": "\u042d\u0442\u043e\u0442 \u0430\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440 \u0443\u0436\u0435 \u0441\u0432\u044f\u0437\u0430\u043d \u0441 \u0434\u0440\u0443\u0433\u0438\u043c \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u0435 \u0441\u0431\u0440\u043e\u0441 \u0430\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440\u0430 \u0438 \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0441\u043d\u043e\u0432\u0430.", + "ignored_model": "\u041f\u043e\u0434\u0434\u0435\u0440\u0436\u043a\u0430 HomeKit \u0434\u043b\u044f \u044d\u0442\u043e\u0439 \u043c\u043e\u0434\u0435\u043b\u0438 \u0437\u0430\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u0430\u043d\u0430, \u0442\u0430\u043a \u043a\u0430\u043a \u0434\u043e\u0441\u0442\u0443\u043f\u043d\u0430 \u0431\u043e\u043b\u0435\u0435 \u043f\u043e\u043b\u043d\u0430\u044f \u043d\u0430\u0442\u0438\u0432\u043d\u0430\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f.", + "invalid_config_entry": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0430\u0435\u0442\u0441\u044f \u043a\u0430\u043a \u0433\u043e\u0442\u043e\u0432\u043e\u0435 \u043a \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044e, \u043d\u043e \u0432 Home Assistant \u0443\u0436\u0435 \u0435\u0441\u0442\u044c \u043a\u043e\u043d\u0444\u043b\u0438\u043a\u0442\u0443\u044e\u0449\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u0434\u043b\u044f \u043d\u0435\u0433\u043e, \u043a\u043e\u0442\u043e\u0440\u0443\u044e \u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u0443\u0434\u0430\u043b\u0438\u0442\u044c.", + "no_devices": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430, \u0434\u043e\u0441\u0442\u0443\u043f\u043d\u044b\u0435 \u0434\u043b\u044f \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f, \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b." + }, + "error": { + "authentication_error": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043a\u043e\u0434 HomeKit. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u043a\u043e\u0434 \u0438 \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0435\u0449\u0435 \u0440\u0430\u0437.", + "busy_error": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435, \u0442\u0430\u043a \u043a\u0430\u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u043e \u0441 \u0434\u0440\u0443\u0433\u0438\u043c \u043a\u043e\u043d\u0442\u0440\u043e\u043b\u043b\u0435\u0440\u043e\u043c.", + "max_peers_error": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043e\u0442\u043a\u043b\u043e\u043d\u0438\u043b\u043e \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435 \u0438\u0437-\u0437\u0430 \u043e\u0442\u0441\u0443\u0442\u0441\u0442\u0432\u0438\u044f \u0441\u0432\u043e\u0431\u043e\u0434\u043d\u043e\u0433\u043e \u043c\u0435\u0441\u0442\u0430.", + "max_tries_error": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043e\u0442\u043a\u043b\u043e\u043d\u0438\u043b\u043e \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435, \u0442\u0430\u043a \u043a\u0430\u043a \u0431\u044b\u043b\u043e \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u043e \u0431\u043e\u043b\u0435\u0435 100 \u043d\u0435\u0443\u0434\u0430\u0447\u043d\u044b\u0445 \u043f\u043e\u043f\u044b\u0442\u043e\u043a \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", + "pairing_failed": "\u0412\u043e \u0432\u0440\u0435\u043c\u044f \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f \u043f\u0440\u043e\u0438\u0437\u043e\u0448\u043b\u0430 \u043e\u0448\u0438\u0431\u043a\u0430. \u042d\u0442\u043e \u043c\u043e\u0436\u0435\u0442 \u0431\u044b\u0442\u044c \u0432\u0440\u0435\u043c\u0435\u043d\u043d\u044b\u0439 \u0441\u0431\u043e\u0439 \u0438\u043b\u0438 \u0412\u0430\u0448\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0430 \u0434\u0430\u043d\u043d\u044b\u0439 \u043c\u043e\u043c\u0435\u043d\u0442 \u0435\u0449\u0435 \u043d\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442\u0441\u044f.", + "unable_to_pair": "\u041d\u0435\u0432\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0435\u0449\u0435 \u0440\u0430\u0437.", + "unknown_error": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0441\u043e\u043e\u0431\u0449\u0438\u043b\u043e \u043e \u043d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u043e\u0439 \u043e\u0448\u0438\u0431\u043a\u0435. \u0421\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435 \u043d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c." + }, + "flow_title": "\u0410\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440 HomeKit: {name}", + "step": { + "pair": { + "data": { + "pairing_code": "\u041a\u043e\u0434 \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043a\u043e\u0434 \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f HomeKit (\u0432 \u0444\u043e\u0440\u043c\u0430\u0442\u0435 XXX-XX-XXX), \u0447\u0442\u043e\u0431\u044b \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c \u044d\u0442\u043e\u0442 \u0430\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440.", + "title": "\u0421\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435 \u0441 \u0430\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440\u043e\u043c HomeKit" + }, + "user": { + "data": { + "device": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e" + }, + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e, \u0441 \u043a\u043e\u0442\u043e\u0440\u044b\u043c \u043d\u0443\u0436\u043d\u043e \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435.", + "title": "\u0421\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435 \u0441 \u0430\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440\u043e\u043c HomeKit" + } + }, + "title": "\u0410\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440 HomeKit" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/sl.json b/homeassistant/components/homekit_controller/.translations/sl.json new file mode 100644 index 000000000..2af8a2a7a --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/sl.json @@ -0,0 +1,40 @@ +{ + "config": { + "abort": { + "accessory_not_found_error": "Seznanjanja ni mogo\u010de dodati, ker naprave ni ve\u010d mogo\u010de najti.", + "already_configured": "Dodatna oprema je \u017ee konfigurirana s tem krmilnikom.", + "already_in_progress": "Konfiguracijski tok za to napravo je \u017ee v teku.", + "already_paired": "Ta dodatna oprema je \u017ee povezana z drugo napravo. Ponastavite dodatno opremo in poskusite znova.", + "ignored_model": "Podpora za HomeKit za ta model je blokirana, saj je na voljo ve\u010d funkcij popolne nativne integracije.", + "invalid_config_entry": "Ta naprava se prikazuje kot pripravljena za povezavo, vendar je konflikt v nastavitvah Home Assistant, ki ga je treba najprej odstraniti.", + "no_devices": "Ni bilo mogo\u010de najti neuparjenih naprav" + }, + "error": { + "authentication_error": "Nepravilna koda HomeKit. Preverite in poskusite znova.", + "busy_error": "Naprava je zavrnila seznanjanje, saj se \u017ee povezuje z drugim krmilnikom.", + "max_peers_error": "Naprava je zavrnila seznanjanje, saj nima prostega pomnilnika za seznanjanje.", + "max_tries_error": "Napravaje zavrnila seznanjanje, saj je prejela ve\u010d kot 100 neuspe\u0161nih poskusov overjanja.", + "pairing_failed": "Pri poskusu seznanjanja s to napravo je pri\u0161lo do napake. To je lahko za\u010dasna napaka ali pa naprava trenutno ni podprta.", + "unable_to_pair": "Ni mogo\u010de seznaniti. Poskusite znova.", + "unknown_error": "Naprava je sporo\u010dila neznano napako. Seznanjanje ni uspelo." + }, + "flow_title": "HomeKit Oprema: {name}", + "step": { + "pair": { + "data": { + "pairing_code": "Koda za seznanjanje" + }, + "description": "\u010ce \u017eeli\u0161 uporabiti to dodatno opremo, vnesi HomeKit kodo.", + "title": "Seznanite s HomeKit Opremo" + }, + "user": { + "data": { + "device": "Naprava" + }, + "description": "Izberite napravo, s katero se \u017eelite seznaniti", + "title": "Seznanite s HomeKit Opremo" + } + }, + "title": "HomeKit oprema" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/sv.json b/homeassistant/components/homekit_controller/.translations/sv.json new file mode 100644 index 000000000..b4b721b7f --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/sv.json @@ -0,0 +1,40 @@ +{ + "config": { + "abort": { + "accessory_not_found_error": "Kan inte genomf\u00f6ra parningsf\u00f6rs\u00f6ket eftersom enheten inte l\u00e4ngre kan hittas.", + "already_configured": "Tillbeh\u00f6ret \u00e4r redan konfigurerat med denna kontroller.", + "already_in_progress": "Konfigurations fl\u00f6det f\u00f6r enheten p\u00e5g\u00e5r redan.", + "already_paired": "Det h\u00e4r tillbeh\u00f6ret \u00e4r redan kopplat till en annan enhet. \u00c5terst\u00e4ll tillbeh\u00f6ret och f\u00f6rs\u00f6k igen.", + "ignored_model": "HomeKit-st\u00f6d f\u00f6r den h\u00e4r modellen blockeras eftersom en mer komplett inbyggd integration \u00e4r tillg\u00e4nglig.", + "invalid_config_entry": "Den h\u00e4r enheten visas som redo att paras ihop, men det finns redan en motstridig konfigurations-post f\u00f6r den i Home Assistant som f\u00f6rst m\u00e5ste tas bort.", + "no_devices": "Inga oparade enheter kunde hittas" + }, + "error": { + "authentication_error": "Felaktig HomeKit-kod. V\u00e4nligen kontrollera och f\u00f6rs\u00f6k igen.", + "busy_error": "Enheten nekade parning d\u00e5 den redan \u00e4r parad med annan controller.", + "max_peers_error": "Enheten nekade parningsf\u00f6rs\u00f6ket d\u00e5 det inte finns n\u00e5got parningsminnesutrymme kvar", + "max_tries_error": "Enheten nekade parningen d\u00e5 den har emottagit mer \u00e4n 100 misslyckade autentiseringsf\u00f6rs\u00f6k", + "pairing_failed": "Ett ok\u00e4nt fel uppstod n\u00e4r parningsf\u00f6rs\u00f6ket gjordes med den h\u00e4r enheten. Det h\u00e4r kan vara ett tillf\u00e4lligt fel, eller s\u00e5 st\u00f6ds inte din enhet i nul\u00e4get.", + "unable_to_pair": "Det g\u00e5r inte att para ihop, f\u00f6rs\u00f6k igen.", + "unknown_error": "Enheten rapporterade ett ok\u00e4nt fel. Parning misslyckades." + }, + "flow_title": "HomeKit-tillbeh\u00f6r: {name}", + "step": { + "pair": { + "data": { + "pairing_code": "Parningskod" + }, + "description": "Ange din HomeKit-parningskod (i formatet XXX-XX-XXX) f\u00f6r att anv\u00e4nda det h\u00e4r tillbeh\u00f6ret", + "title": "Para HomeKit-tillbeh\u00f6r" + }, + "user": { + "data": { + "device": "Enhet" + }, + "description": "V\u00e4lj den enhet du vill para med", + "title": "Para HomeKit-tillbeh\u00f6r" + } + }, + "title": "HomeKit-tillbeh\u00f6r" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/th.json b/homeassistant/components/homekit_controller/.translations/th.json new file mode 100644 index 000000000..c0311b0f1 --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/th.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e40\u0e2a\u0e23\u0e34\u0e21\u0e19\u0e35\u0e49\u0e44\u0e14\u0e49\u0e23\u0e31\u0e1a\u0e01\u0e32\u0e23\u0e01\u0e33\u0e2b\u0e19\u0e14\u0e04\u0e48\u0e32\u0e14\u0e49\u0e27\u0e22\u0e15\u0e31\u0e27\u0e04\u0e27\u0e1a\u0e04\u0e38\u0e21\u0e19\u0e35\u0e49\u0e41\u0e25\u0e49\u0e27", + "already_paired": "\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e40\u0e2a\u0e23\u0e34\u0e21\u0e19\u0e35\u0e49\u0e08\u0e31\u0e1a\u0e04\u0e39\u0e48\u0e01\u0e31\u0e1a\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e2d\u0e37\u0e48\u0e19\u0e41\u0e25\u0e49\u0e27 \u0e42\u0e1b\u0e23\u0e14\u0e23\u0e35\u0e40\u0e0b\u0e47\u0e15\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e40\u0e2a\u0e23\u0e34\u0e21\u0e41\u0e25\u0e49\u0e27\u0e25\u0e2d\u0e07\u0e2d\u0e35\u0e01\u0e04\u0e23\u0e31\u0e49\u0e07", + "ignored_model": "\u0e01\u0e32\u0e23\u0e2a\u0e19\u0e31\u0e1a\u0e2a\u0e19\u0e38\u0e19\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c HomeKit \u0e23\u0e38\u0e48\u0e19\u0e19\u0e35\u0e49\u0e16\u0e39\u0e01\u0e1b\u0e34\u0e14\u0e01\u0e31\u0e49\u0e19\u0e44\u0e27\u0e49 \u0e41\u0e15\u0e48\u0e01\u0e47\u0e21\u0e35\u0e01\u0e32\u0e23\u0e17\u0e33\u0e07\u0e32\u0e19\u0e1a\u0e32\u0e07\u0e2d\u0e22\u0e48\u0e32\u0e07\u0e17\u0e35\u0e48\u0e43\u0e0a\u0e49\u0e07\u0e32\u0e19\u0e44\u0e14\u0e49", + "invalid_config_entry": "\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e19\u0e35\u0e49\u0e1a\u0e2d\u0e01\u0e27\u0e48\u0e32\u0e01\u0e33\u0e25\u0e31\u0e07\u0e1e\u0e23\u0e49\u0e2d\u0e21\u0e17\u0e35\u0e48\u0e08\u0e30\u0e08\u0e31\u0e1a\u0e04\u0e39\u0e48 \u0e41\u0e15\u0e48\u0e21\u0e31\u0e19\u0e21\u0e35\u0e01\u0e32\u0e23\u0e01\u0e33\u0e2b\u0e19\u0e14\u0e04\u0e48\u0e32\u0e17\u0e35\u0e48\u0e02\u0e31\u0e14\u0e41\u0e22\u0e49\u0e07\u0e01\u0e31\u0e19\u0e2d\u0e22\u0e39\u0e48 Home Assistant \u0e40\u0e25\u0e22\u0e17\u0e33\u0e01\u0e32\u0e23\u0e25\u0e1a\u0e17\u0e34\u0e49\u0e07", + "no_devices": "\u0e44\u0e21\u0e48\u0e1e\u0e1a\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e17\u0e35\u0e48\u0e08\u0e30\u0e43\u0e0a\u0e49\u0e08\u0e31\u0e1a\u0e04\u0e39\u0e48\u0e43\u0e14\u0e46 \u0e40\u0e25\u0e22" + }, + "error": { + "authentication_error": "\u0e23\u0e2b\u0e31\u0e2a\u0e01\u0e32\u0e23\u0e08\u0e31\u0e1a\u0e04\u0e39\u0e48 HomeKit \u0e44\u0e21\u0e48\u0e16\u0e39\u0e01\u0e15\u0e49\u0e2d\u0e07 \u0e01\u0e23\u0e38\u0e13\u0e32\u0e15\u0e23\u0e27\u0e08\u0e2a\u0e2d\u0e1a\u0e41\u0e25\u0e30\u0e25\u0e2d\u0e07\u0e2d\u0e35\u0e01\u0e04\u0e23\u0e31\u0e49\u0e07", + "unable_to_pair": "\u0e44\u0e21\u0e48\u0e2a\u0e32\u0e21\u0e32\u0e23\u0e16\u0e08\u0e31\u0e1a\u0e04\u0e39\u0e48\u0e44\u0e14\u0e49 \u0e42\u0e1b\u0e23\u0e14\u0e25\u0e2d\u0e07\u0e2d\u0e35\u0e01\u0e04\u0e23\u0e31\u0e49\u0e07", + "unknown_error": "\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e23\u0e32\u0e22\u0e07\u0e32\u0e19\u0e02\u0e49\u0e2d\u0e1c\u0e34\u0e14\u0e1e\u0e25\u0e32\u0e14\u0e17\u0e35\u0e48\u0e44\u0e21\u0e48\u0e23\u0e39\u0e49\u0e08\u0e31\u0e01 \u0e01\u0e32\u0e23\u0e08\u0e31\u0e1a\u0e04\u0e39\u0e48\u0e25\u0e49\u0e21\u0e40\u0e2b\u0e25\u0e27" + }, + "step": { + "pair": { + "data": { + "pairing_code": "\u0e23\u0e2b\u0e31\u0e2a\u0e01\u0e32\u0e23\u0e08\u0e31\u0e1a\u0e04\u0e39\u0e48" + }, + "description": "\u0e1b\u0e49\u0e2d\u0e19\u0e23\u0e2b\u0e31\u0e2a\u0e01\u0e32\u0e23\u0e08\u0e31\u0e1a\u0e04\u0e39\u0e48 HomeKit \u0e02\u0e2d\u0e07\u0e04\u0e38\u0e13\u0e40\u0e1e\u0e37\u0e48\u0e2d\u0e43\u0e0a\u0e49\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e40\u0e2a\u0e23\u0e34\u0e21\u0e19\u0e35\u0e49", + "title": "\u0e08\u0e31\u0e1a\u0e04\u0e39\u0e48\u0e01\u0e31\u0e1a\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e40\u0e2a\u0e23\u0e34\u0e21 HomeKit" + }, + "user": { + "data": { + "device": "\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c" + }, + "description": "\u0e40\u0e25\u0e37\u0e2d\u0e01\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e17\u0e35\u0e48\u0e04\u0e38\u0e13\u0e15\u0e49\u0e2d\u0e07\u0e01\u0e32\u0e23\u0e08\u0e30\u0e08\u0e31\u0e1a\u0e04\u0e39\u0e48", + "title": "\u0e08\u0e31\u0e1a\u0e04\u0e39\u0e48\u0e01\u0e31\u0e1a\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e40\u0e2a\u0e23\u0e34\u0e21 HomeKit" + } + }, + "title": "\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e40\u0e2a\u0e23\u0e34\u0e21 HomeKit" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/vi.json b/homeassistant/components/homekit_controller/.translations/vi.json new file mode 100644 index 000000000..cc16ebc70 --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/vi.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "pair": { + "data": { + "pairing_code": "M\u00e3 k\u1ebft n\u1ed1i" + }, + "title": "K\u1ebft n\u1ed1i v\u1edbi Ph\u1ee5 ki\u1ec7n HomeKit" + }, + "user": { + "data": { + "device": "Thi\u1ebft b\u1ecb" + }, + "description": "Ch\u1ecdn thi\u1ebft b\u1ecb b\u1ea1n mu\u1ed1n k\u1ebft n\u1ed1i", + "title": "K\u1ebft n\u1ed1i v\u1edbi Ph\u1ee5 ki\u1ec7n HomeKit" + } + }, + "title": "Ph\u1ee5 ki\u1ec7n HomeKit" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/zh-Hans.json b/homeassistant/components/homekit_controller/.translations/zh-Hans.json new file mode 100644 index 000000000..d9fdc8f91 --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/zh-Hans.json @@ -0,0 +1,39 @@ +{ + "config": { + "abort": { + "accessory_not_found_error": "\u65e0\u6cd5\u6dfb\u52a0\u914d\u5bf9\uff0c\u56e0\u4e3a\u65e0\u6cd5\u518d\u627e\u5230\u8bbe\u5907\u3002", + "already_configured": "\u914d\u4ef6\u5df2\u901a\u8fc7\u6b64\u63a7\u5236\u5668\u914d\u7f6e\u5b8c\u6210\u3002", + "already_paired": "\u6b64\u914d\u4ef6\u5df2\u4e0e\u53e6\u4e00\u53f0\u8bbe\u5907\u914d\u5bf9\u3002\u8bf7\u91cd\u7f6e\u914d\u4ef6\uff0c\u7136\u540e\u91cd\u8bd5\u3002", + "ignored_model": "HomeKit \u5bf9\u6b64\u8bbe\u5907\u7684\u652f\u6301\u5df2\u88ab\u963b\u6b62\uff0c\u56e0\u4e3a\u6709\u529f\u80fd\u66f4\u5b8c\u6574\u7684\u539f\u751f\u96c6\u6210\u53ef\u4ee5\u4f7f\u7528\u3002", + "invalid_config_entry": "\u6b64\u8bbe\u5907\u5df2\u51c6\u5907\u597d\u914d\u5bf9\uff0c\u4f46\u662f Home Assistant \u4e2d\u5b58\u5728\u4e0e\u4e4b\u51b2\u7a81\u7684\u914d\u7f6e\uff0c\u5fc5\u987b\u5148\u5c06\u5176\u5220\u9664\u3002", + "no_devices": "\u6ca1\u6709\u627e\u5230\u672a\u914d\u5bf9\u7684\u8bbe\u5907" + }, + "error": { + "authentication_error": "HomeKit \u4ee3\u7801\u4e0d\u6b63\u786e\u3002\u8bf7\u68c0\u67e5\u540e\u91cd\u8bd5\u3002", + "busy_error": "\u8bbe\u5907\u62d2\u7edd\u914d\u5bf9\uff0c\u56e0\u4e3a\u5b83\u5df2\u7ecf\u4e0e\u53e6\u4e00\u4e2a\u63a7\u5236\u5668\u914d\u5bf9\u3002", + "max_peers_error": "\u8bbe\u5907\u62d2\u7edd\u914d\u5bf9\uff0c\u56e0\u4e3a\u5b83\u6ca1\u6709\u7a7a\u95f2\u7684\u914d\u5bf9\u5b58\u50a8\u7a7a\u95f4\u3002", + "max_tries_error": "\u8bbe\u5907\u62d2\u7edd\u914d\u5bf9\uff0c\u56e0\u4e3a\u5b83\u5df2\u6536\u5230\u8d85\u8fc7 100 \u6b21\u5931\u8d25\u7684\u8eab\u4efd\u8ba4\u8bc1\u3002", + "pairing_failed": "\u5c1d\u8bd5\u4e0e\u6b64\u8bbe\u5907\u914d\u5bf9\u65f6\u53d1\u751f\u672a\u5904\u7406\u7684\u9519\u8bef\u3002\u8fd9\u53ef\u80fd\u662f\u6682\u65f6\u6027\u6545\u969c\uff0c\u4e5f\u53ef\u80fd\u662f\u60a8\u7684\u8bbe\u5907\u76ee\u524d\u4e0d\u88ab\u652f\u6301\u3002", + "unable_to_pair": "\u65e0\u6cd5\u914d\u5bf9\uff0c\u8bf7\u518d\u8bd5\u4e00\u6b21\u3002", + "unknown_error": "\u8bbe\u5907\u62a5\u544a\u4e86\u672a\u77e5\u9519\u8bef\u3002\u914d\u5bf9\u5931\u8d25\u3002" + }, + "flow_title": "HomeKit \u914d\u4ef6: {name}", + "step": { + "pair": { + "data": { + "pairing_code": "\u914d\u5bf9\u4ee3\u7801" + }, + "description": "\u8f93\u5165\u60a8\u7684HomeKit\u914d\u5bf9\u4ee3\u7801\uff08\u683c\u5f0f\u4e3aXXX-XX-XXX\uff09\u4ee5\u4f7f\u7528\u6b64\u914d\u4ef6", + "title": "\u4e0e HomeKit \u914d\u4ef6\u914d\u5bf9" + }, + "user": { + "data": { + "device": "\u8bbe\u5907" + }, + "description": "\u9009\u62e9\u60a8\u8981\u914d\u5bf9\u7684\u8bbe\u5907", + "title": "\u4e0e HomeKit \u914d\u4ef6\u914d\u5bf9" + } + }, + "title": "HomeKit \u914d\u4ef6" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/zh-Hant.json b/homeassistant/components/homekit_controller/.translations/zh-Hant.json new file mode 100644 index 000000000..68e87e9ae --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/zh-Hant.json @@ -0,0 +1,40 @@ +{ + "config": { + "abort": { + "accessory_not_found_error": "\u627e\u4e0d\u5230\u8a2d\u5099\uff0c\u7121\u6cd5\u65b0\u589e\u914d\u5c0d\u3002", + "already_configured": "\u914d\u4ef6\u5df2\u7d93\u7531\u6b64\u63a7\u5236\u5668\u8a2d\u5b9a\u5b8c\u6210", + "already_in_progress": "\u8a2d\u5099\u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d\u3002", + "already_paired": "\u914d\u4ef6\u5df2\u7d93\u8207\u5176\u4ed6\u8a2d\u5099\u914d\u5c0d\uff0c\u8acb\u91cd\u7f6e\u914d\u4ef6\u5f8c\u518d\u8a66\u4e00\u6b21\u3002", + "ignored_model": "\u7531\u65bc\u6b64\u578b\u865f\u53ef\u539f\u751f\u652f\u63f4\u66f4\u5b8c\u6574\u529f\u80fd\uff0c\u56e0\u6b64 Homekit \u652f\u63f4\u5df2\u88ab\u7981\u6b62\u3002", + "invalid_config_entry": "\u8a2d\u5099\u986f\u793a\u7b49\u5f85\u9032\u884c\u914d\u5c0d\uff0c\u4f46 Home Assistant \u986f\u793a\u6709\u76f8\u885d\u7a81\u8a2d\u5b9a\u7269\u4ef6\u5fc5\u9808\u5148\u884c\u79fb\u9664\u3002", + "no_devices": "\u627e\u4e0d\u5230\u4efb\u4f55\u672a\u914d\u5c0d\u8a2d\u5099" + }, + "error": { + "authentication_error": "Homekit \u4ee3\u78bc\u932f\u8aa4\uff0c\u8acb\u78ba\u5b9a\u5f8c\u518d\u8a66\u4e00\u6b21\u3002", + "busy_error": "\u8a2d\u5099\u5df2\u7d93\u8207\u5176\u4ed6\u63a7\u5236\u5668\u914d\u5c0d\uff0c\u62d2\u7d55\u9032\u884c\u914d\u5c0d\u3002", + "max_peers_error": "\u8a2d\u5099\u5df2\u7121\u5269\u9918\u914d\u5c0d\u7a7a\u9593\uff0c\u62d2\u7d55\u9032\u884c\u914d\u5c0d\u3002", + "max_tries_error": "\u8a2d\u5099\u6536\u5230\u8d85\u904e 100 \u6b21\u672a\u6210\u529f\u8a8d\u8b49\u5f8c\uff0c\u62d2\u7d55\u9032\u884c\u914d\u5c0d\u3002", + "pairing_failed": "\u7576\u8a66\u5716\u8207\u8a2d\u5099\u914d\u5c0d\u6642\u767c\u751f\u7121\u6cd5\u8655\u7406\u932f\u8aa4\uff0c\u53ef\u80fd\u50c5\u70ba\u66ab\u6642\u5931\u6548\u3001\u6216\u8005\u8a2d\u5099\u76ee\u524d\u4e0d\u652f\u63f4\u3002", + "unable_to_pair": "\u7121\u6cd5\u914d\u5c0d\uff0c\u8acb\u518d\u8a66\u4e00\u6b21\u3002", + "unknown_error": "\u88dd\u7f6e\u56de\u5831\u672a\u77e5\u932f\u8aa4\u3002\u914d\u5c0d\u5931\u6557\u3002" + }, + "flow_title": "HomeKit \u914d\u4ef6\uff1a{name}", + "step": { + "pair": { + "data": { + "pairing_code": "\u8a2d\u5b9a\u4ee3\u78bc" + }, + "description": "\u8f38\u5165\u914d\u4ef6 Homekit \u8a2d\u5b9a\u4ee3\u78bc\uff08\u683c\u5f0f\uff1aXXX-XX-XXX\uff09\u4ee5\u4f7f\u7528\u6b64\u914d\u4ef6", + "title": "HomeKit \u914d\u4ef6\u914d\u5c0d" + }, + "user": { + "data": { + "device": "\u88dd\u7f6e" + }, + "description": "\u9078\u64c7\u6240\u8981\u65b0\u589e\u7684\u88dd\u7f6e", + "title": "HomeKit \u914d\u4ef6\u914d\u5c0d" + } + }, + "title": "HomeKit \u914d\u4ef6" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index 5e24fe823..444f64b6f 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -1,189 +1,25 @@ -""" -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 +"""Support for Homekit device discovery.""" import logging -import os -import uuid -from homeassistant.components.discovery import SERVICE_HOMEKIT -from homeassistant.helpers import discovery +import homekit +from homekit.model.characteristics import CharacteristicsTypes + +from homeassistant.core import callback +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['homekit==0.10'] - -DOMAIN = 'homekit_controller' -HOMEKIT_DIR = '.homekit' - -# Mapping from Homekit type to component. -HOMEKIT_ACCESSORY_DISPATCH = { - 'lightbulb': 'light', - 'outlet': 'switch', - 'thermostat': 'climate', -} - -HOMEKIT_IGNORE = [ - 'BSB002', - 'Home Assistant Bridge', - 'TRADFRI gateway' -] - -KNOWN_ACCESSORIES = "{}-accessories".format(DOMAIN) -KNOWN_DEVICES = "{}-devices".format(DOMAIN) +from .config_flow import normalize_hkid +from .connection import HKDevice, get_accessory_information +from .const import CONTROLLER, DOMAIN, ENTITY_MAP, KNOWN_DEVICES +from .storage import EntityMapStorage _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. - """ - # pylint: disable=protected-access - 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'}]) +def escape_characteristic_name(char_name): + """Escape any dash or dots in a characteristics name.""" + return char_name.replace("-", "_").replace(".", "_") class HomeKitEntity(Entity): @@ -191,74 +27,210 @@ class HomeKitEntity(Entity): 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._accessory = accessory + self._aid = devinfo["aid"] + self._iid = devinfo["iid"] self._features = 0 self._chars = {} + self.setup() - 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: + self._signals = [] + + async def async_added_to_hass(self): + """Entity added to hass.""" + self._signals.append( + self.hass.helpers.dispatcher.async_dispatcher_connect( + self._accessory.signal_state_updated, self.async_state_changed + ) + ) + + self._accessory.add_pollable_characteristics(self.pollable_characteristics) + + async def async_will_remove_from_hass(self): + """Prepare to be removed from hass.""" + self._accessory.remove_pollable_characteristics(self._aid) + + for signal_remove in self._signals: + signal_remove() + self._signals.clear() + + @property + def should_poll(self) -> bool: + """Return False. + + Data update is triggered from HKDevice. + """ + return False + + def setup(self): + """Configure an entity baed on its HomeKit characterstics metadata.""" + accessories = self._accessory.accessories + + get_uuid = CharacteristicsTypes.get_uuid + characteristic_types = [get_uuid(c) for c in self.get_characteristic_types()] + + self.pollable_characteristics = [] + self._chars = {} + self._char_names = {} + + for accessory in accessories: + if accessory["aid"] != self._aid: continue - for service in accessory['services']: - if service['iid'] != self._iid: + self._accessory_info = get_accessory_information(accessory) + for service in accessory["services"]: + if service["iid"] != self._iid: continue - self.update_characteristics(service['characteristics']) - break + for char in service["characteristics"]: + try: + uuid = CharacteristicsTypes.get_uuid(char["type"]) + except KeyError: + # If a KeyError is raised its a non-standard + # characteristic. We must ignore it in this case. + continue + if uuid not in characteristic_types: + continue + self._setup_characteristic(char) + + def _setup_characteristic(self, char): + """Configure an entity based on a HomeKit characteristics metadata.""" + # Build up a list of (aid, iid) tuples to poll on update() + self.pollable_characteristics.append((self._aid, char["iid"])) + + # Build a map of ctype -> iid + short_name = CharacteristicsTypes.get_short(char["type"]) + self._chars[short_name] = char["iid"] + self._char_names[char["iid"]] = short_name + + # Callback to allow entity to configure itself based on this + # characteristics metadata (valid values, value ranges, features, etc) + setup_fn_name = escape_characteristic_name(short_name) + setup_fn = getattr(self, f"_setup_{setup_fn_name}", None) + if not setup_fn: + return + setup_fn(char) + + @callback + def async_state_changed(self): + """Collect new data from bridge and update the entity state in hass.""" + accessory_state = self._accessory.current_state.get(self._aid, {}) + for iid, result in accessory_state.items(): + # No value so dont process this result + if "value" not in result: + continue + + # Unknown iid - this is probably for a sibling service that is part + # of the same physical accessory. Ignore it. + if iid not in self._char_names: + continue + + # Callback to update the entity with this characteristic value + char_name = escape_characteristic_name(self._char_names[iid]) + update_fn = getattr(self, f"_update_{char_name}", None) + if not update_fn: + continue + + update_fn(result["value"]) + + self.async_write_ha_state() @property def unique_id(self): """Return the ID of this device.""" - return self._address + serial = self._accessory_info["serial-number"] + return f"homekit-{serial}-{self._iid}" @property def name(self): """Return the name of the device if any.""" - return self._name + return self._accessory_info.get("name") - def update_characteristics(self, characteristics): - """Synchronise a HomeKit device state with Home Assistant.""" + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._accessory.available + + @property + def device_info(self): + """Return the device info.""" + accessory_serial = self._accessory_info["serial-number"] + + device_info = { + "identifiers": {(DOMAIN, "serial-number", accessory_serial)}, + "name": self._accessory_info["name"], + "manufacturer": self._accessory_info.get("manufacturer", ""), + "model": self._accessory_info.get("model", ""), + "sw_version": self._accessory_info.get("firmware.revision", ""), + } + + # Some devices only have a single accessory - we don't add a + # via_device otherwise it would be self referential. + bridge_serial = self._accessory.connection_info["serial-number"] + if accessory_serial != bridge_serial: + device_info["via_device"] = (DOMAIN, "serial-number", bridge_serial) + + return device_info + + def get_characteristic_types(self): + """Define the homekit characteristics the entity cares about.""" raise NotImplementedError - def put_characteristics(self, characteristics): - """Control a HomeKit device state from Home Assistant.""" - body = json.dumps({'characteristics': characteristics}) - self._securecon.put('/characteristics', body) +async def async_setup_entry(hass, entry): + """Set up a HomeKit connection on a config entry.""" + conn = HKDevice(hass, entry, entry.data) + hass.data[KNOWN_DEVICES][conn.unique_id] = conn -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#']) + # For backwards compat + if entry.unique_id is None: + hass.config_entries.async_update_entry( + entry, unique_id=normalize_hkid(conn.unique_id) + ) - if model in HOMEKIT_IGNORE: - return + if not await conn.async_setup(): + del hass.data[KNOWN_DEVICES][conn.unique_id] + raise ConfigEntryNotReady - # 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 + conn_info = conn.connection_info - _LOGGER.debug('Discovered unique device %s', hkid) - device = HKDevice(hass, host, port, model, hkid, config_num, config) - hass.data[KNOWN_DEVICES][hkid] = device + device_registry = await dr.async_get_registry(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={ + (DOMAIN, "serial-number", conn_info["serial-number"]), + (DOMAIN, "accessory-id", conn.unique_id), + }, + name=conn.name, + manufacturer=conn_info.get("manufacturer"), + model=conn_info.get("model"), + sw_version=conn_info.get("firmware.revision"), + ) - hass.data[KNOWN_ACCESSORIES] = {} - hass.data[KNOWN_DEVICES] = {} - discovery.listen(hass, SERVICE_HOMEKIT, discovery_dispatch) return True + + +async def async_setup(hass, config): + """Set up for Homekit devices.""" + map_storage = hass.data[ENTITY_MAP] = EntityMapStorage(hass) + await map_storage.async_initialize() + + hass.data[CONTROLLER] = homekit.Controller() + hass.data[KNOWN_DEVICES] = {} + + return True + + +async def async_unload_entry(hass, entry): + """Disconnect from HomeKit devices before unloading entry.""" + hkid = entry.data["AccessoryPairingID"] + + if hkid in hass.data[KNOWN_DEVICES]: + connection = hass.data[KNOWN_DEVICES][hkid] + await connection.async_unload() + + return True + + +async def async_remove_entry(hass, entry): + """Cleanup caches before removing config entry.""" + hkid = entry.data["AccessoryPairingID"] + hass.data[ENTITY_MAP].async_delete_map(hkid) diff --git a/homeassistant/components/homekit_controller/alarm_control_panel.py b/homeassistant/components/homekit_controller/alarm_control_panel.py new file mode 100644 index 000000000..8cdbe9b2f --- /dev/null +++ b/homeassistant/components/homekit_controller/alarm_control_panel.py @@ -0,0 +1,134 @@ +"""Support for Homekit Alarm Control Panel.""" +import logging + +from homekit.model.characteristics import CharacteristicsTypes + +from homeassistant.components.alarm_control_panel import AlarmControlPanel +from homeassistant.components.alarm_control_panel.const import ( + SUPPORT_ALARM_ARM_AWAY, + SUPPORT_ALARM_ARM_HOME, + SUPPORT_ALARM_ARM_NIGHT, +) +from homeassistant.const import ( + ATTR_BATTERY_LEVEL, + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_DISARMED, + STATE_ALARM_TRIGGERED, +) + +from . import KNOWN_DEVICES, HomeKitEntity + +ICON = "mdi:security" + +_LOGGER = logging.getLogger(__name__) + +CURRENT_STATE_MAP = { + 0: STATE_ALARM_ARMED_HOME, + 1: STATE_ALARM_ARMED_AWAY, + 2: STATE_ALARM_ARMED_NIGHT, + 3: STATE_ALARM_DISARMED, + 4: STATE_ALARM_TRIGGERED, +} + +TARGET_STATE_MAP = { + STATE_ALARM_ARMED_HOME: 0, + STATE_ALARM_ARMED_AWAY: 1, + STATE_ALARM_ARMED_NIGHT: 2, + STATE_ALARM_DISARMED: 3, +} + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Legacy set up platform.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Homekit alarm control panel.""" + hkid = config_entry.data["AccessoryPairingID"] + conn = hass.data[KNOWN_DEVICES][hkid] + + def async_add_service(aid, service): + if service["stype"] != "security-system": + return False + info = {"aid": aid, "iid": service["iid"]} + async_add_entities([HomeKitAlarmControlPanel(conn, info)], True) + return True + + conn.add_listener(async_add_service) + + +class HomeKitAlarmControlPanel(HomeKitEntity, AlarmControlPanel): + """Representation of a Homekit Alarm Control Panel.""" + + def __init__(self, *args): + """Initialise the Alarm Control Panel.""" + super().__init__(*args) + self._state = None + self._battery_level = None + + def get_characteristic_types(self): + """Define the homekit characteristics the entity cares about.""" + return [ + CharacteristicsTypes.SECURITY_SYSTEM_STATE_CURRENT, + CharacteristicsTypes.SECURITY_SYSTEM_STATE_TARGET, + CharacteristicsTypes.BATTERY_LEVEL, + ] + + def _update_security_system_state_current(self, value): + self._state = CURRENT_STATE_MAP[value] + + def _update_battery_level(self, value): + self._battery_level = value + + @property + def icon(self): + """Return icon.""" + return ICON + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @property + def supported_features(self) -> int: + """Return the list of supported features.""" + return SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY | SUPPORT_ALARM_ARM_NIGHT + + async def async_alarm_disarm(self, code=None): + """Send disarm command.""" + await self.set_alarm_state(STATE_ALARM_DISARMED, code) + + async def async_alarm_arm_away(self, code=None): + """Send arm command.""" + await self.set_alarm_state(STATE_ALARM_ARMED_AWAY, code) + + async def async_alarm_arm_home(self, code=None): + """Send stay command.""" + await self.set_alarm_state(STATE_ALARM_ARMED_HOME, code) + + async def async_alarm_arm_night(self, code=None): + """Send night command.""" + await self.set_alarm_state(STATE_ALARM_ARMED_NIGHT, code) + + async def set_alarm_state(self, state, code=None): + """Send state command.""" + characteristics = [ + { + "aid": self._aid, + "iid": self._chars["security-system-state.target"], + "value": TARGET_STATE_MAP[state], + } + ] + await self._accessory.put_characteristics(characteristics) + + @property + def device_state_attributes(self): + """Return the optional state attributes.""" + if self._battery_level is None: + return None + + return {ATTR_BATTERY_LEVEL: self._battery_level} diff --git a/homeassistant/components/homekit_controller/binary_sensor.py b/homeassistant/components/homekit_controller/binary_sensor.py new file mode 100644 index 000000000..1e1c8ef5d --- /dev/null +++ b/homeassistant/components/homekit_controller/binary_sensor.py @@ -0,0 +1,81 @@ +"""Support for Homekit motion sensors.""" +import logging + +from homekit.model.characteristics import CharacteristicsTypes + +from homeassistant.components.binary_sensor import BinarySensorDevice + +from . import KNOWN_DEVICES, HomeKitEntity + +_LOGGER = logging.getLogger(__name__) + + +class HomeKitMotionSensor(HomeKitEntity, BinarySensorDevice): + """Representation of a Homekit motion sensor.""" + + def __init__(self, *args): + """Initialise the entity.""" + super().__init__(*args) + self._on = False + + def get_characteristic_types(self): + """Define the homekit characteristics the entity is tracking.""" + return [CharacteristicsTypes.MOTION_DETECTED] + + def _update_motion_detected(self, value): + self._on = value + + @property + def device_class(self): + """Define this binary_sensor as a motion sensor.""" + return "motion" + + @property + def is_on(self): + """Has motion been detected.""" + return self._on + + +class HomeKitContactSensor(HomeKitEntity, BinarySensorDevice): + """Representation of a Homekit contact sensor.""" + + def __init__(self, *args): + """Initialise the entity.""" + super().__init__(*args) + self._state = None + + def get_characteristic_types(self): + """Define the homekit characteristics the entity is tracking.""" + return [CharacteristicsTypes.CONTACT_STATE] + + def _update_contact_state(self, value): + self._state = value + + @property + def is_on(self): + """Return true if the binary sensor is on/open.""" + return self._state == 1 + + +ENTITY_TYPES = {"motion": HomeKitMotionSensor, "contact": HomeKitContactSensor} + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Legacy set up platform.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Homekit lighting.""" + hkid = config_entry.data["AccessoryPairingID"] + conn = hass.data[KNOWN_DEVICES][hkid] + + def async_add_service(aid, service): + entity_class = ENTITY_TYPES.get(service["stype"]) + if not entity_class: + return False + info = {"aid": aid, "iid": service["iid"]} + async_add_entities([entity_class(conn, info)], True) + return True + + conn.add_listener(async_add_service) diff --git a/homeassistant/components/homekit_controller/climate.py b/homeassistant/components/homekit_controller/climate.py new file mode 100644 index 000000000..d0ab7bd2e --- /dev/null +++ b/homeassistant/components/homekit_controller/climate.py @@ -0,0 +1,259 @@ +"""Support for Homekit climate devices.""" +import logging + +from homekit.model.characteristics import CharacteristicsTypes + +from homeassistant.components.climate import ( + DEFAULT_MAX_HUMIDITY, + DEFAULT_MIN_HUMIDITY, + ClimateDevice, +) +from homeassistant.components.climate.const import ( + CURRENT_HVAC_COOL, + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, + HVAC_MODE_COOL, + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_OFF, + SUPPORT_TARGET_HUMIDITY, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS + +from . import KNOWN_DEVICES, HomeKitEntity + +_LOGGER = logging.getLogger(__name__) + +# Map of Homekit operation modes to hass modes +MODE_HOMEKIT_TO_HASS = { + 0: HVAC_MODE_OFF, + 1: HVAC_MODE_HEAT, + 2: HVAC_MODE_COOL, + 3: HVAC_MODE_HEAT_COOL, +} + +# Map of hass operation modes to homekit modes +MODE_HASS_TO_HOMEKIT = {v: k for k, v in MODE_HOMEKIT_TO_HASS.items()} + +DEFAULT_VALID_MODES = list(MODE_HOMEKIT_TO_HASS) + +CURRENT_MODE_HOMEKIT_TO_HASS = { + 0: CURRENT_HVAC_IDLE, + 1: CURRENT_HVAC_HEAT, + 2: CURRENT_HVAC_COOL, +} + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Legacy set up platform.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Homekit climate.""" + hkid = config_entry.data["AccessoryPairingID"] + conn = hass.data[KNOWN_DEVICES][hkid] + + def async_add_service(aid, service): + if service["stype"] != "thermostat": + return False + info = {"aid": aid, "iid": service["iid"]} + async_add_entities([HomeKitClimateDevice(conn, info)], True) + return True + + conn.add_listener(async_add_service) + + +class HomeKitClimateDevice(HomeKitEntity, ClimateDevice): + """Representation of a Homekit climate device.""" + + def __init__(self, *args): + """Initialise the device.""" + self._state = None + self._target_mode = None + self._current_mode = None + self._valid_modes = [] + self._current_temp = None + self._target_temp = None + self._current_humidity = None + self._target_humidity = None + self._min_target_temp = None + self._max_target_temp = None + self._min_target_humidity = DEFAULT_MIN_HUMIDITY + self._max_target_humidity = DEFAULT_MAX_HUMIDITY + super().__init__(*args) + + def get_characteristic_types(self): + """Define the homekit characteristics the entity cares about.""" + return [ + CharacteristicsTypes.HEATING_COOLING_CURRENT, + CharacteristicsTypes.HEATING_COOLING_TARGET, + CharacteristicsTypes.TEMPERATURE_CURRENT, + CharacteristicsTypes.TEMPERATURE_TARGET, + CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT, + CharacteristicsTypes.RELATIVE_HUMIDITY_TARGET, + ] + + def _setup_heating_cooling_target(self, characteristic): + if "valid-values" in characteristic: + valid_values = [ + val + for val in DEFAULT_VALID_MODES + if val in characteristic["valid-values"] + ] + else: + valid_values = DEFAULT_VALID_MODES + if "minValue" in characteristic: + valid_values = [ + val for val in valid_values if val >= characteristic["minValue"] + ] + if "maxValue" in characteristic: + valid_values = [ + val for val in valid_values if val <= characteristic["maxValue"] + ] + + self._valid_modes = [MODE_HOMEKIT_TO_HASS[mode] for mode in valid_values] + + def _setup_temperature_target(self, characteristic): + self._features |= SUPPORT_TARGET_TEMPERATURE + + if "minValue" in characteristic: + self._min_target_temp = characteristic["minValue"] + + if "maxValue" in characteristic: + self._max_target_temp = characteristic["maxValue"] + + def _setup_relative_humidity_target(self, characteristic): + self._features |= SUPPORT_TARGET_HUMIDITY + + if "minValue" in characteristic: + self._min_target_humidity = characteristic["minValue"] + + if "maxValue" in characteristic: + self._max_target_humidity = characteristic["maxValue"] + + def _update_heating_cooling_current(self, value): + # This characteristic describes the current mode of a device, + # e.g. a thermostat is "heating" a room to 75 degrees Fahrenheit. + # Can be 0 - 2 (Off, Heat, Cool) + self._current_mode = CURRENT_MODE_HOMEKIT_TO_HASS.get(value) + + def _update_heating_cooling_target(self, value): + # This characteristic describes the target mode + # E.g. should the device start heating a room if the temperature + # falls below the target temperature. + # Can be 0 - 3 (Off, Heat, Cool, Auto) + self._target_mode = MODE_HOMEKIT_TO_HASS.get(value) + + def _update_temperature_current(self, value): + self._current_temp = value + + def _update_temperature_target(self, value): + self._target_temp = value + + def _update_relative_humidity_current(self, value): + self._current_humidity = value + + def _update_relative_humidity_target(self, value): + self._target_humidity = value + + async def async_set_temperature(self, **kwargs): + """Set new target temperature.""" + temp = kwargs.get(ATTR_TEMPERATURE) + + characteristics = [ + {"aid": self._aid, "iid": self._chars["temperature.target"], "value": temp} + ] + await self._accessory.put_characteristics(characteristics) + + async def async_set_humidity(self, humidity): + """Set new target humidity.""" + characteristics = [ + { + "aid": self._aid, + "iid": self._chars["relative-humidity.target"], + "value": humidity, + } + ] + await self._accessory.put_characteristics(characteristics) + + async def async_set_hvac_mode(self, hvac_mode): + """Set new target operation mode.""" + characteristics = [ + { + "aid": self._aid, + "iid": self._chars["heating-cooling.target"], + "value": MODE_HASS_TO_HOMEKIT[hvac_mode], + } + ] + await self._accessory.put_characteristics(characteristics) + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._current_temp + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._target_temp + + @property + def min_temp(self): + """Return the minimum target temp.""" + if self._max_target_temp: + return self._min_target_temp + return super().min_temp + + @property + def max_temp(self): + """Return the maximum target temp.""" + if self._max_target_temp: + return self._max_target_temp + return super().max_temp + + @property + def current_humidity(self): + """Return the current humidity.""" + return self._current_humidity + + @property + def target_humidity(self): + """Return the humidity we try to reach.""" + return self._target_humidity + + @property + def min_humidity(self): + """Return the minimum humidity.""" + return self._min_target_humidity + + @property + def max_humidity(self): + """Return the maximum humidity.""" + return self._max_target_humidity + + @property + def hvac_action(self): + """Return the current running hvac operation.""" + return self._current_mode + + @property + def hvac_mode(self): + """Return hvac operation ie. heat, cool mode.""" + return self._target_mode + + @property + def hvac_modes(self): + """Return the list of available hvac operation modes.""" + return self._valid_modes + + @property + def supported_features(self): + """Return the list of supported features.""" + return self._features + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS diff --git a/homeassistant/components/homekit_controller/config_flow.py b/homeassistant/components/homekit_controller/config_flow.py new file mode 100644 index 000000000..3f230d923 --- /dev/null +++ b/homeassistant/components/homekit_controller/config_flow.py @@ -0,0 +1,363 @@ +"""Config flow to configure homekit_controller.""" +import json +import logging +import os + +import homekit +from homekit.controller.ip_implementation import IpPairing +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.core import callback + +from .connection import get_accessory_name, get_bridge_information +from .const import DOMAIN, KNOWN_DEVICES + +HOMEKIT_IGNORE = ["Home Assistant Bridge"] +HOMEKIT_DIR = ".homekit" +PAIRING_FILE = "pairing.json" + +_LOGGER = logging.getLogger(__name__) + + +def load_old_pairings(hass): + """Load any old pairings from on-disk json fragments.""" + old_pairings = {} + + data_dir = os.path.join(hass.config.path(), HOMEKIT_DIR) + pairing_file = os.path.join(data_dir, PAIRING_FILE) + + # Find any pairings created with in HA 0.85 / 0.86 + if os.path.exists(pairing_file): + with open(pairing_file) as pairing_file: + old_pairings.update(json.load(pairing_file)) + + # Find any pairings created in HA <= 0.84 + if os.path.exists(data_dir): + for device in os.listdir(data_dir): + if not device.startswith("hk-"): + continue + alias = device[3:] + if alias in old_pairings: + continue + with open(os.path.join(data_dir, device)) as pairing_data_fp: + old_pairings[alias] = json.load(pairing_data_fp) + + return old_pairings + + +def normalize_hkid(hkid): + """Normalize a hkid so that it is safe to compare with other normalized hkids.""" + return hkid.lower() + + +@callback +def find_existing_host(hass, serial): + """Return a set of the configured hosts.""" + for entry in hass.config_entries.async_entries(DOMAIN): + if entry.data["AccessoryPairingID"] == serial: + return entry + + +@config_entries.HANDLERS.register(DOMAIN) +class HomekitControllerFlowHandler(config_entries.ConfigFlow): + """Handle a HomeKit config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + def __init__(self): + """Initialize the homekit_controller flow.""" + self.model = None + self.hkid = None + self.devices = {} + self.controller = homekit.Controller() + self.finish_pairing = None + + async def async_step_user(self, user_input=None): + """Handle a flow start.""" + errors = {} + + if user_input is not None: + key = user_input["device"] + self.hkid = self.devices[key]["id"] + self.model = self.devices[key]["md"] + await self.async_set_unique_id( + normalize_hkid(self.hkid), raise_on_progress=False + ) + return await self.async_step_pair() + + all_hosts = await self.hass.async_add_executor_job(self.controller.discover, 5) + + self.devices = {} + for host in all_hosts: + status_flags = int(host["sf"]) + paired = not status_flags & 0x01 + if paired: + continue + self.devices[host["name"]] = host + + if not self.devices: + return self.async_abort(reason="no_devices") + + return self.async_show_form( + step_id="user", + errors=errors, + data_schema=vol.Schema( + {vol.Required("device"): vol.In(self.devices.keys())} + ), + ) + + async def async_step_unignore(self, user_input): + """Rediscover a previously ignored discover.""" + unique_id = user_input["unique_id"] + await self.async_set_unique_id(unique_id) + + records = await self.hass.async_add_executor_job(self.controller.discover, 5) + for record in records: + if normalize_hkid(record["id"]) != unique_id: + continue + return await self.async_step_zeroconf( + { + "host": record["address"], + "port": record["port"], + "hostname": record["name"], + "type": "_hap._tcp.local.", + "name": record["name"], + "properties": { + "md": record["md"], + "pv": record["pv"], + "id": unique_id, + "c#": record["c#"], + "s#": record["s#"], + "ff": record["ff"], + "ci": record["ci"], + "sf": record["sf"], + "sh": "", + }, + } + ) + + return self.async_abort(reason="no_devices") + + async def async_step_zeroconf(self, discovery_info): + """Handle a discovered HomeKit accessory. + + This flow is triggered by the discovery component. + """ + # Normalize properties from discovery + # homekit_python has code to do this, but not in a form we can + # easily use, so do the bare minimum ourselves here instead. + properties = { + key.lower(): value for (key, value) in discovery_info["properties"].items() + } + + # The hkid is a unique random number that looks like a pairing code. + # It changes if a device is factory reset. + hkid = properties["id"] + model = properties["md"] + name = discovery_info["name"].replace("._hap._tcp.local.", "") + status_flags = int(properties["sf"]) + paired = not status_flags & 0x01 + + # The configuration number increases every time the characteristic map + # needs updating. Some devices use a slightly off-spec name so handle + # both cases. + try: + config_num = int(properties["c#"]) + except KeyError: + _LOGGER.warning( + "HomeKit device %s: c# not exposed, in violation of spec", hkid + ) + config_num = None + + # If the device is already paired and known to us we should monitor c# + # (config_num) for changes. If it changes, we check for new entities + if paired and hkid in self.hass.data.get(KNOWN_DEVICES, {}): + conn = self.hass.data[KNOWN_DEVICES][hkid] + if conn.config_num != config_num: + _LOGGER.debug( + "HomeKit info %s: c# incremented, refreshing entities", hkid + ) + self.hass.async_create_task(conn.async_refresh_entity_map(config_num)) + return self.async_abort(reason="already_configured") + + _LOGGER.debug("Discovered device %s (%s - %s)", name, model, hkid) + + await self.async_set_unique_id(normalize_hkid(hkid)) + self._abort_if_unique_id_configured() + + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + self.context["hkid"] = hkid + self.context["title_placeholders"] = {"name": name} + + if paired: + old_pairings = await self.hass.async_add_executor_job( + load_old_pairings, self.hass + ) + + if hkid in old_pairings: + return await self.async_import_legacy_pairing( + properties, old_pairings[hkid] + ) + + # Device is paired but not to us - ignore it + _LOGGER.debug("HomeKit device %s ignored as already paired", hkid) + return self.async_abort(reason="already_paired") + + # Devices in HOMEKIT_IGNORE have native local integrations - users + # should be encouraged to use native integration and not confused + # by alternative HK API. + if model in HOMEKIT_IGNORE: + return self.async_abort(reason="ignored_model") + + # Device isn't paired with us or anyone else. + # But we have a 'complete' config entry for it - that is probably + # invalid. Remove it automatically. + existing = find_existing_host(self.hass, hkid) + if existing: + await self.hass.config_entries.async_remove(existing.entry_id) + + self.model = model + self.hkid = hkid + + # We want to show the pairing form - but don't call async_step_pair + # directly as it has side effects (will ask the device to show a + # pairing code) + return self._async_step_pair_show_form() + + async def async_import_legacy_pairing(self, discovery_props, pairing_data): + """Migrate a legacy pairing to config entries.""" + + hkid = discovery_props["id"] + + existing = find_existing_host(self.hass, hkid) + if existing: + _LOGGER.info( + ( + "Legacy configuration for homekit accessory %s" + "not loaded as already migrated" + ), + hkid, + ) + return self.async_abort(reason="already_configured") + + _LOGGER.info( + ( + "Legacy configuration %s for homekit" + "accessory migrated to config entries" + ), + hkid, + ) + + pairing = IpPairing(pairing_data) + + return await self._entry_from_accessory(pairing) + + async def async_step_pair(self, pair_info=None): + """Pair with a new HomeKit accessory.""" + # If async_step_pair is called with no pairing code then we do the M1 + # phase of pairing. If this is successful the device enters pairing + # mode. + + # If it doesn't have a screen then the pin is static. + + # If it has a display it will display a pin on that display. In + # this case the code is random. So we have to call the start_pairing + # API before the user can enter a pin. But equally we don't want to + # call start_pairing when the device is discovered, only when they + # click on 'Configure' in the UI. + + # start_pairing will make the device show its pin and return a + # callable. We call the callable with the pin that the user has typed + # in. + + errors = {} + + if pair_info: + code = pair_info["pairing_code"] + try: + await self.hass.async_add_executor_job(self.finish_pairing, code) + + pairing = self.controller.pairings.get(self.hkid) + if pairing: + return await self._entry_from_accessory(pairing) + + errors["pairing_code"] = "unable_to_pair" + except homekit.AuthenticationError: + # PairSetup M4 - SRP proof failed + # PairSetup M6 - Ed25519 signature verification failed + # PairVerify M4 - Decryption failed + # PairVerify M4 - Device not recognised + # PairVerify M4 - Ed25519 signature verification failed + errors["pairing_code"] = "authentication_error" + except homekit.UnknownError: + # An error occurred on the device whilst performing this + # operation. + errors["pairing_code"] = "unknown_error" + except homekit.MaxPeersError: + # The device can't pair with any more accessories. + errors["pairing_code"] = "max_peers_error" + except homekit.AccessoryNotFoundError: + # Can no longer find the device on the network + return self.async_abort(reason="accessory_not_found_error") + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Pairing attempt failed with an unhandled exception") + errors["pairing_code"] = "pairing_failed" + + start_pairing = self.controller.start_pairing + try: + self.finish_pairing = await self.hass.async_add_executor_job( + start_pairing, self.hkid, self.hkid + ) + except homekit.BusyError: + # Already performing a pair setup operation with a different + # controller + errors["pairing_code"] = "busy_error" + except homekit.MaxTriesError: + # The accessory has received more than 100 unsuccessful auth + # attempts. + errors["pairing_code"] = "max_tries_error" + except homekit.UnavailableError: + # The accessory is already paired - cannot try to pair again. + return self.async_abort(reason="already_paired") + except homekit.AccessoryNotFoundError: + # Can no longer find the device on the network + return self.async_abort(reason="accessory_not_found_error") + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Pairing attempt failed with an unhandled exception") + errors["pairing_code"] = "pairing_failed" + + return self._async_step_pair_show_form(errors) + + def _async_step_pair_show_form(self, errors=None): + return self.async_show_form( + step_id="pair", + errors=errors or {}, + data_schema=vol.Schema( + {vol.Required("pairing_code"): vol.All(str, vol.Strip)} + ), + ) + + async def _entry_from_accessory(self, pairing): + """Return a config entry from an initialized bridge.""" + # The bulk of the pairing record is stored on the config entry. + # A specific exception is the 'accessories' key. This is more + # volatile. We do cache it, but not against the config entry. + # So copy the pairing data and mutate the copy. + pairing_data = pairing.pairing_data.copy() + + # Use the accessories data from the pairing operation if it is + # available. Otherwise request a fresh copy from the API. + # This removes the 'accessories' key from pairing_data at + # the same time. + accessories = pairing_data.pop("accessories", None) + if not accessories: + accessories = await self.hass.async_add_executor_job( + pairing.list_accessories_and_characteristics + ) + + bridge_info = get_bridge_information(accessories) + name = get_accessory_name(bridge_info) + + return self.async_create_entry(title=name, data=pairing_data) diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py new file mode 100644 index 000000000..3ccfa8b01 --- /dev/null +++ b/homeassistant/components/homekit_controller/connection.py @@ -0,0 +1,354 @@ +"""Helpers for managing a pairing with a HomeKit accessory or bridge.""" +import asyncio +import datetime +import logging + +from homekit.controller.ip_implementation import IpPairing +from homekit.exceptions import ( + AccessoryDisconnectedError, + AccessoryNotFoundError, + EncryptionError, +) +from homekit.model.characteristics import CharacteristicsTypes +from homekit.model.services import ServicesTypes + +from homeassistant.helpers.event import async_track_time_interval + +from .const import DOMAIN, ENTITY_MAP, HOMEKIT_ACCESSORY_DISPATCH + +DEFAULT_SCAN_INTERVAL = datetime.timedelta(seconds=60) +RETRY_INTERVAL = 60 # seconds + +_LOGGER = logging.getLogger(__name__) + + +def get_accessory_information(accessory): + """Obtain the accessory information service of a HomeKit device.""" + result = {} + for service in accessory["services"]: + stype = service["type"].upper() + if ServicesTypes.get_short(stype) != "accessory-information": + continue + for characteristic in service["characteristics"]: + ctype = CharacteristicsTypes.get_short(characteristic["type"]) + if "value" in characteristic: + result[ctype] = characteristic["value"] + return result + + +def get_bridge_information(accessories): + """Return the accessory info for the bridge.""" + for accessory in accessories: + if accessory["aid"] == 1: + return get_accessory_information(accessory) + return get_accessory_information(accessories[0]) + + +def get_accessory_name(accessory_info): + """Return the name field of an accessory.""" + for field in ("name", "model", "manufacturer"): + if field in accessory_info: + return accessory_info[field] + return None + + +class HKDevice: + """HomeKit device.""" + + def __init__(self, hass, config_entry, pairing_data): + """Initialise a generic HomeKit device.""" + + self.hass = hass + self.config_entry = config_entry + + # We copy pairing_data because homekit_python may mutate it, but we + # don't want to mutate a dict owned by a config entry. + self.pairing_data = pairing_data.copy() + + self.pairing = IpPairing(self.pairing_data) + + self.accessories = {} + self.config_num = 0 + + # A list of callbacks that turn HK service metadata into entities + self.listeners = [] + + # The platorms we have forwarded the config entry so far. If a new + # accessory is added to a bridge we may have to load additional + # platforms. We don't want to load all platforms up front if its just + # a lightbulb. And we dont want to forward a config entry twice + # (triggers a Config entry already set up error) + self.platforms = set() + + # This just tracks aid/iid pairs so we know if a HK service has been + # mapped to a HA entity. + self.entities = [] + + # There are multiple entities sharing a single connection - only + # allow one entity to use pairing at once. + self.pairing_lock = asyncio.Lock() + + self.available = True + + self.signal_state_updated = "_".join((DOMAIN, self.unique_id, "state_updated")) + + # Current values of all characteristics homekit_controller is tracking. + # Key is a (accessory_id, characteristic_id) tuple. + self.current_state = {} + + self.pollable_characteristics = [] + + # If this is set polling is active and can be disabled by calling + # this method. + self._polling_interval_remover = None + + # Never allow concurrent polling of the same accessory or bridge + self._polling_lock = asyncio.Lock() + self._polling_lock_warned = False + + def add_pollable_characteristics(self, characteristics): + """Add (aid, iid) pairs that we need to poll.""" + self.pollable_characteristics.extend(characteristics) + + def remove_pollable_characteristics(self, accessory_id): + """Remove all pollable characteristics by accessory id.""" + self.pollable_characteristics = [ + char for char in self.pollable_characteristics if char[0] != accessory_id + ] + + def async_set_unavailable(self): + """Mark state of all entities on this connection as unavailable.""" + self.available = False + self.hass.helpers.dispatcher.async_dispatcher_send(self.signal_state_updated) + + async def async_setup(self): + """Prepare to use a paired HomeKit device in homeassistant.""" + cache = self.hass.data[ENTITY_MAP].get_map(self.unique_id) + if not cache: + if await self.async_refresh_entity_map(self.config_num): + self._polling_interval_remover = async_track_time_interval( + self.hass, self.async_update, DEFAULT_SCAN_INTERVAL + ) + return True + return False + + self.accessories = cache["accessories"] + self.config_num = cache["config_num"] + + # Ensure the Pairing object has access to the latest version of the + # entity map. + self.pairing.pairing_data["accessories"] = self.accessories + + self.async_load_platforms() + + self.add_entities() + + await self.async_update() + + self._polling_interval_remover = async_track_time_interval( + self.hass, self.async_update, DEFAULT_SCAN_INTERVAL + ) + + return True + + async def async_unload(self): + """Stop interacting with device and prepare for removal from hass.""" + if self._polling_interval_remover: + self._polling_interval_remover() + + unloads = [] + for platform in self.platforms: + unloads.append( + self.hass.config_entries.async_forward_entry_unload( + self.config_entry, platform + ) + ) + + results = await asyncio.gather(*unloads) + + return False not in results + + async def async_refresh_entity_map(self, config_num): + """Handle setup of a HomeKit accessory.""" + try: + async with self.pairing_lock: + self.accessories = await self.hass.async_add_executor_job( + self.pairing.list_accessories_and_characteristics + ) + except AccessoryDisconnectedError: + # If we fail to refresh this data then we will naturally retry + # later when Bonjour spots c# is still not up to date. + return + + self.hass.data[ENTITY_MAP].async_create_or_update_map( + self.unique_id, config_num, self.accessories + ) + + self.config_num = config_num + + # For BLE, the Pairing instance relies on the entity map to map + # aid/iid to GATT characteristics. So push it to there as well. + self.pairing.pairing_data["accessories"] = self.accessories + + self.async_load_platforms() + + # Register and add new entities that are available + self.add_entities() + + await self.async_update() + + return True + + def add_listener(self, add_entities_cb): + """Add a callback to run when discovering new entities.""" + self.listeners.append(add_entities_cb) + self._add_new_entities([add_entities_cb]) + + def add_entities(self): + """Process the entity map and create HA entities.""" + self._add_new_entities(self.listeners) + + def _add_new_entities(self, callbacks): + for accessory in self.accessories: + aid = accessory["aid"] + for service in accessory["services"]: + iid = service["iid"] + stype = ServicesTypes.get_short(service["type"].upper()) + service["stype"] = stype + + if (aid, iid) in self.entities: + # Don't add the same entity again + continue + + for listener in callbacks: + if listener(aid, service): + self.entities.append((aid, iid)) + break + + def async_load_platforms(self): + """Load any platforms needed by this HomeKit device.""" + for accessory in self.accessories: + for service in accessory["services"]: + stype = ServicesTypes.get_short(service["type"].upper()) + if stype not in HOMEKIT_ACCESSORY_DISPATCH: + continue + + platform = HOMEKIT_ACCESSORY_DISPATCH[stype] + if platform in self.platforms: + continue + + self.hass.async_create_task( + self.hass.config_entries.async_forward_entry_setup( + self.config_entry, platform + ) + ) + self.platforms.add(platform) + + async def async_update(self, now=None): + """Poll state of all entities attached to this bridge/accessory.""" + if not self.pollable_characteristics: + _LOGGER.debug("HomeKit connection not polling any characteristics.") + return + + if self._polling_lock.locked(): + if not self._polling_lock_warned: + _LOGGER.warning( + "HomeKit controller update skipped as previous poll still in flight" + ) + self._polling_lock_warned = True + return + + if self._polling_lock_warned: + _LOGGER.info( + "HomeKit controller no longer detecting back pressure - not skipping poll" + ) + self._polling_lock_warned = False + + async with self._polling_lock: + _LOGGER.debug("Starting HomeKit controller update") + + try: + new_values_dict = await self.get_characteristics( + self.pollable_characteristics + ) + except AccessoryNotFoundError: + # Not only did the connection fail, but also the accessory is not + # visible on the network. + self.async_set_unavailable() + return + except (AccessoryDisconnectedError, EncryptionError): + # Temporary connection failure. Device is still available but our + # connection was dropped. + return + + self.process_new_events(new_values_dict) + + _LOGGER.debug("Finished HomeKit controller update") + + def process_new_events(self, new_values_dict): + """Process events from accessory into HA state.""" + self.available = True + + for (aid, cid), value in new_values_dict.items(): + accessory = self.current_state.setdefault(aid, {}) + accessory[cid] = value + + self.hass.helpers.dispatcher.async_dispatcher_send(self.signal_state_updated) + + async def get_characteristics(self, *args, **kwargs): + """Read latest state from homekit accessory.""" + async with self.pairing_lock: + chars = await self.hass.async_add_executor_job( + self.pairing.get_characteristics, *args, **kwargs + ) + return chars + + async def put_characteristics(self, characteristics): + """Control a HomeKit device state from Home Assistant.""" + chars = [] + for row in characteristics: + chars.append((row["aid"], row["iid"], row["value"])) + + async with self.pairing_lock: + results = await self.hass.async_add_executor_job( + self.pairing.put_characteristics, chars + ) + + # Feed characteristics back into HA and update the current state + # results will only contain failures, so anythin in characteristics + # but not in results was applied successfully - we can just have HA + # reflect the change immediately. + + new_entity_state = {} + for row in characteristics: + key = (row["aid"], row["iid"]) + + # If the key was returned by put_characteristics() then the + # change didnt work + if key in results: + continue + + # Otherwise it was accepted and we can apply the change to + # our state + new_entity_state[key] = {"value": row["value"]} + + self.process_new_events(new_entity_state) + + @property + def unique_id(self): + """ + Return a unique id for this accessory or bridge. + + This id is random and will change if a device undergoes a hard reset. + """ + return self.pairing_data["AccessoryPairingID"] + + @property + def connection_info(self): + """Return accessory information for the main accessory.""" + return get_bridge_information(self.accessories) + + @property + def name(self): + """Name of the bridge accessory.""" + return get_accessory_name(self.connection_info) or self.unique_id diff --git a/homeassistant/components/homekit_controller/const.py b/homeassistant/components/homekit_controller/const.py new file mode 100644 index 000000000..09a7df2a2 --- /dev/null +++ b/homeassistant/components/homekit_controller/const.py @@ -0,0 +1,29 @@ +"""Constants for the homekit_controller component.""" +DOMAIN = "homekit_controller" + +KNOWN_DEVICES = f"{DOMAIN}-devices" +CONTROLLER = f"{DOMAIN}-controller" +ENTITY_MAP = f"{DOMAIN}-entity-map" + +HOMEKIT_DIR = ".homekit" +PAIRING_FILE = "pairing.json" + +# Mapping from Homekit type to component. +HOMEKIT_ACCESSORY_DISPATCH = { + "lightbulb": "light", + "outlet": "switch", + "switch": "switch", + "thermostat": "climate", + "security-system": "alarm_control_panel", + "garage-door-opener": "cover", + "window": "cover", + "window-covering": "cover", + "lock-mechanism": "lock", + "contact": "binary_sensor", + "motion": "binary_sensor", + "carbon-dioxide": "sensor", + "humidity": "sensor", + "light": "sensor", + "temperature": "sensor", + "battery": "sensor", +} diff --git a/homeassistant/components/homekit_controller/cover.py b/homeassistant/components/homekit_controller/cover.py new file mode 100644 index 000000000..7e5591d95 --- /dev/null +++ b/homeassistant/components/homekit_controller/cover.py @@ -0,0 +1,278 @@ +"""Support for Homekit covers.""" +import logging + +from homekit.model.characteristics import CharacteristicsTypes + +from homeassistant.components.cover import ( + ATTR_POSITION, + ATTR_TILT_POSITION, + SUPPORT_CLOSE, + SUPPORT_CLOSE_TILT, + SUPPORT_OPEN, + SUPPORT_OPEN_TILT, + SUPPORT_SET_POSITION, + SUPPORT_SET_TILT_POSITION, + SUPPORT_STOP, + CoverDevice, +) +from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_OPENING + +from . import KNOWN_DEVICES, HomeKitEntity + +STATE_STOPPED = "stopped" + +_LOGGER = logging.getLogger(__name__) + +CURRENT_GARAGE_STATE_MAP = { + 0: STATE_OPEN, + 1: STATE_CLOSED, + 2: STATE_OPENING, + 3: STATE_CLOSING, + 4: STATE_STOPPED, +} + +TARGET_GARAGE_STATE_MAP = {STATE_OPEN: 0, STATE_CLOSED: 1, STATE_STOPPED: 2} + +CURRENT_WINDOW_STATE_MAP = {0: STATE_CLOSING, 1: STATE_OPENING, 2: STATE_STOPPED} + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Legacy set up platform.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Homekit covers.""" + hkid = config_entry.data["AccessoryPairingID"] + conn = hass.data[KNOWN_DEVICES][hkid] + + def async_add_service(aid, service): + info = {"aid": aid, "iid": service["iid"]} + if service["stype"] == "garage-door-opener": + async_add_entities([HomeKitGarageDoorCover(conn, info)], True) + return True + + if service["stype"] in ("window-covering", "window"): + async_add_entities([HomeKitWindowCover(conn, info)], True) + return True + + return False + + conn.add_listener(async_add_service) + + +class HomeKitGarageDoorCover(HomeKitEntity, CoverDevice): + """Representation of a HomeKit Garage Door.""" + + def __init__(self, accessory, discovery_info): + """Initialise the Cover.""" + super().__init__(accessory, discovery_info) + self._state = None + self._obstruction_detected = None + self.lock_state = None + + @property + def device_class(self): + """Define this cover as a garage door.""" + return "garage" + + def get_characteristic_types(self): + """Define the homekit characteristics the entity cares about.""" + return [ + CharacteristicsTypes.DOOR_STATE_CURRENT, + CharacteristicsTypes.DOOR_STATE_TARGET, + CharacteristicsTypes.OBSTRUCTION_DETECTED, + ] + + def _update_door_state_current(self, value): + self._state = CURRENT_GARAGE_STATE_MAP[value] + + def _update_obstruction_detected(self, value): + self._obstruction_detected = value + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_OPEN | SUPPORT_CLOSE + + @property + def is_closed(self): + """Return true if cover is closed, else False.""" + return self._state == STATE_CLOSED + + @property + def is_closing(self): + """Return if the cover is closing or not.""" + return self._state == STATE_CLOSING + + @property + def is_opening(self): + """Return if the cover is opening or not.""" + return self._state == STATE_OPENING + + async def async_open_cover(self, **kwargs): + """Send open command.""" + await self.set_door_state(STATE_OPEN) + + async def async_close_cover(self, **kwargs): + """Send close command.""" + await self.set_door_state(STATE_CLOSED) + + async def set_door_state(self, state): + """Send state command.""" + characteristics = [ + { + "aid": self._aid, + "iid": self._chars["door-state.target"], + "value": TARGET_GARAGE_STATE_MAP[state], + } + ] + await self._accessory.put_characteristics(characteristics) + + @property + def device_state_attributes(self): + """Return the optional state attributes.""" + if self._obstruction_detected is None: + return None + + return {"obstruction-detected": self._obstruction_detected} + + +class HomeKitWindowCover(HomeKitEntity, CoverDevice): + """Representation of a HomeKit Window or Window Covering.""" + + def __init__(self, accessory, discovery_info): + """Initialise the Cover.""" + super().__init__(accessory, discovery_info) + self._state = None + self._position = None + self._tilt_position = None + self._obstruction_detected = None + self.lock_state = None + self._features = SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_SET_POSITION + + def get_characteristic_types(self): + """Define the homekit characteristics the entity cares about.""" + return [ + CharacteristicsTypes.POSITION_STATE, + CharacteristicsTypes.POSITION_CURRENT, + CharacteristicsTypes.POSITION_TARGET, + CharacteristicsTypes.POSITION_HOLD, + CharacteristicsTypes.VERTICAL_TILT_CURRENT, + CharacteristicsTypes.VERTICAL_TILT_TARGET, + CharacteristicsTypes.HORIZONTAL_TILT_CURRENT, + CharacteristicsTypes.HORIZONTAL_TILT_TARGET, + CharacteristicsTypes.OBSTRUCTION_DETECTED, + ] + + def _setup_position_hold(self, char): + self._features |= SUPPORT_STOP + + def _setup_vertical_tilt_current(self, char): + self._features |= ( + SUPPORT_OPEN_TILT | SUPPORT_CLOSE_TILT | SUPPORT_SET_TILT_POSITION + ) + + def _setup_horizontal_tilt_current(self, char): + self._features |= ( + SUPPORT_OPEN_TILT | SUPPORT_CLOSE_TILT | SUPPORT_SET_TILT_POSITION + ) + + def _update_position_state(self, value): + self._state = CURRENT_WINDOW_STATE_MAP[value] + + def _update_position_current(self, value): + self._position = value + + def _update_vertical_tilt_current(self, value): + self._tilt_position = value + + def _update_horizontal_tilt_current(self, value): + self._tilt_position = value + + def _update_obstruction_detected(self, value): + self._obstruction_detected = value + + @property + def supported_features(self): + """Flag supported features.""" + return self._features + + @property + def current_cover_position(self): + """Return the current position of cover.""" + return self._position + + @property + def is_closed(self): + """Return true if cover is closed, else False.""" + return self._position == 0 + + @property + def is_closing(self): + """Return if the cover is closing or not.""" + return self._state == STATE_CLOSING + + @property + def is_opening(self): + """Return if the cover is opening or not.""" + return self._state == STATE_OPENING + + async def async_stop_cover(self, **kwargs): + """Send hold command.""" + characteristics = [ + {"aid": self._aid, "iid": self._chars["position.hold"], "value": 1} + ] + await self._accessory.put_characteristics(characteristics) + + async def async_open_cover(self, **kwargs): + """Send open command.""" + await self.async_set_cover_position(position=100) + + async def async_close_cover(self, **kwargs): + """Send close command.""" + await self.async_set_cover_position(position=0) + + async def async_set_cover_position(self, **kwargs): + """Send position command.""" + position = kwargs[ATTR_POSITION] + characteristics = [ + {"aid": self._aid, "iid": self._chars["position.target"], "value": position} + ] + await self._accessory.put_characteristics(characteristics) + + @property + def current_cover_tilt_position(self): + """Return current position of cover tilt.""" + return self._tilt_position + + async def async_set_cover_tilt_position(self, **kwargs): + """Move the cover tilt to a specific position.""" + tilt_position = kwargs[ATTR_TILT_POSITION] + if "vertical-tilt.target" in self._chars: + characteristics = [ + { + "aid": self._aid, + "iid": self._chars["vertical-tilt.target"], + "value": tilt_position, + } + ] + await self._accessory.put_characteristics(characteristics) + elif "horizontal-tilt.target" in self._chars: + characteristics = [ + { + "aid": self._aid, + "iid": self._chars["horizontal-tilt.target"], + "value": tilt_position, + } + ] + await self._accessory.put_characteristics(characteristics) + + @property + def device_state_attributes(self): + """Return the optional state attributes.""" + state_attributes = {} + if self._obstruction_detected is not None: + state_attributes["obstruction-detected"] = self._obstruction_detected + + return state_attributes diff --git a/homeassistant/components/homekit_controller/light.py b/homeassistant/components/homekit_controller/light.py new file mode 100644 index 000000000..fe2a0e9bc --- /dev/null +++ b/homeassistant/components/homekit_controller/light.py @@ -0,0 +1,158 @@ +"""Support for Homekit lights.""" +import logging + +from homekit.model.characteristics import CharacteristicsTypes + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP, + ATTR_HS_COLOR, + SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, + SUPPORT_COLOR_TEMP, + Light, +) + +from . import KNOWN_DEVICES, HomeKitEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Legacy set up platform.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Homekit lightbulb.""" + hkid = config_entry.data["AccessoryPairingID"] + conn = hass.data[KNOWN_DEVICES][hkid] + + def async_add_service(aid, service): + if service["stype"] != "lightbulb": + return False + info = {"aid": aid, "iid": service["iid"]} + async_add_entities([HomeKitLight(conn, info)], True) + return True + + conn.add_listener(async_add_service) + + +class HomeKitLight(HomeKitEntity, Light): + """Representation of a Homekit light.""" + + def __init__(self, *args): + """Initialise the light.""" + super().__init__(*args) + self._on = False + self._brightness = 0 + self._color_temperature = 0 + self._hue = 0 + self._saturation = 0 + + def get_characteristic_types(self): + """Define the homekit characteristics the entity cares about.""" + return [ + CharacteristicsTypes.ON, + CharacteristicsTypes.BRIGHTNESS, + CharacteristicsTypes.COLOR_TEMPERATURE, + CharacteristicsTypes.HUE, + CharacteristicsTypes.SATURATION, + ] + + def _setup_brightness(self, char): + self._features |= SUPPORT_BRIGHTNESS + + def _setup_color_temperature(self, char): + self._features |= SUPPORT_COLOR_TEMP + + def _setup_hue(self, char): + self._features |= SUPPORT_COLOR + + def _setup_saturation(self, char): + self._features |= SUPPORT_COLOR + + def _update_on(self, value): + self._on = value + + def _update_brightness(self, value): + self._brightness = value + + def _update_color_temperature(self, value): + self._color_temperature = value + + def _update_hue(self, value): + self._hue = value + + def _update_saturation(self, value): + self._saturation = 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.""" + return self._brightness * 255 / 100 + + @property + def hs_color(self): + """Return the color property.""" + return (self._hue, self._saturation) + + @property + def color_temp(self): + """Return the color temperature.""" + return self._color_temperature + + @property + def supported_features(self): + """Flag supported features.""" + return self._features + + async def async_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} + ) + await self._accessory.put_characteristics(characteristics) + + async def async_turn_off(self, **kwargs): + """Turn the specified light off.""" + characteristics = [{"aid": self._aid, "iid": self._chars["on"], "value": False}] + await self._accessory.put_characteristics(characteristics) diff --git a/homeassistant/components/homekit_controller/lock.py b/homeassistant/components/homekit_controller/lock.py new file mode 100644 index 000000000..53f7bb5df --- /dev/null +++ b/homeassistant/components/homekit_controller/lock.py @@ -0,0 +1,93 @@ +"""Support for HomeKit Controller locks.""" +import logging + +from homekit.model.characteristics import CharacteristicsTypes + +from homeassistant.components.lock import LockDevice +from homeassistant.const import ATTR_BATTERY_LEVEL, STATE_LOCKED, STATE_UNLOCKED + +from . import KNOWN_DEVICES, HomeKitEntity + +_LOGGER = logging.getLogger(__name__) + +STATE_JAMMED = "jammed" + +CURRENT_STATE_MAP = {0: STATE_UNLOCKED, 1: STATE_LOCKED, 2: STATE_JAMMED, 3: None} + +TARGET_STATE_MAP = {STATE_UNLOCKED: 0, STATE_LOCKED: 1} + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Legacy set up platform.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Homekit lock.""" + hkid = config_entry.data["AccessoryPairingID"] + conn = hass.data[KNOWN_DEVICES][hkid] + + def async_add_service(aid, service): + if service["stype"] != "lock-mechanism": + return False + info = {"aid": aid, "iid": service["iid"]} + async_add_entities([HomeKitLock(conn, info)], True) + return True + + conn.add_listener(async_add_service) + + +class HomeKitLock(HomeKitEntity, LockDevice): + """Representation of a HomeKit Controller Lock.""" + + def __init__(self, accessory, discovery_info): + """Initialise the Lock.""" + super().__init__(accessory, discovery_info) + self._state = None + self._battery_level = None + + def get_characteristic_types(self): + """Define the homekit characteristics the entity cares about.""" + return [ + CharacteristicsTypes.LOCK_MECHANISM_CURRENT_STATE, + CharacteristicsTypes.LOCK_MECHANISM_TARGET_STATE, + CharacteristicsTypes.BATTERY_LEVEL, + ] + + def _update_lock_mechanism_current_state(self, value): + self._state = CURRENT_STATE_MAP[value] + + def _update_battery_level(self, value): + self._battery_level = value + + @property + def is_locked(self): + """Return true if device is locked.""" + return self._state == STATE_LOCKED + + async def async_lock(self, **kwargs): + """Lock the device.""" + await self._set_lock_state(STATE_LOCKED) + + async def async_unlock(self, **kwargs): + """Unlock the device.""" + await self._set_lock_state(STATE_UNLOCKED) + + async def _set_lock_state(self, state): + """Send state command.""" + characteristics = [ + { + "aid": self._aid, + "iid": self._chars["lock-mechanism.target-state"], + "value": TARGET_STATE_MAP[state], + } + ] + await self._accessory.put_characteristics(characteristics) + + @property + def device_state_attributes(self): + """Return the optional state attributes.""" + if self._battery_level is None: + return None + + return {ATTR_BATTERY_LEVEL: self._battery_level} diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json new file mode 100644 index 000000000..2a6602813 --- /dev/null +++ b/homeassistant/components/homekit_controller/manifest.json @@ -0,0 +1,14 @@ +{ + "domain": "homekit_controller", + "name": "Homekit controller", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/homekit_controller", + "requirements": [ + "homekit[IP]==0.15.0" + ], + "dependencies": [], + "zeroconf": ["_hap._tcp.local."], + "codeowners": [ + "@Jc2k" + ] +} diff --git a/homeassistant/components/homekit_controller/sensor.py b/homeassistant/components/homekit_controller/sensor.py new file mode 100644 index 000000000..f91dae26b --- /dev/null +++ b/homeassistant/components/homekit_controller/sensor.py @@ -0,0 +1,262 @@ +"""Support for Homekit sensors.""" +from homekit.model.characteristics import CharacteristicsTypes + +from homeassistant.const import DEVICE_CLASS_BATTERY, TEMP_CELSIUS + +from . import KNOWN_DEVICES, HomeKitEntity + +HUMIDITY_ICON = "mdi:water-percent" +TEMP_C_ICON = "mdi:thermometer" +BRIGHTNESS_ICON = "mdi:brightness-6" +CO2_ICON = "mdi:periodic-table-co2" + +UNIT_PERCENT = "%" +UNIT_LUX = "lux" +UNIT_CO2 = "ppm" + + +class HomeKitHumiditySensor(HomeKitEntity): + """Representation of a Homekit humidity sensor.""" + + def __init__(self, *args): + """Initialise the entity.""" + super().__init__(*args) + self._state = None + + def get_characteristic_types(self): + """Define the homekit characteristics the entity is tracking.""" + return [CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT] + + @property + def name(self): + """Return the name of the device.""" + return f"{super().name} Humidity" + + @property + def icon(self): + """Return the sensor icon.""" + return HUMIDITY_ICON + + @property + def unit_of_measurement(self): + """Return units for the sensor.""" + return UNIT_PERCENT + + def _update_relative_humidity_current(self, value): + self._state = value + + @property + def state(self): + """Return the current humidity.""" + return self._state + + +class HomeKitTemperatureSensor(HomeKitEntity): + """Representation of a Homekit temperature sensor.""" + + def __init__(self, *args): + """Initialise the entity.""" + super().__init__(*args) + self._state = None + + def get_characteristic_types(self): + """Define the homekit characteristics the entity is tracking.""" + return [CharacteristicsTypes.TEMPERATURE_CURRENT] + + @property + def name(self): + """Return the name of the device.""" + return f"{super().name} Temperature" + + @property + def icon(self): + """Return the sensor icon.""" + return TEMP_C_ICON + + @property + def unit_of_measurement(self): + """Return units for the sensor.""" + return TEMP_CELSIUS + + def _update_temperature_current(self, value): + self._state = value + + @property + def state(self): + """Return the current temperature in Celsius.""" + return self._state + + +class HomeKitLightSensor(HomeKitEntity): + """Representation of a Homekit light level sensor.""" + + def __init__(self, *args): + """Initialise the entity.""" + super().__init__(*args) + self._state = None + + def get_characteristic_types(self): + """Define the homekit characteristics the entity is tracking.""" + return [CharacteristicsTypes.LIGHT_LEVEL_CURRENT] + + @property + def name(self): + """Return the name of the device.""" + return f"{super().name} Light Level" + + @property + def icon(self): + """Return the sensor icon.""" + return BRIGHTNESS_ICON + + @property + def unit_of_measurement(self): + """Return units for the sensor.""" + return UNIT_LUX + + def _update_light_level_current(self, value): + self._state = value + + @property + def state(self): + """Return the current light level in lux.""" + return self._state + + +class HomeKitCarbonDioxideSensor(HomeKitEntity): + """Representation of a Homekit Carbon Dioxide sensor.""" + + def __init__(self, *args): + """Initialise the entity.""" + super().__init__(*args) + self._state = None + + def get_characteristic_types(self): + """Define the homekit characteristics the entity is tracking.""" + return [CharacteristicsTypes.CARBON_DIOXIDE_LEVEL] + + @property + def name(self): + """Return the name of the device.""" + return f"{super().name} CO2" + + @property + def icon(self): + """Return the sensor icon.""" + return CO2_ICON + + @property + def unit_of_measurement(self): + """Return units for the sensor.""" + return UNIT_CO2 + + def _update_carbon_dioxide_level(self, value): + self._state = value + + @property + def state(self): + """Return the current CO2 level in ppm.""" + return self._state + + +class HomeKitBatterySensor(HomeKitEntity): + """Representation of a Homekit battery sensor.""" + + def __init__(self, *args): + """Initialise the entity.""" + super().__init__(*args) + self._state = None + self._low_battery = False + self._charging = False + + def get_characteristic_types(self): + """Define the homekit characteristics the entity is tracking.""" + return [ + CharacteristicsTypes.BATTERY_LEVEL, + CharacteristicsTypes.STATUS_LO_BATT, + CharacteristicsTypes.CHARGING_STATE, + ] + + @property + def device_class(self) -> str: + """Return the device class of the sensor.""" + return DEVICE_CLASS_BATTERY + + @property + def name(self): + """Return the name of the device.""" + return f"{super().name} Battery" + + @property + def icon(self): + """Return the sensor icon.""" + if not self.available or self.state is None: + return "mdi:battery-unknown" + + # This is similar to the logic in helpers.icon, but we have delegated the + # decision about what mdi:battery-alert is to the device. + icon = "mdi:battery" + if self._charging and self.state > 10: + percentage = int(round(self.state / 20 - 0.01)) * 20 + icon += f"-charging-{percentage}" + elif self._charging: + icon += "-outline" + elif self._low_battery: + icon += "-alert" + elif self.state < 95: + percentage = max(int(round(self.state / 10 - 0.01)) * 10, 10) + icon += f"-{percentage}" + + return icon + + @property + def unit_of_measurement(self): + """Return units for the sensor.""" + return UNIT_PERCENT + + def _update_battery_level(self, value): + self._state = value + + def _update_status_lo_batt(self, value): + self._low_battery = value == 1 + + def _update_charging_state(self, value): + # 0 = not charging + # 1 = charging + # 2 = not chargeable + self._charging = value == 1 + + @property + def state(self): + """Return the current battery level percentage.""" + return self._state + + +ENTITY_TYPES = { + "humidity": HomeKitHumiditySensor, + "temperature": HomeKitTemperatureSensor, + "light": HomeKitLightSensor, + "carbon-dioxide": HomeKitCarbonDioxideSensor, + "battery": HomeKitBatterySensor, +} + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Legacy set up platform.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Homekit sensors.""" + hkid = config_entry.data["AccessoryPairingID"] + conn = hass.data[KNOWN_DEVICES][hkid] + + def async_add_service(aid, service): + entity_class = ENTITY_TYPES.get(service["stype"]) + if not entity_class: + return False + info = {"aid": aid, "iid": service["iid"]} + async_add_entities([entity_class(conn, info)], True) + return True + + conn.add_listener(async_add_service) diff --git a/homeassistant/components/homekit_controller/storage.py b/homeassistant/components/homekit_controller/storage.py new file mode 100644 index 000000000..ffc2da5fb --- /dev/null +++ b/homeassistant/components/homekit_controller/storage.py @@ -0,0 +1,71 @@ +"""Helpers for HomeKit data stored in HA storage.""" + +from homeassistant.core import callback +from homeassistant.helpers.storage import Store + +from .const import DOMAIN + +ENTITY_MAP_STORAGE_KEY = f"{DOMAIN}-entity-map" +ENTITY_MAP_STORAGE_VERSION = 1 +ENTITY_MAP_SAVE_DELAY = 10 + + +class EntityMapStorage: + """ + Holds a cache of entity structure data from a paired HomeKit device. + + HomeKit has a cacheable entity map that describes how an IP or BLE + endpoint is structured. This object holds the latest copy of that data. + + An endpoint is made of accessories, services and characteristics. It is + safe to cache this data until the c# discovery data changes. + + Caching this data means we can add HomeKit devices to HA immediately at + start even if discovery hasn't seen them yet or they are out of range. It + is also important for BLE devices - accessing the entity structure is + very slow for these devices. + """ + + def __init__(self, hass): + """Create a new entity map store.""" + self.hass = hass + self.store = Store(hass, ENTITY_MAP_STORAGE_VERSION, ENTITY_MAP_STORAGE_KEY) + self.storage_data = {} + + async def async_initialize(self): + """Get the pairing cache data.""" + raw_storage = await self.store.async_load() + if not raw_storage: + # There is no cached data about HomeKit devices yet + return + + self.storage_data = raw_storage.get("pairings", {}) + + def get_map(self, homekit_id): + """Get a pairing cache item.""" + return self.storage_data.get(homekit_id) + + def async_create_or_update_map(self, homekit_id, config_num, accessories): + """Create a new pairing cache.""" + data = {"config_num": config_num, "accessories": accessories} + self.storage_data[homekit_id] = data + self._async_schedule_save() + return data + + def async_delete_map(self, homekit_id): + """Delete pairing cache.""" + if homekit_id not in self.storage_data: + return + + self.storage_data.pop(homekit_id) + self._async_schedule_save() + + @callback + def _async_schedule_save(self): + """Schedule saving the entity map cache.""" + self.store.async_delay_save(self._data_to_save, ENTITY_MAP_SAVE_DELAY) + + @callback + def _data_to_save(self): + """Return data of entity map to store in a file.""" + return {"pairings": self.storage_data} diff --git a/homeassistant/components/homekit_controller/strings.json b/homeassistant/components/homekit_controller/strings.json new file mode 100644 index 000000000..b51dcb1f6 --- /dev/null +++ b/homeassistant/components/homekit_controller/strings.json @@ -0,0 +1,40 @@ +{ + "config": { + "title": "HomeKit Accessory", + "flow_title": "HomeKit Accessory: {name}", + "step": { + "user": { + "title": "Pair with HomeKit Accessory", + "description": "Select the device you want to pair with", + "data": { + "device": "Device" + } + }, + "pair": { + "title": "Pair with HomeKit Accessory", + "description": "Enter your HomeKit pairing code (in the format XXX-XX-XXX) to use this accessory", + "data": { + "pairing_code": "Pairing Code" + } + } + }, + "error": { + "unable_to_pair": "Unable to pair, please try again.", + "unknown_error": "Device reported an unknown error. Pairing failed.", + "authentication_error": "Incorrect HomeKit code. Please check it and try again.", + "max_peers_error": "Device refused to add pairing as it has no free pairing storage.", + "busy_error": "Device refused to add pairing as it is already pairing with another controller.", + "max_tries_error": "Device refused to add pairing as it has received more than 100 unsuccessful authentication attempts.", + "pairing_failed": "An unhandled error occured while attempting to pair with this device. This may be a temporary failure or your device may not be supported currently." + }, + "abort": { + "no_devices": "No unpaired devices could be found", + "already_paired": "This accessory is already paired to another device. Please reset the accessory and try again.", + "ignored_model": "HomeKit support for this model is blocked as a more feature complete native integration is available.", + "already_configured": "Accessory is already configured with this controller.", + "invalid_config_entry": "This device is showing as ready to pair but there is already a conflicting config entry for it in Home Assistant that must first be removed.", + "accessory_not_found_error": "Cannot add pairing as device can no longer be found.", + "already_in_progress": "Config flow for device is already in progress." + } + } +} diff --git a/homeassistant/components/homekit_controller/switch.py b/homeassistant/components/homekit_controller/switch.py new file mode 100644 index 000000000..7eedda1b1 --- /dev/null +++ b/homeassistant/components/homekit_controller/switch.py @@ -0,0 +1,74 @@ +"""Support for Homekit switches.""" +import logging + +from homekit.model.characteristics import CharacteristicsTypes + +from homeassistant.components.switch import SwitchDevice + +from . import KNOWN_DEVICES, HomeKitEntity + +OUTLET_IN_USE = "outlet_in_use" + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Legacy set up platform.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Homekit lock.""" + hkid = config_entry.data["AccessoryPairingID"] + conn = hass.data[KNOWN_DEVICES][hkid] + + def async_add_service(aid, service): + if service["stype"] not in ("switch", "outlet"): + return False + info = {"aid": aid, "iid": service["iid"]} + async_add_entities([HomeKitSwitch(conn, info)], True) + return True + + conn.add_listener(async_add_service) + + +class HomeKitSwitch(HomeKitEntity, SwitchDevice): + """Representation of a Homekit switch.""" + + def __init__(self, *args): + """Initialise the switch.""" + super().__init__(*args) + self._on = None + self._outlet_in_use = None + + def get_characteristic_types(self): + """Define the homekit characteristics the entity cares about.""" + return [CharacteristicsTypes.ON, CharacteristicsTypes.OUTLET_IN_USE] + + def _update_on(self, value): + self._on = value + + def _update_outlet_in_use(self, value): + self._outlet_in_use = value + + @property + def is_on(self): + """Return true if device is on.""" + return self._on + + async def async_turn_on(self, **kwargs): + """Turn the specified switch on.""" + self._on = True + characteristics = [{"aid": self._aid, "iid": self._chars["on"], "value": True}] + await self._accessory.put_characteristics(characteristics) + + async def async_turn_off(self, **kwargs): + """Turn the specified switch off.""" + characteristics = [{"aid": self._aid, "iid": self._chars["on"], "value": False}] + await self._accessory.put_characteristics(characteristics) + + @property + def device_state_attributes(self): + """Return the optional state attributes.""" + if self._outlet_in_use is not None: + return {OUTLET_IN_USE: self._outlet_in_use} diff --git a/homeassistant/components/homematic/__init__.py b/homeassistant/components/homematic/__init__.py index 4e6b3f04e..828e170c6 100644 --- a/homeassistant/components/homematic/__init__.py +++ b/homeassistant/components/homematic/__init__.py @@ -1,325 +1,372 @@ -""" -Support for HomeMatic devices. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/homematic/ -""" -import asyncio -from datetime import timedelta +"""Support for HomeMatic devices.""" +from datetime import datetime, timedelta from functools import partial import logging -import socket +from pyhomematic import HMConnection import voluptuous as vol from homeassistant.const import ( - ATTR_ENTITY_ID, ATTR_NAME, CONF_HOST, CONF_HOSTS, CONF_PASSWORD, - CONF_PLATFORM, CONF_USERNAME, EVENT_HOMEASSISTANT_STOP, STATE_UNKNOWN) + ATTR_ENTITY_ID, + ATTR_MODE, + ATTR_NAME, + CONF_HOST, + CONF_HOSTS, + CONF_PASSWORD, + CONF_PLATFORM, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, + 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.loader import bind_hass - -REQUIREMENTS = ['pyhomematic==0.1.49'] _LOGGER = logging.getLogger(__name__) -DOMAIN = 'homematic' +DOMAIN = "homematic" SCAN_INTERVAL_HUB = timedelta(seconds=300) SCAN_INTERVAL_VARIABLES = timedelta(seconds=30) -DISCOVER_SWITCHES = 'homematic.switch' -DISCOVER_LIGHTS = 'homematic.light' -DISCOVER_SENSORS = 'homematic.sensor' -DISCOVER_BINARY_SENSORS = 'homematic.binary_sensor' -DISCOVER_COVER = 'homematic.cover' -DISCOVER_CLIMATE = 'homematic.climate' -DISCOVER_LOCKS = 'homematic.locks' +DISCOVER_SWITCHES = "homematic.switch" +DISCOVER_LIGHTS = "homematic.light" +DISCOVER_SENSORS = "homematic.sensor" +DISCOVER_BINARY_SENSORS = "homematic.binary_sensor" +DISCOVER_COVER = "homematic.cover" +DISCOVER_CLIMATE = "homematic.climate" +DISCOVER_LOCKS = "homematic.locks" +DISCOVER_BATTERY = "homematic.battery" -ATTR_DISCOVER_DEVICES = 'devices' -ATTR_PARAM = 'param' -ATTR_CHANNEL = 'channel' -ATTR_ADDRESS = 'address' -ATTR_VALUE = 'value' -ATTR_INTERFACE = 'interface' -ATTR_ERRORCODE = 'error' -ATTR_MESSAGE = 'message' -ATTR_MODE = 'mode' -ATTR_TIME = 'time' -ATTR_UNIQUE_ID = 'unique_id' -ATTR_PARAMSET_KEY = 'paramset_key' -ATTR_PARAMSET = 'paramset' +ATTR_DISCOVER_DEVICES = "devices" +ATTR_PARAM = "param" +ATTR_CHANNEL = "channel" +ATTR_ADDRESS = "address" +ATTR_VALUE = "value" +ATTR_VALUE_TYPE = "value_type" +ATTR_INTERFACE = "interface" +ATTR_ERRORCODE = "error" +ATTR_MESSAGE = "message" +ATTR_TIME = "time" +ATTR_UNIQUE_ID = "unique_id" +ATTR_PARAMSET_KEY = "paramset_key" +ATTR_PARAMSET = "paramset" +ATTR_DISCOVERY_TYPE = "discovery_type" +ATTR_LOW_BAT = "LOW_BAT" +ATTR_LOWBAT = "LOWBAT" -EVENT_KEYPRESS = 'homematic.keypress' -EVENT_IMPULSE = 'homematic.impulse' -EVENT_ERROR = 'homematic.error' -SERVICE_VIRTUALKEY = 'virtualkey' -SERVICE_RECONNECT = 'reconnect' -SERVICE_SET_VARIABLE_VALUE = 'set_variable_value' -SERVICE_SET_DEVICE_VALUE = 'set_device_value' -SERVICE_SET_INSTALL_MODE = 'set_install_mode' -SERVICE_PUT_PARAMSET = 'put_paramset' +EVENT_KEYPRESS = "homematic.keypress" +EVENT_IMPULSE = "homematic.impulse" +EVENT_ERROR = "homematic.error" + +SERVICE_VIRTUALKEY = "virtualkey" +SERVICE_RECONNECT = "reconnect" +SERVICE_SET_VARIABLE_VALUE = "set_variable_value" +SERVICE_SET_DEVICE_VALUE = "set_device_value" +SERVICE_SET_INSTALL_MODE = "set_install_mode" +SERVICE_PUT_PARAMSET = "put_paramset" HM_DEVICE_TYPES = { DISCOVER_SWITCHES: [ - 'Switch', 'SwitchPowermeter', 'IOSwitch', 'IPSwitch', 'RFSiren', - 'IPSwitchPowermeter', 'HMWIOSwitch', 'Rain', 'EcoLogic', - 'IPKeySwitchPowermeter'], - DISCOVER_LIGHTS: ['Dimmer', 'KeyDimmer', 'IPKeyDimmer'], + "Switch", + "SwitchPowermeter", + "IOSwitch", + "IPSwitch", + "RFSiren", + "IPSwitchPowermeter", + "HMWIOSwitch", + "Rain", + "EcoLogic", + "IPKeySwitchPowermeter", + "IPGarage", + "IPKeySwitch", + "IPKeySwitchLevel", + "IPMultiIO", + ], + DISCOVER_LIGHTS: [ + "Dimmer", + "KeyDimmer", + "IPKeyDimmer", + "IPDimmer", + "ColorEffectLight", + "IPKeySwitchLevel", + ], DISCOVER_SENSORS: [ - 'SwitchPowermeter', 'Motion', 'MotionV2', 'RemoteMotion', 'MotionIP', - 'ThermostatWall', 'AreaThermostat', 'RotaryHandleSensor', - 'WaterSensor', 'PowermeterGas', 'LuxSensor', 'WeatherSensor', - 'WeatherStation', 'ThermostatWall2', 'TemperatureDiffSensor', - 'TemperatureSensor', 'CO2Sensor', 'IPSwitchPowermeter', 'HMWIOSwitch', - 'FillingLevel', 'ValveDrive', 'EcoLogic', 'IPThermostatWall', - 'IPSmoke', 'RFSiren', 'PresenceIP', 'IPAreaThermostat', - 'IPWeatherSensor', 'RotaryHandleSensorIP', 'IPPassageSensor', - 'IPKeySwitchPowermeter', 'IPThermostatWall230V', 'IPWeatherSensorPlus', - 'IPWeatherSensorBasic'], + "SwitchPowermeter", + "Motion", + "MotionV2", + "RemoteMotion", + "MotionIP", + "ThermostatWall", + "AreaThermostat", + "RotaryHandleSensor", + "WaterSensor", + "PowermeterGas", + "LuxSensor", + "WeatherSensor", + "WeatherStation", + "ThermostatWall2", + "TemperatureDiffSensor", + "TemperatureSensor", + "CO2Sensor", + "IPSwitchPowermeter", + "HMWIOSwitch", + "FillingLevel", + "ValveDrive", + "EcoLogic", + "IPThermostatWall", + "IPSmoke", + "RFSiren", + "PresenceIP", + "IPAreaThermostat", + "IPWeatherSensor", + "RotaryHandleSensorIP", + "IPPassageSensor", + "IPKeySwitchPowermeter", + "IPThermostatWall230V", + "IPWeatherSensorPlus", + "IPWeatherSensorBasic", + "IPBrightnessSensor", + "IPGarage", + "UniversalSensor", + "MotionIPV2", + "IPMultiIO", + "IPThermostatWall2", + ], DISCOVER_CLIMATE: [ - 'Thermostat', 'ThermostatWall', 'MAXThermostat', 'ThermostatWall2', - 'MAXWallThermostat', 'IPThermostat', 'IPThermostatWall', - 'ThermostatGroup', 'IPThermostatWall230V'], + "Thermostat", + "ThermostatWall", + "MAXThermostat", + "ThermostatWall2", + "MAXWallThermostat", + "IPThermostat", + "IPThermostatWall", + "ThermostatGroup", + "IPThermostatWall230V", + "IPThermostatWall2", + ], DISCOVER_BINARY_SENSORS: [ - 'ShutterContact', 'Smoke', 'SmokeV2', 'Motion', 'MotionV2', - 'MotionIP', 'RemoteMotion', 'WeatherSensor', 'TiltSensor', - 'IPShutterContact', 'HMWIOSwitch', 'MaxShutterContact', 'Rain', - 'WiredSensor', 'PresenceIP', 'IPWeatherSensor', 'IPPassageSensor', - 'SmartwareMotion', 'IPWeatherSensorPlus'], - DISCOVER_COVER: ['Blind', 'KeyBlind', 'IPKeyBlind', 'IPKeyBlindTilt'], - DISCOVER_LOCKS: ['KeyMatic'] + "ShutterContact", + "Smoke", + "SmokeV2", + "Motion", + "MotionV2", + "MotionIP", + "RemoteMotion", + "WeatherSensor", + "TiltSensor", + "IPShutterContact", + "HMWIOSwitch", + "MaxShutterContact", + "Rain", + "WiredSensor", + "PresenceIP", + "IPWeatherSensor", + "IPPassageSensor", + "SmartwareMotion", + "IPWeatherSensorPlus", + "MotionIPV2", + "WaterIP", + "IPMultiIO", + "TiltIP", + "IPShutterContactSabotage", + ], + DISCOVER_COVER: ["Blind", "KeyBlind", "IPKeyBlind", "IPKeyBlindTilt"], + DISCOVER_LOCKS: ["KeyMatic"], } -HM_IGNORE_DISCOVERY_NODE = [ - 'ACTUAL_TEMPERATURE', - 'ACTUAL_HUMIDITY' -] +HM_IGNORE_DISCOVERY_NODE = ["ACTUAL_TEMPERATURE", "ACTUAL_HUMIDITY"] HM_IGNORE_DISCOVERY_NODE_EXCEPTIONS = { - 'ACTUAL_TEMPERATURE': ['IPAreaThermostat', 'IPWeatherSensor'], + "ACTUAL_TEMPERATURE": [ + "IPAreaThermostat", + "IPWeatherSensor", + "IPWeatherSensorPlus", + "IPWeatherSensorBasic", + "IPThermostatWall", + "IPThermostatWall2", + ] } HM_ATTRIBUTE_SUPPORT = { - 'LOWBAT': ['battery', {0: 'High', 1: 'Low'}], - 'LOW_BAT': ['battery', {0: 'High', 1: 'Low'}], - 'ERROR': ['sabotage', {0: 'No', 1: 'Yes'}], - 'SABOTAGE': ['sabotage', {0: 'No', 1: 'Yes'}], - 'RSSI_PEER': ['rssi', {}], - 'RSSI_DEVICE': ['rssi', {}], - 'VALVE_STATE': ['valve', {}], - 'BATTERY_STATE': ['battery', {}], - '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'}] + "LOWBAT": ["battery", {0: "High", 1: "Low"}], + "LOW_BAT": ["battery", {0: "High", 1: "Low"}], + "ERROR": ["error", {0: "No"}], + "ERROR_SABOTAGE": ["sabotage", {0: "No", 1: "Yes"}], + "SABOTAGE": ["sabotage", {0: "No", 1: "Yes"}], + "RSSI_PEER": ["rssi_peer", {}], + "RSSI_DEVICE": ["rssi_device", {}], + "VALVE_STATE": ["valve", {}], + "LEVEL": ["level", {}], + "BATTERY_STATE": ["battery", {}], + "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"}], + "STATE_UNCERTAIN": ["state_uncertain", {}], } HM_PRESS_EVENTS = [ - 'PRESS_SHORT', - 'PRESS_LONG', - 'PRESS_CONT', - 'PRESS_LONG_RELEASE', - 'PRESS', + "PRESS_SHORT", + "PRESS_LONG", + "PRESS_CONT", + "PRESS_LONG_RELEASE", + "PRESS", ] -HM_IMPULSE_EVENTS = [ - 'SEQUENCE_OK', -] +HM_IMPULSE_EVENTS = ["SEQUENCE_OK"] -CONF_RESOLVENAMES_OPTIONS = [ - 'metadata', - 'json', - 'xml', - False -] +CONF_RESOLVENAMES_OPTIONS = ["metadata", "json", "xml", False] -DATA_HOMEMATIC = 'homematic' -DATA_STORE = 'homematic_store' -DATA_CONF = 'homematic_conf' +DATA_HOMEMATIC = "homematic" +DATA_STORE = "homematic_store" +DATA_CONF = "homematic_conf" -CONF_INTERFACES = 'interfaces' -CONF_LOCAL_IP = 'local_ip' -CONF_LOCAL_PORT = 'local_port' -CONF_PORT = 'port' -CONF_PATH = 'path' -CONF_CALLBACK_IP = 'callback_ip' -CONF_CALLBACK_PORT = 'callback_port' -CONF_RESOLVENAMES = 'resolvenames' -CONF_JSONPORT = 'jsonport' -CONF_VARIABLES = 'variables' -CONF_DEVICES = 'devices' -CONF_PRIMARY = 'primary' +CONF_INTERFACES = "interfaces" +CONF_LOCAL_IP = "local_ip" +CONF_LOCAL_PORT = "local_port" +CONF_PORT = "port" +CONF_PATH = "path" +CONF_CALLBACK_IP = "callback_ip" +CONF_CALLBACK_PORT = "callback_port" +CONF_RESOLVENAMES = "resolvenames" +CONF_JSONPORT = "jsonport" +CONF_VARIABLES = "variables" +CONF_DEVICES = "devices" +CONF_PRIMARY = "primary" -DEFAULT_LOCAL_IP = '0.0.0.0' +DEFAULT_LOCAL_IP = "0.0.0.0" DEFAULT_LOCAL_PORT = 0 DEFAULT_RESOLVENAMES = False DEFAULT_JSONPORT = 80 DEFAULT_PORT = 2001 -DEFAULT_PATH = '' -DEFAULT_USERNAME = 'Admin' -DEFAULT_PASSWORD = '' +DEFAULT_PATH = "" +DEFAULT_USERNAME = "Admin" +DEFAULT_PASSWORD = "" +DEFAULT_SSL = False +DEFAULT_VERIFY_SSL = False +DEFAULT_CHANNEL = 1 -DEVICE_SCHEMA = vol.Schema({ - vol.Required(CONF_PLATFORM): 'homematic', - vol.Required(ATTR_NAME): cv.string, - vol.Required(ATTR_ADDRESS): cv.string, - vol.Required(ATTR_INTERFACE): cv.string, - vol.Optional(ATTR_CHANNEL, default=1): vol.Coerce(int), - vol.Optional(ATTR_PARAM): cv.string, - vol.Optional(ATTR_UNIQUE_ID): cv.string, -}) +DEVICE_SCHEMA = vol.Schema( + { + vol.Required(CONF_PLATFORM): "homematic", + vol.Required(ATTR_NAME): cv.string, + vol.Required(ATTR_ADDRESS): cv.string, + vol.Required(ATTR_INTERFACE): cv.string, + vol.Optional(ATTR_CHANNEL, default=DEFAULT_CHANNEL): vol.Coerce(int), + vol.Optional(ATTR_PARAM): cv.string, + vol.Optional(ATTR_UNIQUE_ID): cv.string, + } +) -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - 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_JSONPORT, default=DEFAULT_JSONPORT): cv.port, - 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): cv.port, - }), -}, extra=vol.ALLOW_EXTRA) +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + 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_JSONPORT, default=DEFAULT_JSONPORT): cv.port, + 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_SSL, default=DEFAULT_SSL): cv.boolean, + vol.Optional( + CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL + ): cv.boolean, + } + }, + 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): cv.port, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) -SCHEMA_SERVICE_VIRTUALKEY = vol.Schema({ - 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_INTERFACE): cv.string, -}) +SCHEMA_SERVICE_VIRTUALKEY = vol.Schema( + { + 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_INTERFACE): cv.string, + } +) -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_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_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_INTERFACE): cv.string, -}) +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_VALUE_TYPE): vol.In( + ["boolean", "dateTime.iso8601", "double", "int", "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), -}) - -SCHEMA_SERVICE_PUT_PARAMSET = vol.Schema({ - vol.Required(ATTR_INTERFACE): cv.string, - vol.Required(ATTR_ADDRESS): vol.All(cv.string, vol.Upper), - vol.Required(ATTR_PARAMSET_KEY): vol.All(cv.string, vol.Upper), - vol.Required(ATTR_PARAMSET): dict, -}) - - -@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_INTERFACE: interface, +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), } +) - hass.services.call(DOMAIN, SERVICE_VIRTUALKEY, data) - - -@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, +SCHEMA_SERVICE_PUT_PARAMSET = vol.Schema( + { + vol.Required(ATTR_INTERFACE): cv.string, + vol.Required(ATTR_ADDRESS): vol.All(cv.string, vol.Upper), + vol.Required(ATTR_PARAMSET_KEY): vol.All(cv.string, vol.Upper), + vol.Required(ATTR_PARAMSET): dict, } - - hass.services.call(DOMAIN, SERVICE_SET_VARIABLE_VALUE, data) - - -@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_INTERFACE: interface, - } - - hass.services.call(DOMAIN, SERVICE_SET_DEVICE_VALUE, data) - - -@bind_hass -def put_paramset(hass, interface, address, paramset_key, paramset): - """Call putParamset XML-RPC method of supplied interface.""" - data = { - ATTR_INTERFACE: interface, - ATTR_ADDRESS: address, - ATTR_PARAMSET_KEY: paramset_key, - ATTR_PARAMSET: paramset, - } - - hass.services.call(DOMAIN, SERVICE_PUT_PARAMSET, 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, {}) +) def setup(hass, config): """Set up the Homematic component.""" - from pyhomematic import HMConnection conf = config[DOMAIN] hass.data[DATA_CONF] = remotes = {} @@ -328,25 +375,27 @@ def setup(hass, config): # Create hosts-dictionary for pyhomematic 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), - 'jsonport': rconfig.get(CONF_JSONPORT), - 'username': rconfig.get(CONF_USERNAME), - 'password': rconfig.get(CONF_PASSWORD), - 'callbackip': rconfig.get(CONF_CALLBACK_IP), - 'callbackport': rconfig.get(CONF_CALLBACK_PORT), - 'connect': True, + "ip": rconfig.get(CONF_HOST), + "port": rconfig.get(CONF_PORT), + "path": rconfig.get(CONF_PATH), + "resolvenames": rconfig.get(CONF_RESOLVENAMES), + "jsonport": rconfig.get(CONF_JSONPORT), + "username": rconfig.get(CONF_USERNAME), + "password": rconfig.get(CONF_PASSWORD), + "callbackip": rconfig.get(CONF_CALLBACK_IP), + "callbackport": rconfig.get(CONF_CALLBACK_PORT), + "ssl": rconfig.get(CONF_SSL), + "verify_ssl": rconfig.get(CONF_VERIFY_SSL), + "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, + "ip": sconfig.get(CONF_HOST), + "port": DEFAULT_PORT, + "username": sconfig.get(CONF_USERNAME), + "password": sconfig.get(CONF_PASSWORD), + "connect": False, } # Create server thread @@ -356,15 +405,14 @@ def setup(hass, config): localport=config[DOMAIN].get(CONF_LOCAL_PORT, DEFAULT_LOCAL_PORT), remotes=remotes, systemcallback=bound_system_callback, - interface_id='homeassistant' + interface_id="homeassistant", ) # Start server thread, connect to hosts, initialize to receive events homematic.start() # Stops server when HASS is shutting down - hass.bus.listen_once( - EVENT_HOMEASSISTANT_STOP, hass.data[DATA_HOMEMATIC].stop) + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, hass.data[DATA_HOMEMATIC].stop) # Init homematic hubs entity_hubs = [] @@ -390,16 +438,18 @@ def setup(hass, config): # Channel doesn't exist for device if channel not in hmdevice.ACTIONNODE[param]: - _LOGGER.error("%i is not a channel in hm device %s", - channel, address) + _LOGGER.error("%i is not a channel in hm device %s", channel, address) return # Call parameter hmdevice.actionNodeData(param, True, channel) hass.services.register( - DOMAIN, SERVICE_VIRTUALKEY, _hm_service_virtualkey, - schema=SCHEMA_SERVICE_VIRTUALKEY) + DOMAIN, + SERVICE_VIRTUALKEY, + _hm_service_virtualkey, + schema=SCHEMA_SERVICE_VIRTUALKEY, + ) def _service_handle_value(service): """Service to call setValue method for HomeMatic system variable.""" @@ -408,8 +458,9 @@ def setup(hass, config): value = service.data[ATTR_VALUE] if entity_ids: - entities = [entity for entity in entity_hubs if - entity.entity_id in entity_ids] + entities = [ + entity for entity in entity_hubs if entity.entity_id in entity_ids + ] else: entities = entity_hubs @@ -421,16 +472,22 @@ def setup(hass, config): hub.hm_set_variable(name, value) hass.services.register( - DOMAIN, SERVICE_SET_VARIABLE_VALUE, _service_handle_value, - schema=SCHEMA_SERVICE_SET_VARIABLE_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.""" homematic.reconnect() hass.services.register( - DOMAIN, SERVICE_RECONNECT, _service_handle_reconnect, - schema=SCHEMA_SERVICE_RECONNECT) + DOMAIN, + SERVICE_RECONNECT, + _service_handle_reconnect, + schema=SCHEMA_SERVICE_RECONNECT, + ) def _service_handle_device(service): """Service to call setValue method for HomeMatic devices.""" @@ -438,6 +495,22 @@ def setup(hass, config): channel = service.data.get(ATTR_CHANNEL) param = service.data.get(ATTR_PARAM) value = service.data.get(ATTR_VALUE) + value_type = service.data.get(ATTR_VALUE_TYPE) + + # Convert value into correct XML-RPC Type. + # https://docs.python.org/3/library/xmlrpc.client.html#xmlrpc.client.ServerProxy + if value_type: + if value_type == "int": + value = int(value) + elif value_type == "double": + value = float(value) + elif value_type == "boolean": + value = bool(value) + elif value_type == "dateTime.iso8601": + value = datetime.strptime(value, "%Y%m%dT%H:%M:%S") + else: + # Default is 'string' + value = str(value) # Device not found hmdevice = _device_from_servicecall(hass, service) @@ -448,8 +521,11 @@ def setup(hass, config): hmdevice.setValue(param, value, channel) hass.services.register( - DOMAIN, SERVICE_SET_DEVICE_VALUE, _service_handle_device, - schema=SCHEMA_SERVICE_SET_DEVICE_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.""" @@ -461,8 +537,11 @@ def setup(hass, config): 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) + DOMAIN, + SERVICE_SET_INSTALL_MODE, + _service_handle_install_mode, + schema=SCHEMA_SERVICE_SET_INSTALL_MODE, + ) def _service_put_paramset(service): """Service to call the putParamset method on a HomeMatic connection.""" @@ -476,13 +555,19 @@ def setup(hass, config): _LOGGER.debug( "Calling putParamset: %s, %s, %s, %s", - interface, address, paramset_key, paramset + interface, + address, + paramset_key, + paramset, ) homematic.putParamset(interface, address, paramset_key, paramset) hass.services.register( - DOMAIN, SERVICE_PUT_PARAMSET, _service_put_paramset, - schema=SCHEMA_SERVICE_PUT_PARAMSET) + DOMAIN, + SERVICE_PUT_PARAMSET, + _service_put_paramset, + schema=SCHEMA_SERVICE_PUT_PARAMSET, + ) return True @@ -490,17 +575,17 @@ def setup(hass, config): def _system_callback_handler(hass, config, src, *args): """System callback handler.""" # New devices available at hub - if src == 'newDevices': + if src == "newDevices": (interface_id, dev_descriptions) = args - interface = interface_id.split('-')[-1] + interface = interface_id.split("-")[-1] # Device support active? - if not hass.data[DATA_CONF][interface]['connect']: + if not hass.data[DATA_CONF][interface]["connect"]: return addresses = [] for dev in dev_descriptions: - address = dev['ADDRESS'].split(':')[0] + address = dev["ADDRESS"].split(":")[0] if address not in hass.data[DATA_STORE]: hass.data[DATA_STORE].add(address) addresses.append(address) @@ -512,38 +597,42 @@ def _system_callback_handler(hass, config, src, *args): hmdevice = hass.data[DATA_HOMEMATIC].devices[interface].get(dev) if hmdevice.EVENTNODE: - hmdevice.setEventCallback( - callback=bound_event_callback, bequeath=True) + hmdevice.setEventCallback(callback=bound_event_callback, bequeath=True) # Create HASS entities if addresses: for component_name, discovery_type in ( - ('switch', DISCOVER_SWITCHES), - ('light', DISCOVER_LIGHTS), - ('cover', DISCOVER_COVER), - ('binary_sensor', DISCOVER_BINARY_SENSORS), - ('sensor', DISCOVER_SENSORS), - ('climate', DISCOVER_CLIMATE), - ('lock', DISCOVER_LOCKS)): + ("switch", DISCOVER_SWITCHES), + ("light", DISCOVER_LIGHTS), + ("cover", DISCOVER_COVER), + ("binary_sensor", DISCOVER_BINARY_SENSORS), + ("sensor", DISCOVER_SENSORS), + ("climate", DISCOVER_CLIMATE), + ("lock", DISCOVER_LOCKS), + ("binary_sensor", DISCOVER_BATTERY), + ): # Get all devices of a specific type - found_devices = _get_devices( - hass, discovery_type, addresses, interface) + found_devices = _get_devices(hass, discovery_type, addresses, interface) # When devices of this type are found # 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 - }, config) + discovery.load_platform( + hass, + component_name, + DOMAIN, + { + ATTR_DISCOVER_DEVICES: found_devices, + ATTR_DISCOVERY_TYPE: discovery_type, + }, + config, + ) # Homegear error message - elif src == 'error': + elif src == "error": _LOGGER.error("Error: %s", args) (interface_id, errorcode, message) = args - hass.bus.fire(EVENT_ERROR, { - ATTR_ERRORCODE: errorcode, - ATTR_MESSAGE: message - }) + hass.bus.fire(EVENT_ERROR, {ATTR_ERRORCODE: errorcode, ATTR_MESSAGE: message}) def _get_devices(hass, discovery_type, keys, interface): @@ -556,7 +645,10 @@ def _get_devices(hass, discovery_type, keys, interface): metadata = {} # Class not supported by discovery type - if class_name not in HM_DEVICE_TYPES[discovery_type]: + if ( + discovery_type != DISCOVER_BATTERY + and class_name not in HM_DEVICE_TYPES[discovery_type] + ): continue # Load metadata needed to generate a parameter list @@ -564,26 +656,39 @@ def _get_devices(hass, discovery_type, keys, interface): metadata.update(device.SENSORNODE) elif discovery_type == DISCOVER_BINARY_SENSORS: metadata.update(device.BINARYNODE) + elif discovery_type == DISCOVER_BATTERY: + if ATTR_LOWBAT in device.ATTRIBUTENODE: + metadata.update({ATTR_LOWBAT: device.ATTRIBUTENODE[ATTR_LOWBAT]}) + elif ATTR_LOW_BAT in device.ATTRIBUTENODE: + metadata.update({ATTR_LOW_BAT: device.ATTRIBUTENODE[ATTR_LOW_BAT]}) + else: + continue else: metadata.update({None: device.ELEMENT}) # Generate options for 1...n elements with 1...n parameters for param, channels in metadata.items(): - if param in HM_IGNORE_DISCOVERY_NODE and class_name not in \ - HM_IGNORE_DISCOVERY_NODE_EXCEPTIONS.get(param, []): + if ( + param in HM_IGNORE_DISCOVERY_NODE + and class_name not in HM_IGNORE_DISCOVERY_NODE_EXCEPTIONS.get(param, []) + ): continue + if discovery_type == DISCOVER_SWITCHES and class_name == "IPKeySwitchLevel": + channels.remove(8) + channels.remove(12) + if discovery_type == DISCOVER_LIGHTS and class_name == "IPKeySwitchLevel": + channels.remove(4) # Add devices - _LOGGER.debug("%s: Handling %s: %s: %s", - discovery_type, key, param, channels) + _LOGGER.debug( + "%s: Handling %s: %s: %s", discovery_type, key, param, channels + ) for channel in channels: name = _create_ha_id( - name=device.NAME, channel=channel, param=param, - count=len(channels) + name=device.NAME, channel=channel, param=param, count=len(channels) ) unique_id = _create_ha_id( - name=key, channel=channel, param=param, - count=len(channels) + name=key, channel=channel, param=param, count=len(channels) ) device_dict = { CONF_PLATFORM: "homematic", @@ -591,7 +696,7 @@ def _get_devices(hass, discovery_type, keys, interface): ATTR_INTERFACE: interface, ATTR_NAME: name, ATTR_CHANNEL: channel, - ATTR_UNIQUE_ID: unique_id + ATTR_UNIQUE_ID: unique_id, } if param is not None: device_dict[ATTR_PARAM] = param @@ -601,8 +706,7 @@ def _get_devices(hass, discovery_type, keys, interface): DEVICE_SCHEMA(device_dict) device_arr.append(device_dict) except vol.MultipleInvalid as err: - _LOGGER.error("Invalid device config: %s", - str(err)) + _LOGGER.error("Invalid device config: %s", str(err)) return device_arr @@ -614,15 +718,15 @@ def _create_ha_id(name, channel, param, count): # Has multiple elements/channels if count > 1 and param is None: - return "{} {}".format(name, channel) + return f"{name} {channel}" # With multiple parameters on first channel if count == 1 and param is not None: - return "{} {}".format(name, param) + return f"{name} {param}" # Multiple parameters with multiple channels if count > 1 and param is not None: - return "{} {} {}".format(name, channel, param) + return f"{name} {channel} {param}" def _hm_event_handler(hass, interface, device, caller, attribute, value): @@ -639,24 +743,19 @@ def _hm_event_handler(hass, interface, device, caller, attribute, value): if attribute not in hmdevice.EVENTNODE: return - _LOGGER.debug("Event %s for %s channel %i", attribute, - hmdevice.NAME, channel) + _LOGGER.debug("Event %s for %s channel %i", attribute, hmdevice.NAME, channel) # Keypress event if attribute in HM_PRESS_EVENTS: - hass.bus.fire(EVENT_KEYPRESS, { - ATTR_NAME: hmdevice.NAME, - ATTR_PARAM: attribute, - ATTR_CHANNEL: channel - }) + hass.bus.fire( + EVENT_KEYPRESS, + {ATTR_NAME: hmdevice.NAME, ATTR_PARAM: attribute, ATTR_CHANNEL: channel}, + ) return # Impulse event if attribute in HM_IMPULSE_EVENTS: - hass.bus.fire(EVENT_IMPULSE, { - ATTR_NAME: hmdevice.NAME, - ATTR_CHANNEL: channel - }) + hass.bus.fire(EVENT_IMPULSE, {ATTR_NAME: hmdevice.NAME, ATTR_CHANNEL: channel}) return _LOGGER.warning("Event is unknown and not forwarded") @@ -666,8 +765,8 @@ def _device_from_servicecall(hass, service): """Extract HomeMatic device from service call.""" address = service.data.get(ATTR_ADDRESS) interface = service.data.get(ATTR_INTERFACE) - if address == 'BIDCOS-RF': - address = 'BidCoS-RF' + if address == "BIDCOS-RF": + address = "BidCoS-RF" if interface: return hass.data[DATA_HOMEMATIC].devices[interface].get(address) @@ -690,12 +789,12 @@ class HMHub(Entity): self._state = None # Load data - self.hass.helpers.event.track_time_interval( - self._update_hub, SCAN_INTERVAL_HUB) + self.hass.helpers.event.track_time_interval(self._update_hub, SCAN_INTERVAL_HUB) self.hass.add_job(self._update_hub, None) self.hass.helpers.event.track_time_interval( - self._update_variables, SCAN_INTERVAL_VARIABLES) + self._update_variables, SCAN_INTERVAL_VARIABLES + ) self.hass.add_job(self._update_variables, None) @property @@ -788,10 +887,9 @@ class HMDevice(Entity): if self._state: self._state = self._state.upper() - @asyncio.coroutine - def async_added_to_hass(self): + async def async_added_to_hass(self): """Load data init callbacks.""" - yield from self.hass.async_add_job(self.link_homematic) + await self.hass.async_add_job(self.link_homematic) @property def unique_id(self): @@ -826,8 +924,8 @@ class HMDevice(Entity): attr[data[0]] = value # Static attributes - attr['id'] = self._hmdevice.ADDRESS - attr['interface'] = self._interface + attr["id"] = self._hmdevice.ADDRESS + attr["interface"] = self._interface return attr @@ -838,8 +936,7 @@ class HMDevice(Entity): # Initialize self._homematic = self.hass.data[DATA_HOMEMATIC] - self._hmdevice = \ - self._homematic.devices[self._interface][self._address] + self._hmdevice = self._homematic.devices[self._interface][self._address] self._connected = True try: @@ -850,16 +947,13 @@ class HMDevice(Entity): # Link events from pyhomematic self._subscribe_homematic_events() self._available = not self._hmdevice.UNREACH - # pylint: disable=broad-except - except Exception as err: + except Exception as err: # pylint: disable=broad-except self._connected = False - _LOGGER.error("Exception while linking %s: %s", - self._address, str(err)) + _LOGGER.error("Exception while linking %s: %s", self._address, str(err)) def _hm_event_callback(self, device, caller, attribute, value): """Handle all pyhomematic device events.""" - _LOGGER.debug("%s received event '%s' value: %s", self._name, - attribute, value) + _LOGGER.debug("%s received event '%s' value: %s", self._name, attribute, value) has_changed = False # Is data needed for this instance? @@ -870,11 +964,8 @@ class HMDevice(Entity): has_changed = True # Availability has changed - if attribute == 'UNREACH': - self._available = not bool(value) - has_changed = True - elif not self.available: - self._available = False + if self.available != (not self._hmdevice.UNREACH): + self._available = not self._hmdevice.UNREACH has_changed = True # If it has changed data point, update HASS @@ -884,13 +975,16 @@ class HMDevice(Entity): def _subscribe_homematic_events(self): """Subscribe all required events to handle job.""" channels_to_sub = set() - channels_to_sub.add(0) # Add channel 0 for UNREACH # Push data to channels_to_sub from hmdevice metadata - for metadata in (self._hmdevice.SENSORNODE, self._hmdevice.BINARYNODE, - self._hmdevice.ATTRIBUTENODE, - self._hmdevice.WRITENODE, self._hmdevice.EVENTNODE, - self._hmdevice.ACTIONNODE): + for metadata in ( + self._hmdevice.SENSORNODE, + self._hmdevice.BINARYNODE, + self._hmdevice.ATTRIBUTENODE, + self._hmdevice.WRITENODE, + self._hmdevice.EVENTNODE, + self._hmdevice.ACTIONNODE, + ): for node, channels in metadata.items(): # Data is needed for this instance if node in self._data: @@ -904,16 +998,14 @@ class HMDevice(Entity): try: channels_to_sub.add(int(channel)) except (ValueError, TypeError): - _LOGGER.error("Invalid channel in metadata from %s", - self._name) + _LOGGER.error("Invalid channel in metadata from %s", self._name) # Set callbacks for channel in channels_to_sub: - _LOGGER.debug( - "Subscribe channel %d from %s", channel, self._name) + _LOGGER.debug("Subscribe channel %d from %s", channel, self._name) self._hmdevice.setEventCallback( - callback=self._hm_event_callback, bequeath=False, - channel=channel) + callback=self._hm_event_callback, bequeath=False, channel=channel + ) def _load_data_from_hm(self): """Load first value from pyhomematic.""" @@ -922,11 +1014,11 @@ class HMDevice(Entity): # Read data from pyhomematic for metadata, funct in ( - (self._hmdevice.ATTRIBUTENODE, - self._hmdevice.getAttributeData), - (self._hmdevice.WRITENODE, self._hmdevice.getWriteData), - (self._hmdevice.SENSORNODE, self._hmdevice.getSensorData), - (self._hmdevice.BINARYNODE, self._hmdevice.getBinaryData)): + (self._hmdevice.ATTRIBUTENODE, self._hmdevice.getAttributeData), + (self._hmdevice.WRITENODE, self._hmdevice.getWriteData), + (self._hmdevice.SENSORNODE, self._hmdevice.getSensorData), + (self._hmdevice.BINARYNODE, self._hmdevice.getBinaryData), + ): for node in metadata: if metadata[node] and node in self._data: self._data[node] = funct(name=node, channel=self._channel) diff --git a/homeassistant/components/homematic/binary_sensor.py b/homeassistant/components/homematic/binary_sensor.py new file mode 100644 index 000000000..cc2907c64 --- /dev/null +++ b/homeassistant/components/homematic/binary_sensor.py @@ -0,0 +1,92 @@ +"""Support for HomeMatic binary sensors.""" +import logging + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_MOTION, + DEVICE_CLASS_OPENING, + DEVICE_CLASS_PRESENCE, + DEVICE_CLASS_SMOKE, + BinarySensorDevice, +) +from homeassistant.components.homematic import ATTR_DISCOVERY_TYPE, DISCOVER_BATTERY + +from . import ATTR_DISCOVER_DEVICES, HMDevice + +_LOGGER = logging.getLogger(__name__) + +SENSOR_TYPES_CLASS = { + "IPShutterContact": DEVICE_CLASS_OPENING, + "IPShutterContactSabotage": DEVICE_CLASS_OPENING, + "MaxShutterContact": DEVICE_CLASS_OPENING, + "Motion": DEVICE_CLASS_MOTION, + "MotionV2": DEVICE_CLASS_MOTION, + "PresenceIP": DEVICE_CLASS_PRESENCE, + "Remote": None, + "RemoteMotion": None, + "ShutterContact": DEVICE_CLASS_OPENING, + "Smoke": DEVICE_CLASS_SMOKE, + "SmokeV2": DEVICE_CLASS_SMOKE, + "TiltSensor": None, + "WeatherSensor": None, +} + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the HomeMatic binary sensor platform.""" + if discovery_info is None: + return + + devices = [] + for conf in discovery_info[ATTR_DISCOVER_DEVICES]: + if discovery_info[ATTR_DISCOVERY_TYPE] == DISCOVER_BATTERY: + devices.append(HMBatterySensor(conf)) + else: + devices.append(HMBinarySensor(conf)) + + add_entities(devices) + + +class HMBinarySensor(HMDevice, BinarySensorDevice): + """Representation of a binary HomeMatic device.""" + + @property + def is_on(self): + """Return true if switch is on.""" + if not self.available: + return False + return bool(self._hm_get_state()) + + @property + def device_class(self): + """Return the class of this sensor from DEVICE_CLASSES.""" + # If state is MOTION (Only RemoteMotion working) + if self._state == "MOTION": + return DEVICE_CLASS_MOTION + return SENSOR_TYPES_CLASS.get(self._hmdevice.__class__.__name__) + + def _init_data_struct(self): + """Generate the data dictionary (self._data) from metadata.""" + # Add state to data struct + if self._state: + self._data.update({self._state: None}) + + +class HMBatterySensor(HMDevice, BinarySensorDevice): + """Representation of an HomeMatic low battery sensor.""" + + @property + def device_class(self): + """Return battery as a device class.""" + return DEVICE_CLASS_BATTERY + + @property + def is_on(self): + """Return True if battery is low.""" + return bool(self._hm_get_state()) + + def _init_data_struct(self): + """Generate the data dictionary (self._data) from metadata.""" + # Add state to data struct + if self._state: + self._data.update({self._state: None}) diff --git a/homeassistant/components/homematic/climate.py b/homeassistant/components/homematic/climate.py new file mode 100644 index 000000000..935ebb9b4 --- /dev/null +++ b/homeassistant/components/homematic/climate.py @@ -0,0 +1,198 @@ +"""Support for Homematic thermostats.""" +import logging + +from homeassistant.components.climate import ClimateDevice +from homeassistant.components.climate.const import ( + HVAC_MODE_AUTO, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + PRESET_BOOST, + PRESET_COMFORT, + PRESET_ECO, + SUPPORT_PRESET_MODE, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS + +from . import ATTR_DISCOVER_DEVICES, HM_ATTRIBUTE_SUPPORT, HMDevice + +_LOGGER = logging.getLogger(__name__) + +HM_TEMP_MAP = ["ACTUAL_TEMPERATURE", "TEMPERATURE"] + +HM_HUMI_MAP = ["ACTUAL_HUMIDITY", "HUMIDITY"] + +HM_PRESET_MAP = { + "BOOST_MODE": PRESET_BOOST, + "COMFORT_MODE": PRESET_COMFORT, + "LOWERING_MODE": PRESET_ECO, +} + +HM_CONTROL_MODE = "CONTROL_MODE" +HMIP_CONTROL_MODE = "SET_POINT_MODE" + +SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Homematic thermostat platform.""" + if discovery_info is None: + return + + devices = [] + for conf in discovery_info[ATTR_DISCOVER_DEVICES]: + new_device = HMThermostat(conf) + devices.append(new_device) + + add_entities(devices) + + +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.""" + return TEMP_CELSIUS + + @property + def hvac_mode(self): + """Return hvac operation ie. heat, cool mode. + + Need to be one of HVAC_MODE_*. + """ + if self.target_temperature <= self._hmdevice.OFF_VALUE + 0.5: + return HVAC_MODE_OFF + if "MANU_MODE" in self._hmdevice.ACTIONNODE: + if self._hm_control_mode == self._hmdevice.MANU_MODE: + return HVAC_MODE_HEAT + return HVAC_MODE_AUTO + + # Simple devices + if self._data.get("BOOST_MODE"): + return HVAC_MODE_AUTO + return HVAC_MODE_HEAT + + @property + def hvac_modes(self): + """Return the list of available hvac operation modes. + + Need to be a subset of HVAC_MODES. + """ + if "AUTO_MODE" in self._hmdevice.ACTIONNODE: + return [HVAC_MODE_AUTO, HVAC_MODE_HEAT, HVAC_MODE_OFF] + return [HVAC_MODE_HEAT, HVAC_MODE_OFF] + + @property + def preset_mode(self): + """Return the current preset mode, e.g., home, away, temp.""" + if self._data.get("BOOST_MODE", False): + return "boost" + + if not self._hm_control_mode: + return None + + mode = HM_ATTRIBUTE_SUPPORT[HM_CONTROL_MODE][1][self._hm_control_mode] + mode = mode.lower() + + # Filter HVAC states + if mode not in (HVAC_MODE_AUTO, HVAC_MODE_HEAT): + return None + return mode + + @property + def preset_modes(self): + """Return a list of available preset modes.""" + preset_modes = [] + for mode in self._hmdevice.ACTIONNODE: + if mode in HM_PRESET_MAP: + preset_modes.append(HM_PRESET_MAP[mode]) + return preset_modes + + @property + def current_humidity(self): + """Return the current humidity.""" + for node in HM_HUMI_MAP: + if node in self._data: + return self._data[node] + + @property + def current_temperature(self): + """Return the current temperature.""" + for node in HM_TEMP_MAP: + if node in self._data: + return self._data[node] + + @property + def target_temperature(self): + """Return the target temperature.""" + return self._data.get(self._state) + + def set_temperature(self, **kwargs): + """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return None + + self._hmdevice.writeNodeData(self._state, float(temperature)) + + def set_hvac_mode(self, hvac_mode): + """Set new target hvac mode.""" + if hvac_mode == HVAC_MODE_AUTO: + self._hmdevice.MODE = self._hmdevice.AUTO_MODE + elif hvac_mode == HVAC_MODE_HEAT: + self._hmdevice.MODE = self._hmdevice.MANU_MODE + elif hvac_mode == HVAC_MODE_OFF: + self._hmdevice.turnoff() + + def set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + if preset_mode == PRESET_BOOST: + self._hmdevice.MODE = self._hmdevice.BOOST_MODE + elif preset_mode == PRESET_COMFORT: + self._hmdevice.MODE = self._hmdevice.COMFORT_MODE + elif preset_mode == PRESET_ECO: + self._hmdevice.MODE = self._hmdevice.LOWERING_MODE + + @property + def min_temp(self): + """Return the minimum temperature.""" + return 4.5 + + @property + def max_temp(self): + """Return the maximum temperature.""" + return 30.5 + + @property + def target_temperature_step(self): + """Return the supported step of target temperature.""" + return 0.5 + + @property + def _hm_control_mode(self): + """Return Control mode.""" + if HMIP_CONTROL_MODE in self._data: + return self._data[HMIP_CONTROL_MODE] + + # Homematic + return self._data.get("CONTROL_MODE") + + 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] = None + + if ( + HM_CONTROL_MODE in self._hmdevice.ATTRIBUTENODE + or HMIP_CONTROL_MODE in self._hmdevice.ATTRIBUTENODE + ): + self._data[HM_CONTROL_MODE] = None + + for node in self._hmdevice.SENSORNODE.keys(): + self._data[node] = None diff --git a/homeassistant/components/homematic/cover.py b/homeassistant/components/homematic/cover.py new file mode 100644 index 000000000..893b3ce89 --- /dev/null +++ b/homeassistant/components/homematic/cover.py @@ -0,0 +1,107 @@ +"""Support for HomeMatic covers.""" +import logging + +from homeassistant.components.cover import ( + ATTR_POSITION, + ATTR_TILT_POSITION, + CoverDevice, +) +from homeassistant.const import STATE_UNKNOWN + +from . import ATTR_DISCOVER_DEVICES, HMDevice + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the platform.""" + if discovery_info is None: + return + + devices = [] + for conf in discovery_info[ATTR_DISCOVER_DEVICES]: + new_device = HMCover(conf) + devices.append(new_device) + + add_entities(devices) + + +class HMCover(HMDevice, CoverDevice): + """Representation a HomeMatic Cover.""" + + @property + def current_cover_position(self): + """ + Return current position of cover. + + None is unknown, 0 is closed, 100 is fully open. + """ + return int(self._hm_get_state() * 100) + + def set_cover_position(self, **kwargs): + """Move the cover to a specific position.""" + if ATTR_POSITION in kwargs: + position = float(kwargs[ATTR_POSITION]) + position = min(100, max(0, position)) + level = position / 100.0 + self._hmdevice.set_level(level, self._channel) + + @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 + return None + + def open_cover(self, **kwargs): + """Open the cover.""" + self._hmdevice.move_up(self._channel) + + def close_cover(self, **kwargs): + """Close the cover.""" + self._hmdevice.move_down(self._channel) + + def stop_cover(self, **kwargs): + """Stop the device if in motion.""" + self._hmdevice.stop(self._channel) + + def _init_data_struct(self): + """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/homematic/light.py b/homeassistant/components/homematic/light.py new file mode 100644 index 000000000..29992bcce --- /dev/null +++ b/homeassistant/components/homematic/light.py @@ -0,0 +1,119 @@ +"""Support for Homematic lights.""" +import logging + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_EFFECT, + ATTR_HS_COLOR, + ATTR_TRANSITION, + SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, + SUPPORT_EFFECT, + Light, +) + +from . import ATTR_DISCOVER_DEVICES, HMDevice + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_HOMEMATIC = SUPPORT_BRIGHTNESS + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Homematic light platform.""" + if discovery_info is None: + return + + devices = [] + for conf in discovery_info[ATTR_DISCOVER_DEVICES]: + new_device = HMLight(conf) + devices.append(new_device) + + add_entities(devices) + + +class HMLight(HMDevice, Light): + """Representation of a Homematic light.""" + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + # Is dimmer? + if self._state == "LEVEL": + return int(self._hm_get_state() * 255) + return None + + @property + def is_on(self): + """Return true if light is on.""" + try: + return self._hm_get_state() > 0 + except TypeError: + return False + + @property + def supported_features(self): + """Flag supported features.""" + features = SUPPORT_BRIGHTNESS + if "COLOR" in self._hmdevice.WRITENODE: + features |= SUPPORT_COLOR + if "PROGRAM" in self._hmdevice.WRITENODE: + features |= SUPPORT_EFFECT + return features + + @property + def hs_color(self): + """Return the hue and saturation color value [float, float].""" + if not self.supported_features & SUPPORT_COLOR: + return None + hue, sat = self._hmdevice.get_hs_color(self._channel) + return hue * 360.0, sat * 100.0 + + @property + def effect_list(self): + """Return the list of supported effects.""" + if not self.supported_features & SUPPORT_EFFECT: + return None + return self._hmdevice.get_effect_list() + + @property + def effect(self): + """Return the current color change program of the light.""" + if not self.supported_features & SUPPORT_EFFECT: + return None + return self._hmdevice.get_effect() + + def turn_on(self, **kwargs): + """Turn the light on and/or change color or color effect settings.""" + if ATTR_TRANSITION in kwargs: + self._hmdevice.setValue("RAMP_TIME", kwargs[ATTR_TRANSITION]) + + if ATTR_BRIGHTNESS in kwargs and self._state == "LEVEL": + percent_bright = float(kwargs[ATTR_BRIGHTNESS]) / 255 + self._hmdevice.set_level(percent_bright, self._channel) + elif ATTR_HS_COLOR not in kwargs and ATTR_EFFECT not in kwargs: + self._hmdevice.on(self._channel) + + if ATTR_HS_COLOR in kwargs: + self._hmdevice.set_hs_color( + hue=kwargs[ATTR_HS_COLOR][0] / 360.0, + saturation=kwargs[ATTR_HS_COLOR][1] / 100.0, + channel=self._channel, + ) + if ATTR_EFFECT in kwargs: + self._hmdevice.set_effect(kwargs[ATTR_EFFECT]) + + def turn_off(self, **kwargs): + """Turn the light off.""" + self._hmdevice.off(self._channel) + + def _init_data_struct(self): + """Generate a data dict (self._data) from the Homematic metadata.""" + # Use LEVEL + self._state = "LEVEL" + self._data[self._state] = None + + if self.supported_features & SUPPORT_COLOR: + self._data.update({"COLOR": None}) + if self.supported_features & SUPPORT_EFFECT: + self._data.update({"PROGRAM": None}) diff --git a/homeassistant/components/homematic/lock.py b/homeassistant/components/homematic/lock.py new file mode 100644 index 000000000..7f796b328 --- /dev/null +++ b/homeassistant/components/homematic/lock.py @@ -0,0 +1,52 @@ +"""Support for Homematic locks.""" +import logging + +from homeassistant.components.lock import SUPPORT_OPEN, LockDevice +from homeassistant.const import STATE_UNKNOWN + +from . import ATTR_DISCOVER_DEVICES, HMDevice + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, 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_entities(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/homematic/manifest.json b/homeassistant/components/homematic/manifest.json new file mode 100644 index 000000000..8a86fd19c --- /dev/null +++ b/homeassistant/components/homematic/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "homematic", + "name": "Homematic", + "documentation": "https://www.home-assistant.io/integrations/homematic", + "requirements": [ + "pyhomematic==0.1.62" + ], + "dependencies": [], + "codeowners": [ + "@pvizeli", + "@danielperna84" + ] +} diff --git a/homeassistant/components/homematic/notify.py b/homeassistant/components/homematic/notify.py new file mode 100644 index 000000000..9fd94b983 --- /dev/null +++ b/homeassistant/components/homematic/notify.py @@ -0,0 +1,66 @@ +"""Notification support for Homematic.""" +import logging + +import voluptuous as vol + +from homeassistant.components.notify import ( + ATTR_DATA, + PLATFORM_SCHEMA, + BaseNotificationService, +) +import homeassistant.helpers.config_validation as cv +import homeassistant.helpers.template as template_helper + +from . import ( + ATTR_ADDRESS, + ATTR_CHANNEL, + ATTR_INTERFACE, + ATTR_PARAM, + ATTR_VALUE, + DOMAIN, + SERVICE_SET_DEVICE_VALUE, +) + +_LOGGER = logging.getLogger(__name__) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + 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_INTERFACE): cv.string, + } +) + + +def get_service(hass, config, discovery_info=None): + """Get the Homematic notification service.""" + data = { + ATTR_ADDRESS: config[ATTR_ADDRESS], + ATTR_CHANNEL: config[ATTR_CHANNEL], + ATTR_PARAM: config[ATTR_PARAM], + ATTR_VALUE: config[ATTR_VALUE], + } + if ATTR_INTERFACE in config: + data[ATTR_INTERFACE] = config[ATTR_INTERFACE] + + return HomematicNotificationService(hass, data) + + +class HomematicNotificationService(BaseNotificationService): + """Implement the notification service for Homematic.""" + + def __init__(self, hass, data): + """Initialize the service.""" + self.hass = hass + self.data = data + + def send_message(self, message="", **kwargs): + """Send a notification to the device.""" + data = {**self.data, **kwargs.get(ATTR_DATA, {})} + + if data.get(ATTR_VALUE) is not None: + templ = template_helper.Template(self.data[ATTR_VALUE], self.hass) + data[ATTR_VALUE] = template_helper.render_complex(templ, None) + + self.hass.services.call(DOMAIN, SERVICE_SET_DEVICE_VALUE, data) diff --git a/homeassistant/components/homematic/sensor.py b/homeassistant/components/homematic/sensor.py new file mode 100644 index 000000000..10c402a0d --- /dev/null +++ b/homeassistant/components/homematic/sensor.py @@ -0,0 +1,122 @@ +"""Support for HomeMatic sensors.""" +import logging + +from homeassistant.const import ( + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_POWER, + DEVICE_CLASS_TEMPERATURE, + ENERGY_WATT_HOUR, + POWER_WATT, + STATE_UNKNOWN, +) + +from . import ATTR_DISCOVER_DEVICES, HMDevice + +_LOGGER = logging.getLogger(__name__) + +HM_STATE_HA_CAST = { + "RotaryHandleSensor": {0: "closed", 1: "tilted", 2: "open"}, + "RotaryHandleSensorIP": {0: "closed", 1: "tilted", 2: "open"}, + "WaterSensor": {0: "dry", 1: "wet", 2: "water"}, + "CO2Sensor": {0: "normal", 1: "added", 2: "strong"}, + "IPSmoke": {0: "off", 1: "primary", 2: "intrusion", 3: "secondary"}, + "RFSiren": { + 0: "disarmed", + 1: "extsens_armed", + 2: "allsens_armed", + 3: "alarm_blocked", + }, +} + +HM_UNIT_HA_CAST = { + "HUMIDITY": "%", + "TEMPERATURE": "°C", + "ACTUAL_TEMPERATURE": "°C", + "BRIGHTNESS": "#", + "POWER": POWER_WATT, + "CURRENT": "mA", + "VOLTAGE": "V", + "ENERGY_COUNTER": ENERGY_WATT_HOUR, + "GAS_POWER": "m3", + "GAS_ENERGY_COUNTER": "m3", + "LUX": "lx", + "ILLUMINATION": "lx", + "CURRENT_ILLUMINATION": "lx", + "AVERAGE_ILLUMINATION": "lx", + "LOWEST_ILLUMINATION": "lx", + "HIGHEST_ILLUMINATION": "lx", + "RAIN_COUNTER": "mm", + "WIND_SPEED": "km/h", + "WIND_DIRECTION": "°", + "WIND_DIRECTION_RANGE": "°", + "SUNSHINEDURATION": "#", + "AIR_PRESSURE": "hPa", + "FREQUENCY": "Hz", + "VALUE": "#", +} + +HM_DEVICE_CLASS_HA_CAST = { + "HUMIDITY": DEVICE_CLASS_HUMIDITY, + "TEMPERATURE": DEVICE_CLASS_TEMPERATURE, + "ACTUAL_TEMPERATURE": DEVICE_CLASS_TEMPERATURE, + "LUX": DEVICE_CLASS_ILLUMINANCE, + "CURRENT_ILLUMINATION": DEVICE_CLASS_ILLUMINANCE, + "AVERAGE_ILLUMINATION": DEVICE_CLASS_ILLUMINANCE, + "LOWEST_ILLUMINATION": DEVICE_CLASS_ILLUMINANCE, + "HIGHEST_ILLUMINATION": DEVICE_CLASS_ILLUMINANCE, + "POWER": DEVICE_CLASS_POWER, + "CURRENT": DEVICE_CLASS_POWER, +} + +HM_ICON_HA_CAST = {"WIND_SPEED": "mdi:weather-windy", "BRIGHTNESS": "mdi:invert-colors"} + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the HomeMatic sensor platform.""" + if discovery_info is None: + return + + devices = [] + for conf in discovery_info[ATTR_DISCOVER_DEVICES]: + new_device = HMSensor(conf) + devices.append(new_device) + + add_entities(devices) + + +class HMSensor(HMDevice): + """Representation of a HomeMatic sensor.""" + + @property + def state(self): + """Return the state of the sensor.""" + # Does a cast exist for this class? + name = self._hmdevice.__class__.__name__ + if name in HM_STATE_HA_CAST: + return HM_STATE_HA_CAST[name].get(self._hm_get_state()) + + # No cast, return original value + return self._hm_get_state() + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return HM_UNIT_HA_CAST.get(self._state) + + @property + def device_class(self): + """Return the device class to use in the frontend, if any.""" + return HM_DEVICE_CLASS_HA_CAST.get(self._state) + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return HM_ICON_HA_CAST.get(self._state) + + def _init_data_struct(self): + """Generate a data dictionary (self._data) from metadata.""" + if self._state: + self._data.update({self._state: STATE_UNKNOWN}) + else: + _LOGGER.critical("Unable to initialize sensor: %s", self._name) diff --git a/homeassistant/components/homematic/switch.py b/homeassistant/components/homematic/switch.py new file mode 100644 index 000000000..b77b3a1f7 --- /dev/null +++ b/homeassistant/components/homematic/switch.py @@ -0,0 +1,62 @@ +"""Support for HomeMatic switches.""" +import logging + +from homeassistant.components.switch import SwitchDevice +from homeassistant.const import STATE_UNKNOWN + +from . import ATTR_DISCOVER_DEVICES, HMDevice + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the HomeMatic switch platform.""" + if discovery_info is None: + return + + devices = [] + for conf in discovery_info[ATTR_DISCOVER_DEVICES]: + new_device = HMSwitch(conf) + devices.append(new_device) + + add_entities(devices) + + +class HMSwitch(HMDevice, SwitchDevice): + """Representation of a HomeMatic switch.""" + + @property + def is_on(self): + """Return True if switch is on.""" + try: + return self._hm_get_state() > 0 + except TypeError: + return False + + @property + def today_energy_kwh(self): + """Return the current power usage in kWh.""" + if "ENERGY_COUNTER" in self._data: + try: + return self._data["ENERGY_COUNTER"] / 1000 + except ZeroDivisionError: + return 0 + + return None + + def turn_on(self, **kwargs): + """Turn the switch on.""" + self._hmdevice.on(self._channel) + + def turn_off(self, **kwargs): + """Turn the switch off.""" + self._hmdevice.off(self._channel) + + def _init_data_struct(self): + """Generate the data dictionary (self._data) from metadata.""" + self._state = "STATE" + self._data.update({self._state: STATE_UNKNOWN}) + + # Need sensor values for SwitchPowermeter + for node in self._hmdevice.SENSORNODE: + self._data.update({node: STATE_UNKNOWN}) diff --git a/homeassistant/components/homematicip_cloud/.translations/bg.json b/homeassistant/components/homematicip_cloud/.translations/bg.json new file mode 100644 index 000000000..d2b9a1b17 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/bg.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "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", + "connection_aborted": "\u041d\u0435\u0432\u044a\u0437\u043c\u043e\u0436\u043d\u043e\u0441\u0442 \u0437\u0430 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u0441 HMIP \u0441\u044a\u0440\u0432\u044a\u0440", + "unknown": "\u0412\u044a\u0437\u043d\u0438\u043a\u043d\u0430 \u043d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430." + }, + "error": { + "invalid_pin": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u0435\u043d \u041f\u0418\u041d \u043a\u043e\u0434, \u043c\u043e\u043b\u044f \u043e\u043f\u0438\u0442\u0430\u0439\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e.", + "press_the_button": "\u041c\u043e\u043b\u044f, \u043d\u0430\u0442\u0438\u0441\u043d\u0435\u0442\u0435 \u0441\u0438\u043d\u0438\u044f \u0431\u0443\u0442\u043e\u043d.", + "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.", + "timeout_button": "\u0421\u0438\u043d\u0438\u044f \u0431\u0443\u0442\u043e\u043d \u043d\u0435 \u0431\u0435 \u043d\u0430\u0442\u0438\u0441\u043d\u0430\u0442 \u0441\u0432\u043e\u0435\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u043e, \u043c\u043e\u043b\u044f \u043e\u043f\u0438\u0442\u0430\u0439\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e." + }, + "step": { + "init": { + "data": { + "hapid": "ID \u043d\u0430 \u0431\u0430\u0437\u043e\u0432\u0430 \u0441\u0442\u0430\u043d\u0446\u0438\u044f (SGTIN)", + "name": "\u0418\u043c\u0435 (\u043d\u0435\u0437\u0430\u0434\u044a\u043b\u0436\u0438\u0442\u0435\u043b\u043d\u043e, \u0438\u0437\u043f\u043e\u043b\u0437\u0432\u0430 \u0441\u0435 \u043a\u0430\u0442\u043e \u043f\u0440\u0435\u0444\u0438\u043a\u0441 \u043d\u0430 \u0438\u043c\u0435\u043d\u0430\u0442\u0430 \u043d\u0430 \u0432\u0441\u0438\u0447\u043a\u0438 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430)", + "pin": "\u041f\u0418\u041d \u043a\u043e\u0434 (\u043d\u0435\u0437\u0430\u0434\u044a\u043b\u0436\u0438\u0442\u0435\u043b\u043d\u043e)" + }, + "title": "\u0418\u0437\u0431\u0435\u0440\u0435\u0442\u0435 HomematicIP \u0431\u0430\u0437\u043e\u0432\u0430 \u0441\u0442\u0430\u043d\u0446\u0438\u044f" + }, + "link": { + "description": "\u041d\u0430\u0442\u0438\u0441\u043d\u0435\u0442\u0435 \u0441\u0438\u043d\u0438\u044f \u0431\u0443\u0442\u043e\u043d \u043d\u0430 \u0431\u0430\u0437\u043e\u0432\u0430\u0442\u0430 \u0441\u0442\u0430\u043d\u0446\u0438\u044f \u0438 \u043d\u0430\u0442\u0438\u0441\u043d\u0435\u0442\u0435 \u0431\u0443\u0442\u043e\u043d\u0430 \"\u0418\u0437\u043f\u0440\u0430\u0449\u0430\u043d\u0435\", \u0437\u0430 \u0434\u0430 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0430\u0442\u0435 HomematicIP \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 \u043d\u0430 \u0431\u0430\u0437\u043e\u0432\u0430\u0442\u0430 \u0441\u0442\u0430\u043d\u0446\u0438\u044f](/static/images/config_flows/config_homematicip_cloud.png)", + "title": "\u0421\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u043d\u0430 \u0431\u0430\u0437\u043e\u0432\u0430 \u0441\u0442\u0430\u043d\u0446\u0438\u044f" + } + }, + "title": "HomematicIP \u041e\u0431\u043b\u0430\u043a" + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/ca.json b/homeassistant/components/homematicip_cloud/.translations/ca.json index aab974ba1..f7c149709 100644 --- a/homeassistant/components/homematicip_cloud/.translations/ca.json +++ b/homeassistant/components/homematicip_cloud/.translations/ca.json @@ -2,15 +2,14 @@ "config": { "abort": { "already_configured": "El punt d'acc\u00e9s ja est\u00e0 configurat", - "conection_aborted": "No s'ha pogut connectar al servidor HMIP", "connection_aborted": "No s'ha pogut connectar al servidor HMIP", "unknown": "S'ha produ\u00eft un error desconegut." }, "error": { "invalid_pin": "Codi PIN inv\u00e0lid, torna-ho a provar.", - "press_the_button": "Si us plau, premeu el bot\u00f3 blau.", - "register_failed": "Error al registrar, torneu-ho a provar.", - "timeout_button": "Temps d'espera per pr\u00e9mer el bot\u00f3 blau esgotat, torneu-ho a provar." + "press_the_button": "Si us plau, prem el bot\u00f3 blau.", + "register_failed": "Error al registrar, torna-ho a provar.", + "timeout_button": "El temps d'espera m\u00e0xim per pr\u00e9mer el bot\u00f3 blau s'ha esgotat, torna-ho a provar." }, "step": { "init": { @@ -19,11 +18,11 @@ "name": "Nom (opcional, s'utilitza com a nom prefix per a tots els dispositius)", "pin": "Codi PIN (opcional)" }, - "title": "Trieu el punt d'acc\u00e9s HomematicIP" + "title": "Tria el punt d'acc\u00e9s HomematicIP" }, "link": { - "description": "Premeu el bot\u00f3 blau del punt d'acc\u00e9s i el bot\u00f3 de enviar per registrar HomematicIP amb Home Assistent. \n\n ![Ubicaci\u00f3 del bot\u00f3 al pont](/static/images/config_flows/config_homematicip_cloud.png)", - "title": "Enlla\u00e7ar punt d'acc\u00e9s" + "description": "Prem el bot\u00f3 blau del punt d'acc\u00e9s i el bot\u00f3 Envia per registrar HomematicIP amb Home Assistent. \n\n![Ubicaci\u00f3 del bot\u00f3 al pont](/static/images/config_flows/config_homematicip_cloud.png)", + "title": "Enlla\u00e7 amb punt d'acc\u00e9s" } }, "title": "HomematicIP Cloud" diff --git a/homeassistant/components/homematicip_cloud/.translations/cs.json b/homeassistant/components/homematicip_cloud/.translations/cs.json index 4030450e5..fa98029f6 100644 --- a/homeassistant/components/homematicip_cloud/.translations/cs.json +++ b/homeassistant/components/homematicip_cloud/.translations/cs.json @@ -2,7 +2,6 @@ "config": { "abort": { "already_configured": "P\u0159\u00edstupov\u00fd bod je ji\u017e nakonfigurov\u00e1n", - "conection_aborted": "Nelze se p\u0159ipojit k serveru HMIP", "connection_aborted": "Nelze se p\u0159ipojit k HMIP serveru", "unknown": "Do\u0161lo k nezn\u00e1m\u00e9 chyb\u011b" }, diff --git a/homeassistant/components/homematicip_cloud/.translations/da.json b/homeassistant/components/homematicip_cloud/.translations/da.json index 7473b4a7b..4b8371fc7 100644 --- a/homeassistant/components/homematicip_cloud/.translations/da.json +++ b/homeassistant/components/homematicip_cloud/.translations/da.json @@ -1,14 +1,30 @@ { "config": { + "abort": { + "already_configured": "Access point er allerede konfigureret", + "connection_aborted": "Kunne ikke oprette forbindelse til HMIP-serveren", + "unknown": "Ukendt fejl opstod" + }, "error": { - "invalid_pin": "Ugyldig PIN, pr\u00f8v igen." + "invalid_pin": "Ugyldig PIN, pr\u00f8v igen.", + "press_the_button": "Tryk venligst p\u00e5 den bl\u00e5 knap.", + "register_failed": "Fejl ved registrering, pr\u00f8v venligst igen.", + "timeout_button": "Tryk p\u00e5 bl\u00e5 knap timeout, pr\u00f8v venligst igen." }, "step": { "init": { "data": { + "hapid": "Access point ID (SGTIN)", + "name": "Navn (valgfrit, bruges som pr\u00e6fiks til navnet for alle enheder)", "pin": "Pin kode (valgfri)" - } + }, + "title": "V\u00e6lg HomematicIP Access point" + }, + "link": { + "description": "Tryk p\u00e5 den bl\u00e5 knap p\u00e5 adgangspunktet og send knappen for at registrere HomematicIP med Home Assistant.\n\n ![Placering af knap p\u00e5 bridge](/static/images/config_flows/config_homematicip_cloud.png)", + "title": "Link adgangspunkt" } - } + }, + "title": "HomematicIP Cloud" } } \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/de.json b/homeassistant/components/homematicip_cloud/.translations/de.json index fdccac0d2..c2a7579e4 100644 --- a/homeassistant/components/homematicip_cloud/.translations/de.json +++ b/homeassistant/components/homematicip_cloud/.translations/de.json @@ -2,15 +2,14 @@ "config": { "abort": { "already_configured": "Der Accesspoint ist bereits konfiguriert", - "conection_aborted": "Keine Verbindung zum HMIP-Server m\u00f6glich", "connection_aborted": "Konnte nicht mit HMIP Server verbinden", "unknown": "Ein unbekannter Fehler ist aufgetreten." }, "error": { - "invalid_pin": "Ung\u00fcltige PIN, bitte versuchen Sie es erneut.", - "press_the_button": "Bitte dr\u00fccken Sie die blaue Taste.", - "register_failed": "Registrierung fehlgeschlagen, bitte versuchen Sie es erneut.", - "timeout_button": "Zeit\u00fcberschreitung beim Dr\u00fccken der blauen Taste. Bitte versuchen Sie es erneut." + "invalid_pin": "Ung\u00fcltige PIN, bitte versuche es erneut.", + "press_the_button": "Bitte dr\u00fccke die blaue Taste.", + "register_failed": "Registrierung fehlgeschlagen, bitte versuche es erneut.", + "timeout_button": "Zeit\u00fcberschreitung beim Dr\u00fccken der blauen Taste. Bitte versuche es erneut." }, "step": { "init": { @@ -19,10 +18,10 @@ "name": "Name (optional, wird als Pr\u00e4fix f\u00fcr alle Ger\u00e4te verwendet)", "pin": "PIN Code (optional)" }, - "title": "HometicIP Accesspoint ausw\u00e4hlen" + "title": "HomematicIP Accesspoint ausw\u00e4hlen" }, "link": { - "description": "Dr\u00fccken Sie den blauen Taster auf dem Accesspoint, sowie den Senden Button um HomematicIP mit Home Assistant zu verbinden.\n\n![Position des Tasters auf dem AP](/static/images/config_flows/config_homematicip_cloud.png)", + "description": "Dr\u00fccke den blauen Taster auf dem Accesspoint, sowie den Senden Button um HomematicIP mit Home Assistant zu verbinden.\n\n![Position des Tasters auf dem AP](/static/images/config_flows/config_homematicip_cloud.png)", "title": "Verkn\u00fcpfe den Accesspoint" } }, diff --git a/homeassistant/components/homematicip_cloud/.translations/en.json b/homeassistant/components/homematicip_cloud/.translations/en.json index 6fcfcddd7..605bb0d25 100644 --- a/homeassistant/components/homematicip_cloud/.translations/en.json +++ b/homeassistant/components/homematicip_cloud/.translations/en.json @@ -2,7 +2,6 @@ "config": { "abort": { "already_configured": "Access point is already configured", - "conection_aborted": "Could not connect to HMIP server", "connection_aborted": "Could not connect to HMIP server", "unknown": "Unknown error occurred." }, diff --git a/homeassistant/components/homematicip_cloud/.translations/es-419.json b/homeassistant/components/homematicip_cloud/.translations/es-419.json index e15d0dbae..5102b25aa 100644 --- a/homeassistant/components/homematicip_cloud/.translations/es-419.json +++ b/homeassistant/components/homematicip_cloud/.translations/es-419.json @@ -2,7 +2,6 @@ "config": { "abort": { "already_configured": "Accesspoint ya est\u00e1 configurado", - "conection_aborted": "No se pudo conectar al servidor HMIP", "connection_aborted": "No se pudo conectar al servidor HMIP", "unknown": "Se produjo un error desconocido." }, @@ -18,6 +17,9 @@ "name": "Nombre (opcional, usado como prefijo de nombre para todos los dispositivos)", "pin": "C\u00f3digo PIN (opcional)" } + }, + "link": { + "description": "Presione el bot\u00f3n azul en el punto de acceso y el bot\u00f3n enviar para registrar HomematicIP con Home Assistant. \n\n ! [Ubicaci\u00f3n del bot\u00f3n en el puente] (/static/images/config_flows/config_homematicip_cloud.png)" } }, "title": "HomematicIP Cloud" diff --git a/homeassistant/components/homematicip_cloud/.translations/es.json b/homeassistant/components/homematicip_cloud/.translations/es.json index 3f16c4538..206bd05a3 100644 --- a/homeassistant/components/homematicip_cloud/.translations/es.json +++ b/homeassistant/components/homematicip_cloud/.translations/es.json @@ -1,19 +1,30 @@ { "config": { "abort": { + "already_configured": "El punto de acceso ya est\u00e1 configurado", + "connection_aborted": "No se pudo conectar al servidor HMIP", "unknown": "Se ha producido un error desconocido." }, "error": { "invalid_pin": "PIN no v\u00e1lido, por favor int\u00e9ntalo de nuevo.", - "press_the_button": "Por favor, pulsa el bot\u00f3n azul" + "press_the_button": "Por favor, pulsa el bot\u00f3n azul", + "register_failed": "No se pudo registrar, por favor intentelo de nuevo.", + "timeout_button": "Tiempo de espera agotado desde que se apret\u00f3 el bot\u00f3n azul, por favor, int\u00e9ntalo de nuevo." }, "step": { "init": { "data": { + "hapid": "ID de punto de acceso (SGTIN)", "name": "Nombre (opcional, utilizado como prefijo para todos los dispositivos)", "pin": "C\u00f3digo PIN (opcional)" - } + }, + "title": "Elegir punto de acceso HomematicIP" + }, + "link": { + "description": "Pulsa el bot\u00f3n azul en el punto de acceso y el bot\u00f3n de env\u00edo para registrar HomematicIP en Home Assistant.\n\n![Ubicaci\u00f3n del bot\u00f3n en el puente](/static/images/config_flows/config_homematicip_cloud.png)", + "title": "Enlazar punto de acceso" } - } + }, + "title": "HomematicIP Cloud" } } \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/et.json b/homeassistant/components/homematicip_cloud/.translations/et.json new file mode 100644 index 000000000..7aedd80b5 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/et.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "invalid_pin": "Vale PIN, palun proovige uuesti" + }, + "step": { + "init": { + "data": { + "hapid": "P\u00e4\u00e4supunkti ID (SGTIN)", + "pin": "PIN-kood (valikuline)" + } + } + }, + "title": "HomematicIP Pilv" + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/fr.json b/homeassistant/components/homematicip_cloud/.translations/fr.json index 6cab0993c..0e724d62b 100644 --- a/homeassistant/components/homematicip_cloud/.translations/fr.json +++ b/homeassistant/components/homematicip_cloud/.translations/fr.json @@ -2,7 +2,6 @@ "config": { "abort": { "already_configured": "Le point d'acc\u00e8s est d\u00e9j\u00e0 configur\u00e9", - "conection_aborted": "Impossible de se connecter au serveur HMIP", "connection_aborted": "Impossible de se connecter au serveur HMIP", "unknown": "Une erreur inconnue s'est produite." }, diff --git a/homeassistant/components/homematicip_cloud/.translations/he.json b/homeassistant/components/homematicip_cloud/.translations/he.json index bdf1e436b..c60294e21 100644 --- a/homeassistant/components/homematicip_cloud/.translations/he.json +++ b/homeassistant/components/homematicip_cloud/.translations/he.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "\u05e0\u05e7\u05d5\u05d3\u05ea \u05d4\u05d2\u05d9\u05e9\u05d4 \u05db\u05d1\u05e8 \u05de\u05d5\u05d2\u05d3\u05e8\u05ea", - "conection_aborted": "\u05dc\u05d0 \u05e0\u05d9\u05ea\u05df \u05dc\u05d4\u05ea\u05d7\u05d1\u05e8 \u05dc\u05e9\u05e8\u05ea HMIP", + "connection_aborted": "\u05dc\u05d0 \u05e0\u05d9\u05ea\u05df \u05dc\u05d4\u05ea\u05d7\u05d1\u05e8 \u05dc\u05e9\u05e8\u05ea HMIP", "unknown": "\u05d0\u05d9\u05e8\u05e2\u05d4 \u05e9\u05d2\u05d9\u05d0\u05d4 \u05dc\u05d0 \u05d9\u05d3\u05d5\u05e2\u05d4." }, "error": { diff --git a/homeassistant/components/homematicip_cloud/.translations/hr.json b/homeassistant/components/homematicip_cloud/.translations/hr.json new file mode 100644 index 000000000..648dbfe73 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/hr.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "unknown": "Do\u0161lo je do nepoznate pogre\u0161ke." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/hu.json b/homeassistant/components/homematicip_cloud/.translations/hu.json index f2f22e6a4..61ff5ac5f 100644 --- a/homeassistant/components/homematicip_cloud/.translations/hu.json +++ b/homeassistant/components/homematicip_cloud/.translations/hu.json @@ -1,5 +1,29 @@ { "config": { + "abort": { + "already_configured": "A hozz\u00e1f\u00e9r\u00e9si pontot m\u00e1r konfigur\u00e1ltuk", + "connection_aborted": "Nem siker\u00fclt csatlakozni a HMIP szerverhez", + "unknown": "Unknown error occurred." + }, + "error": { + "invalid_pin": "\u00c9rv\u00e9nytelen PIN, pr\u00f3b\u00e1lkozz \u00fajra.", + "press_the_button": "Nyomd meg a k\u00e9k gombot.", + "register_failed": "Regisztr\u00e1ci\u00f3 nem siker\u00fclt, pr\u00f3b\u00e1ld \u00fajra.", + "timeout_button": "K\u00e9k gomb megnyom\u00e1s\u00e1nak id\u0151t\u00fall\u00e9p\u00e9se, pr\u00f3b\u00e1lkozz \u00fajra." + }, + "step": { + "init": { + "data": { + "hapid": "Hozz\u00e1f\u00e9r\u00e9si pont azonos\u00edt\u00f3ja (SGTIN)", + "name": "N\u00e9v (opcion\u00e1lis, minden eszk\u00f6z n\u00e9vel\u0151tagjak\u00e9nt haszn\u00e1latos)", + "pin": "Pin k\u00f3d (opcion\u00e1lis)" + }, + "title": "V\u00e1lassz HomematicIP hozz\u00e1f\u00e9r\u00e9si pontot" + }, + "link": { + "title": "Link Hozz\u00e1f\u00e9r\u00e9si pont" + } + }, "title": "HomematicIP Felh\u0151" } } \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/id.json b/homeassistant/components/homematicip_cloud/.translations/id.json new file mode 100644 index 000000000..048743427 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/id.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Jalur akses sudah dikonfigurasi", + "connection_aborted": "Tidak dapat terhubung ke server HMIP", + "unknown": "Kesalahan tidak dikenal terjadi." + }, + "error": { + "invalid_pin": "PIN tidak valid, silakan coba lagi.", + "press_the_button": "Silakan tekan tombol biru.", + "register_failed": "Gagal mendaftar, silakan coba lagi.", + "timeout_button": "Batas waktu tekan tombol biru berakhir, silakan coba lagi." + }, + "step": { + "init": { + "data": { + "hapid": "Titik akses ID (SGTIN)", + "name": "Nama (opsional, digunakan sebagai awalan nama untuk semua perangkat)", + "pin": "Kode Pin (opsional)" + }, + "title": "Pilih HomematicIP Access point" + }, + "link": { + "description": "Tekan tombol biru pada access point dan tombol submit untuk mendaftarkan HomematicIP dengan rumah asisten.\n\n! [Lokasi tombol di bridge] (/ static/images/config_flows/config_homematicip_cloud.png)", + "title": "Tautkan jalur akses" + } + }, + "title": "HomematicIP Cloud" + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/it.json b/homeassistant/components/homematicip_cloud/.translations/it.json index 9ef1abd50..c7f1af21f 100644 --- a/homeassistant/components/homematicip_cloud/.translations/it.json +++ b/homeassistant/components/homematicip_cloud/.translations/it.json @@ -2,19 +2,29 @@ "config": { "abort": { "already_configured": "Il punto di accesso \u00e8 gi\u00e0 configurato", - "connection_aborted": "Impossibile connettersi al server HMIP" + "connection_aborted": "Impossibile connettersi al server HMIP", + "unknown": "Si \u00e8 verificato un errore sconosciuto." }, "error": { "invalid_pin": "PIN non valido, riprova.", "press_the_button": "Si prega di premere il pulsante blu.", - "register_failed": "Registrazione fallita, si prega di riprovare." + "register_failed": "Registrazione fallita, si prega di riprovare.", + "timeout_button": "Timeout della pressione del pulsante blu, riprovare." }, "step": { "init": { "data": { + "hapid": "ID del punto di accesso (SGTIN)", + "name": "Nome (opzionale, usato come prefisso del nome per tutti i dispositivi)", "pin": "Codice Pin (opzionale)" - } + }, + "title": "Scegli punto di accesso HomematicIP" + }, + "link": { + "description": "Premi il pulsante blu sull'access point ed il pulsante di invio per registrare HomematicIP con Home Assistant. \n\n ![Posizione del pulsante sul bridge](/static/images/config_flows/config_homematicip_cloud.png)", + "title": "Collegamento access point" } - } + }, + "title": "HomematicIP Cloud" } } \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/ja.json b/homeassistant/components/homematicip_cloud/.translations/ja.json index 105a74157..6a03f3ec7 100644 --- a/homeassistant/components/homematicip_cloud/.translations/ja.json +++ b/homeassistant/components/homematicip_cloud/.translations/ja.json @@ -2,7 +2,6 @@ "config": { "abort": { "already_configured": "\u30a2\u30af\u30bb\u30b9\u30dd\u30a4\u30f3\u30c8\u306f\u65e2\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", - "conection_aborted": "HMIP\u30b5\u30fc\u30d0\u30fc\u306b\u63a5\u7d9a\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f", "unknown": "\u4e0d\u660e\u306a\u30a8\u30e9\u30fc\u304c\u767a\u751f\u3057\u307e\u3057\u305f\u3002" }, "error": { diff --git a/homeassistant/components/homematicip_cloud/.translations/ko.json b/homeassistant/components/homematicip_cloud/.translations/ko.json index 617b65ff6..2f47fcddf 100644 --- a/homeassistant/components/homematicip_cloud/.translations/ko.json +++ b/homeassistant/components/homematicip_cloud/.translations/ko.json @@ -2,9 +2,8 @@ "config": { "abort": { "already_configured": "\uc561\uc138\uc2a4 \ud3ec\uc778\ud2b8\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", - "conection_aborted": "HMIP \uc11c\ubc84\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", "connection_aborted": "HMIP \uc11c\ubc84\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", - "unknown": "\uc54c \uc218\uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + "unknown": "\uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, "error": { "invalid_pin": "PIN\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", @@ -16,14 +15,14 @@ "init": { "data": { "hapid": "\uc561\uc138\uc2a4 \ud3ec\uc778\ud2b8 ID (SGTIN)", - "name": "\uc774\ub984 (\uc120\ud0dd \uc0ac\ud56d, \ubaa8\ub4e0 \uc7a5\uce58 \uc774\ub984\uc758 \uc811\ub450\uc5b4\ub85c \uc0ac\uc6a9)", + "name": "\uc774\ub984 (\uc120\ud0dd \uc0ac\ud56d, \ubaa8\ub4e0 \uae30\uae30 \uc774\ub984\uc758 \uc811\ub450\uc5b4\ub85c \uc0ac\uc6a9)", "pin": "PIN \ucf54\ub4dc (\uc120\ud0dd\uc0ac\ud56d)" }, "title": "HomematicIP \uc561\uc138\uc2a4 \ud3ec\uc778\ud2b8 \uc120\ud0dd" }, "link": { - "description": "Home Assistant\uc5d0 HomematicIP\ub97c \ub4f1\ub85d\ud558\ub824\uba74 \uc561\uc138\uc2a4 \ud3ec\uc778\ud2b8\uc758 \ud30c\ub780\uc0c9 \ubc84\ud2bc\uacfc \uc11c\ubc0b \ubc84\ud2bc\uc744 \ub20c\ub7ec\uc8fc\uc138\uc694.\n\n![\ube0c\ub9bf\uc9c0\uc758 \ubc84\ud2bc \uc704\uce58 \ubcf4\uae30](/static/images/config_flows/config_homematicip_cloud.png)", - "title": "\uc561\uc138\uc2a4 \ud3ec\uc778\ud2b8\uc5d0 \uc5f0\uacb0" + "description": "Home Assistant \uc5d0 HomematicIP \ub97c \ub4f1\ub85d\ud558\ub824\uba74 \uc561\uc138\uc2a4 \ud3ec\uc778\ud2b8\uc758 \ud30c\ub780\uc0c9 \ubc84\ud2bc\uacfc Submit \ubc84\ud2bc\uc744 \ub20c\ub7ec\uc8fc\uc138\uc694.\n\n![\ube0c\ub9bf\uc9c0\uc758 \ubc84\ud2bc \uc704\uce58 \ubcf4\uae30](/static/images/config_flows/config_homematicip_cloud.png)", + "title": "\uc561\uc138\uc2a4 \ud3ec\uc778\ud2b8 \uc5f0\uacb0" } }, "title": "HomematicIP \ud074\ub77c\uc6b0\ub4dc" diff --git a/homeassistant/components/homematicip_cloud/.translations/lb.json b/homeassistant/components/homematicip_cloud/.translations/lb.json index a21767fc7..f8ae990d3 100644 --- a/homeassistant/components/homematicip_cloud/.translations/lb.json +++ b/homeassistant/components/homematicip_cloud/.translations/lb.json @@ -2,7 +2,6 @@ "config": { "abort": { "already_configured": "Acesspoint ass schon konfigur\u00e9iert", - "conection_aborted": "Konnt sech net mam HMIP Server verbannen", "connection_aborted": "Konnt sech net mam HMIP Server verbannen", "unknown": "Onbekannten Feeler opgetrueden" }, @@ -22,7 +21,7 @@ "title": "HomematicIP Accesspoint auswielen" }, "link": { - "description": "Dr\u00e9ckt de bloen Kn\u00e4ppchen um Accesspoint an den Submit Kn\u00e4ppchen fir d'HomematicIP mam Home Assistant ze registr\u00e9ieren.", + "description": "Dr\u00e9ckt de bloen Kn\u00e4ppchen um Accesspoint an den Submit Kn\u00e4ppchen fir d'HomematicIP mam Home Assistant ze registr\u00e9ieren.\n\n![Standuert vum Kn\u00e4ppchen op der Bridge](/static/images/config_flows/config_homematicip_cloud.png)", "title": "Accesspoint verbannen" } }, diff --git a/homeassistant/components/homematicip_cloud/.translations/nl.json b/homeassistant/components/homematicip_cloud/.translations/nl.json index 40d1ced50..ff3e2dea2 100644 --- a/homeassistant/components/homematicip_cloud/.translations/nl.json +++ b/homeassistant/components/homematicip_cloud/.translations/nl.json @@ -2,7 +2,6 @@ "config": { "abort": { "already_configured": "Accesspoint is al geconfigureerd", - "conection_aborted": "Kon geen verbinding maken met de HMIP-server", "connection_aborted": "Kon geen verbinding maken met de HMIP-server", "unknown": "Er is een onbekende fout opgetreden." }, diff --git a/homeassistant/components/homematicip_cloud/.translations/nn.json b/homeassistant/components/homematicip_cloud/.translations/nn.json new file mode 100644 index 000000000..da375563d --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/nn.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Tilgangspunktet er allereie konfigurert", + "connection_aborted": "Kunne ikkje kople til HMIP-serveren", + "unknown": "Det hende ein ukjent feil." + }, + "error": { + "invalid_pin": "Ugyldig PIN. Pr\u00f8v igjen.", + "press_the_button": "Ver vennleg og trykk p\u00e5 den bl\u00e5 knappen.", + "register_failed": "Kunne ikkje registrere. Pr\u00f8v igjen.", + "timeout_button": "TIda gjekk ut for \u00e5 trykke p\u00e5 den bl\u00e5 knappen. Ver vennleg og pr\u00f8v igjen." + }, + "step": { + "init": { + "data": { + "hapid": "TilgangspunktID (SGTIN)", + "name": "Namn (valfrii. Brukt som namnprefiks for alle einingar)", + "pin": "Pinkode (valfritt)" + }, + "title": "Vel HomematicIP tilgangspunkt" + }, + "link": { + "description": "Trykk p\u00e5 den bl\u00e5 knappen p\u00e5 tilgangspunktet og sendknappen for \u00e5 registrere HomematicIP med Home Assistant.\n\n ! [Plassering av knapp p\u00e5 bro] (/ static / images / config_flows / config_homematicip_cloud.png)", + "title": "Link tilgangspunk" + } + }, + "title": "HomematicIP Cloud" + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/no.json b/homeassistant/components/homematicip_cloud/.translations/no.json index 730f00ae6..9a4dd424b 100644 --- a/homeassistant/components/homematicip_cloud/.translations/no.json +++ b/homeassistant/components/homematicip_cloud/.translations/no.json @@ -2,7 +2,6 @@ "config": { "abort": { "already_configured": "Tilgangspunktet er allerede konfigurert", - "conection_aborted": "Kunne ikke koble til HMIP serveren", "connection_aborted": "Kunne ikke koble til HMIP serveren", "unknown": "Ukjent feil oppstod." }, @@ -15,7 +14,7 @@ "step": { "init": { "data": { - "hapid": "Tilgangspunkt ID (SGTIN)", + "hapid": "Tilgangspunkt-ID (SGTIN)", "name": "Navn (valgfritt, brukes som prefiks for alle enheter)", "pin": "PIN kode (valgfritt)" }, @@ -26,6 +25,6 @@ "title": "Link tilgangspunkt" } }, - "title": "HomematicIP Sky" + "title": "HomematicIP Cloud" } } \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/pl.json b/homeassistant/components/homematicip_cloud/.translations/pl.json index 3fcbe7e69..7c8714c2c 100644 --- a/homeassistant/components/homematicip_cloud/.translations/pl.json +++ b/homeassistant/components/homematicip_cloud/.translations/pl.json @@ -2,7 +2,6 @@ "config": { "abort": { "already_configured": "Punkt dost\u0119pu jest ju\u017c skonfigurowany", - "conection_aborted": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z serwerem HMIP", "connection_aborted": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z serwerem HMIP", "unknown": "Wyst\u0105pi\u0142 nieznany b\u0142\u0105d" }, diff --git a/homeassistant/components/homematicip_cloud/.translations/pt-BR.json b/homeassistant/components/homematicip_cloud/.translations/pt-BR.json index d4ecbe501..82166a1aa 100644 --- a/homeassistant/components/homematicip_cloud/.translations/pt-BR.json +++ b/homeassistant/components/homematicip_cloud/.translations/pt-BR.json @@ -2,7 +2,6 @@ "config": { "abort": { "already_configured": "O Accesspoint j\u00e1 est\u00e1 configurado", - "conection_aborted": "N\u00e3o foi poss\u00edvel conectar ao servidor HMIP", "connection_aborted": "N\u00e3o foi poss\u00edvel conectar ao servidor HMIP", "unknown": "Ocorreu um erro desconhecido." }, diff --git a/homeassistant/components/homematicip_cloud/.translations/pt.json b/homeassistant/components/homematicip_cloud/.translations/pt.json index 87ee494a8..0954f3ff4 100644 --- a/homeassistant/components/homematicip_cloud/.translations/pt.json +++ b/homeassistant/components/homematicip_cloud/.translations/pt.json @@ -2,14 +2,13 @@ "config": { "abort": { "already_configured": "O ponto de acesso j\u00e1 se encontra configurado", - "conection_aborted": "N\u00e3o foi poss\u00edvel ligar ao servidor HMIP", "connection_aborted": "N\u00e3o foi poss\u00edvel ligar ao servidor HMIP", "unknown": "Ocorreu um erro desconhecido." }, "error": { - "invalid_pin": "PIN inv\u00e1lido, por favor, tente novamente.", + "invalid_pin": "PIN inv\u00e1lido. Por favor, tente novamente.", "press_the_button": "Por favor, pressione o bot\u00e3o azul.", - "register_failed": "Falha ao registrar, por favor, tente novamente.", + "register_failed": "Falha ao registar. Por favor, tente novamente.", "timeout_button": "Tempo limite ultrapassado para carregar bot\u00e3o azul, por favor, tente de novo." }, "step": { @@ -22,7 +21,7 @@ "title": "Escolher ponto de acesso HomematicIP" }, "link": { - "description": "Pressione o bot\u00e3o azul no ponto de acesso e o bot\u00e3o enviar para registrar HomematicIP com o Home Assistant.\n\n![Localiza\u00e7\u00e3o do bot\u00e3o na ponte](/ static/images/config_flows/config_homematicip_cloud.png)", + "description": "Pressione o bot\u00e3o azul no ponto de acesso e o bot\u00e3o enviar para registrar HomematicIP com o Home Assistant.\n\n![Localiza\u00e7\u00e3o do bot\u00e3o na bridge](/ static/images/config_flows/config_homematicip_cloud.png)", "title": "Associar ponto de acesso" } }, diff --git a/homeassistant/components/homematicip_cloud/.translations/ro.json b/homeassistant/components/homematicip_cloud/.translations/ro.json new file mode 100644 index 000000000..a5399e7e6 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/ro.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Punctul de acces este deja configurat", + "unknown": "Sa produs o eroare necunoscut\u0103." + }, + "error": { + "invalid_pin": "Cod PIN invalid, \u00eencerca\u021bi din nou.", + "press_the_button": "V\u0103 rug\u0103m s\u0103 ap\u0103sa\u021bi butonul albastru." + }, + "step": { + "init": { + "data": { + "pin": "Cod PIN (op\u021bional)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/ru.json b/homeassistant/components/homematicip_cloud/.translations/ru.json index ed42daf19..35f52a7b2 100644 --- a/homeassistant/components/homematicip_cloud/.translations/ru.json +++ b/homeassistant/components/homematicip_cloud/.translations/ru.json @@ -1,16 +1,15 @@ { "config": { "abort": { - "already_configured": "\u0422\u043e\u0447\u043a\u0430 \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u0443\u0436\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u0430", - "conection_aborted": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0441\u0435\u0440\u0432\u0435\u0440\u0443 HMIP", - "connection_aborted": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0441\u0435\u0440\u0432\u0435\u0440\u0443 HMIP", - "unknown": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430" + "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0442\u043e\u0447\u043a\u0438 \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.", + "connection_aborted": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0441\u0435\u0440\u0432\u0435\u0440\u0443 HMIP.", + "unknown": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." }, "error": { "invalid_pin": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 PIN-\u043a\u043e\u0434, \u043f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0441\u043d\u043e\u0432\u0430.", "press_the_button": "\u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043d\u0430\u0436\u043c\u0438\u0442\u0435 \u0441\u0438\u043d\u044e\u044e \u043a\u043d\u043e\u043f\u043a\u0443.", - "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", - "timeout_button": "\u0412\u044b \u043d\u0435 \u043d\u0430\u0436\u0430\u043b\u0438 \u0441\u0438\u043d\u0438\u044e \u043a\u043d\u043e\u043f\u043a\u0443 \u0432 \u043f\u0440\u0435\u0434\u0435\u043b\u0430\u0445 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u0435\u043d\u043d\u043e\u0433\u043e \u0432\u0440\u0435\u043c\u0435\u043d\u0438, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0441\u043d\u043e\u0432\u0430" + "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.", + "timeout_button": "\u0412\u044b \u043d\u0435 \u043d\u0430\u0436\u0430\u043b\u0438 \u0441\u0438\u043d\u044e\u044e \u043a\u043d\u043e\u043f\u043a\u0443 \u0432 \u043f\u0440\u0435\u0434\u0435\u043b\u0430\u0445 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u0435\u043d\u043d\u043e\u0433\u043e \u0432\u0440\u0435\u043c\u0435\u043d\u0438, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0441\u043d\u043e\u0432\u0430." }, "step": { "init": { @@ -19,10 +18,10 @@ "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 (\u043d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e, \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442\u0441\u044f \u043a\u0430\u043a \u043f\u0440\u0435\u0444\u0438\u043a\u0441 \u0434\u043b\u044f \u043d\u0430\u0437\u0432\u0430\u043d\u0438\u044f \u0432\u0441\u0435\u0445 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432)", "pin": "PIN-\u043a\u043e\u0434 (\u043d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e)" }, - "title": "\u0412\u044b\u0431\u0438\u0440\u0438\u0442\u0435 \u0442\u043e\u0447\u043a\u0443 \u0434\u043e\u0441\u0442\u0443\u043f\u0430 HomematicIP" + "title": "HomematicIP Cloud" }, "link": { - "description": "\u041d\u0430\u0436\u043c\u0438\u0442\u0435 \u0441\u0438\u043d\u044e\u044e \u043a\u043d\u043e\u043f\u043a\u0443 \u043d\u0430 \u0442\u043e\u0447\u043a\u0435 \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u0438 \u043a\u043d\u043e\u043f\u043a\u0443 \u043e\u0442\u043f\u0440\u0430\u0432\u043a\u0438, \u0447\u0442\u043e\u0431\u044b \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c HomematicIP \u0432 Home Assistant. \n\n ![\u0420\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u043a\u043d\u043e\u043f\u043a\u0438](/static/images/config_flows/config_homematicip_cloud.png)", + "description": "\u041d\u0430\u0436\u043c\u0438\u0442\u0435 \u0441\u0438\u043d\u044e\u044e \u043a\u043d\u043e\u043f\u043a\u0443 \u043d\u0430 \u0442\u043e\u0447\u043a\u0435 \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u0438 \u043a\u043d\u043e\u043f\u043a\u0443 **\u041f\u041e\u0414\u0422\u0412\u0415\u0420\u0414\u0418\u0422\u042c**, \u0447\u0442\u043e\u0431\u044b \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c HomematicIP \u0432 Home Assistant. \n\n ![\u0420\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u043a\u043d\u043e\u043f\u043a\u0438](/static/images/config_flows/config_homematicip_cloud.png)", "title": "\u041f\u0440\u0438\u0432\u044f\u0437\u0430\u0442\u044c \u0442\u043e\u0447\u043a\u0443 \u0434\u043e\u0441\u0442\u0443\u043f\u0430" } }, diff --git a/homeassistant/components/homematicip_cloud/.translations/sl.json b/homeassistant/components/homematicip_cloud/.translations/sl.json index 4c4a00e31..cdde0f12d 100644 --- a/homeassistant/components/homematicip_cloud/.translations/sl.json +++ b/homeassistant/components/homematicip_cloud/.translations/sl.json @@ -1,8 +1,7 @@ { "config": { "abort": { - "already_configured": "Dostopna to\u010dka je \u017ee konfigurirana", - "conection_aborted": "Povezave s stre\u017enikom HMIP ni bila mogo\u010da", + "already_configured": "Dostopna to\u010dka je \u017ee nastavljena", "connection_aborted": "Povezava s stre\u017enikom HMIP ni bila mogo\u010da", "unknown": "Pri\u0161lo je do neznane napake" }, @@ -22,8 +21,8 @@ "title": "Izberite dostopno to\u010dko HomematicIP" }, "link": { - "description": "Pritisnite modro tipko na dostopni to\u010dko in gumb po\u0161lji, da registrirate homematicIP s Home Assistentom. \n\n![Location of button on bridge](/static/images/config_flows/config_homematicip_cloud.png)", - "title": "Pove\u017eite dostopno to\u010dno" + "description": "Pritisnite modro tipko na dostopni to\u010dko in gumb po\u0161lji, da registrirate homematicIP s Home Assistantom. \n\n![Location of button on bridge](/static/images/config_flows/config_homematicip_cloud.png)", + "title": "Pove\u017eite dostopno to\u010dko" } }, "title": "HomematicIP Cloud" diff --git a/homeassistant/components/homematicip_cloud/.translations/sv.json b/homeassistant/components/homematicip_cloud/.translations/sv.json index 4e8aac999..f155e8fd1 100644 --- a/homeassistant/components/homematicip_cloud/.translations/sv.json +++ b/homeassistant/components/homematicip_cloud/.translations/sv.json @@ -2,7 +2,6 @@ "config": { "abort": { "already_configured": "Accesspunkten \u00e4r redan konfigurerad", - "conection_aborted": "Kunde inte ansluta till HMIP server", "connection_aborted": "Det gick inte att ansluta till HMIP-servern", "unknown": "Ett ok\u00e4nt fel har intr\u00e4ffat" }, @@ -22,7 +21,7 @@ "title": "V\u00e4lj HomematicIP Accesspunkt" }, "link": { - "description": "Tryck p\u00e5 den bl\u00e5 knappen p\u00e5 accesspunkten och p\u00e5 skickaknappen f\u00f6r att registrera HomematicIP med Home-Assistant. \n\n ![Placering av knapp p\u00e5 bryggan](/static/images/config_flows/config_homematicip_cloud.png)", + "description": "Tryck p\u00e5 den bl\u00e5 knappen p\u00e5 accesspunkten och p\u00e5 skicka-knappen f\u00f6r att registrera HomematicIP med Home Assistant. \n\n ![Placering av knappen p\u00e5 bryggan](/static/images/config_flows/config_homematicip_cloud.png)", "title": "L\u00e4nka Accesspunkt" } }, diff --git a/homeassistant/components/homematicip_cloud/.translations/th.json b/homeassistant/components/homematicip_cloud/.translations/th.json new file mode 100644 index 000000000..cae3361a6 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/th.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "already_configured": "\u0e08\u0e38\u0e14\u0e40\u0e0a\u0e37\u0e48\u0e2d\u0e21\u0e15\u0e48\u0e2d (AP) \u0e44\u0e14\u0e49\u0e17\u0e33\u0e01\u0e32\u0e23\u0e01\u0e33\u0e2b\u0e19\u0e14\u0e04\u0e48\u0e32\u0e41\u0e25\u0e49\u0e27" + }, + "step": { + "init": { + "data": { + "hapid": "\u0e44\u0e2d\u0e14\u0e35\u0e08\u0e38\u0e14\u0e40\u0e02\u0e49\u0e32\u0e43\u0e0a\u0e49\u0e07\u0e32\u0e19 (SGTIN)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/zh-Hans.json b/homeassistant/components/homematicip_cloud/.translations/zh-Hans.json index 930b649bc..4c2b6268e 100644 --- a/homeassistant/components/homematicip_cloud/.translations/zh-Hans.json +++ b/homeassistant/components/homematicip_cloud/.translations/zh-Hans.json @@ -1,8 +1,7 @@ { "config": { "abort": { - "already_configured": "\u63a5\u5165\u70b9\u5df2\u7ecf\u914d\u7f6e\u5b8c\u6210", - "conection_aborted": "\u65e0\u6cd5\u8fde\u63a5\u5230 HMIP \u670d\u52a1\u5668", + "already_configured": "\u63a5\u5165\u70b9\u5df2\u914d\u7f6e", "connection_aborted": "\u65e0\u6cd5\u8fde\u63a5\u5230 HMIP \u670d\u52a1\u5668", "unknown": "\u53d1\u751f\u672a\u77e5\u9519\u8bef\u3002" }, diff --git a/homeassistant/components/homematicip_cloud/.translations/zh-Hant.json b/homeassistant/components/homematicip_cloud/.translations/zh-Hant.json index 9340070d9..c6a960f1a 100644 --- a/homeassistant/components/homematicip_cloud/.translations/zh-Hant.json +++ b/homeassistant/components/homematicip_cloud/.translations/zh-Hant.json @@ -1,8 +1,7 @@ { "config": { "abort": { - "already_configured": "Accesspoint \u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", - "conection_aborted": "\u7121\u6cd5\u9023\u7dda\u81f3 HMIP \u4f3a\u670d\u5668", + "already_configured": "Access point \u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", "connection_aborted": "\u7121\u6cd5\u9023\u7dda\u81f3 HMIP \u4f3a\u670d\u5668", "unknown": "\u767c\u751f\u672a\u77e5\u932f\u8aa4\u3002" }, @@ -15,15 +14,15 @@ "step": { "init": { "data": { - "hapid": "Accesspoint ID (SGTIN)", - "name": "\u540d\u7a31\uff08\u9078\u9805\uff0c\u7528\u4ee5\u4f5c\u70ba\u6240\u6709\u88dd\u7f6e\u7684\u5b57\u9996\u7528\uff09", + "hapid": "Access point ID (SGTIN)", + "name": "\u540d\u7a31\uff08\u9078\u9805\uff0c\u7528\u4ee5\u4f5c\u70ba\u6240\u6709\u8a2d\u5099\u7684\u5b57\u9996\u7528\uff09", "pin": "PIN \u78bc\uff08\u9078\u9805\uff09" }, - "title": "\u9078\u64c7 HomematicIP Accesspoint" + "title": "\u9078\u64c7 HomematicIP Access point" }, "link": { "description": "\u6309\u4e0b AP \u4e0a\u7684\u85cd\u8272\u6309\u9215\u8207\u50b3\u9001\u6309\u9215\uff0c\u4ee5\u65bc Home Assistant \u4e0a\u9032\u884c HomematicIP \u8a3b\u518a\u3002\n\n![\u6a4b\u63a5\u5668\u4e0a\u7684\u6309\u9215\u4f4d\u7f6e](/static/images/config_flows/config_homematicip_cloud.png)", - "title": "\u9023\u7d50 Accesspoint" + "title": "\u9023\u7d50 Access point" } }, "title": "HomematicIP Cloud" diff --git a/homeassistant/components/homematicip_cloud/__init__.py b/homeassistant/components/homematicip_cloud/__init__.py index 05c5c970d..62f3f9ec5 100644 --- a/homeassistant/components/homematicip_cloud/__init__.py +++ b/homeassistant/components/homematicip_cloud/__init__.py @@ -1,38 +1,123 @@ -""" -Support for HomematicIP Cloud components. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/homematicip_cloud/ -""" +"""Support for HomematicIP Cloud devices.""" import logging +from pathlib import Path +from typing import Optional +from homematicip.aio.group import AsyncHeatingGroup +from homematicip.aio.home import AsyncHome +from homematicip.base.helpers import handle_config import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_NAME +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_ENTITY_ID, CONF_NAME +from homeassistant.helpers import device_registry as dr import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.config_validation import comp_entity_ids +from homeassistant.helpers.typing import ConfigType, HomeAssistantType from .config_flow import configured_haps from .const import ( - CONF_ACCESSPOINT, CONF_AUTHTOKEN, DOMAIN, HMIPC_AUTHTOKEN, HMIPC_HAPID, - HMIPC_NAME) + CONF_ACCESSPOINT, + CONF_AUTHTOKEN, + DOMAIN, + HMIPC_AUTHTOKEN, + HMIPC_HAPID, + HMIPC_NAME, +) from .device import HomematicipGenericDevice # noqa: F401 from .hap import HomematicipAuth, HomematicipHAP # noqa: F401 -REQUIREMENTS = ['homematicip==0.9.8'] - _LOGGER = logging.getLogger(__name__) -CONFIG_SCHEMA = vol.Schema({ - vol.Optional(DOMAIN, default=[]): vol.All(cv.ensure_list, [vol.Schema({ - vol.Optional(CONF_NAME, default=''): vol.Any(cv.string), - vol.Required(CONF_ACCESSPOINT): cv.string, - vol.Required(CONF_AUTHTOKEN): cv.string, - })]), -}, extra=vol.ALLOW_EXTRA) +ATTR_ACCESSPOINT_ID = "accesspoint_id" +ATTR_ANONYMIZE = "anonymize" +ATTR_CLIMATE_PROFILE_INDEX = "climate_profile_index" +ATTR_CONFIG_OUTPUT_FILE_PREFIX = "config_output_file_prefix" +ATTR_CONFIG_OUTPUT_PATH = "config_output_path" +ATTR_DURATION = "duration" +ATTR_ENDTIME = "endtime" +ATTR_TEMPERATURE = "temperature" + +DEFAULT_CONFIG_FILE_PREFIX = "hmip-config" + +SERVICE_ACTIVATE_ECO_MODE_WITH_DURATION = "activate_eco_mode_with_duration" +SERVICE_ACTIVATE_ECO_MODE_WITH_PERIOD = "activate_eco_mode_with_period" +SERVICE_ACTIVATE_VACATION = "activate_vacation" +SERVICE_DEACTIVATE_ECO_MODE = "deactivate_eco_mode" +SERVICE_DEACTIVATE_VACATION = "deactivate_vacation" +SERVICE_DUMP_HAP_CONFIG = "dump_hap_config" +SERVICE_SET_ACTIVE_CLIMATE_PROFILE = "set_active_climate_profile" + +CONFIG_SCHEMA = vol.Schema( + { + vol.Optional(DOMAIN, default=[]): vol.All( + cv.ensure_list, + [ + vol.Schema( + { + vol.Optional(CONF_NAME, default=""): vol.Any(cv.string), + vol.Required(CONF_ACCESSPOINT): cv.string, + vol.Required(CONF_AUTHTOKEN): cv.string, + } + ) + ], + ) + }, + extra=vol.ALLOW_EXTRA, +) + +SCHEMA_ACTIVATE_ECO_MODE_WITH_DURATION = vol.Schema( + { + vol.Required(ATTR_DURATION): cv.positive_int, + vol.Optional(ATTR_ACCESSPOINT_ID): vol.All(str, vol.Length(min=24, max=24)), + } +) + +SCHEMA_ACTIVATE_ECO_MODE_WITH_PERIOD = vol.Schema( + { + vol.Required(ATTR_ENDTIME): cv.datetime, + vol.Optional(ATTR_ACCESSPOINT_ID): vol.All(str, vol.Length(min=24, max=24)), + } +) + +SCHEMA_ACTIVATE_VACATION = vol.Schema( + { + vol.Required(ATTR_ENDTIME): cv.datetime, + vol.Required(ATTR_TEMPERATURE, default=18.0): vol.All( + vol.Coerce(float), vol.Range(min=0, max=55) + ), + vol.Optional(ATTR_ACCESSPOINT_ID): vol.All(str, vol.Length(min=24, max=24)), + } +) + +SCHEMA_DEACTIVATE_ECO_MODE = vol.Schema( + {vol.Optional(ATTR_ACCESSPOINT_ID): vol.All(str, vol.Length(min=24, max=24))} +) + +SCHEMA_DEACTIVATE_VACATION = vol.Schema( + {vol.Optional(ATTR_ACCESSPOINT_ID): vol.All(str, vol.Length(min=24, max=24))} +) + +SCHEMA_SET_ACTIVE_CLIMATE_PROFILE = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): comp_entity_ids, + vol.Required(ATTR_CLIMATE_PROFILE_INDEX): cv.positive_int, + } +) + +SCHEMA_DUMP_HAP_CONFIG = vol.Schema( + { + vol.Optional(ATTR_CONFIG_OUTPUT_PATH): cv.string, + vol.Optional( + ATTR_CONFIG_OUTPUT_FILE_PREFIX, default=DEFAULT_CONFIG_FILE_PREFIX + ): cv.string, + vol.Optional(ATTR_ANONYMIZE, default=True): cv.boolean, + } +) -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: """Set up the HomematicIP Cloud component.""" hass.data[DOMAIN] = {} @@ -40,27 +125,208 @@ async def async_setup(hass, config): for conf in accesspoints: if conf[CONF_ACCESSPOINT] not in configured_haps(hass): - hass.async_add_job(hass.config_entries.flow.async_init( - DOMAIN, context={'source': config_entries.SOURCE_IMPORT}, - data={ - HMIPC_HAPID: conf[CONF_ACCESSPOINT], - HMIPC_AUTHTOKEN: conf[CONF_AUTHTOKEN], - HMIPC_NAME: conf[CONF_NAME], - } - )) + hass.async_add_job( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + HMIPC_HAPID: conf[CONF_ACCESSPOINT], + HMIPC_AUTHTOKEN: conf[CONF_AUTHTOKEN], + HMIPC_NAME: conf[CONF_NAME], + }, + ) + ) + + async def _async_activate_eco_mode_with_duration(service) -> None: + """Service to activate eco mode with duration.""" + duration = service.data[ATTR_DURATION] + hapid = service.data.get(ATTR_ACCESSPOINT_ID) + + if hapid: + home = _get_home(hapid) + if home: + await home.activate_absence_with_duration(duration) + else: + for hap in hass.data[DOMAIN].values(): + await hap.home.activate_absence_with_duration(duration) + + hass.services.async_register( + DOMAIN, + SERVICE_ACTIVATE_ECO_MODE_WITH_DURATION, + _async_activate_eco_mode_with_duration, + schema=SCHEMA_ACTIVATE_ECO_MODE_WITH_DURATION, + ) + + async def _async_activate_eco_mode_with_period(service) -> None: + """Service to activate eco mode with period.""" + endtime = service.data[ATTR_ENDTIME] + hapid = service.data.get(ATTR_ACCESSPOINT_ID) + + if hapid: + home = _get_home(hapid) + if home: + await home.activate_absence_with_period(endtime) + else: + for hap in hass.data[DOMAIN].values(): + await hap.home.activate_absence_with_period(endtime) + + hass.services.async_register( + DOMAIN, + SERVICE_ACTIVATE_ECO_MODE_WITH_PERIOD, + _async_activate_eco_mode_with_period, + schema=SCHEMA_ACTIVATE_ECO_MODE_WITH_PERIOD, + ) + + async def _async_activate_vacation(service) -> None: + """Service to activate vacation.""" + endtime = service.data[ATTR_ENDTIME] + temperature = service.data[ATTR_TEMPERATURE] + hapid = service.data.get(ATTR_ACCESSPOINT_ID) + + if hapid: + home = _get_home(hapid) + if home: + await home.activate_vacation(endtime, temperature) + else: + for hap in hass.data[DOMAIN].values(): + await hap.home.activate_vacation(endtime, temperature) + + hass.services.async_register( + DOMAIN, + SERVICE_ACTIVATE_VACATION, + _async_activate_vacation, + schema=SCHEMA_ACTIVATE_VACATION, + ) + + async def _async_deactivate_eco_mode(service) -> None: + """Service to deactivate eco mode.""" + hapid = service.data.get(ATTR_ACCESSPOINT_ID) + + if hapid: + home = _get_home(hapid) + if home: + await home.deactivate_absence() + else: + for hap in hass.data[DOMAIN].values(): + await hap.home.deactivate_absence() + + hass.services.async_register( + DOMAIN, + SERVICE_DEACTIVATE_ECO_MODE, + _async_deactivate_eco_mode, + schema=SCHEMA_DEACTIVATE_ECO_MODE, + ) + + async def _async_deactivate_vacation(service) -> None: + """Service to deactivate vacation.""" + hapid = service.data.get(ATTR_ACCESSPOINT_ID) + + if hapid: + home = _get_home(hapid) + if home: + await home.deactivate_vacation() + else: + for hap in hass.data[DOMAIN].values(): + await hap.home.deactivate_vacation() + + hass.services.async_register( + DOMAIN, + SERVICE_DEACTIVATE_VACATION, + _async_deactivate_vacation, + schema=SCHEMA_DEACTIVATE_VACATION, + ) + + async def _set_active_climate_profile(service) -> None: + """Service to set the active climate profile.""" + entity_id_list = service.data[ATTR_ENTITY_ID] + climate_profile_index = service.data[ATTR_CLIMATE_PROFILE_INDEX] - 1 + + for hap in hass.data[DOMAIN].values(): + if entity_id_list != "all": + for entity_id in entity_id_list: + group = hap.hmip_device_by_entity_id.get(entity_id) + if group: + await group.set_active_profile(climate_profile_index) + else: + for group in hap.home.groups: + if isinstance(group, AsyncHeatingGroup): + await group.set_active_profile(climate_profile_index) + + hass.services.async_register( + DOMAIN, + SERVICE_SET_ACTIVE_CLIMATE_PROFILE, + _set_active_climate_profile, + schema=SCHEMA_SET_ACTIVE_CLIMATE_PROFILE, + ) + + async def _async_dump_hap_config(service) -> None: + """Service to dump the configuration of a Homematic IP Access Point.""" + config_path = ( + service.data.get(ATTR_CONFIG_OUTPUT_PATH) or hass.config.config_dir + ) + config_file_prefix = service.data[ATTR_CONFIG_OUTPUT_FILE_PREFIX] + anonymize = service.data[ATTR_ANONYMIZE] + + for hap in hass.data[DOMAIN].values(): + hap_sgtin = hap.config_entry.title + + if anonymize: + hap_sgtin = hap_sgtin[-4:] + + file_name = f"{config_file_prefix}_{hap_sgtin}.json" + path = Path(config_path) + config_file = path / file_name + + json_state = await hap.home.download_configuration() + json_state = handle_config(json_state, anonymize) + + config_file.write_text(json_state, encoding="utf8") + + hass.services.async_register( + DOMAIN, + SERVICE_DUMP_HAP_CONFIG, + _async_dump_hap_config, + schema=SCHEMA_DUMP_HAP_CONFIG, + ) + + def _get_home(hapid: str) -> Optional[AsyncHome]: + """Return a HmIP home.""" + hap = hass.data[DOMAIN].get(hapid) + if hap: + return hap.home + + _LOGGER.info("No matching access point found for access point id %s", hapid) + return None return True -async def async_setup_entry(hass, entry): +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: """Set up an access point from a config entry.""" hap = HomematicipHAP(hass, entry) - hapid = entry.data[HMIPC_HAPID].replace('-', '').upper() + hapid = entry.data[HMIPC_HAPID].replace("-", "").upper() hass.data[DOMAIN][hapid] = hap - return await hap.async_setup() + + if not await hap.async_setup(): + return False + + # Register hap as device in registry. + device_registry = await dr.async_get_registry(hass) + home = hap.home + # Add the HAP name from configuration if set. + hapname = home.label if not home.name else f"{home.name} {home.label}" + device_registry.async_get_or_create( + config_entry_id=home.id, + identifiers={(DOMAIN, home.id)}, + manufacturer="eQ-3", + name=hapname, + model=home.modelType, + sw_version=home.currentAPVersion, + ) + return True -async def async_unload_entry(hass, entry): +async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: """Unload a config entry.""" hap = hass.data[DOMAIN].pop(entry.data[HMIPC_HAPID]) return await hap.async_reset() diff --git a/homeassistant/components/homematicip_cloud/alarm_control_panel.py b/homeassistant/components/homematicip_cloud/alarm_control_panel.py new file mode 100644 index 000000000..f9a912034 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/alarm_control_panel.py @@ -0,0 +1,131 @@ +"""Support for HomematicIP Cloud alarm control panel.""" +import logging +from typing import Any, Dict + +from homematicip.functionalHomes import SecurityAndAlarmHome + +from homeassistant.components.alarm_control_panel import AlarmControlPanel +from homeassistant.components.alarm_control_panel.const import ( + SUPPORT_ALARM_ARM_AWAY, + SUPPORT_ALARM_ARM_HOME, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_DISARMED, + STATE_ALARM_TRIGGERED, +) +from homeassistant.helpers.typing import HomeAssistantType + +from . import DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID +from .hap import HomematicipHAP + +_LOGGER = logging.getLogger(__name__) + +CONST_ALARM_CONTROL_PANEL_NAME = "HmIP Alarm Control Panel" + + +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None +) -> None: + """Set up the HomematicIP Cloud alarm control devices.""" + pass + + +async def async_setup_entry( + hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities +) -> None: + """Set up the HomematicIP alrm control panel from a config entry.""" + hap = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]] + async_add_entities([HomematicipAlarmControlPanel(hap)]) + + +class HomematicipAlarmControlPanel(AlarmControlPanel): + """Representation of an alarm control panel.""" + + def __init__(self, hap: HomematicipHAP) -> None: + """Initialize the alarm control panel.""" + self._home = hap.home + _LOGGER.info("Setting up %s", self.name) + + @property + def device_info(self) -> Dict[str, Any]: + """Return device specific attributes.""" + return { + "identifiers": {(HMIPC_DOMAIN, f"ACP {self._home.id}")}, + "name": self.name, + "manufacturer": "eQ-3", + "model": CONST_ALARM_CONTROL_PANEL_NAME, + "via_device": (HMIPC_DOMAIN, self._home.id), + } + + @property + def state(self) -> str: + """Return the state of the device.""" + # check for triggered alarm + if self._security_and_alarm.alarmActive: + return STATE_ALARM_TRIGGERED + + activation_state = self._home.get_security_zones_activation() + # check arm_away + if activation_state == (True, True): + return STATE_ALARM_ARMED_AWAY + # check arm_home + if activation_state == (False, True): + return STATE_ALARM_ARMED_HOME + + return STATE_ALARM_DISARMED + + @property + def _security_and_alarm(self) -> SecurityAndAlarmHome: + return self._home.get_functionalHome(SecurityAndAlarmHome) + + @property + def supported_features(self) -> int: + """Return the list of supported features.""" + return SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY + + async def async_alarm_disarm(self, code=None) -> None: + """Send disarm command.""" + await self._home.set_security_zones_activation(False, False) + + async def async_alarm_arm_home(self, code=None) -> None: + """Send arm home command.""" + await self._home.set_security_zones_activation(False, True) + + async def async_alarm_arm_away(self, code=None) -> None: + """Send arm away command.""" + await self._home.set_security_zones_activation(True, True) + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + self._home.on_update(self._async_device_changed) + + def _async_device_changed(self, *args, **kwargs) -> None: + """Handle device state changes.""" + _LOGGER.debug("Event %s (%s)", self.name, CONST_ALARM_CONTROL_PANEL_NAME) + self.async_schedule_update_ha_state() + + @property + def name(self) -> str: + """Return the name of the generic device.""" + name = CONST_ALARM_CONTROL_PANEL_NAME + if self._home.name: + name = f"{self._home.name} {name}" + return name + + @property + def should_poll(self) -> bool: + """No polling needed.""" + return False + + @property + def available(self) -> bool: + """Device available.""" + return self._home.connected + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return f"{self.__class__.__name__}_{self._home.id}" diff --git a/homeassistant/components/homematicip_cloud/binary_sensor.py b/homeassistant/components/homematicip_cloud/binary_sensor.py new file mode 100644 index 000000000..83d48d0a7 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/binary_sensor.py @@ -0,0 +1,428 @@ +"""Support for HomematicIP Cloud binary sensor.""" +import logging +from typing import Any, Dict + +from homematicip.aio.device import ( + AsyncAccelerationSensor, + AsyncContactInterface, + AsyncDevice, + AsyncFullFlushContactInterface, + AsyncMotionDetectorIndoor, + AsyncMotionDetectorOutdoor, + AsyncMotionDetectorPushButton, + AsyncPresenceDetectorIndoor, + AsyncRotaryHandleSensor, + AsyncShutterContact, + AsyncShutterContactMagnetic, + AsyncSmokeDetector, + AsyncWaterSensor, + AsyncWeatherSensor, + AsyncWeatherSensorPlus, + AsyncWeatherSensorPro, +) +from homematicip.aio.group import AsyncSecurityGroup, AsyncSecurityZoneGroup +from homematicip.base.enums import SmokeDetectorAlarmType, WindowState + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_DOOR, + DEVICE_CLASS_LIGHT, + DEVICE_CLASS_MOISTURE, + DEVICE_CLASS_MOTION, + DEVICE_CLASS_MOVING, + DEVICE_CLASS_OPENING, + DEVICE_CLASS_PRESENCE, + DEVICE_CLASS_SAFETY, + DEVICE_CLASS_SMOKE, + BinarySensorDevice, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.typing import HomeAssistantType + +from . import DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID, HomematicipGenericDevice +from .hap import HomematicipHAP + +_LOGGER = logging.getLogger(__name__) + +ATTR_ACCELERATION_SENSOR_MODE = "acceleration_sensor_mode" +ATTR_ACCELERATION_SENSOR_NEUTRAL_POSITION = "acceleration_sensor_neutral_position" +ATTR_ACCELERATION_SENSOR_SENSITIVITY = "acceleration_sensor_sensitivity" +ATTR_ACCELERATION_SENSOR_TRIGGER_ANGLE = "acceleration_sensor_trigger_angle" +ATTR_MOISTURE_DETECTED = "moisture_detected" +ATTR_MOTION_DETECTED = "motion_detected" +ATTR_POWER_MAINS_FAILURE = "power_mains_failure" +ATTR_PRESENCE_DETECTED = "presence_detected" +ATTR_SMOKE_DETECTOR_ALARM = "smoke_detector_alarm" +ATTR_TODAY_SUNSHINE_DURATION = "today_sunshine_duration_in_minutes" +ATTR_WATER_LEVEL_DETECTED = "water_level_detected" +ATTR_WINDOW_STATE = "window_state" + +GROUP_ATTRIBUTES = { + "moistureDetected": ATTR_MOISTURE_DETECTED, + "motionDetected": ATTR_MOTION_DETECTED, + "powerMainsFailure": ATTR_POWER_MAINS_FAILURE, + "presenceDetected": ATTR_PRESENCE_DETECTED, + "waterlevelDetected": ATTR_WATER_LEVEL_DETECTED, +} + +SAM_DEVICE_ATTRIBUTES = { + "accelerationSensorNeutralPosition": ATTR_ACCELERATION_SENSOR_NEUTRAL_POSITION, + "accelerationSensorMode": ATTR_ACCELERATION_SENSOR_MODE, + "accelerationSensorSensitivity": ATTR_ACCELERATION_SENSOR_SENSITIVITY, + "accelerationSensorTriggerAngle": ATTR_ACCELERATION_SENSOR_TRIGGER_ANGLE, +} + + +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None +) -> None: + """Set up the HomematicIP Cloud binary sensor devices.""" + pass + + +async def async_setup_entry( + hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities +) -> None: + """Set up the HomematicIP Cloud binary sensor from a config entry.""" + hap = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]] + entities = [] + for device in hap.home.devices: + if isinstance(device, AsyncAccelerationSensor): + entities.append(HomematicipAccelerationSensor(hap, device)) + if isinstance(device, (AsyncContactInterface, AsyncFullFlushContactInterface)): + entities.append(HomematicipContactInterface(hap, device)) + if isinstance( + device, + (AsyncShutterContact, AsyncShutterContactMagnetic, AsyncRotaryHandleSensor), + ): + entities.append(HomematicipShutterContact(hap, device)) + if isinstance( + device, + ( + AsyncMotionDetectorIndoor, + AsyncMotionDetectorOutdoor, + AsyncMotionDetectorPushButton, + ), + ): + entities.append(HomematicipMotionDetector(hap, device)) + if isinstance(device, AsyncPresenceDetectorIndoor): + entities.append(HomematicipPresenceDetector(hap, device)) + if isinstance(device, AsyncSmokeDetector): + entities.append(HomematicipSmokeDetector(hap, device)) + if isinstance(device, AsyncWaterSensor): + entities.append(HomematicipWaterDetector(hap, device)) + if isinstance(device, (AsyncWeatherSensorPlus, AsyncWeatherSensorPro)): + entities.append(HomematicipRainSensor(hap, device)) + if isinstance( + device, (AsyncWeatherSensor, AsyncWeatherSensorPlus, AsyncWeatherSensorPro) + ): + entities.append(HomematicipStormSensor(hap, device)) + entities.append(HomematicipSunshineSensor(hap, device)) + if isinstance(device, AsyncDevice) and device.lowBat is not None: + entities.append(HomematicipBatterySensor(hap, device)) + + for group in hap.home.groups: + if isinstance(group, AsyncSecurityGroup): + entities.append(HomematicipSecuritySensorGroup(hap, group)) + elif isinstance(group, AsyncSecurityZoneGroup): + entities.append(HomematicipSecurityZoneSensorGroup(hap, group)) + + if entities: + async_add_entities(entities) + + +class HomematicipAccelerationSensor(HomematicipGenericDevice, BinarySensorDevice): + """Representation of a HomematicIP Cloud acceleration sensor.""" + + @property + def device_class(self) -> str: + """Return the class of this sensor.""" + return DEVICE_CLASS_MOVING + + @property + def is_on(self) -> bool: + """Return true if acceleration is detected.""" + return self._device.accelerationSensorTriggered + + @property + def device_state_attributes(self) -> Dict[str, Any]: + """Return the state attributes of the acceleration sensor.""" + state_attr = super().device_state_attributes + + for attr, attr_key in SAM_DEVICE_ATTRIBUTES.items(): + attr_value = getattr(self._device, attr, None) + if attr_value: + state_attr[attr_key] = attr_value + + return state_attr + + +class HomematicipContactInterface(HomematicipGenericDevice, BinarySensorDevice): + """Representation of a HomematicIP Cloud contact interface.""" + + @property + def device_class(self) -> str: + """Return the class of this sensor.""" + return DEVICE_CLASS_OPENING + + @property + def is_on(self) -> bool: + """Return true if the contact interface is on/open.""" + if self._device.windowState is None: + return None + return self._device.windowState != WindowState.CLOSED + + +class HomematicipShutterContact(HomematicipGenericDevice, BinarySensorDevice): + """Representation of a HomematicIP Cloud shutter contact.""" + + @property + def device_class(self) -> str: + """Return the class of this sensor.""" + return DEVICE_CLASS_DOOR + + @property + def is_on(self) -> bool: + """Return true if the shutter contact is on/open.""" + if self._device.windowState is None: + return None + return self._device.windowState != WindowState.CLOSED + + +class HomematicipMotionDetector(HomematicipGenericDevice, BinarySensorDevice): + """Representation of a HomematicIP Cloud motion detector.""" + + @property + def device_class(self) -> str: + """Return the class of this sensor.""" + return DEVICE_CLASS_MOTION + + @property + def is_on(self) -> bool: + """Return true if motion is detected.""" + return self._device.motionDetected + + +class HomematicipPresenceDetector(HomematicipGenericDevice, BinarySensorDevice): + """Representation of a HomematicIP Cloud presence detector.""" + + @property + def device_class(self) -> str: + """Return the class of this sensor.""" + return DEVICE_CLASS_PRESENCE + + @property + def is_on(self) -> bool: + """Return true if presence is detected.""" + return self._device.presenceDetected + + +class HomematicipSmokeDetector(HomematicipGenericDevice, BinarySensorDevice): + """Representation of a HomematicIP Cloud smoke detector.""" + + @property + def device_class(self) -> str: + """Return the class of this sensor.""" + return DEVICE_CLASS_SMOKE + + @property + def is_on(self) -> bool: + """Return true if smoke is detected.""" + return self._device.smokeDetectorAlarmType != SmokeDetectorAlarmType.IDLE_OFF + + +class HomematicipWaterDetector(HomematicipGenericDevice, BinarySensorDevice): + """Representation of a HomematicIP Cloud water detector.""" + + @property + def device_class(self) -> str: + """Return the class of this sensor.""" + return DEVICE_CLASS_MOISTURE + + @property + def is_on(self) -> bool: + """Return true, if moisture or waterlevel is detected.""" + return self._device.moistureDetected or self._device.waterlevelDetected + + +class HomematicipStormSensor(HomematicipGenericDevice, BinarySensorDevice): + """Representation of a HomematicIP Cloud storm sensor.""" + + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize storm sensor.""" + super().__init__(hap, device, "Storm") + + @property + def icon(self) -> str: + """Return the icon.""" + return "mdi:weather-windy" if self.is_on else "mdi:pinwheel-outline" + + @property + def is_on(self) -> bool: + """Return true, if storm is detected.""" + return self._device.storm + + +class HomematicipRainSensor(HomematicipGenericDevice, BinarySensorDevice): + """Representation of a HomematicIP Cloud rain sensor.""" + + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize rain sensor.""" + super().__init__(hap, device, "Raining") + + @property + def device_class(self) -> str: + """Return the class of this sensor.""" + return DEVICE_CLASS_MOISTURE + + @property + def is_on(self) -> bool: + """Return true, if it is raining.""" + return self._device.raining + + +class HomematicipSunshineSensor(HomematicipGenericDevice, BinarySensorDevice): + """Representation of a HomematicIP Cloud sunshine sensor.""" + + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize sunshine sensor.""" + super().__init__(hap, device, "Sunshine") + + @property + def device_class(self) -> str: + """Return the class of this sensor.""" + return DEVICE_CLASS_LIGHT + + @property + def is_on(self) -> bool: + """Return true if sun is shining.""" + return self._device.sunshine + + @property + def device_state_attributes(self) -> Dict[str, Any]: + """Return the state attributes of the illuminance sensor.""" + state_attr = super().device_state_attributes + + today_sunshine_duration = getattr(self._device, "todaySunshineDuration", None) + if today_sunshine_duration: + state_attr[ATTR_TODAY_SUNSHINE_DURATION] = today_sunshine_duration + + return state_attr + + +class HomematicipBatterySensor(HomematicipGenericDevice, BinarySensorDevice): + """Representation of a HomematicIP Cloud low battery sensor.""" + + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize battery sensor.""" + super().__init__(hap, device, "Battery") + + @property + def device_class(self) -> str: + """Return the class of this sensor.""" + return DEVICE_CLASS_BATTERY + + @property + def is_on(self) -> bool: + """Return true if battery is low.""" + return self._device.lowBat + + +class HomematicipSecurityZoneSensorGroup(HomematicipGenericDevice, BinarySensorDevice): + """Representation of a HomematicIP Cloud security zone group.""" + + def __init__(self, hap: HomematicipHAP, device, post: str = "SecurityZone") -> None: + """Initialize security zone group.""" + device.modelType = f"HmIP-{post}" + super().__init__(hap, device, post) + + @property + def device_class(self) -> str: + """Return the class of this sensor.""" + return DEVICE_CLASS_SAFETY + + @property + def available(self) -> bool: + """Security-Group available.""" + # A security-group must be available, and should not be affected by + # the individual availability of group members. + return True + + @property + def device_state_attributes(self) -> Dict[str, Any]: + """Return the state attributes of the security zone group.""" + state_attr = super().device_state_attributes + + for attr, attr_key in GROUP_ATTRIBUTES.items(): + attr_value = getattr(self._device, attr, None) + if attr_value: + state_attr[attr_key] = attr_value + + window_state = getattr(self._device, "windowState", None) + if window_state and window_state != WindowState.CLOSED: + state_attr[ATTR_WINDOW_STATE] = str(window_state) + + return state_attr + + @property + def is_on(self) -> bool: + """Return true if security issue detected.""" + if ( + self._device.motionDetected + or self._device.presenceDetected + or self._device.unreach + or self._device.sabotage + ): + return True + + if ( + self._device.windowState is not None + and self._device.windowState != WindowState.CLOSED + ): + return True + return False + + +class HomematicipSecuritySensorGroup( + HomematicipSecurityZoneSensorGroup, BinarySensorDevice +): + """Representation of a HomematicIP security group.""" + + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize security group.""" + super().__init__(hap, device, "Sensors") + + @property + def device_state_attributes(self) -> Dict[str, Any]: + """Return the state attributes of the security group.""" + state_attr = super().device_state_attributes + + smoke_detector_at = getattr(self._device, "smokeDetectorAlarmType", None) + if smoke_detector_at and smoke_detector_at != SmokeDetectorAlarmType.IDLE_OFF: + state_attr[ATTR_SMOKE_DETECTOR_ALARM] = str(smoke_detector_at) + + return state_attr + + @property + def is_on(self) -> bool: + """Return true if safety issue detected.""" + parent_is_on = super().is_on + if parent_is_on: + return True + + if ( + self._device.powerMainsFailure + or self._device.moistureDetected + or self._device.waterlevelDetected + or self._device.lowBat + or self._device.dutyCycle + ): + return True + + if ( + self._device.smokeDetectorAlarmType is not None + and self._device.smokeDetectorAlarmType != SmokeDetectorAlarmType.IDLE_OFF + ): + return True + + return False diff --git a/homeassistant/components/homematicip_cloud/climate.py b/homeassistant/components/homematicip_cloud/climate.py new file mode 100644 index 000000000..e3c922dc5 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/climate.py @@ -0,0 +1,343 @@ +"""Support for HomematicIP Cloud climate devices.""" +import logging +from typing import Any, Dict, List, Optional, Union + +from homematicip.aio.device import AsyncHeatingThermostat, AsyncHeatingThermostatCompact +from homematicip.aio.group import AsyncHeatingGroup +from homematicip.base.enums import AbsenceType +from homematicip.device import Switch +from homematicip.functionalHomes import IndoorClimateHome + +from homeassistant.components.climate import ClimateDevice +from homeassistant.components.climate.const import ( + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, + HVAC_MODE_AUTO, + HVAC_MODE_COOL, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + PRESET_AWAY, + PRESET_BOOST, + PRESET_ECO, + PRESET_NONE, + SUPPORT_PRESET_MODE, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS +from homeassistant.helpers.typing import HomeAssistantType + +from . import DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID, HomematicipGenericDevice +from .hap import HomematicipHAP + +HEATING_PROFILES = {"PROFILE_1": 0, "PROFILE_2": 1, "PROFILE_3": 2} +COOLING_PROFILES = {"PROFILE_4": 3, "PROFILE_5": 4, "PROFILE_6": 5} + +_LOGGER = logging.getLogger(__name__) + +ATTR_PRESET_END_TIME = "preset_end_time" +PERMANENT_END_TIME = "permanent" + +HMIP_AUTOMATIC_CM = "AUTOMATIC" +HMIP_MANUAL_CM = "MANUAL" +HMIP_ECO_CM = "ECO" + + +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None +) -> None: + """Set up the HomematicIP Cloud climate devices.""" + pass + + +async def async_setup_entry( + hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities +) -> None: + """Set up the HomematicIP climate from a config entry.""" + hap = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]] + entities = [] + for device in hap.home.groups: + if isinstance(device, AsyncHeatingGroup): + entities.append(HomematicipHeatingGroup(hap, device)) + + if entities: + async_add_entities(entities) + + +class HomematicipHeatingGroup(HomematicipGenericDevice, ClimateDevice): + """Representation of a HomematicIP heating group. + + Heat mode is supported for all heating devices incl. their defined profiles. + Boost is available for radiator thermostats only. + Cool mode is only available for floor heating systems, if basically enabled in the hmip app. + """ + + def __init__(self, hap: HomematicipHAP, device: AsyncHeatingGroup) -> None: + """Initialize heating group.""" + device.modelType = "HmIP-Heating-Group" + super().__init__(hap, device) + self._simple_heating = None + if device.actualTemperature is None: + self._simple_heating = self._first_radiator_thermostat + + @property + def device_info(self) -> Dict[str, Any]: + """Return device specific attributes.""" + return { + "identifiers": {(HMIPC_DOMAIN, self._device.id)}, + "name": self._device.label, + "manufacturer": "eQ-3", + "model": self._device.modelType, + "via_device": (HMIPC_DOMAIN, self._device.homeId), + } + + @property + def temperature_unit(self) -> str: + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def supported_features(self) -> int: + """Return the list of supported features.""" + return SUPPORT_PRESET_MODE | SUPPORT_TARGET_TEMPERATURE + + @property + def target_temperature(self) -> float: + """Return the temperature we try to reach.""" + return self._device.setPointTemperature + + @property + def current_temperature(self) -> float: + """Return the current temperature.""" + if self._simple_heating: + return self._simple_heating.valveActualTemperature + return self._device.actualTemperature + + @property + def current_humidity(self) -> int: + """Return the current humidity.""" + return self._device.humidity + + @property + def hvac_mode(self) -> str: + """Return hvac operation ie.""" + if self._disabled_by_cooling_mode and not self._has_switch: + return HVAC_MODE_OFF + if self._device.boostMode: + return HVAC_MODE_HEAT + if self._device.controlMode == HMIP_MANUAL_CM: + return HVAC_MODE_HEAT if self._heat_mode_enabled else HVAC_MODE_COOL + + return HVAC_MODE_AUTO + + @property + def hvac_modes(self) -> List[str]: + """Return the list of available hvac operation modes.""" + if self._disabled_by_cooling_mode and not self._has_switch: + return [HVAC_MODE_OFF] + + return ( + [HVAC_MODE_AUTO, HVAC_MODE_HEAT] + if self._heat_mode_enabled + else [HVAC_MODE_AUTO, HVAC_MODE_COOL] + ) + + @property + def hvac_action(self) -> Optional[str]: + """ + Return the current hvac_action. + + This is only relevant for radiator thermostats. + """ + if ( + self._device.floorHeatingMode == "RADIATOR" + and self._has_radiator_thermostat + and self._heat_mode_enabled + ): + return ( + CURRENT_HVAC_HEAT if self._device.valvePosition else CURRENT_HVAC_IDLE + ) + + return None + + @property + def preset_mode(self) -> Optional[str]: + """Return the current preset mode.""" + if self._device.boostMode: + return PRESET_BOOST + if self.hvac_mode in (HVAC_MODE_COOL, HVAC_MODE_HEAT, HVAC_MODE_OFF): + return PRESET_NONE + if self._device.controlMode == HMIP_ECO_CM: + if self._indoor_climate.absenceType == AbsenceType.VACATION: + return PRESET_AWAY + if self._indoor_climate.absenceType in [ + AbsenceType.PARTY, + AbsenceType.PERIOD, + AbsenceType.PERMANENT, + ]: + return PRESET_ECO + + return ( + self._device.activeProfile.name + if self._device.activeProfile.name in self._device_profile_names + else None + ) + + @property + def preset_modes(self) -> List[str]: + """Return a list of available preset modes incl. hmip profiles.""" + # Boost is only available if a radiator thermostat is in the room, + # and heat mode is enabled. + profile_names = self._device_profile_names + + presets = [] + if ( + self._heat_mode_enabled and self._has_radiator_thermostat + ) or self._has_switch: + if not profile_names: + presets.append(PRESET_NONE) + presets.append(PRESET_BOOST) + + presets.extend(profile_names) + + return presets + + @property + def min_temp(self) -> float: + """Return the minimum temperature.""" + return self._device.minTemperature + + @property + def max_temp(self) -> float: + """Return the maximum temperature.""" + return self._device.maxTemperature + + async def async_set_temperature(self, **kwargs) -> None: + """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return + + if self.min_temp <= temperature <= self.max_temp: + await self._device.set_point_temperature(temperature) + + async def async_set_hvac_mode(self, hvac_mode: str) -> None: + """Set new target hvac mode.""" + if hvac_mode not in self.hvac_modes: + return + + if hvac_mode == HVAC_MODE_AUTO: + await self._device.set_control_mode(HMIP_AUTOMATIC_CM) + else: + await self._device.set_control_mode(HMIP_MANUAL_CM) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + if preset_mode not in self.preset_modes: + return + + if self._device.boostMode and preset_mode != PRESET_BOOST: + await self._device.set_boost(False) + if preset_mode == PRESET_BOOST: + await self._device.set_boost() + if preset_mode in self._device_profile_names: + profile_idx = self._get_profile_idx_by_name(preset_mode) + if self._device.controlMode != HMIP_AUTOMATIC_CM: + await self.async_set_hvac_mode(HVAC_MODE_AUTO) + await self._device.set_active_profile(profile_idx) + + @property + def device_state_attributes(self) -> Dict[str, Any]: + """Return the state attributes of the access point.""" + state_attr = super().device_state_attributes + + if self._device.controlMode == HMIP_ECO_CM: + if self._indoor_climate.absenceType in [ + AbsenceType.PARTY, + AbsenceType.PERIOD, + AbsenceType.VACATION, + ]: + state_attr[ATTR_PRESET_END_TIME] = self._indoor_climate.absenceEndTime + elif self._indoor_climate.absenceType == AbsenceType.PERMANENT: + state_attr[ATTR_PRESET_END_TIME] = PERMANENT_END_TIME + + return state_attr + + @property + def _indoor_climate(self) -> IndoorClimateHome: + """Return the hmip indoor climate functional home of this group.""" + return self._home.get_functionalHome(IndoorClimateHome) + + @property + def _device_profiles(self) -> List[str]: + """Return the relevant profiles.""" + return [ + profile + for profile in self._device.profiles + if profile.visible + and profile.name != "" + and profile.index in self._relevant_profile_group + ] + + @property + def _device_profile_names(self) -> List[str]: + """Return a collection of profile names.""" + return [profile.name for profile in self._device_profiles] + + def _get_profile_idx_by_name(self, profile_name: str) -> int: + """Return a profile index by name.""" + relevant_index = self._relevant_profile_group + index_name = [ + profile.index + for profile in self._device_profiles + if profile.name == profile_name + ] + + return relevant_index[index_name[0]] + + @property + def _heat_mode_enabled(self) -> bool: + """Return, if heating mode is enabled.""" + return not self._device.cooling + + @property + def _disabled_by_cooling_mode(self) -> bool: + """Return, if group is disabled by the cooling mode.""" + return self._device.cooling and ( + self._device.coolingIgnored or not self._device.coolingAllowed + ) + + @property + def _relevant_profile_group(self) -> List[str]: + """Return the relevant profile groups.""" + if self._disabled_by_cooling_mode: + return [] + + return HEATING_PROFILES if self._heat_mode_enabled else COOLING_PROFILES + + @property + def _has_switch(self) -> bool: + """Return, if a switch is in the hmip heating group.""" + for device in self._device.devices: + if isinstance(device, Switch): + return True + + return False + + @property + def _has_radiator_thermostat(self) -> bool: + """Return, if a radiator thermostat is in the hmip heating group.""" + return bool(self._first_radiator_thermostat) + + @property + def _first_radiator_thermostat( + self, + ) -> Optional[Union[AsyncHeatingThermostat, AsyncHeatingThermostatCompact]]: + """Return the first radiator thermostat from the hmip heating group.""" + for device in self._device.devices: + if isinstance( + device, (AsyncHeatingThermostat, AsyncHeatingThermostatCompact) + ): + return device + + return None diff --git a/homeassistant/components/homematicip_cloud/config_flow.py b/homeassistant/components/homematicip_cloud/config_flow.py index ea251a3bf..8d85dfda3 100644 --- a/homeassistant/components/homematicip_cloud/config_flow.py +++ b/homeassistant/components/homematicip_cloud/config_flow.py @@ -1,20 +1,30 @@ """Config flow to configure the HomematicIP Cloud component.""" +from typing import Any, Dict, Set + import voluptuous as vol from homeassistant import config_entries from homeassistant.core import callback +from homeassistant.helpers.typing import HomeAssistantType -from .const import DOMAIN as HMIPC_DOMAIN -from .const import HMIPC_AUTHTOKEN, HMIPC_HAPID, HMIPC_NAME, HMIPC_PIN -from .const import _LOGGER +from .const import ( + _LOGGER, + DOMAIN as HMIPC_DOMAIN, + HMIPC_AUTHTOKEN, + HMIPC_HAPID, + HMIPC_NAME, + HMIPC_PIN, +) from .hap import HomematicipAuth @callback -def configured_haps(hass): +def configured_haps(hass: HomeAssistantType) -> Set[str]: """Return a set of the configured access points.""" - return set(entry.data[HMIPC_HAPID] for entry - in hass.config_entries.async_entries(HMIPC_DOMAIN)) + return set( + entry.data[HMIPC_HAPID] + for entry in hass.config_entries.async_entries(HMIPC_DOMAIN) + ) @config_entries.HANDLERS.register(HMIPC_DOMAIN) @@ -24,23 +34,22 @@ class HomematicipCloudFlowHandler(config_entries.ConfigFlow): VERSION = 1 CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH - def __init__(self): + def __init__(self) -> None: """Initialize HomematicIP Cloud config flow.""" self.auth = None - async def async_step_user(self, user_input=None): + async def async_step_user(self, user_input=None) -> Dict[str, Any]: """Handle a flow initialized by the user.""" return await self.async_step_init(user_input) - async def async_step_init(self, user_input=None): + async def async_step_init(self, user_input=None) -> Dict[str, Any]: """Handle a flow start.""" errors = {} if user_input is not None: - user_input[HMIPC_HAPID] = \ - user_input[HMIPC_HAPID].replace('-', '').upper() + user_input[HMIPC_HAPID] = user_input[HMIPC_HAPID].replace("-", "").upper() if user_input[HMIPC_HAPID] in configured_haps(self.hass): - return self.async_abort(reason='already_configured') + return self.async_abort(reason="already_configured") self.auth = HomematicipAuth(self.hass, user_input) connected = await self.auth.async_setup() @@ -49,16 +58,18 @@ class HomematicipCloudFlowHandler(config_entries.ConfigFlow): return await self.async_step_link() return self.async_show_form( - step_id='init', - data_schema=vol.Schema({ - vol.Required(HMIPC_HAPID): str, - vol.Optional(HMIPC_NAME): str, - vol.Optional(HMIPC_PIN): str, - }), - errors=errors + step_id="init", + data_schema=vol.Schema( + { + vol.Required(HMIPC_HAPID): str, + vol.Optional(HMIPC_NAME): str, + vol.Optional(HMIPC_PIN): str, + } + ), + errors=errors, ) - async def async_step_link(self, user_input=None): + async def async_step_link(self, user_input=None) -> Dict[str, Any]: """Attempt to link with the HomematicIP Cloud access point.""" errors = {} @@ -72,30 +83,27 @@ class HomematicipCloudFlowHandler(config_entries.ConfigFlow): data={ HMIPC_HAPID: self.auth.config.get(HMIPC_HAPID), HMIPC_AUTHTOKEN: authtoken, - HMIPC_NAME: self.auth.config.get(HMIPC_NAME) - }) - return self.async_abort(reason='connection_aborted') - errors['base'] = 'press_the_button' + HMIPC_NAME: self.auth.config.get(HMIPC_NAME), + }, + ) + return self.async_abort(reason="connection_aborted") + errors["base"] = "press_the_button" - return self.async_show_form(step_id='link', errors=errors) + return self.async_show_form(step_id="link", errors=errors) - async def async_step_import(self, import_info): + async def async_step_import(self, import_info) -> Dict[str, Any]: """Import a new access point as a config entry.""" hapid = import_info[HMIPC_HAPID] authtoken = import_info[HMIPC_AUTHTOKEN] name = import_info[HMIPC_NAME] - hapid = hapid.replace('-', '').upper() + hapid = hapid.replace("-", "").upper() if hapid in configured_haps(self.hass): - return self.async_abort(reason='already_configured') + return self.async_abort(reason="already_configured") _LOGGER.info("Imported authentication for %s", hapid) return self.async_create_entry( title=hapid, - data={ - HMIPC_AUTHTOKEN: authtoken, - HMIPC_HAPID: hapid, - HMIPC_NAME: name, - } + data={HMIPC_AUTHTOKEN: authtoken, HMIPC_HAPID: hapid, HMIPC_NAME: name}, ) diff --git a/homeassistant/components/homematicip_cloud/const.py b/homeassistant/components/homematicip_cloud/const.py index ba9c37b83..5c48de975 100644 --- a/homeassistant/components/homematicip_cloud/const.py +++ b/homeassistant/components/homematicip_cloud/const.py @@ -1,23 +1,25 @@ """Constants for the HomematicIP Cloud component.""" import logging -_LOGGER = logging.getLogger('homeassistant.components.homematicip_cloud') +_LOGGER = logging.getLogger(".") -DOMAIN = 'homematicip_cloud' +DOMAIN = "homematicip_cloud" COMPONENTS = [ - 'alarm_control_panel', - 'binary_sensor', - 'climate', - 'light', - 'sensor', - 'switch', + "alarm_control_panel", + "binary_sensor", + "climate", + "cover", + "light", + "sensor", + "switch", + "weather", ] -CONF_ACCESSPOINT = 'accesspoint' -CONF_AUTHTOKEN = 'authtoken' +CONF_ACCESSPOINT = "accesspoint" +CONF_AUTHTOKEN = "authtoken" -HMIPC_NAME = 'name' -HMIPC_HAPID = 'hapid' -HMIPC_AUTHTOKEN = 'authtoken' -HMIPC_PIN = 'pin' +HMIPC_NAME = "name" +HMIPC_HAPID = "hapid" +HMIPC_AUTHTOKEN = "authtoken" +HMIPC_PIN = "pin" diff --git a/homeassistant/components/homematicip_cloud/cover.py b/homeassistant/components/homematicip_cloud/cover.py new file mode 100644 index 000000000..ef8cbacfd --- /dev/null +++ b/homeassistant/components/homematicip_cloud/cover.py @@ -0,0 +1,108 @@ +"""Support for HomematicIP Cloud cover devices.""" +import logging +from typing import Optional + +from homematicip.aio.device import AsyncFullFlushBlind, AsyncFullFlushShutter + +from homeassistant.components.cover import ( + ATTR_POSITION, + ATTR_TILT_POSITION, + CoverDevice, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.typing import HomeAssistantType + +from . import DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID, HomematicipGenericDevice + +_LOGGER = logging.getLogger(__name__) + +HMIP_COVER_OPEN = 0 +HMIP_COVER_CLOSED = 1 +HMIP_SLATS_OPEN = 0 +HMIP_SLATS_CLOSED = 1 + + +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None +) -> None: + """Set up the HomematicIP Cloud cover devices.""" + pass + + +async def async_setup_entry( + hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities +) -> None: + """Set up the HomematicIP cover from a config entry.""" + hap = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]] + entities = [] + for device in hap.home.devices: + if isinstance(device, AsyncFullFlushBlind): + entities.append(HomematicipCoverSlats(hap, device)) + elif isinstance(device, AsyncFullFlushShutter): + entities.append(HomematicipCoverShutter(hap, device)) + + if entities: + async_add_entities(entities) + + +class HomematicipCoverShutter(HomematicipGenericDevice, CoverDevice): + """Representation of a HomematicIP Cloud cover shutter device.""" + + @property + def current_cover_position(self) -> int: + """Return current position of cover.""" + return int((1 - self._device.shutterLevel) * 100) + + async def async_set_cover_position(self, **kwargs) -> None: + """Move the cover to a specific position.""" + position = kwargs[ATTR_POSITION] + # HmIP cover is closed:1 -> open:0 + level = 1 - position / 100.0 + await self._device.set_shutter_level(level) + + @property + def is_closed(self) -> Optional[bool]: + """Return if the cover is closed.""" + if self._device.shutterLevel is not None: + return self._device.shutterLevel == HMIP_COVER_CLOSED + return None + + async def async_open_cover(self, **kwargs) -> None: + """Open the cover.""" + await self._device.set_shutter_level(HMIP_COVER_OPEN) + + async def async_close_cover(self, **kwargs) -> None: + """Close the cover.""" + await self._device.set_shutter_level(HMIP_COVER_CLOSED) + + async def async_stop_cover(self, **kwargs) -> None: + """Stop the device if in motion.""" + await self._device.set_shutter_stop() + + +class HomematicipCoverSlats(HomematicipCoverShutter, CoverDevice): + """Representation of a HomematicIP Cloud cover slats device.""" + + @property + def current_cover_tilt_position(self) -> int: + """Return current tilt position of cover.""" + return int((1 - self._device.slatsLevel) * 100) + + async def async_set_cover_tilt_position(self, **kwargs) -> None: + """Move the cover to a specific tilt position.""" + position = kwargs[ATTR_TILT_POSITION] + # HmIP slats is closed:1 -> open:0 + level = 1 - position / 100.0 + await self._device.set_slats_level(level) + + async def async_open_cover_tilt(self, **kwargs) -> None: + """Open the slats.""" + await self._device.set_slats_level(HMIP_SLATS_OPEN) + + async def async_close_cover_tilt(self, **kwargs) -> None: + """Close the slats.""" + await self._device.set_slats_level(HMIP_SLATS_CLOSED) + + async def async_stop_cover_tilt(self, **kwargs) -> None: + """Stop the device if in motion.""" + await self._device.set_shutter_stop() diff --git a/homeassistant/components/homematicip_cloud/device.py b/homeassistant/components/homematicip_cloud/device.py index 9c335befd..f35b69676 100644 --- a/homeassistant/components/homematicip_cloud/device.py +++ b/homeassistant/components/homematicip_cloud/device.py @@ -1,81 +1,216 @@ """Generic device for the HomematicIP Cloud component.""" import logging +from typing import Any, Dict, Optional +from homematicip.aio.device import AsyncDevice +from homematicip.aio.group import AsyncGroup + +from homeassistant.core import callback +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity import Entity +from .const import DOMAIN as HMIPC_DOMAIN +from .hap import HomematicipHAP + _LOGGER = logging.getLogger(__name__) -ATTR_CONNECTED = 'connected' -ATTR_DEVICE_ID = 'device_id' -ATTR_DEVICE_LABEL = 'device_label' -ATTR_DEVICE_RSSI = 'device_rssi' -ATTR_DUTY_CYCLE = 'duty_cycle' -ATTR_FIRMWARE_STATE = 'firmware_state' -ATTR_GROUP_TYPE = 'group_type' -ATTR_HOME_ID = 'home_id' -ATTR_HOME_NAME = 'home_name' -ATTR_LOW_BATTERY = 'low_battery' -ATTR_MODEL_TYPE = 'model_type' -ATTR_OPERATION_LOCK = 'operation_lock' -ATTR_SABOTAGE = 'sabotage' -ATTR_STATUS_UPDATE = 'status_update' -ATTR_UNREACHABLE = 'unreachable' +ATTR_MODEL_TYPE = "model_type" +ATTR_LOW_BATTERY = "low_battery" +ATTR_CONFIG_PENDING = "config_pending" +ATTR_DUTY_CYCLE_REACHED = "duty_cycle_reached" +ATTR_ID = "id" +ATTR_IS_GROUP = "is_group" +# RSSI HAP -> Device +ATTR_RSSI_DEVICE = "rssi_device" +# RSSI Device -> HAP +ATTR_RSSI_PEER = "rssi_peer" +ATTR_SABOTAGE = "sabotage" +ATTR_GROUP_MEMBER_UNREACHABLE = "group_member_unreachable" +ATTR_DEVICE_OVERHEATED = "device_overheated" +ATTR_DEVICE_OVERLOADED = "device_overloaded" +ATTR_DEVICE_UNTERVOLTAGE = "device_undervoltage" +ATTR_EVENT_DELAY = "event_delay" + +DEVICE_ATTRIBUTE_ICONS = { + "lowBat": "mdi:battery-outline", + "sabotage": "mdi:shield-alert", + "dutyCycle": "mdi:alert", + "deviceOverheated": "mdi:alert", + "deviceOverloaded": "mdi:alert", + "deviceUndervoltage": "mdi:alert", + "configPending": "mdi:alert-circle", +} + +DEVICE_ATTRIBUTES = { + "modelType": ATTR_MODEL_TYPE, + "sabotage": ATTR_SABOTAGE, + "dutyCycle": ATTR_DUTY_CYCLE_REACHED, + "rssiDeviceValue": ATTR_RSSI_DEVICE, + "rssiPeerValue": ATTR_RSSI_PEER, + "deviceOverheated": ATTR_DEVICE_OVERHEATED, + "deviceOverloaded": ATTR_DEVICE_OVERLOADED, + "deviceUndervoltage": ATTR_DEVICE_UNTERVOLTAGE, + "configPending": ATTR_CONFIG_PENDING, + "eventDelay": ATTR_EVENT_DELAY, + "id": ATTR_ID, +} + +GROUP_ATTRIBUTES = { + "modelType": ATTR_MODEL_TYPE, + "lowBat": ATTR_LOW_BATTERY, + "sabotage": ATTR_SABOTAGE, + "dutyCycle": ATTR_DUTY_CYCLE_REACHED, + "configPending": ATTR_CONFIG_PENDING, + "unreach": ATTR_GROUP_MEMBER_UNREACHABLE, +} class HomematicipGenericDevice(Entity): """Representation of an HomematicIP generic device.""" - def __init__(self, home, device, post=None): + def __init__(self, hap: HomematicipHAP, device, post: Optional[str] = None) -> None: """Initialize the generic device.""" - self._home = home + self._hap = hap + self._home = hap.home self._device = device self.post = post + # Marker showing that the HmIP device hase been removed. + self.hmip_device_removed = False _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) + @property + def device_info(self) -> Dict[str, Any]: + """Return device specific attributes.""" + # Only physical devices should be HA devices. + if isinstance(self._device, AsyncDevice): + return { + "identifiers": { + # Serial numbers of Homematic IP device + (HMIPC_DOMAIN, self._device.id) + }, + "name": self._device.label, + "manufacturer": self._device.oem, + "model": self._device.modelType, + "sw_version": self._device.firmwareVersion, + "via_device": (HMIPC_DOMAIN, self._device.homeId), + } + return None - def _device_changed(self, json, **kwargs): + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + self._hap.hmip_device_by_entity_id[self.entity_id] = self._device + self._device.on_update(self._async_device_changed) + self._device.on_remove(self._async_device_removed) + + @callback + def _async_device_changed(self, *args, **kwargs) -> None: """Handle device state changes.""" - _LOGGER.debug("Event %s (%s)", self.name, self._device.modelType) - self.async_schedule_update_ha_state() + # Don't update disabled entities + if self.enabled: + _LOGGER.debug("Event %s (%s)", self.name, self._device.modelType) + self.async_schedule_update_ha_state() + else: + _LOGGER.debug( + "Device Changed Event for %s (%s) not fired. Entity is disabled.", + self.name, + self._device.modelType, + ) + + async def async_will_remove_from_hass(self) -> None: + """Run when entity will be removed from hass.""" + + # Only go further if the device/entity should be removed from registries + # due to a removal of the HmIP device. + if self.hmip_device_removed: + del self._hap.hmip_device_by_entity_id[self.entity_id] + await self.async_remove_from_registries() + + async def async_remove_from_registries(self) -> None: + """Remove entity/device from registry.""" + + # Remove callback from device. + self._device.remove_callback(self._async_device_changed) + self._device.remove_callback(self._async_device_removed) + + if not self.registry_entry: + return + + device_id = self.registry_entry.device_id + if device_id: + # Remove from device registry. + device_registry = await dr.async_get_registry(self.hass) + if device_id in device_registry.devices: + # This will also remove associated entities from entity registry. + device_registry.async_remove_device(device_id) + else: + # Remove from entity registry. + # Only relevant for entities that do not belong to a device. + entity_id = self.registry_entry.entity_id + if entity_id: + entity_registry = await er.async_get_registry(self.hass) + if entity_id in entity_registry.entities: + entity_registry.async_remove(entity_id) + + @callback + def _async_device_removed(self, *args, **kwargs) -> None: + """Handle hmip device removal.""" + # Set marker showing that the HmIP device hase been removed. + self.hmip_device_removed = True + self.hass.async_create_task(self.async_remove()) @property - def name(self): + def name(self) -> str: """Return the name of the generic device.""" name = self._device.label - if self._home.name is not None and self._home.name != '': - name = "{} {}".format(self._home.name, name) - if self.post is not None and self.post != '': - name = "{} {}".format(name, self.post) + if self._home.name is not None and self._home.name != "": + name = f"{self._home.name} {name}" + if self.post is not None and self.post != "": + name = f"{name} {self.post}" return name @property - def should_poll(self): + def should_poll(self) -> bool: """No polling needed.""" return False @property - def available(self): + def available(self) -> bool: """Device available.""" return not self._device.unreach @property - def icon(self): + def unique_id(self) -> str: + """Return a unique ID.""" + return f"{self.__class__.__name__}_{self._device.id}" + + @property + def icon(self) -> Optional[str]: """Return the icon.""" - if hasattr(self._device, 'lowBat') and self._device.lowBat: - return 'mdi:battery-outline' - if hasattr(self._device, 'sabotage') and self._device.sabotage: - return 'mdi:alert' + for attr, icon in DEVICE_ATTRIBUTE_ICONS.items(): + if getattr(self._device, attr, None): + return icon + return None @property - def device_state_attributes(self): + def device_state_attributes(self) -> Dict[str, Any]: """Return the state attributes of the generic device.""" - attr = {ATTR_MODEL_TYPE: self._device.modelType} - if hasattr(self._device, 'lowBat') and self._device.lowBat: - attr.update({ATTR_LOW_BATTERY: self._device.lowBat}) - if hasattr(self._device, 'sabotage') and self._device.sabotage: - attr.update({ATTR_SABOTAGE: self._device.sabotage}) - return attr + state_attr = {} + + if isinstance(self._device, AsyncDevice): + for attr, attr_key in DEVICE_ATTRIBUTES.items(): + attr_value = getattr(self._device, attr, None) + if attr_value: + state_attr[attr_key] = attr_value + + state_attr[ATTR_IS_GROUP] = False + + if isinstance(self._device, AsyncGroup): + for attr, attr_key in GROUP_ATTRIBUTES.items(): + attr_value = getattr(self._device, attr, None) + if attr_value: + state_attr[attr_key] = attr_value + + state_attr[ATTR_IS_GROUP] = True + + return state_attr diff --git a/homeassistant/components/homematicip_cloud/hap.py b/homeassistant/components/homematicip_cloud/hap.py index 6fddc7c00..63bdf3166 100644 --- a/homeassistant/components/homematicip_cloud/hap.py +++ b/homeassistant/components/homematicip_cloud/hap.py @@ -2,12 +2,18 @@ import asyncio import logging -from homeassistant import config_entries -from homeassistant.core import callback -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homematicip.aio.auth import AsyncAuth +from homematicip.aio.home import AsyncHome +from homematicip.base.base_connection import HmipConnectionError +from homematicip.base.enums import EventType -from .const import ( - COMPONENTS, HMIPC_AUTHTOKEN, HMIPC_HAPID, HMIPC_NAME, HMIPC_PIN) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import callback +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import HomeAssistantType + +from .const import COMPONENTS, HMIPC_AUTHTOKEN, HMIPC_HAPID, HMIPC_NAME, HMIPC_PIN from .errors import HmipcConnectionError _LOGGER = logging.getLogger(__name__) @@ -16,38 +22,31 @@ _LOGGER = logging.getLogger(__name__) class HomematicipAuth: """Manages HomematicIP client registration.""" - def __init__(self, hass, config): + def __init__(self, hass, config) -> None: """Initialize HomematicIP Cloud client registration.""" self.hass = hass self.config = config self.auth = None - async def async_setup(self): + async def async_setup(self) -> bool: """Connect to HomematicIP for registration.""" try: self.auth = await self.get_auth( - self.hass, - self.config.get(HMIPC_HAPID), - self.config.get(HMIPC_PIN) + self.hass, self.config.get(HMIPC_HAPID), self.config.get(HMIPC_PIN) ) return True except HmipcConnectionError: return False - async def async_checkbutton(self): + async def async_checkbutton(self) -> bool: """Check blue butten has been pressed.""" - from homematicip.base.base_connection import HmipConnectionError - try: - await self.auth.isRequestAcknowledged() - return True + return await self.auth.isRequestAcknowledged() except HmipConnectionError: return False async def async_register(self): """Register client at HomematicIP.""" - from homematicip.base.base_connection import HmipConnectionError - try: authtoken = await self.auth.requestAuthToken() await self.auth.confirmAuthToken(authtoken) @@ -55,18 +54,14 @@ class HomematicipAuth: except HmipConnectionError: return False - async def get_auth(self, hass, hapid, pin): + async def get_auth(self, hass: HomeAssistantType, hapid, pin): """Create a HomematicIP access point object.""" - from homematicip.aio.auth import AsyncAuth - from homematicip.base.base_connection import HmipConnectionError - auth = AsyncAuth(hass.loop, async_get_clientsession(hass)) - print(auth) try: await auth.init(hapid) if pin: auth.pin = pin - await auth.connectionRequest('HomeAssistant') + await auth.connectionRequest("HomeAssistant") except HmipConnectionError: return False return auth @@ -75,7 +70,7 @@ class HomematicipAuth: class HomematicipHAP: """Manages HomematicIP HTTP and WebSocket connection.""" - def __init__(self, hass, config_entry): + def __init__(self, hass: HomeAssistantType, config_entry: ConfigEntry) -> None: """Initialize HomematicIP Cloud connection.""" self.hass = hass self.config_entry = config_entry @@ -85,163 +80,159 @@ class HomematicipHAP: self._retry_task = None self._tries = 0 self._accesspoint_connected = True - self._retry_setup = None + self.hmip_device_by_entity_id = {} - async def async_setup(self, tries=0): + async def async_setup(self, tries: int = 0) -> bool: """Initialize connection.""" try: self.home = await self.get_hap( self.hass, self.config_entry.data.get(HMIPC_HAPID), self.config_entry.data.get(HMIPC_AUTHTOKEN), - self.config_entry.data.get(HMIPC_NAME) + self.config_entry.data.get(HMIPC_NAME), ) except HmipcConnectionError: - retry_delay = 2 ** min(tries + 1, 6) - _LOGGER.error("Error connecting to HomematicIP with HAP %s. " - "Retrying in %d seconds", - self.config_entry.data.get(HMIPC_HAPID), retry_delay) + raise ConfigEntryNotReady - async def retry_setup(_now): - """Retry setup.""" - if await self.async_setup(tries + 1): - self.config_entry.state = config_entries.ENTRY_STATE_LOADED - - self._retry_setup = self.hass.helpers.event.async_call_later( - retry_delay, retry_setup) - - return False - - _LOGGER.info("Connected to HomematicIP with HAP %s", - self.config_entry.data.get(HMIPC_HAPID)) + _LOGGER.info( + "Connected to HomematicIP with HAP %s", + self.config_entry.data.get(HMIPC_HAPID), + ) for component in COMPONENTS: self.hass.async_create_task( self.hass.config_entries.async_forward_entry_setup( - self.config_entry, component) + self.config_entry, component + ) ) return True @callback - def async_update(self, *args, **kwargs): + def async_update(self, *args, **kwargs) -> None: """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 + 1. We are 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. + 2. We need to update home including devices and groups after a reconnect. + 3. We need to update home without devices and groups in all other cases. + """ if not self.home.connected: - _LOGGER.error( - "HMIP access point has lost connection with the cloud") + _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: + # Now the HOME_CHANGED event has fired indicating the access + # point has reconnected to the cloud again. # Explicitly getting an update as device states might have # changed during access point disconnect.""" - job = self.hass.async_add_job(self.get_state()) + job = self.hass.async_create_task(self.get_state()) job.add_done_callback(self.get_state_finished) + self._accesspoint_connected = True + else: + # Update home with the given json from arg[0], + # without devices and groups. - async def get_state(self): + self.home.update_home_only(args[0]) + + @callback + def async_create_entity(self, *args, **kwargs) -> None: + """Create a device or a group.""" + is_device = EventType(kwargs["event_type"]) == EventType.DEVICE_ADDED + self.hass.async_create_task(self.async_create_entity_lazy(is_device)) + + async def async_create_entity_lazy(self, is_device=True) -> None: + """Delay entity creation to allow the user to enter a device name.""" + if is_device: + await asyncio.sleep(30) + await self.hass.config_entries.async_reload(self.config_entry.entry_id) + + async def get_state(self) -> None: """Update HMIP state and tell Home Assistant.""" await self.home.get_current_state() self.update_all() - def get_state_finished(self, future): + def get_state_finished(self, future) -> None: """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 HMIP access point reconnect failed") - self.hass.async_add_job(self.home.disable_events()) + _LOGGER.error("Updating state after HMIP access point reconnect failed") + self.hass.async_create_task(self.home.disable_events()) - def set_all_to_unavailable(self): + def set_all_to_unavailable(self) -> None: """Set all devices to unavailable and tell Home Assistant.""" for device in self.home.devices: device.unreach = True self.update_all() - def update_all(self): + def update_all(self) -> None: """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 - - try: - await self.home.get_current_state() - except HmipConnectionError: - return - hmip_events = await self.home.enable_events() - try: - await hmip_events - except HmipConnectionError: - return - - async def async_connect(self): + async def async_connect(self) -> None: """Start WebSocket connection.""" - from homematicip.base.base_connection import HmipConnectionError - tries = 0 while True: + retry_delay = 2 ** min(tries, 8) + try: await self.home.get_current_state() hmip_events = await self.home.enable_events() tries = 0 await hmip_events except HmipConnectionError: - pass + _LOGGER.error( + "Error connecting to HomematicIP with HAP %s. " + "Retrying in %d seconds", + self.config_entry.data.get(HMIPC_HAPID), + retry_delay, + ) if self._ws_close_requested: break self._ws_close_requested = False - tries += 1 - retry_delay = 2 ** min(tries + 1, 6) - _LOGGER.error("Error connecting to HomematicIP with HAP %s. " - "Retrying in %d seconds", - self.config_entry.data.get(HMIPC_HAPID), retry_delay) + try: - self._retry_task = self.hass.async_add_job(asyncio.sleep( - retry_delay, loop=self.hass.loop)) + self._retry_task = self.hass.async_create_task( + asyncio.sleep(retry_delay) + ) await self._retry_task except asyncio.CancelledError: break - async def async_reset(self): + async def async_reset(self) -> bool: """Close the websocket connection.""" self._ws_close_requested = True - if self._retry_setup is not None: - self._retry_setup.cancel() if self._retry_task is not None: self._retry_task.cancel() - self.home.disable_events() + await self.home.disable_events() _LOGGER.info("Closed connection to HomematicIP cloud server") for component in COMPONENTS: await self.hass.config_entries.async_forward_entry_unload( - self.config_entry, component) + self.config_entry, component + ) + self.hmip_device_by_entity_id = {} return True - async def get_hap(self, hass, hapid, authtoken, name): + async def get_hap( + self, hass: HomeAssistantType, hapid: str, authtoken: str, name: str + ) -> AsyncHome: """Create a HomematicIP access point object.""" - from homematicip.aio.home import AsyncHome - from homematicip.base.base_connection import HmipConnectionError - home = AsyncHome(hass.loop, async_get_clientsession(hass)) home.name = name - home.label = 'Access Point' - home.modelType = 'HmIP-HAP' + home.label = "Access Point" + home.modelType = "HmIP-HAP" home.set_auth_token(authtoken) try: @@ -250,6 +241,7 @@ class HomematicipHAP: except HmipConnectionError: raise HmipcConnectionError home.on_update(self.async_update) + home.on_create(self.async_create_entity) hass.loop.create_task(self.async_connect()) return home diff --git a/homeassistant/components/homematicip_cloud/light.py b/homeassistant/components/homematicip_cloud/light.py new file mode 100644 index 000000000..79083f031 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/light.py @@ -0,0 +1,280 @@ +"""Support for HomematicIP Cloud lights.""" +import logging +from typing import Any, Dict + +from homematicip.aio.device import ( + AsyncBrandDimmer, + AsyncBrandSwitchMeasuring, + AsyncBrandSwitchNotificationLight, + AsyncDimmer, + AsyncFullFlushDimmer, + AsyncPluggableDimmer, +) +from homematicip.base.enums import RGBColorState +from homematicip.base.functionalChannels import NotificationLightChannel + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_NAME, + ATTR_HS_COLOR, + ATTR_TRANSITION, + SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, + Light, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.typing import HomeAssistantType + +from . import DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID, HomematicipGenericDevice +from .hap import HomematicipHAP + +_LOGGER = logging.getLogger(__name__) + +ATTR_TODAY_ENERGY_KWH = "today_energy_kwh" +ATTR_CURRENT_POWER_W = "current_power_w" + + +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None +) -> None: + """Old way of setting up HomematicIP Cloud lights.""" + pass + + +async def async_setup_entry( + hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities +) -> None: + """Set up the HomematicIP Cloud lights from a config entry.""" + hap = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]] + entities = [] + for device in hap.home.devices: + if isinstance(device, AsyncBrandSwitchMeasuring): + entities.append(HomematicipLightMeasuring(hap, device)) + elif isinstance(device, AsyncBrandSwitchNotificationLight): + entities.append(HomematicipLight(hap, device)) + entities.append( + HomematicipNotificationLight(hap, device, device.topLightChannelIndex) + ) + entities.append( + HomematicipNotificationLight( + hap, device, device.bottomLightChannelIndex + ) + ) + elif isinstance( + device, + (AsyncDimmer, AsyncPluggableDimmer, AsyncBrandDimmer, AsyncFullFlushDimmer), + ): + entities.append(HomematicipDimmer(hap, device)) + + if entities: + async_add_entities(entities) + + +class HomematicipLight(HomematicipGenericDevice, Light): + """Representation of a HomematicIP Cloud light device.""" + + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize the light device.""" + super().__init__(hap, device) + + @property + def is_on(self) -> bool: + """Return true if device is on.""" + return self._device.on + + async def async_turn_on(self, **kwargs) -> None: + """Turn the device on.""" + await self._device.turn_on() + + async def async_turn_off(self, **kwargs) -> None: + """Turn the device off.""" + await self._device.turn_off() + + +class HomematicipLightMeasuring(HomematicipLight): + """Representation of a HomematicIP Cloud measuring light device.""" + + @property + def device_state_attributes(self) -> Dict[str, Any]: + """Return the state attributes of the generic device.""" + state_attr = super().device_state_attributes + + current_power_w = self._device.currentPowerConsumption + if current_power_w > 0.05: + state_attr[ATTR_CURRENT_POWER_W] = round(current_power_w, 2) + + state_attr[ATTR_TODAY_ENERGY_KWH] = round(self._device.energyCounter, 2) + + return state_attr + + +class HomematicipDimmer(HomematicipGenericDevice, Light): + """Representation of HomematicIP Cloud dimmer light device.""" + + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize the dimmer light device.""" + super().__init__(hap, device) + + @property + def is_on(self) -> bool: + """Return true if device is on.""" + return self._device.dimLevel is not None and self._device.dimLevel > 0.0 + + @property + def brightness(self) -> int: + """Return the brightness of this light between 0..255.""" + return int((self._device.dimLevel or 0.0) * 255) + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return SUPPORT_BRIGHTNESS + + async def async_turn_on(self, **kwargs) -> None: + """Turn the light on.""" + if ATTR_BRIGHTNESS in kwargs: + await self._device.set_dim_level(kwargs[ATTR_BRIGHTNESS] / 255.0) + else: + await self._device.set_dim_level(1) + + async def async_turn_off(self, **kwargs) -> None: + """Turn the light off.""" + await self._device.set_dim_level(0) + + +class HomematicipNotificationLight(HomematicipGenericDevice, Light): + """Representation of HomematicIP Cloud dimmer light device.""" + + def __init__(self, hap: HomematicipHAP, device, channel: int) -> None: + """Initialize the dimmer light device.""" + self.channel = channel + if self.channel == 2: + super().__init__(hap, device, "Top") + else: + super().__init__(hap, device, "Bottom") + + self._color_switcher = { + RGBColorState.WHITE: [0.0, 0.0], + RGBColorState.RED: [0.0, 100.0], + RGBColorState.YELLOW: [60.0, 100.0], + RGBColorState.GREEN: [120.0, 100.0], + RGBColorState.TURQUOISE: [180.0, 100.0], + RGBColorState.BLUE: [240.0, 100.0], + RGBColorState.PURPLE: [300.0, 100.0], + } + + @property + def _func_channel(self) -> NotificationLightChannel: + return self._device.functionalChannels[self.channel] + + @property + def is_on(self) -> bool: + """Return true if device is on.""" + return ( + self._func_channel.dimLevel is not None + and self._func_channel.dimLevel > 0.0 + ) + + @property + def brightness(self) -> int: + """Return the brightness of this light between 0..255.""" + return int((self._func_channel.dimLevel or 0.0) * 255) + + @property + def hs_color(self) -> tuple: + """Return the hue and saturation color value [float, float].""" + simple_rgb_color = self._func_channel.simpleRGBColorState + return self._color_switcher.get(simple_rgb_color, [0.0, 0.0]) + + @property + def device_state_attributes(self) -> Dict[str, Any]: + """Return the state attributes of the generic device.""" + state_attr = super().device_state_attributes + + if self.is_on: + state_attr[ATTR_COLOR_NAME] = self._func_channel.simpleRGBColorState + + return state_attr + + @property + def name(self) -> str: + """Return the name of the generic device.""" + return f"{super().name} Notification" + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return SUPPORT_BRIGHTNESS | SUPPORT_COLOR + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return f"{self.__class__.__name__}_{self.post}_{self._device.id}" + + async def async_turn_on(self, **kwargs) -> None: + """Turn the light on.""" + # Use hs_color from kwargs, + # if not applicable use current hs_color. + hs_color = kwargs.get(ATTR_HS_COLOR, self.hs_color) + simple_rgb_color = _convert_color(hs_color) + + # Use brightness from kwargs, + # if not applicable use current brightness. + brightness = kwargs.get(ATTR_BRIGHTNESS, self.brightness) + + # If no kwargs, use default value. + if not kwargs: + brightness = 255 + + # Minimum brightness is 10, otherwise the led is disabled + brightness = max(10, brightness) + dim_level = brightness / 255.0 + transition = kwargs.get(ATTR_TRANSITION, 0.5) + + await self._device.set_rgb_dim_level_with_time( + channelIndex=self.channel, + rgb=simple_rgb_color, + dimLevel=dim_level, + onTime=0, + rampTime=transition, + ) + + async def async_turn_off(self, **kwargs) -> None: + """Turn the light off.""" + simple_rgb_color = self._func_channel.simpleRGBColorState + transition = kwargs.get(ATTR_TRANSITION, 0.5) + + await self._device.set_rgb_dim_level_with_time( + channelIndex=self.channel, + rgb=simple_rgb_color, + dimLevel=0.0, + onTime=0, + rampTime=transition, + ) + + +def _convert_color(color: tuple) -> RGBColorState: + """ + Convert the given color to the reduced RGBColorState color. + + RGBColorStat contains only 8 colors including white and black, + so a conversion is required. + """ + if color is None: + return RGBColorState.WHITE + + hue = int(color[0]) + saturation = int(color[1]) + if saturation < 5: + return RGBColorState.WHITE + if 30 < hue <= 90: + return RGBColorState.YELLOW + if 90 < hue <= 160: + return RGBColorState.GREEN + if 150 < hue <= 210: + return RGBColorState.TURQUOISE + if 210 < hue <= 270: + return RGBColorState.BLUE + if 270 < hue <= 330: + return RGBColorState.PURPLE + return RGBColorState.RED diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json new file mode 100644 index 000000000..4feef19c8 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "homematicip_cloud", + "name": "Homematicip cloud", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/homematicip_cloud", + "requirements": [ + "homematicip==0.10.13" + ], + "dependencies": [], + "codeowners": [ + "@SukramJ" + ] +} diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py new file mode 100644 index 000000000..a8ca3d17e --- /dev/null +++ b/homeassistant/components/homematicip_cloud/sensor.py @@ -0,0 +1,426 @@ +"""Support for HomematicIP Cloud sensors.""" +import logging +from typing import Any, Dict + +from homematicip.aio.device import ( + AsyncBrandSwitchMeasuring, + AsyncFullFlushSwitchMeasuring, + AsyncHeatingThermostat, + AsyncHeatingThermostatCompact, + AsyncLightSensor, + AsyncMotionDetectorIndoor, + AsyncMotionDetectorOutdoor, + AsyncMotionDetectorPushButton, + AsyncPassageDetector, + AsyncPlugableSwitchMeasuring, + AsyncPresenceDetectorIndoor, + AsyncTemperatureHumiditySensorDisplay, + AsyncTemperatureHumiditySensorOutdoor, + AsyncTemperatureHumiditySensorWithoutDisplay, + AsyncWeatherSensor, + AsyncWeatherSensorPlus, + AsyncWeatherSensorPro, +) +from homematicip.base.enums import ValveState + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_POWER, + DEVICE_CLASS_TEMPERATURE, + POWER_WATT, + TEMP_CELSIUS, +) +from homeassistant.helpers.typing import HomeAssistantType + +from . import DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID, HomematicipGenericDevice +from .device import ATTR_IS_GROUP, ATTR_MODEL_TYPE +from .hap import HomematicipHAP + +_LOGGER = logging.getLogger(__name__) + +ATTR_CURRENT_ILLUMINATION = "current_illumination" +ATTR_LOWEST_ILLUMINATION = "lowest_illumination" +ATTR_HIGHEST_ILLUMINATION = "highest_illumination" +ATTR_LEFT_COUNTER = "left_counter" +ATTR_RIGHT_COUNTER = "right_counter" +ATTR_TEMPERATURE_OFFSET = "temperature_offset" +ATTR_WIND_DIRECTION = "wind_direction" +ATTR_WIND_DIRECTION_VARIATION = "wind_direction_variation_in_degree" + +ILLUMINATION_DEVICE_ATTRIBUTES = { + "currentIllumination": ATTR_CURRENT_ILLUMINATION, + "lowestIllumination": ATTR_LOWEST_ILLUMINATION, + "highestIllumination": ATTR_HIGHEST_ILLUMINATION, +} + + +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None +) -> None: + """Set up the HomematicIP Cloud sensors devices.""" + pass + + +async def async_setup_entry( + hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities +) -> None: + """Set up the HomematicIP Cloud sensors from a config entry.""" + hap = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]] + entities = [HomematicipAccesspointStatus(hap)] + for device in hap.home.devices: + if isinstance(device, (AsyncHeatingThermostat, AsyncHeatingThermostatCompact)): + entities.append(HomematicipHeatingThermostat(hap, device)) + entities.append(HomematicipTemperatureSensor(hap, device)) + if isinstance( + device, + ( + AsyncTemperatureHumiditySensorDisplay, + AsyncTemperatureHumiditySensorWithoutDisplay, + AsyncTemperatureHumiditySensorOutdoor, + AsyncWeatherSensor, + AsyncWeatherSensorPlus, + AsyncWeatherSensorPro, + ), + ): + entities.append(HomematicipTemperatureSensor(hap, device)) + entities.append(HomematicipHumiditySensor(hap, device)) + if isinstance( + device, + ( + AsyncLightSensor, + AsyncMotionDetectorIndoor, + AsyncMotionDetectorOutdoor, + AsyncMotionDetectorPushButton, + AsyncPresenceDetectorIndoor, + AsyncWeatherSensor, + AsyncWeatherSensorPlus, + AsyncWeatherSensorPro, + ), + ): + entities.append(HomematicipIlluminanceSensor(hap, device)) + if isinstance( + device, + ( + AsyncPlugableSwitchMeasuring, + AsyncBrandSwitchMeasuring, + AsyncFullFlushSwitchMeasuring, + ), + ): + entities.append(HomematicipPowerSensor(hap, device)) + if isinstance( + device, (AsyncWeatherSensor, AsyncWeatherSensorPlus, AsyncWeatherSensorPro) + ): + entities.append(HomematicipWindspeedSensor(hap, device)) + if isinstance(device, (AsyncWeatherSensorPlus, AsyncWeatherSensorPro)): + entities.append(HomematicipTodayRainSensor(hap, device)) + if isinstance(device, AsyncPassageDetector): + entities.append(HomematicipPassageDetectorDeltaCounter(hap, device)) + + if entities: + async_add_entities(entities) + + +class HomematicipAccesspointStatus(HomematicipGenericDevice): + """Representation of an HomeMaticIP Cloud access point.""" + + def __init__(self, hap: HomematicipHAP) -> None: + """Initialize access point device.""" + super().__init__(hap, hap.home) + + @property + def device_info(self) -> Dict[str, Any]: + """Return device specific attributes.""" + # Adds a sensor to the existing HAP device + return { + "identifiers": { + # Serial numbers of Homematic IP device + (HMIPC_DOMAIN, self._device.id) + } + } + + @property + def icon(self) -> str: + """Return the icon of the access point device.""" + return "mdi:access-point-network" + + @property + def state(self) -> float: + """Return the state of the access point.""" + return self._home.dutyCycle + + @property + def available(self) -> bool: + """Device available.""" + return self._home.connected + + @property + def unit_of_measurement(self) -> str: + """Return the unit this state is expressed in.""" + return "%" + + @property + def device_state_attributes(self) -> Dict[str, Any]: + """Return the state attributes of the access point.""" + state_attr = super().device_state_attributes + + state_attr[ATTR_MODEL_TYPE] = "HmIP-HAP" + state_attr[ATTR_IS_GROUP] = False + + return state_attr + + +class HomematicipHeatingThermostat(HomematicipGenericDevice): + """Representation of a HomematicIP heating thermostat device.""" + + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize heating thermostat device.""" + super().__init__(hap, device, "Heating") + + @property + def icon(self) -> str: + """Return the icon.""" + if super().icon: + return super().icon + if self._device.valveState != ValveState.ADAPTION_DONE: + return "mdi:alert" + return "mdi:radiator" + + @property + def state(self) -> int: + """Return the state of the radiator valve.""" + if self._device.valveState != ValveState.ADAPTION_DONE: + return self._device.valveState + return round(self._device.valvePosition * 100) + + @property + def unit_of_measurement(self) -> str: + """Return the unit this state is expressed in.""" + return "%" + + +class HomematicipHumiditySensor(HomematicipGenericDevice): + """Representation of a HomematicIP Cloud humidity device.""" + + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize the thermometer device.""" + super().__init__(hap, device, "Humidity") + + @property + def device_class(self) -> str: + """Return the device class of the sensor.""" + return DEVICE_CLASS_HUMIDITY + + @property + def state(self) -> int: + """Return the state.""" + return self._device.humidity + + @property + def unit_of_measurement(self) -> str: + """Return the unit this state is expressed in.""" + return "%" + + +class HomematicipTemperatureSensor(HomematicipGenericDevice): + """Representation of a HomematicIP Cloud thermometer device.""" + + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize the thermometer device.""" + super().__init__(hap, device, "Temperature") + + @property + def device_class(self) -> str: + """Return the device class of the sensor.""" + return DEVICE_CLASS_TEMPERATURE + + @property + def state(self) -> float: + """Return the state.""" + if hasattr(self._device, "valveActualTemperature"): + return self._device.valveActualTemperature + + return self._device.actualTemperature + + @property + def unit_of_measurement(self) -> str: + """Return the unit this state is expressed in.""" + return TEMP_CELSIUS + + @property + def device_state_attributes(self) -> Dict[str, Any]: + """Return the state attributes of the windspeed sensor.""" + state_attr = super().device_state_attributes + + temperature_offset = getattr(self._device, "temperatureOffset", None) + if temperature_offset: + state_attr[ATTR_TEMPERATURE_OFFSET] = temperature_offset + + return state_attr + + +class HomematicipIlluminanceSensor(HomematicipGenericDevice): + """Representation of a HomematicIP Illuminance device.""" + + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize the device.""" + super().__init__(hap, device, "Illuminance") + + @property + def device_class(self) -> str: + """Return the device class of the sensor.""" + return DEVICE_CLASS_ILLUMINANCE + + @property + def state(self) -> float: + """Return the state.""" + if hasattr(self._device, "averageIllumination"): + return self._device.averageIllumination + + return self._device.illumination + + @property + def unit_of_measurement(self) -> str: + """Return the unit this state is expressed in.""" + return "lx" + + @property + def device_state_attributes(self) -> Dict[str, Any]: + """Return the state attributes of the wind speed sensor.""" + state_attr = super().device_state_attributes + + for attr, attr_key in ILLUMINATION_DEVICE_ATTRIBUTES.items(): + attr_value = getattr(self._device, attr, None) + if attr_value: + state_attr[attr_key] = attr_value + + return state_attr + + +class HomematicipPowerSensor(HomematicipGenericDevice): + """Representation of a HomematicIP power measuring device.""" + + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize the device.""" + super().__init__(hap, device, "Power") + + @property + def device_class(self) -> str: + """Return the device class of the sensor.""" + return DEVICE_CLASS_POWER + + @property + def state(self) -> float: + """Representation of the HomematicIP power comsumption value.""" + return self._device.currentPowerConsumption + + @property + def unit_of_measurement(self) -> str: + """Return the unit this state is expressed in.""" + return POWER_WATT + + +class HomematicipWindspeedSensor(HomematicipGenericDevice): + """Representation of a HomematicIP wind speed sensor.""" + + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize the device.""" + super().__init__(hap, device, "Windspeed") + + @property + def state(self) -> float: + """Representation of the HomematicIP wind speed value.""" + return self._device.windSpeed + + @property + def unit_of_measurement(self) -> str: + """Return the unit this state is expressed in.""" + return "km/h" + + @property + def device_state_attributes(self) -> Dict[str, Any]: + """Return the state attributes of the wind speed sensor.""" + state_attr = super().device_state_attributes + + wind_direction = getattr(self._device, "windDirection", None) + if wind_direction is not None: + state_attr[ATTR_WIND_DIRECTION] = _get_wind_direction(wind_direction) + + wind_direction_variation = getattr(self._device, "windDirectionVariation", None) + if wind_direction_variation: + state_attr[ATTR_WIND_DIRECTION_VARIATION] = wind_direction_variation + + return state_attr + + +class HomematicipTodayRainSensor(HomematicipGenericDevice): + """Representation of a HomematicIP rain counter of a day sensor.""" + + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize the device.""" + super().__init__(hap, device, "Today Rain") + + @property + def state(self) -> float: + """Representation of the HomematicIP todays rain value.""" + return round(self._device.todayRainCounter, 2) + + @property + def unit_of_measurement(self) -> str: + """Return the unit this state is expressed in.""" + return "mm" + + +class HomematicipPassageDetectorDeltaCounter(HomematicipGenericDevice): + """Representation of a HomematicIP passage detector delta counter.""" + + @property + def state(self) -> int: + """Representation of the HomematicIP passage detector delta counter value.""" + return self._device.leftRightCounterDelta + + @property + def device_state_attributes(self) -> Dict[str, Any]: + """Return the state attributes of the delta counter.""" + state_attr = super().device_state_attributes + + state_attr[ATTR_LEFT_COUNTER] = self._device.leftCounter + state_attr[ATTR_RIGHT_COUNTER] = self._device.rightCounter + + return state_attr + + +def _get_wind_direction(wind_direction_degree: float) -> str: + """Convert wind direction degree to named direction.""" + if 11.25 <= wind_direction_degree < 33.75: + return "NNE" + if 33.75 <= wind_direction_degree < 56.25: + return "NE" + if 56.25 <= wind_direction_degree < 78.75: + return "ENE" + if 78.75 <= wind_direction_degree < 101.25: + return "E" + if 101.25 <= wind_direction_degree < 123.75: + return "ESE" + if 123.75 <= wind_direction_degree < 146.25: + return "SE" + if 146.25 <= wind_direction_degree < 168.75: + return "SSE" + if 168.75 <= wind_direction_degree < 191.25: + return "S" + if 191.25 <= wind_direction_degree < 213.75: + return "SSW" + if 213.75 <= wind_direction_degree < 236.25: + return "SW" + if 236.25 <= wind_direction_degree < 258.75: + return "WSW" + if 258.75 <= wind_direction_degree < 281.25: + return "W" + if 281.25 <= wind_direction_degree < 303.75: + return "WNW" + if 303.75 <= wind_direction_degree < 326.25: + return "NW" + if 326.25 <= wind_direction_degree < 348.75: + return "NNW" + return "N" diff --git a/homeassistant/components/homematicip_cloud/services.yaml b/homeassistant/components/homematicip_cloud/services.yaml new file mode 100644 index 000000000..9a7d90eba --- /dev/null +++ b/homeassistant/components/homematicip_cloud/services.yaml @@ -0,0 +1,71 @@ +# Describes the format for available component services + +activate_eco_mode_with_duration: + description: Activate eco mode with period. + fields: + duration: + description: The duration of eco mode in minutes. + example: 60 + accesspoint_id: + description: The ID of the Homematic IP Access Point (optional) + example: 3014xxxxxxxxxxxxxxxxxxxx + +activate_eco_mode_with_period: + description: Activate eco mode with period. + fields: + endtime: + description: The time when the eco mode should automatically be disabled. + example: 2019-02-17 14:00 + accesspoint_id: + description: The ID of the Homematic IP Access Point (optional) + example: 3014xxxxxxxxxxxxxxxxxxxx + +activate_vacation: + description: Activates the vacation mode until the given time. + fields: + endtime: + description: The time when the vacation mode should automatically be disabled. + example: 2019-09-17 14:00 + temperature: + description: the set temperature during the vacation mode. + example: 18.5 + accesspoint_id: + description: The ID of the Homematic IP Access Point (optional) + example: 3014xxxxxxxxxxxxxxxxxxxx + +deactivate_eco_mode: + description: Deactivates the eco mode immediately. + fields: + accesspoint_id: + description: The ID of the Homematic IP Access Point (optional) + example: 3014xxxxxxxxxxxxxxxxxxxx + +deactivate_vacation: + description: Deactivates the vacation mode immediately. + fields: + accesspoint_id: + description: The ID of the Homematic IP Access Point (optional) + example: 3014xxxxxxxxxxxxxxxxxxxx + +set_active_climate_profile: + description: Set the active climate profile index. + fields: + entity_id: + description: The ID of the climte entity. Use 'all' keyword to switch the profile for all entities. + example: climate.livingroom + climate_profile_index: + description: The index of the climate profile (1 based) + example: 1 + +dump_hap_config: + description: Dump the configuration of the Homematic IP Access Point(s). + fields: + config_output_path: + description: (Default is 'Your home-assistant config directory') Path where to store the config. + example: '/config' + config_output_file_prefix: + description: (Default is 'hmip-config') Name of the config file. The SGTIN of the AP will always be appended. + example: 'hmip-config' + anonymize: + description: (Default is True) Should the Configuration be anonymized? + example: True diff --git a/homeassistant/components/homematicip_cloud/switch.py b/homeassistant/components/homematicip_cloud/switch.py new file mode 100644 index 000000000..8e15313a4 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/switch.py @@ -0,0 +1,175 @@ +"""Support for HomematicIP Cloud switches.""" +import logging +from typing import Any, Dict + +from homematicip.aio.device import ( + AsyncBrandSwitchMeasuring, + AsyncFullFlushSwitchMeasuring, + AsyncMultiIOBox, + AsyncOpenCollector8Module, + AsyncPlugableSwitch, + AsyncPlugableSwitchMeasuring, + AsyncPrintedCircuitBoardSwitch2, + AsyncPrintedCircuitBoardSwitchBattery, +) +from homematicip.aio.group import AsyncSwitchingGroup + +from homeassistant.components.switch import SwitchDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.typing import HomeAssistantType + +from . import DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID, HomematicipGenericDevice +from .device import ATTR_GROUP_MEMBER_UNREACHABLE +from .hap import HomematicipHAP + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None +) -> None: + """Set up the HomematicIP Cloud switch devices.""" + pass + + +async def async_setup_entry( + hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities +) -> None: + """Set up the HomematicIP switch from a config entry.""" + hap = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]] + entities = [] + for device in hap.home.devices: + if isinstance(device, AsyncBrandSwitchMeasuring): + # BrandSwitchMeasuring inherits PlugableSwitchMeasuring + # This device is implemented in the light platform and will + # not be added in the switch platform + pass + elif isinstance( + device, (AsyncPlugableSwitchMeasuring, AsyncFullFlushSwitchMeasuring) + ): + entities.append(HomematicipSwitchMeasuring(hap, device)) + elif isinstance( + device, (AsyncPlugableSwitch, AsyncPrintedCircuitBoardSwitchBattery) + ): + entities.append(HomematicipSwitch(hap, device)) + elif isinstance(device, AsyncOpenCollector8Module): + for channel in range(1, 9): + entities.append(HomematicipMultiSwitch(hap, device, channel)) + elif isinstance(device, AsyncMultiIOBox): + for channel in range(1, 3): + entities.append(HomematicipMultiSwitch(hap, device, channel)) + elif isinstance(device, AsyncPrintedCircuitBoardSwitch2): + for channel in range(1, 3): + entities.append(HomematicipMultiSwitch(hap, device, channel)) + + for group in hap.home.groups: + if isinstance(group, AsyncSwitchingGroup): + entities.append(HomematicipGroupSwitch(hap, group)) + + if entities: + async_add_entities(entities) + + +class HomematicipSwitch(HomematicipGenericDevice, SwitchDevice): + """representation of a HomematicIP Cloud switch device.""" + + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize the switch device.""" + super().__init__(hap, device) + + @property + def is_on(self) -> bool: + """Return true if device is on.""" + return self._device.on + + async def async_turn_on(self, **kwargs) -> None: + """Turn the device on.""" + await self._device.turn_on() + + async def async_turn_off(self, **kwargs) -> None: + """Turn the device off.""" + await self._device.turn_off() + + +class HomematicipGroupSwitch(HomematicipGenericDevice, SwitchDevice): + """representation of a HomematicIP switching group.""" + + def __init__(self, hap: HomematicipHAP, device, post: str = "Group") -> None: + """Initialize switching group.""" + device.modelType = f"HmIP-{post}" + super().__init__(hap, device, post) + + @property + def is_on(self) -> bool: + """Return true if group is on.""" + return self._device.on + + @property + def available(self) -> bool: + """Switch-Group available.""" + # A switch-group must be available, and should not be affected by the + # individual availability of group members. + # This allows switching even when individual group members + # are not available. + return True + + @property + def device_state_attributes(self) -> Dict[str, Any]: + """Return the state attributes of the switch-group.""" + state_attr = super().device_state_attributes + + if self._device.unreach: + state_attr[ATTR_GROUP_MEMBER_UNREACHABLE] = True + + return state_attr + + async def async_turn_on(self, **kwargs) -> None: + """Turn the group on.""" + await self._device.turn_on() + + async def async_turn_off(self, **kwargs) -> None: + """Turn the group off.""" + await self._device.turn_off() + + +class HomematicipSwitchMeasuring(HomematicipSwitch): + """Representation of a HomematicIP measuring switch device.""" + + @property + def current_power_w(self) -> float: + """Return the current power usage in W.""" + return self._device.currentPowerConsumption + + @property + def today_energy_kwh(self) -> int: + """Return the today total energy usage in kWh.""" + if self._device.energyCounter is None: + return 0 + return round(self._device.energyCounter) + + +class HomematicipMultiSwitch(HomematicipGenericDevice, SwitchDevice): + """Representation of a HomematicIP Cloud multi switch device.""" + + def __init__(self, hap: HomematicipHAP, device, channel: int) -> None: + """Initialize the multi switch device.""" + self.channel = channel + super().__init__(hap, device, f"Channel{channel}") + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return f"{self.__class__.__name__}_{self.post}_{self._device.id}" + + @property + def is_on(self) -> bool: + """Return true if device is on.""" + return self._device.functionalChannels[self.channel].on + + async def async_turn_on(self, **kwargs) -> None: + """Turn the device on.""" + await self._device.turn_on(self.channel) + + async def async_turn_off(self, **kwargs) -> None: + """Turn the device off.""" + await self._device.turn_off(self.channel) diff --git a/homeassistant/components/homematicip_cloud/weather.py b/homeassistant/components/homematicip_cloud/weather.py new file mode 100644 index 000000000..ebc7eacf7 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/weather.py @@ -0,0 +1,174 @@ +"""Support for HomematicIP Cloud weather devices.""" +import logging + +from homematicip.aio.device import ( + AsyncWeatherSensor, + AsyncWeatherSensorPlus, + AsyncWeatherSensorPro, +) +from homematicip.base.enums import WeatherCondition + +from homeassistant.components.weather import WeatherEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import TEMP_CELSIUS +from homeassistant.helpers.typing import HomeAssistantType + +from . import DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID, HomematicipGenericDevice +from .hap import HomematicipHAP + +_LOGGER = logging.getLogger(__name__) + +HOME_WEATHER_CONDITION = { + WeatherCondition.CLEAR: "sunny", + WeatherCondition.LIGHT_CLOUDY: "partlycloudy", + WeatherCondition.CLOUDY: "cloudy", + WeatherCondition.CLOUDY_WITH_RAIN: "rainy", + WeatherCondition.CLOUDY_WITH_SNOW_RAIN: "snowy-rainy", + WeatherCondition.HEAVILY_CLOUDY: "cloudy", + WeatherCondition.HEAVILY_CLOUDY_WITH_RAIN: "rainy", + WeatherCondition.HEAVILY_CLOUDY_WITH_STRONG_RAIN: "snowy-rainy", + WeatherCondition.HEAVILY_CLOUDY_WITH_SNOW: "snowy", + WeatherCondition.HEAVILY_CLOUDY_WITH_SNOW_RAIN: "snowy-rainy", + WeatherCondition.HEAVILY_CLOUDY_WITH_THUNDER: "lightning", + WeatherCondition.HEAVILY_CLOUDY_WITH_RAIN_AND_THUNDER: "lightning-rainy", + WeatherCondition.FOGGY: "fog", + WeatherCondition.STRONG_WIND: "windy", + WeatherCondition.UNKNOWN: "", +} + + +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None +) -> None: + """Set up the HomematicIP Cloud weather sensor.""" + pass + + +async def async_setup_entry( + hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities +) -> None: + """Set up the HomematicIP weather sensor from a config entry.""" + hap = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]] + entities = [] + for device in hap.home.devices: + if isinstance(device, AsyncWeatherSensorPro): + entities.append(HomematicipWeatherSensorPro(hap, device)) + elif isinstance(device, (AsyncWeatherSensor, AsyncWeatherSensorPlus)): + entities.append(HomematicipWeatherSensor(hap, device)) + + entities.append(HomematicipHomeWeather(hap)) + + if entities: + async_add_entities(entities) + + +class HomematicipWeatherSensor(HomematicipGenericDevice, WeatherEntity): + """representation of a HomematicIP Cloud weather sensor plus & basic.""" + + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize the weather sensor.""" + super().__init__(hap, device) + + @property + def name(self) -> str: + """Return the name of the sensor.""" + return self._device.label + + @property + def temperature(self) -> float: + """Return the platform temperature.""" + return self._device.actualTemperature + + @property + def temperature_unit(self) -> str: + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def humidity(self) -> int: + """Return the humidity.""" + return self._device.humidity + + @property + def wind_speed(self) -> float: + """Return the wind speed.""" + return self._device.windSpeed + + @property + def attribution(self) -> str: + """Return the attribution.""" + return "Powered by Homematic IP" + + @property + def condition(self) -> str: + """Return the current condition.""" + if getattr(self._device, "raining", None): + return "rainy" + if self._device.storm: + return "windy" + if self._device.sunshine: + return "sunny" + return "" + + +class HomematicipWeatherSensorPro(HomematicipWeatherSensor): + """representation of a HomematicIP weather sensor pro.""" + + @property + def wind_bearing(self) -> float: + """Return the wind bearing.""" + return self._device.windDirection + + +class HomematicipHomeWeather(HomematicipGenericDevice, WeatherEntity): + """representation of a HomematicIP Cloud home weather.""" + + def __init__(self, hap: HomematicipHAP) -> None: + """Initialize the home weather.""" + hap.home.modelType = "HmIP-Home-Weather" + super().__init__(hap, hap.home) + + @property + def available(self) -> bool: + """Device available.""" + return self._home.connected + + @property + def name(self) -> str: + """Return the name of the sensor.""" + return f"Weather {self._home.location.city}" + + @property + def temperature(self) -> float: + """Return the platform temperature.""" + return self._device.weather.temperature + + @property + def temperature_unit(self) -> str: + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def humidity(self) -> int: + """Return the humidity.""" + return self._device.weather.humidity + + @property + def wind_speed(self) -> float: + """Return the wind speed.""" + return round(self._device.weather.windSpeed, 1) + + @property + def wind_bearing(self) -> float: + """Return the wind bearing.""" + return self._device.weather.windDirection + + @property + def attribution(self) -> str: + """Return the attribution.""" + return "Powered by Homematic IP" + + @property + def condition(self) -> str: + """Return the current condition.""" + return HOME_WEATHER_CONDITION.get(self._device.weather.weatherCondition) diff --git a/homeassistant/components/homeworks/__init__.py b/homeassistant/components/homeworks/__init__.py new file mode 100644 index 000000000..c6296d8f4 --- /dev/null +++ b/homeassistant/components/homeworks/__init__.py @@ -0,0 +1,149 @@ +"""Support for Lutron Homeworks Series 4 and 8 systems.""" +import logging + +from pyhomeworks.pyhomeworks import HW_BUTTON_PRESSED, HW_BUTTON_RELEASED, Homeworks +import voluptuous as vol + +from homeassistant.const import ( + CONF_HOST, + CONF_ID, + CONF_NAME, + CONF_PORT, + EVENT_HOMEASSISTANT_STOP, +) +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.discovery import load_platform +from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send +from homeassistant.util import slugify + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "homeworks" + +HOMEWORKS_CONTROLLER = "homeworks" +ENTITY_SIGNAL = "homeworks_entity_{}" +EVENT_BUTTON_PRESS = "homeworks_button_press" +EVENT_BUTTON_RELEASE = "homeworks_button_release" + +CONF_DIMMERS = "dimmers" +CONF_KEYPADS = "keypads" +CONF_ADDR = "addr" +CONF_RATE = "rate" + +FADE_RATE = 1.0 + +CV_FADE_RATE = vol.All(vol.Coerce(float), vol.Range(min=0, max=20)) + +DIMMER_SCHEMA = vol.Schema( + { + vol.Required(CONF_ADDR): cv.string, + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_RATE, default=FADE_RATE): CV_FADE_RATE, + } +) + +KEYPAD_SCHEMA = vol.Schema( + {vol.Required(CONF_ADDR): cv.string, vol.Required(CONF_NAME): cv.string} +) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PORT): cv.port, + vol.Required(CONF_DIMMERS): vol.All(cv.ensure_list, [DIMMER_SCHEMA]), + vol.Optional(CONF_KEYPADS, default=[]): vol.All( + cv.ensure_list, [KEYPAD_SCHEMA] + ), + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +def setup(hass, base_config): + """Start Homeworks controller.""" + + def hw_callback(msg_type, values): + """Dispatch state changes.""" + _LOGGER.debug("callback: %s, %s", msg_type, values) + addr = values[0] + signal = ENTITY_SIGNAL.format(addr) + dispatcher_send(hass, signal, msg_type, values) + + config = base_config.get(DOMAIN) + controller = Homeworks(config[CONF_HOST], config[CONF_PORT], hw_callback) + hass.data[HOMEWORKS_CONTROLLER] = controller + + def cleanup(event): + controller.close() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, cleanup) + + dimmers = config[CONF_DIMMERS] + load_platform(hass, "light", DOMAIN, {CONF_DIMMERS: dimmers}, base_config) + + for key_config in config[CONF_KEYPADS]: + addr = key_config[CONF_ADDR] + name = key_config[CONF_NAME] + HomeworksKeypadEvent(hass, addr, name) + + return True + + +class HomeworksDevice: + """Base class of a Homeworks device.""" + + def __init__(self, controller, addr, name): + """Initialize Homeworks device.""" + self._addr = addr + self._name = name + self._controller = controller + + @property + def unique_id(self): + """Return a unique identifier.""" + return f"homeworks.{self._addr}" + + @property + def name(self): + """Device name.""" + return self._name + + @property + def should_poll(self): + """No need to poll.""" + return False + + +class HomeworksKeypadEvent: + """When you want signals instead of entities. + + Stateless sensors such as keypads are expected to generate an event + instead of a sensor entity in hass. + """ + + def __init__(self, hass, addr, name): + """Register callback that will be used for signals.""" + self._hass = hass + self._addr = addr + self._name = name + self._id = slugify(self._name) + signal = ENTITY_SIGNAL.format(self._addr) + async_dispatcher_connect(self._hass, signal, self._update_callback) + + @callback + def _update_callback(self, msg_type, values): + """Fire events if button is pressed or released.""" + + if msg_type == HW_BUTTON_PRESSED: + event = EVENT_BUTTON_PRESS + elif msg_type == HW_BUTTON_RELEASED: + event = EVENT_BUTTON_RELEASE + else: + return + data = {CONF_ID: self._id, CONF_NAME: self._name, "button": values[1]} + self._hass.bus.async_fire(event, data) diff --git a/homeassistant/components/homeworks/light.py b/homeassistant/components/homeworks/light.py new file mode 100644 index 000000000..2c0034ee9 --- /dev/null +++ b/homeassistant/components/homeworks/light.py @@ -0,0 +1,103 @@ +"""Support for Lutron Homeworks lights.""" +import logging + +from pyhomeworks.pyhomeworks import HW_LIGHT_CHANGED + +from homeassistant.components.light import ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light +from homeassistant.const import CONF_NAME +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from . import ( + CONF_ADDR, + CONF_DIMMERS, + CONF_RATE, + ENTITY_SIGNAL, + HOMEWORKS_CONTROLLER, + HomeworksDevice, +) + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discover_info=None): + """Set up Homeworks lights.""" + if discover_info is None: + return + + controller = hass.data[HOMEWORKS_CONTROLLER] + devs = [] + for dimmer in discover_info[CONF_DIMMERS]: + dev = HomeworksLight( + controller, dimmer[CONF_ADDR], dimmer[CONF_NAME], dimmer[CONF_RATE] + ) + devs.append(dev) + add_entities(devs, True) + + +class HomeworksLight(HomeworksDevice, Light): + """Homeworks Light.""" + + def __init__(self, controller, addr, name, rate): + """Create device with Addr, name, and rate.""" + super().__init__(controller, addr, name) + self._rate = rate + self._level = 0 + self._prev_level = 0 + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + signal = ENTITY_SIGNAL.format(self._addr) + _LOGGER.debug("connecting %s", signal) + async_dispatcher_connect(self.hass, signal, self._update_callback) + self._controller.request_dimmer_level(self._addr) + + @property + def supported_features(self): + """Supported features.""" + return SUPPORT_BRIGHTNESS + + def turn_on(self, **kwargs): + """Turn on the light.""" + if ATTR_BRIGHTNESS in kwargs: + new_level = kwargs[ATTR_BRIGHTNESS] + elif self._prev_level == 0: + new_level = 255 + else: + new_level = self._prev_level + self._set_brightness(new_level) + + def turn_off(self, **kwargs): + """Turn off the light.""" + self._set_brightness(0) + + @property + def brightness(self): + """Control the brightness.""" + return self._level + + def _set_brightness(self, level): + """Send the brightness level to the device.""" + self._controller.fade_dim( + float((level * 100.0) / 255.0), self._rate, 0, self._addr + ) + + @property + def device_state_attributes(self): + """Supported attributes.""" + return {"homeworks_address": self._addr} + + @property + def is_on(self): + """Is the light on/off.""" + return self._level != 0 + + @callback + def _update_callback(self, msg_type, values): + """Process device specific messages.""" + + if msg_type == HW_LIGHT_CHANGED: + self._level = int((values[1] * 255.0) / 100.0) + if self._level != 0: + self._prev_level = self._level + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/homeworks/manifest.json b/homeassistant/components/homeworks/manifest.json new file mode 100644 index 000000000..f2929fb65 --- /dev/null +++ b/homeassistant/components/homeworks/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "homeworks", + "name": "Homeworks", + "documentation": "https://www.home-assistant.io/integrations/homeworks", + "requirements": [ + "pyhomeworks==0.0.6" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/honeywell/__init__.py b/homeassistant/components/honeywell/__init__.py new file mode 100644 index 000000000..57176c9ac --- /dev/null +++ b/homeassistant/components/honeywell/__init__.py @@ -0,0 +1 @@ +"""Support for Honeywell (US) Total Connect Comfort climate systems.""" diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py new file mode 100644 index 000000000..f8537bfe9 --- /dev/null +++ b/homeassistant/components/honeywell/climate.py @@ -0,0 +1,459 @@ +"""Support for Honeywell (US) Total Connect Comfort climate systems.""" +import datetime +import logging +from typing import Any, Dict, List, Optional + +import requests +import somecomfort +import voluptuous as vol + +from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateDevice +from homeassistant.components.climate.const import ( + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + CURRENT_HVAC_COOL, + CURRENT_HVAC_FAN, + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, + FAN_AUTO, + FAN_DIFFUSE, + FAN_ON, + HVAC_MODE_COOL, + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_OFF, + PRESET_AWAY, + PRESET_NONE, + SUPPORT_AUX_HEAT, + SUPPORT_FAN_MODE, + SUPPORT_PRESET_MODE, + SUPPORT_TARGET_HUMIDITY, + SUPPORT_TARGET_TEMPERATURE, + SUPPORT_TARGET_TEMPERATURE_RANGE, +) +from homeassistant.const import ( + ATTR_TEMPERATURE, + CONF_PASSWORD, + CONF_REGION, + CONF_USERNAME, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +ATTR_FAN_ACTION = "fan_action" + +CONF_COOL_AWAY_TEMPERATURE = "away_cool_temperature" +CONF_HEAT_AWAY_TEMPERATURE = "away_heat_temperature" + +DEFAULT_COOL_AWAY_TEMPERATURE = 88 +DEFAULT_HEAT_AWAY_TEMPERATURE = 61 +DEFAULT_REGION = "eu" +REGIONS = ["eu", "us"] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional( + CONF_COOL_AWAY_TEMPERATURE, default=DEFAULT_COOL_AWAY_TEMPERATURE + ): vol.Coerce(int), + vol.Optional( + CONF_HEAT_AWAY_TEMPERATURE, default=DEFAULT_HEAT_AWAY_TEMPERATURE + ): vol.Coerce(int), + vol.Optional(CONF_REGION, default=DEFAULT_REGION): vol.In(REGIONS), + } +) + +HVAC_MODE_TO_HW_MODE = { + "SwitchOffAllowed": {HVAC_MODE_OFF: "off"}, + "SwitchAutoAllowed": {HVAC_MODE_HEAT_COOL: "auto"}, + "SwitchCoolAllowed": {HVAC_MODE_COOL: "cool"}, + "SwitchHeatAllowed": {HVAC_MODE_HEAT: "heat"}, +} +HW_MODE_TO_HVAC_MODE = { + "off": HVAC_MODE_OFF, + "emheat": HVAC_MODE_HEAT, + "heat": HVAC_MODE_HEAT, + "cool": HVAC_MODE_COOL, + "auto": HVAC_MODE_HEAT_COOL, +} +HW_MODE_TO_HA_HVAC_ACTION = { + "off": CURRENT_HVAC_IDLE, + "fan": CURRENT_HVAC_FAN, + "heat": CURRENT_HVAC_HEAT, + "cool": CURRENT_HVAC_COOL, +} +FAN_MODE_TO_HW = { + "fanModeOnAllowed": {FAN_ON: "on"}, + "fanModeAutoAllowed": {FAN_AUTO: "auto"}, + "fanModeCirculateAllowed": {FAN_DIFFUSE: "circulate"}, +} +HW_FAN_MODE_TO_HA = { + "on": FAN_ON, + "auto": FAN_AUTO, + "circulate": FAN_DIFFUSE, + "follow schedule": FAN_AUTO, +} + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Honeywell thermostat.""" + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + + if config.get(CONF_REGION) == "us": + try: + client = somecomfort.SomeComfort(username, password) + except somecomfort.AuthError: + _LOGGER.error("Failed to login to honeywell account %s", username) + return + except somecomfort.SomeComfortError: + _LOGGER.error( + "Failed to initialize the Honeywell client: " + "Check your configuration (username, password), " + "or maybe you have exceeded the API rate limit?" + ) + return + + dev_id = config.get("thermostat") + loc_id = config.get("location") + cool_away_temp = config.get(CONF_COOL_AWAY_TEMPERATURE) + heat_away_temp = config.get(CONF_HEAT_AWAY_TEMPERATURE) + + add_entities( + [ + HoneywellUSThermostat( + client, device, cool_away_temp, heat_away_temp, username, password + ) + for location in client.locations_by_id.values() + for device in location.devices_by_id.values() + if ( + (not loc_id or location.locationid == loc_id) + and (not dev_id or device.deviceid == dev_id) + ) + ] + ) + return + + _LOGGER.warning( + "The honeywell component has been deprecated for EU (i.e. non-US) " + "systems. For EU-based systems, use the evohome component, " + "see: https://home-assistant.io/integrations/evohome" + ) + + +class HoneywellUSThermostat(ClimateDevice): + """Representation of a Honeywell US Thermostat.""" + + def __init__( + self, client, device, cool_away_temp, heat_away_temp, username, password + ): + """Initialize the thermostat.""" + self._client = client + self._device = device + self._cool_away_temp = cool_away_temp + self._heat_away_temp = heat_away_temp + self._away = False + self._username = username + self._password = password + + _LOGGER.debug("latestData = %s ", device._data) + + # not all honeywell HVACs support all modes + mappings = [v for k, v in HVAC_MODE_TO_HW_MODE.items() if device.raw_ui_data[k]] + self._hvac_mode_map = {k: v for d in mappings for k, v in d.items()} + + self._supported_features = ( + SUPPORT_PRESET_MODE + | SUPPORT_TARGET_TEMPERATURE + | SUPPORT_TARGET_TEMPERATURE_RANGE + ) + + if device._data["canControlHumidification"]: + self._supported_features |= SUPPORT_TARGET_HUMIDITY + + if device.raw_ui_data["SwitchEmergencyHeatAllowed"]: + self._supported_features |= SUPPORT_AUX_HEAT + + if not device._data["hasFan"]: + return + + # not all honeywell fans support all modes + mappings = [v for k, v in FAN_MODE_TO_HW.items() if device.raw_fan_data[k]] + self._fan_mode_map = {k: v for d in mappings for k, v in d.items()} + + self._supported_features |= SUPPORT_FAN_MODE + + @property + def name(self) -> Optional[str]: + """Return the name of the honeywell, if any.""" + return self._device.name + + @property + def device_state_attributes(self) -> Dict[str, Any]: + """Return the device specific state attributes.""" + data = {} + data[ATTR_FAN_ACTION] = "running" if self._device.fan_running else "idle" + if self._device.raw_dr_data: + data["dr_phase"] = self._device.raw_dr_data.get("Phase") + return data + + @property + def supported_features(self) -> int: + """Return the list of supported features.""" + return self._supported_features + + @property + def min_temp(self) -> float: + """Return the minimum temperature.""" + if self.hvac_mode in [HVAC_MODE_COOL, HVAC_MODE_HEAT_COOL]: + return self._device.raw_ui_data["CoolLowerSetptLimit"] + if self.hvac_mode == HVAC_MODE_HEAT: + return self._device.raw_ui_data["HeatLowerSetptLimit"] + return None + + @property + def max_temp(self) -> float: + """Return the maximum temperature.""" + if self.hvac_mode == HVAC_MODE_COOL: + return self._device.raw_ui_data["CoolUpperSetptLimit"] + if self.hvac_mode in [HVAC_MODE_HEAT, HVAC_MODE_HEAT_COOL]: + return self._device.raw_ui_data["HeatUpperSetptLimit"] + return None + + @property + def temperature_unit(self) -> str: + """Return the unit of measurement.""" + return TEMP_CELSIUS if self._device.temperature_unit == "C" else TEMP_FAHRENHEIT + + @property + def current_humidity(self) -> Optional[int]: + """Return the current humidity.""" + return self._device.current_humidity + + @property + def hvac_mode(self) -> str: + """Return hvac operation ie. heat, cool mode.""" + return HW_MODE_TO_HVAC_MODE[self._device.system_mode] + + @property + def hvac_modes(self) -> List[str]: + """Return the list of available hvac operation modes.""" + return list(self._hvac_mode_map) + + @property + def hvac_action(self) -> Optional[str]: + """Return the current running hvac operation if supported.""" + if self.hvac_mode == HVAC_MODE_OFF: + return None + return HW_MODE_TO_HA_HVAC_ACTION[self._device.equipment_output_status] + + @property + def current_temperature(self) -> Optional[float]: + """Return the current temperature.""" + return self._device.current_temperature + + @property + def target_temperature(self) -> Optional[float]: + """Return the temperature we try to reach.""" + if self.hvac_mode == HVAC_MODE_COOL: + return self._device.setpoint_cool + if self.hvac_mode == HVAC_MODE_HEAT: + return self._device.setpoint_heat + return None + + @property + def target_temperature_high(self) -> Optional[float]: + """Return the highbound target temperature we try to reach.""" + if self.hvac_mode == HVAC_MODE_HEAT_COOL: + return self._device.setpoint_cool + return None + + @property + def target_temperature_low(self) -> Optional[float]: + """Return the lowbound target temperature we try to reach.""" + if self.hvac_mode == HVAC_MODE_HEAT_COOL: + return self._device.setpoint_heat + return None + + @property + def preset_mode(self) -> Optional[str]: + """Return the current preset mode, e.g., home, away, temp.""" + return PRESET_AWAY if self._away else None + + @property + def preset_modes(self) -> Optional[List[str]]: + """Return a list of available preset modes.""" + return [PRESET_NONE, PRESET_AWAY] + + @property + def is_aux_heat(self) -> Optional[str]: + """Return true if aux heater.""" + return self._device.system_mode == "emheat" + + @property + def fan_mode(self) -> Optional[str]: + """Return the fan setting.""" + return HW_FAN_MODE_TO_HA[self._device.fan_mode] + + @property + def fan_modes(self) -> Optional[List[str]]: + """Return the list of available fan modes.""" + return list(self._fan_mode_map) + + def _set_temperature(self, **kwargs) -> None: + """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return + try: + # Get current mode + mode = self._device.system_mode + # Set hold if this is not the case + if getattr(self._device, f"hold_{mode}") is False: + # Get next period key + next_period_key = f"{mode.capitalize()}NextPeriod" + # Get next period raw value + next_period = self._device.raw_ui_data.get(next_period_key) + # Get next period time + hour, minute = divmod(next_period * 15, 60) + # Set hold time + setattr(self._device, f"hold_{mode}", datetime.time(hour, minute)) + # Set temperature + setattr(self._device, f"setpoint_{mode}", temperature) + except somecomfort.SomeComfortError: + _LOGGER.error("Temperature %.1f out of range", temperature) + + def set_temperature(self, **kwargs) -> None: + """Set new target temperature.""" + if {HVAC_MODE_COOL, HVAC_MODE_HEAT} & set(self._hvac_mode_map): + self._set_temperature(**kwargs) + + try: + if HVAC_MODE_HEAT_COOL in self._hvac_mode_map: + temperature = kwargs.get(ATTR_TARGET_TEMP_HIGH) + if temperature: + self._device.setpoint_cool = temperature + temperature = kwargs.get(ATTR_TARGET_TEMP_LOW) + if temperature: + self._device.setpoint_heat = temperature + except somecomfort.SomeComfortError as err: + _LOGGER.error("Invalid temperature %s: %s", temperature, err) + + def set_fan_mode(self, fan_mode: str) -> None: + """Set new target fan mode.""" + self._device.fan_mode = self._fan_mode_map[fan_mode] + + def set_hvac_mode(self, hvac_mode: str) -> None: + """Set new target hvac mode.""" + self._device.system_mode = self._hvac_mode_map[hvac_mode] + + def _turn_away_mode_on(self) -> None: + """Turn away on. + + Somecomfort does have a proprietary away mode, but it doesn't really + work the way it should. For example: If you set a temperature manually + it doesn't get overwritten when away mode is switched on. + """ + self._away = True + try: + # Get current mode + mode = self._device.system_mode + except somecomfort.SomeComfortError: + _LOGGER.error("Can not get system mode") + return + try: + + # Set permanent hold + setattr(self._device, f"hold_{mode}", True) + # Set temperature + setattr( + self._device, f"setpoint_{mode}", getattr(self, f"_{mode}_away_temp") + ) + except somecomfort.SomeComfortError: + _LOGGER.error( + "Temperature %.1f out of range", getattr(self, f"_{mode}_away_temp") + ) + + def _turn_away_mode_off(self) -> None: + """Turn away off.""" + self._away = False + try: + # Disabling all hold modes + self._device.hold_cool = False + self._device.hold_heat = False + except somecomfort.SomeComfortError: + _LOGGER.error("Can not stop hold mode") + + def set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + if preset_mode == PRESET_AWAY: + self._turn_away_mode_on() + else: + self._turn_away_mode_off() + + def turn_aux_heat_on(self) -> None: + """Turn auxiliary heater on.""" + self._device.system_mode = "emheat" + + def turn_aux_heat_off(self) -> None: + """Turn auxiliary heater off.""" + if HVAC_MODE_HEAT in self.hvac_modes: + self.set_hvac_mode(HVAC_MODE_HEAT) + else: + self.set_hvac_mode(HVAC_MODE_OFF) + + def _retry(self) -> bool: + """Recreate a new somecomfort client. + + When we got an error, the best way to be sure that the next query + will succeed, is to recreate a new somecomfort client. + """ + try: + self._client = somecomfort.SomeComfort(self._username, self._password) + except somecomfort.AuthError: + _LOGGER.error("Failed to login to honeywell account %s", self._username) + return False + except somecomfort.SomeComfortError as ex: + _LOGGER.error("Failed to initialize honeywell client: %s", str(ex)) + return False + + devices = [ + device + for location in self._client.locations_by_id.values() + for device in location.devices_by_id.values() + if device.name == self._device.name + ] + + if len(devices) != 1: + _LOGGER.error("Failed to find device %s", self._device.name) + return False + + self._device = devices[0] + return True + + def update(self) -> None: + """Update the state.""" + retries = 3 + while retries > 0: + try: + self._device.refresh() + break + except ( + somecomfort.client.APIRateLimited, + OSError, + requests.exceptions.ReadTimeout, + ) as exp: + retries -= 1 + if retries == 0: + raise exp + if not self._retry(): + raise exp + _LOGGER.error("SomeComfort update failed, Retrying - Error: %s", exp) + + _LOGGER.debug( + "latestData = %s ", self._device._data # pylint: disable=protected-access + ) diff --git a/homeassistant/components/honeywell/manifest.json b/homeassistant/components/honeywell/manifest.json new file mode 100644 index 000000000..9d644de44 --- /dev/null +++ b/homeassistant/components/honeywell/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "honeywell", + "name": "Honeywell", + "documentation": "https://www.home-assistant.io/integrations/honeywell", + "requirements": [ + "somecomfort==0.5.2" + ], + "dependencies": [], + "codeowners": ["@zxdavb"] +} diff --git a/homeassistant/components/hook/__init__.py b/homeassistant/components/hook/__init__.py new file mode 100644 index 000000000..bc85e27d7 --- /dev/null +++ b/homeassistant/components/hook/__init__.py @@ -0,0 +1 @@ +"""The hook component.""" diff --git a/homeassistant/components/hook/manifest.json b/homeassistant/components/hook/manifest.json new file mode 100644 index 000000000..035354c96 --- /dev/null +++ b/homeassistant/components/hook/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "hook", + "name": "Hook", + "documentation": "https://www.home-assistant.io/integrations/hook", + "requirements": [], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/hook/switch.py b/homeassistant/components/hook/switch.py new file mode 100644 index 000000000..14c4d4ba6 --- /dev/null +++ b/homeassistant/components/hook/switch.py @@ -0,0 +1,132 @@ +"""Support Hook, available at hooksmarthome.com.""" +import asyncio +import logging + +import aiohttp +import async_timeout +import voluptuous as vol + +from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice +from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +HOOK_ENDPOINT = "https://api.gethook.io/v1/" +TIMEOUT = 10 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Exclusive( + CONF_PASSWORD, + "hook_secret", + msg="hook: provide " + "username/password OR token", + ): cv.string, + vol.Exclusive( + CONF_TOKEN, + "hook_secret", + msg="hook: provide " + "username/password OR token", + ): cv.string, + vol.Inclusive(CONF_USERNAME, "hook_auth"): cv.string, + vol.Inclusive(CONF_PASSWORD, "hook_auth"): cv.string, + } +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up Hook by getting the access token and list of actions.""" + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + token = config.get(CONF_TOKEN) + websession = async_get_clientsession(hass) + # If password is set in config, prefer it over token + if username is not None and password is not None: + try: + with async_timeout.timeout(TIMEOUT): + response = await websession.post( + "{}{}".format(HOOK_ENDPOINT, "user/login"), + data={"username": username, "password": password}, + ) + # The Hook API returns JSON but calls it 'text/html'. Setting + # content_type=None disables aiohttp's content-type validation. + data = await response.json(content_type=None) + except (asyncio.TimeoutError, aiohttp.ClientError) as error: + _LOGGER.error("Failed authentication API call: %s", error) + return False + + try: + token = data["data"]["token"] + except KeyError: + _LOGGER.error("No token. Check username and password") + return False + + try: + with async_timeout.timeout(TIMEOUT): + response = await websession.get( + "{}{}".format(HOOK_ENDPOINT, "device"), params={"token": token} + ) + data = await response.json(content_type=None) + except (asyncio.TimeoutError, aiohttp.ClientError) as error: + _LOGGER.error("Failed getting devices: %s", error) + return False + + async_add_entities( + HookSmartHome(hass, token, d["device_id"], d["device_name"]) + for lst in data["data"] + for d in lst + ) + + +class HookSmartHome(SwitchDevice): + """Representation of a Hook device, allowing on and off commands.""" + + def __init__(self, hass, token, device_id, device_name): + """Initialize the switch.""" + self.hass = hass + self._token = token + self._state = False + self._id = device_id + self._name = device_name + _LOGGER.debug("Creating Hook object: ID: %s Name: %s", self._id, self._name) + + @property + def name(self): + """Return the name of the switch.""" + return self._name + + @property + def is_on(self): + """Return true if device is on.""" + return self._state + + async def _send(self, url): + """Send the url to the Hook API.""" + try: + _LOGGER.debug("Sending: %s", url) + websession = async_get_clientsession(self.hass) + with async_timeout.timeout(TIMEOUT): + response = await websession.get(url, params={"token": self._token}) + data = await response.json(content_type=None) + + except (asyncio.TimeoutError, aiohttp.ClientError) as error: + _LOGGER.error("Failed setting state: %s", error) + return False + + _LOGGER.debug("Got: %s", data) + return data["return_value"] == "1" + + async def async_turn_on(self, **kwargs): + """Turn the device on asynchronously.""" + _LOGGER.debug("Turning on: %s", self._name) + url = "{}{}{}{}".format(HOOK_ENDPOINT, "device/trigger/", self._id, "/On") + success = await self._send(url) + self._state = success + + async def async_turn_off(self, **kwargs): + """Turn the device off asynchronously.""" + _LOGGER.debug("Turning off: %s", self._name) + url = "{}{}{}{}".format(HOOK_ENDPOINT, "device/trigger/", self._id, "/Off") + success = await self._send(url) + # If it wasn't successful, keep state as true + self._state = not success diff --git a/homeassistant/components/horizon/__init__.py b/homeassistant/components/horizon/__init__.py new file mode 100644 index 000000000..77ac25098 --- /dev/null +++ b/homeassistant/components/horizon/__init__.py @@ -0,0 +1 @@ +"""The horizon component.""" diff --git a/homeassistant/components/horizon/manifest.json b/homeassistant/components/horizon/manifest.json new file mode 100644 index 000000000..4ba3a61d8 --- /dev/null +++ b/homeassistant/components/horizon/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "horizon", + "name": "Horizon", + "documentation": "https://www.home-assistant.io/integrations/horizon", + "requirements": [ + "horimote==0.4.1" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/horizon/media_player.py b/homeassistant/components/horizon/media_player.py new file mode 100644 index 000000000..44e93d26a --- /dev/null +++ b/homeassistant/components/horizon/media_player.py @@ -0,0 +1,205 @@ +"""Support for the Unitymedia Horizon HD Recorder.""" +from datetime import timedelta +import logging + +from horimote import Client, keys +from horimote.exceptions import AuthenticationError +import voluptuous as vol + +from homeassistant import util +from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerDevice +from homeassistant.components.media_player.const import ( + MEDIA_TYPE_CHANNEL, + SUPPORT_NEXT_TRACK, + SUPPORT_PAUSE, + SUPPORT_PLAY, + SUPPORT_PLAY_MEDIA, + SUPPORT_PREVIOUS_TRACK, + SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, +) +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PORT, + STATE_OFF, + STATE_PAUSED, + STATE_PLAYING, +) +from homeassistant.exceptions import PlatformNotReady +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = "Horizon" +DEFAULT_PORT = 5900 + +MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(seconds=1) +MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) + +SUPPORT_HORIZON = ( + SUPPORT_NEXT_TRACK + | SUPPORT_PAUSE + | SUPPORT_PLAY + | SUPPORT_PLAY_MEDIA + | SUPPORT_PREVIOUS_TRACK + | SUPPORT_TURN_ON + | SUPPORT_TURN_OFF +) + +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_entities, discovery_info=None): + """Set up the Horizon platform.""" + + host = config[CONF_HOST] + name = config[CONF_NAME] + port = config[CONF_PORT] + + try: + client = Client(host, port=port) + except AuthenticationError as msg: + _LOGGER.error("Authentication to %s at %s failed: %s", name, host, msg) + return + except OSError as msg: + # occurs if horizon box is offline + _LOGGER.error("Connection to %s at %s failed: %s", name, host, msg) + raise PlatformNotReady + + _LOGGER.info("Connection to %s at %s established", name, host) + + add_entities([HorizonDevice(client, name, keys)], True) + + +class HorizonDevice(MediaPlayerDevice): + """Representation of a Horizon HD Recorder.""" + + def __init__(self, client, name, remote_keys): + """Initialize the remote.""" + self._client = client + self._name = name + self._state = None + self._keys = remote_keys + + @property + def name(self): + """Return the name of the remote.""" + 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_HORIZON + + @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) + def update(self): + """Update State using the media server running on the Horizon.""" + try: + if self._client.is_powered_on(): + self._state = STATE_PLAYING + else: + self._state = STATE_OFF + except OSError: + self._state = STATE_OFF + + def turn_on(self): + """Turn the device on.""" + if self._state is STATE_OFF: + self._send_key(self._keys.POWER) + + def turn_off(self): + """Turn the device off.""" + if self._state is not STATE_OFF: + self._send_key(self._keys.POWER) + + def media_previous_track(self): + """Channel down.""" + self._send_key(self._keys.CHAN_DOWN) + self._state = STATE_PLAYING + + def media_next_track(self): + """Channel up.""" + self._send_key(self._keys.CHAN_UP) + self._state = STATE_PLAYING + + def media_play(self): + """Send play command.""" + self._send_key(self._keys.PAUSE) + self._state = STATE_PLAYING + + def media_pause(self): + """Send pause command.""" + self._send_key(self._keys.PAUSE) + self._state = STATE_PAUSED + + def media_play_pause(self): + """Send play/pause command.""" + self._send_key(self._keys.PAUSE) + if self._state == STATE_PAUSED: + self._state = STATE_PLAYING + else: + self._state = STATE_PAUSED + + def play_media(self, media_type, media_id, **kwargs): + """Play media / switch to channel.""" + if MEDIA_TYPE_CHANNEL == media_type: + try: + self._select_channel(int(media_id)) + self._state = STATE_PLAYING + except ValueError: + _LOGGER.error("Invalid channel: %s", media_id) + else: + _LOGGER.error( + "Invalid media type %s. Supported type: %s", + media_type, + MEDIA_TYPE_CHANNEL, + ) + + def _select_channel(self, channel): + """Select a channel (taken from einder library, thx).""" + self._send(channel=channel) + + def _send_key(self, key): + """Send a key to the Horizon device.""" + self._send(key=key) + + def _send(self, key=None, channel=None): + """Send a key to the Horizon device.""" + + try: + if key: + self._client.send_key(key) + elif channel: + self._client.select_channel(channel) + except OSError as msg: + _LOGGER.error( + "%s disconnected: %s. Trying to reconnect...", self._name, msg + ) + + # for reconnect, first gracefully disconnect + self._client.disconnect() + + try: + self._client.connect() + self._client.authorize() + except AuthenticationError as msg: + _LOGGER.error("Authentication to %s failed: %s", self._name, msg) + return + except OSError as msg: + # occurs if horizon box is offline + _LOGGER.error("Reconnect to %s failed: %s", self._name, msg) + return + + self._send(key=key, channel=channel) diff --git a/homeassistant/components/hp_ilo/__init__.py b/homeassistant/components/hp_ilo/__init__.py new file mode 100644 index 000000000..67135b947 --- /dev/null +++ b/homeassistant/components/hp_ilo/__init__.py @@ -0,0 +1 @@ +"""The HP Integrated Lights-Out (iLO) component.""" diff --git a/homeassistant/components/hp_ilo/manifest.json b/homeassistant/components/hp_ilo/manifest.json new file mode 100644 index 000000000..3dc591cac --- /dev/null +++ b/homeassistant/components/hp_ilo/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "hp_ilo", + "name": "Hp ilo", + "documentation": "https://www.home-assistant.io/integrations/hp_ilo", + "requirements": [ + "python-hpilo==4.3" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/hp_ilo/sensor.py b/homeassistant/components/hp_ilo/sensor.py new file mode 100644 index 000000000..04c715dc0 --- /dev/null +++ b/homeassistant/components/hp_ilo/sensor.py @@ -0,0 +1,196 @@ +"""Support for information from HP iLO sensors.""" +from datetime import timedelta +import logging + +import hpilo +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_HOST, + CONF_MONITORED_VARIABLES, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_SENSOR_TYPE, + CONF_UNIT_OF_MEASUREMENT, + CONF_USERNAME, + CONF_VALUE_TEMPLATE, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = "HP ILO" +DEFAULT_PORT = 443 + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=300) + +SENSOR_TYPES = { + "server_name": ["Server Name", "get_server_name"], + "server_fqdn": ["Server FQDN", "get_server_fqdn"], + "server_host_data": ["Server Host Data", "get_host_data"], + "server_oa_info": ["Server Onboard Administrator Info", "get_oa_info"], + "server_power_status": ["Server Power state", "get_host_power_status"], + "server_power_readings": ["Server Power readings", "get_power_readings"], + "server_power_on_time": ["Server Power On time", "get_server_power_on_time"], + "server_asset_tag": ["Server Asset Tag", "get_asset_tag"], + "server_uid_status": ["Server UID light", "get_uid_status"], + "server_health": ["Server Health", "get_embedded_health"], + "network_settings": ["Network Settings", "get_network_settings"], +} + +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_MONITORED_VARIABLES, default=[]): vol.All( + cv.ensure_list, + [ + vol.Schema( + { + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_SENSOR_TYPE): vol.All( + cv.string, vol.In(SENSOR_TYPES) + ), + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + } + ) + ], + ), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the HP iLO sensors.""" + hostname = config.get(CONF_HOST) + port = config.get(CONF_PORT) + login = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + monitored_variables = config.get(CONF_MONITORED_VARIABLES) + + # Create a data fetcher to support all of the configured sensors. Then make + # the first call to init the data and confirm we can connect. + try: + hp_ilo_data = HpIloData(hostname, port, login, password) + except ValueError as error: + _LOGGER.error(error) + return + + # Initialize and add all of the sensors. + devices = [] + for monitored_variable in monitored_variables: + new_device = HpIloSensor( + hass=hass, + hp_ilo_data=hp_ilo_data, + sensor_name="{} {}".format( + config.get(CONF_NAME), monitored_variable[CONF_NAME] + ), + sensor_type=monitored_variable[CONF_SENSOR_TYPE], + sensor_value_template=monitored_variable.get(CONF_VALUE_TEMPLATE), + unit_of_measurement=monitored_variable.get(CONF_UNIT_OF_MEASUREMENT), + ) + devices.append(new_device) + + add_entities(devices, True) + + +class HpIloSensor(Entity): + """Representation of a HP iLO sensor.""" + + def __init__( + self, + hass, + hp_ilo_data, + sensor_type, + sensor_name, + sensor_value_template, + unit_of_measurement, + ): + """Initialize the HP iLO sensor.""" + self._hass = hass + self._name = sensor_name + self._unit_of_measurement = unit_of_measurement + self._ilo_function = SENSOR_TYPES[sensor_type][1] + self.hp_ilo_data = hp_ilo_data + + if sensor_value_template is not None: + sensor_value_template.hass = hass + self._sensor_value_template = sensor_value_template + + self._state = None + self._state_attributes = None + + _LOGGER.debug("Created HP iLO sensor %r", self) + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def unit_of_measurement(self): + """Return the unit of measurement of the sensor.""" + return self._unit_of_measurement + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + return self._state_attributes + + def update(self): + """Get the latest data from HP iLO and updates the states.""" + # Call the API for new data. Each sensor will re-trigger this + # same exact call, but that's fine. Results should be cached for + # a short period of time to prevent hitting API limits. + self.hp_ilo_data.update() + ilo_data = getattr(self.hp_ilo_data.data, self._ilo_function)() + + if self._sensor_value_template is not None: + ilo_data = self._sensor_value_template.render(ilo_data=ilo_data) + + self._state = ilo_data + + +class HpIloData: + """Gets the latest data from HP iLO.""" + + def __init__(self, host, port, login, password): + """Initialize the data object.""" + self._host = host + self._port = port + self._login = login + self._password = password + + self.data = None + + self.update() + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data from HP iLO.""" + try: + self.data = hpilo.Ilo( + hostname=self._host, + login=self._login, + password=self._password, + port=self._port, + ) + except ( + hpilo.IloError, + hpilo.IloCommunicationError, + hpilo.IloLoginFailed, + ) as error: + raise ValueError(f"Unable to init HP ILO, {error}") diff --git a/homeassistant/components/html5/__init__.py b/homeassistant/components/html5/__init__.py new file mode 100644 index 000000000..88e437ef5 --- /dev/null +++ b/homeassistant/components/html5/__init__.py @@ -0,0 +1 @@ +"""The html5 component.""" diff --git a/homeassistant/components/html5/const.py b/homeassistant/components/html5/const.py new file mode 100644 index 000000000..1d0689511 --- /dev/null +++ b/homeassistant/components/html5/const.py @@ -0,0 +1,3 @@ +"""Constants for the HTML5 component.""" +DOMAIN = "html5" +SERVICE_DISMISS = "dismiss" diff --git a/homeassistant/components/html5/manifest.json b/homeassistant/components/html5/manifest.json new file mode 100644 index 000000000..dd794ae03 --- /dev/null +++ b/homeassistant/components/html5/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "html5", + "name": "HTML5 Notifications", + "documentation": "https://www.home-assistant.io/integrations/html5", + "requirements": ["pywebpush==1.9.2"], + "dependencies": ["http"], + "codeowners": ["@robbiet480"] +} diff --git a/homeassistant/components/html5/notify.py b/homeassistant/components/html5/notify.py new file mode 100644 index 000000000..6d6fcd5c3 --- /dev/null +++ b/homeassistant/components/html5/notify.py @@ -0,0 +1,573 @@ +"""HTML5 Push Messaging notification service.""" +from datetime import datetime, timedelta +from functools import partial +import json +import logging +import time +from urllib.parse import urlparse +import uuid + +from aiohttp.hdrs import AUTHORIZATION +import jwt +from py_vapid import Vapid +from pywebpush import WebPusher +import voluptuous as vol +from voluptuous.humanize import humanize_error + +from homeassistant.components import websocket_api +from homeassistant.components.frontend import add_manifest_json_key +from homeassistant.components.http import HomeAssistantView +from homeassistant.components.notify import ( + ATTR_DATA, + ATTR_TARGET, + ATTR_TITLE, + ATTR_TITLE_DEFAULT, + PLATFORM_SCHEMA, + BaseNotificationService, +) +from homeassistant.const import ( + HTTP_BAD_REQUEST, + HTTP_INTERNAL_SERVER_ERROR, + HTTP_UNAUTHORIZED, + URL_ROOT, +) +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv +from homeassistant.util import ensure_unique_string +from homeassistant.util.json import load_json, save_json + +from .const import DOMAIN, SERVICE_DISMISS + +_LOGGER = logging.getLogger(__name__) + +REGISTRATIONS_FILE = "html5_push_registrations.conf" + +ATTR_GCM_SENDER_ID = "gcm_sender_id" +ATTR_GCM_API_KEY = "gcm_api_key" +ATTR_VAPID_PUB_KEY = "vapid_pub_key" +ATTR_VAPID_PRV_KEY = "vapid_prv_key" +ATTR_VAPID_EMAIL = "vapid_email" + + +def gcm_api_deprecated(value): + """Warn user that GCM API config is deprecated.""" + if value: + _LOGGER.warning( + "Configuring html5_push_notifications via the GCM api" + " has been deprecated and will stop working after April 11," + " 2019. Use the VAPID configuration instead. For instructions," + " see https://www.home-assistant.io/integrations/html5/" + ) + return value + + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(ATTR_GCM_SENDER_ID): vol.All(cv.string, gcm_api_deprecated), + vol.Optional(ATTR_GCM_API_KEY): cv.string, + vol.Optional(ATTR_VAPID_PUB_KEY): cv.string, + vol.Optional(ATTR_VAPID_PRV_KEY): cv.string, + vol.Optional(ATTR_VAPID_EMAIL): cv.string, + } +) + +ATTR_SUBSCRIPTION = "subscription" +ATTR_BROWSER = "browser" +ATTR_NAME = "name" + +ATTR_ENDPOINT = "endpoint" +ATTR_KEYS = "keys" +ATTR_AUTH = "auth" +ATTR_P256DH = "p256dh" +ATTR_EXPIRATIONTIME = "expirationTime" + +ATTR_TAG = "tag" +ATTR_ACTION = "action" +ATTR_ACTIONS = "actions" +ATTR_TYPE = "type" +ATTR_URL = "url" +ATTR_DISMISS = "dismiss" +ATTR_PRIORITY = "priority" +DEFAULT_PRIORITY = "normal" +ATTR_TTL = "ttl" +DEFAULT_TTL = 86400 + +ATTR_JWT = "jwt" + +WS_TYPE_APPKEY = "notify/html5/appkey" +SCHEMA_WS_APPKEY = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( + {vol.Required("type"): WS_TYPE_APPKEY} +) + +# The number of days after the moment a notification is sent that a JWT +# 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), + } + ), +) + +DISMISS_SERVICE_SCHEMA = vol.Schema( + { + vol.Optional(ATTR_TARGET): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(ATTR_DATA): dict, + } +) + +REGISTER_SCHEMA = vol.Schema( + { + vol.Required(ATTR_SUBSCRIPTION): SUBSCRIPTION_SCHEMA, + vol.Required(ATTR_BROWSER): vol.In(["chrome", "firefox"]), + vol.Optional(ATTR_NAME): cv.string, + } +) + +CALLBACK_EVENT_PAYLOAD_SCHEMA = vol.Schema( + { + vol.Required(ATTR_TAG): cv.string, + vol.Required(ATTR_TYPE): vol.In(["received", "clicked", "closed"]), + vol.Required(ATTR_TARGET): cv.string, + vol.Optional(ATTR_ACTION): cv.string, + vol.Optional(ATTR_DATA): dict, + } +) + +NOTIFY_CALLBACK_EVENT = "html5_notification" + +# Badge and timestamp are Chrome specific (not in official spec) +HTML5_SHOWNOTIFICATION_PARAMETERS = ( + "actions", + "badge", + "body", + "dir", + "icon", + "image", + "lang", + "renotify", + "requireInteraction", + "tag", + "timestamp", + "vibrate", +) + + +def get_service(hass, config, discovery_info=None): + """Get the HTML5 push notification service.""" + json_path = hass.config.path(REGISTRATIONS_FILE) + + registrations = _load_config(json_path) + + if registrations is None: + return None + + vapid_pub_key = config.get(ATTR_VAPID_PUB_KEY) + vapid_prv_key = config.get(ATTR_VAPID_PRV_KEY) + vapid_email = config.get(ATTR_VAPID_EMAIL) + + def websocket_appkey(hass, connection, msg): + connection.send_message(websocket_api.result_message(msg["id"], vapid_pub_key)) + + hass.components.websocket_api.async_register_command( + WS_TYPE_APPKEY, websocket_appkey, SCHEMA_WS_APPKEY + ) + + hass.http.register_view(HTML5PushRegistrationView(registrations, json_path)) + hass.http.register_view(HTML5PushCallbackView(registrations)) + + gcm_api_key = config.get(ATTR_GCM_API_KEY) + gcm_sender_id = config.get(ATTR_GCM_SENDER_ID) + + if gcm_sender_id is not None: + add_manifest_json_key(ATTR_GCM_SENDER_ID, config.get(ATTR_GCM_SENDER_ID)) + + return HTML5NotificationService( + hass, gcm_api_key, vapid_prv_key, vapid_email, registrations, json_path + ) + + +def _load_config(filename): + """Load configuration.""" + try: + return load_json(filename) + except HomeAssistantError: + pass + return {} + + +class HTML5PushRegistrationView(HomeAssistantView): + """Accepts push registrations from a browser.""" + + url = "/api/notify.html5" + name = "api:notify.html5" + + def __init__(self, registrations, json_path): + """Init HTML5PushRegistrationView.""" + self.registrations = registrations + self.json_path = json_path + + async def post(self, request): + """Accept the POST request for push registrations from a browser.""" + try: + data = await request.json() + except ValueError: + return self.json_message("Invalid JSON", HTTP_BAD_REQUEST) + try: + data = REGISTER_SCHEMA(data) + except vol.Invalid as ex: + return self.json_message(humanize_error(data, ex), HTTP_BAD_REQUEST) + + devname = data.get(ATTR_NAME) + data.pop(ATTR_NAME, None) + + name = self.find_registration_name(data, devname) + previous_registration = self.registrations.get(name) + + self.registrations[name] = data + + 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 + ) + + def find_registration_name(self, data, suggested=None): + """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(suggested or "unnamed device", self.registrations) + + async def delete(self, request): + """Delete a registration.""" + try: + data = await request.json() + except ValueError: + return self.json_message("Invalid JSON", HTTP_BAD_REQUEST) + + subscription = data.get(ATTR_SUBSCRIPTION) + + found = None + + for key, registration in self.registrations.items(): + if registration.get(ATTR_SUBSCRIPTION) == subscription: + found = key + break + + if not found: + # If not found, unregistering was already done. Return 200 + return self.json_message("Registration not found.") + + reg = self.registrations.pop(found) + + 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 + ) + + return self.json_message("Push notification subscriber unregistered.") + + +class HTML5PushCallbackView(HomeAssistantView): + """Accepts push registrations from a browser.""" + + requires_auth = False + url = "/api/notify.html5/callback" + name = "api:notify.html5/callback" + + def __init__(self, registrations): + """Init HTML5PushCallbackView.""" + self.registrations = registrations + + def decode_jwt(self, token): + """Find the registration that signed this JWT and return it.""" + + # 1. Check claims w/o verifying to see if a target is in there. + # 2. If target in claims, attempt to verify against the given name. + # 2a. If decode is successful, return the payload. + # 2b. If decode is unsuccessful, return a 401. + + target_check = jwt.decode(token, verify=False) + if target_check.get(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, algorithms=["ES256", "HS256"]) + except jwt.exceptions.DecodeError: + pass + + return self.json_message( + "No target found in JWT", status_code=HTTP_UNAUTHORIZED + ) + + # The following is based on code from Auth0 + # https://auth0.com/docs/quickstart/backend/python + def check_authorization_header(self, request): + """Check the authorization header.""" + + auth = request.headers.get(AUTHORIZATION, None) + if not auth: + return self.json_message( + "Authorization header is expected", status_code=HTTP_UNAUTHORIZED + ) + + parts = auth.split() + + if parts[0].lower() != "bearer": + return self.json_message( + "Authorization header must " "start with Bearer", + status_code=HTTP_UNAUTHORIZED, + ) + if len(parts) != 2: + return self.json_message( + "Authorization header must " "be Bearer token", + status_code=HTTP_UNAUTHORIZED, + ) + + token = parts[1] + try: + payload = self.decode_jwt(token) + except jwt.exceptions.InvalidTokenError: + return self.json_message("token is invalid", status_code=HTTP_UNAUTHORIZED) + return payload + + 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 = await request.json() + except ValueError: + return self.json_message("Invalid JSON", HTTP_BAD_REQUEST) + + event_payload = { + ATTR_TAG: data.get(ATTR_TAG), + ATTR_TYPE: data[ATTR_TYPE], + ATTR_TARGET: auth_check[ATTR_TARGET], + } + + if data.get(ATTR_ACTION) is not None: + event_payload[ATTR_ACTION] = data.get(ATTR_ACTION) + + if data.get(ATTR_DATA) is not None: + event_payload[ATTR_DATA] = data.get(ATTR_DATA) + + try: + event_payload = CALLBACK_EVENT_PAYLOAD_SCHEMA(event_payload) + except vol.Invalid as ex: + _LOGGER.warning( + "Callback event payload is not valid: %s", + humanize_error(event_payload, ex), + ) + + 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]}) + + +class HTML5NotificationService(BaseNotificationService): + """Implement the notification service for HTML5.""" + + def __init__(self, hass, gcm_key, vapid_prv, vapid_email, registrations, json_path): + """Initialize the service.""" + self._gcm_key = gcm_key + self._vapid_prv = vapid_prv + self._vapid_email = vapid_email + self.registrations = registrations + self.registrations_json_path = json_path + + async def async_dismiss_message(service): + """Handle dismissing notification message service calls.""" + kwargs = {} + + if self.targets is not None: + kwargs[ATTR_TARGET] = self.targets + elif service.data.get(ATTR_TARGET) is not None: + kwargs[ATTR_TARGET] = service.data.get(ATTR_TARGET) + + kwargs[ATTR_DATA] = service.data.get(ATTR_DATA) + + await self.async_dismiss(**kwargs) + + hass.services.async_register( + DOMAIN, + SERVICE_DISMISS, + async_dismiss_message, + schema=DISMISS_SERVICE_SCHEMA, + ) + + @property + def targets(self): + """Return a dictionary of registered targets.""" + targets = {} + for registration in self.registrations: + targets[registration] = registration + return targets + + def dismiss(self, **kwargs): + """Dismisses a notification.""" + data = kwargs.get(ATTR_DATA) + tag = data.get(ATTR_TAG) if data else "" + payload = {ATTR_TAG: tag, ATTR_DISMISS: True, ATTR_DATA: {}} + + self._push_message(payload, **kwargs) + + async def async_dismiss(self, **kwargs): + """Dismisses a notification. + + This method must be run in the event loop. + """ + await self.hass.async_add_executor_job(partial(self.dismiss, **kwargs)) + + def send_message(self, message="", **kwargs): + """Send a message to a user.""" + tag = str(uuid.uuid4()) + payload = { + "badge": "/static/images/notification-badge.png", + "body": message, + ATTR_DATA: {}, + "icon": "/static/icons/favicon-192x192.png", + ATTR_TAG: tag, + ATTR_TITLE: kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT), + } + + data = kwargs.get(ATTR_DATA) + + if data: + # Pick out fields that should go into the notification directly vs + # into the notification data dictionary. + + data_tmp = {} + + for key, val in data.items(): + if key in HTML5_SHOWNOTIFICATION_PARAMETERS: + payload[key] = val + else: + data_tmp[key] = val + + payload[ATTR_DATA] = data_tmp + + if ( + payload[ATTR_DATA].get(ATTR_URL) is None + and payload.get(ATTR_ACTIONS) is None + ): + payload[ATTR_DATA][ATTR_URL] = URL_ROOT + + self._push_message(payload, **kwargs) + + def _push_message(self, payload, **kwargs): + """Send the message.""" + + timestamp = int(time.time()) + ttl = int(kwargs.get(ATTR_TTL, DEFAULT_TTL)) + priority = kwargs.get(ATTR_PRIORITY, DEFAULT_PRIORITY) + if priority not in ["normal", "high"]: + priority = DEFAULT_PRIORITY + payload["timestamp"] = timestamp * 1000 # Javascript ms since epoch + targets = kwargs.get(ATTR_TARGET) + + if not targets: + targets = self.registrations.keys() + + for target in list(targets): + info = self.registrations.get(target) + try: + info = REGISTER_SCHEMA(info) + except vol.Invalid: + _LOGGER.error( + "%s is not a valid HTML5 push notification" " target", target + ) + continue + payload[ATTR_DATA][ATTR_JWT] = add_jwt( + timestamp, + target, + payload[ATTR_TAG], + info[ATTR_SUBSCRIPTION][ATTR_KEYS][ATTR_AUTH], + ) + webpusher = WebPusher(info[ATTR_SUBSCRIPTION]) + if self._vapid_prv and self._vapid_email: + vapid_headers = create_vapid_headers( + self._vapid_email, info[ATTR_SUBSCRIPTION], self._vapid_prv + ) + vapid_headers.update({"urgency": priority, "priority": priority}) + response = webpusher.send( + data=json.dumps(payload), headers=vapid_headers, ttl=ttl + ) + else: + # 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.send(json.dumps(payload), gcm_key=gcm_key, ttl=ttl) + + if response.status_code == 410: + _LOGGER.info("Notification channel has expired") + reg = self.registrations.pop(target) + if not save_json(self.registrations_json_path, self.registrations): + self.registrations[target] = reg + _LOGGER.error("Error saving registration") + else: + _LOGGER.info("Configuration saved") + + +def add_jwt(timestamp, target, tag, jwt_secret): + """Create JWT json to put into payload.""" + + jwt_exp = datetime.fromtimestamp(timestamp) + timedelta(days=JWT_VALID_DAYS) + jwt_claims = { + "exp": jwt_exp, + "nbf": timestamp, + "iat": timestamp, + ATTR_TARGET: target, + ATTR_TAG: tag, + } + return jwt.encode(jwt_claims, jwt_secret).decode("utf-8") + + +def create_vapid_headers(vapid_email, subscription_info, vapid_private_key): + """Create encrypted headers to send to WebPusher.""" + + if vapid_email and vapid_private_key and ATTR_ENDPOINT in subscription_info: + url = urlparse(subscription_info.get(ATTR_ENDPOINT)) + vapid_claims = { + "sub": f"mailto:{vapid_email}", + "aud": f"{url.scheme}://{url.netloc}", + } + vapid = Vapid.from_string(private_key=vapid_private_key) + return vapid.sign(vapid_claims) + return None diff --git a/homeassistant/components/html5/services.yaml b/homeassistant/components/html5/services.yaml new file mode 100644 index 000000000..5fd068a64 --- /dev/null +++ b/homeassistant/components/html5/services.yaml @@ -0,0 +1,9 @@ +dismiss: + description: Dismiss a html5 notification. + fields: + target: + description: An array of targets. Optional. + example: ['my_phone', 'my_tablet'] + data: + description: Extended information of notification. Supports tag. Optional. + example: '{ "tag": "tagname" }' diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 1b22f8e62..c720d134c 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -1,9 +1,4 @@ -""" -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/ -""" +"""Support to serve the Home Assistant API as WSGI application.""" from ipaddress import ip_network import logging import os @@ -15,99 +10,100 @@ from aiohttp.web_exceptions import HTTPMovedPermanently import voluptuous as vol from homeassistant.const import ( - EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, SERVER_PORT) + EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP, + SERVER_PORT, +) import homeassistant.helpers.config_validation as cv import homeassistant.util as hass_util -from homeassistant.util.logging import HideSensitiveDataFilter from homeassistant.util import ssl as ssl_util from .auth import setup_auth from .ban import setup_bans +from .const import KEY_AUTHENTICATED, KEY_HASS, KEY_HASS_USER, KEY_REAL_IP # noqa: F401 from .cors import setup_cors from .real_ip import setup_real_ip -from .static import ( - CachingFileResponse, CachingStaticResource, staticresource_middleware) +from .static import CACHE_HEADERS, CachingStaticResource +from .view import HomeAssistantView # noqa: F401 -# Import as alias -from .const import KEY_AUTHENTICATED, KEY_REAL_IP # noqa -from .view import HomeAssistantView # noqa +# mypy: allow-untyped-defs, no-check-untyped-defs -REQUIREMENTS = ['aiohttp_cors==0.7.0'] +DOMAIN = "http" -DOMAIN = 'http' +CONF_SERVER_HOST = "server_host" +CONF_SERVER_PORT = "server_port" +CONF_BASE_URL = "base_url" +CONF_SSL_CERTIFICATE = "ssl_certificate" +CONF_SSL_PEER_CERTIFICATE = "ssl_peer_certificate" +CONF_SSL_KEY = "ssl_key" +CONF_CORS_ORIGINS = "cors_allowed_origins" +CONF_USE_X_FORWARDED_FOR = "use_x_forwarded_for" +CONF_TRUSTED_PROXIES = "trusted_proxies" +CONF_LOGIN_ATTEMPTS_THRESHOLD = "login_attempts_threshold" +CONF_IP_BAN_ENABLED = "ip_ban_enabled" +CONF_SSL_PROFILE = "ssl_profile" -CONF_API_PASSWORD = 'api_password' -CONF_SERVER_HOST = 'server_host' -CONF_SERVER_PORT = 'server_port' -CONF_BASE_URL = 'base_url' -CONF_SSL_CERTIFICATE = 'ssl_certificate' -CONF_SSL_PEER_CERTIFICATE = 'ssl_peer_certificate' -CONF_SSL_KEY = 'ssl_key' -CONF_CORS_ORIGINS = 'cors_allowed_origins' -CONF_USE_X_FORWARDED_FOR = 'use_x_forwarded_for' -CONF_TRUSTED_PROXIES = 'trusted_proxies' -CONF_TRUSTED_NETWORKS = 'trusted_networks' -CONF_LOGIN_ATTEMPTS_THRESHOLD = 'login_attempts_threshold' -CONF_IP_BAN_ENABLED = 'ip_ban_enabled' -CONF_SSL_PROFILE = 'ssl_profile' - -SSL_MODERN = 'modern' -SSL_INTERMEDIATE = 'intermediate' +SSL_MODERN = "modern" +SSL_INTERMEDIATE = "intermediate" _LOGGER = logging.getLogger(__name__) -DEFAULT_SERVER_HOST = '0.0.0.0' -DEFAULT_DEVELOPMENT = '0' +DEFAULT_SERVER_HOST = "0.0.0.0" +DEFAULT_DEVELOPMENT = "0" +# To be able to load custom cards. +DEFAULT_CORS = "https://cast.home-assistant.io" NO_LOGIN_ATTEMPT_THRESHOLD = -1 -HTTP_SCHEMA = vol.Schema({ - 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_SSL_CERTIFICATE): cv.isfile, - vol.Optional(CONF_SSL_PEER_CERTIFICATE): cv.isfile, - vol.Optional(CONF_SSL_KEY): cv.isfile, - vol.Optional(CONF_CORS_ORIGINS, default=[]): - vol.All(cv.ensure_list, [cv.string]), - vol.Inclusive(CONF_USE_X_FORWARDED_FOR, 'proxy'): cv.boolean, - vol.Inclusive(CONF_TRUSTED_PROXIES, 'proxy'): - vol.All(cv.ensure_list, [ip_network]), - vol.Optional(CONF_TRUSTED_NETWORKS, default=[]): - vol.All(cv.ensure_list, [ip_network]), - vol.Optional(CONF_LOGIN_ATTEMPTS_THRESHOLD, - default=NO_LOGIN_ATTEMPT_THRESHOLD): - vol.Any(cv.positive_int, NO_LOGIN_ATTEMPT_THRESHOLD), - vol.Optional(CONF_IP_BAN_ENABLED, default=True): cv.boolean, - vol.Optional(CONF_SSL_PROFILE, default=SSL_MODERN): - vol.In([SSL_INTERMEDIATE, SSL_MODERN]), -}) -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: HTTP_SCHEMA, -}, extra=vol.ALLOW_EXTRA) +HTTP_SCHEMA = vol.Schema( + { + 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_SSL_CERTIFICATE): cv.isfile, + vol.Optional(CONF_SSL_PEER_CERTIFICATE): cv.isfile, + vol.Optional(CONF_SSL_KEY): cv.isfile, + vol.Optional(CONF_CORS_ORIGINS, default=[DEFAULT_CORS]): vol.All( + cv.ensure_list, [cv.string] + ), + vol.Inclusive(CONF_USE_X_FORWARDED_FOR, "proxy"): cv.boolean, + vol.Inclusive(CONF_TRUSTED_PROXIES, "proxy"): vol.All( + cv.ensure_list, [ip_network] + ), + vol.Optional( + CONF_LOGIN_ATTEMPTS_THRESHOLD, default=NO_LOGIN_ATTEMPT_THRESHOLD + ): vol.Any(cv.positive_int, NO_LOGIN_ATTEMPT_THRESHOLD), + vol.Optional(CONF_IP_BAN_ENABLED, default=True): cv.boolean, + vol.Optional(CONF_SSL_PROFILE, default=SSL_MODERN): vol.In( + [SSL_INTERMEDIATE, SSL_MODERN] + ), + } +) + +CONFIG_SCHEMA = vol.Schema({DOMAIN: HTTP_SCHEMA}, extra=vol.ALLOW_EXTRA) class ApiConfig: """Configuration settings for API server.""" - def __init__(self, host: str, port: Optional[int] = SERVER_PORT, - use_ssl: bool = False, - api_password: Optional[str] = None) -> None: + def __init__( + self, host: str, port: Optional[int] = SERVER_PORT, use_ssl: bool = False + ) -> None: """Initialize a new API config object.""" self.host = host self.port = port - self.api_password = api_password + self.use_ssl = use_ssl + host = host.rstrip("/") if host.startswith(("http://", "https://")): self.base_url = host elif use_ssl: - self.base_url = "https://{}".format(host) + self.base_url = f"https://{host}" else: - self.base_url = "http://{}".format(host) + self.base_url = f"http://{host}" if port is not None: - self.base_url += ':{}'.format(port) + self.base_url += f":{port}" async def async_setup(hass, config): @@ -117,7 +113,6 @@ async def async_setup(hass, config): if conf is None: conf = HTTP_SCHEMA({}) - api_password = conf.get(CONF_API_PASSWORD) server_host = conf[CONF_SERVER_HOST] server_port = conf[CONF_SERVER_PORT] ssl_certificate = conf.get(CONF_SSL_CERTIFICATE) @@ -126,27 +121,20 @@ async def async_setup(hass, config): cors_origins = conf[CONF_CORS_ORIGINS] use_x_forwarded_for = conf.get(CONF_USE_X_FORWARDED_FOR, False) trusted_proxies = conf.get(CONF_TRUSTED_PROXIES, []) - trusted_networks = conf[CONF_TRUSTED_NETWORKS] is_ban_enabled = conf[CONF_IP_BAN_ENABLED] login_threshold = conf[CONF_LOGIN_ATTEMPTS_THRESHOLD] ssl_profile = conf[CONF_SSL_PROFILE] - if api_password is not None: - logging.getLogger('aiohttp.access').addFilter( - HideSensitiveDataFilter(api_password)) - server = HomeAssistantHTTP( hass, server_host=server_host, server_port=server_port, - api_password=api_password, ssl_certificate=ssl_certificate, ssl_peer_certificate=ssl_peer_certificate, ssl_key=ssl_key, cors_origins=cors_origins, use_x_forwarded_for=use_x_forwarded_for, trusted_proxies=trusted_proxies, - trusted_networks=trusted_networks, login_threshold=login_threshold, is_ban_enabled=is_ban_enabled, ssl_profile=ssl_profile, @@ -176,8 +164,7 @@ async def async_setup(hass, config): host = hass_util.get_local_ip() port = server_port - hass.config.api = ApiConfig(host, port, ssl_certificate is not None, - api_password) + hass.config.api = ApiConfig(host, port, ssl_certificate is not None) return True @@ -185,14 +172,24 @@ async def async_setup(hass, config): class HomeAssistantHTTP: """HTTP server for Home Assistant.""" - def __init__(self, hass, api_password, - ssl_certificate, ssl_peer_certificate, - ssl_key, server_host, server_port, cors_origins, - use_x_forwarded_for, trusted_proxies, trusted_networks, - login_threshold, is_ban_enabled, ssl_profile): + def __init__( + self, + hass, + ssl_certificate, + ssl_peer_certificate, + ssl_key, + server_host, + server_port, + cors_origins, + use_x_forwarded_for, + trusted_proxies, + login_threshold, + is_ban_enabled, + ssl_profile, + ): """Initialize the HTTP Home Assistant server.""" - app = self.app = web.Application( - middlewares=[staticresource_middleware]) + app = self.app = web.Application(middlewares=[]) + app[KEY_HASS] = hass # This order matters setup_real_ip(app, use_x_forwarded_for, trusted_proxies) @@ -200,27 +197,17 @@ class HomeAssistantHTTP: if is_ban_enabled: setup_bans(hass, app, login_threshold) - if hass.auth.active and hass.auth.support_legacy: - _LOGGER.warning( - "legacy_api_password support has been enabled. If you don't " - "require it, remove the 'api_password' from your http config.") - - setup_auth(app, trusted_networks, hass.auth.active, - support_legacy=hass.auth.support_legacy, - api_password=api_password) + setup_auth(hass, app) setup_cors(app, cors_origins) - app['hass'] = hass - self.hass = hass - self.api_password = api_password self.ssl_certificate = ssl_certificate self.ssl_peer_certificate = ssl_peer_certificate self.ssl_key = ssl_key self.server_host = server_host self.server_port = server_port - self.trusted_networks = trusted_networks + self.trusted_proxies = trusted_proxies self.is_ban_enabled = is_ban_enabled self.ssl_profile = ssl_profile self._handler = None @@ -238,17 +225,13 @@ class HomeAssistantHTTP: # Instantiate the view, if needed view = view() - if not hasattr(view, 'url'): + if not hasattr(view, "url"): class_name = view.__class__.__name__ - raise AttributeError( - '{0} missing required attribute "url"'.format(class_name) - ) + raise AttributeError(f'{class_name} missing required attribute "url"') - if not hasattr(view, 'name'): + if not hasattr(view, "name"): class_name = view.__class__.__name__ - raise AttributeError( - '{0} missing required attribute "name"'.format(class_name) - ) + raise AttributeError(f'{class_name} missing required attribute "name"') view.register(self.app, self.app.router) @@ -261,11 +244,12 @@ class HomeAssistantHTTP: for the redirect, otherwise it has to be a string with placeholders in rule syntax. """ - def redirect(request): + + async def redirect(request): """Redirect to location.""" raise HTTPMovedPermanently(redirect_to) - self.app.router.add_route('GET', url, redirect) + self.app.router.add_route("GET", url, redirect) def register_static_path(self, url_path, path, cache_headers=True): """Register a folder or file to serve as a static path.""" @@ -278,36 +262,21 @@ class HomeAssistantHTTP: return if cache_headers: + async def serve_file(request): """Serve file from disk.""" - return CachingFileResponse(path) + return web.FileResponse(path, headers=CACHE_HEADERS) + else: + async def serve_file(request): """Serve file from disk.""" return web.FileResponse(path) - # aiohttp supports regex matching for variables. Using that as temp - # to work around cache busting MD5. - # Turns something like /static/dev-panel.html into - # /static/{filename:dev-panel(-[a-z0-9]{32}|)\.html} - base, ext = os.path.splitext(url_path) - if ext: - base, file = base.rsplit('/', 1) - regex = r"{}(-[a-z0-9]{{32}}|){}".format(file, ext) - url_pattern = "{}/{{filename:{}}}".format(base, regex) - else: - url_pattern = url_path - - self.app.router.add_route('GET', url_pattern, serve_file) + self.app.router.add_route("GET", url_path, serve_file) async def start(self): """Start the aiohttp server.""" - # 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: if self.ssl_profile == SSL_INTERMEDIATE: @@ -315,18 +284,21 @@ class HomeAssistantHTTP: else: context = ssl_util.server_context_modern() await self.hass.async_add_executor_job( - context.load_cert_chain, self.ssl_certificate, - self.ssl_key) + context.load_cert_chain, self.ssl_certificate, self.ssl_key + ) except OSError as error: - _LOGGER.error("Could not read SSL certificate from %s: %s", - self.ssl_certificate, error) + _LOGGER.error( + "Could not read SSL certificate from %s: %s", + self.ssl_certificate, + error, + ) return if self.ssl_peer_certificate: context.verify_mode = ssl.CERT_REQUIRED await self.hass.async_add_executor_job( - context.load_verify_locations, - self.ssl_peer_certificate) + context.load_verify_locations, self.ssl_peer_certificate + ) else: context = None @@ -335,17 +307,20 @@ class HomeAssistantHTTP: # However in Home Assistant components can be discovered after boot. # This will now raise a RunTimeError. # To work around this we now prevent the router from getting frozen + # pylint: disable=protected-access self.app._router.freeze = lambda: None self.runner = web.AppRunner(self.app) await self.runner.setup() - self.site = web.TCPSite(self.runner, self.server_host, - self.server_port, ssl_context=context) + self.site = web.TCPSite( + self.runner, self.server_host, self.server_port, ssl_context=context + ) try: await self.site.start() except OSError as error: - _LOGGER.error("Failed to create HTTP server at port %d: %s", - self.server_port, error) + _LOGGER.error( + "Failed to create HTTP server at port %d: %s", self.server_port, error + ) async def stop(self): """Stop the aiohttp server.""" diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index a18b4de7a..58814b77e 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -1,132 +1,137 @@ """Authentication for HTTP component.""" - -import base64 -import hmac import logging +import secrets from aiohttp import hdrs from aiohttp.web import middleware +import jwt from homeassistant.core import callback -from homeassistant.const import HTTP_HEADER_HA_AUTH -from .const import KEY_AUTHENTICATED, KEY_REAL_IP +from homeassistant.util import dt as dt_util -DATA_API_PASSWORD = 'api_password' +from .const import KEY_AUTHENTICATED, KEY_HASS_USER, KEY_REAL_IP + +# mypy: allow-untyped-defs, no-check-untyped-defs _LOGGER = logging.getLogger(__name__) +DATA_API_PASSWORD = "api_password" +DATA_SIGN_SECRET = "http.auth.sign_secret" +SIGN_QUERY_PARAM = "authSig" + @callback -def setup_auth(app, trusted_networks, use_auth, - support_legacy=False, api_password=None): +def async_sign_path(hass, refresh_token_id, path, expiration): + """Sign a path for temporary access without auth header.""" + secret = hass.data.get(DATA_SIGN_SECRET) + + if secret is None: + secret = hass.data[DATA_SIGN_SECRET] = secrets.token_hex() + + now = dt_util.utcnow() + return "{}?{}={}".format( + path, + SIGN_QUERY_PARAM, + jwt.encode( + { + "iss": refresh_token_id, + "path": path, + "iat": now, + "exp": now + expiration, + }, + secret, + algorithm="HS256", + ).decode(), + ) + + +@callback +def setup_auth(hass, app): """Create auth middleware for the app.""" - old_auth_warning = set() + + async def async_validate_auth_header(request): + """ + Test authorization header against access token. + + Basic auth_type is legacy code, should be removed with api_password. + """ + 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 != "Bearer": + return False + + refresh_token = await hass.auth.async_validate_access_token(auth_val) + + if refresh_token is None: + return False + + request[KEY_HASS_USER] = refresh_token.user + return True + + async def async_validate_signed_request(request): + """Validate a signed request.""" + secret = hass.data.get(DATA_SIGN_SECRET) + + if secret is None: + return False + + signature = request.query.get(SIGN_QUERY_PARAM) + + if signature is None: + return False + + try: + claims = jwt.decode( + signature, secret, algorithms=["HS256"], options={"verify_iss": False} + ) + except jwt.InvalidTokenError: + return False + + if claims["path"] != request.path: + return False + + refresh_token = await hass.auth.async_get_refresh_token(claims["iss"]) + + if refresh_token is None: + return False + + request[KEY_HASS_USER] = refresh_token.user + return True @middleware async def auth_middleware(request, handler): """Authenticate as middleware.""" authenticated = False - if use_auth and (HTTP_HEADER_HA_AUTH in request.headers or - DATA_API_PASSWORD in request.query): - if request.path not in old_auth_warning: - _LOGGER.log( - logging.INFO if support_legacy else logging.WARNING, - 'You need to use a bearer token to access %s from %s', - request.path, request[KEY_REAL_IP]) - old_auth_warning.add(request.path) - - legacy_auth = (not use_auth or support_legacy) and api_password - if (hdrs.AUTHORIZATION in request.headers and - await async_validate_auth_header( - request, api_password if legacy_auth else None)): - # it included both use_auth and api_password Basic auth + if hdrs.AUTHORIZATION in request.headers and await async_validate_auth_header( + request + ): authenticated = True + auth_type = "bearer token" - elif (legacy_auth and HTTP_HEADER_HA_AUTH in request.headers and - hmac.compare_digest( - api_password.encode('utf-8'), - request.headers[HTTP_HEADER_HA_AUTH].encode('utf-8'))): - # A valid auth header has been set + # We first start with a string check to avoid parsing query params + # for every request. + elif ( + request.method == "GET" + and SIGN_QUERY_PARAM in request.query + and await async_validate_signed_request(request) + ): authenticated = True + auth_type = "signed request" - elif (legacy_auth and DATA_API_PASSWORD in request.query and - hmac.compare_digest( - api_password.encode('utf-8'), - request.query[DATA_API_PASSWORD].encode('utf-8'))): - authenticated = True - - elif _is_trusted_ip(request, trusted_networks): - authenticated = True - - elif not use_auth and api_password is None: - # If neither password nor auth_providers set, - # just always set authenticated=True - authenticated = True + if authenticated: + _LOGGER.debug( + "Authenticated %s for %s using %s", + request[KEY_REAL_IP], + request.path, + auth_type, + ) request[KEY_AUTHENTICATED] = authenticated return await handler(request) - async def auth_startup(app): - """Initialize auth middleware when app starts up.""" - app.middlewares.append(auth_middleware) - - app.on_startup.append(auth_startup) - - -def _is_trusted_ip(request, trusted_networks): - """Test if request is from a trusted ip.""" - ip_addr = request[KEY_REAL_IP] - - return any( - ip_addr in trusted_network for trusted_network - in trusted_networks) - - -def validate_password(request, api_password): - """Test if password is valid.""" - return hmac.compare_digest( - api_password.encode('utf-8'), - request.app['hass'].http.api_password.encode('utf-8')) - - -async def async_validate_auth_header(request, api_password=None): - """ - Test authorization header against access token. - - Basic auth_type is legacy code, should be removed with api_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 == 'Bearer': - hass = request.app['hass'] - refresh_token = await hass.auth.async_validate_access_token(auth_val) - if refresh_token is None: - return False - - request['hass_user'] = refresh_token.user - return True - - if auth_type == 'Basic' and api_password is not None: - 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')) - - return False + app.middlewares.append(auth_middleware) diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py index 2a25de96e..553d36571 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -3,46 +3,51 @@ from collections import defaultdict from datetime import datetime from ipaddress import ip_address import logging -import os +from typing import List, Optional from aiohttp.web import middleware from aiohttp.web_exceptions import HTTPForbidden, HTTPUnauthorized import voluptuous as vol -from homeassistant.core import callback from homeassistant.config import load_yaml_config_file +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.util.yaml import dump + from .const import KEY_REAL_IP +# mypy: allow-untyped-defs, no-check-untyped-defs + _LOGGER = logging.getLogger(__name__) -KEY_BANNED_IPS = 'ha_banned_ips' -KEY_FAILED_LOGIN_ATTEMPTS = 'ha_failed_login_attempts' -KEY_LOGIN_THRESHOLD = 'ha_login_threshold' +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' +NOTIFICATION_ID_BAN = "ip-ban" +NOTIFICATION_ID_LOGIN = "http-login" -IP_BANS_FILE = 'ip_bans.yaml' +IP_BANS_FILE = "ip_bans.yaml" ATTR_BANNED_AT = "banned_at" -SCHEMA_IP_BAN_ENTRY = vol.Schema({ - vol.Optional('banned_at'): vol.Any(None, cv.datetime) -}) +SCHEMA_IP_BAN_ENTRY = vol.Schema( + {vol.Optional("banned_at"): vol.Any(None, cv.datetime)} +) @callback def setup_bans(hass, app, login_threshold): """Create IP Ban middleware for the app.""" + app.middlewares.append(ban_middleware) + app[KEY_FAILED_LOGIN_ATTEMPTS] = defaultdict(int) + app[KEY_LOGIN_THRESHOLD] = login_threshold + 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 + app[KEY_BANNED_IPS] = await async_load_ip_bans_config( + hass, hass.config.path(IP_BANS_FILE) + ) app.on_startup.append(ban_startup) @@ -51,13 +56,14 @@ def setup_bans(hass, app, login_threshold): 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') + _LOGGER.error("IP Ban middleware loaded but banned IPs not loaded") return await handler(request) # 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]) + is_banned = any( + ip_ban.ip_address == ip_address_ for ip_ban in request.app[KEY_BANNED_IPS] + ) if is_banned: raise HTTPForbidden() @@ -71,12 +77,14 @@ async def ban_middleware(request, handler): def log_invalid_auth(func): """Decorate function to handle invalid auth or failed login attempts.""" + async def handle_req(view, request, *args, **kwargs): """Try to log failed login attempts if response status >= 400.""" resp = await func(view, request, *args, **kwargs) if resp.status >= 400: await process_wrong_login(request) return resp + return handle_req @@ -88,35 +96,40 @@ async def process_wrong_login(request): """ remote_addr = request[KEY_REAL_IP] - msg = ('Login attempt or request with invalid authentication ' - 'from {}'.format(remote_addr)) + msg = "Login attempt or request with invalid authentication " "from {}".format( + remote_addr + ) _LOGGER.warning(msg) - hass = request.app['hass'] + hass = request.app["hass"] hass.components.persistent_notification.async_create( - msg, 'Login attempt failed', NOTIFICATION_ID_LOGIN) + msg, "Login attempt failed", NOTIFICATION_ID_LOGIN + ) # Check if ban middleware is loaded - if (KEY_BANNED_IPS not in request.app or - request.app[KEY_LOGIN_THRESHOLD] < 1): + if KEY_BANNED_IPS not in request.app or request.app[KEY_LOGIN_THRESHOLD] < 1: return request.app[KEY_FAILED_LOGIN_ATTEMPTS][remote_addr] += 1 - if (request.app[KEY_FAILED_LOGIN_ATTEMPTS][remote_addr] > - request.app[KEY_LOGIN_THRESHOLD]): + if ( + request.app[KEY_FAILED_LOGIN_ATTEMPTS][remote_addr] + >= request.app[KEY_LOGIN_THRESHOLD] + ): new_ban = IpBan(remote_addr) request.app[KEY_BANNED_IPS].append(new_ban) await hass.async_add_job( - update_ip_bans_config, hass.config.path(IP_BANS_FILE), new_ban) + update_ip_bans_config, hass.config.path(IP_BANS_FILE), new_ban + ) - _LOGGER.warning( - "Banned IP %s for too many login attempts", remote_addr) + _LOGGER.warning("Banned IP %s for too many login attempts", remote_addr) hass.components.persistent_notification.async_create( - 'Too many login attempts from {}'.format(remote_addr), - 'Banning IP address', NOTIFICATION_ID_BAN) + f"Too many login attempts from {remote_addr}", + "Banning IP address", + NOTIFICATION_ID_BAN, + ) async def process_success_login(request): @@ -129,43 +142,44 @@ async def process_success_login(request): remote_addr = request[KEY_REAL_IP] # Check if ban middleware is loaded - if (KEY_BANNED_IPS not in request.app or - request.app[KEY_LOGIN_THRESHOLD] < 1): + if KEY_BANNED_IPS not in request.app or request.app[KEY_LOGIN_THRESHOLD] < 1: return - if remote_addr in request.app[KEY_FAILED_LOGIN_ATTEMPTS] and \ - request.app[KEY_FAILED_LOGIN_ATTEMPTS][remote_addr] > 0: - _LOGGER.debug('Login success, reset failed login attempts counter' - ' from %s', remote_addr) + if ( + remote_addr in request.app[KEY_FAILED_LOGIN_ATTEMPTS] + and request.app[KEY_FAILED_LOGIN_ATTEMPTS][remote_addr] > 0 + ): + _LOGGER.debug( + "Login success, reset failed login attempts counter" " from %s", remote_addr + ) request.app[KEY_FAILED_LOGIN_ATTEMPTS].pop(remote_addr) class IpBan: """Represents banned IP address.""" - def __init__(self, ip_ban: str, banned_at: datetime = None) -> None: + def __init__(self, ip_ban: str, banned_at: Optional[datetime] = None) -> None: """Initialize IP Ban object.""" self.ip_address = ip_address(ip_ban) self.banned_at = banned_at or datetime.utcnow() -def load_ip_bans_config(path: str): +async def async_load_ip_bans_config(hass: HomeAssistant, path: str) -> List[IpBan]: """Load list of banned IPs from config file.""" - ip_list = [] - - if not os.path.isfile(path): - return ip_list + ip_list: List[IpBan] = [] try: - list_ = load_yaml_config_file(path) + list_ = await hass.async_add_executor_job(load_yaml_config_file, path) + except FileNotFoundError: + return ip_list except HomeAssistantError as err: - _LOGGER.error('Unable to load %s: %s', path, str(err)) + _LOGGER.error("Unable to load %s: %s", path, str(err)) return ip_list for ip_ban, ip_info in list_.items(): try: ip_info = SCHEMA_IP_BAN_ENTRY(ip_info) - ip_list.append(IpBan(ip_ban, ip_info['banned_at'])) + ip_list.append(IpBan(ip_ban, ip_info["banned_at"])) except vol.Invalid as err: _LOGGER.error("Failed to load IP ban %s: %s", ip_info, err) continue @@ -173,11 +187,13 @@ def load_ip_bans_config(path: str): return ip_list -def update_ip_bans_config(path: str, ip_ban: IpBan): +def update_ip_bans_config(path: str, ip_ban: IpBan) -> None: """Update config file with new banned IP address.""" - with open(path, 'a') as out: - ip_ = {str(ip_ban.ip_address): { - ATTR_BANNED_AT: ip_ban.banned_at.strftime("%Y-%m-%dT%H:%M:%S") - }} - out.write('\n') + with open(path, "a") as out: + ip_ = { + str(ip_ban.ip_address): { + ATTR_BANNED_AT: ip_ban.banned_at.strftime("%Y-%m-%dT%H:%M:%S") + } + } + out.write("\n") out.write(dump(ip_)) diff --git a/homeassistant/components/http/const.py b/homeassistant/components/http/const.py index e5494e945..9392e790d 100644 --- a/homeassistant/components/http/const.py +++ b/homeassistant/components/http/const.py @@ -1,3 +1,5 @@ """HTTP specific constants.""" -KEY_AUTHENTICATED = 'ha_authenticated' -KEY_REAL_IP = 'ha_real_ip' +KEY_AUTHENTICATED = "ha_authenticated" +KEY_HASS = "hass" +KEY_HASS_USER = "hass_user" +KEY_REAL_IP = "ha_real_ip" diff --git a/homeassistant/components/http/cors.py b/homeassistant/components/http/cors.py index 5698c6048..2d99a049e 100644 --- a/homeassistant/components/http/cors.py +++ b/homeassistant/components/http/cors.py @@ -1,62 +1,78 @@ """Provide CORS support for the HTTP component.""" +from aiohttp.hdrs import ACCEPT, AUTHORIZATION, CONTENT_TYPE, ORIGIN +from aiohttp.web_urldispatcher import Resource, ResourceRoute, StaticResource - -from aiohttp.hdrs import ACCEPT, ORIGIN, CONTENT_TYPE - -from homeassistant.const import ( - HTTP_HEADER_X_REQUESTED_WITH, HTTP_HEADER_HA_AUTH) - - +from homeassistant.const import HTTP_HEADER_X_REQUESTED_WITH from homeassistant.core import callback +# mypy: allow-untyped-defs, no-check-untyped-defs ALLOWED_CORS_HEADERS = [ - ORIGIN, ACCEPT, HTTP_HEADER_X_REQUESTED_WITH, CONTENT_TYPE, - HTTP_HEADER_HA_AUTH] + ORIGIN, + ACCEPT, + HTTP_HEADER_X_REQUESTED_WITH, + CONTENT_TYPE, + AUTHORIZATION, +] +VALID_CORS_TYPES = (Resource, ResourceRoute, StaticResource) @callback def setup_cors(app, origins): """Set up CORS.""" + # This import should remain here. That way the HTTP integration can always + # be imported by other integrations without it's requirements being installed. + # pylint: disable=import-outside-toplevel import aiohttp_cors - cors = aiohttp_cors.setup(app, defaults={ - host: aiohttp_cors.ResourceOptions( - allow_headers=ALLOWED_CORS_HEADERS, - allow_methods='*', - ) for host in origins - }) + cors = aiohttp_cors.setup( + app, + defaults={ + host: aiohttp_cors.ResourceOptions( + allow_headers=ALLOWED_CORS_HEADERS, allow_methods="*" + ) + for host in origins + }, + ) cors_added = set() def _allow_cors(route, config=None): """Allow CORS on a route.""" - if hasattr(route, 'resource'): + if hasattr(route, "resource"): path = route.resource else: path = route + if not isinstance(path, VALID_CORS_TYPES): + return + path = path.canonical + if path.startswith("/api/hassio_ingress/"): + return + if path in cors_added: return cors.add(route, config) cors_added.add(path) - app['allow_cors'] = lambda route: _allow_cors(route, { - '*': aiohttp_cors.ResourceOptions( - allow_headers=ALLOWED_CORS_HEADERS, - allow_methods='*', - ) - }) + app["allow_cors"] = lambda route: _allow_cors( + route, + { + "*": aiohttp_cors.ResourceOptions( + allow_headers=ALLOWED_CORS_HEADERS, allow_methods="*" + ) + }, + ) if not origins: return async def cors_startup(app): """Initialize CORS when app starts up.""" - for route in list(app.router.routes()): - _allow_cors(route) + for resource in list(app.router.resources()): + _allow_cors(resource) app.on_startup.append(cors_startup) diff --git a/homeassistant/components/http/data_validator.py b/homeassistant/components/http/data_validator.py index 8fc7cd8e6..51b3b5617 100644 --- a/homeassistant/components/http/data_validator.py +++ b/homeassistant/components/http/data_validator.py @@ -1,10 +1,11 @@ """Decorator for view methods to help with data validation.""" - from functools import wraps import logging import voluptuous as vol +# mypy: allow-untyped-defs + _LOGGER = logging.getLogger(__name__) @@ -19,11 +20,15 @@ class RequestDataValidator: def __init__(self, schema, allow_empty=False): """Initialize the decorator.""" + if isinstance(schema, dict): + schema = vol.Schema(schema) + 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.""" @@ -31,18 +36,16 @@ class RequestDataValidator: 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) + 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) + 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) + _LOGGER.error("Data does not match schema: %s", err) + return view.json_message(f"Message format incorrect: {err}", 400) result = await method(view, request, *args, **kwargs) return result diff --git a/homeassistant/components/http/manifest.json b/homeassistant/components/http/manifest.json new file mode 100644 index 000000000..6db8b041c --- /dev/null +++ b/homeassistant/components/http/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "http", + "name": "HTTP", + "documentation": "https://www.home-assistant.io/integrations/http", + "requirements": [ + "aiohttp_cors==0.7.0" + ], + "dependencies": [], + "codeowners": [ + "@home-assistant/core" + ] +} diff --git a/homeassistant/components/http/real_ip.py b/homeassistant/components/http/real_ip.py index f8adc815f..f2334ce0a 100644 --- a/homeassistant/components/http/real_ip.py +++ b/homeassistant/components/http/real_ip.py @@ -1,40 +1,41 @@ """Middleware to fetch real IP.""" - from ipaddress import ip_address -from aiohttp.web import middleware from aiohttp.hdrs import X_FORWARDED_FOR +from aiohttp.web import middleware from homeassistant.core import callback from .const import KEY_REAL_IP +# mypy: allow-untyped-defs + @callback def setup_real_ip(app, use_x_forwarded_for, trusted_proxies): """Create IP Ban middleware for the app.""" + @middleware async def real_ip_middleware(request, handler): """Real IP middleware.""" - connected_ip = ip_address( - request.transport.get_extra_info('peername')[0]) + connected_ip = ip_address(request.transport.get_extra_info("peername")[0]) request[KEY_REAL_IP] = connected_ip # Only use the XFF header if enabled, present, and from a trusted proxy try: - if (use_x_forwarded_for and - X_FORWARDED_FOR in request.headers and - any(connected_ip in trusted_proxy - for trusted_proxy in trusted_proxies)): + if ( + use_x_forwarded_for + and X_FORWARDED_FOR in request.headers + and any( + connected_ip in trusted_proxy for trusted_proxy in trusted_proxies + ) + ): request[KEY_REAL_IP] = ip_address( - request.headers.get(X_FORWARDED_FOR).split(', ')[-1]) + request.headers.get(X_FORWARDED_FOR).split(", ")[-1] + ) except ValueError: pass 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) + app.middlewares.append(real_ip_middleware) diff --git a/homeassistant/components/http/static.py b/homeassistant/components/http/static.py index 8b28a7cf2..a5fe686a6 100644 --- a/homeassistant/components/http/static.py +++ b/homeassistant/components/http/static.py @@ -1,23 +1,29 @@ """Static file handling for HTTP component.""" - -import re +from pathlib import Path from aiohttp import hdrs -from aiohttp.web import FileResponse, middleware -from aiohttp.web_exceptions import HTTPNotFound +from aiohttp.web import FileResponse +from aiohttp.web_exceptions import HTTPForbidden, HTTPNotFound from aiohttp.web_urldispatcher import StaticResource -from yarl import URL -_FINGERPRINT = re.compile(r'^(.+)-[a-z0-9]{32}\.(\w+)$', re.IGNORECASE) +# mypy: allow-untyped-defs + +CACHE_TIME = 31 * 86400 # = 1 month +CACHE_HEADERS = {hdrs.CACHE_CONTROL: f"public, max-age={CACHE_TIME}"} class CachingStaticResource(StaticResource): """Static Resource handler that will add cache headers.""" async def _handle(self, request): - filename = URL(request.match_info['filename']).path + rel_url = request.match_info["filename"] try: - # PyLint is wrong about resolve not being a member. + filename = Path(rel_url) + if filename.anchor: + # rel_url is an absolute name like + # /static/\\machine_name\c$ or /static/D:\path + # where the static dir is totally different + raise HTTPForbidden() filepath = self._directory.joinpath(filename).resolve() if not self._follow_symlinks: filepath.relative_to(self._directory) @@ -29,46 +35,14 @@ class CachingStaticResource(StaticResource): request.app.logger.exception(error) raise HTTPNotFound() from error + # on opening a dir, load its contents if allowed if filepath.is_dir(): return await super()._handle(request) if filepath.is_file(): - return CachingFileResponse(filepath, chunk_size=self._chunk_size) + return FileResponse( + filepath, + chunk_size=self._chunk_size, + # type ignore: https://github.com/aio-libs/aiohttp/pull/3976 + headers=CACHE_HEADERS, # type: ignore + ) raise HTTPNotFound - - -# pylint: disable=too-many-ancestors -class CachingFileResponse(FileResponse): - """FileSender class that caches output if not in dev mode.""" - - def __init__(self, *args, **kwargs): - """Initialize the hass file sender.""" - super().__init__(*args, **kwargs) - - orig_sendfile = self._sendfile - - async def sendfile(request, fobj, count): - """Sendfile that includes a cache header.""" - cache_time = 31 * 86400 # = 1 month - self.headers[hdrs.CACHE_CONTROL] = "public, max-age={}".format( - cache_time) - - await orig_sendfile(request, fobj, count) - - # Overwriting like this because __init__ can change implementation. - self._sendfile = sendfile - - -@middleware -async def staticresource_middleware(request, handler): - """Middleware to strip out fingerprint from fingerprinted assets.""" - path = request.path - if not path.startswith('/static/') and not path.startswith('/frontend'): - return await handler(request) - - fingerprinted = _FINGERPRINT.match(request.match_info['filename']) - - if fingerprinted: - request.match_info['filename'] = \ - '{}.{}'.format(*fingerprinted.groups()) - - return await handler(request) diff --git a/homeassistant/components/http/view.py b/homeassistant/components/http/view.py index b3b2587fc..31f968336 100644 --- a/homeassistant/components/http/view.py +++ b/homeassistant/components/http/view.py @@ -1,74 +1,81 @@ -""" -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/ -""" +"""Support for views.""" import asyncio import json import logging +from typing import List, Optional from aiohttp import web -from aiohttp.web_exceptions import HTTPUnauthorized, HTTPInternalServerError +from aiohttp.web_exceptions import ( + HTTPBadRequest, + HTTPInternalServerError, + HTTPUnauthorized, +) +import voluptuous as vol -from homeassistant.components.http.ban import process_success_login -from homeassistant.core import Context, is_callback +from homeassistant import exceptions from homeassistant.const import CONTENT_TYPE_JSON +from homeassistant.core import Context, is_callback from homeassistant.helpers.json import JSONEncoder -from .const import KEY_AUTHENTICATED, KEY_REAL_IP - +from .const import KEY_AUTHENTICATED, KEY_HASS, KEY_REAL_IP _LOGGER = logging.getLogger(__name__) +# mypy: allow-untyped-defs, no-check-untyped-defs + + class HomeAssistantView: """Base view for all views.""" - url = None - extra_urls = [] + url: Optional[str] = None + extra_urls: List[str] = [] # Views inheriting from this class can override this requires_auth = True cors_allowed = False - # pylint: disable=no-self-use - def context(self, request): + @staticmethod + def context(request): """Generate a context from a request.""" - user = request.get('hass_user') + user = request.get("hass_user") if user is None: return Context() return Context(user_id=user.id) - def json(self, result, status_code=200, headers=None): + @staticmethod + def json(result, status_code=200, headers=None): """Return a JSON response.""" try: msg = json.dumps( - result, sort_keys=True, cls=JSONEncoder).encode('UTF-8') - except TypeError as err: - _LOGGER.error('Unable to serialize to JSON: %s\n%s', err, result) + result, sort_keys=True, cls=JSONEncoder, allow_nan=False + ).encode("UTF-8") + except (ValueError, 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) + 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): + def json_message(self, message, status_code=200, message_code=None, headers=None): """Return a JSON message response.""" - data = {'message': message} + data = {"message": message} if message_code is not None: - data['code'] = message_code + data["code"] = message_code return self.json(data, status_code, headers=headers) def register(self, app, router): """Register the view with a router.""" - assert self.url is not None, 'No url set for view' + assert self.url is not None, "No url set for view" urls = [self.url] + self.extra_urls routes = [] - for method in ('get', 'post', 'delete', 'put'): + for method in ("get", "post", "delete", "put", "patch", "head", "options"): handler = getattr(self, method, None) if not handler: @@ -83,34 +90,43 @@ class HomeAssistantView: return for route in routes: - app['allow_cors'](route) + app["allow_cors"](route) 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." + 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: + if not request.app[KEY_HASS].is_running: return web.Response(status=503) authenticated = request.get(KEY_AUTHENTICATED, False) - if view.requires_auth: - if authenticated: - await process_success_login(request) - else: - raise HTTPUnauthorized() + 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) + _LOGGER.debug( + "Serving %s to %s (auth: %s)", + request.path, + request.get(KEY_REAL_IP), + authenticated, + ) - result = handler(request, **request.match_info) + try: + result = handler(request, **request.match_info) - if asyncio.iscoroutine(result): - result = await result + if asyncio.iscoroutine(result): + result = await result + except vol.Invalid: + raise HTTPBadRequest() + except exceptions.ServiceNotFound: + raise HTTPInternalServerError() + except exceptions.Unauthorized: + raise HTTPUnauthorized() if isinstance(result, web.StreamResponse): # The method handler returned a ready-made Response, how nice of it @@ -122,12 +138,13 @@ def request_handler_factory(view, handler): result, status_code = result if isinstance(result, str): - result = result.encode('utf-8') + result = result.encode("utf-8") elif result is None: - result = b'' + result = b"" elif not isinstance(result, bytes): - assert False, ('Result should be None, string, bytes or Response. ' - 'Got: {}').format(result) + assert False, ( + "Result should be None, string, bytes or Response. " "Got: {}" + ).format(result) return web.Response(body=result, status=status_code) diff --git a/homeassistant/components/htu21d/__init__.py b/homeassistant/components/htu21d/__init__.py new file mode 100644 index 000000000..c36c8bfcf --- /dev/null +++ b/homeassistant/components/htu21d/__init__.py @@ -0,0 +1 @@ +"""The htu21d component.""" diff --git a/homeassistant/components/htu21d/manifest.json b/homeassistant/components/htu21d/manifest.json new file mode 100644 index 000000000..14b0d7b3f --- /dev/null +++ b/homeassistant/components/htu21d/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "htu21d", + "name": "Htu21d", + "documentation": "https://www.home-assistant.io/integrations/htu21d", + "requirements": [ + "i2csense==0.0.4", + "smbus-cffi==0.5.1" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/htu21d/sensor.py b/homeassistant/components/htu21d/sensor.py new file mode 100644 index 000000000..954ba60ab --- /dev/null +++ b/homeassistant/components/htu21d/sensor.py @@ -0,0 +1,111 @@ +"""Support for HTU21D temperature and humidity sensor.""" +from datetime import timedelta +from functools import partial +import logging + +from i2csense.htu21d import HTU21D # pylint: disable=import-error +import smbus # pylint: disable=import-error +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_NAME, TEMP_FAHRENHEIT +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle +from homeassistant.util.temperature import celsius_to_fahrenheit + +_LOGGER = logging.getLogger(__name__) + +CONF_I2C_BUS = "i2c_bus" +DEFAULT_I2C_BUS = 1 + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=5) + +DEFAULT_NAME = "HTU21D Sensor" + +SENSOR_TEMPERATURE = "temperature" +SENSOR_HUMIDITY = "humidity" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_I2C_BUS, default=DEFAULT_I2C_BUS): vol.Coerce(int), + } +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the HTU21D sensor.""" + name = config.get(CONF_NAME) + bus_number = config.get(CONF_I2C_BUS) + temp_unit = hass.config.units.temperature_unit + + bus = smbus.SMBus(config.get(CONF_I2C_BUS)) + sensor = await hass.async_add_job(partial(HTU21D, bus, logger=_LOGGER)) + if not sensor.sample_ok: + _LOGGER.error("HTU21D sensor not detected in bus %s", bus_number) + return False + + sensor_handler = await hass.async_add_job(HTU21DHandler, sensor) + + dev = [ + HTU21DSensor(sensor_handler, name, SENSOR_TEMPERATURE, temp_unit), + HTU21DSensor(sensor_handler, name, SENSOR_HUMIDITY, "%"), + ] + + async_add_entities(dev) + + +class HTU21DHandler: + """Implement HTU21D communication.""" + + def __init__(self, sensor): + """Initialize the sensor handler.""" + self.sensor = sensor + self.sensor.update() + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Read raw data and calculate temperature and humidity.""" + self.sensor.update() + + +class HTU21DSensor(Entity): + """Implementation of the HTU21D sensor.""" + + def __init__(self, htu21d_client, name, variable, unit): + """Initialize the sensor.""" + self._name = f"{name}_{variable}" + self._variable = variable + self._unit_of_measurement = unit + self._client = htu21d_client + self._state = None + + @property + def name(self) -> str: + """Return the name of the sensor.""" + return self._name + + @property + def state(self) -> int: + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self) -> str: + """Return the unit of measurement of the sensor.""" + return self._unit_of_measurement + + async def async_update(self): + """Get the latest data from the HTU21D sensor and update the state.""" + await self.hass.async_add_job(self._client.update) + if self._client.sensor.sample_ok: + if self._variable == SENSOR_TEMPERATURE: + value = round(self._client.sensor.temperature, 1) + if self.unit_of_measurement == TEMP_FAHRENHEIT: + value = celsius_to_fahrenheit(value) + else: + value = round(self._client.sensor.humidity, 1) + self._state = value + else: + _LOGGER.warning("Bad sample") diff --git a/homeassistant/components/huawei_lte.py b/homeassistant/components/huawei_lte.py deleted file mode 100644 index 33da6be56..000000000 --- a/homeassistant/components/huawei_lte.py +++ /dev/null @@ -1,127 +0,0 @@ -""" -Support for Huawei LTE routers. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/huawei_lte/ -""" -from datetime import timedelta -from functools import reduce -import logging -import operator - -import voluptuous as vol -import attr - -from homeassistant.const import ( - CONF_URL, CONF_USERNAME, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP, -) -from homeassistant.helpers import config_validation as cv -from homeassistant.util import Throttle - - -_LOGGER = logging.getLogger(__name__) - -REQUIREMENTS = ['huawei-lte-api==1.0.12'] - -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10) - -DOMAIN = 'huawei_lte' -DATA_KEY = 'huawei_lte' - -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.All(cv.ensure_list, [vol.Schema({ - vol.Required(CONF_URL): cv.url, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - })]) -}, extra=vol.ALLOW_EXTRA) - - -@attr.s -class RouterData: - """Class for router state.""" - - client = attr.ib() - device_information = attr.ib(init=False, factory=dict) - device_signal = attr.ib(init=False, factory=dict) - traffic_statistics = attr.ib(init=False, factory=dict) - wlan_host_list = attr.ib(init=False, factory=dict) - - def __getitem__(self, path: str): - """ - Get value corresponding to a dotted path. - - The first path component designates a member of this class - such as device_information, device_signal etc, and the remaining - path points to a value in the member's data structure. - """ - root, *rest = path.split(".") - try: - data = getattr(self, root) - except AttributeError as err: - raise KeyError from err - return reduce(operator.getitem, rest, data) - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self) -> None: - """Call API to update data.""" - self.device_information = self.client.device.information() - _LOGGER.debug("device_information=%s", self.device_information) - self.device_signal = self.client.device.signal() - _LOGGER.debug("device_signal=%s", self.device_signal) - self.traffic_statistics = self.client.monitoring.traffic_statistics() - _LOGGER.debug("traffic_statistics=%s", self.traffic_statistics) - self.wlan_host_list = self.client.wlan.host_list() - _LOGGER.debug("wlan_host_list=%s", self.wlan_host_list) - - -@attr.s -class HuaweiLteData: - """Shared state.""" - - data = attr.ib(init=False, factory=dict) - - def get_data(self, config): - """Get the requested or the only data value.""" - if CONF_URL in config: - return self.data.get(config[CONF_URL]) - if len(self.data) == 1: - return next(iter(self.data.values())) - - return None - - -def setup(hass, config) -> bool: - """Set up Huawei LTE component.""" - if DATA_KEY not in hass.data: - hass.data[DATA_KEY] = HuaweiLteData() - for conf in config.get(DOMAIN, []): - _setup_lte(hass, conf) - return True - - -def _setup_lte(hass, lte_config) -> None: - """Set up Huawei LTE router.""" - from huawei_lte_api.AuthorizedConnection import AuthorizedConnection - from huawei_lte_api.Client import Client - - url = lte_config[CONF_URL] - username = lte_config[CONF_USERNAME] - password = lte_config[CONF_PASSWORD] - - connection = AuthorizedConnection( - url, - username=username, - password=password, - ) - client = Client(connection) - - data = RouterData(client) - data.update() - hass.data[DATA_KEY].data[url] = data - - def cleanup(event): - """Clean up resources.""" - client.user.logout() - - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, cleanup) diff --git a/homeassistant/components/huawei_lte/.translations/bg.json b/homeassistant/components/huawei_lte/.translations/bg.json new file mode 100644 index 000000000..44746468b --- /dev/null +++ b/homeassistant/components/huawei_lte/.translations/bg.json @@ -0,0 +1,42 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e", + "already_in_progress": "\u0422\u043e\u0432\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0432\u0435\u0447\u0435 \u0441\u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430", + "not_huawei_lte": "\u041d\u0435 \u0435 Huawei LTE \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e" + }, + "error": { + "connection_failed": "\u0421\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435\u0442\u043e \u0435 \u043d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e", + "connection_timeout": "\u0412\u0440\u0435\u043c\u0435\u0442\u043e \u0437\u0430 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u0438\u0437\u0442\u0435\u0447\u0435", + "incorrect_password": "\u041d\u0435\u043f\u0440\u0430\u0432\u0438\u043b\u043d\u0430 \u043f\u0430\u0440\u043e\u043b\u0430", + "incorrect_username": "\u041d\u0435\u043f\u0440\u0430\u0432\u0438\u043b\u043d\u043e \u043f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435", + "incorrect_username_or_password": "\u041d\u0435\u043f\u0440\u0430\u0432\u0438\u043b\u043d\u043e \u043f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435 \u0438\u043b\u0438 \u043f\u0430\u0440\u043e\u043b\u0430", + "invalid_url": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u0435\u043d \u0430\u0434\u0440\u0435\u0441", + "login_attempts_exceeded": "\u041c\u0430\u043a\u0441\u0438\u043c\u0430\u043b\u043d\u0438\u0442\u0435 \u043e\u043f\u0438\u0442\u0438 \u0437\u0430 \u0432\u043b\u0438\u0437\u0430\u043d\u0435 \u0441\u0430 \u043d\u0430\u0434\u0432\u0438\u0448\u0435\u043d\u0438. \u041c\u043e\u043b\u044f, \u043e\u043f\u0438\u0442\u0430\u0439\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e \u043f\u043e-\u043a\u044a\u0441\u043d\u043e", + "response_error": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430 \u043e\u0442 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e", + "unknown_connection_error": "\u041d\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 \u0441 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e" + }, + "step": { + "user": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "url": "URL \u0410\u0434\u0440\u0435\u0441", + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" + }, + "description": "\u0412\u044a\u0432\u0435\u0434\u0435\u0442\u0435 \u0434\u0430\u043d\u043d\u0438 \u0437\u0430 \u0434\u043e\u0441\u0442\u044a\u043f \u0434\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e. \u041f\u043e\u0441\u043e\u0447\u0432\u0430\u043d\u0435\u0442\u043e \u043d\u0430 \u043f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435 \u0438 \u043f\u0430\u0440\u043e\u043b\u0430 \u043d\u0435 \u0435 \u0437\u0430\u0434\u044a\u043b\u0436\u0438\u0442\u0435\u043b\u043d\u043e, \u043d\u043e \u0434\u0430\u0432\u0430 \u0432\u044a\u0437\u043c\u043e\u0436\u043d\u043e\u0441\u0442 \u0437\u0430 \u043f\u043e\u0432\u0435\u0447\u0435 \u0444\u0443\u043d\u043a\u0446\u0438\u0438 \u0437\u0430 \u0438\u043d\u0442\u0435\u0433\u0440\u0438\u0440\u0430\u043d\u0435. \u041e\u0442 \u0434\u0440\u0443\u0433\u0430 \u0441\u0442\u0440\u0430\u043d\u0430, \u0438\u0437\u043f\u043e\u043b\u0437\u0432\u0430\u043d\u0435\u0442\u043e \u043d\u0430 \u043e\u0442\u043e\u0440\u0438\u0437\u0438\u0440\u0430\u043d\u0430 \u0432\u0440\u044a\u0437\u043a\u0430 \u043c\u043e\u0436\u0435 \u0434\u0430 \u0434\u043e\u0432\u0435\u0434\u0435 \u0434\u043e \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u0438 \u0441 \u0434\u043e\u0441\u0442\u044a\u043f\u0430 \u0434\u043e \u0443\u0435\u0431 \u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430 \u043d\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u043e\u0442\u0432\u044a\u043d Home Assistant, \u0434\u043e\u043a\u0430\u0442\u043e \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f\u0442\u0430 \u0435 \u0430\u043a\u0442\u0438\u0432\u043d\u0430, \u0438 \u043e\u0431\u0440\u0430\u0442\u043d\u043e\u0442\u043e.", + "title": "\u041a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 Huawei LTE" + } + }, + "title": "Huawei LTE" + }, + "options": { + "step": { + "init": { + "data": { + "recipient": "\u041f\u043e\u043b\u0443\u0447\u0430\u0442\u0435\u043b\u0438 \u043d\u0430 SMS \u0438\u0437\u0432\u0435\u0441\u0442\u0438\u044f", + "track_new_devices": "\u041f\u0440\u043e\u0441\u043b\u0435\u0434\u044f\u0432\u0430\u043d\u0435 \u043d\u0430 \u043d\u043e\u0432\u0438 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/.translations/ca.json b/homeassistant/components/huawei_lte/.translations/ca.json new file mode 100644 index 000000000..b213da018 --- /dev/null +++ b/homeassistant/components/huawei_lte/.translations/ca.json @@ -0,0 +1,42 @@ +{ + "config": { + "abort": { + "already_configured": "Aquest dispositiu ja est\u00e0 configurat", + "already_in_progress": "Aquest dispositiu ja s'est\u00e0 configurant", + "not_huawei_lte": "No \u00e9s un dispositiu Huawei LTE" + }, + "error": { + "connection_failed": "La connexi\u00f3 ha fallat", + "connection_timeout": "S'ha acabat el temps d'espera de la connexi\u00f3", + "incorrect_password": "Contrasenya incorrecta", + "incorrect_username": "Nom d'usuari incorrecte", + "incorrect_username_or_password": "Nom d'usuari o contrasenya incorrectes", + "invalid_url": "URL inv\u00e0lid", + "login_attempts_exceeded": "Nombre m\u00e0xim d'intents d'inici de sessi\u00f3 superat, torna-ho a provar m\u00e9s tard", + "response_error": "S'ha produ\u00eft un error desconegut del dispositiu", + "unknown_connection_error": "S'ha produ\u00eft un error desconegut en connectar-se al dispositiu" + }, + "step": { + "user": { + "data": { + "password": "Contrasenya", + "url": "URL", + "username": "Nom d'usuari" + }, + "description": "Introdueix les dades d\u2019acc\u00e9s del dispositiu. El nom d\u2019usuari i contrasenya s\u00f3n opcionals, per\u00f2 habiliten m\u00e9s funcions de la integraci\u00f3. D'altra banda, (mentre la integraci\u00f3 estigui activa) l'\u00fas d'una connexi\u00f3 autoritzada pot causar problemes per accedir a la interf\u00edcie web del dispositiu des de fora de Home Assistant i viceversa.", + "title": "Con de Huawei LTE" + } + }, + "title": "Huawei LTE" + }, + "options": { + "step": { + "init": { + "data": { + "recipient": "Destinataris de notificacions SMS", + "track_new_devices": "Segueix dispositius nous" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/.translations/cs.json b/homeassistant/components/huawei_lte/.translations/cs.json new file mode 100644 index 000000000..8d7ac01c5 --- /dev/null +++ b/homeassistant/components/huawei_lte/.translations/cs.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "Toto za\u0159\u00edzen\u00ed je ji\u017e nakonfigurov\u00e1no" + }, + "error": { + "connection_failed": "P\u0159ipojen\u00ed se nezda\u0159ilo", + "incorrect_password": "Nespr\u00e1vn\u00e9 heslo", + "incorrect_username": "Nespr\u00e1vn\u00e9 u\u017eivatelsk\u00e9 jm\u00e9no", + "incorrect_username_or_password": "Nespr\u00e1vn\u00e9 u\u017eivatelsk\u00e9 jm\u00e9no \u010di heslo", + "invalid_url": "Neplatn\u00e1 adresa URL", + "login_attempts_exceeded": "Maxim\u00e1ln\u00ed pokus o p\u0159ihl\u00e1\u0161en\u00ed byl p\u0159ekro\u010den, zkuste to znovu pozd\u011bji", + "response_error": "Nezn\u00e1m\u00e1 chyba ze za\u0159\u00edzen\u00ed", + "unknown_connection_error": "Nezn\u00e1m\u00e1 chyba p\u0159i p\u0159ipojov\u00e1n\u00ed k za\u0159\u00edzen\u00ed" + }, + "step": { + "user": { + "data": { + "password": "Heslo", + "url": "URL", + "username": "U\u017eivatelsk\u00e9 jm\u00e9no" + }, + "title": "Konfigurovat Huawei LTE" + } + }, + "title": "Huawei LTE" + }, + "options": { + "step": { + "init": { + "data": { + "recipient": "P\u0159\u00edjemci ozn\u00e1men\u00ed SMS", + "track_new_devices": "Sledovat nov\u00e1 za\u0159\u00edzen\u00ed" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/.translations/de.json b/homeassistant/components/huawei_lte/.translations/de.json new file mode 100644 index 000000000..c3f4025b8 --- /dev/null +++ b/homeassistant/components/huawei_lte/.translations/de.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Dieses Ger\u00e4t wurde bereits konfiguriert", + "already_in_progress": "Dieses Ger\u00e4t wurde bereits konfiguriert" + }, + "error": { + "connection_failed": "Verbindung fehlgeschlagen.", + "connection_timeout": "Verbindungszeit\u00fcberschreitung", + "incorrect_password": "Ung\u00fcltiges Passwort", + "incorrect_username": "Ung\u00fcltiger Benutzername", + "incorrect_username_or_password": "Ung\u00fcltiger Benutzername oder Kennwort", + "invalid_url": "Ung\u00fcltige URL" + }, + "step": { + "user": { + "data": { + "password": "Passwort", + "url": "URL", + "username": "Benutzername" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "recipient": "SMS-Benachrichtigungsempf\u00e4nger" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/.translations/en.json b/homeassistant/components/huawei_lte/.translations/en.json new file mode 100644 index 000000000..52aaafe59 --- /dev/null +++ b/homeassistant/components/huawei_lte/.translations/en.json @@ -0,0 +1,42 @@ +{ + "config": { + "abort": { + "already_configured": "This device has already been configured", + "already_in_progress": "This device is already being configured", + "not_huawei_lte": "Not a Huawei LTE device" + }, + "error": { + "connection_failed": "Connection failed", + "connection_timeout": "Connection timeout", + "incorrect_password": "Incorrect password", + "incorrect_username": "Incorrect username", + "incorrect_username_or_password": "Incorrect username or password", + "invalid_url": "Invalid URL", + "login_attempts_exceeded": "Maximum login attempts exceeded, please try again later", + "response_error": "Unknown error from device", + "unknown_connection_error": "Unknown error connecting to device" + }, + "step": { + "user": { + "data": { + "password": "Password", + "url": "URL", + "username": "User name" + }, + "description": "Enter device access details. Specifying username and password is optional, but enables support for more integration features. On the other hand, use of an authorized connection may cause problems accessing the device web interface from outside Home Assistant while the integration is active, and the other way around.", + "title": "Configure Huawei LTE" + } + }, + "title": "Huawei LTE" + }, + "options": { + "step": { + "init": { + "data": { + "recipient": "SMS notification recipients", + "track_new_devices": "Track new devices" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/.translations/es.json b/homeassistant/components/huawei_lte/.translations/es.json new file mode 100644 index 000000000..92ccf8fc0 --- /dev/null +++ b/homeassistant/components/huawei_lte/.translations/es.json @@ -0,0 +1,42 @@ +{ + "config": { + "abort": { + "already_configured": "Este dispositivo ya ha sido configurado", + "already_in_progress": "Este dispositivo ya se est\u00e1 configurando", + "not_huawei_lte": "No es un dispositivo Huawei LTE" + }, + "error": { + "connection_failed": "Fallo de conexi\u00f3n", + "connection_timeout": "Tiempo de espera de la conexi\u00f3n superado", + "incorrect_password": "Contrase\u00f1a incorrecta", + "incorrect_username": "Nombre de usuario incorrecto", + "incorrect_username_or_password": "Nombre de usuario o contrase\u00f1a incorrectos", + "invalid_url": "URL no v\u00e1lida", + "login_attempts_exceeded": "Se han superado los intentos de inicio de sesi\u00f3n m\u00e1ximos, int\u00e9ntelo de nuevo m\u00e1s tarde.", + "response_error": "Error desconocido del dispositivo", + "unknown_connection_error": "Error desconocido al conectarse al dispositivo" + }, + "step": { + "user": { + "data": { + "password": "Contrase\u00f1a", + "url": "URL", + "username": "Nombre de usuario" + }, + "description": "Introduzca los detalles de acceso al dispositivo. La especificaci\u00f3n del nombre de usuario y la contrase\u00f1a es opcional, pero permite admitir m\u00e1s funciones de integraci\u00f3n. Por otro lado, el uso de una conexi\u00f3n autorizada puede causar problemas para acceder a la interfaz web del dispositivo desde fuera de Home Assistant mientras la integraci\u00f3n est\u00e1 activa, y viceversa.", + "title": "Configurar Huawei LTE" + } + }, + "title": "Huawei LTE" + }, + "options": { + "step": { + "init": { + "data": { + "recipient": "Destinatarios de notificaciones por SMS", + "track_new_devices": "Rastrea nuevos dispositivos" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/.translations/fr.json b/homeassistant/components/huawei_lte/.translations/fr.json new file mode 100644 index 000000000..34db4e93b --- /dev/null +++ b/homeassistant/components/huawei_lte/.translations/fr.json @@ -0,0 +1,42 @@ +{ + "config": { + "abort": { + "already_configured": "Cet appareil est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "Ce p\u00e9riph\u00e9rique est d\u00e9j\u00e0 en cours de configuration", + "not_huawei_lte": "Pas un appareil Huawei LTE" + }, + "error": { + "connection_failed": "La connexion a \u00e9chou\u00e9", + "connection_timeout": "D\u00e9lai de connexion d\u00e9pass\u00e9", + "incorrect_password": "Mot de passe incorrect", + "incorrect_username": "Nom d'utilisateur incorrect", + "incorrect_username_or_password": "identifiant ou mot de passe incorrect", + "invalid_url": "URL invalide", + "login_attempts_exceeded": "Nombre maximal de tentatives de connexion d\u00e9pass\u00e9, veuillez r\u00e9essayer ult\u00e9rieurement", + "response_error": "Erreur inconnue de l'appareil", + "unknown_connection_error": "Erreur inconnue lors de la connexion \u00e0 l'appareil" + }, + "step": { + "user": { + "data": { + "password": "Mot de passe", + "url": "URL", + "username": "Nom d'utilisateur" + }, + "description": "Entrez les d\u00e9tails d'acc\u00e8s au p\u00e9riph\u00e9rique. La sp\u00e9cification du nom d'utilisateur et du mot de passe est facultative, mais permet de prendre en charge davantage de fonctionnalit\u00e9s d'int\u00e9gration. En revanche, l\u2019utilisation d\u2019une connexion autoris\u00e9e peut entra\u00eener des probl\u00e8mes d\u2019acc\u00e8s \u00e0 l\u2019interface Web du p\u00e9riph\u00e9rique depuis l\u2019assistant externe lorsque l\u2019int\u00e9gration est active et inversement.", + "title": "Configurer Huawei LTE" + } + }, + "title": "Huawei LTE" + }, + "options": { + "step": { + "init": { + "data": { + "recipient": "Destinataires des notifications SMS", + "track_new_devices": "Suivre les nouveaux appareils" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/.translations/it.json b/homeassistant/components/huawei_lte/.translations/it.json new file mode 100644 index 000000000..bcbae3b1b --- /dev/null +++ b/homeassistant/components/huawei_lte/.translations/it.json @@ -0,0 +1,42 @@ +{ + "config": { + "abort": { + "already_configured": "Questo dispositivo \u00e8 gi\u00e0 stato configurato", + "already_in_progress": "Questo dispositivo \u00e8 gi\u00e0 in fase di configurazione", + "not_huawei_lte": "Non \u00e8 un dispositivo Huawei LTE" + }, + "error": { + "connection_failed": "Connessione fallita", + "connection_timeout": "Timeout di connessione", + "incorrect_password": "Password errata", + "incorrect_username": "Nome utente errato", + "incorrect_username_or_password": "Nome utente o password errati", + "invalid_url": "URL non valido", + "login_attempts_exceeded": "Superati i tentativi di accesso massimi, riprovare pi\u00f9 tardi", + "response_error": "Errore sconosciuto dal dispositivo", + "unknown_connection_error": "Errore sconosciuto durante la connessione al dispositivo" + }, + "step": { + "user": { + "data": { + "password": "Password", + "url": "URL", + "username": "Nome utente" + }, + "description": "Immettere i dettagli di accesso al dispositivo. La specifica di nome utente e password \u00e8 facoltativa, ma abilita il supporto per altre funzionalit\u00e0 di integrazione. D'altra parte, l'uso di una connessione autorizzata pu\u00f2 causare problemi di accesso all'interfaccia Web del dispositivo dall'esterno di Home Assistant mentre l'integrazione \u00e8 attiva e viceversa.", + "title": "Configura Huawei LTE" + } + }, + "title": "Huawei LTE" + }, + "options": { + "step": { + "init": { + "data": { + "recipient": "Destinatari della notifica SMS", + "track_new_devices": "Traccia nuovi dispositivi" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/.translations/ko.json b/homeassistant/components/huawei_lte/.translations/ko.json new file mode 100644 index 000000000..a9ac8d7f6 --- /dev/null +++ b/homeassistant/components/huawei_lte/.translations/ko.json @@ -0,0 +1,42 @@ +{ + "config": { + "abort": { + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "already_in_progress": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "not_huawei_lte": "\ud654\uc6e8\uc774 LTE \uae30\uae30\uac00 \uc544\ub2d8" + }, + "error": { + "connection_failed": "\uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", + "connection_timeout": "\uc811\uc18d \uc2dc\uac04 \ucd08\uacfc", + "incorrect_password": "\ube44\ubc00\ubc88\ud638\uac00 \uc77c\uce58\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4", + "incorrect_username": "\uc0ac\uc6a9\uc790 \uc774\ub984\uc774 \uc77c\uce58\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4", + "incorrect_username_or_password": "\uc0ac\uc6a9\uc790 \uc774\ub984 \ub610\ub294 \ube44\ubc00\ubc88\ud638\uac00 \uc77c\uce58\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4", + "invalid_url": "URL \uc8fc\uc18c\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "login_attempts_exceeded": "\ucd5c\ub300 \ub85c\uadf8\uc778 \uc2dc\ub3c4 \ud69f\uc218\ub97c \ucd08\uacfc\ud588\uc2b5\ub2c8\ub2e4. \ub098\uc911\uc5d0 \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694", + "response_error": "\uae30\uae30\uc5d0\uc11c \uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4", + "unknown_connection_error": "\uae30\uae30\uc5d0 \uc5f0\uacb0\ud558\ub294 \uc911 \uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "password": "\ube44\ubc00\ubc88\ud638", + "url": "URL \uc8fc\uc18c", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" + }, + "description": "\uae30\uae30 \uc561\uc138\uc2a4 \uc138\ubd80 \uc0ac\ud56d\uc744 \uc785\ub825\ud574\uc8fc\uc138\uc694. \uc0ac\uc6a9\uc790 \uc774\ub984\uacfc \ube44\ubc00\ubc88\ud638\ub97c \uc124\uc815\ud558\ub294 \uac83\uc740 \uc120\ud0dd \uc0ac\ud56d\uc774\uc9c0\ub9cc \ub354 \ub9ce\uc740 \uae30\ub2a5\uc744 \uc9c0\uc6d0\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \ubc18\uba74, \uc778\uc99d \ub41c \uc5f0\uacb0\uc744 \uc0ac\uc6a9\ud558\uba74, \ud1b5\ud569 \uad6c\uc131\uc694\uc18c\uac00 \ud65c\uc131\ud654 \ub41c \uc0c1\ud0dc\uc5d0\uc11c \ub2e4\ub978 \ubc29\ubc95\uc73c\ub85c Home Assistant \uc758 \uc678\ubd80\uc5d0\uc11c \uae30\uae30\uc758 \uc6f9 \uc778\ud130\ud398\uc774\uc2a4\uc5d0 \uc561\uc138\uc2a4\ud558\ub294 \ub370 \ubb38\uc81c\uac00 \ubc1c\uc0dd\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.", + "title": "Huawei LTE \uc124\uc815" + } + }, + "title": "Huawei LTE" + }, + "options": { + "step": { + "init": { + "data": { + "recipient": "SMS \uc54c\ub9bc \uc218\uc2e0\uc790", + "track_new_devices": "\uc0c8\ub85c\uc6b4 \uae30\uae30 \ucd94\uc801" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/.translations/lb.json b/homeassistant/components/huawei_lte/.translations/lb.json new file mode 100644 index 000000000..3c8f0464a --- /dev/null +++ b/homeassistant/components/huawei_lte/.translations/lb.json @@ -0,0 +1,42 @@ +{ + "config": { + "abort": { + "already_configured": "D\u00ebsen Apparat ass scho konfigur\u00e9iert", + "already_in_progress": "D\u00ebsen Apparat g\u00ebtt scho konfigur\u00e9iert", + "not_huawei_lte": "Ken Huawei LTE Apparat" + }, + "error": { + "connection_failed": "Feeler bei der Verbindung", + "connection_timeout": "Z\u00e4it Iwwerschreidung beim verbannen", + "incorrect_password": "Ong\u00ebltegt Passwuert", + "incorrect_username": "Ong\u00ebltege Benotzernumm", + "incorrect_username_or_password": "Ong\u00ebltege Benotzernumm oder Passwuert", + "invalid_url": "Ong\u00eblteg URL", + "login_attempts_exceeded": "Maximal Login Versich iwwerschratt, w.e.g. m\u00e9i sp\u00e9it nach eng K\u00e9ier", + "response_error": "Onbekannte Feeler vum Apparat", + "unknown_connection_error": "Onbekannte Feeler beim verbannen mam Apparat" + }, + "step": { + "user": { + "data": { + "password": "Passwuert", + "url": "URL", + "username": "Benotzernumm" + }, + "description": "Gitt Detailer fir den Acc\u00e8s op den Apparat an. Benotzernumm a Passwuert si fakultativ, erm\u00e9iglecht awer d'\u00cbnnerst\u00ebtzung fir m\u00e9i Integratiouns Optiounen. Op der anerer S\u00e4it kann d'Benotzung vun enger autoris\u00e9ierter Verbindung Problemer mam Acc\u00e8s zum Web Interface vum Apparat ausserhalb vum Home Assistant verursaachen, w\u00e4rend d'Integratioun aktiv ass.", + "title": "Huawei LTE ariichten" + } + }, + "title": "Huawei LTE" + }, + "options": { + "step": { + "init": { + "data": { + "recipient": "Empf\u00e4nger vun SMS Notifikatioune", + "track_new_devices": "Nei Apparater verfollegen" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/.translations/nl.json b/homeassistant/components/huawei_lte/.translations/nl.json new file mode 100644 index 000000000..6d5e5c3e9 --- /dev/null +++ b/homeassistant/components/huawei_lte/.translations/nl.json @@ -0,0 +1,42 @@ +{ + "config": { + "abort": { + "already_configured": "Dit apparaat is reeds geconfigureerd", + "already_in_progress": "Dit apparaat wordt al geconfigureerd", + "not_huawei_lte": "Geen Huawei LTE-apparaat" + }, + "error": { + "connection_failed": "Verbinding mislukt", + "connection_timeout": "Time-out van de verbinding", + "incorrect_password": "Onjuist wachtwoord", + "incorrect_username": "Onjuiste gebruikersnaam", + "incorrect_username_or_password": "Onjuiste gebruikersnaam of wachtwoord", + "invalid_url": "Ongeldige URL", + "login_attempts_exceeded": "Maximale aanmeldingspogingen overschreden, probeer het later opnieuw.", + "response_error": "Onbekende fout van het apparaat", + "unknown_connection_error": "Onbekende fout bij verbinden met apparaat" + }, + "step": { + "user": { + "data": { + "password": "Wachtwoord", + "url": "URL", + "username": "Gebruikersnaam" + }, + "description": "Voer de toegangsgegevens van het apparaat in. Opgeven van gebruikersnaam en wachtwoord is optioneel, maar biedt ondersteuning voor meer integratiefuncties. Aan de andere kant kan het gebruik van een geautoriseerde verbinding problemen veroorzaken bij het openen van het webinterface van het apparaat buiten de Home Assitant, terwijl de integratie actief is en andersom.", + "title": "Configureer Huawei LTE" + } + }, + "title": "Huawei LTE" + }, + "options": { + "step": { + "init": { + "data": { + "recipient": "Ontvangers van sms-berichten", + "track_new_devices": "Volg nieuwe apparaten" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/.translations/nn.json b/homeassistant/components/huawei_lte/.translations/nn.json new file mode 100644 index 000000000..1a5c63f10 --- /dev/null +++ b/homeassistant/components/huawei_lte/.translations/nn.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Huawei LTE" + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/.translations/no.json b/homeassistant/components/huawei_lte/.translations/no.json new file mode 100644 index 000000000..35a5d531c --- /dev/null +++ b/homeassistant/components/huawei_lte/.translations/no.json @@ -0,0 +1,42 @@ +{ + "config": { + "abort": { + "already_configured": "Denne enheten er allerede konfigurert", + "already_in_progress": "Denne enheten blir allerede konfigurert", + "not_huawei_lte": "Ikke en Huawei LTE-enhet" + }, + "error": { + "connection_failed": "Tilkoblingen mislyktes", + "connection_timeout": "Tilkoblingsavbrudd", + "incorrect_password": "feil passord", + "incorrect_username": "Feil brukernavn", + "incorrect_username_or_password": "Feil brukernavn eller passord", + "invalid_url": "Ugyldig URL-adresse", + "login_attempts_exceeded": "Maksimalt antall p\u00e5loggingsfors\u00f8k er overskredet, vennligst pr\u00f8v igjen senere", + "response_error": "Ukjent feil fra enheten", + "unknown_connection_error": "Ukjent feil under tilkobling til enhet" + }, + "step": { + "user": { + "data": { + "password": "Passord", + "url": "URL", + "username": "Brukernavn" + }, + "description": "Angi detaljer for enhetstilgang. Angivelse av brukernavn og passord er valgfritt, men gir st\u00f8tte for flere integreringsfunksjoner. P\u00e5 den annen side kan bruk av en autorisert tilkobling f\u00f8re til problemer med tilgang til enhetens webgrensesnitt utenfor Home Assistant mens integreringen er aktiv, og omvendt.", + "title": "Konfigurer Huawei LTE" + } + }, + "title": "Huawei LTE" + }, + "options": { + "step": { + "init": { + "data": { + "recipient": "Mottakere av SMS-varsling", + "track_new_devices": "Spor nye enheter" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/.translations/pl.json b/homeassistant/components/huawei_lte/.translations/pl.json new file mode 100644 index 000000000..3851d0a40 --- /dev/null +++ b/homeassistant/components/huawei_lte/.translations/pl.json @@ -0,0 +1,42 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", + "already_in_progress": "Urz\u0105dzenie jest ju\u017c skonfigurowane", + "not_huawei_lte": "To nie jest urz\u0105dzenie Huawei LTE" + }, + "error": { + "connection_failed": "Po\u0142\u0105czenie nie powiod\u0142o si\u0119", + "connection_timeout": "Przekroczono limit czasu pr\u00f3by po\u0142\u0105czenia.", + "incorrect_password": "Nieprawid\u0142owe has\u0142o", + "incorrect_username": "Nieprawid\u0142owa nazwa u\u017cytkownika", + "incorrect_username_or_password": "Nieprawid\u0142owa nazwa u\u017cytkownika lub has\u0142o", + "invalid_url": "Nieprawid\u0142owy URL", + "login_attempts_exceeded": "Przekroczono maksymaln\u0105 liczb\u0119 pr\u00f3b logowania. Spr\u00f3buj ponownie p\u00f3\u017aniej.", + "response_error": "Wyst\u0105pi\u0142 nieznany b\u0142\u0105d w urz\u0105dzeniu.", + "unknown_connection_error": "Nieznany b\u0142\u0105d podczas \u0142\u0105czenia z urz\u0105dzeniem" + }, + "step": { + "user": { + "data": { + "password": "Has\u0142o", + "url": "URL", + "username": "Nazwa u\u017cytkownika" + }, + "description": "Wprowad\u017a szczeg\u00f3\u0142y dost\u0119pu do urz\u0105dzenia. Okre\u015blenie nazwy u\u017cytkownika i has\u0142a jest opcjonalne, ale umo\u017cliwia obs\u0142ug\u0119 wi\u0119kszej liczby funkcji integracji. Z drugiej strony u\u017cycie autoryzowanego po\u0142\u0105czenia mo\u017ce powodowa\u0107 problemy z dost\u0119pem do interfejsu internetowego urz\u0105dzenia z zewn\u0105trz Home Assistant'a gdy integracja jest aktywna.", + "title": "Konfiguracja Huawei LTE" + } + }, + "title": "Huawei LTE" + }, + "options": { + "step": { + "init": { + "data": { + "recipient": "Odbiorcy powiadomie\u0144 SMS", + "track_new_devices": "\u015aled\u017a nowe urz\u0105dzenia" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/.translations/pt.json b/homeassistant/components/huawei_lte/.translations/pt.json new file mode 100644 index 000000000..6e3a06ac6 --- /dev/null +++ b/homeassistant/components/huawei_lte/.translations/pt.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Este dispositivo j\u00e1 foi configurado", + "already_in_progress": "Este dispositivo j\u00e1 est\u00e1 a ser configurado" + }, + "error": { + "incorrect_password": "Palavra-passe incorreta", + "incorrect_username": "Nome de Utilizador incorreto", + "incorrect_username_or_password": "Nome de utilizador ou palavra passe incorretos" + }, + "step": { + "user": { + "data": { + "password": "Palavra-passe", + "url": "", + "username": "Utilizador" + }, + "title": "Configurar o Huawei LTE" + } + }, + "title": "" + }, + "options": { + "step": { + "init": { + "data": { + "recipient": "Destinat\u00e1rios de notifica\u00e7\u00e3o por SMS", + "track_new_devices": "Seguir novos dispositivos" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/.translations/ru.json b/homeassistant/components/huawei_lte/.translations/ru.json new file mode 100644 index 000000000..ec28325dc --- /dev/null +++ b/homeassistant/components/huawei_lte/.translations/ru.json @@ -0,0 +1,42 @@ +{ + "config": { + "abort": { + "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.", + "already_in_progress": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.", + "not_huawei_lte": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u044f\u0432\u043b\u044f\u0435\u0442\u0441\u044f \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c Huawei LTE" + }, + "error": { + "connection_failed": "\u041e\u0448\u0438\u0431\u043a\u0430 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f.", + "connection_timeout": "\u0418\u0441\u0442\u0435\u043a\u043b\u043e \u0432\u0440\u0435\u043c\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f.", + "incorrect_password": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043f\u0430\u0440\u043e\u043b\u044c.", + "incorrect_username": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043b\u043e\u0433\u0438\u043d.", + "incorrect_username_or_password": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043b\u043e\u0433\u0438\u043d \u0438\u043b\u0438 \u043f\u0430\u0440\u043e\u043b\u044c.", + "invalid_url": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 URL-\u0430\u0434\u0440\u0435\u0441.", + "login_attempts_exceeded": "\u041f\u0440\u0435\u0432\u044b\u0448\u0435\u043d\u043e \u043c\u0430\u043a\u0441\u0438\u043c\u0430\u043b\u044c\u043d\u043e\u0435 \u043a\u043e\u043b\u0438\u0447\u0435\u0441\u0442\u0432\u043e \u043f\u043e\u043f\u044b\u0442\u043e\u043a \u0432\u0445\u043e\u0434\u0430, \u043f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u0435 \u043f\u043e\u043f\u044b\u0442\u043a\u0443 \u043f\u043e\u0437\u0436\u0435.", + "response_error": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430.", + "unknown_connection_error": "\u041e\u0448\u0438\u0431\u043a\u0430 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f \u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443." + }, + "step": { + "user": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "url": "URL-\u0430\u0434\u0440\u0435\u0441", + "username": "\u041b\u043e\u0433\u0438\u043d" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 \u0434\u043b\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f \u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443. \u0423\u043a\u0430\u0437\u044b\u0432\u0430\u0442\u044c \u043b\u043e\u0433\u0438\u043d \u0438 \u043f\u0430\u0440\u043e\u043b\u044c \u043d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e, \u043d\u043e \u044d\u0442\u043e \u043f\u043e\u0437\u0432\u043e\u043b\u0438\u0442 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u0434\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0435 \u0444\u0443\u043d\u043a\u0446\u0438\u0438. \u0421 \u0434\u0440\u0443\u0433\u043e\u0439 \u0441\u0442\u043e\u0440\u043e\u043d\u044b, \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u043e\u0432\u0430\u043d\u043d\u043e\u0433\u043e \u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f \u043c\u043e\u0436\u0435\u0442 \u0432\u044b\u0437\u0432\u0430\u0442\u044c \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u044b \u0441 \u0434\u043e\u0441\u0442\u0443\u043f\u043e\u043c \u043a \u0432\u0435\u0431-\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0443 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u043d\u0435 \u0438\u0437 Home Assistant, \u043a\u043e\u0433\u0434\u0430 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f \u0430\u043a\u0442\u0438\u0432\u043d\u0430, \u0438 \u043d\u0430\u043e\u0431\u043e\u0440\u043e\u0442.", + "title": "Huawei LTE" + } + }, + "title": "Huawei LTE" + }, + "options": { + "step": { + "init": { + "data": { + "recipient": "\u041f\u043e\u043b\u0443\u0447\u0430\u0442\u0435\u043b\u0438 SMS-\u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u0439", + "track_new_devices": "\u041e\u0442\u0441\u043b\u0435\u0436\u0438\u0432\u0430\u0442\u044c \u043d\u043e\u0432\u044b\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/.translations/sl.json b/homeassistant/components/huawei_lte/.translations/sl.json new file mode 100644 index 000000000..5022e358c --- /dev/null +++ b/homeassistant/components/huawei_lte/.translations/sl.json @@ -0,0 +1,42 @@ +{ + "config": { + "abort": { + "already_configured": "Ta naprava je \u017ee konfigurirana", + "already_in_progress": "Ta naprava se \u017ee nastavlja", + "not_huawei_lte": "Ni naprava Huawei LTE" + }, + "error": { + "connection_failed": "Povezava ni uspela", + "connection_timeout": "\u010casovna omejitev povezave", + "incorrect_password": "Nepravilno geslo", + "incorrect_username": "Nepravilno uporabni\u0161ko ime", + "incorrect_username_or_password": "Nepravilno uporabni\u0161ko ime ali geslo", + "invalid_url": "Neveljaven URL", + "login_attempts_exceeded": "Najve\u010d poskusov prijave prese\u017eeno, prosimo, poskusite znova pozneje", + "response_error": "Neznana napaka iz naprave", + "unknown_connection_error": "Neznana napaka pri povezovanju z napravo" + }, + "step": { + "user": { + "data": { + "password": "Geslo", + "url": "URL", + "username": "Uporabni\u0161ko ime" + }, + "description": "Vnesite podatke za dostop do naprave. Dolo\u010danje uporabni\u0161kega imena in gesla je izbirno, vendar omogo\u010da podporo za ve\u010d funkcij integracije. Po drugi strani pa lahko uporaba poobla\u0161\u010dene povezave povzro\u010di te\u017eave pri dostopu do spletnega vmesnika naprave zunaj Home Assistant-a, medtem ko je integracija aktivna, in obratno.", + "title": "Konfigurirajte Huawei LTE" + } + }, + "title": "Huawei LTE" + }, + "options": { + "step": { + "init": { + "data": { + "recipient": "Prejemniki obvestil SMS", + "track_new_devices": "Sledi novim napravam" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/.translations/zh-Hant.json b/homeassistant/components/huawei_lte/.translations/zh-Hant.json new file mode 100644 index 000000000..37f1111b7 --- /dev/null +++ b/homeassistant/components/huawei_lte/.translations/zh-Hant.json @@ -0,0 +1,42 @@ +{ + "config": { + "abort": { + "already_configured": "\u6b64\u8a2d\u5099\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "already_in_progress": "\u6b64\u8a2d\u5099\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "not_huawei_lte": "\u4e26\u975e\u83ef\u70ba LTE \u8a2d\u5099" + }, + "error": { + "connection_failed": "\u9023\u7dda\u5931\u6557", + "connection_timeout": "\u9023\u7dda\u903e\u6642", + "incorrect_password": "\u5bc6\u78bc\u932f\u8aa4", + "incorrect_username": "\u4f7f\u7528\u8005\u540d\u7a31\u932f\u8aa4", + "incorrect_username_or_password": "\u4f7f\u7528\u8005\u540d\u7a31\u6216\u5bc6\u78bc\u932f\u8aa4", + "invalid_url": "\u7db2\u5740\u7121\u6548", + "login_attempts_exceeded": "\u5df2\u9054\u5617\u8a66\u767b\u5165\u6700\u5927\u6b21\u6578\uff0c\u8acb\u7a0d\u5f8c\u518d\u8a66", + "response_error": "\u4f86\u81ea\u8a2d\u5099\u672a\u77e5\u932f\u8aa4", + "unknown_connection_error": "\u9023\u7dda\u81f3\u8a2d\u5099\u672a\u77e5\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "password": "\u5bc6\u78bc", + "url": "\u7db2\u5740", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + }, + "description": "\u8f38\u5165\u8a2d\u5099\u5b58\u53d6\u8a73\u7d30\u8cc7\u6599\u3002\u6307\u5b9a\u4f7f\u7528\u8005\u540d\u7a31\u8207\u5bc6\u78bc\u70ba\u9078\u9805\u8f38\u5165\uff0c\u4f46\u958b\u555f\u5c07\u652f\u63f4\u66f4\u591a\u6574\u5408\u529f\u80fd\u3002\u6b64\u5916\uff0c\u4f7f\u7528\u6388\u6b0a\u9023\u7dda\uff0c\u53ef\u80fd\u5c0e\u81f4\u6574\u5408\u555f\u7528\u5f8c\uff0c\u7531\u5916\u90e8\u9023\u7dda\u81f3 Home Assistant \u8a2d\u5099 Web \u4ecb\u9762\u51fa\u73fe\u67d0\u4e9b\u554f\u984c\uff0c\u53cd\u4e4b\u4ea6\u7136\u3002", + "title": "\u8a2d\u5b9a\u83ef\u70ba LTE" + } + }, + "title": "\u83ef\u70ba LTE" + }, + "options": { + "step": { + "init": { + "data": { + "recipient": "\u7c21\u8a0a\u901a\u77e5\u6536\u4ef6\u8005", + "track_new_devices": "\u8ffd\u8e64\u65b0\u8a2d\u5099" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py new file mode 100644 index 000000000..531529b17 --- /dev/null +++ b/homeassistant/components/huawei_lte/__init__.py @@ -0,0 +1,542 @@ +"""Support for Huawei LTE routers.""" + +from collections import defaultdict +from datetime import timedelta +from functools import partial +import ipaddress +import logging +from typing import Any, Callable, Dict, List, Set, Tuple +from urllib.parse import urlparse + +import attr +from getmac import get_mac_address +from huawei_lte_api.AuthorizedConnection import AuthorizedConnection +from huawei_lte_api.Client import Client +from huawei_lte_api.Connection import Connection +from huawei_lte_api.exceptions import ( + ResponseErrorLoginRequiredException, + ResponseErrorNotSupportedException, +) +from requests.exceptions import Timeout +from url_normalize import url_normalize +import voluptuous as vol + +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN +from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import ( + CONF_PASSWORD, + CONF_RECIPIENT, + CONF_URL, + CONF_USERNAME, + EVENT_HOMEASSISTANT_STOP, +) +from homeassistant.core import CALLBACK_TYPE +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + discovery, +) +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, + dispatcher_send, +) +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.typing import HomeAssistantType + +from .const import ( + ALL_KEYS, + CONNECTION_TIMEOUT, + DEFAULT_DEVICE_NAME, + DOMAIN, + KEY_DEVICE_BASIC_INFORMATION, + KEY_DEVICE_INFORMATION, + KEY_DEVICE_SIGNAL, + KEY_DIALUP_MOBILE_DATASWITCH, + KEY_MONITORING_STATUS, + KEY_MONITORING_TRAFFIC_STATISTICS, + KEY_WLAN_HOST_LIST, + SERVICE_CLEAR_TRAFFIC_STATISTICS, + SERVICE_REBOOT, + UPDATE_OPTIONS_SIGNAL, + UPDATE_SIGNAL, +) + +_LOGGER = logging.getLogger(__name__) + +# dicttoxml (used by huawei-lte-api) has uselessly verbose INFO level. +# https://github.com/quandyfactory/dicttoxml/issues/60 +logging.getLogger("dicttoxml").setLevel(logging.WARNING) + +DEFAULT_NAME_TEMPLATE = "Huawei {} {}" + +SCAN_INTERVAL = timedelta(seconds=10) + +NOTIFY_SCHEMA = vol.Any( + None, + vol.Schema( + { + vol.Optional(CONF_RECIPIENT): vol.Any( + None, vol.All(cv.ensure_list, [cv.string]) + ) + } + ), +) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.All( + cv.ensure_list, + [ + vol.Schema( + { + vol.Required(CONF_URL): cv.url, + vol.Optional(CONF_USERNAME): cv.string, + vol.Optional(CONF_PASSWORD): cv.string, + vol.Optional(NOTIFY_DOMAIN): NOTIFY_SCHEMA, + } + ) + ], + ) + }, + extra=vol.ALLOW_EXTRA, +) + +SERVICE_SCHEMA = vol.Schema({vol.Optional(CONF_URL): cv.url}) + +CONFIG_ENTRY_PLATFORMS = ( + BINARY_SENSOR_DOMAIN, + DEVICE_TRACKER_DOMAIN, + SENSOR_DOMAIN, + SWITCH_DOMAIN, +) + + +@attr.s +class Router: + """Class for router state.""" + + connection: Connection = attr.ib() + url: str = attr.ib() + mac: str = attr.ib() + signal_update: CALLBACK_TYPE = attr.ib() + + data: Dict[str, Any] = attr.ib(init=False, factory=dict) + subscriptions: Dict[str, Set[str]] = attr.ib( + init=False, + factory=lambda: defaultdict(set, ((x, {"initial_scan"}) for x in ALL_KEYS)), + ) + unload_handlers: List[CALLBACK_TYPE] = attr.ib(init=False, factory=list) + client: Client + + def __attrs_post_init__(self): + """Set up internal state on init.""" + self.client = Client(self.connection) + + @property + def device_name(self) -> str: + """Get router device name.""" + for key, item in ( + (KEY_DEVICE_BASIC_INFORMATION, "devicename"), + (KEY_DEVICE_INFORMATION, "DeviceName"), + ): + try: + return self.data[key][item] + except (KeyError, TypeError): + pass + return DEFAULT_DEVICE_NAME + + @property + def device_connections(self) -> Set[Tuple[str, str]]: + """Get router connections for device registry.""" + return {(dr.CONNECTION_NETWORK_MAC, self.mac)} if self.mac else set() + + def _get_data(self, key: str, func: Callable[[None], Any]) -> None: + if not self.subscriptions.get(key): + return + _LOGGER.debug("Getting %s for subscribers %s", key, self.subscriptions[key]) + try: + self.data[key] = func() + except ResponseErrorNotSupportedException: + _LOGGER.info( + "%s not supported by device, excluding from future updates", key + ) + self.subscriptions.pop(key) + except ResponseErrorLoginRequiredException: + if isinstance(self.connection, AuthorizedConnection): + _LOGGER.debug("Trying to authorize again...") + if self.connection.enforce_authorized_connection(): + _LOGGER.debug( + "...success, %s will be updated by a future periodic run", key, + ) + else: + _LOGGER.debug("...failed") + return + _LOGGER.info( + "%s requires authorization, excluding from future updates", key + ) + self.subscriptions.pop(key) + finally: + _LOGGER.debug("%s=%s", key, self.data.get(key)) + + def update(self) -> None: + """Update router data.""" + + self._get_data(KEY_DEVICE_INFORMATION, self.client.device.information) + if self.data.get(KEY_DEVICE_INFORMATION): + # Full information includes everything in basic + self.subscriptions.pop(KEY_DEVICE_BASIC_INFORMATION, None) + self._get_data( + KEY_DEVICE_BASIC_INFORMATION, self.client.device.basic_information + ) + self._get_data(KEY_DEVICE_SIGNAL, self.client.device.signal) + self._get_data( + KEY_DIALUP_MOBILE_DATASWITCH, self.client.dial_up.mobile_dataswitch + ) + self._get_data(KEY_MONITORING_STATUS, self.client.monitoring.status) + self._get_data( + KEY_MONITORING_TRAFFIC_STATISTICS, self.client.monitoring.traffic_statistics + ) + self._get_data(KEY_WLAN_HOST_LIST, self.client.wlan.host_list) + + self.signal_update() + + def cleanup(self, *_) -> None: + """Clean up resources.""" + + self.subscriptions.clear() + + for handler in self.unload_handlers: + handler() + self.unload_handlers.clear() + + if not isinstance(self.connection, AuthorizedConnection): + return + try: + self.client.user.logout() + except ResponseErrorNotSupportedException: + _LOGGER.debug("Logout not supported by device", exc_info=True) + except ResponseErrorLoginRequiredException: + _LOGGER.debug("Logout not supported when not logged in", exc_info=True) + except Exception: # pylint: disable=broad-except + _LOGGER.warning("Logout error", exc_info=True) + + +@attr.s +class HuaweiLteData: + """Shared state.""" + + hass_config: dict = attr.ib() + # Our YAML config, keyed by router URL + config: Dict[str, Dict[str, Any]] = attr.ib() + routers: Dict[str, Router] = attr.ib(init=False, factory=dict) + + +async def async_setup_entry(hass: HomeAssistantType, config_entry: ConfigEntry) -> bool: + """Set up Huawei LTE component from config entry.""" + url = config_entry.data[CONF_URL] + + # Override settings from YAML config, but only if they're changed in it + # Old values are stored as *_from_yaml in the config entry + yaml_config = hass.data[DOMAIN].config.get(url) + if yaml_config: + # Config values + new_data = {} + for key in CONF_USERNAME, CONF_PASSWORD: + if key in yaml_config: + value = yaml_config[key] + if value != config_entry.data.get(f"{key}_from_yaml"): + new_data[f"{key}_from_yaml"] = value + new_data[key] = value + # Options + new_options = {} + yaml_recipient = yaml_config.get(NOTIFY_DOMAIN, {}).get(CONF_RECIPIENT) + if yaml_recipient is not None and yaml_recipient != config_entry.options.get( + f"{CONF_RECIPIENT}_from_yaml" + ): + new_options[f"{CONF_RECIPIENT}_from_yaml"] = yaml_recipient + new_options[CONF_RECIPIENT] = yaml_recipient + # Update entry if overrides were found + if new_data or new_options: + hass.config_entries.async_update_entry( + config_entry, + data={**config_entry.data, **new_data}, + options={**config_entry.options, **new_options}, + ) + + # Get MAC address for use in unique ids. Being able to use something + # from the API would be nice, but all of that seems to be available only + # through authenticated calls (e.g. device_information.SerialNumber), and + # we want this available and the same when unauthenticated too. + host = urlparse(url).hostname + try: + if ipaddress.ip_address(host).version == 6: + mode = "ip6" + else: + mode = "ip" + except ValueError: + mode = "hostname" + mac = await hass.async_add_executor_job(partial(get_mac_address, **{mode: host})) + + def get_connection() -> Connection: + """ + Set up a connection. + + Authorized one if username/pass specified (even if empty), unauthorized one otherwise. + """ + username = config_entry.data.get(CONF_USERNAME) + password = config_entry.data.get(CONF_PASSWORD) + if username or password: + connection = AuthorizedConnection( + url, username=username, password=password, timeout=CONNECTION_TIMEOUT + ) + else: + connection = Connection(url, timeout=CONNECTION_TIMEOUT) + return connection + + def signal_update() -> None: + """Signal updates to data.""" + dispatcher_send(hass, UPDATE_SIGNAL, url) + + try: + connection = await hass.async_add_executor_job(get_connection) + except Timeout as ex: + raise ConfigEntryNotReady from ex + + # Set up router and store reference to it + router = Router(connection, url, mac, signal_update) + hass.data[DOMAIN].routers[url] = router + + # Do initial data update + await hass.async_add_executor_job(router.update) + + # Clear all subscriptions, enabled entities will push back theirs + router.subscriptions.clear() + + # Set up device registry + device_data = {} + sw_version = None + if router.data.get(KEY_DEVICE_INFORMATION): + device_info = router.data[KEY_DEVICE_INFORMATION] + serial_number = device_info.get("SerialNumber") + if serial_number: + device_data["identifiers"] = {(DOMAIN, serial_number)} + sw_version = device_info.get("SoftwareVersion") + if device_info.get("DeviceName"): + device_data["model"] = device_info["DeviceName"] + if not sw_version and router.data.get(KEY_DEVICE_BASIC_INFORMATION): + sw_version = router.data[KEY_DEVICE_BASIC_INFORMATION].get("SoftwareVersion") + if sw_version: + device_data["sw_version"] = sw_version + device_registry = await dr.async_get_registry(hass) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections=router.device_connections, + name=router.device_name, + manufacturer="Huawei", + **device_data, + ) + + # Forward config entry setup to platforms + for domain in CONFIG_ENTRY_PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, domain) + ) + # Notify doesn't support config entry setup yet, load with discovery for now + await discovery.async_load_platform( + hass, + NOTIFY_DOMAIN, + DOMAIN, + {CONF_URL: url, CONF_RECIPIENT: config_entry.options.get(CONF_RECIPIENT)}, + hass.data[DOMAIN].hass_config, + ) + + # Add config entry options update listener + router.unload_handlers.append( + config_entry.add_update_listener(async_signal_options_update) + ) + + def _update_router(*_: Any) -> None: + """ + Update router data. + + Separate passthrough function because lambdas don't work with track_time_interval. + """ + router.update() + + # Set up periodic update + router.unload_handlers.append( + async_track_time_interval(hass, _update_router, SCAN_INTERVAL) + ) + + # Clean up at end + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, router.cleanup) + + return True + + +async def async_unload_entry( + hass: HomeAssistantType, config_entry: ConfigEntry +) -> bool: + """Unload config entry.""" + + # Forward config entry unload to platforms + for domain in CONFIG_ENTRY_PLATFORMS: + await hass.config_entries.async_forward_entry_unload(config_entry, domain) + + # Forget about the router and invoke its cleanup + router = hass.data[DOMAIN].routers.pop(config_entry.data[CONF_URL]) + await hass.async_add_executor_job(router.cleanup) + + return True + + +async def async_setup(hass: HomeAssistantType, config) -> bool: + """Set up Huawei LTE component.""" + + # Arrange our YAML config to dict with normalized URLs as keys + domain_config = {} + if DOMAIN not in hass.data: + hass.data[DOMAIN] = HuaweiLteData(hass_config=config, config=domain_config) + for router_config in config.get(DOMAIN, []): + domain_config[url_normalize(router_config.pop(CONF_URL))] = router_config + + def service_handler(service) -> None: + """Apply a service.""" + url = service.data.get(CONF_URL) + routers = hass.data[DOMAIN].routers + if url: + router = routers.get(url) + elif len(routers) == 1: + router = next(iter(routers.values())) + else: + _LOGGER.error( + "%s: more than one router configured, must specify one of URLs %s", + service.service, + sorted(routers), + ) + return + if not router: + _LOGGER.error("%s: router %s unavailable", service.service, url) + return + + if service.service == SERVICE_CLEAR_TRAFFIC_STATISTICS: + result = router.client.monitoring.set_clear_traffic() + _LOGGER.debug("%s: %s", service.service, result) + elif service.service == SERVICE_REBOOT: + result = router.client.device.reboot() + _LOGGER.debug("%s: %s", service.service, result) + else: + _LOGGER.error("%s: unsupported service", service.service) + + for service in (SERVICE_CLEAR_TRAFFIC_STATISTICS, SERVICE_REBOOT): + hass.helpers.service.async_register_admin_service( + DOMAIN, service, service_handler, schema=SERVICE_SCHEMA, + ) + + for url, router_config in domain_config.items(): + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_URL: url, + CONF_USERNAME: router_config.get(CONF_USERNAME), + CONF_PASSWORD: router_config.get(CONF_PASSWORD), + }, + ) + ) + + return True + + +async def async_signal_options_update( + hass: HomeAssistantType, config_entry: ConfigEntry +) -> None: + """Handle config entry options update.""" + async_dispatcher_send(hass, UPDATE_OPTIONS_SIGNAL, config_entry) + + +@attr.s +class HuaweiLteBaseEntity(Entity): + """Huawei LTE entity base class.""" + + router: Router = attr.ib() + + _available: bool = attr.ib(init=False, default=True) + _unsub_handlers: List[Callable] = attr.ib(init=False, factory=list) + + @property + def _entity_name(self) -> str: + raise NotImplementedError + + @property + def _device_unique_id(self) -> str: + """Return unique ID for entity within a router.""" + raise NotImplementedError + + @property + def unique_id(self) -> str: + """Return unique ID for entity.""" + return f"{self.router.mac}-{self._device_unique_id}" + + @property + def name(self) -> str: + """Return entity name.""" + return DEFAULT_NAME_TEMPLATE.format(self.router.device_name, self._entity_name) + + @property + def available(self) -> bool: + """Return whether the entity is available.""" + return self._available + + @property + def should_poll(self) -> bool: + """Huawei LTE entities report their state without polling.""" + return False + + @property + def device_info(self) -> Dict[str, Any]: + """Get info for matching with parent router.""" + return {"connections": self.router.device_connections} + + async def async_update(self) -> None: + """Update state.""" + raise NotImplementedError + + async def async_update_options(self, config_entry: ConfigEntry) -> None: + """Update config entry options.""" + pass + + async def async_added_to_hass(self) -> None: + """Connect to update signals.""" + self._unsub_handlers.append( + async_dispatcher_connect(self.hass, UPDATE_SIGNAL, self._async_maybe_update) + ) + self._unsub_handlers.append( + async_dispatcher_connect( + self.hass, UPDATE_OPTIONS_SIGNAL, self._async_maybe_update_options + ) + ) + + async def _async_maybe_update(self, url: str) -> None: + """Update state if the update signal comes from our router.""" + if url == self.router.url: + self.async_schedule_update_ha_state(True) + + async def _async_maybe_update_options(self, config_entry: ConfigEntry) -> None: + """Update options if the update signal comes from our router.""" + if config_entry.data[CONF_URL] == self.router.url: + await self.async_update_options(config_entry) + + async def async_will_remove_from_hass(self) -> None: + """Invoke unsubscription handlers.""" + for unsub in self._unsub_handlers: + unsub() + self._unsub_handlers.clear() diff --git a/homeassistant/components/huawei_lte/binary_sensor.py b/homeassistant/components/huawei_lte/binary_sensor.py new file mode 100644 index 000000000..104933fe7 --- /dev/null +++ b/homeassistant/components/huawei_lte/binary_sensor.py @@ -0,0 +1,122 @@ +"""Support for Huawei LTE binary sensors.""" + +import logging +from typing import Optional + +import attr +from huawei_lte_api.enums.cradle import ConnectionStatusEnum + +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDevice, +) +from homeassistant.const import CONF_URL + +from . import HuaweiLteBaseEntity +from .const import DOMAIN, KEY_MONITORING_STATUS + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up from config entry.""" + router = hass.data[DOMAIN].routers[config_entry.data[CONF_URL]] + entities = [] + + if router.data.get(KEY_MONITORING_STATUS): + entities.append(HuaweiLteMobileConnectionBinarySensor(router)) + + async_add_entities(entities, True) + + +@attr.s +class HuaweiLteBaseBinarySensor(HuaweiLteBaseEntity, BinarySensorDevice): + """Huawei LTE binary sensor device base class.""" + + key: str + item: str + _raw_state: Optional[str] = attr.ib(init=False, default=None) + + async def async_added_to_hass(self): + """Subscribe to needed data on add.""" + await super().async_added_to_hass() + self.router.subscriptions[self.key].add(f"{BINARY_SENSOR_DOMAIN}/{self.item}") + + async def async_will_remove_from_hass(self): + """Unsubscribe from needed data on remove.""" + await super().async_will_remove_from_hass() + self.router.subscriptions[self.key].remove( + f"{BINARY_SENSOR_DOMAIN}/{self.item}" + ) + + async def async_update(self): + """Update state.""" + try: + value = self.router.data[self.key][self.item] + except KeyError: + _LOGGER.debug("%s[%s] not in data", self.key, self.item) + self._available = False + return + self._available = True + self._raw_state = str(value) + + +CONNECTION_STATE_ATTRIBUTES = { + str(ConnectionStatusEnum.CONNECTING): "Connecting", + str(ConnectionStatusEnum.DISCONNECTING): "Disconnecting", + str(ConnectionStatusEnum.CONNECT_FAILED): "Connect failed", + str(ConnectionStatusEnum.CONNECT_STATUS_NULL): "Status not available", + str(ConnectionStatusEnum.CONNECT_STATUS_ERROR): "Status error", +} + + +@attr.s +class HuaweiLteMobileConnectionBinarySensor(HuaweiLteBaseBinarySensor): + """Huawei LTE mobile connection binary sensor.""" + + def __attrs_post_init__(self): + """Initialize identifiers.""" + self.key = KEY_MONITORING_STATUS + self.item = "ConnectionStatus" + + @property + def _entity_name(self) -> str: + return "Mobile connection" + + @property + def _device_unique_id(self) -> str: + return f"{self.key}.{self.item}" + + @property + def is_on(self) -> bool: + """Return whether the binary sensor is on.""" + return self._raw_state and int(self._raw_state) in ( + ConnectionStatusEnum.CONNECTED, + ConnectionStatusEnum.DISCONNECTING, + ) + + @property + def assumed_state(self) -> bool: + """Return True if real state is assumed, not known.""" + return not self._raw_state or int(self._raw_state) not in ( + ConnectionStatusEnum.CONNECT_FAILED, + ConnectionStatusEnum.CONNECTED, + ConnectionStatusEnum.DISCONNECTED, + ) + + @property + def icon(self): + """Return mobile connectivity sensor icon.""" + return "mdi:signal" if self.is_on else "mdi:signal-off" + + @property + def device_state_attributes(self): + """Get additional attributes related to connection status.""" + attributes = super().device_state_attributes + if self._raw_state in CONNECTION_STATE_ATTRIBUTES: + if attributes is None: + attributes = {} + attributes["additional_state"] = CONNECTION_STATE_ATTRIBUTES[ + self._raw_state + ] + return attributes diff --git a/homeassistant/components/huawei_lte/config_flow.py b/homeassistant/components/huawei_lte/config_flow.py new file mode 100644 index 000000000..b316472ef --- /dev/null +++ b/homeassistant/components/huawei_lte/config_flow.py @@ -0,0 +1,255 @@ +"""Config flow for the Huawei LTE platform.""" + +from collections import OrderedDict +import logging +from typing import Optional +from urllib.parse import urlparse + +from huawei_lte_api.AuthorizedConnection import AuthorizedConnection +from huawei_lte_api.Client import Client +from huawei_lte_api.Connection import Connection +from huawei_lte_api.exceptions import ( + LoginErrorPasswordWrongException, + LoginErrorUsernamePasswordOverrunException, + LoginErrorUsernamePasswordWrongException, + LoginErrorUsernameWrongException, + ResponseErrorException, +) +from requests.exceptions import Timeout +from url_normalize import url_normalize +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components import ssdp +from homeassistant.const import CONF_PASSWORD, CONF_RECIPIENT, CONF_URL, CONF_USERNAME +from homeassistant.core import callback + +# see https://github.com/PyCQA/pylint/issues/3202 about the DOMAIN's pylint issue +from .const import CONNECTION_TIMEOUT, DEFAULT_DEVICE_NAME +from .const import DOMAIN # pylint: disable=unused-import + +_LOGGER = logging.getLogger(__name__) + + +class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle Huawei LTE config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get options flow.""" + return OptionsFlowHandler(config_entry) + + async def _async_show_user_form(self, user_input=None, errors=None): + if user_input is None: + user_input = {} + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + OrderedDict( + ( + ( + vol.Required( + CONF_URL, + default=user_input.get( + CONF_URL, + # https://github.com/PyCQA/pylint/issues/3167 + self.context.get( # pylint: disable=no-member + CONF_URL, "" + ), + ), + ), + str, + ), + ( + vol.Optional( + CONF_USERNAME, default=user_input.get(CONF_USERNAME, "") + ), + str, + ), + ( + vol.Optional( + CONF_PASSWORD, default=user_input.get(CONF_PASSWORD, "") + ), + str, + ), + ) + ) + ), + errors=errors or {}, + ) + + async def async_step_import(self, user_input=None): + """Handle import initiated config flow.""" + return await self.async_step_user(user_input) + + def _already_configured(self, user_input): + """See if we already have a router matching user input configured.""" + existing_urls = { + url_normalize(entry.data[CONF_URL], default_scheme="http") + for entry in self._async_current_entries() + } + return user_input[CONF_URL] in existing_urls + + async def async_step_user(self, user_input=None): + """Handle user initiated config flow.""" + if user_input is None: + return await self._async_show_user_form() + + errors = {} + + # Normalize URL + user_input[CONF_URL] = url_normalize( + user_input[CONF_URL], default_scheme="http" + ) + if "://" not in user_input[CONF_URL]: + errors[CONF_URL] = "invalid_url" + return await self._async_show_user_form( + user_input=user_input, errors=errors + ) + + if self._already_configured(user_input): + return self.async_abort(reason="already_configured") + + conn = None + + def logout(): + if hasattr(conn, "user"): + try: + conn.user.logout() + except Exception: # pylint: disable=broad-except + _LOGGER.debug("Could not logout", exc_info=True) + + def try_connect(username: Optional[str], password: Optional[str]) -> Connection: + """Try connecting with given credentials.""" + if username or password: + conn = AuthorizedConnection( + user_input[CONF_URL], + username=username, + password=password, + timeout=CONNECTION_TIMEOUT, + ) + else: + try: + conn = AuthorizedConnection( + user_input[CONF_URL], + username="", + password="", + timeout=CONNECTION_TIMEOUT, + ) + user_input[CONF_USERNAME] = "" + user_input[CONF_PASSWORD] = "" + except ResponseErrorException: + _LOGGER.debug( + "Could not login with empty credentials, proceeding unauthenticated", + exc_info=True, + ) + conn = Connection(user_input[CONF_URL], timeout=CONNECTION_TIMEOUT) + del user_input[CONF_USERNAME] + del user_input[CONF_PASSWORD] + return conn + + def get_router_title(conn: Connection) -> str: + """Get title for router.""" + title = None + client = Client(conn) + try: + info = client.device.basic_information() + except Exception: # pylint: disable=broad-except + _LOGGER.debug("Could not get device.basic_information", exc_info=True) + else: + title = info.get("devicename") + if not title: + try: + info = client.device.information() + except Exception: # pylint: disable=broad-except + _LOGGER.debug("Could not get device.information", exc_info=True) + else: + title = info.get("DeviceName") + return title or DEFAULT_DEVICE_NAME + + username = user_input.get(CONF_USERNAME) + password = user_input.get(CONF_PASSWORD) + try: + conn = await self.hass.async_add_executor_job( + try_connect, username, password + ) + except LoginErrorUsernameWrongException: + errors[CONF_USERNAME] = "incorrect_username" + except LoginErrorPasswordWrongException: + errors[CONF_PASSWORD] = "incorrect_password" + except LoginErrorUsernamePasswordWrongException: + errors[CONF_USERNAME] = "incorrect_username_or_password" + except LoginErrorUsernamePasswordOverrunException: + errors["base"] = "login_attempts_exceeded" + except ResponseErrorException: + _LOGGER.warning("Response error", exc_info=True) + errors["base"] = "response_error" + except Timeout: + _LOGGER.warning("Connection timeout", exc_info=True) + errors[CONF_URL] = "connection_timeout" + except Exception: # pylint: disable=broad-except + _LOGGER.warning("Unknown error connecting to device", exc_info=True) + errors[CONF_URL] = "unknown_connection_error" + if errors: + await self.hass.async_add_executor_job(logout) + return await self._async_show_user_form( + user_input=user_input, errors=errors + ) + + title = await self.hass.async_add_executor_job(get_router_title, conn) + await self.hass.async_add_executor_job(logout) + + return self.async_create_entry(title=title, data=user_input) + + async def async_step_ssdp(self, discovery_info): + """Handle SSDP initiated config flow.""" + # Attempt to distinguish from other non-LTE Huawei router devices, at least + # some ones we are interested in have "Mobile Wi-Fi" friendlyName. + if "mobile" not in discovery_info.get(ssdp.ATTR_UPNP_FRIENDLY_NAME, "").lower(): + return self.async_abort(reason="not_huawei_lte") + + # https://github.com/PyCQA/pylint/issues/3167 + url = self.context[CONF_URL] = url_normalize( # pylint: disable=no-member + discovery_info.get( + ssdp.ATTR_UPNP_PRESENTATION_URL, + f"http://{urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION]).hostname}/", + ) + ) + + if any( + url == flow["context"].get(CONF_URL) for flow in self._async_in_progress() + ): + return self.async_abort(reason="already_in_progress") + + user_input = {CONF_URL: url} + if self._already_configured(user_input): + return self.async_abort(reason="already_configured") + + return await self._async_show_user_form(user_input) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Huawei LTE options flow.""" + + def __init__(self, config_entry: config_entries.ConfigEntry): + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Handle options flow.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + data_schema = vol.Schema( + { + vol.Optional( + CONF_RECIPIENT, + default=self.config_entry.options.get(CONF_RECIPIENT, ""), + ): str + } + ) + return self.async_show_form(step_id="init", data_schema=data_schema) diff --git a/homeassistant/components/huawei_lte/const.py b/homeassistant/components/huawei_lte/const.py new file mode 100644 index 000000000..c71b51435 --- /dev/null +++ b/homeassistant/components/huawei_lte/const.py @@ -0,0 +1,38 @@ +"""Huawei LTE constants.""" + +DOMAIN = "huawei_lte" + +DEFAULT_DEVICE_NAME = "LTE" + +UPDATE_SIGNAL = f"{DOMAIN}_update" +UPDATE_OPTIONS_SIGNAL = f"{DOMAIN}_options_update" + +UNIT_BYTES = "B" +UNIT_SECONDS = "s" + +CONNECTION_TIMEOUT = 10 + +SERVICE_CLEAR_TRAFFIC_STATISTICS = "clear_traffic_statistics" +SERVICE_REBOOT = "reboot" + +KEY_DEVICE_BASIC_INFORMATION = "device_basic_information" +KEY_DEVICE_INFORMATION = "device_information" +KEY_DEVICE_SIGNAL = "device_signal" +KEY_DIALUP_MOBILE_DATASWITCH = "dialup_mobile_dataswitch" +KEY_MONITORING_STATUS = "monitoring_status" +KEY_MONITORING_TRAFFIC_STATISTICS = "monitoring_traffic_statistics" +KEY_WLAN_HOST_LIST = "wlan_host_list" + +BINARY_SENSOR_KEYS = {KEY_MONITORING_STATUS} + +DEVICE_TRACKER_KEYS = {KEY_WLAN_HOST_LIST} + +SENSOR_KEYS = { + KEY_DEVICE_INFORMATION, + KEY_DEVICE_SIGNAL, + KEY_MONITORING_TRAFFIC_STATISTICS, +} + +SWITCH_KEYS = {KEY_DIALUP_MOBILE_DATASWITCH} + +ALL_KEYS = BINARY_SENSOR_KEYS | DEVICE_TRACKER_KEYS | SENSOR_KEYS | SWITCH_KEYS diff --git a/homeassistant/components/huawei_lte/device_tracker.py b/homeassistant/components/huawei_lte/device_tracker.py new file mode 100644 index 000000000..a9c61831f --- /dev/null +++ b/homeassistant/components/huawei_lte/device_tracker.py @@ -0,0 +1,162 @@ +"""Support for device tracking of Huawei LTE routers.""" + +import logging +import re +from typing import Any, Dict, List, Optional, Set + +import attr +from stringcase import snakecase + +from homeassistant.components.device_tracker import ( + DOMAIN as DEVICE_TRACKER_DOMAIN, + SOURCE_TYPE_ROUTER, +) +from homeassistant.components.device_tracker.config_entry import ScannerEntity +from homeassistant.const import CONF_URL +from homeassistant.helpers import entity_registry +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from . import HuaweiLteBaseEntity +from .const import DOMAIN, KEY_WLAN_HOST_LIST, UPDATE_SIGNAL + +_LOGGER = logging.getLogger(__name__) + +_DEVICE_SCAN = f"{DEVICE_TRACKER_DOMAIN}/device_scan" + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up from config entry.""" + + # Grab hosts list once to examine whether the initial fetch has got some data for + # us, i.e. if wlan host list is supported. Only set up a subscription and proceed + # with adding and tracking entities if it is. + router = hass.data[DOMAIN].routers[config_entry.data[CONF_URL]] + try: + _ = router.data[KEY_WLAN_HOST_LIST]["Hosts"]["Host"] + except KeyError: + _LOGGER.debug("%s[%s][%s] not in data", KEY_WLAN_HOST_LIST, "Hosts", "Host") + return + + # Initialize already tracked entities + tracked: Set[str] = set() + registry = await entity_registry.async_get_registry(hass) + known_entities: List[HuaweiLteScannerEntity] = [] + for entity in registry.entities.values(): + if ( + entity.domain == DEVICE_TRACKER_DOMAIN + and entity.config_entry_id == config_entry.entry_id + ): + tracked.add(entity.unique_id) + known_entities.append( + HuaweiLteScannerEntity(router, entity.unique_id.partition("-")[2]) + ) + async_add_entities(known_entities, True) + + # Tell parent router to poll hosts list to gather new devices + router.subscriptions[KEY_WLAN_HOST_LIST].add(_DEVICE_SCAN) + + async def _async_maybe_add_new_entities(url: str) -> None: + """Add new entities if the update signal comes from our router.""" + if url == router.url: + async_add_new_entities(hass, url, async_add_entities, tracked) + + # Register to handle router data updates + disconnect_dispatcher = async_dispatcher_connect( + hass, UPDATE_SIGNAL, _async_maybe_add_new_entities + ) + router.unload_handlers.append(disconnect_dispatcher) + + # Add new entities from initial scan + async_add_new_entities(hass, router.url, async_add_entities, tracked) + + +def async_add_new_entities(hass, router_url, async_add_entities, tracked): + """Add new entities that are not already being tracked.""" + router = hass.data[DOMAIN].routers[router_url] + try: + hosts = router.data[KEY_WLAN_HOST_LIST]["Hosts"]["Host"] + except KeyError: + _LOGGER.debug("%s[%s][%s] not in data", KEY_WLAN_HOST_LIST, "Hosts", "Host") + return + + new_entities = [] + for host in (x for x in hosts if x.get("MacAddress")): + entity = HuaweiLteScannerEntity(router, host["MacAddress"]) + if entity.unique_id in tracked: + continue + tracked.add(entity.unique_id) + new_entities.append(entity) + async_add_entities(new_entities, True) + + +def _better_snakecase(text: str) -> str: + if text == text.upper(): + # All uppercase to all lowercase to get http for HTTP, not h_t_t_p + text = text.lower() + else: + # Three or more consecutive uppercase with middle part lowercased + # to get http_response for HTTPResponse, not h_t_t_p_response + text = re.sub( + r"([A-Z])([A-Z]+)([A-Z](?:[^A-Z]|$))", + lambda match: f"{match.group(1)}{match.group(2).lower()}{match.group(3)}", + text, + ) + return snakecase(text) + + +@attr.s +class HuaweiLteScannerEntity(HuaweiLteBaseEntity, ScannerEntity): + """Huawei LTE router scanner entity.""" + + mac: str = attr.ib() + + _is_connected: bool = attr.ib(init=False, default=False) + _hostname: Optional[str] = attr.ib(init=False, default=None) + _device_state_attributes: Dict[str, Any] = attr.ib(init=False, factory=dict) + + def __attrs_post_init__(self): + """Initialize internal state.""" + self._device_state_attributes["mac_address"] = self.mac + + @property + def _entity_name(self) -> str: + return self._hostname or self.mac + + @property + def _device_unique_id(self) -> str: + return self.mac + + @property + def source_type(self) -> str: + """Return SOURCE_TYPE_ROUTER.""" + return SOURCE_TYPE_ROUTER + + @property + def is_connected(self) -> bool: + """Get whether the entity is connected.""" + return self._is_connected + + @property + def device_state_attributes(self) -> Dict[str, Any]: + """Get additional attributes related to entity state.""" + return self._device_state_attributes + + async def async_update(self) -> None: + """Update state.""" + hosts = self.router.data[KEY_WLAN_HOST_LIST]["Hosts"]["Host"] + host = next((x for x in hosts if x.get("MacAddress") == self.mac), None) + self._is_connected = host is not None + if self._is_connected: + self._hostname = host.get("HostName") + self._device_state_attributes = { + _better_snakecase(k): v for k, v in host.items() if k != "HostName" + } + + +def get_scanner(*args, **kwargs): # pylint: disable=useless-return + """Old no longer used way to set up Huawei LTE device tracker.""" + _LOGGER.warning( + "Loading and configuring as a platform is no longer supported or " + "required, convert to enabling/disabling available entities" + ) + return None diff --git a/homeassistant/components/huawei_lte/manifest.json b/homeassistant/components/huawei_lte/manifest.json new file mode 100644 index 000000000..8fd4ba4be --- /dev/null +++ b/homeassistant/components/huawei_lte/manifest.json @@ -0,0 +1,22 @@ +{ + "domain": "huawei_lte", + "name": "Huawei LTE", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/huawei_lte", + "requirements": [ + "getmac==0.8.1", + "huawei-lte-api==1.4.4", + "stringcase==1.2.0", + "url-normalize==1.4.1" + ], + "ssdp": [ + { + "deviceType": "urn:schemas-upnp-org:device:InternetGatewayDevice:1", + "manufacturer": "Huawei" + } + ], + "dependencies": [], + "codeowners": [ + "@scop" + ] +} diff --git a/homeassistant/components/huawei_lte/notify.py b/homeassistant/components/huawei_lte/notify.py new file mode 100644 index 000000000..494d0ec72 --- /dev/null +++ b/homeassistant/components/huawei_lte/notify.py @@ -0,0 +1,53 @@ +"""Support for Huawei LTE router notifications.""" + +import logging +from typing import Any, List + +import attr +from huawei_lte_api.exceptions import ResponseErrorException + +from homeassistant.components.notify import ATTR_TARGET, BaseNotificationService +from homeassistant.const import CONF_RECIPIENT, CONF_URL + +from . import Router +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_get_service(hass, config, discovery_info=None): + """Get the notification service.""" + if discovery_info is None: + _LOGGER.warning( + "Loading as a platform is no longer supported, convert to use " + "config entries or the huawei_lte component" + ) + return None + + router = hass.data[DOMAIN].routers[discovery_info[CONF_URL]] + default_targets = discovery_info[CONF_RECIPIENT] or [] + + return HuaweiLteSmsNotificationService(router, default_targets) + + +@attr.s +class HuaweiLteSmsNotificationService(BaseNotificationService): + """Huawei LTE router SMS notification service.""" + + router: Router = attr.ib() + default_targets: List[str] = attr.ib() + + def send_message(self, message: str = "", **kwargs: Any) -> None: + """Send message to target numbers.""" + + targets = kwargs.get(ATTR_TARGET, self.default_targets) + if not targets or not message: + return + + try: + resp = self.router.client.sms.send_sms( + phone_numbers=targets, message=message + ) + _LOGGER.debug("Sent to %s: %s", targets, resp) + except ResponseErrorException as ex: + _LOGGER.error("Could not send to %s: %s", targets, ex) diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py new file mode 100644 index 000000000..3b6b75edf --- /dev/null +++ b/homeassistant/components/huawei_lte/sensor.py @@ -0,0 +1,270 @@ +"""Support for Huawei LTE sensors.""" + +import logging +import re +from typing import Optional + +import attr + +from homeassistant.components.sensor import ( + DEVICE_CLASS_SIGNAL_STRENGTH, + DOMAIN as SENSOR_DOMAIN, +) +from homeassistant.const import CONF_URL, STATE_UNKNOWN + +from . import HuaweiLteBaseEntity +from .const import ( + DOMAIN, + KEY_DEVICE_INFORMATION, + KEY_DEVICE_SIGNAL, + KEY_MONITORING_TRAFFIC_STATISTICS, + UNIT_BYTES, + UNIT_SECONDS, +) + +_LOGGER = logging.getLogger(__name__) + + +SENSOR_META = { + KEY_DEVICE_INFORMATION: dict( + include=re.compile(r"^WanIP.*Address$", re.IGNORECASE) + ), + (KEY_DEVICE_INFORMATION, "WanIPAddress"): dict( + name="WAN IP address", icon="mdi:ip", enabled_default=True + ), + (KEY_DEVICE_INFORMATION, "WanIPv6Address"): dict( + name="WAN IPv6 address", icon="mdi:ip" + ), + (KEY_DEVICE_SIGNAL, "band"): dict(name="Band"), + (KEY_DEVICE_SIGNAL, "cell_id"): dict(name="Cell ID"), + (KEY_DEVICE_SIGNAL, "lac"): dict(name="LAC", icon="mdi:map-marker"), + (KEY_DEVICE_SIGNAL, "mode"): dict( + name="Mode", + formatter=lambda x: ({"0": "2G", "2": "3G", "7": "4G"}.get(x, "Unknown"), None), + ), + (KEY_DEVICE_SIGNAL, "pci"): dict(name="PCI"), + (KEY_DEVICE_SIGNAL, "rsrq"): dict( + name="RSRQ", + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + # http://www.lte-anbieter.info/technik/rsrq.php + icon=lambda x: (x is None or x < -11) + and "mdi:signal-cellular-outline" + or x < -8 + and "mdi:signal-cellular-1" + or x < -5 + and "mdi:signal-cellular-2" + or "mdi:signal-cellular-3", + enabled_default=True, + ), + (KEY_DEVICE_SIGNAL, "rsrp"): dict( + name="RSRP", + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + # http://www.lte-anbieter.info/technik/rsrp.php + icon=lambda x: (x is None or x < -110) + and "mdi:signal-cellular-outline" + or x < -95 + and "mdi:signal-cellular-1" + or x < -80 + and "mdi:signal-cellular-2" + or "mdi:signal-cellular-3", + enabled_default=True, + ), + (KEY_DEVICE_SIGNAL, "rssi"): dict( + name="RSSI", + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + # https://eyesaas.com/wi-fi-signal-strength/ + icon=lambda x: (x is None or x < -80) + and "mdi:signal-cellular-outline" + or x < -70 + and "mdi:signal-cellular-1" + or x < -60 + and "mdi:signal-cellular-2" + or "mdi:signal-cellular-3", + enabled_default=True, + ), + (KEY_DEVICE_SIGNAL, "sinr"): dict( + name="SINR", + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + # http://www.lte-anbieter.info/technik/sinr.php + icon=lambda x: (x is None or x < 0) + and "mdi:signal-cellular-outline" + or x < 5 + and "mdi:signal-cellular-1" + or x < 10 + and "mdi:signal-cellular-2" + or "mdi:signal-cellular-3", + enabled_default=True, + ), + (KEY_DEVICE_SIGNAL, "rscp"): dict( + name="RSCP", + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + # https://wiki.teltonika.lt/view/RSCP + icon=lambda x: (x is None or x < -95) + and "mdi:signal-cellular-outline" + or x < -85 + and "mdi:signal-cellular-1" + or x < -75 + and "mdi:signal-cellular-2" + or "mdi:signal-cellular-3", + ), + (KEY_DEVICE_SIGNAL, "ecio"): dict( + name="EC/IO", + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + # https://wiki.teltonika.lt/view/EC/IO + icon=lambda x: (x is None or x < -20) + and "mdi:signal-cellular-outline" + or x < -10 + and "mdi:signal-cellular-1" + or x < -6 + and "mdi:signal-cellular-2" + or "mdi:signal-cellular-3", + ), + KEY_MONITORING_TRAFFIC_STATISTICS: dict( + exclude=re.compile(r"^showtraffic$", re.IGNORECASE) + ), + (KEY_MONITORING_TRAFFIC_STATISTICS, "CurrentConnectTime"): dict( + name="Current connection duration", unit=UNIT_SECONDS, icon="mdi:timer" + ), + (KEY_MONITORING_TRAFFIC_STATISTICS, "CurrentDownload"): dict( + name="Current connection download", unit=UNIT_BYTES, icon="mdi:download" + ), + (KEY_MONITORING_TRAFFIC_STATISTICS, "CurrentUpload"): dict( + name="Current connection upload", unit=UNIT_BYTES, icon="mdi:upload" + ), + (KEY_MONITORING_TRAFFIC_STATISTICS, "TotalConnectTime"): dict( + name="Total connected duration", unit=UNIT_SECONDS, icon="mdi:timer" + ), + (KEY_MONITORING_TRAFFIC_STATISTICS, "TotalDownload"): dict( + name="Total download", unit=UNIT_BYTES, icon="mdi:download" + ), + (KEY_MONITORING_TRAFFIC_STATISTICS, "TotalUpload"): dict( + name="Total upload", unit=UNIT_BYTES, icon="mdi:upload" + ), +} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up from config entry.""" + router = hass.data[DOMAIN].routers[config_entry.data[CONF_URL]] + sensors = [] + for key in ( + KEY_DEVICE_INFORMATION, + KEY_DEVICE_SIGNAL, + KEY_MONITORING_TRAFFIC_STATISTICS, + ): + items = router.data.get(key) + if not items: + continue + key_meta = SENSOR_META.get(key) + if key_meta: + include = key_meta.get("include") + if include: + items = filter(include.search, items) + exclude = key_meta.get("exclude") + if exclude: + items = [x for x in items if not exclude.search(x)] + for item in items: + sensors.append( + HuaweiLteSensor(router, key, item, SENSOR_META.get((key, item), {})) + ) + + async_add_entities(sensors, True) + + +def format_default(value): + """Format value.""" + unit = None + if value is not None: + # Clean up value and infer unit, e.g. -71dBm, 15 dB + match = re.match( + r"([>=<]*)(?P.+?)\s*(?P[a-zA-Z]+)\s*$", str(value) + ) + if match: + try: + value = float(match.group("value")) + unit = match.group("unit") + except ValueError: + pass + return value, unit + + +@attr.s +class HuaweiLteSensor(HuaweiLteBaseEntity): + """Huawei LTE sensor entity.""" + + key: str = attr.ib() + item: str = attr.ib() + meta: dict = attr.ib() + + _state = attr.ib(init=False, default=STATE_UNKNOWN) + _unit: str = attr.ib(init=False) + + async def async_added_to_hass(self): + """Subscribe to needed data on add.""" + await super().async_added_to_hass() + self.router.subscriptions[self.key].add(f"{SENSOR_DOMAIN}/{self.item}") + + async def async_will_remove_from_hass(self): + """Unsubscribe from needed data on remove.""" + await super().async_will_remove_from_hass() + self.router.subscriptions[self.key].remove(f"{SENSOR_DOMAIN}/{self.item}") + + @property + def _entity_name(self) -> str: + return self.meta.get("name", self.item) + + @property + def _device_unique_id(self) -> str: + return f"{self.key}.{self.item}" + + @property + def state(self): + """Return sensor state.""" + return self._state + + @property + def device_class(self) -> Optional[str]: + """Return sensor device class.""" + return self.meta.get("device_class") + + @property + def unit_of_measurement(self): + """Return sensor's unit of measurement.""" + return self.meta.get("unit", self._unit) + + @property + def icon(self): + """Return icon for sensor.""" + icon = self.meta.get("icon") + if callable(icon): + return icon(self.state) + return icon + + @property + def entity_registry_enabled_default(self) -> bool: + """Return if the entity should be enabled when first added to the entity registry.""" + return bool(self.meta.get("enabled_default")) + + async def async_update(self): + """Update state.""" + try: + value = self.router.data[self.key][self.item] + except KeyError: + _LOGGER.debug("%s[%s] not in data", self.key, self.item) + self._available = False + return + self._available = True + + formatter = self.meta.get("formatter") + if not callable(formatter): + formatter = format_default + + self._state, self._unit = formatter(value) + + +async def async_setup_platform(*args, **kwargs): + """Old no longer used way to set up Huawei LTE sensors.""" + _LOGGER.warning( + "Loading and configuring as a platform is no longer supported or " + "required, convert to enabling/disabling available entities" + ) diff --git a/homeassistant/components/huawei_lte/services.yaml b/homeassistant/components/huawei_lte/services.yaml new file mode 100644 index 000000000..428745ee3 --- /dev/null +++ b/homeassistant/components/huawei_lte/services.yaml @@ -0,0 +1,13 @@ +clear_traffic_statistics: + description: Clear traffic statistics. + fields: + url: + description: URL of router to clear; optional when only one is configured. + example: http://192.168.100.1/ + +reboot: + description: Reboot router. + fields: + url: + description: URL of router to reboot; optional when only one is configured. + example: http://192.168.100.1/ diff --git a/homeassistant/components/huawei_lte/strings.json b/homeassistant/components/huawei_lte/strings.json new file mode 100644 index 000000000..176842536 --- /dev/null +++ b/homeassistant/components/huawei_lte/strings.json @@ -0,0 +1,42 @@ +{ + "config": { + "abort": { + "already_configured": "This device has already been configured", + "already_in_progress": "This device is already being configured", + "not_huawei_lte": "Not a Huawei LTE device" + }, + "error": { + "connection_failed": "Connection failed", + "incorrect_password": "Incorrect password", + "incorrect_username": "Incorrect username", + "incorrect_username_or_password": "Incorrect username or password", + "invalid_url": "Invalid URL", + "login_attempts_exceeded": "Maximum login attempts exceeded, please try again later", + "response_error": "Unknown error from device", + "connection_timeout": "Connection timeout", + "unknown_connection_error": "Unknown error connecting to device" + }, + "step": { + "user": { + "data": { + "password": "Password", + "url": "URL", + "username": "User name" + }, + "description": "Enter device access details. Specifying username and password is optional, but enables support for more integration features. On the other hand, use of an authorized connection may cause problems accessing the device web interface from outside Home Assistant while the integration is active, and the other way around.", + "title": "Configure Huawei LTE" + } + }, + "title": "Huawei LTE" + }, + "options": { + "step": { + "init": { + "data": { + "recipient": "SMS notification recipients", + "track_new_devices": "Track new devices" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/switch.py b/homeassistant/components/huawei_lte/switch.py new file mode 100644 index 000000000..44d2da0c8 --- /dev/null +++ b/homeassistant/components/huawei_lte/switch.py @@ -0,0 +1,109 @@ +"""Support for Huawei LTE switches.""" + +import logging +from typing import Optional + +import attr + +from homeassistant.components.switch import ( + DEVICE_CLASS_SWITCH, + DOMAIN as SWITCH_DOMAIN, + SwitchDevice, +) +from homeassistant.const import CONF_URL + +from . import HuaweiLteBaseEntity +from .const import DOMAIN, KEY_DIALUP_MOBILE_DATASWITCH + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up from config entry.""" + router = hass.data[DOMAIN].routers[config_entry.data[CONF_URL]] + switches = [] + + if router.data.get(KEY_DIALUP_MOBILE_DATASWITCH): + switches.append(HuaweiLteMobileDataSwitch(router)) + + async_add_entities(switches, True) + + +@attr.s +class HuaweiLteBaseSwitch(HuaweiLteBaseEntity, SwitchDevice): + """Huawei LTE switch device base class.""" + + key: str + item: str + _raw_state: Optional[str] = attr.ib(init=False, default=None) + + def _turn(self, state: bool) -> None: + raise NotImplementedError + + def turn_on(self, **kwargs): + """Turn switch on.""" + self._turn(state=True) + + def turn_off(self, **kwargs): + """Turn switch off.""" + self._turn(state=False) + + @property + def device_class(self): + """Return device class.""" + return DEVICE_CLASS_SWITCH + + async def async_added_to_hass(self): + """Subscribe to needed data on add.""" + await super().async_added_to_hass() + self.router.subscriptions[self.key].add(f"{SWITCH_DOMAIN}/{self.item}") + + async def async_will_remove_from_hass(self): + """Unsubscribe from needed data on remove.""" + await super().async_will_remove_from_hass() + self.router.subscriptions[self.key].remove(f"{SWITCH_DOMAIN}/{self.item}") + + async def async_update(self): + """Update state.""" + try: + value = self.router.data[self.key][self.item] + except KeyError: + _LOGGER.debug("%s[%s] not in data", self.key, self.item) + self._available = False + return + self._available = True + self._raw_state = str(value) + + +@attr.s +class HuaweiLteMobileDataSwitch(HuaweiLteBaseSwitch): + """Huawei LTE mobile data switch device.""" + + def __attrs_post_init__(self): + """Initialize identifiers.""" + self.key = KEY_DIALUP_MOBILE_DATASWITCH + self.item = "dataswitch" + + @property + def _entity_name(self) -> str: + return "Mobile data" + + @property + def _device_unique_id(self) -> str: + return f"{self.key}.{self.item}" + + @property + def is_on(self) -> bool: + """Return whether the switch is on.""" + return self._raw_state == "1" + + def _turn(self, state: bool) -> None: + value = 1 if state else 0 + self.router.client.dial_up.set_mobile_dataswitch(dataswitch=value) + self._raw_state = str(value) + self.schedule_update_ha_state() + + @property + def icon(self): + """Return switch icon.""" + return "mdi:signal" if self.is_on else "mdi:signal-off" diff --git a/homeassistant/components/huawei_router/__init__.py b/homeassistant/components/huawei_router/__init__.py new file mode 100644 index 000000000..861809992 --- /dev/null +++ b/homeassistant/components/huawei_router/__init__.py @@ -0,0 +1 @@ +"""The huawei_router component.""" diff --git a/homeassistant/components/huawei_router/device_tracker.py b/homeassistant/components/huawei_router/device_tracker.py new file mode 100644 index 000000000..4b52060e4 --- /dev/null +++ b/homeassistant/components/huawei_router/device_tracker.py @@ -0,0 +1,156 @@ +"""Support for HUAWEI routers.""" +import base64 +from collections import namedtuple +import logging +import re + +import requests +import voluptuous as vol + +from homeassistant.components.device_tracker import ( + DOMAIN, + PLATFORM_SCHEMA, + DeviceScanner, +) +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, + } +) + + +def get_scanner(hass, config): + """Validate the configuration and return a HUAWEI scanner.""" + scanner = HuaweiDeviceScanner(config[DOMAIN]) + + return scanner + + +Device = namedtuple("Device", ["name", "ip", "mac", "state"]) + + +class HuaweiDeviceScanner(DeviceScanner): + """This class queries a router running HUAWEI firmware.""" + + ARRAY_REGEX = re.compile(r"var UserDevinfo = new Array\((.*)null\);") + DEVICE_REGEX = re.compile(r"new USERDevice\((.*?)\),") + DEVICE_ATTR_REGEX = re.compile( + '"(?P.*?)","(?P.*?)",' + '"(?P.*?)","(?P.*?)",' + '"(?P.*?)","(?P.*?)",' + '"(?P.*?)","(?P.*?)",' + '"(?P