diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 0000000000000..be739b6180983 --- /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 f41886aaa0ff6..de93cac55b50a 100644 --- a/.coveragerc +++ b/.coveragerc @@ -2,33 +2,846 @@ source = homeassistant omit = - homeassistant/external/* + homeassistant/__main__.py + homeassistant/helpers/signal.py + homeassistant/helpers/typing.py + homeassistant/scripts/*.py + homeassistant/util/async.py # omit pieces of code that rely on external devices being present - homeassistant/components/wink.py - homeassistant/components/*/wink.py - - homeassistant/components/zwave.py - homeassistant/components/*/zwave.py - - homeassistant/components/*/tellstick.py - homeassistant/components/*/vera.py - - homeassistant/components/keyboard.py - homeassistant/components/switch/wemo.py - homeassistant/components/thermostat/nest.py - homeassistant/components/light/hue.py - homeassistant/components/sensor/systemmonitor.py - homeassistant/components/sensor/sabnzbd.py - homeassistant/components/notify/pushbullet.py - homeassistant/components/notify/pushover.py - homeassistant/components/media_player/cast.py - homeassistant/components/device_tracker/luci.py - homeassistant/components/device_tracker/tomato.py - homeassistant/components/device_tracker/netgear.py - homeassistant/components/device_tracker/nmap_tracker.py - homeassistant/components/device_tracker/ddwrt.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_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/brother/__init__.py + homeassistant/components/brother/sensor.py + homeassistant/components/brother/const.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/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/gios/__init__.py + homeassistant/components/gios/air_quality.py + homeassistant/components/gios/consts.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/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/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/imap/sensor.py + homeassistant/components/imap_email_content/sensor.py + homeassistant/components/influxdb/sensor.py + homeassistant/components/insteon/* + 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/kef/* + 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/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/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/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/sentry/__init__.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/surepetcare/*.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/tellduslive/* + homeassistant/components/tellstick/* + homeassistant/components/telnet/switch.py + homeassistant/components/temper/sensor.py + homeassistant/components/tensorflow/image_processing.py + homeassistant/components/tesla/__init__.py + homeassistant/components/tesla/binary_sensor.py + homeassistant/components/tesla/climate.py + homeassistant/components/tesla/const.py + homeassistant/components/tesla/device_tracker.py + homeassistant/components/tesla/lock.py + homeassistant/components/tesla/sensor.py + homeassistant/components/tesla/switch.py + 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/tmb/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/light.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/* + 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] # Regexes for lines to exclude from consideration diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000000000..7b56b66d0b59c --- /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/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000..3d8c32cfb926e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +# General files +.git +.github +config + +# Test related files +.tox + +# Other virtualization methods +venv +.vagrant + +# Temporary files +**/__pycache__ \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000000..caff2fc5c1f7a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +# Ensure Docker script files uses LF to support Docker for Windows. +# Ensure "git config --global core.autocrlf input" before you clone +* text eol=lf +*.py whitespace=error + +*.ico binary +*.jpg binary +*.png binary +*.zip binary +*.mp3 binary diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000000000..f68fbbc800ca6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,48 @@ + + +**Home Assistant release with the issue:** + + + +**Last working Home Assistant release (if known):** + + +**Operating environment (Hass.io/Docker/Windows/etc.):** + + +**Integration:** + + + +**Description of problem:** + + + +**Problem-relevant `configuration.yaml` entries and (fill out even if it seems unimportant):** +```yaml + +``` + +**Traceback (if applicable):** +``` + +``` + +**Additional information:** + diff --git a/.github/ISSUE_TEMPLATE/Bug_report.md b/.github/ISSUE_TEMPLATE/Bug_report.md new file mode 100644 index 0000000000000..885164d7a3469 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/Bug_report.md @@ -0,0 +1,52 @@ +--- +name: Bug report +about: Create a report to help us improve + +--- + + + +**Home Assistant release with the issue:** + + + +**Last working Home Assistant release (if known):** + + +**Operating environment (Hass.io/Docker/Windows/etc.):** + + +**Integration:** + + + +**Description of problem:** + + + +**Problem-relevant `configuration.yaml` entries and (fill out even if it seems unimportant):** +```yaml + +``` + +**Traceback (if applicable):** +``` + +``` + +**Additional information:** diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000000..474dff86b3dfa --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,35 @@ +## Breaking Change: + + + +## Description: + + +**Related issue (if applicable):** fixes # + +**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 + +``` + +## 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.io](https://github.com/home-assistant/home-assistant.io) + +If the code communicates with devices, web services, or third-party tools: + - [ ] [_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. + +[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 0000000000000..93666bc6eebe1 --- /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/move.yml b/.github/move.yml new file mode 100644 index 0000000000000..e041083c9ae11 --- /dev/null +++ b/.github/move.yml @@ -0,0 +1,13 @@ +# Configuration for move-issues - https://github.com/dessant/move-issues + +# Delete the command comment. Ignored when the comment also contains other content +deleteCommand: true +# Close the source issue after moving +closeSourceIssue: true +# Lock the source issue after moving +lockSourceIssue: false +# Set custom aliases for targets +# aliases: +# r: repo +# or: owner/repo + diff --git a/.github/stale.yml b/.github/stale.yml new file mode 100644 index 0000000000000..44cd95e1f5d71 --- /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 65c584d143a9b..2473aeb4bf650 100644 --- a/.gitignore +++ b/.gitignore @@ -1,21 +1,17 @@ config/* -!config/home-assistant.conf.default -homeassistant/components/frontend/www_static/polymer/bower_components/* +config2/* -# There is not a better solution afaik.. -!config/custom_components -config/custom_components/* -!config/custom_components/example.py -!config/custom_components/hello_world.py +tests/testing_config/deps +tests/testing_config/home-assistant.log + +# hass-release +data/ +.token # Hide sublime text stuff *.sublime-project *.sublime-workspace -# Hide code validator output -pep8.txt -pylint.txt - # Hide some OS X stuff .DS_Store .AppleDouble @@ -25,7 +21,13 @@ Icon # Thumbnails ._* +# IntelliJ IDEA .idea +*.iml + +# pytest +.pytest_cache +.cache # GITHUB Proposed Python stuff: *.py[cod] @@ -39,6 +41,7 @@ Icon dist build eggs +.eggs parts bin var @@ -47,14 +50,21 @@ develop-eggs .installed.cfg lib lib64 +pip-wheel-metadata -# Installer logs +# Logs +*.log pip-log.txt # Unit test / coverage reports .coverage .tox +coverage.xml nosetests.xml +htmlcov/ +test-reports/ +test-results.xml +test-output.xml # Translations *.mo @@ -64,7 +74,60 @@ nosetests.xml .project .pydevproject -# Hide emacs backups +.python-version + +# emacs auto backups *~ *# *.orig + +# venv stuff +pyvenv.cfg +pip-selfcheck.json +venv +.venv +Pipfile* +share/* +Scripts/ + +# vimmy stuff +*.swp +*.swo +tags +ctags.tmp + +# vagrant stuff +virtualization/vagrant/setup_done +virtualization/vagrant/.vagrant +virtualization/vagrant/config + +# Visual Studio Code +.vscode/* +!.vscode/cSpell.json +!.vscode/extensions.json +!.vscode/tasks.json + +# Built docs +docs/build + +# Windows Explorer +desktop.ini +/home-assistant.pyproj +/home-assistant.sln +/.vs/* + +# 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/.gitmodules b/.gitmodules deleted file mode 100644 index ae38be7c61b8c..0000000000000 --- a/.gitmodules +++ /dev/null @@ -1,21 +0,0 @@ -[submodule "homeassistant/external/pynetgear"] - path = homeassistant/external/pynetgear - url = https://github.com/balloob/pynetgear.git -[submodule "homeassistant/external/pywemo"] - path = homeassistant/external/pywemo - url = https://github.com/balloob/pywemo.git -[submodule "homeassistant/external/netdisco"] - path = homeassistant/external/netdisco - url = https://github.com/balloob/netdisco.git -[submodule "homeassistant/external/noop"] - path = homeassistant/external/noop - url = https://github.com/balloob/noop.git -[submodule "homeassistant/components/frontend/www_static/polymer/home-assistant-js"] - path = homeassistant/components/frontend/www_static/polymer/home-assistant-js - url = https://github.com/balloob/home-assistant-js.git -[submodule "homeassistant/external/vera"] - path = homeassistant/external/vera - url = https://github.com/jamespcole/home-assistant-vera-api.git -[submodule "homeassistant/external/nzbclients"] - path = homeassistant/external/nzbclients - url = https://github.com/jamespcole/home-assistant-nzb-clients.git diff --git a/.hound.yml b/.hound.yml new file mode 100644 index 0000000000000..c5ab91614dc6d --- /dev/null +++ b/.hound.yml @@ -0,0 +1,2 @@ +python: + enabled: true diff --git a/.ignore b/.ignore new file mode 100644 index 0000000000000..45c6dc5561f70 --- /dev/null +++ b/.ignore @@ -0,0 +1,6 @@ +# Patterns matched in this file will be ignored by supported search utilities + +# Ignore generated html and javascript files +/homeassistant/components/frontend/www_static/*.html +/homeassistant/components/frontend/www_static/*.js +/homeassistant/components/frontend/www_static/panels/*.html diff --git a/.pre-commit-config-all.yaml b/.pre-commit-config-all.yaml new file mode 100644 index 0000000000000..1eabfcb0017cd --- /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 0000000000000..226708bb94712 --- /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 0000000000000..0303f84d51c1f --- /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 d8632860c2a47..6add8c15bfc5a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,12 +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.7.0" + env: TOXENV=lint + - python: "3.7.0" + env: TOXENV=pylint PYLINT_ARGS=--jobs=0 TRAVIS_WAIT=30 + - python: "3.7.0" + env: TOXENV=typing + - python: "3.7.0" + env: TOXENV=py37 + +cache: + pip: true + directories: + - $HOME/.cache/pre-commit +install: pip install -U tox language: python -python: - - "3.4" -install: - - pip install -r requirements.txt - - pip install flake8 pylint coveralls -script: - - flake8 homeassistant --exclude bower_components,external - - pylint homeassistant - - coverage run -m unittest discover tests -after_success: - - coveralls +script: ${TRAVIS_WAIT:+travis_wait $TRAVIS_WAIT} tox --develop diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000000000..1a0bfb16a9be4 --- /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/CLA.md b/CLA.md new file mode 100644 index 0000000000000..f8570cef55116 --- /dev/null +++ b/CLA.md @@ -0,0 +1,39 @@ +# Contributor License Agreement + +``` +By making a contribution to this project, I certify that: + +(a) The contribution was created in whole or in part by me and I + have the right to submit it under the Apache 2.0 license; or + +(b) The contribution is based upon previous work that, to the best + of my knowledge, is covered under an appropriate open source + license and I have the right under that license to submit that + work with modifications, whether created in whole or in part + by me, under the Apache 2.0 license; or + +(c) The contribution was provided directly to me by some other + person who certified (a), (b) or (c) and I have not modified + it. + +(d) I understand and agree that this project and the contribution + are public and that a record of the contribution (including all + personal information I submit with it) is maintained indefinitely + and may be redistributed consistent with this project or the open + source license(s) involved. +``` + +## Attribution + +The text of this license is available under the [Creative Commons Attribution-ShareAlike 3.0 Unported License](http://creativecommons.org/licenses/by-sa/3.0/). It is based on the Linux [Developer Certificate Of Origin](http://elinux.org/Developer_Certificate_Of_Origin), but is modified to explicitly use the Apache 2.0 license +and not mention sign-off. + +## Signing + +To sign this CLA you must first submit a pull request to a repository under the Home Assistant organization. + +## Adoption + +This Contributor License Agreement (CLA) was first announced on January 21st, 2017 in [this][cla-blog] blog post and adopted January 28th, 2017. + +[cla-blog]: https://home-assistant.io/blog/2017/01/21/home-assistant-governance/ diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000000000..82c8f2d709c6a --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,402 @@ +# 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 + +# 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/brother/* @bieniu +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/gios/* @bieniu +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/kef/* @basnijholt +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/local_ip/* @issacg +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/onewire/* @garbled1 +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/pilight/* @trekky12 +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/sentry/* @dcramer +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/surepetcare/* @benleb +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 @tetienne +homeassistant/components/tesla/* @zabuldon @alandtse +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/tmb/* @alemuro +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/webostv/* @bendavid +homeassistant/components/websocket_api/* @home-assistant/core +homeassistant/components/wemo/* @sqldiablo +homeassistant/components/withings/* @vangorra +homeassistant/components/wled/* @frenck +homeassistant/components/workday/* @fabaff +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/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000000000..5d2149dce05ee --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,80 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, gender identity and expression, level of experience, +nationality, personal appearance, race, religion, or sexual identity and +orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or +advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at [safety@home-assistant.io][email]. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available [here][version]. + +## Adoption + +This Code of Conduct was first adopted January 21st, 2017 and announced in [this][coc-blog] blog post. + +[homepage]: http://contributor-covenant.org +[version]: http://contributor-covenant.org/version/1/4/ +[email]: mailto:safety@home-assistant.io +[coc-blog]: https://home-assistant.io/blog/2017/01/21/home-assistant-governance/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0f1c2c658bba1..1921e5d38dd14 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,41 +1,18 @@ -# Adding support for a new device +# Contributing to Home Assistant -For help on building your component, please see the See the [developer documentation on home-assistant.io](https://home-assistant.io/developers/). +Everybody is invited and welcome to contribute to Home Assistant. There is a lot to do...if you are not a developer perhaps you would like to help with the documentation on [home-assistant.io](https://home-assistant.io/)? If you are a developer and have devices in your home which aren't working with Home Assistant yet, why not spend a couple of hours and help to integrate them? -After you finish adding support for your device: +The process is straight-forward. - - update the supported devices in README.md. - - add any new dependencies to requirements.txt. - - Make sure all your code passes Pylint, flake8 (PEP8 and some more) validation. To generate reports, run `pylint homeassistant > pylint.txt` and `flake8 homeassistant --exclude bower_components,external > flake8.txt`. + - Read [How to get faster PR reviews](https://github.com/kubernetes/community/blob/master/contributors/guide/pull-requests.md#best-practices-for-faster-reviews) by Kubernetes (but skip step 0 and 1) + - Fork the Home Assistant [git repository](https://github.com/home-assistant/home-assistant). + - Write the code for your device, notification service, sensor, or IoT thing. + - Ensure tests work. + - Create a Pull Request against the [**dev**](https://github.com/home-assistant/home-assistant/tree/dev) branch of Home Assistant. -If you've added a component: +Still interested? Then you should take a peek at the [developer documentation](https://developers.home-assistant.io/) to get more details. - - update the file [`domain-icon.html`](https://github.com/balloob/home-assistant/blob/master/homeassistant/components/http/www_static/polymer/domain-icon.html) with an icon for your domain ([pick from this list](https://www.polymer-project.org/components/core-icons/demo.html)) - - update the demo component with two states that it provides - - Add your component to home-assistant.conf.example +## Feature suggestions -Since you've updated domain-icon.html, you've made changes to the frontend: - - - run `build_frontend`. This will build a new version of the frontend. Make sure you add the changed files `frontend.py` and `frontend.html` to the commit. - -## Setting states - -It is the responsibility of the component to maintain the states of the devices in your domain. Each device should be a single state and, if possible, a group should be provided that tracks the combined state of the devices. - -A state can have several attributes that will help the frontend in displaying your state: - - - `friendly_name`: this name will be used as the name of the device - - `entity_picture`: this picture will be shown instead of the domain icon - - `unit_of_measurement`: this will be appended to the state in the interface - -These attributes are defined in [homeassistant.components](https://github.com/balloob/home-assistant/blob/master/homeassistant/components/__init__.py#L25). - -## Working on the frontend - -The frontend is composed of Polymer web-components and compiled into the file `frontend.html`. During development you do not want to work with the compiled version but with the seperate files. To have Home Assistant serve the seperate files, set `development=1` for the http-component in your config. - -When you are done with development and ready to commit your changes, run `build_frontend`, set `development=0` in your config and validate that everything still works. - -## Notes on PyLint and PEP8 validation - -In case a PyLint warning cannot be avoided, add a comment to disable the PyLint check for that line. This can be done using the format `# pylint: disable=YOUR-ERROR-NAME`. Example of an unavoidable PyLint warning is if you do not use the passed in datetime if you're listening for time change. +If you want to suggest a new feature for Home Assistant (e.g., new integrations), please open a thread in our [Community Forum: Feature Requests](https://community.home-assistant.io/c/feature-requests). +We use [GitHub for tracking issues](https://github.com/home-assistant/home-assistant/issues), not for tracking feature requests. \ No newline at end of file diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index ff34dc7929783..0000000000000 --- a/Dockerfile +++ /dev/null @@ -1,12 +0,0 @@ -FROM python:3-onbuild -MAINTAINER Paulus Schoutsen - -VOLUME /config - -RUN apt-get update && \ - apt-get install -y cython3 libudev-dev && \ - apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* && \ - pip3 install cython && \ - scripts/build_python_openzwave - -CMD [ "python", "-m", "homeassistant", "--config", "/config" ] diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000000000..fa90a84fc1e57 --- /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/LICENSE b/LICENSE deleted file mode 100644 index b3c5e1df7501a..0000000000000 --- a/LICENSE +++ /dev/null @@ -1,20 +0,0 @@ -The MIT License (MIT) - -Copyright (c) 2013 Paulus Schoutsen - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000000000..261eeb9e9f8b2 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000000000..490b550e705e5 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,4 @@ +include README.rst +include LICENSE.md +graft homeassistant +recursive-exclude * *.py[co] diff --git a/README.md b/README.md deleted file mode 100644 index 43c4d58a33d4f..0000000000000 --- a/README.md +++ /dev/null @@ -1,49 +0,0 @@ -# Home Assistant [![Build Status](https://travis-ci.org/balloob/home-assistant.svg?branch=master)](https://travis-ci.org/balloob/home-assistant) [![Coverage Status](https://img.shields.io/coveralls/balloob/home-assistant.svg)](https://coveralls.io/r/balloob/home-assistant?branch=master) - -This is the source code for Home Assistant. For installation instructions, tutorials and the docs, please see [the website](https://home-assistant.io). For a functioning demo frontend of Home Assistant, [click here](https://home-assistant.io/demo/). - -Home Assistant is a home automation platform running on Python 3. The goal of Home Assistant is to be able to track and control all devices at home and offer a platform for automating control. - -It offers the following functionality through built-in components: - - * Track if devices are home by monitoring connected devices to a wireless router (supporting [OpenWrt](https://openwrt.org/), [Tomato](http://www.polarcloud.com/tomato), [Netgear](http://netgear.com), [DD-WRT](http://www.dd-wrt.com/site/index)) - * Track and control [Philips Hue](http://meethue.com) lights - * Track and control [WeMo switches](http://www.belkin.com/us/Products/home-automation/c/wemo-home-automation/) - * Track and control [Google Chromecasts](http://www.google.com/intl/en/chrome/devices/chromecast) - * Track running services by monitoring `ps` output - * Track and control [Tellstick devices and sensors](http://www.telldus.se/products/tellstick) - * Turn on the lights when people get home after sun set - * Turn on lights slowly during sun set to compensate for light loss - * Turn off all lights and devices when everybody leaves the house - * Offers web interface to monitor and control Home Assistant - * Offers a [REST API](https://home-assistant.io/developers/api.html) for easy integration with other projects - * [Ability to have multiple instances of Home Assistant work together](https://home-assistant.io/developers/architecture.html) - -Home Assistant also includes functionality for controlling HTPCs: - - * Simulate key presses for Play/Pause, Next track, Prev track, Volume up, Volume Down - * Download files - * Open URLs in the default browser - -[![screenshot-states](https://raw.github.com/balloob/home-assistant/master/docs/screenshots.png)](https://home-assistant.io/demo/) - -The system is built modular so support for other devices or actions can be implemented easily. See also the [section on architecture](https://home-assistant.io/developers/architecture.html) and the [section on creating your own components](https://home-assistant.io/developers/creating_components.html). - -If you run into issues while using Home Assistant or during development of a component, reach out to the [Home Assistant developer community](https://groups.google.com/forum/#!forum/home-assistant-dev). - -## Installation instructions / Quick-start guide - -Running Home Assistant requires that python 3.4 and the package requests are installed. Run the following code to install and start Home Assistant: - -```python -git clone --recursive https://github.com/balloob/home-assistant.git -cd home-assistant -pip3 install -r requirements.txt -python3 -m homeassistant --open-ui -``` - -The last command will start the Home Assistant server and launch its webinterface. By default Home Assistant looks for the configuration file `config/home-assistant.conf`. A standard configuration file will be written if none exists. - -If you are still exploring if you want to use Home Assistant in the first place, you can enable the demo mode by adding the `--demo-mode` argument to the last command. - -Please see [the getting started guide](https://home-assistant.io/getting-started/) on how to further configure Home Asssitant. diff --git a/README.rst b/README.rst new file mode 100644 index 0000000000000..0de30d43c650f --- /dev/null +++ b/README.rst @@ -0,0 +1,28 @@ +Home Assistant |Chat Status| +================================================================================= + +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 `__, +`tutorials `__ and `documentation `__. + +|screenshot-states| + +Featured integrations +--------------------- + +|screenshot-components| + +The system is built using a modular approach so support for other devices or actions can be implemented easily. See also the `section on architecture `__ and the `section on creating your own +components `__. + +If you run into issues while using Home Assistant or during development +of a component, check the `Home Assistant help section `__ of our website for further help and information. + +.. |Chat Status| image:: https://img.shields.io/discord/330944238910963714.svg + :target: https://discord.gg/c5DvZ4e +.. |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/integrations/ diff --git a/azure-pipelines-ci.yml b/azure-pipelines-ci.yml new file mode 100644 index 0000000000000..546b63950fe4d --- /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 0000000000000..ddf95d354c38f --- /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.9' + - 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,odroid-n2' + 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 0000000000000..2fd49c056f7fd --- /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 0000000000000..5092010c49c38 --- /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/config/configuration.yaml.example b/config/configuration.yaml.example deleted file mode 100644 index 125be6e2d054d..0000000000000 --- a/config/configuration.yaml.example +++ /dev/null @@ -1,162 +0,0 @@ -homeassistant: - # Omitted values in this section will be auto detected using freegeoip.net - - # Location required to calculate the time the sun rises and sets - latitude: 32.87336 - longitude: 117.22743 - - # C for Celcius, F for Fahrenheit - temperature_unit: C - - # Pick yours from here: - # http://en.wikipedia.org/wiki/List_of_tz_database_time_zones - time_zone: America/Los_Angeles - - # Name of the location where Home Assistant is running - name: Home - -http: - api_password: mypass - # Set to 1 to enable development mode - # development: 1 - -light: -# platform: hue - -wink: - # Get your token at https://winkbearertoken.appspot.com - access_token: 'YOUR_TOKEN' - -device_tracker: - # The following types are available: netgear, tomato, luci, nmap_tracker - platform: netgear - host: 192.168.1.1 - username: admin - password: PASSWORD - # http_id is needed for Tomato routers only - # http_id: ABCDEFGHH - # For nmap_tracker, only the IP addresses to scan are needed: - # hosts: 192.168.1.1/24 # netmask prefix notation or - # hosts: 192.168.1.1-255 # address range - -chromecast: - -switch: - platform: wemo - -thermostat: - platform: nest - # Required: username and password that are used to login to the Nest thermostat. - username: myemail@mydomain.com - password: mypassword - -downloader: - download_dir: downloads - -notify: - platform: pushbullet - api_key: ABCDEFGHJKLMNOPQRSTUVXYZ - -device_sun_light_trigger: - # Optional: specify a specific light/group of lights that has to be turned on - light_group: group.living_room - # Optional: specify which light profile to use when turning lights on - light_profile: relax - # Optional: disable lights being turned off when everybody leaves the house - # disable_turn_off: 1 - -# A comma seperated list of states that have to be tracked as a single group -# Grouped states should share the same type of states (ON/OFF or HOME/NOT_HOME) -group: - living_room: - - light.Bowl - - light.Ceiling - - light.TV_back_light - children: - - device_tracker.child_1 - - device_tracker.child_2 - -process: - # items are which processes to look for: : - xbmc: XBMC.App - -example: - -simple_alarm: - # Which light/light group has to flash when a known device comes home - known_light: light.Bowl - # Which light/light group has to flash red when light turns on while no one home - unknown_light: group.living_room - -browser: - -keyboard: - -automation: - platform: state - alias: Sun starts shining - - state_entity_id: sun.sun - # Next two are optional, omit to match all - state_from: below_horizon - state_to: above_horizon - - execute_service: light.turn_off - service_entity_id: group.living_room - -automation 2: - platform: time - alias: Beer o Clock - - time_hours: 16 - time_minutes: 0 - time_seconds: 0 - - execute_service: notify.notify - service_data: - message: It's 4, time for beer! - -sensor: - platform: systemmonitor - resources: - - type: 'disk_use_percent' - arg: '/' - - type: 'disk_use_percent' - arg: '/home' - - type: 'disk_use' - arg: '/home' - - type: 'disk_free' - arg: '/' - - type: 'memory_use_percent' - - type: 'memory_use' - - type: 'memory_free' - - type: 'processor_use' - - type: 'process' - arg: 'octave-cli' - -script: - # Turns on the bedroom lights and then the living room lights 1 minute later - wakeup: - alias: Wake Up - sequence: - # alias is optional - - alias: Bedroom lights on - execute_service: light.turn_on - service_data: - entity_id: group.bedroom - - delay: - # supports seconds, milliseconds, minutes, hours, etc. - minutes: 1 - - alias: Living room lights on - execute_service: light.turn_on - service_data: - entity_id: group.living_room - -scene: - - name: Romantic - entities: - light.tv_back_light: on - light.ceiling: - state: on - color: [0.33, 0.66] - brightness: 200 diff --git a/config/custom_components/example.py b/config/custom_components/example.py deleted file mode 100644 index a972e3ab57647..0000000000000 --- a/config/custom_components/example.py +++ /dev/null @@ -1,119 +0,0 @@ -""" -custom_components.example -~~~~~~~~~~~~~~~~~~~~~~~~~ - -Example component to target an entity_id to: - - turn it on at 7AM in the morning - - turn it on if anyone comes home and it is off - - turn it off if all lights are turned off - - turn it off if all people leave the house - - offer a service to turn it on for 10 seconds -""" -import time -import logging - -from homeassistant.const import STATE_HOME, STATE_NOT_HOME, STATE_ON, STATE_OFF -import homeassistant.loader as loader -from homeassistant.helpers import validate_config -import homeassistant.components as core - -# The domain of your component. Should be equal to the name of your component -DOMAIN = "example" - -# List of component names (string) your component depends upon -# We depend on group because group will be loaded after all the components that -# initalize devices have been setup. -DEPENDENCIES = ['group'] - -# Configuration key for the entity id we are targetting -CONF_TARGET = 'target' - -# Name of the service that we expose -SERVICE_FLASH = 'flash' - -_LOGGER = logging.getLogger(__name__) - - -def setup(hass, config): - """ Setup example component. """ - - # Validate that all required config options are given - if not validate_config(config, {DOMAIN: [CONF_TARGET]}, _LOGGER): - return False - - target_id = config[DOMAIN][CONF_TARGET] - - # Validate that the target entity id exists - if hass.states.get(target_id) is None: - _LOGGER.error("Target entity id %s does not exist", target_id) - - # Tell the bootstrapper that we failed to initialize - return False - - # We will use the component helper methods to check the states. - device_tracker = loader.get_component('device_tracker') - light = loader.get_component('light') - - def track_devices(entity_id, old_state, new_state): - """ Called when the group.all devices change state. """ - - # If anyone comes home and the core is not on, turn it on. - if new_state.state == STATE_HOME and not core.is_on(hass, target_id): - - core.turn_on(hass, target_id) - - # If all people leave the house and the core is on, turn it off - elif new_state.state == STATE_NOT_HOME and core.is_on(hass, target_id): - - core.turn_off(hass, target_id) - - # Register our track_devices method to receive state changes of the - # all tracked devices group. - hass.states.track_change( - device_tracker.ENTITY_ID_ALL_DEVICES, track_devices) - - def wake_up(now): - """ Turn it on in the morning if there are people home and - it is not already on. """ - - if device_tracker.is_on(hass) and not core.is_on(hass, target_id): - _LOGGER.info('People home at 7AM, turning it on') - core.turn_on(hass, target_id) - - # Register our wake_up service to be called at 7AM in the morning - hass.track_time_change(wake_up, hour=7, minute=0, second=0) - - def all_lights_off(entity_id, old_state, new_state): - """ If all lights turn off, turn off. """ - - if core.is_on(hass, target_id): - _LOGGER.info('All lights have been turned off, turning it off') - core.turn_off(hass, target_id) - - # Register our all_lights_off method to be called when all lights turn off - hass.states.track_change( - light.ENTITY_ID_ALL_LIGHTS, all_lights_off, STATE_ON, STATE_OFF) - - def flash_service(call): - """ Service that will turn the target off for 10 seconds - if on and vice versa. """ - - if core.is_on(hass, target_id): - core.turn_off(hass, target_id) - - time.sleep(10) - - core.turn_on(hass, target_id) - - else: - core.turn_on(hass, target_id) - - time.sleep(10) - - core.turn_off(hass, target_id) - - # Register our service with HASS. - hass.services.register(DOMAIN, SERVICE_FLASH, flash_service) - - # Tells the bootstrapper that the component was succesfully initialized - return True diff --git a/config/custom_components/hello_world.py b/config/custom_components/hello_world.py deleted file mode 100644 index be1b935c8aded..0000000000000 --- a/config/custom_components/hello_world.py +++ /dev/null @@ -1,22 +0,0 @@ -""" -custom_components.hello_world -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Implements the bare minimum that a component should implement. -""" - -# The domain of your component. Should be equal to the name of your component -DOMAIN = "hello_world" - -# List of component names (string) your component depends upon -DEPENDENCIES = [] - - -def setup(hass, config): - """ Setup our skeleton component. """ - - # States are in the format DOMAIN.OBJECT_ID - hass.states.set('hello_world.Hello_World', 'Works!') - - # return boolean to indicate that initialization was successful - return True diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000000000..69893c438471d --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,230 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +PAPER = +BUILDDIR = build + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source +# the i18n builder cannot share the environment and doctrees with the others +I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source + +.PHONY: help +help: + @echo "Please use \`make ' where is one of" + @echo " html to make standalone HTML files" + @echo " livehtml to make standalone HTML files via sphinx-autobuild" + @echo " dirhtml to make HTML files named index.html in directories" + @echo " singlehtml to make a single large HTML file" + @echo " pickle to make pickle files" + @echo " json to make JSON files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " qthelp to make HTML files and a qthelp project" + @echo " applehelp to make an Apple Help Book" + @echo " devhelp to make HTML files and a Devhelp project" + @echo " epub to make an epub" + @echo " epub3 to make an epub3" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " latexpdf to make LaTeX files and run them through pdflatex" + @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" + @echo " text to make text files" + @echo " man to make manual pages" + @echo " texinfo to make Texinfo files" + @echo " info to make Texinfo files and run them through makeinfo" + @echo " gettext to make PO message catalogs" + @echo " changes to make an overview of all changed/added/deprecated items" + @echo " xml to make Docutils-native XML files" + @echo " pseudoxml to make pseudoxml-XML files for display purposes" + @echo " linkcheck to check all external links for integrity" + @echo " doctest to run all doctests embedded in the documentation (if enabled)" + @echo " coverage to run coverage check of the documentation (if enabled)" + @echo " dummy to check syntax errors of document sources" + +.PHONY: clean +clean: + rm -rf $(BUILDDIR)/* + +.PHONY: html +html: + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +.PHONY: livehtml +livehtml: + sphinx-autobuild -z ../homeassistant/ --port 0 -B -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + +.PHONY: dirhtml +dirhtml: + $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." + +.PHONY: singlehtml +singlehtml: + $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml + @echo + @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." + +.PHONY: pickle +pickle: + $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle + @echo + @echo "Build finished; now you can process the pickle files." + +.PHONY: json +json: + $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json + @echo + @echo "Build finished; now you can process the JSON files." + +.PHONY: htmlhelp +htmlhelp: + $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp + @echo + @echo "Build finished; now you can run HTML Help Workshop with the" \ + ".hhp project file in $(BUILDDIR)/htmlhelp." + +.PHONY: qthelp +qthelp: + $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp + @echo + @echo "Build finished; now you can run "qcollectiongenerator" with the" \ + ".qhcp project file in $(BUILDDIR)/qthelp, like this:" + @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Home-Assistant.qhcp" + @echo "To view the help file:" + @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Home-Assistant.qhc" + +.PHONY: applehelp +applehelp: + $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp + @echo + @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." + @echo "N.B. You won't be able to view it unless you put it in" \ + "~/Library/Documentation/Help or install it in your application" \ + "bundle." + +.PHONY: devhelp +devhelp: + $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp + @echo + @echo "Build finished." + @echo "To view the help file:" + @echo "# mkdir -p $$HOME/.local/share/devhelp/Home-Assistant" + @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Home-Assistant" + @echo "# devhelp" + +.PHONY: epub +epub: + $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub + @echo + @echo "Build finished. The epub file is in $(BUILDDIR)/epub." + +.PHONY: epub3 +epub3: + $(SPHINXBUILD) -b epub3 $(ALLSPHINXOPTS) $(BUILDDIR)/epub3 + @echo + @echo "Build finished. The epub3 file is in $(BUILDDIR)/epub3." + +.PHONY: latex +latex: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo + @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." + @echo "Run \`make' in that directory to run these through (pdf)latex" \ + "(use \`make latexpdf' here to do that automatically)." + +.PHONY: latexpdf +latexpdf: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through pdflatex..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +.PHONY: latexpdfja +latexpdfja: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through platex and dvipdfmx..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +.PHONY: text +text: + $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text + @echo + @echo "Build finished. The text files are in $(BUILDDIR)/text." + +.PHONY: man +man: + $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man + @echo + @echo "Build finished. The manual pages are in $(BUILDDIR)/man." + +.PHONY: texinfo +texinfo: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo + @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." + @echo "Run \`make' in that directory to run these through makeinfo" \ + "(use \`make info' here to do that automatically)." + +.PHONY: info +info: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo "Running Texinfo files through makeinfo..." + make -C $(BUILDDIR)/texinfo info + @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." + +.PHONY: gettext +gettext: + $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale + @echo + @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." + +.PHONY: changes +changes: + $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes + @echo + @echo "The overview file is in $(BUILDDIR)/changes." + +.PHONY: linkcheck +linkcheck: + $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck + @echo + @echo "Link check complete; look for any errors in the above output " \ + "or in $(BUILDDIR)/linkcheck/output.txt." + +.PHONY: doctest +doctest: + $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest + @echo "Testing of doctests in the sources finished, look at the " \ + "results in $(BUILDDIR)/doctest/output.txt." + +.PHONY: coverage +coverage: + $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage + @echo "Testing of coverage in the sources finished, look at the " \ + "results in $(BUILDDIR)/coverage/python.txt." + +.PHONY: xml +xml: + $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml + @echo + @echo "Build finished. The XML files are in $(BUILDDIR)/xml." + +.PHONY: pseudoxml +pseudoxml: + $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml + @echo + @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." + +.PHONY: dummy +dummy: + $(SPHINXBUILD) -b dummy $(ALLSPHINXOPTS) $(BUILDDIR)/dummy + @echo + @echo "Build finished. Dummy builder generates no files." diff --git a/docs/architecture-remote.png b/docs/architecture-remote.png deleted file mode 100644 index 3109c92184675..0000000000000 Binary files a/docs/architecture-remote.png and /dev/null differ diff --git a/docs/architecture.png b/docs/architecture.png deleted file mode 100644 index 7fe62cf3144fd..0000000000000 Binary files a/docs/architecture.png and /dev/null differ diff --git a/docs/build/.empty b/docs/build/.empty new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000000000..7713f1cadb04c --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,281 @@ +@ECHO OFF + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set BUILDDIR=build +set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% source +set I18NSPHINXOPTS=%SPHINXOPTS% source +if NOT "%PAPER%" == "" ( + set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% + set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% +) + +if "%1" == "" goto help + +if "%1" == "help" ( + :help + echo.Please use `make ^` where ^ is one of + echo. html to make standalone HTML files + echo. dirhtml to make HTML files named index.html in directories + echo. singlehtml to make a single large HTML file + echo. pickle to make pickle files + echo. json to make JSON files + echo. htmlhelp to make HTML files and a HTML help project + echo. qthelp to make HTML files and a qthelp project + echo. devhelp to make HTML files and a Devhelp project + echo. epub to make an epub + echo. epub3 to make an epub3 + echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter + echo. text to make text files + echo. man to make manual pages + echo. texinfo to make Texinfo files + echo. gettext to make PO message catalogs + echo. changes to make an overview over all changed/added/deprecated items + echo. xml to make Docutils-native XML files + echo. pseudoxml to make pseudoxml-XML files for display purposes + echo. linkcheck to check all external links for integrity + echo. doctest to run all doctests embedded in the documentation if enabled + echo. coverage to run coverage check of the documentation if enabled + echo. dummy to check syntax errors of document sources + goto end +) + +if "%1" == "clean" ( + for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i + del /q /s %BUILDDIR%\* + goto end +) + + +REM Check if sphinx-build is available and fallback to Python version if any +%SPHINXBUILD% 1>NUL 2>NUL +if errorlevel 9009 goto sphinx_python +goto sphinx_ok + +:sphinx_python + +set SPHINXBUILD=python -m sphinx.__init__ +%SPHINXBUILD% 2> nul +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +:sphinx_ok + + +if "%1" == "html" ( + %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/html. + goto end +) + +if "%1" == "dirhtml" ( + %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. + goto end +) + +if "%1" == "singlehtml" ( + %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. + goto end +) + +if "%1" == "pickle" ( + %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the pickle files. + goto end +) + +if "%1" == "json" ( + %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the JSON files. + goto end +) + +if "%1" == "htmlhelp" ( + %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run HTML Help Workshop with the ^ +.hhp project file in %BUILDDIR%/htmlhelp. + goto end +) + +if "%1" == "qthelp" ( + %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run "qcollectiongenerator" with the ^ +.qhcp project file in %BUILDDIR%/qthelp, like this: + echo.^> qcollectiongenerator %BUILDDIR%\qthelp\Home-Assistant.qhcp + echo.To view the help file: + echo.^> assistant -collectionFile %BUILDDIR%\qthelp\Home-Assistant.ghc + goto end +) + +if "%1" == "devhelp" ( + %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. + goto end +) + +if "%1" == "epub" ( + %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The epub file is in %BUILDDIR%/epub. + goto end +) + +if "%1" == "epub3" ( + %SPHINXBUILD% -b epub3 %ALLSPHINXOPTS% %BUILDDIR%/epub3 + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The epub3 file is in %BUILDDIR%/epub3. + goto end +) + +if "%1" == "latex" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "latexpdf" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + cd %BUILDDIR%/latex + make all-pdf + cd %~dp0 + echo. + echo.Build finished; the PDF files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "latexpdfja" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + cd %BUILDDIR%/latex + make all-pdf-ja + cd %~dp0 + echo. + echo.Build finished; the PDF files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "text" ( + %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The text files are in %BUILDDIR%/text. + goto end +) + +if "%1" == "man" ( + %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The manual pages are in %BUILDDIR%/man. + goto end +) + +if "%1" == "texinfo" ( + %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. + goto end +) + +if "%1" == "gettext" ( + %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The message catalogs are in %BUILDDIR%/locale. + goto end +) + +if "%1" == "changes" ( + %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes + if errorlevel 1 exit /b 1 + echo. + echo.The overview file is in %BUILDDIR%/changes. + goto end +) + +if "%1" == "linkcheck" ( + %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck + if errorlevel 1 exit /b 1 + echo. + echo.Link check complete; look for any errors in the above output ^ +or in %BUILDDIR%/linkcheck/output.txt. + goto end +) + +if "%1" == "doctest" ( + %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest + if errorlevel 1 exit /b 1 + echo. + echo.Testing of doctests in the sources finished, look at the ^ +results in %BUILDDIR%/doctest/output.txt. + goto end +) + +if "%1" == "coverage" ( + %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage + if errorlevel 1 exit /b 1 + echo. + echo.Testing of coverage in the sources finished, look at the ^ +results in %BUILDDIR%/coverage/python.txt. + goto end +) + +if "%1" == "xml" ( + %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The XML files are in %BUILDDIR%/xml. + goto end +) + +if "%1" == "pseudoxml" ( + %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. + goto end +) + +if "%1" == "dummy" ( + %SPHINXBUILD% -b dummy %ALLSPHINXOPTS% %BUILDDIR%/dummy + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. Dummy builder generates no files. + goto end +) + +:end diff --git a/docs/screenshot-components.png b/docs/screenshot-components.png new file mode 100644 index 0000000000000..44dc373e285e0 Binary files /dev/null and b/docs/screenshot-components.png differ diff --git a/docs/screenshots.png b/docs/screenshots.png index 09dff77c8946c..1305cddbb9dfe 100644 Binary files a/docs/screenshots.png and b/docs/screenshots.png differ diff --git a/docs/source/_ext/edit_on_github.py b/docs/source/_ext/edit_on_github.py new file mode 100644 index 0000000000000..a31fb13ebf115 --- /dev/null +++ b/docs/source/_ext/edit_on_github.py @@ -0,0 +1,44 @@ +""" +Sphinx extension to add ReadTheDocs-style "Edit on GitHub" links to the +sidebar. + +Loosely based on https://github.com/astropy/astropy/pull/347 +""" + +import os +import warnings + +__licence__ = 'BSD (3 clause)' + + +def get_github_url(app, view, path): + github_fmt = 'https://github.com/{}/{}/{}/{}{}' + return ( + github_fmt.format(app.config.edit_on_github_project, view, + app.config.edit_on_github_branch, + app.config.edit_on_github_src_path, path)) + + +def html_page_context(app, pagename, templatename, context, doctree): + if templatename != 'page.html': + return + + if not app.config.edit_on_github_project: + warnings.warn("edit_on_github_project not specified") + return + if not doctree: + warnings.warn("doctree is None") + return + path = os.path.relpath(doctree.get('source'), app.builder.srcdir) + show_url = get_github_url(app, 'blob', path) + edit_url = get_github_url(app, 'edit', path) + + context['show_on_github_url'] = show_url + context['edit_on_github_url'] = edit_url + + +def setup(app): + app.add_config_value('edit_on_github_project', '', True) + app.add_config_value('edit_on_github_branch', 'master', True) + app.add_config_value('edit_on_github_src_path', '', True) # 'eg' "docs/" + app.connect('html-page-context', html_page_context) diff --git a/homeassistant/components/frontend/www_static/favicon.ico b/docs/source/_static/favicon.ico similarity index 100% rename from homeassistant/components/frontend/www_static/favicon.ico rename to docs/source/_static/favicon.ico diff --git a/docs/source/_static/logo-apple.png b/docs/source/_static/logo-apple.png new file mode 100644 index 0000000000000..03b5dd7780c0e Binary files /dev/null and b/docs/source/_static/logo-apple.png differ diff --git a/docs/source/_static/logo.png b/docs/source/_static/logo.png new file mode 100644 index 0000000000000..3cd8005a1662a Binary files /dev/null and b/docs/source/_static/logo.png differ diff --git a/docs/source/_templates/links.html b/docs/source/_templates/links.html new file mode 100644 index 0000000000000..53a8d1e425d70 --- /dev/null +++ b/docs/source/_templates/links.html @@ -0,0 +1,6 @@ + diff --git a/docs/source/_templates/sourcelink.html b/docs/source/_templates/sourcelink.html new file mode 100644 index 0000000000000..8cf2c4f92ae78 --- /dev/null +++ b/docs/source/_templates/sourcelink.html @@ -0,0 +1,13 @@ +{%- if show_source and has_source and sourcename %} +

{{ _('This Page') }}

+ +{%- endif %} diff --git a/docs/source/api/bootstrap.rst b/docs/source/api/bootstrap.rst new file mode 100644 index 0000000000000..363f796996165 --- /dev/null +++ b/docs/source/api/bootstrap.rst @@ -0,0 +1,7 @@ +.. _bootstrap_module: + +:mod:`homeassistant.bootstrap` +------------------------- + +.. automodule:: homeassistant.bootstrap + :members: diff --git a/docs/source/api/core.rst b/docs/source/api/core.rst new file mode 100644 index 0000000000000..bbaf591052ca0 --- /dev/null +++ b/docs/source/api/core.rst @@ -0,0 +1,38 @@ +.. _core_module: + +:mod:`homeassistant.core` +------------------------- + +.. automodule:: homeassistant.core + +.. autoclass:: Config + :members: + +.. autoclass:: Event + :members: + +.. autoclass:: EventBus + :members: + +.. autoclass:: HomeAssistant + :members: + +.. autoclass:: State + :members: + +.. autoclass:: StateMachine + :members: + +.. autoclass:: ServiceCall + :members: + +.. autoclass:: ServiceRegistry + :members: + +Module contents +--------------- + +.. automodule:: homeassistant.core + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/api/device_tracker.rst b/docs/source/api/device_tracker.rst new file mode 100644 index 0000000000000..e3d65174815b1 --- /dev/null +++ b/docs/source/api/device_tracker.rst @@ -0,0 +1,10 @@ +.. _components_device_tracker_module: + +:mod:`homeassistant.components.device_tracker` +---------------------------------------------- + +.. automodule:: homeassistant.components.device_tracker + :members: + +.. autoclass:: Device + :members: diff --git a/docs/source/api/entity.rst b/docs/source/api/entity.rst new file mode 100644 index 0000000000000..99ae43dc3ae2c --- /dev/null +++ b/docs/source/api/entity.rst @@ -0,0 +1,12 @@ +.. _helpers_entity_module: + +:mod:`homeassistant.helpers.entity` +----------------------------------- + +.. automodule:: homeassistant.helpers.entity + +.. autoclass:: Entity + :members: + +.. autoclass:: ToggleEntity + :members: diff --git a/docs/source/api/event.rst b/docs/source/api/event.rst new file mode 100644 index 0000000000000..b1295b814092f --- /dev/null +++ b/docs/source/api/event.rst @@ -0,0 +1,20 @@ +.. _helpers_event_module: + +:mod:`homeassistant.helpers.event` +---------------------------------- + +.. automodule:: homeassistant.helpers.event + +.. autofunction:: track_state_change + +.. autofunction:: track_point_in_time + +.. autofunction:: track_point_in_utc_time + +.. autofunction:: track_sunrise + +.. autofunction:: track_sunset + +.. autofunction:: track_utc_time_change + +.. autofunction:: track_time_change diff --git a/docs/source/api/helpers.rst b/docs/source/api/helpers.rst new file mode 100644 index 0000000000000..8ad645b7977c3 --- /dev/null +++ b/docs/source/api/helpers.rst @@ -0,0 +1,287 @@ +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 +-------------------------------------- + +.. automodule:: homeassistant.helpers.condition + :members: + :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 +---------------------------------------------- + +.. automodule:: homeassistant.helpers.config_validation + :members: + :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 +-------------------------------------- + +.. automodule:: homeassistant.helpers.discovery + :members: + :undoc-members: + :show-inheritance: + +homeassistant.helpers.dispatcher module +--------------------------------------- + +.. automodule:: homeassistant.helpers.dispatcher + :members: + :undoc-members: + :show-inheritance: + +homeassistant.helpers.entity module +----------------------------------- + +.. automodule:: homeassistant.helpers.entity + :members: + :undoc-members: + :show-inheritance: + +homeassistant.helpers.entity_component module +--------------------------------------------- + +.. automodule:: homeassistant.helpers.entity_component + :members: + :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 +---------------------------------- + +.. automodule:: homeassistant.helpers.event + :members: + :undoc-members: + :show-inheritance: + +homeassistant.helpers.icon module +--------------------------------- + +.. 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: + +homeassistant.helpers.location module +------------------------------------- + +.. automodule:: homeassistant.helpers.location + :members: + :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 +----------------------------------- + +.. automodule:: homeassistant.helpers.script + :members: + :undoc-members: + :show-inheritance: + +homeassistant.helpers.service module +------------------------------------ + +.. automodule:: homeassistant.helpers.service + :members: + :undoc-members: + :show-inheritance: + +homeassistant.helpers.signal module +----------------------------------- + +.. automodule:: homeassistant.helpers.signal + :members: + :undoc-members: + :show-inheritance: + +homeassistant.helpers.state module +---------------------------------- + +.. automodule:: homeassistant.helpers.state + :members: + :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 +------------------------------------- + +.. automodule:: homeassistant.helpers.template + :members: + :undoc-members: + :show-inheritance: + +homeassistant.helpers.translation module +----------------------------------------- + +.. automodule:: homeassistant.helpers.translation + :members: + :undoc-members: + :show-inheritance: + +homeassistant.helpers.typing module +----------------------------------- + +.. automodule:: homeassistant.helpers.typing + :members: + :undoc-members: + :show-inheritance: + + +Module contents +--------------- + +.. automodule:: homeassistant.helpers + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/api/homeassistant.rst b/docs/source/api/homeassistant.rst new file mode 100644 index 0000000000000..599f5fb801957 --- /dev/null +++ b/docs/source/api/homeassistant.rst @@ -0,0 +1,70 @@ +homeassistant package +===================== + +Subpackages +----------- + +.. toctree:: + + helpers + util + +Submodules +---------- + +bootstrap module +------------------------------ + +.. automodule:: homeassistant.bootstrap + :members: + :undoc-members: + :show-inheritance: + +config module +--------------------------- + +.. automodule:: homeassistant.config + :members: + :undoc-members: + :show-inheritance: + +const module +-------------------------- + +.. automodule:: homeassistant.const + :members: + :undoc-members: + :show-inheritance: + +core module +------------------------- + +.. automodule:: homeassistant.core + :members: + :undoc-members: + :show-inheritance: + +exceptions module +------------------------------- + +.. automodule:: homeassistant.exceptions + :members: + :undoc-members: + :show-inheritance: + +loader module +--------------------------- + +.. automodule:: homeassistant.loader + :members: + :undoc-members: + :show-inheritance: + + +Module contents +--------------- + +.. automodule:: homeassistant + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/api/util.rst b/docs/source/api/util.rst new file mode 100644 index 0000000000000..fb61cd94fe622 --- /dev/null +++ b/docs/source/api/util.rst @@ -0,0 +1,86 @@ +homeassistant.util package +========================== + +Submodules +---------- + +homeassistant.util.async_ module +------------------------------- + +.. automodule:: homeassistant.util.async_ + :members: + :undoc-members: + :show-inheritance: + +homeassistant.util.color module +------------------------------- + +.. automodule:: homeassistant.util.color + :members: + :undoc-members: + :show-inheritance: + +homeassistant.util.distance module +---------------------------------- + +.. automodule:: homeassistant.util.distance + :members: + :undoc-members: + :show-inheritance: + +homeassistant.util.dt module +---------------------------- + +.. automodule:: homeassistant.util.dt + :members: + :undoc-members: + :show-inheritance: + +homeassistant.util.location module +---------------------------------- + +.. automodule:: homeassistant.util.location + :members: + :undoc-members: + :show-inheritance: + +homeassistant.util.package module +--------------------------------- + +.. automodule:: homeassistant.util.package + :members: + :undoc-members: + :show-inheritance: + +homeassistant.util.temperature module +------------------------------------- + +.. automodule:: homeassistant.util.temperature + :members: + :undoc-members: + :show-inheritance: + +homeassistant.util.unit_system module +------------------------------------- + +.. automodule:: homeassistant.util.unit_system + :members: + :undoc-members: + :show-inheritance: + +homeassistant.util.yaml module +------------------------------ + +.. automodule:: homeassistant.util.yaml + :members: + :undoc-members: + :show-inheritance: + + +Module contents +--------------- + +.. automodule:: homeassistant.util + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000000000..f36b5b8124a41 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,432 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# Home-Assistant documentation build configuration file, created by +# sphinx-quickstart on Sun Aug 28 13:13:10 2016. +# +# This file is execfile()d with the current directory set to its +# containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +# If extensions (or modules to document with autodoc) are in another directory, +# 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 inspect +import os +import sys + +from homeassistant.const import __short_version__, __version__ + +PROJECT_NAME = 'Home Assistant' +PROJECT_PACKAGE_NAME = 'homeassistant' +PROJECT_AUTHOR = 'The Home Assistant Authors' +PROJECT_COPYRIGHT = ' 2013-2018, {}'.format(PROJECT_AUTHOR) +PROJECT_LONG_DESCRIPTION = ('Home Assistant is an open-source ' + 'home automation platform running on Python 3. ' + 'Track and control all devices at home and ' + 'automate control. ' + 'Installation in less than a minute.') +PROJECT_GITHUB_USERNAME = 'home-assistant' +PROJECT_GITHUB_REPOSITORY = 'home-assistant' + +GITHUB_PATH = '{}/{}'.format( + PROJECT_GITHUB_USERNAME, PROJECT_GITHUB_REPOSITORY) +GITHUB_URL = 'https://github.com/{}'.format(GITHUB_PATH) + + +sys.path.insert(0, os.path.abspath('_ext')) +sys.path.insert(0, os.path.abspath('../homeassistant')) + +# -- General configuration ------------------------------------------------ + +# If your documentation needs a minimal Sphinx version, state it here. +# +# needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.linkcode', + 'sphinx_autodoc_annotation', + 'edit_on_github' +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# +# source_suffix = ['.rst', '.md'] +source_suffix = '.rst' + +# The encoding of source files. +# +# source_encoding = 'utf-8-sig' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = PROJECT_NAME +copyright = PROJECT_COPYRIGHT +author = PROJECT_AUTHOR + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +version = __short_version__ +# The full version, including alpha/beta/rc tags. +release = __version__ + +code_branch = 'dev' if 'dev' in __version__ else 'master' + +# Edit on Github config +edit_on_github_project = GITHUB_PATH +edit_on_github_branch = code_branch +edit_on_github_src_path = 'docs/source/' + + +def linkcode_resolve(domain, info): + """Determine the URL corresponding to Python object.""" + if domain != 'py': + return None + modname = info['module'] + fullname = info['fullname'] + submod = sys.modules.get(modname) + if submod is None: + return None + obj = submod + for part in fullname.split('.'): + try: + obj = getattr(obj, part) + except: + return None + try: + fn = inspect.getsourcefile(obj) + except: + fn = None + if not fn: + return None + try: + source, lineno = inspect.findsource(obj) + except: + lineno = None + if lineno: + linespec = "#L%d" % (lineno + 1) + else: + linespec = "" + index = fn.find("/homeassistant/") + if index == -1: + index = 0 + + fn = fn[index:] + + return '{}/blob/{}/{}{}'.format(GITHUB_URL, code_branch, fn, linespec) + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +# +# today = '' +# +# Else, today_fmt is used as the format for a strftime call. +# +# today_fmt = '%B %d, %Y' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This patterns also effect to html_static_path and html_extra_path +exclude_patterns = [] + +# The reST default role (used for this markup: `text`) to use for all +# documents. +# +# default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +# +# add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +# +# add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +# +# show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# A list of ignored prefixes for module index sorting. +# modindex_common_prefix = [] + +# If true, keep warnings as "system message" paragraphs in the built documents. +# keep_warnings = False + +# If true, `todo` and `todoList` produce output, else they produce nothing. +todo_include_todos = False + + +# -- Options for HTML output ---------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = 'alabaster' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +# +html_theme_options = { + 'logo': 'logo.png', + 'logo_name': PROJECT_NAME, + 'description': PROJECT_LONG_DESCRIPTION, + 'github_user': PROJECT_GITHUB_USERNAME, + 'github_repo': PROJECT_GITHUB_REPOSITORY, + 'github_type': 'star', + 'github_banner': True, + 'travis_button': True, + 'touch_icon': 'logo-apple.png', + # 'fixed_sidebar': True, # Re-enable when we have more content +} + +# Add any paths that contain custom themes here, relative to this directory. +# html_theme_path = [] + +# The name for this set of Sphinx documents. +# " v documentation" by default. +# +# html_title = 'Home-Assistant v0.27.0' + +# A shorter title for the navigation bar. Default is the same as html_title. +# +# html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +# +# html_logo = '_static/logo.png' + +# The name of an image file (relative to this directory) to use as a favicon of +# the docs. +# This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +# +html_favicon = '_static/favicon.ico' + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# Add any extra paths that contain custom files (such as robots.txt or +# .htaccess) here, relative to this directory. These files are copied +# directly to the root of the documentation. +# +# html_extra_path = [] + +# If not None, a 'Last updated on:' timestamp is inserted at every page +# bottom, using the given strftime format. +# The empty string is equivalent to '%b %d, %Y'. +# +html_last_updated_fmt = '%b %d, %Y' + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +# +html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +# +html_sidebars = { + '**': [ + 'about.html', + 'links.html', + 'searchbox.html', + 'sourcelink.html', + 'navigation.html', + 'relations.html' + ] +} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +# +# html_additional_pages = {} + +# If false, no module index is generated. +# +# html_domain_indices = True + +# If false, no index is generated. +# +# html_use_index = True + +# If true, the index is split into individual pages for each letter. +# +# html_split_index = False + +# If true, links to the reST sources are added to the pages. +# +# html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +# +# html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +# +# html_show_copyright = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +# +# html_use_opensearch = '' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +# html_file_suffix = None + +# Language to be used for generating the HTML full-text search index. +# Sphinx supports the following languages: +# 'da', 'de', 'en', 'es', 'fi', 'fr', 'h', 'it', 'ja' +# 'nl', 'no', 'pt', 'ro', 'r', 'sv', 'tr', 'zh' +# +# html_search_language = 'en' + +# A dictionary with options for the search language support, empty by default. +# 'ja' uses this config value. +# 'zh' user can custom change `jieba` dictionary path. +# +# html_search_options = {'type': 'default'} + +# The name of a javascript file (relative to the configuration directory) that +# implements a search results scorer. If empty, the default will be used. +# +# html_search_scorer = 'scorer.js' + +# Output file base name for HTML help builder. +htmlhelp_basename = 'Home-Assistantdoc' + +# -- Options for LaTeX output --------------------------------------------- + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + # + # 'papersize': 'letterpaper', + + # The font size ('10pt', '11pt' or '12pt'). + # + # 'pointsize': '10pt', + + # Additional stuff for the LaTeX preamble. + # + # 'preamble': '', + + # Latex figure (float) alignment + # + # 'figure_align': 'htbp', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + (master_doc, 'home-assistant.tex', 'Home Assistant Documentation', + 'Home Assistant Team', 'manual'), +] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +# +# latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +# +# latex_use_parts = False + +# If true, show page references after internal links. +# +# latex_show_pagerefs = False + +# If true, show URL addresses after external links. +# +# latex_show_urls = False + +# Documents to append as an appendix to all manuals. +# +# latex_appendices = [] + +# It false, will not define \strong, \code, itleref, \crossref ... but only +# \sphinxstrong, ..., \sphinxtitleref, ... To help avoid clash with user added +# packages. +# +# latex_keep_old_macro_names = True + +# If false, no module index is generated. +# +# latex_domain_indices = True + + +# -- Options for manual page output --------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + (master_doc, 'home-assistant', 'Home Assistant Documentation', + [author], 1) +] + +# If true, show URL addresses after external links. +# +# man_show_urls = False + + +# -- Options for Texinfo output ------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + (master_doc, 'Home-Assistant', 'Home Assistant Documentation', + author, 'Home Assistant', 'Open-source home automation platform.', + 'Miscellaneous'), +] + +# Documents to append as an appendix to all manuals. +# +# texinfo_appendices = [] + +# If false, no module index is generated. +# +# texinfo_domain_indices = True + +# How to display URL addresses: 'footnote', 'no', or 'inline'. +# +# texinfo_show_urls = 'footnote' + +# If true, do not generate a @detailmenu in the "Top" node's menu. +# +# texinfo_no_detailmenu = False diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000000000..c592f66c070c0 --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,22 @@ +================================ +Home Assistant API Documentation +================================ + +Public API documentation for `Home Assistant developers`_. + +Contents: + +.. toctree:: + :maxdepth: 2 + :glob: + + api/* + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` + +.. _Home Assistant developers: https://developers.home-assistant.io/ diff --git a/homeassistant/__init__.py b/homeassistant/__init__.py index b93a8ee99beac..32da6ab0afbb9 100644 --- a/homeassistant/__init__.py +++ b/homeassistant/__init__.py @@ -1,946 +1 @@ -""" -homeassistant -~~~~~~~~~~~~~ - -Home Assistant is a Home Automation framework for observing the state -of entities and react to changes. -""" - -import os -import time -import logging -import threading -import enum -import re -import datetime as dt -import functools as ft - -import requests - -from homeassistant.const import ( - EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, - SERVICE_HOMEASSISTANT_STOP, EVENT_TIME_CHANGED, EVENT_STATE_CHANGED, - EVENT_CALL_SERVICE, ATTR_NOW, ATTR_DOMAIN, ATTR_SERVICE, MATCH_ALL, - EVENT_SERVICE_EXECUTED, ATTR_SERVICE_CALL_ID, EVENT_SERVICE_REGISTERED, - TEMP_CELCIUS, TEMP_FAHRENHEIT) -import homeassistant.util as util - -DOMAIN = "homeassistant" - -# How often time_changed event should fire -TIMER_INTERVAL = 1 # seconds - -# How long we wait for the result of a service call -SERVICE_CALL_LIMIT = 10 # seconds - -# Define number of MINIMUM worker threads. -# During bootstrap of HA (see bootstrap.from_config_dict()) worker threads -# will be added for each component that polls devices. -MIN_WORKER_THREAD = 2 - -# Pattern for validating entity IDs (format: .) -ENTITY_ID_PATTERN = re.compile(r"^(?P\w+)\.(?P\w+)$") - -_LOGGER = logging.getLogger(__name__) - - -class HomeAssistant(object): - """ Core class to route all communication to right components. """ - - def __init__(self): - self.pool = pool = create_worker_pool() - self.bus = EventBus(pool) - self.services = ServiceRegistry(self.bus, pool) - self.states = StateMachine(self.bus) - self.config = Config() - - @property - def components(self): - """ DEPRECATED 3/21/2015. Use hass.config.components """ - _LOGGER.warning( - 'hass.components is deprecated. Use hass.config.components') - return self.config.components - - @property - def local_api(self): - """ DEPRECATED 3/21/2015. Use hass.config.api """ - _LOGGER.warning( - 'hass.local_api is deprecated. Use hass.config.api') - return self.config.api - - @property - def config_dir(self): - """ DEPRECATED 3/18/2015. Use hass.config.config_dir """ - _LOGGER.warning( - 'hass.config_dir is deprecated. Use hass.config.config_dir') - return self.config.config_dir - - def get_config_path(self, path): - """ DEPRECATED 3/18/2015. Use hass.config.path """ - _LOGGER.warning( - 'hass.get_config_path is deprecated. Use hass.config.path') - return self.config.path(path) - - def start(self): - """ Start home assistant. """ - _LOGGER.info( - "Starting Home Assistant (%d threads)", self.pool.worker_count) - - Timer(self) - - self.bus.fire(EVENT_HOMEASSISTANT_START) - - def block_till_stopped(self): - """ Will register service homeassistant/stop and - will block until called. """ - request_shutdown = threading.Event() - - self.services.register(DOMAIN, SERVICE_HOMEASSISTANT_STOP, - lambda service: request_shutdown.set()) - - while not request_shutdown.isSet(): - try: - time.sleep(1) - - except KeyboardInterrupt: - break - - self.stop() - - def track_point_in_time(self, action, point_in_time): - """ - Adds a listener that fires once at or after a spefic point in time. - """ - - @ft.wraps(action) - def point_in_time_listener(event): - """ Listens for matching time_changed events. """ - now = event.data[ATTR_NOW] - - if now >= point_in_time and \ - not hasattr(point_in_time_listener, 'run'): - - # Set variable so that we will never run twice. - # Because the event bus might have to wait till a thread comes - # available to execute this listener it might occur that the - # listener gets lined up twice to be executed. This will make - # sure the second time it does nothing. - point_in_time_listener.run = True - - self.bus.remove_listener(EVENT_TIME_CHANGED, - point_in_time_listener) - - action(now) - - self.bus.listen(EVENT_TIME_CHANGED, point_in_time_listener) - return point_in_time_listener - - # pylint: disable=too-many-arguments - def track_time_change(self, action, - year=None, month=None, day=None, - hour=None, minute=None, second=None): - """ Adds a listener that will fire if time matches a pattern. """ - - # We do not have to wrap the function with time pattern matching logic - # if no pattern given - if any((val is not None for val in - (year, month, day, hour, minute, second))): - - pmp = _process_match_param - year, month, day = pmp(year), pmp(month), pmp(day) - hour, minute, second = pmp(hour), pmp(minute), pmp(second) - - @ft.wraps(action) - def time_listener(event): - """ Listens for matching time_changed events. """ - now = event.data[ATTR_NOW] - - mat = _matcher - - if mat(now.year, year) and \ - mat(now.month, month) and \ - mat(now.day, day) and \ - mat(now.hour, hour) and \ - mat(now.minute, minute) and \ - mat(now.second, second): - - action(now) - - else: - @ft.wraps(action) - def time_listener(event): - """ Fires every time event that comes in. """ - action(event.data[ATTR_NOW]) - - self.bus.listen(EVENT_TIME_CHANGED, time_listener) - return time_listener - - def stop(self): - """ Stops Home Assistant and shuts down all threads. """ - _LOGGER.info("Stopping") - - self.bus.fire(EVENT_HOMEASSISTANT_STOP) - - # Wait till all responses to homeassistant_stop are done - self.pool.block_till_done() - - self.pool.stop() - - def get_entity_ids(self, domain_filter=None): - """ - Returns known entity ids. - - THIS METHOD IS DEPRECATED. Use hass.states.entity_ids - """ - _LOGGER.warning( - "hass.get_entiy_ids is deprecated. Use hass.states.entity_ids") - - return self.states.entity_ids(domain_filter) - - def listen_once_event(self, event_type, listener): - """ Listen once for event of a specific type. - - To listen to all events specify the constant ``MATCH_ALL`` - as event_type. - - Note: at the moment it is impossible to remove a one time listener. - - THIS METHOD IS DEPRECATED. Please use hass.events.listen_once. - """ - _LOGGER.warning( - "hass.listen_once_event is deprecated. Use hass.bus.listen_once") - - self.bus.listen_once(event_type, listener) - - def track_state_change(self, entity_ids, action, - from_state=None, to_state=None): - """ - Track specific state changes. - entity_ids, from_state and to_state can be string or list. - Use list to match multiple. - - THIS METHOD IS DEPRECATED. Use hass.states.track_change - """ - _LOGGER.warning(( - "hass.track_state_change is deprecated. " - "Use hass.states.track_change")) - - self.states.track_change(entity_ids, action, from_state, to_state) - - def call_service(self, domain, service, service_data=None): - """ - Fires event to call specified service. - - THIS METHOD IS DEPRECATED. Use hass.services.call - """ - _LOGGER.warning(( - "hass.services.call is deprecated. " - "Use hass.services.call")) - - self.services.call(domain, service, service_data) - - -def _process_match_param(parameter): - """ Wraps parameter in a list if it is not one and returns it. """ - if parameter is None or parameter == MATCH_ALL: - return MATCH_ALL - elif isinstance(parameter, str) or not hasattr(parameter, '__iter__'): - return (parameter,) - else: - return tuple(parameter) - - -def _matcher(subject, pattern): - """ Returns True if subject matches the pattern. - - Pattern is either a list of allowed subjects or a `MATCH_ALL`. - """ - return MATCH_ALL == pattern or subject in pattern - - -class JobPriority(util.OrderedEnum): - """ Provides priorities for bus events. """ - # pylint: disable=no-init,too-few-public-methods - - EVENT_CALLBACK = 0 - EVENT_SERVICE = 1 - EVENT_STATE = 2 - EVENT_TIME = 3 - EVENT_DEFAULT = 4 - - @staticmethod - def from_event_type(event_type): - """ Returns a priority based on event type. """ - if event_type == EVENT_TIME_CHANGED: - return JobPriority.EVENT_TIME - elif event_type == EVENT_STATE_CHANGED: - return JobPriority.EVENT_STATE - elif event_type == EVENT_CALL_SERVICE: - return JobPriority.EVENT_SERVICE - elif event_type == EVENT_SERVICE_EXECUTED: - return JobPriority.EVENT_CALLBACK - else: - return JobPriority.EVENT_DEFAULT - - -def create_worker_pool(): - """ Creates a worker pool to be used. """ - - def job_handler(job): - """ Called whenever a job is available to do. """ - try: - func, arg = job - func(arg) - except Exception: # pylint: disable=broad-except - # Catch any exception our service/event_listener might throw - # We do not want to crash our ThreadPool - _LOGGER.exception("BusHandler:Exception doing job") - - def busy_callback(worker_count, current_jobs, pending_jobs_count): - """ Callback to be called when the pool queue gets too big. """ - - _LOGGER.warning( - "WorkerPool:All %d threads are busy and %d jobs pending", - worker_count, pending_jobs_count) - - for start, job in current_jobs: - _LOGGER.warning("WorkerPool:Current job from %s: %s", - util.datetime_to_str(start), job) - - return util.ThreadPool(job_handler, MIN_WORKER_THREAD, busy_callback) - - -class EventOrigin(enum.Enum): - """ Distinguish between origin of event. """ - # pylint: disable=no-init,too-few-public-methods - - local = "LOCAL" - remote = "REMOTE" - - def __str__(self): - return self.value - - -# pylint: disable=too-few-public-methods -class Event(object): - """ Represents an event within the Bus. """ - - __slots__ = ['event_type', 'data', 'origin'] - - def __init__(self, event_type, data=None, origin=EventOrigin.local): - self.event_type = event_type - self.data = data or {} - self.origin = origin - - def as_dict(self): - """ Returns a dict representation of this Event. """ - return { - 'event_type': self.event_type, - 'data': dict(self.data), - 'origin': str(self.origin) - } - - def __repr__(self): - # pylint: disable=maybe-no-member - if self.data: - return "".format( - self.event_type, str(self.origin)[0], - util.repr_helper(self.data)) - else: - return "".format(self.event_type, - str(self.origin)[0]) - - -class EventBus(object): - """ Class that allows different components to communicate via services - and events. - """ - - def __init__(self, pool=None): - self._listeners = {} - self._lock = threading.Lock() - self._pool = pool or create_worker_pool() - - @property - def listeners(self): - """ Dict with events that is being listened for and the number - of listeners. - """ - with self._lock: - return {key: len(self._listeners[key]) - for key in self._listeners} - - def fire(self, event_type, event_data=None, origin=EventOrigin.local): - """ Fire an event. """ - with self._lock: - # Copy the list of the current listeners because some listeners - # remove themselves as a listener while being executed which - # causes the iterator to be confused. - get = self._listeners.get - listeners = get(MATCH_ALL, []) + get(event_type, []) - - event = Event(event_type, event_data, origin) - - if event_type != EVENT_TIME_CHANGED: - _LOGGER.info("Bus:Handling %s", event) - - if not listeners: - return - - job_priority = JobPriority.from_event_type(event_type) - - for func in listeners: - self._pool.add_job(job_priority, (func, event)) - - def listen(self, event_type, listener): - """ Listen for all events or events of a specific type. - - To listen to all events specify the constant ``MATCH_ALL`` - as event_type. - """ - with self._lock: - if event_type in self._listeners: - self._listeners[event_type].append(listener) - else: - self._listeners[event_type] = [listener] - - def listen_once(self, event_type, listener): - """ Listen once for event of a specific type. - - To listen to all events specify the constant ``MATCH_ALL`` - as event_type. - - Note: at the moment it is impossible to remove a one time listener. - """ - @ft.wraps(listener) - def onetime_listener(event): - """ Removes listener from eventbus and then fires listener. """ - if not hasattr(onetime_listener, 'run'): - # Set variable so that we will never run twice. - # Because the event bus might have to wait till a thread comes - # available to execute this listener it might occur that the - # listener gets lined up twice to be executed. - # This will make sure the second time it does nothing. - onetime_listener.run = True - - self.remove_listener(event_type, onetime_listener) - - listener(event) - - self.listen(event_type, onetime_listener) - - def remove_listener(self, event_type, listener): - """ Removes a listener of a specific event_type. """ - with self._lock: - try: - self._listeners[event_type].remove(listener) - - # delete event_type list if empty - if not self._listeners[event_type]: - self._listeners.pop(event_type) - - except (KeyError, ValueError): - # KeyError is key event_type listener did not exist - # ValueError if listener did not exist within event_type - pass - - -class State(object): - """ - Object to represent a state within the state machine. - - entity_id: the entity that is represented. - state: the state of the entity - attributes: extra information on entity and state - last_changed: last time the state was changed, not the attributes. - last_updated: last time this object was updated. - """ - - __slots__ = ['entity_id', 'state', 'attributes', - 'last_changed', 'last_updated'] - - def __init__(self, entity_id, state, attributes=None, last_changed=None): - if not ENTITY_ID_PATTERN.match(entity_id): - raise InvalidEntityFormatError(( - "Invalid entity id encountered: {}. " - "Format should be .").format(entity_id)) - - self.entity_id = entity_id.lower() - self.state = state - self.attributes = attributes or {} - self.last_updated = dt.datetime.now() - - # Strip microsecond from last_changed else we cannot guarantee - # state == State.from_dict(state.as_dict()) - # This behavior occurs because to_dict uses datetime_to_str - # which does not preserve microseconds - self.last_changed = util.strip_microseconds( - last_changed or self.last_updated) - - @property - def domain(self): - """ Returns domain of this state. """ - return util.split_entity_id(self.entity_id)[0] - - def copy(self): - """ Creates a copy of itself. """ - return State(self.entity_id, self.state, - dict(self.attributes), self.last_changed) - - def as_dict(self): - """ Converts State to a dict to be used within JSON. - Ensures: state == State.from_dict(state.as_dict()) """ - - return {'entity_id': self.entity_id, - 'state': self.state, - 'attributes': self.attributes, - 'last_changed': util.datetime_to_str(self.last_changed)} - - @classmethod - def from_dict(cls, json_dict): - """ Static method to create a state from a dict. - Ensures: state == State.from_json_dict(state.to_json_dict()) """ - - if not (json_dict and - 'entity_id' in json_dict and - 'state' in json_dict): - return None - - last_changed = json_dict.get('last_changed') - - if last_changed: - last_changed = util.str_to_datetime(last_changed) - - return cls(json_dict['entity_id'], json_dict['state'], - json_dict.get('attributes'), last_changed) - - def __eq__(self, other): - return (self.__class__ == other.__class__ and - self.entity_id == other.entity_id and - self.state == other.state and - self.attributes == other.attributes) - - def __repr__(self): - attr = "; {}".format(util.repr_helper(self.attributes)) \ - if self.attributes else "" - - return "".format( - self.entity_id, self.state, attr, - util.datetime_to_str(self.last_changed)) - - -class StateMachine(object): - """ Helper class that tracks the state of different entities. """ - - def __init__(self, bus): - self._states = {} - self._bus = bus - self._lock = threading.Lock() - - def entity_ids(self, domain_filter=None): - """ List of entity ids that are being tracked. """ - if domain_filter is not None: - domain_filter = domain_filter.lower() - - return [state.entity_id for key, state - in self._states.items() - if util.split_entity_id(key)[0] == domain_filter] - else: - return list(self._states.keys()) - - def all(self): - """ Returns a list of all states. """ - return [state.copy() for state in self._states.values()] - - def get(self, entity_id): - """ Returns the state of the specified entity. """ - state = self._states.get(entity_id.lower()) - - # Make a copy so people won't mutate the state - return state.copy() if state else None - - def get_since(self, point_in_time): - """ - Returns all states that have been changed since point_in_time. - """ - point_in_time = util.strip_microseconds(point_in_time) - - with self._lock: - return [state for state in self._states.values() - if state.last_updated >= point_in_time] - - def is_state(self, entity_id, state): - """ Returns True if entity exists and is specified state. """ - entity_id = entity_id.lower() - - return (entity_id in self._states and - self._states[entity_id].state == state) - - def remove(self, entity_id): - """ Removes an entity from the state machine. - - Returns boolean to indicate if an entity was removed. """ - entity_id = entity_id.lower() - - with self._lock: - return self._states.pop(entity_id, None) is not None - - def set(self, entity_id, new_state, attributes=None): - """ Set the state of an entity, add entity if it does not exist. - - Attributes is an optional dict to specify attributes of this state. - - If you just update the attributes and not the state, last changed will - not be affected. - """ - entity_id = entity_id.lower() - new_state = str(new_state) - attributes = attributes or {} - - with self._lock: - old_state = self._states.get(entity_id) - - is_existing = old_state is not None - same_state = is_existing and old_state.state == new_state - same_attr = is_existing and old_state.attributes == attributes - - # If state did not exist or is different, set it - if not (same_state and same_attr): - last_changed = old_state.last_changed if same_state else None - - state = State(entity_id, new_state, attributes, last_changed) - self._states[entity_id] = state - - event_data = {'entity_id': entity_id, 'new_state': state} - - if old_state: - event_data['old_state'] = old_state - - self._bus.fire(EVENT_STATE_CHANGED, event_data) - - def track_change(self, entity_ids, action, from_state=None, to_state=None): - """ - Track specific state changes. - entity_ids, from_state and to_state can be string or list. - Use list to match multiple. - - Returns the listener that listens on the bus for EVENT_STATE_CHANGED. - Pass the return value into hass.bus.remove_listener to remove it. - """ - from_state = _process_match_param(from_state) - to_state = _process_match_param(to_state) - - # Ensure it is a lowercase list with entity ids we want to match on - if isinstance(entity_ids, str): - entity_ids = (entity_ids.lower(),) - else: - entity_ids = tuple(entity_id.lower() for entity_id in entity_ids) - - @ft.wraps(action) - def state_listener(event): - """ The listener that listens for specific state changes. """ - if event.data['entity_id'] not in entity_ids: - return - - if 'old_state' in event.data: - old_state = event.data['old_state'].state - else: - old_state = None - - if _matcher(old_state, from_state) and \ - _matcher(event.data['new_state'].state, to_state): - - action(event.data['entity_id'], - event.data.get('old_state'), - event.data['new_state']) - - self._bus.listen(EVENT_STATE_CHANGED, state_listener) - - return state_listener - - -# pylint: disable=too-few-public-methods -class ServiceCall(object): - """ Represents a call to a service. """ - - __slots__ = ['domain', 'service', 'data'] - - def __init__(self, domain, service, data=None): - self.domain = domain - self.service = service - self.data = data or {} - - def __repr__(self): - if self.data: - return "".format( - self.domain, self.service, util.repr_helper(self.data)) - else: - return "".format(self.domain, self.service) - - -class ServiceRegistry(object): - """ Offers services over the eventbus. """ - - def __init__(self, bus, pool=None): - self._services = {} - self._lock = threading.Lock() - self._pool = pool or create_worker_pool() - self._bus = bus - self._cur_id = 0 - bus.listen(EVENT_CALL_SERVICE, self._event_to_service_call) - - @property - def services(self): - """ Dict with per domain a list of available services. """ - with self._lock: - return {domain: list(self._services[domain].keys()) - for domain in self._services} - - def has_service(self, domain, service): - """ Returns True if specified service exists. """ - return service in self._services.get(domain, []) - - def register(self, domain, service, service_func): - """ Register a service. """ - with self._lock: - if domain in self._services: - self._services[domain][service] = service_func - else: - self._services[domain] = {service: service_func} - - self._bus.fire( - EVENT_SERVICE_REGISTERED, - {ATTR_DOMAIN: domain, ATTR_SERVICE: service}) - - def call(self, domain, service, service_data=None, blocking=False): - """ - Calls specified service. - Specify blocking=True to wait till service is executed. - Waits a maximum of SERVICE_CALL_LIMIT. - - If blocking = True, will return boolean if service executed - succesfully within SERVICE_CALL_LIMIT. - - This method will fire an event to call the service. - This event will be picked up by this ServiceRegistry and any - other ServiceRegistry that is listening on the EventBus. - - Because the service is sent as an event you are not allowed to use - the keys ATTR_DOMAIN and ATTR_SERVICE in your service_data. - """ - call_id = self._generate_unique_id() - event_data = service_data or {} - event_data[ATTR_DOMAIN] = domain - event_data[ATTR_SERVICE] = service - event_data[ATTR_SERVICE_CALL_ID] = call_id - - if blocking: - executed_event = threading.Event() - - def service_executed(call): - """ - Called when a service is executed. - Will set the event if matches our service call. - """ - if call.data[ATTR_SERVICE_CALL_ID] == call_id: - executed_event.set() - - self._bus.remove_listener( - EVENT_SERVICE_EXECUTED, service_executed) - - self._bus.listen(EVENT_SERVICE_EXECUTED, service_executed) - - self._bus.fire(EVENT_CALL_SERVICE, event_data) - - if blocking: - # wait will return False if event not set after our limit has - # passed. If not set, clean up the listener - if not executed_event.wait(SERVICE_CALL_LIMIT): - self._bus.remove_listener( - EVENT_SERVICE_EXECUTED, service_executed) - - return False - - return True - - def _event_to_service_call(self, event): - """ Calls a service from an event. """ - service_data = dict(event.data) - domain = service_data.pop(ATTR_DOMAIN, None) - service = service_data.pop(ATTR_SERVICE, None) - - with self._lock: - if domain in self._services and service in self._services[domain]: - service_call = ServiceCall(domain, service, service_data) - - # Add a job to the pool that calls _execute_service - self._pool.add_job(JobPriority.EVENT_SERVICE, - (self._execute_service, - (self._services[domain][service], - service_call))) - - def _execute_service(self, service_and_call): - """ Executes a service and fires a SERVICE_EXECUTED event. """ - service, call = service_and_call - - service(call) - - self._bus.fire( - EVENT_SERVICE_EXECUTED, { - ATTR_SERVICE_CALL_ID: call.data[ATTR_SERVICE_CALL_ID] - }) - - def _generate_unique_id(self): - """ Generates a unique service call id. """ - self._cur_id += 1 - return "{}-{}".format(id(self), self._cur_id) - - -class Timer(threading.Thread): - """ Timer will sent out an event every TIMER_INTERVAL seconds. """ - - def __init__(self, hass, interval=None): - threading.Thread.__init__(self) - - self.daemon = True - self.hass = hass - self.interval = interval or TIMER_INTERVAL - self._stop_event = threading.Event() - - # We want to be able to fire every time a minute starts (seconds=0). - # We want this so other modules can use that to make sure they fire - # every minute. - assert 60 % self.interval == 0, "60 % TIMER_INTERVAL should be 0!" - - hass.bus.listen_once(EVENT_HOMEASSISTANT_START, - lambda event: self.start()) - - def run(self): - """ Start the timer. """ - - self.hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, - lambda event: self._stop_event.set()) - - _LOGGER.info("Timer:starting") - - last_fired_on_second = -1 - - calc_now = dt.datetime.now - interval = self.interval - - while not self._stop_event.isSet(): - now = calc_now() - - # First check checks if we are not on a second matching the - # timer interval. Second check checks if we did not already fire - # this interval. - if now.second % interval or \ - now.second == last_fired_on_second: - - # Sleep till it is the next time that we have to fire an event. - # Aim for halfway through the second that fits TIMER_INTERVAL. - # If TIMER_INTERVAL is 10 fire at .5, 10.5, 20.5, etc seconds. - # This will yield the best results because time.sleep() is not - # 100% accurate because of non-realtime OS's - slp_seconds = interval - now.second % interval + \ - .5 - now.microsecond/1000000.0 - - time.sleep(slp_seconds) - - now = calc_now() - - last_fired_on_second = now.second - - self.hass.bus.fire(EVENT_TIME_CHANGED, {ATTR_NOW: now}) - - -class Config(object): - """ Configuration settings for Home Assistant. """ - - # pylint: disable=too-many-instance-attributes - def __init__(self): - self.latitude = None - self.longitude = None - self.temperature_unit = None - self.location_name = None - self.time_zone = None - - # List of loaded components - self.components = [] - - # Remote.API object pointing at local API - self.api = None - - # Directory that holds the configuration - self.config_dir = os.path.join(os.getcwd(), 'config') - - def auto_detect(self): - """ Will attempt to detect config of Home Assistant. """ - # Only detect if location or temp unit missing - if None not in (self.latitude, self.longitude, self.temperature_unit): - return - - _LOGGER.info('Auto detecting location and temperature unit') - - try: - info = requests.get('https://freegeoip.net/json/').json() - except requests.RequestException: - return - - if self.latitude is None and self.longitude is None: - self.latitude = info['latitude'] - self.longitude = info['longitude'] - - if self.temperature_unit is None: - # From Wikipedia: - # Fahrenheit is used in the Bahamas, Belize, the Cayman Islands, - # Palau, and the United States and associated territories of - # American Samoa and the U.S. Virgin Islands - if info['country_code'] in ('BS', 'BZ', 'KY', 'PW', - 'US', 'AS', 'VI'): - self.temperature_unit = TEMP_FAHRENHEIT - else: - self.temperature_unit = TEMP_CELCIUS - - if self.location_name is None: - self.location_name = info['city'] - - if self.time_zone is None: - self.time_zone = info['time_zone'] - - def path(self, path): - """ Returns path to the file within the config dir. """ - return os.path.join(self.config_dir, path) - - def temperature(self, value, unit): - """ Converts temperature to user preferred unit if set. """ - if not (unit and self.temperature_unit and - unit != self.temperature_unit): - return value, unit - - try: - if unit == TEMP_CELCIUS: - # Convert C to F - return round(float(value) * 1.8 + 32.0, 1), TEMP_FAHRENHEIT - - # Convert F to C - return round((float(value)-32.0)/1.8, 1), TEMP_CELCIUS - - except ValueError: - # Could not convert value to float - return value, unit - - -class HomeAssistantError(Exception): - """ General Home Assistant exception occured. """ - pass - - -class InvalidEntityFormatError(HomeAssistantError): - """ When an invalid formatted entity is encountered. """ - pass - - -class NoEntitySpecifiedError(HomeAssistantError): - """ When no entity is specified. """ - pass +"""Init file for Home Assistant.""" diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index 13703dee4168e..5ebdc71680ef7 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -1,156 +1,380 @@ -""" Starts home assistant. """ -from __future__ import print_function - -import sys -import os +"""Start Home Assistant.""" import argparse -import importlib +import asyncio +import os +import platform +import subprocess +import sys +import threading +from typing import TYPE_CHECKING, Any, Dict, List +from homeassistant.const import REQUIRED_PYTHON_VER, RESTART_EXIT_CODE, __version__ -def validate_python(): - """ Validate we're running the right Python version. """ - major, minor = sys.version_info[:2] +if TYPE_CHECKING: + from homeassistant import core - if major < 3 or (major == 3 and minor < 4): - print("Home Assistant requires atleast Python 3.4") - sys.exit() +def set_loop() -> None: + """Attempt to use different loop.""" + from asyncio.events import BaseDefaultEventLoopPolicy -def validate_dependencies(): - """ Validate all dependencies that HA uses. """ - import_fail = False + if sys.platform == "win32": + if hasattr(asyncio, "WindowsProactorEventLoopPolicy"): + # pylint: disable=no-member + policy = asyncio.WindowsProactorEventLoopPolicy() + else: - for module in ['requests']: - try: - importlib.import_module(module) - except ImportError: - import_fail = True - print( - 'Fatal Error: Unable to find dependency {}'.format(module)) + class ProactorPolicy(BaseDefaultEventLoopPolicy): + """Event loop policy to create proactor loops.""" - if import_fail: - print(("Install dependencies by running: " - "pip3 install -r requirements.txt")) - sys.exit() + _loop_factory = asyncio.ProactorEventLoop + policy = ProactorPolicy() -def ensure_path_and_load_bootstrap(): - """ Ensure sys load path is correct and load Home Assistant bootstrap. """ - try: - from homeassistant import bootstrap + asyncio.set_event_loop_policy(policy) - except ImportError: - # This is to add support to load Home Assistant using - # `python3 homeassistant` instead of `python3 -m homeassistant` - # Insert the parent directory of this file into the module search path - sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) +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 + ) + ) + sys.exit(1) - from homeassistant import bootstrap - return bootstrap +def ensure_config_path(config_dir: str) -> None: + """Validate the configuration directory.""" + import homeassistant.config as config_util - -def validate_git_submodules(): - """ Validate the git submodules are cloned. """ - try: - # pylint: disable=no-name-in-module, unused-variable - from homeassistant.external.noop import WORKING # noqa - except ImportError: - print("Repository submodules have not been initialized") - print("Please run: git submodule update --init --recursive") - sys.exit() - - -def ensure_config_path(config_dir): - """ Gets the path to the configuration file. - Creates one if it not exists. """ + lib_dir = os.path.join(config_dir, "deps") # Test if configuration directory exists if not os.path.isdir(config_dir): - print(('Fatal Error: Unable to find specified configuration ' - 'directory {} ').format(config_dir)) - sys.exit() - - # Try to use yaml configuration first - config_path = os.path.join(config_dir, 'configuration.yaml') - if not os.path.isfile(config_path): - config_path = os.path.join(config_dir, 'home-assistant.conf') - - # Ensure a config file exists to make first time usage easier - if not os.path.isfile(config_path): - config_path = os.path.join(config_dir, 'configuration.yaml') + if config_dir != config_util.get_default_config_dir(): + print( + f"Fatal Error: Specified configuration directory {config_dir} " + "does not exist" + ) + sys.exit(1) + + try: + os.mkdir(config_dir) + except OSError: + print( + "Fatal Error: Unable to create default configuration " + f"directory {config_dir}" + ) + sys.exit(1) + + # Test if library directory exists + if not os.path.isdir(lib_dir): try: - with open(config_path, 'w') as conf: - conf.write("frontend:\n\n") - conf.write("discovery:\n\n") - conf.write("history:\n\n") - except IOError: - print(('Fatal Error: No configuration file found and unable ' - 'to write a default one to {}').format(config_path)) - sys.exit() + os.mkdir(lib_dir) + except OSError: + print(f"Fatal Error: Unable to create library directory {lib_dir}") + sys.exit(1) + + +async def ensure_config_file(hass: "core.HomeAssistant", config_dir: str) -> str: + """Ensure configuration file exists.""" + import homeassistant.config as config_util + + config_path = await config_util.async_ensure_config_exists(hass, config_dir) + + if config_path is None: + print("Error getting configuration path") + sys.exit(1) return config_path -def get_arguments(): - """ Get parsed passed in arguments. """ - parser = argparse.ArgumentParser() +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__) + parser.add_argument( + "-c", + "--config", + metavar="path_to_config_dir", + default=config_util.get_default_config_dir(), + help="Directory that contains the Home Assistant configuration", + ) + parser.add_argument( + "--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" + ) + parser.add_argument( + "--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", + ) + parser.add_argument( + "-v", "--verbose", action="store_true", help="Enable verbose logging to file." + ) + parser.add_argument( + "--pid-file", + metavar="path_to_pid_file", + default=None, + help="Path to PID file useful for running as daemon", + ) + parser.add_argument( + "--log-rotate-days", + type=int, + default=None, + help="Enables daily log rotation and keeps up to the specified days", + ) parser.add_argument( - '-c', '--config', - metavar='path_to_config_dir', - default="config", - help="Directory that contains the Home Assistant configuration") + "--log-file", + type=str, + default=None, + help="Log file to write to. If not set, CONFIG/home-assistant.log is used", + ) parser.add_argument( - '--demo-mode', - action='store_true', - help='Start Home Assistant in demo mode') + "--log-no-color", action="store_true", help="Disable color logs" + ) parser.add_argument( - '--open-ui', - action='store_true', - help='Open the webinterface in a browser') + "--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" + ) + if os.name == "posix": + parser.add_argument( + "--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) + + return arguments + + +def daemonize() -> None: + """Move current process to daemon process.""" + # Create first fork + pid = os.fork() + if pid > 0: + sys.exit(0) + + # Decouple fork + os.setsid() + + # Create second fork + pid = os.fork() + if pid > 0: + sys.exit(0) + + # redirect standard file descriptors to devnull + infd = open(os.devnull, "r") + outfd = open(os.devnull, "a+") + sys.stdout.flush() + sys.stderr.flush() + os.dup2(infd.fileno(), sys.stdin.fileno()) + os.dup2(outfd.fileno(), sys.stdout.fileno()) + os.dup2(outfd.fileno(), sys.stderr.fileno()) + + +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: + pid = int(file.readline()) + except OSError: + # PID File does not exist + return - return parser.parse_args() + # If we just restarted, we just found our own pidfile. + if pid == os.getpid(): + return + try: + os.kill(pid, 0) + except OSError: + # PID does not exist + return + print("Fatal Error: Home Assistant is already running.") + sys.exit(1) -def main(): - """ Starts Home Assistant. """ - validate_python() - validate_dependencies() - bootstrap = ensure_path_and_load_bootstrap() +def write_pid(pid_file: str) -> None: + """Create a PID File.""" + pid = os.getpid() + try: + with open(pid_file, "w") as file: + file.write(str(pid)) + except OSError: + print(f"Fatal Error: Unable to write pid file {pid_file}") + sys.exit(1) - validate_git_submodules() - args = get_arguments() +def closefds_osx(min_fd: int, max_fd: int) -> None: + """Make sure file descriptors get closed when we restart. - config_dir = os.path.join(os.getcwd(), args.config) - config_path = ensure_config_path(config_dir) + We cannot call close on guarded fds, and we cannot easily test which fds + are guarded. But we can set the close-on-exec flag on everything we want to + get rid of. + """ + from fcntl import fcntl, F_GETFD, F_SETFD, FD_CLOEXEC + + for _fd in range(min_fd, max_fd): + try: + val = fcntl(_fd, F_GETFD) + if not val & FD_CLOEXEC: + fcntl(_fd, F_SETFD, val | FD_CLOEXEC) + 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": + 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"] + + return [arg for arg in sys.argv if arg != "--daemon"] + + +async def setup_and_run_hass(config_dir: str, args: argparse.Namespace) -> int: + """Set up Home Assistant and run.""" + from homeassistant import bootstrap, core + + hass = core.HomeAssistant() if args.demo_mode: - from homeassistant.components import http, demo + 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 = 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() + + +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") + + # 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() + ) + if nthreads > 1: + 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 + # which are not marked as stopped at the python level. + except AssertionError: + sys.stderr.write("Failed to count non-daemonic threads.\n") + + # Try to not leave behind open filedescriptors with the emphasis on try. + try: + max_fd = os.sysconf("SC_OPEN_MAX") + except ValueError: + max_fd = 256 - # Demo mode only requires http and demo components. - hass = bootstrap.from_config_dict({ - http.DOMAIN: {}, - demo.DOMAIN: {} - }) + if platform.system() == "Darwin": + closefds_osx(3, max_fd) else: - hass = bootstrap.from_config_file(config_path) + os.closerange(3, max_fd) + + # Now launch into a new instance of Home Assistant. If this fails we + # fall through and exit with error 100 (RESTART_EXIT_CODE) in which case + # systemd will restart us when RestartForceExitStatus=100 is set in the + # systemd.service file. + sys.stderr.write("Restarting Home Assistant\n") + args = cmdline() + os.execv(args[0], args) + + +def main() -> int: + """Start Home Assistant.""" + validate_python() + + set_loop() + + # 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) + ensure_config_path(config_dir) - if args.open_ui: - from homeassistant.const import EVENT_HOMEASSISTANT_START + # Daemon functions + if args.pid_file: + check_pid(args.pid_file) + if args.daemon: + daemonize() + if args.pid_file: + write_pid(args.pid_file) - def open_browser(event): - """ Open the webinterface in a browser. """ - if hass.local_api is not None: - import webbrowser - webbrowser.open(hass.local_api.base_url) + exit_code = asyncio.run(setup_and_run_hass(config_dir, args)) + if exit_code == RESTART_EXIT_CODE and not args.runner: + try_to_restart() - hass.bus.listen_once(EVENT_HOMEASSISTANT_START, open_browser) + return exit_code - hass.start() - hass.block_till_stopped() if __name__ == "__main__": - main() + sys.exit(main()) diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py new file mode 100644 index 0000000000000..9b3cf49fa22f1 --- /dev/null +++ b/homeassistant/auth/__init__.py @@ -0,0 +1,502 @@ +"""Provide an authentication layer for Home Assistant.""" +import asyncio +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 HomeAssistant, callback +from homeassistant.util import dt as dt_util + +from . import auth_store, models +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] +_ProviderKey = Tuple[str, Optional[str]] +_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": + """Initialize an auth manager from config. + + CORE_CONFIG_SCHEMA will make sure do duplicated auth providers or + mfa modules exist in configs. + """ + store = auth_store.AuthStore(hass) + if provider_configs: + providers = await asyncio.gather( + *( + auth_provider_from_config(hass, store, config) + for config in provider_configs + ) + ) + else: + providers = [] + # So returned auth providers are in same order as config + provider_hash: _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) + ) + else: + modules = [] + # So returned auth modules are in same order as config + module_hash: _MfaModuleDict = OrderedDict() + for module in modules: + module_hash[module.id] = module + + manager = AuthManager(hass, store, provider_hash, module_hash) + return manager + + +class AuthManagerFlowManager(data_entry_flow.FlowManager): + """Manage authentication flows.""" + + def __init__(self, hass: HomeAssistant, auth_manager: "AuthManager"): + """Init auth manager flows.""" + super().__init__(hass) + self.auth_manager = auth_manager + + async def async_create_flow( + self, + handler_key: Any, + *, + context: Optional[Dict[str, Any]] = None, + data: Optional[Dict[str, Any]] = None, + ) -> data_entry_flow.FlowHandler: + """Create a login flow.""" + auth_provider = self.auth_manager.get_auth_provider(*handler_key) + if not auth_provider: + raise KeyError(f"Unknown auth provider {handler_key}") + return await auth_provider.async_login_flow(context) + + async def async_finish_flow( + self, flow: data_entry_flow.FlowHandler, result: Dict[str, Any] + ) -> Dict[str, Any]: + """Return a user as result of login flow.""" + flow = cast(LoginFlow, flow) + + 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"] + return result + + auth_provider = self.auth_manager.get_auth_provider(*result["handler"]) + if not auth_provider: + raise KeyError(f"Unknown auth provider {result['handler']}") + + credentials = await auth_provider.async_get_or_create_credentials( + result["data"] + ) + + if flow.context.get("credential_only"): + result["result"] = credentials + return result + + # multi-factor module cannot enabled for new credential + # which has not linked to a user yet + if auth_provider.support_mfa and not credentials.is_new: + user = await self.auth_manager.async_get_user_by_credentials(credentials) + if user is not None: + modules = await self.auth_manager.async_get_enabled_mfa(user) + + if modules: + flow.user = user + flow.available_mfa_modules = modules + return await flow.async_step_select_mfa_module() + + result["result"] = await self.auth_manager.async_get_or_create_user(credentials) + return result + + +class AuthManager: + """Manage the authentication for Home Assistant.""" + + 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 = AuthManagerFlowManager(hass, self) + + @property + def auth_providers(self) -> List[AuthProvider]: + """Return a list of available auth providers.""" + return list(self._providers.values()) + + @property + def auth_mfa_modules(self) -> List[MultiFactorAuthModule]: + """Return a list of available auth modules.""" + return list(self._mfa_modules.values()) + + 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]: + """Retrieve all users.""" + return await self._store.async_get_users() + + async def async_get_user(self, user_id: str) -> Optional[models.User]: + """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]: + """Get a user by credential, return None if not found.""" + for user in await self.async_get_users(): + for creds in user.credentials: + if creds.id == credentials.id: + return user + + return None + + async def async_create_system_user( + self, name: str, group_ids: Optional[List[str]] = None + ) -> models.User: + """Create a system user.""" + 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: Dict[str, Any] = { + "name": name, + "is_active": True, + "group_ids": [GROUP_ID_ADMIN], + } + + if await self._user_should_be_owner(): + kwargs["is_owner"] = True + + user = await self._store.async_create_user(**kwargs) + + 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.") + return user + + auth_provider = self._async_get_auth_provider(credentials) + + if auth_provider is None: + raise RuntimeError("Credential with unknown provider encountered") + + info = await auth_provider.async_user_meta_for_credentials(credentials) + + user = await self._store.async_create_user( + credentials=credentials, + name=info.name, + is_active=info.is_active, + group_ids=[GROUP_ID_ADMIN], + ) + + 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) + + async def async_remove_user(self, user: models.User) -> None: + """Remove a user.""" + tasks = [ + self.async_remove_credentials(credentials) + for credentials in user.credentials + ] + + if tasks: + await asyncio.wait(tasks) + + 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) + + async def async_deactivate_user(self, user: models.User) -> None: + """Deactivate a user.""" + if user.is_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: + """Remove credentials.""" + provider = self._async_get_auth_provider(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 + ) + + await self._store.async_remove_credentials(credentials) + + 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." + ) + + module = self.get_auth_mfa_module(mfa_module_id) + if module is None: + 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: + """Disable a multi-factor auth module for user.""" + if user.system_generated: + 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(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: 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: + """Create a new refresh token for a user.""" + if not user.is_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." + ) + + if token_type is None: + if user.system_generated: + token_type = models.TOKEN_TYPE_SYSTEM + else: + token_type = models.TOKEN_TYPE_NORMAL + + if user.system_generated != (token_type == models.TOKEN_TYPE_SYSTEM): + raise ValueError( + "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.") + + 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 + ): + # Each client_name can only have one + # long_lived_access_token type of refresh token + 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, + ) + + async def async_get_refresh_token( + 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]: + """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: + """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: + """Create a new access token.""" + self._store.async_log_refresh_token_usage(refresh_token, remote_ip) + + 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() + + async def async_validate_access_token( + self, token: str + ) -> Optional[models.RefreshToken]: + """Return refresh token if an access token is valid.""" + try: + unverif_claims = jwt.decode(token, verify=False) + except jwt.InvalidTokenError: + return None + + refresh_token = await self.async_get_refresh_token( + cast(str, unverif_claims.get("iss")) + ) + + if refresh_token is None: + 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"]) + except jwt.InvalidTokenError: + return None + + if refresh_token is None or not refresh_token.user.is_active: + return None + + return refresh_token + + @callback + def _async_get_auth_provider( + 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, + ) + return self._providers.get(auth_provider_key) + + async def _user_should_be_owner(self) -> bool: + """Determine if user should be owner. + + A user should be an owner if it is the first non-system user that is + being created. + """ + for user in await self._store.async_get_users(): + if not user.system_generated: + return False + + return True diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py new file mode 100644 index 0000000000000..57ec9ee63dcc4 --- /dev/null +++ b/homeassistant/auth/auth_store.py @@ -0,0 +1,598 @@ +"""Storage for auth models.""" +import asyncio +from collections import OrderedDict +from datetime import timedelta +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" +GROUP_NAME_ADMIN = "Administrators" +GROUP_NAME_USER = "Users" +GROUP_NAME_READ_ONLY = "Read Only" + + +class AuthStore: + """Stores authentication info. + + Any mutation to an object should happen inside the auth store. + + The auth store is lazy. It won't load the data from disk until a method is + called that needs it. + """ + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the auth store.""" + self.hass = hass + 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.""" + if self._users is None: + await self._async_load() + assert self._users is not None + + return list(self._users.values()) + + async def async_get_user(self, user_id: str) -> Optional[models.User]: + """Retrieve a user by id.""" + if self._users is None: + await self._async_load() + assert self._users is not None + + 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, + 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 + 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 + + if is_active is not None: + kwargs["is_active"] = is_active + + if system_generated is not None: + kwargs["system_generated"] = system_generated + + new_user = models.User(**kwargs) + + self._users[new_user.id] = new_user + + if credentials is None: + self._async_schedule_save() + return new_user + + # Saving is done inside the link. + await self.async_link_user(new_user, credentials) + return new_user + + 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() + credentials.is_new = False + + async def async_remove_user(self, user: models.User) -> None: + """Remove a user.""" + if self._users is None: + await self._async_load() + assert self._users is not None + + 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 + self._async_schedule_save() + + async def async_deactivate_user(self, user: models.User) -> None: + """Activate a user.""" + user.is_active = False + self._async_schedule_save() + + async def async_remove_credentials(self, credentials: models.Credentials) -> None: + """Remove credentials.""" + if self._users is None: + await self._async_load() + assert self._users is not None + + for user in self._users.values(): + found = None + + for index, cred in enumerate(user.credentials): + if cred is credentials: + found = index + break + + if found is not None: + user.credentials.pop(found) + break + + 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: + """Create a new token for a user.""" + 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 + if client_icon: + kwargs["client_icon"] = client_icon + + refresh_token = models.RefreshToken(**kwargs) + user.refresh_tokens[refresh_token.id] = refresh_token + + self._async_schedule_save() + return refresh_token + + async def async_remove_refresh_token( + self, refresh_token: models.RefreshToken + ) -> None: + """Remove a refresh token.""" + if self._users is None: + await self._async_load() + assert self._users is not None + + for user in self._users.values(): + if user.refresh_tokens.pop(refresh_token.id, None): + self._async_schedule_save() + break + + async def async_get_refresh_token( + self, token_id: str + ) -> Optional[models.RefreshToken]: + """Get refresh token by id.""" + if self._users is None: + await self._async_load() + assert self._users is not None + + for user in self._users.values(): + refresh_token = user.refresh_tokens.get(token_id) + if refresh_token is not None: + return refresh_token + + return None + + async def async_get_refresh_token_by_token( + self, token: str + ) -> Optional[models.RefreshToken]: + """Get refresh token by token.""" + if self._users is None: + await self._async_load() + assert self._users is not None + + found = None + + for user in self._users.values(): + for refresh_token in user.refresh_tokens.values(): + if hmac.compare_digest(refresh_token.token, token): + found = refresh_token + + return found + + @callback + def async_log_refresh_token_usage( + 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 + self._async_schedule_save() + + async def _async_load(self) -> None: + """Load the users.""" + 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 + + self._perm_lookup = perm_lookup = PermissionLookup(ent_reg, dev_reg) + + if data is None: + self._set_defaults() + return + + users: Dict[str, models.User] = OrderedDict() + groups: Dict[str, models.Group] = OrderedDict() + + # 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 + + # 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 + + 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, + ) + continue + + token_type = rt_dict.get("token_type") + if token_type 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") + 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"], + # use dict.get to keep backward compatibility + 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"], + last_used_at=last_used_at, + last_used_ip=rt_dict.get("last_used_ip"), + ) + users[rt_dict["user_id"]].refresh_tokens[token.id] = token + + self._groups = groups + self._users = users + + @callback + def _async_schedule_save(self) -> None: + """Save users.""" + if self._users is None: + return + + self._store.async_delay_save(self._data_to_save, 1) + + @callback + 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, + "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, + } + for user in self._users.values() + for credential in user.credentials + ] + + 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, + } + for user in self._users.values() + for refresh_token in user.refresh_tokens.values() + ] + + return { + "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 new file mode 100644 index 0000000000000..5e17e752bdd82 --- /dev/null +++ b/homeassistant/auth/const.py @@ -0,0 +1,9 @@ +"""Constants for the auth module.""" +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 new file mode 100644 index 0000000000000..fd9e61b9d1711 --- /dev/null +++ b/homeassistant/auth/mfa_modules/__init__.py @@ -0,0 +1,170 @@ +"""Plugable auth modules for Home Assistant.""" +import importlib +import logging +import types +from typing import Any, Dict, Optional + +import voluptuous as vol +from voluptuous.humanize import humanize_error + +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 +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, +) + +DATA_REQS = "mfa_auth_module_reqs_processed" + +_LOGGER = logging.getLogger(__name__) + + +class MultiFactorAuthModule: + """Multi-factor Auth Module of validation function.""" + + DEFAULT_TITLE = "Unnamed auth module" + MAX_RETRY_TIME = 3 + + def __init__(self, hass: HomeAssistant, config: Dict[str, Any]) -> None: + """Initialize an auth module.""" + self.hass = hass + self.config = config + + @property + def id(self) -> str: + """Return id of the auth module. + + Default is same as type + """ + return self.config.get(CONF_ID, self.type) + + @property + def type(self) -> str: + """Return type of the module.""" + return self.config[CONF_TYPE] # type: ignore + + @property + def name(self) -> str: + """Return the name of the auth module.""" + return self.config.get(CONF_NAME, self.DEFAULT_TITLE) + + # Implement by extending class + + @property + def input_schema(self) -> vol.Schema: + """Return a voluptuous schema to define mfa auth module's input.""" + raise NotImplementedError + + async def async_setup_flow(self, user_id: str) -> "SetupFlow": + """Return a data entry flow handler for setup module. + + Mfa module should extend SetupFlow + """ + raise NotImplementedError + + async def async_setup_user(self, user_id: str, setup_data: Any) -> Any: + """Set up user for mfa auth module.""" + raise NotImplementedError + + async def async_depose_user(self, user_id: str) -> None: + """Remove user from mfa module.""" + raise NotImplementedError + + async def async_is_user_setup(self, user_id: str) -> bool: + """Return whether user is setup.""" + raise NotImplementedError + + async def async_validate(self, user_id: str, user_input: Dict[str, Any]) -> bool: + """Return True if validation passed.""" + raise NotImplementedError + + +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: + """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]: + """Handle the first step of setup flow. + + Return self.async_show_form(step_id='init') if user_input is None. + Return self.async_create_entry(data={'result': result}) if finish. + """ + errors: Dict[str, str] = {} + + if 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} + ) + + return self.async_show_form( + step_id="init", data_schema=self._setup_schema, errors=errors + ) + + +async def auth_mfa_module_from_config( + 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) + + 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), + ) + raise + + return MULTI_FACTOR_AUTH_MODULES[module_name](hass, config) # type: ignore + + +async def _load_mfa_module(hass: HomeAssistant, module_name: str) -> types.ModuleType: + """Load an mfa auth module.""" + 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(f"Unable to load mfa module {module_name}: {err}") + + if hass.config.skip_pip or not hasattr(module, "REQUIREMENTS"): + return module + + processed = hass.data.get(DATA_REQS) + if processed and module_name in processed: + return module + + processed = hass.data[DATA_REQS] = set() + + # https://github.com/python/mypy/issues/1424 + 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 new file mode 100644 index 0000000000000..45cc07ae5810c --- /dev/null +++ b/homeassistant/auth/mfa_modules/insecure_example.py @@ -0,0 +1,94 @@ +"""Example auth module.""" +import logging +from typing import Any, Dict + +import voluptuous as vol + +from homeassistant.core import HomeAssistant + +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, +) + +_LOGGER = logging.getLogger(__name__) + + +@MULTI_FACTOR_AUTH_MODULES.register("insecure_example") +class InsecureExampleModule(MultiFactorAuthModule): + """Example auth module validate pin.""" + + 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"] + + @property + def input_schema(self) -> vol.Schema: + """Validate login flow input data.""" + return vol.Schema({"pin": str}) + + @property + def setup_schema(self) -> vol.Schema: + """Validate async_setup_user input data.""" + return vol.Schema({"pin": str}) + + 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 SetupFlow(self, self.setup_schema, user_id) + + 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"] + + for data in self._data: + if data["user_id"] == user_id: + # already setup, override + data["pin"] = pin + return + + 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: + found = data + break + if found: + self._data.remove(found) + + 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: + return True + return False + + 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: + # user_input has been validate in caller + 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 0000000000000..46cc634bcaeec --- /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 new file mode 100644 index 0000000000000..6abddd2123f09 --- /dev/null +++ b/homeassistant/auth/mfa_modules/totp.py @@ -0,0 +1,236 @@ +"""Time-based One Time Password auth module.""" +import asyncio +from io import BytesIO +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 ( + MULTI_FACTOR_AUTH_MODULE_SCHEMA, + MULTI_FACTOR_AUTH_MODULES, + MultiFactorAuthModule, + SetupFlow, +) + +REQUIREMENTS = ["pyotp==2.3.0", "PyQRCode==1.2.1"] + +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" + +INPUT_FIELD_CODE = "code" + +DUMMY_SECRET = "FPPTH34D4E3MI2HG" + +_LOGGER = logging.getLogger(__name__) + + +def _generate_qr_code(data: str) -> str: + """Generate a base64 PNG string represent QR Code image of data.""" + import pyqrcode + + qr_code = pyqrcode.create(data) + + with BytesIO() as buffer: + qr_code.svg(file=buffer, scale=4) + return "{}".format( + buffer.getvalue() + .decode("ascii") + .replace("\n", "") + .replace( + '' + ' Tuple[str, str, str]: + """Generate a secret, url, and QR code.""" + import pyotp + + ota_secret = pyotp.random_base32() + url = pyotp.totp.TOTP(ota_secret).provisioning_uri( + username, issuer_name="Home Assistant" + ) + image = _generate_qr_code(url) + return ota_secret, url, image + + +@MULTI_FACTOR_AUTH_MODULES.register("totp") +class TotpAuthModule(MultiFactorAuthModule): + """Auth module validate 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: Optional[Dict[str, str]] = None + self._user_store = hass.helpers.storage.Store( + STORAGE_VERSION, STORAGE_KEY, private=True + ) + 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._users is not None: + return + + data = await self._user_store.async_load() + + 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: + """Create a ota_secret for user.""" + import pyotp + + ota_secret: str = secret or pyotp.random_base32() + + self._users[user_id] = ota_secret # type: ignore + return ota_secret + + async def async_setup_flow(self, user_id: str) -> SetupFlow: + """Return a data entry flow handler for setup module. + + Mfa module should extend SetupFlow + """ + 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: + """Set up auth module for user.""" + if self._users is None: + await self._async_load() + + result = await self.hass.async_add_executor_job( + self._add_ota_secret, user_id, setup_data.get("secret") + ) + + await self._async_save() + return result + + async def async_depose_user(self, user_id: str) -> None: + """Depose auth module for user.""" + if self._users is None: + await self._async_load() + + if self._users.pop(user_id, None): # type: ignore + await self._async_save() + + async def async_is_user_setup(self, user_id: str) -> bool: + """Return whether user is setup.""" + if self._users is None: + await self._async_load() + + return user_id in self._users # type: ignore + + 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() + + # 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, "") + ) + + def _validate_2fa(self, user_id: str, code: str) -> bool: + """Validate two factor authentication code.""" + import pyotp + + ota_secret = self._users.get(user_id) # type: ignore + if ota_secret is None: + # even we cannot find user, we still do verify + # to make timing the same as if user was found. + pyotp.TOTP(DUMMY_SECRET).verify(code, valid_window=1) + return False + + return bool(pyotp.TOTP(ota_secret).verify(code, valid_window=1)) + + +class TotpSetupFlow(SetupFlow): + """Handler for the setup flow.""" + + 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: TotpAuthModule = auth_module + self._user = user + 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]: + """Handle the first step of setup flow. + + 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: 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"] + ) + if verified: + result = await self._auth_module.async_setup_user( + self._user_id, {"secret": self._ota_secret} + ) + return self.async_create_entry( + title=self._auth_module.name, data={"result": result} + ) + + errors["base"] = "invalid_code" + + else: + hass = self._auth_module.hass + ( + 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", + data_schema=self._setup_schema, + description_placeholders={ + "code": self._ota_secret, + "url": self._url, + "qr_code": self._image, + }, + errors=errors, + ) diff --git a/homeassistant/auth/models.py b/homeassistant/auth/models.py new file mode 100644 index 0000000000000..08f2f375b4116 --- /dev/null +++ b/homeassistant/auth/models.py @@ -0,0 +1,120 @@ +"""Auth models.""" +from datetime import datetime, timedelta +import secrets +from typing import Dict, List, NamedTuple, Optional +import uuid + +import attr + +from homeassistant.util import dt as dt_util + +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" + + +@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=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["Credentials"], factory=list, cmp=False) + + # Tokens associated with a user. + 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) +class RefreshToken: + """RefreshToken for a user to grant new access tokens.""" + + user = attr.ib(type=User) + client_id = attr.ib(type=Optional[str]) + 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, 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) + + +@attr.s(slots=True) +class Credentials: + """Credentials for a user on an auth provider.""" + + auth_provider_type = attr.ib(type=str) + auth_provider_id = attr.ib(type=Optional[str]) + + # Allow the auth provider to store data to represent their auth. + data = attr.ib(type=dict) + + 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)]) diff --git a/homeassistant/auth/permissions/__init__.py b/homeassistant/auth/permissions/__init__.py new file mode 100644 index 0000000000000..92d02c75b91be --- /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 0000000000000..e6c44036a7efa --- /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 0000000000000..be30c7bf69aef --- /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 0000000000000..fad98b3f22a3f --- /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 0000000000000..1224ea07b23a7 --- /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 0000000000000..b45984653fb37 --- /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 0000000000000..6ce394ebb9263 --- /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 0000000000000..11bbd878eb23d --- /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 new file mode 100644 index 0000000000000..bb0fc55b5c4dd --- /dev/null +++ b/homeassistant/auth/providers/__init__.py @@ -0,0 +1,272 @@ +"""Auth providers for Home Assistant.""" +import importlib +import logging +import types +from typing import Any, Dict, List, Optional + +import voluptuous as vol +from voluptuous.humanize import humanize_error + +from homeassistant import data_entry_flow, requirements +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 ..const import MFA_SESSION_EXPIRATION +from ..models import Credentials, User, UserMeta + +_LOGGER = logging.getLogger(__name__) +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, +) + + +class AuthProvider: + """Provider of user authentication.""" + + DEFAULT_TITLE = "Unnamed auth provider" + + 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]: + """Return id of the auth provider. + + Optional, can be None. + """ + return self.config.get(CONF_ID) + + @property + def type(self) -> str: + """Return type of the provider.""" + return self.config[CONF_TYPE] # type: ignore + + @property + def name(self) -> str: + """Return the name of the auth provider.""" + return self.config.get(CONF_NAME, self.DEFAULT_TITLE) + + @property + def support_mfa(self) -> bool: + """Return whether multi-factor auth supported by the auth provider.""" + return True + + async def async_credentials(self) -> List[Credentials]: + """Return all credentials of this provider.""" + users = await self.store.async_get_users() + return [ + credentials + for user in users + for credentials in user.credentials + 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 + ) + + # Implement by extending class + + 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. + """ + raise NotImplementedError + + async def async_get_or_create_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: + """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: + """Initialize an auth provider from a config.""" + provider_name = config[CONF_TYPE] + module = await load_auth_provider_module(hass, provider_name) + + 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), + ) + raise + + return AUTH_PROVIDERS[provider_name](hass, store, config) # type: ignore + + +async def load_auth_provider_module( + hass: HomeAssistant, provider: str +) -> types.ModuleType: + """Load an auth provider.""" + try: + 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(f"Unable to load auth provider {provider}: {err}") + + if hass.config.skip_pip or not hasattr(module, "REQUIREMENTS"): + return module + + processed = hass.data.get(DATA_REQS) + + if processed is None: + processed = hass.data[DATA_REQS] = set() + elif provider in processed: + return module + + # https://github.com/python/mypy/issues/1424 + reqs = module.REQUIREMENTS # type: ignore + await requirements.async_process_requirements( + hass, f"auth provider {provider}", reqs + ) + + processed.add(provider) + return module + + +class LoginFlow(data_entry_flow.FlowHandler): + """Handler for the login flow.""" + + def __init__(self, auth_provider: AuthProvider) -> None: + """Initialize the login flow.""" + self._auth_provider = auth_provider + self._auth_module_id: Optional[str] = None + self._auth_manager = auth_provider.hass.auth # type: ignore + self.available_mfa_modules: Dict[str, str] = {} + self.created_at = dt_util.utcnow() + 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]: + """Handle the first step of login flow. + + 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]: + """Handle the step of select mfa module.""" + errors = {} + + if user_input is not None: + 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" + + 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)} + ), + errors=errors, + ) + + async def async_step_mfa( + 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) + 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 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") + + 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" + 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: Dict[str, Optional[str]] = { + "mfa_module_name": auth_module.name, + "mfa_module_id": auth_module.id, + } + + return self.async_show_form( + step_id="mfa", + data_schema=auth_module.input_schema, + description_placeholders=description_placeholders, + errors=errors, + ) + + 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) diff --git a/homeassistant/auth/providers/command_line.py b/homeassistant/auth/providers/command_line.py new file mode 100644 index 0000000000000..12e27c015049a --- /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 new file mode 100644 index 0000000000000..b3acaaa63520c --- /dev/null +++ b/homeassistant/auth/providers/homeassistant.py @@ -0,0 +1,302 @@ +"""Home Assistant auth provider.""" +import asyncio +import base64 +from collections import OrderedDict +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 HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError + +from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow +from ..models import Credentials, UserMeta + +STORAGE_VERSION = 1 +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.") + + return conf + + +CONFIG_SCHEMA = vol.All(AUTH_PROVIDER_SCHEMA, _disallow_id) + + +class InvalidAuth(HomeAssistantError): + """Raised when we encounter invalid authentication.""" + + +class InvalidUser(HomeAssistantError): + """Raised when invalid user is specified. + + Will not be raised when validating authentication. + """ + + +class Data: + """Hold the user data.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the user data store.""" + self.hass = hass + 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 = {"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 + + def validate_login(self, username: str, password: str) -> None: + """Validate a username and password. + + Raises InvalidAuth if auth invalid. + """ + 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 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) + raise InvalidAuth + + user_hash = base64.b64decode(found["password"]) + + # bcrypt.checkpw is timing-safe + if not bcrypt.checkpw(password.encode(), user_hash): + raise InvalidAuth + + # pylint: disable=no-self-use + def hash_password(self, password: str, for_storage: bool = False) -> bytes: + """Encode a password.""" + 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.""" + 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(), + } + ) + + @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 self.normalize_username(user["username"]) == username: + index = i + break + + if index is None: + raise InvalidUser + + self.users.pop(index) + + def change_password(self, username: str, new_password: str) -> None: + """Update the password. + + Raises InvalidUser if user cannot be found. + """ + username = self.normalize_username(username) + + for user in self.users: + if self.normalize_username(user["username"]) == username: + user["password"] = self.hash_password(new_password, True).decode() + break + else: + raise InvalidUser + + async def async_save(self) -> None: + """Save data.""" + await self._store.async_save(self._data) + + +@AUTH_PROVIDERS.register("homeassistant") +class HassAuthProvider(AuthProvider): + """Auth provider based on a local storage of users in Home Assistant config dir.""" + + DEFAULT_TITLE = "Home Assistant Local" + + 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.""" + async with self._init_lock: + if self.data is not None: + return + + data = Data(self.hass) + await data.async_load() + self.data = data + + async def async_login_flow(self, context: Optional[Dict]) -> LoginFlow: + """Return a flow to login.""" + return HassLoginFlow(self) + + async def async_validate_login(self, username: str, password: str) -> None: + """Validate a username and password.""" + if self.data is None: + await self.async_initialize() + assert self.data is not None + + await self.hass.async_add_executor_job( + self.data.validate_login, username, password + ) + + async def async_get_or_create_credentials( + self, flow_result: Dict[str, str] + ) -> Credentials: + """Get credentials based on the flow result.""" + 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 norm_username(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: + """Get extra info for this credential.""" + return UserMeta(name=credentials.data["username"], is_active=True) + + 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"]) + await self.data.async_save() + except InvalidUser: + # Can happen if somehow we didn't clean up a credential + pass + + +class HassLoginFlow(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: + try: + await cast(HassAuthProvider, self._auth_provider).async_validate_login( + user_input["username"], user_input["password"] + ) + except InvalidAuth: + errors["base"] = "invalid_auth" + + if not errors: + user_input.pop("password") + return await self.async_finish(user_input) + + 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 + ) diff --git a/homeassistant/auth/providers/insecure_example.py b/homeassistant/auth/providers/insecure_example.py new file mode 100644 index 0000000000000..70014a236cdae --- /dev/null +++ b/homeassistant/auth/providers/insecure_example.py @@ -0,0 +1,120 @@ +"""Example auth provider.""" +from collections import OrderedDict +import hmac +from typing import Any, Dict, Optional, cast + +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError + +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, + } +) + + +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") +class ExampleAuthProvider(AuthProvider): + """Example auth provider based on hardcoded usernames and passwords.""" + + async def async_login_flow(self, context: Optional[Dict]) -> LoginFlow: + """Return a flow to login.""" + return ExampleLoginFlow(self) + + @callback + def async_validate_login(self, username: str, password: str) -> None: + """Validate a username and password.""" + user = None + + # Compare all users to avoid timing attacks. + for usr in self.config["users"]: + if hmac.compare_digest( + username.encode("utf-8"), usr["username"].encode("utf-8") + ): + user = usr + + if user is None: + # Do one more compare to make timing the same as if user was found. + hmac.compare_digest(password.encode("utf-8"), password.encode("utf-8")) + raise InvalidAuthError + + if not hmac.compare_digest( + user["password"].encode("utf-8"), password.encode("utf-8") + ): + raise InvalidAuthError + + async def async_get_or_create_credentials( + self, flow_result: 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. + + Will be used to populate info when creating a new user. + """ + username = credentials.data["username"] + name = None + + for user in self.config["users"]: + if user["username"] == username: + name = user.get("name") + break + + return UserMeta(name=name, is_active=True) + + +class ExampleLoginFlow(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: + try: + cast(ExampleAuthProvider, 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] = 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/legacy_api_password.py b/homeassistant/auth/providers/legacy_api_password.py new file mode 100644 index 0000000000000..15ba1dfc14c5c --- /dev/null +++ b/homeassistant/auth/providers/legacy_api_password.py @@ -0,0 +1,118 @@ +""" +Support Legacy API password auth provider. + +It will be removed when auth system production ready +""" +import hmac +from typing import Any, Dict, Optional, cast + +import voluptuous as vol + +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +import homeassistant.helpers.config_validation as cv + +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" + +CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend( + {vol.Required(CONF_API_PASSWORD): cv.string}, extra=vol.PREVENT_EXTRA +) + +LEGACY_USER_NAME = "Legacy API password user" + + +class InvalidAuthError(HomeAssistantError): + """Raised when submitting invalid authentication.""" + + +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") + + 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.""" + return LegacyLoginFlow(self) + + @callback + def async_validate_login(self, password: str) -> None: + """Validate password.""" + api_password = str(self.config[CONF_API_PASSWORD]) + + 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: + """Return credentials for this login.""" + credentials = await self.async_credentials() + if credentials: + return credentials[0] + + return self.async_create_credentials({}) + + async def async_user_meta_for_credentials( + self, credentials: Credentials + ) -> UserMeta: + """ + Return info for the user. + + Will be used to populate info when creating a new user. + """ + return UserMeta(name=LEGACY_USER_NAME, is_active=True) + + +class LegacyLoginFlow(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: + try: + cast( + LegacyApiPasswordAuthProvider, self._auth_provider + ).async_validate_login(user_input["password"]) + except InvalidAuthError: + 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 + ) diff --git a/homeassistant/auth/providers/trusted_networks.py b/homeassistant/auth/providers/trusted_networks.py new file mode 100644 index 0000000000000..bc995368fec8f --- /dev/null +++ b/homeassistant/auth/providers/trusted_networks.py @@ -0,0 +1,206 @@ +"""Trusted Networks auth provider. + +It shows list of users if access from trusted network. +Abort login flow if not access from trusted network. +""" +from ipaddress import IPv4Address, IPv4Network, IPv6Address, IPv6Network, ip_network +from typing import Any, Dict, List, Optional, Union, cast + +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError +import homeassistant.helpers.config_validation as cv + +from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow +from ..models import Credentials, UserMeta + +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): + """Raised when try to access from untrusted networks.""" + + +class InvalidUserError(HomeAssistantError): + """Raised when try to login as invalid user.""" + + +@AUTH_PROVIDERS.register("trusted_networks") +class TrustedNetworksAuthProvider(AuthProvider): + """Trusted Networks auth provider. + + Allow passwordless access from trusted network. + """ + + 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: + """Trusted Networks auth provider does not support MFA.""" + return False + + 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 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, + 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: + """Get credentials based on the flow result.""" + 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: + for credential in await self.async_credentials(): + if credential.data["user_id"] == user_id: + return credential + cred = self.async_create_credentials({"user_id": user_id}) + await self.store.async_link_user(user, cred) + return cred + + # We only allow login as exist user + raise InvalidUserError + + async def async_user_meta_for_credentials( + self, credentials: Credentials + ) -> UserMeta: + """Return extra user metadata for credentials. + + Trusted network auth provider should never create new user. + """ + raise NotImplementedError + + @callback + 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. + """ + if not self.trusted_networks: + raise InvalidAuthError("trusted_networks is not configured") + + 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_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_addr + self._allow_bypass_login = allow_bypass_login + + async def async_step_init( + 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) + + except InvalidAuthError: + 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)}), + ) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 4a6aab534836e..7ceedba5bd5a1 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -1,224 +1,397 @@ -""" -homeassistant.bootstrap -~~~~~~~~~~~~~~~~~~~~~~~ -Provides methods to bootstrap a home assistant instance. - -Each method will return a tuple (bus, statemachine). - -After bootstrapping you can add your own components or -start by calling homeassistant.start_home_assistant(bus) -""" - -import os -import configparser -import yaml -import io +"""Provide methods to bootstrap a Home Assistant instance.""" +import asyncio +from collections import OrderedDict import logging -from collections import defaultdict +import logging.handlers +import os +import sys +from time import time +from typing import Any, Dict, Optional, Set + +import voluptuous as vol -import homeassistant -import homeassistant.loader as loader -import homeassistant.components as core_components -import homeassistant.components.group as group +from homeassistant import config as conf_util, config_entries, core, loader from homeassistant.const import ( - EVENT_COMPONENT_LOADED, CONF_LATITUDE, CONF_LONGITUDE, - CONF_TEMPERATURE_UNIT, CONF_NAME, CONF_TIME_ZONE, TEMP_CELCIUS, - TEMP_FAHRENHEIT) + 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 _LOGGER = logging.getLogger(__name__) -ATTR_COMPONENT = "component" - - -def setup_component(hass, domain, config=None): - """ Setup a component and all its dependencies. """ - - if domain in hass.config.components: - return True - - _ensure_loader_prepared(hass) - - if config is None: - config = defaultdict(dict) - - components = loader.load_order_component(domain) - - # OrderedSet is empty if component or dependencies could not be resolved - if not components: - return False - - for component in components: - if component in hass.config.components: - continue - - if not _setup_component(hass, component, config): - return False - - return True - - -def _setup_component(hass, domain, config): - """ Setup a component for Home Assistant. """ - component = loader.get_component(domain) - - missing_deps = [dep for dep in component.DEPENDENCIES - if dep not in hass.config.components] - - if missing_deps: - _LOGGER.error( - "Not initializing %s because not all dependencies loaded: %s", - domain, ", ".join(missing_deps)) - - return False - - try: - if component.setup(hass, config): - hass.config.components.append(component.DOMAIN) - - # Assumption: if a component does not depend on groups - # it communicates with devices - if group.DOMAIN not in component.DEPENDENCIES: - hass.pool.add_worker() - - hass.bus.fire( - EVENT_COMPONENT_LOADED, {ATTR_COMPONENT: component.DOMAIN}) - - return True - - else: - _LOGGER.error("component %s failed to initialize", domain) - - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Error during setup of component %s", domain) - - return False - - -# pylint: disable=too-many-branches, too-many-statements -def from_config_dict(config, hass=None): - """ - Tries to configure Home Assistant from a config dict. +ERROR_LOG_FILENAME = "home-assistant.log" + +# hass.data key for logging information. +DATA_LOGGING = "logging" + +DEBUGGER_INTEGRATIONS = {"ptvsd"} +CORE_INTEGRATIONS = ("homeassistant", "persistent_notification") +LOGGING_INTEGRATIONS = {"logger", "system_log", "sentry"} +STAGE_1_INTEGRATIONS = { + # To record data + "recorder", + # To make sure we forward data to other instances + "mqtt_eventstream", + # To provide account link implementations + "cloud", +} + + +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. + This method is a coroutine. """ - if hass is None: - hass = homeassistant.HomeAssistant() - - process_ha_core_config(hass, config.get(homeassistant.DOMAIN, {})) - - enable_logging(hass) + start = time() - _ensure_loader_prepared(hass) - - # Make a copy because we are mutating it. - # Convert it to defaultdict so components can always have config dict - # Convert values to dictionaries if they are None - config = defaultdict( - dict, {key: value or {} for key, value in config.items()}) - - # Filter out the repeating and common config section [homeassistant] - components = (key for key in config.keys() - if ' ' not in key and key != homeassistant.DOMAIN) + if enable_log: + async_enable_logging(hass, verbose, log_rotate_days, log_file, log_no_color) - if not core_components.setup(hass, config): - _LOGGER.error("Home Assistant core failed to initialize. " - "Further initialization aborted.") + hass.config.skip_pip = skip_pip + if skip_pip: + _LOGGER.warning( + "Skipping pip installation of required modules. This may cause issues" + ) - return hass + core_config = config.get(core.DOMAIN, {}) - _LOGGER.info("Home Assistant core initialized") + 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 - # Setup the components - for domain in loader.load_order_components(components): - _setup_component(hass, domain, config) + # Make a copy because we are mutating it. + config = OrderedDict(config) + + # Merge packages + 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 -def from_config_file(config_path, hass=None): - """ - Reads the configuration file and tries to start all the required - 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 = homeassistant.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. + This method is a coroutine. + """ # Set config dir to directory holding config file - hass.config.config_dir = os.path.abspath(os.path.dirname(config_path)) + config_dir = os.path.abspath(os.path.dirname(config_path)) + hass.config.config_dir = config_dir - config_dict = {} - # check config file type - if os.path.splitext(config_path)[1] == '.yaml': - # Read yaml - config_dict = yaml.load(io.open(config_path, 'r')) + if not is_virtual_env(): + await async_mount_local_lib_path(config_dir) - # If YAML file was empty - if config_dict is None: - config_dict = {} - - else: - # Read config - config = configparser.ConfigParser() - config.read(config_path) + async_enable_logging(hass, verbose, log_rotate_days, log_file, log_no_color) - for section in config.sections(): - config_dict[section] = {} + await hass.async_add_executor_job(conf_util.process_ha_config_upgrade, hass) - for key, val in config.items(section): - config_dict[section][key] = val - - return from_config_dict(config_dict, hass) - - -def enable_logging(hass): - """ Setup the logging for home assistant. """ - logging.basicConfig(level=logging.INFO) + try: + config_dict = await hass.async_add_executor_job( + conf_util.load_yaml_config_file, config_path + ) + except HomeAssistantError as err: + _LOGGER.error("Error loading %s: %s", config_path, err) + return None + finally: + clear_secret_cache() + + return await async_from_config_dict( + 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: + """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" + + 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 = 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 + + # If the above initialization failed for any reason, setup the default + # formatting. If the above succeeds, this wil result in a no-op. + logging.basicConfig(format=fmt, datefmt=datefmt, level=logging.INFO) + + # Suppress overly verbose logs from libraries that aren't helpful + logging.getLogger("requests").setLevel(logging.WARNING) + logging.getLogger("urllib3").setLevel(logging.WARNING) + logging.getLogger("aiohttp.access").setLevel(logging.WARNING) # Log errors to a file if we have write access to file or config dir - err_log_path = hass.config.path("home-assistant.log") + if log_file is None: + err_log_path = hass.config.path(ERROR_LOG_FILENAME) + else: + err_log_path = os.path.abspath(log_file) + err_path_exists = os.path.isfile(err_log_path) + err_dir = os.path.dirname(err_log_path) # Check if we can write to the error log if it exists or that # we can create files in the containing directory if not. - if (err_path_exists and os.access(err_log_path, os.W_OK)) or \ - (not err_path_exists and os.access(hass.config.config_dir, os.W_OK)): + 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.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)) - err_handler.setLevel(logging.WARNING) - err_handler.setFormatter( - logging.Formatter('%(asctime)s %(name)s: %(message)s', - datefmt='%H:%M %d-%m-%y')) - logging.getLogger('').addHandler(err_handler) + async_handler = AsyncHandler(hass.loop, err_handler) - else: - _LOGGER.error( - "Unable to setup error log %s (access denied)", err_log_path) + async def async_stop_async_handler(_: Any) -> None: + """Cleanup async handler.""" + 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) -def process_ha_core_config(hass, config): - """ Processes the [homeassistant] section from the config. """ - for key, attr in ((CONF_LATITUDE, 'latitude'), - (CONF_LONGITUDE, 'longitude'), - (CONF_NAME, 'location_name'), - (CONF_TIME_ZONE, 'time_zone')): - if key in config: - setattr(hass.config, attr, config[key]) + logger = logging.getLogger("") + logger.addHandler(async_handler) # type: ignore + logger.setLevel(logging.INFO) - if CONF_TEMPERATURE_UNIT in config: - unit = config[CONF_TEMPERATURE_UNIT] + # 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) - if unit == 'C': - hass.config.temperature_unit = TEMP_CELCIUS - elif unit == 'F': - hass.config.temperature_unit = TEMP_FAHRENHEIT - hass.config.auto_detect() +async def async_mount_local_lib_path(config_dir: str) -> str: + """Add local library to Python Path. + This function is a coroutine. + """ + 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 -def _ensure_loader_prepared(hass): - """ Ensure Home Assistant loader is prepared. """ - if not loader.PREPARED: - loader.prepare(hass) + +@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 0b757766bc04c..90e0f32226c33 100644 --- a/homeassistant/components/__init__.py +++ b/homeassistant/components/__init__.py @@ -1,103 +1,46 @@ """ -homeassistant.components -~~~~~~~~~~~~~~~~~~~~~~~~ - This package contains components that can be plugged into Home Assistant. Component design guidelines: - -Each component defines a constant DOMAIN that is equal to its filename. - -Each component that tracks states should create state entity names in the -format ".". - -Each component should publish services only under its own domain. - +- Each component defines a constant DOMAIN that is equal to its filename. +- Each component that tracks states should create state entity names in the + format ".". +- Each component should publish services only under its own domain. """ -import itertools as it import logging -import homeassistant as ha -import homeassistant.util as util -from homeassistant.helpers import extract_entity_ids -from homeassistant.loader import get_component -from homeassistant.const import ( - ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF) +from homeassistant.core import split_entity_id + +# mypy: allow-untyped-defs _LOGGER = logging.getLogger(__name__) def is_on(hass, entity_id=None): - """ Loads up the module to call the is_on method. - If there is no entity id given we will check all. """ - if entity_id: - group = get_component('group') + """Load up the module to call the is_on method. - entity_ids = group.expand_entity_ids(hass, [entity_id]) + If there is no entity id given we will check all. + """ + if entity_id: + entity_ids = hass.components.group.expand_entity_ids([entity_id]) else: entity_ids = hass.states.entity_ids() - for entity_id in entity_ids: - domain = util.split_entity_id(entity_id)[0] - - module = get_component(domain) + for ent_id in entity_ids: + domain = split_entity_id(ent_id)[0] try: - if module.is_on(hass, entity_id): - return True - - except AttributeError: - # module is None or method is_on does not exist - _LOGGER.exception("Failed to call %s.is_on for %s", - module, entity_id) - - return False - + component = getattr(hass.components, domain) -def turn_on(hass, entity_id=None, **service_data): - """ Turns specified entity on if possible. """ - if entity_id is not None: - service_data[ATTR_ENTITY_ID] = entity_id + except ImportError: + _LOGGER.error("Failed to call %s.is_on: component not found", domain) + continue - hass.services.call(ha.DOMAIN, SERVICE_TURN_ON, service_data) + 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 -def turn_off(hass, entity_id=None, **service_data): - """ Turns 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 setup(hass, config): - """ Setup general services related to homeassistant. """ - - def handle_turn_service(service): - """ Method to 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: util.split_entity_id(item)[0]) - - for domain, ent_ids in by_domain: - # 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) - - hass.services.call(domain, service.service, data, True) - - hass.services.register(ha.DOMAIN, SERVICE_TURN_OFF, handle_turn_service) - hass.services.register(ha.DOMAIN, SERVICE_TURN_ON, handle_turn_service) - - return True + return False diff --git a/homeassistant/components/abode/.translations/bg.json b/homeassistant/components/abode/.translations/bg.json new file mode 100644 index 0000000000000..29e3f342cf4d5 --- /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 0000000000000..2424fd9b5f0ef --- /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 0000000000000..75c65f01e113e --- /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 0000000000000..4a5fa763ea16d --- /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 0000000000000..ed5ec85a5d7b2 --- /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 0000000000000..e8daeb22c0a75 --- /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 0000000000000..908e8f0fbc3f3 --- /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 0000000000000..c0c2a35081b15 --- /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 0000000000000..af51aca8af929 --- /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 0000000000000..9560dde6b3d9a --- /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 0000000000000..ed65a5df7c579 --- /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 0000000000000..89b5ae0c4a57e --- /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 0000000000000..e0c1b6d6a7d60 --- /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 0000000000000..542381cbb6415 --- /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 0000000000000..c3f3b8f2c88b8 --- /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 0000000000000..30980103b38cb --- /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 0000000000000..4a371c706f774 --- /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 0000000000000..590f766273132 --- /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 0000000000000..b840913b7bea7 --- /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 0000000000000..5bc9efc36969e --- /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 0000000000000..a5f3e6116a462 --- /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 0000000000000..88a072bd79cd5 --- /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 0000000000000..56c7bbcc1ff09 --- /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 0000000000000..c6f366e0e51a2 --- /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 0000000000000..89b389798f632 --- /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 0000000000000..267eb04f72e40 --- /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 0000000000000..a4fce7e7b8aca --- /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 0000000000000..c02019e6bcc68 --- /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 Home Assistant 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 Home Assistant 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 0000000000000..e7ed40849de1a --- /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 0000000000000..ce71906dfcc51 --- /dev/null +++ b/homeassistant/components/abode/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "abode", + "name": "Abode", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/abode", + "requirements": ["abodepy==0.16.7"], + "dependencies": [], + "codeowners": ["@shred86"] +} diff --git a/homeassistant/components/abode/sensor.py b/homeassistant/components/abode/sensor.py new file mode 100644 index 0000000000000..573df6d49b4fa --- /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 0000000000000..ad0bb076d90b4 --- /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 0000000000000..bf7e768f6e325 --- /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 0000000000000..c092c1ef3f0b4 --- /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 0000000000000..39896d203b1f4 --- /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 0000000000000..eac1c36401aea --- /dev/null +++ b/homeassistant/components/acer_projector/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..b28d67562d4bd --- /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 0000000000000..fa59cc870633a --- /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 0000000000000..302a8d56173e9 --- /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 0000000000000..ddb4954794cb5 --- /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 0000000000000..398927d370a16 --- /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 0000000000000..9b7b3c39b03cf --- /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 0000000000000..e9e6415518d83 --- /dev/null +++ b/homeassistant/components/adguard/.translations/da.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "adguard_home_addon_outdated": "Denne integration kr\u00e6ver AdGuard Home {minimal_version} eller h\u00f8jere, du har {current_version}. Opdater venligst din Hass.io AdGuard Home-tilf\u00f8jelse.", + "adguard_home_outdated": "Denne integration kr\u00e6ver AdGuard Home {minimal_version} eller h\u00f8jere, du har {current_version}.", + "existing_instance_updated": "Opdaterede eksisterende konfiguration.", + "single_instance_allowed": "Kun en enkelt konfiguration af AdGuard Home er tilladt." + }, + "error": { + "connection_error": "Forbindelse mislykkedes." + }, + "step": { + "hassio_confirm": { + "description": "Vil du konfigurere Home Assistant til at oprette forbindelse til AdGuard Home leveret af Hass.io-tilf\u00f8jelsen: {addon}?", + "title": "AdGuard Home via Hass.io-tilf\u00f8jelse" + }, + "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": "Forbind din 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 0000000000000..3434b6feac6aa --- /dev/null +++ b/homeassistant/components/adguard/.translations/de.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "adguard_home_addon_outdated": "Diese Integration erfordert AdGuard Home {minimal_version} oder h\u00f6her, Sie haben {current_version}. Bitte aktualisieren Sie Ihr Hass.io AdGuard Home Add-on.", + "adguard_home_outdated": "Diese Integration erfordert AdGuard Home {minimal_version} oder h\u00f6her, Sie haben {current_version}.", + "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 0000000000000..00d048c3343e0 --- /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 0000000000000..ed8e0c3a35800 --- /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 0000000000000..c6946ab61201c --- /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 0000000000000..749ba7d9c03dc --- /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 0000000000000..869cc46ea106d --- /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 0000000000000..34b601027c221 --- /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 0000000000000..3548361e396bb --- /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 0000000000000..6dc6ae18d8171 --- /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 0000000000000..e1f3925929242 --- /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 0000000000000..e449f668fd935 --- /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 0000000000000..bd0dcc5fa4324 --- /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 0000000000000..0e2e82437e827 --- /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 0000000000000..22a8c23644f6e --- /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 0000000000000..69ba6b024e2d1 --- /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 0000000000000..690947364e1c9 --- /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 0000000000000..77ce7025f70c9 --- /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 0000000000000..eca46d7db0094 --- /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 0000000000000..974524c932da7 --- /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 0000000000000..22bd81e3e9739 --- /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 0000000000000..1b76fef567192 --- /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 0000000000000..7c52a9d1ac00a --- /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 0000000000000..d08a5715a8e81 --- /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 0000000000000..1f4d63d627be4 --- /dev/null +++ b/homeassistant/components/adguard/__init__.py @@ -0,0 +1,198 @@ +"""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], + 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 0000000000000..3657d4ee3ad09 --- /dev/null +++ b/homeassistant/components/adguard/config_flow.py @@ -0,0 +1,191 @@ +"""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], + 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, + 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 0000000000000..eb12a9c163f62 --- /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 0000000000000..c77e0b3254d7b --- /dev/null +++ b/homeassistant/components/adguard/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "adguard", + "name": "AdGuard Home", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/adguard", + "requirements": ["adguardhome==0.4.1"], + "dependencies": [], + "codeowners": ["@frenck"] +} diff --git a/homeassistant/components/adguard/sensor.py b/homeassistant/components/adguard/sensor.py new file mode 100644 index 0000000000000..c818752ad2f71 --- /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, + "AdGuard Safe Searches 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 0000000000000..736acdd923c85 --- /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 0000000000000..d33ba2b397a84 --- /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 0000000000000..39cd1ef028da0 --- /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 new file mode 100644 index 0000000000000..adaaaa08b7f7b --- /dev/null +++ b/homeassistant/components/ads/__init__.py @@ -0,0 +1,325 @@ +"""Support for Automation Device Specification (ADS).""" +import asyncio +from collections import namedtuple +import ctypes +import logging +import struct +import threading + +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" + +# Supported Types +ADSTYPE_BOOL = "bool" +ADSTYPE_BYTE = "byte" +ADSTYPE_DINT = "dint" +ADSTYPE_INT = "int" +ADSTYPE_UDINT = "udint" +ADSTYPE_UINT = "uint" + +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" + +STATE_KEY_STATE = "state" +STATE_KEY_BRIGHTNESS = "brightness" +STATE_KEY_POSITION = "position" + +DOMAIN = "ads" + +SERVICE_WRITE_DATA_BY_NAME = "write_data_by_name" + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_DEVICE): cv.string, + vol.Required(CONF_PORT): cv.port, + vol.Optional(CONF_IP_ADDRESS): cv.string, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + +SCHEMA_SERVICE_WRITE_DATA_BY_NAME = vol.Schema( + { + vol.Required(CONF_ADS_TYPE): vol.In( + [ + ADSTYPE_INT, + ADSTYPE_UINT, + ADSTYPE_BYTE, + 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.""" + + conf = config[DOMAIN] + + net_id = conf.get(CONF_DEVICE) + ip_address = conf.get(CONF_IP_ADDRESS) + port = conf.get(CONF_PORT) + + client = pyads.Connection(net_id, port, ip_address) + + AdsHub.ADS_TYPEMAP = { + ADSTYPE_BOOL: pyads.PLCTYPE_BOOL, + ADSTYPE_BYTE: pyads.PLCTYPE_BYTE, + ADSTYPE_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 + + try: + ads = AdsHub(client) + except pyads.ADSError: + _LOGGER.error( + "Could not connect to ADS host (netid=%s, ip=%s, port=%s)", + net_id, + ip_address, + port, + ) + return False + + hass.data[DATA_ADS] = ads + hass.bus.listen(EVENT_HOMEASSISTANT_STOP, ads.shutdown) + + def handle_write_data_by_name(call): + """Write a value to the connected ADS device.""" + ads_var = call.data.get(CONF_ADS_VAR) + ads_type = call.data.get(CONF_ADS_TYPE) + value = call.data.get(CONF_ADS_VALUE) + + try: + ads.write_by_name(ads_var, value, ads.ADS_TYPEMAP[ads_type]) + except pyads.ADSError as err: + _LOGGER.error(err) + + hass.services.register( + DOMAIN, + SERVICE_WRITE_DATA_BY_NAME, + handle_write_data_by_name, + schema=SCHEMA_SERVICE_WRITE_DATA_BY_NAME, + ) + + return True + + +# Tuple to hold data needed for notification +NotificationItem = namedtuple( + "NotificationItem", "hnotify huser name plc_datatype callback" +) + + +class AdsHub: + """Representation of an ADS connection.""" + + def __init__(self, ads_client): + """Initialize the ADS hub.""" + self._client = ads_client + self._client.open() + + # All ADS devices are registered here + self._devices = [] + self._notification_items = {} + self._lock = threading.Lock() + + def shutdown(self, *args, **kwargs): + """Shutdown ADS connection.""" + + _LOGGER.debug("Shutting down ADS") + for notification_item in self._notification_items.values(): + _LOGGER.debug( + "Deleting device notification %d, %d", + 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.""" + self._devices.append(device) + + def write_by_name(self, name, value, plc_datatype): + """Write a value to the device.""" + + with self._lock: + 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: + 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.""" + + attr = pyads.NotificationAttrib(ctypes.sizeof(plc_datatype)) + + with self._lock: + 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 + ) + + def _device_notification_callback(self, notification, name): + """Handle device notifications.""" + contents = notification.contents + + hnotify = int(contents.hNotification) + _LOGGER.debug("Received notification %d", hnotify) + data = contents.data + + try: + with self._lock: + notification_item = self._notification_items[hnotify] + except KeyError: + _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 0000000000000..b48a360da2886 --- /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 0000000000000..31cfec7e7aacb --- /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 0000000000000..2040faea6b61f --- /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 0000000000000..1859f084bf1fa --- /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 0000000000000..af0eac39cdcac --- /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 0000000000000..116b6df83e605 --- /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 0000000000000..b1f79d1724163 --- /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 0000000000000..a689ee6acf030 --- /dev/null +++ b/homeassistant/components/airvisual/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..3b177c4ce670b --- /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, Home Assistant 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 0000000000000..90196616dc5c3 --- /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 0000000000000..4cfcd5403ddd5 --- /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 0000000000000..ca38f26ff1f70 --- /dev/null +++ b/homeassistant/components/aladdin_connect/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..a9342c8c47727 --- /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 0000000000000..d60cf3173c7f9 --- /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 0000000000000..247a4e96da470 --- /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 0000000000000..220034d23e17d --- /dev/null +++ b/homeassistant/components/alarm_control_panel/.translations/da.json @@ -0,0 +1,18 @@ +{ + "device_automation": { + "action_type": { + "arm_away": "Tilkobl {entity_name} ude", + "arm_home": "Tilkobl {entity_name} hjemme", + "arm_night": "Tilkobl {entity_name} nat", + "disarm": "Frakobl {entity_name}", + "trigger": "Udl\u00f8s {entity_name}" + }, + "trigger_type": { + "armed_away": "{entity_name} tilkoblet ude", + "armed_home": "{entity_name} tilkoblet hjemme", + "armed_night": "{entity_name} tilkoblet nat", + "disarmed": "{entity_name} frakoblet", + "triggered": "{entity_name} udl\u00f8st" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/.translations/de.json b/homeassistant/components/alarm_control_panel/.translations/de.json new file mode 100644 index 0000000000000..3e94345138af0 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/.translations/de.json @@ -0,0 +1,11 @@ +{ + "device_automation": { + "trigger_type": { + "armed_away": "{entity_name} Unterwegs", + "armed_home": "{entity_name} Zuhause", + "armed_night": "{entity_name} Nacht-Modus", + "disarmed": "{entity_name} deaktiviert", + "triggered": "{entity_name} ausgel\u00f6st" + } + } +} \ 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 0000000000000..a00e81feb921f --- /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 0000000000000..8200755de0fd1 --- /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 0000000000000..fbdc6a5605f92 --- /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 0000000000000..b249a16c9f1ce --- /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 0000000000000..78a3f0b07e5fe --- /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 0000000000000..b70ae8dc0259b --- /dev/null +++ b/homeassistant/components/alarm_control_panel/.translations/ko.json @@ -0,0 +1,18 @@ +{ + "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" + }, + "trigger_type": { + "armed_away": "{entity_name} \uc774(\uac00) \uc678\ucd9c \uacbd\ube44\ubaa8\ub4dc\ub85c \uc124\uc815\ub420 \ub54c", + "armed_home": "{entity_name} \uc774(\uac00) \uc7ac\uc2e4 \uacbd\ube44\ubaa8\ub4dc\ub85c \uc124\uc815\ub420 \ub54c", + "armed_night": "{entity_name} \uc774(\uac00) \uc57c\uac04 \uacbd\ube44\ubaa8\ub4dc\ub85c \uc124\uc815\ub420 \ub54c", + "disarmed": "{entity_name} \uc774(\uac00) \ud574\uc81c\ub420 \ub54c", + "triggered": "{entity_name} \uc774(\uac00) \ud2b8\ub9ac\uac70\ub420 \ub54c" + } + } +} \ 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 0000000000000..add11f5b8fe62 --- /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 0000000000000..6f26cc99e21fc --- /dev/null +++ b/homeassistant/components/alarm_control_panel/.translations/nl.json @@ -0,0 +1,18 @@ +{ + "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}" + }, + "trigger_type": { + "armed_away": "{entity_name} afwezig ingeschakeld", + "armed_home": "{entity_name} thuis ingeschakeld", + "armed_night": "{entity_name} nachtstand ingeschakeld", + "disarmed": "{entity_name} uitgeschakeld", + "triggered": "{entity_name} geactiveerd" + } + } +} \ 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 0000000000000..0b58064fe0985 --- /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} aktivert borte", + "armed_home": "{entity_name} aktivert hjemme", + "armed_night": "{entity_name} aktivert natt", + "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 0000000000000..024a0861c1c03 --- /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 0000000000000..274aa8cb4c2e3 --- /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": "{entity_name} 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 0000000000000..90b9b1d43d52f --- /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 0000000000000..f9a0e859e1113 --- /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 0000000000000..855c50ab8273c --- /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 0000000000000..72c0b65436dd8 --- /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 new file mode 100644 index 0000000000000..5fb44a18a0be6 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -0,0 +1,199 @@ +"""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, + 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 + +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" +FORMAT_TEXT = "text" +FORMAT_NUMBER = "number" +ATTR_CODE_ARM_REQUIRED = "code_arm_required" + +ENTITY_ID_FORMAT = DOMAIN + ".{}" + +ALARM_SERVICE_SCHEMA = make_entity_service_schema({vol.Optional(ATTR_CODE): cv.string}) + + +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 + ) + + await component.async_setup(config) + + component.async_register_entity_service( + 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", + [SUPPORT_ALARM_ARM_HOME], + ) + component.async_register_entity_service( + 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", + [SUPPORT_ALARM_ARM_NIGHT], + ) + component.async_register_entity_service( + 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", + [SUPPORT_ALARM_TRIGGER], + ) + + 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 AlarmControlPanel(Entity): + """An abstract class for alarm control devices.""" + + @property + def code_format(self): + """Regex for code format or None if no code is required.""" + return None + + @property + def changed_by(self): + """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() + + def async_alarm_disarm(self, code=None): + """Send disarm command. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_executor_job(self.alarm_disarm, code) + + def alarm_arm_home(self, code=None): + """Send arm home command.""" + raise NotImplementedError() + + def async_alarm_arm_home(self, code=None): + """Send arm home command. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_executor_job(self.alarm_arm_home, code) + + def alarm_arm_away(self, code=None): + """Send arm away command.""" + raise NotImplementedError() + + def async_alarm_arm_away(self, code=None): + """Send arm away command. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_executor_job(self.alarm_arm_away, code) + + def alarm_arm_night(self, code=None): + """Send arm night command.""" + raise NotImplementedError() + + def async_alarm_arm_night(self, code=None): + """Send arm night command. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_executor_job(self.alarm_arm_night, code) + + def alarm_trigger(self, code=None): + """Send alarm trigger command.""" + raise NotImplementedError() + + def async_alarm_trigger(self, code=None): + """Send alarm trigger command. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_executor_job(self.alarm_trigger, code) + + def alarm_arm_custom_bypass(self, code=None): + """Send arm custom bypass command.""" + raise NotImplementedError() + + def async_alarm_arm_custom_bypass(self, code=None): + """Send arm custom bypass command. + + 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) + + @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_CODE_ARM_REQUIRED: self.code_arm_required, + } + return state_attr diff --git a/homeassistant/components/alarm_control_panel/const.py b/homeassistant/components/alarm_control_panel/const.py new file mode 100644 index 0000000000000..77f7846fc3476 --- /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/device_action.py b/homeassistant/components/alarm_control_panel/device_action.py new file mode 100644 index 0000000000000..81e444ae16f0c --- /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 0000000000000..95ae17aaaf562 --- /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/manifest.json b/homeassistant/components/alarm_control_panel/manifest.json new file mode 100644 index 0000000000000..80c245b8d8f6b --- /dev/null +++ b/homeassistant/components/alarm_control_panel/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "alarm_control_panel", + "name": "Alarm Control Panel", + "documentation": "https://www.home-assistant.io/integrations/alarm_control_panel", + "requirements": [], + "dependencies": [], + "codeowners": [], + "quality_scale": "internal" +} 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 0000000000000..705bca608a650 --- /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/services.yaml b/homeassistant/components/alarm_control_panel/services.yaml new file mode 100644 index 0000000000000..b31cb718b3f8e --- /dev/null +++ b/homeassistant/components/alarm_control_panel/services.yaml @@ -0,0 +1,61 @@ +# Describes the format for available alarm control panel services + +alarm_disarm: + description: Send the alarm the command for disarm. + fields: + entity_id: + description: Name of alarm control panel to disarm. + example: 'alarm_control_panel.downstairs' + code: + description: An optional code to disarm the alarm control panel with. + example: 1234 + +alarm_arm_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: + entity_id: + description: Name of alarm control panel to arm home. + example: 'alarm_control_panel.downstairs' + code: + description: An optional code to arm home the alarm control panel with. + example: 1234 + +alarm_arm_away: + description: Send the alarm the command for arm away. + fields: + entity_id: + description: Name of alarm control panel to arm away. + example: 'alarm_control_panel.downstairs' + code: + description: An optional code to arm away the alarm control panel with. + example: 1234 + +alarm_arm_night: + description: Send the alarm the command for arm night. + fields: + entity_id: + description: Name of alarm control panel to arm night. + example: 'alarm_control_panel.downstairs' + code: + description: An optional code to arm night the alarm control panel with. + example: 1234 + +alarm_trigger: + description: Send the alarm the command for trigger. + fields: + entity_id: + description: Name of alarm control panel to trigger. + example: 'alarm_control_panel.downstairs' + code: + description: An optional code to trigger the alarm control panel with. + example: 1234 diff --git a/homeassistant/components/alarm_control_panel/strings.json b/homeassistant/components/alarm_control_panel/strings.json new file mode 100644 index 0000000000000..cbca15c8cf665 --- /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/alarmdecoder/__init__.py b/homeassistant/components/alarmdecoder/__init__.py new file mode 100644 index 0000000000000..833156e98b2ce --- /dev/null +++ b/homeassistant/components/alarmdecoder/__init__.py @@ -0,0 +1,215 @@ +"""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_AUTO_BYPASS = "autobypass" +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_AUTO_BYPASS = False +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_AUTO_BYPASS, default=DEFAULT_AUTO_BYPASS): 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 0000000000000..70f3e67e15b0a --- /dev/null +++ b/homeassistant/components/alarmdecoder/alarm_control_panel.py @@ -0,0 +1,186 @@ +"""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(discovery_info["autobypass"]) + 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, auto_bypass): + """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 + self._auto_bypass = auto_bypass + + 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: + if self._auto_bypass: + self.hass.data[DATA_AD].send(f"{code!s}6#") + self.hass.data[DATA_AD].send(f"{code!s}2") + + def alarm_arm_home(self, code=None): + """Send arm home command.""" + if code: + if self._auto_bypass: + self.hass.data[DATA_AD].send(f"{code!s}6#") + 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 0000000000000..dc3f16b7d2248 --- /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 0000000000000..fd0e79cef8aed --- /dev/null +++ b/homeassistant/components/alarmdecoder/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "alarmdecoder", + "name": "AlarmDecoder", + "documentation": "https://www.home-assistant.io/integrations/alarmdecoder", + "requirements": ["alarmdecoder==1.13.9"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/alarmdecoder/sensor.py b/homeassistant/components/alarmdecoder/sensor.py new file mode 100644 index 0000000000000..196e8d704e158 --- /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 0000000000000..12268d48bb712 --- /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 0000000000000..0a715230e9fb2 --- /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 0000000000000..dd6b1272223ce --- /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 0000000000000..9468649171aaa --- /dev/null +++ b/homeassistant/components/alarmdotcom/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "alarmdotcom", + "name": "Alarm.com", + "documentation": "https://www.home-assistant.io/integrations/alarmdotcom", + "requirements": ["pyalarmdotcom==0.3.2"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/alert/__init__.py b/homeassistant/components/alert/__init__.py new file mode 100644 index 0000000000000..3a473b17f170d --- /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): + """Home Assistant 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 0000000000000..93c88655d34b1 --- /dev/null +++ b/homeassistant/components/alert/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "alert", + "name": "Alert", + "documentation": "https://www.home-assistant.io/integrations/alert", + "requirements": [], + "dependencies": [], + "after_dependencies": ["notify"], + "codeowners": [], + "quality_scale": "internal" +} diff --git a/homeassistant/components/alert/services.yaml b/homeassistant/components/alert/services.yaml new file mode 100644 index 0000000000000..1cdd1f02e7eed --- /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 new file mode 100644 index 0000000000000..5861c4cc9853c --- /dev/null +++ b/homeassistant/components/alexa/__init__.py @@ -0,0 +1,99 @@ +"""Support for Alexa skill service end point.""" +import logging + +import voluptuous as vol + +from homeassistant.const import CONF_NAME +from homeassistant.helpers import config_validation as cv, entityfilter + +from . import flash_briefings, intent, smart_home_http +from .const import ( + CONF_AUDIO, + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + CONF_DESCRIPTION, + CONF_DISPLAY_CATEGORIES, + CONF_DISPLAY_URL, + CONF_ENDPOINT, + CONF_ENTITY_CONFIG, + CONF_FILTER, + CONF_LOCALE, + CONF_SUPPORTED_LOCALES, + CONF_TEXT, + CONF_TITLE, + CONF_UID, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + +CONF_FLASH_BRIEFINGS = "flash_briefings" +CONF_SMART_HOME = "smart_home" +DEFAULT_LOCALE = "en-US" + +ALEXA_ENTITY_SCHEMA = vol.Schema( + { + vol.Optional(CONF_DESCRIPTION): cv.string, + vol.Optional(CONF_DISPLAY_CATEGORIES): cv.string, + vol.Optional(CONF_NAME): cv.string, + } +) + +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_LOCALE, default=DEFAULT_LOCALE): vol.In( + CONF_SUPPORTED_LOCALES + ), + 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, +) + + +async def async_setup(hass, config): + """Activate the Alexa component.""" + config = config.get(DOMAIN, {}) + flash_briefings_config = config.get(CONF_FLASH_BRIEFINGS) + + intent.async_setup(hass) + + if flash_briefings_config: + flash_briefings.async_setup(hass, flash_briefings_config) + + try: + smart_home_config = config[CONF_SMART_HOME] + except KeyError: + pass + else: + smart_home_config = smart_home_config or SMART_HOME_SCHEMA({}) + 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 0000000000000..94789c33305fa --- /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 0000000000000..26d0776074754 --- /dev/null +++ b/homeassistant/components/alexa/capabilities.py @@ -0,0 +1,1655 @@ +"""Alexa capabilities.""" +import logging + +from homeassistant.components import cover, fan, image_processing, input_number, 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 + """ + + supported_locales = {"en-US"} + + 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. + + Applicable to the ThermostatController, SecurityControlPanel, ModeController, RangeController, + and EventDetectionSensor. + """ + return [] + + @staticmethod + def configurations(): + """Return the configurations object. + + The plural configurations object is different that the singular configuration object. + Applicable to EqualizerController interface. + """ + 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 + + # The plural configurations object is different than the singular configuration object above. + configurations = self.configurations() + if configurations: + result["configurations"] = configurations + + 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 + """ + + supported_locales = { + "de-DE", + "en-AU", + "en-CA", + "en-GB", + "en-IN", + "en-US", + "es-ES", + "es-MX", + "fr-CA", + "fr-FR", + "it-IT", + "ja-JP", + } + + 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 + """ + + supported_locales = { + "de-DE", + "en-AU", + "en-CA", + "en-GB", + "en-IN", + "en-US", + "es-ES", + "fr-FR", + "it-IT", + "ja-JP", + } + + 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 + """ + + supported_locales = { + "de-DE", + "en-AU", + "en-CA", + "en-GB", + "en-IN", + "en-US", + "es-ES", + "fr-FR", + "it-IT", + "ja-JP", + } + + 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 + """ + + supported_locales = { + "de-DE", + "en-AU", + "en-CA", + "en-GB", + "en-US", + "es-ES", + "it-IT", + "ja-JP", + } + + 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 + """ + + supported_locales = { + "de-DE", + "en-CA", + "en-GB", + "en-IN", + "en-US", + "es-ES", + "fr-FR", + "it-IT", + } + + 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 + """ + + supported_locales = { + "de-DE", + "en-AU", + "en-CA", + "en-GB", + "en-IN", + "en-US", + "es-ES", + "fr-FR", + "it-IT", + "ja-JP", + } + + 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 + """ + + supported_locales = { + "de-DE", + "en-AU", + "en-CA", + "en-GB", + "en-IN", + "en-US", + "es-ES", + "fr-FR", + "it-IT", + "ja-JP", + } + + 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 + """ + + supported_locales = { + "de-DE", + "en-AU", + "en-CA", + "en-GB", + "en-IN", + "en-US", + "es-ES", + "fr-FR", + "it-IT", + "ja-JP", + } + + 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 + """ + + supported_locales = { + "de-DE", + "en-AU", + "en-CA", + "en-GB", + "en-IN", + "en-US", + "es-ES", + "fr-FR", + "it-IT", + "ja-JP", + } + + 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 + """ + + supported_locales = {"de-DE", "en-AU", "en-CA", "en-GB", "en-IN", "en-US"} + + 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 + """ + + supported_locales = {"de-DE", "en-AU", "en-CA", "en-GB", "en-IN", "en-US"} + + 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 + """ + + supported_locales = {"de-DE", "en-AU", "en-CA", "en-GB", "en-IN", "en-US", "fr-FR"} + + 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 + """ + + supported_locales = {"de-DE", "en-AU", "en-CA", "en-GB", "en-IN", "en-US"} + + 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 + """ + + supported_locales = { + "de-DE", + "en-AU", + "en-CA", + "en-GB", + "en-IN", + "en-US", + "es-ES", + "fr-FR", + "it-IT", + "ja-JP", + } + + 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 + """ + + supported_locales = {"en-CA", "en-US"} + + 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 + """ + + supported_locales = {"en-CA", "en-US"} + + 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 + """ + + supported_locales = { + "de-DE", + "en-AU", + "en-CA", + "en-GB", + "en-IN", + "en-US", + "es-ES", + "fr-FR", + "it-IT", + "ja-JP", + } + + 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 + """ + + supported_locales = { + "de-DE", + "en-AU", + "en-CA", + "en-GB", + "en-IN", + "en-US", + "es-ES", + "fr-FR", + "it-IT", + "ja-JP", + } + + 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 + """ + + supported_locales = {"en-AU", "en-CA", "en-IN", "en-US"} + + 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 + """ + + supported_locales = { + "de-DE", + "en-AU", + "en-CA", + "en-GB", + "en-IN", + "en-US", + "es-ES", + "es-MX", + "fr-CA", + "fr-FR", + "it-IT", + "ja-JP", + } + + 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 + """ + + supported_locales = { + "de-DE", + "en-AU", + "en-CA", + "en-GB", + "en-IN", + "en-US", + "es-ES", + "es-MX", + "fr-CA", + "fr-FR", + "it-IT", + "ja-JP", + } + + 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) + + # Input Number Value + if self.instance == f"{input_number.DOMAIN}.{input_number.ATTR_VALUE}": + return float(self.entity.state) + + 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() + + # Input Number Value + if self.instance == f"{input_number.DOMAIN}.{input_number.ATTR_VALUE}": + min_value = float(self.entity.attributes[input_number.ATTR_MIN]) + max_value = float(self.entity.attributes[input_number.ATTR_MAX]) + precision = float(self.entity.attributes.get(input_number.ATTR_STEP, 1)) + unit = self.entity.attributes.get(input_number.ATTR_UNIT_OF_MEASUREMENT) + + self._resource = AlexaPresetResource( + ["Value"], + min_value=min_value, + max_value=max_value, + precision=precision, + unit=unit, + ) + self._resource.add_preset( + value=min_value, labels=[AlexaGlobalCatalog.VALUE_MINIMUM] + ) + self._resource.add_preset( + value=max_value, labels=[AlexaGlobalCatalog.VALUE_MAXIMUM] + ) + 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 + """ + + supported_locales = { + "de-DE", + "en-AU", + "en-CA", + "en-GB", + "en-IN", + "en-US", + "es-ES", + "es-MX", + "fr-CA", + "fr-FR", + "it-IT", + "ja-JP", + } + + 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 + """ + + supported_locales = {"de-DE", "en-AU", "en-CA", "en-GB", "en-IN", "en-US"} + + 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 + """ + + supported_locales = {"en-US"} + + 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 + """ + + supported_locales = {"de-DE", "en-GB", "en-US", "fr-FR"} + + 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 + """ + + supported_locales = {"de-DE", "en-GB", "en-US"} + + 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 + """ + + supported_locales = {"en-US"} + + 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, + } + }, + } + + +class AlexaEqualizerController(AlexaCapability): + """Implements Alexa.EqualizerController. + + https://developer.amazon.com/en-US/docs/alexa/device-apis/alexa-equalizercontroller.html + """ + + supported_locales = {"en-US"} + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.EqualizerController" + + def properties_supported(self): + """Return what properties this entity supports. + + Either bands, mode or both can be specified. Only mode is supported at this time. + """ + return [{"name": "mode"}] + + def get_property(self, name): + """Read and return a property.""" + if name != "mode": + raise UnsupportedProperty(name) + + sound_mode = self.entity.attributes.get(media_player.ATTR_SOUND_MODE) + if sound_mode and sound_mode.upper() in ( + "MOVIE", + "MUSIC", + "NIGHT", + "SPORT", + "TV", + ): + return sound_mode.upper() + + return None + + def configurations(self): + """Return the sound modes supported in the configurations object. + + Valid Values for modes are: MOVIE, MUSIC, NIGHT, SPORT, TV. + """ + configurations = None + sound_mode_list = self.entity.attributes.get(media_player.ATTR_SOUND_MODE_LIST) + if sound_mode_list: + supported_sound_modes = [] + for sound_mode in sound_mode_list: + if sound_mode.upper() in ("MOVIE", "MUSIC", "NIGHT", "SPORT", "TV"): + supported_sound_modes.append({"name": sound_mode.upper()}) + + configurations = {"modes": {"supported": supported_sound_modes}} + + return configurations diff --git a/homeassistant/components/alexa/config.py b/homeassistant/components/alexa/config.py new file mode 100644 index 0000000000000..a6e45c619538e --- /dev/null +++ b/homeassistant/components/alexa/config.py @@ -0,0 +1,82 @@ +"""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 locale(self): + """Return config locale.""" + 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 new file mode 100644 index 0000000000000..f5f19bbf9556a --- /dev/null +++ b/homeassistant/components/alexa/const.py @@ -0,0 +1,221 @@ +"""Constants for the Alexa integration.""" +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_FILTER = "filter" +CONF_ENTITY_CONFIG = "entity_config" +CONF_ENDPOINT = "endpoint" +CONF_CLIENT_ID = "client_id" +CONF_CLIENT_SECRET = "client_secret" +CONF_LOCALE = "locale" + +ATTR_UID = "uid" +ATTR_UPDATE_DATE = "updateDate" +ATTR_TITLE_TEXT = "titleText" +ATTR_STREAM_URL = "streamUrl" +ATTR_MAIN_TEXT = "mainText" +ATTR_REDIRECTION_URL = "redirectionURL" + +SYN_RESOLUTION_MATCH = "ER_SUCCESS_MATCH" + +DATE_FORMAT = "%Y-%m-%dT%H:%M:%S.0Z" + +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" +CONF_SUPPORTED_LOCALES = ( + "de-DE", + "en-AU", + "en-CA", + "en-GB", + "en-IN", + "en-US", + "es-ES", + "es-MX", + "fr-CA", + "fr-FR", + "it-IT", + "ja-JP", +) + +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", + } + + VALID_SOUND_MODE_MAP = { + "movie": "MOVIE", + "music": "MUSIC", + "night": "NIGHT", + "sport": "SPORT", + "tv": "TV", + } diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py new file mode 100644 index 0000000000000..d6fa0415640e0 --- /dev/null +++ b/homeassistant/components/alexa/entities.py @@ -0,0 +1,707 @@ +"""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, + input_number, + 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, + AlexaEqualizerController, + 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.""" + result = { + "displayCategories": self.display_categories(), + "cookie": {}, + "endpointId": self.alexa_id(), + "friendlyName": self.friendly_name(), + "description": self.description(), + "manufacturerName": "Home Assistant", + } + + locale = self.config.locale + capabilities = [] + for i in self.interfaces(): + if locale in i.supported_locales: + capabilities.append(i.serialize_discovery()) + result["capabilities"] = capabilities + + return result + + +@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) + + if supported & media_player.const.SUPPORT_SELECT_SOUND_MODE: + yield AlexaEqualizerController(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) + + +@ENTITY_ADAPTERS.register(input_number.DOMAIN) +class InputNumberCapabilities(AlexaEntity): + """Class to represent input_number capabilities.""" + + def default_display_categories(self): + """Return the display categories for this entity.""" + return [DisplayCategory.OTHER] + + def interfaces(self): + """Yield the supported interfaces.""" + + yield AlexaRangeController( + self.entity, instance=f"{input_number.DOMAIN}.{input_number.ATTR_VALUE}" + ) + 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 0000000000000..29643bacc53e5 --- /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 new file mode 100644 index 0000000000000..45d31d6088afc --- /dev/null +++ b/homeassistant/components/alexa/flash_briefings.py @@ -0,0 +1,96 @@ +"""Support for Alexa skill service end point.""" +import copy +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, +) + +_LOGGER = logging.getLogger(__name__) + +FLASH_BRIEFINGS_API_ENDPOINT = "/api/alexa/flash_briefings/{briefing_id}" + + +@callback +def async_setup(hass, flash_briefing_config): + """Activate Alexa component.""" + hass.http.register_view(AlexaFlashBriefingView(hass, flash_briefing_config)) + + +class AlexaFlashBriefingView(http.HomeAssistantView): + """Handle Alexa Flash Briefing skill requests.""" + + url = FLASH_BRIEFINGS_API_ENDPOINT + name = "api:alexa:flash_briefings" + + def __init__(self, hass, flash_briefings): + """Initialize Alexa view.""" + super().__init__() + self.flash_briefings = copy.deepcopy(flash_briefings) + template.attach(hass, self.flash_briefings) + + @callback + def get(self, request, briefing_id): + """Handle Alexa Flash Briefing request.""" + _LOGGER.debug("Received Alexa flash briefing request for: %s", briefing_id) + + if self.flash_briefings.get(briefing_id) is None: + err = "No configured Alexa flash briefing was found for: %s" + _LOGGER.error(err, briefing_id) + return b"", 404 + + briefing = [] + + for item in self.flash_briefings.get(briefing_id, []): + output = {} + if item.get(CONF_TITLE) is not None: + if isinstance(item.get(CONF_TITLE), template.Template): + output[ATTR_TITLE_TEXT] = item[CONF_TITLE].async_render() + else: + output[ATTR_TITLE_TEXT] = item.get(CONF_TITLE) + + if item.get(CONF_TEXT) is not None: + if isinstance(item.get(CONF_TEXT), template.Template): + output[ATTR_MAIN_TEXT] = item[CONF_TEXT].async_render() + else: + output[ATTR_MAIN_TEXT] = item.get(CONF_TEXT) + + uid = item.get(CONF_UID) + if uid is None: + uid = str(uuid.uuid4()) + output[ATTR_UID] = uid + + if item.get(CONF_AUDIO) is not None: + if isinstance(item.get(CONF_AUDIO), template.Template): + output[ATTR_STREAM_URL] = item[CONF_AUDIO].async_render() + else: + output[ATTR_STREAM_URL] = item.get(CONF_AUDIO) + + if item.get(CONF_DISPLAY_URL) is not None: + if isinstance(item.get(CONF_DISPLAY_URL), template.Template): + output[ATTR_REDIRECTION_URL] = item[CONF_DISPLAY_URL].async_render() + else: + output[ATTR_REDIRECTION_URL] = item.get(CONF_DISPLAY_URL) + + output[ATTR_UPDATE_DATE] = dt_util.utcnow().strftime(DATE_FORMAT) + + briefing.append(output) + + return self.json(briefing) diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py new file mode 100644 index 0000000000000..74c1b24d42be0 --- /dev/null +++ b/homeassistant/components/alexa/handlers.py @@ -0,0 +1,1398 @@ +"""Alexa message handlers.""" +import logging +import math + +from homeassistant import core as ha +from homeassistant.components import ( + cover, + fan, + group, + input_number, + 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.""" + if config.locale not in {"de-DE", "en-US", "ja-JP"}: + msg = f"The unlock directive is not supported for the following locales: {config.locale}" + raise AlexaInvalidDirectiveError(msg) + + 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 = directive.payload["rangeValue"] + + # Fan Speed + if instance == f"{fan.DOMAIN}.{fan.ATTR_SPEED}": + service = fan.SERVICE_SET_SPEED + speed = SPEED_FAN_MAP.get(int(range_value)) + + 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}": + range_value = int(range_value) + 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}": + range_value = int(range_value) + 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 + + # Input Number Value + elif instance == f"{input_number.DOMAIN}.{input_number.ATTR_VALUE}": + range_value = float(range_value) + service = input_number.SERVICE_SET_VALUE + min_value = float(entity.attributes[input_number.ATTR_MIN]) + max_value = float(entity.attributes[input_number.ATTR_MAX]) + data[input_number.ATTR_VALUE] = min(max_value, max(min_value, 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 = directive.payload["rangeValueDelta"] + response_value = 0 + + # Fan Speed + if instance == f"{fan.DOMAIN}.{fan.ATTR_SPEED}": + range_delta = int(range_delta) + 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}": + range_delta = int(range_delta) + 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}": + range_delta = int(range_delta) + 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) + ) + + # Input Number Value + elif instance == f"{input_number.DOMAIN}.{input_number.ATTR_VALUE}": + range_delta = float(range_delta) + service = input_number.SERVICE_SET_VALUE + min_value = float(entity.attributes[input_number.ATTR_MIN]) + max_value = float(entity.attributes[input_number.ATTR_MAX]) + current = float(entity.state) + data[input_number.ATTR_VALUE] = response_value = min( + max_value, max(min_value, 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 + ) + + +@HANDLERS.register(("Alexa.EqualizerController", "SetMode")) +async def async_api_set_eq_mode(hass, config, directive, context): + """Process a SetMode request for EqualizerController.""" + mode = directive.payload["mode"] + entity = directive.entity + data = {ATTR_ENTITY_ID: entity.entity_id} + + sound_mode_list = entity.attributes.get(media_player.const.ATTR_SOUND_MODE_LIST) + if sound_mode_list and mode.lower() in sound_mode_list: + data[media_player.const.ATTR_SOUND_MODE] = mode.lower() + else: + msg = "failed to map sound mode {} to a mode on {}".format( + mode, entity.entity_id + ) + raise AlexaInvalidValueError(msg) + + await hass.services.async_call( + entity.domain, + media_player.SERVICE_SELECT_SOUND_MODE, + data, + blocking=False, + context=context, + ) + + return directive.response() + + +@HANDLERS.register(("Alexa.EqualizerController", "AdjustBands")) +@HANDLERS.register(("Alexa.EqualizerController", "ResetBands")) +@HANDLERS.register(("Alexa.EqualizerController", "SetBands")) +async def async_api_bands_directive(hass, config, directive, context): + """Handle an AdjustBands, ResetBands, SetBands request. + + Only mode directives are currently supported for the EqualizerController. + """ + # Currently bands directives are not supported. + msg = "Entity does not support directive" + raise AlexaInvalidDirectiveError(msg) diff --git a/homeassistant/components/alexa/intent.py b/homeassistant/components/alexa/intent.py new file mode 100644 index 0000000000000..4cb75c65bc975 --- /dev/null +++ b/homeassistant/components/alexa/intent.py @@ -0,0 +1,284 @@ +"""Support for Alexa skill service end point.""" +import enum +import logging + +from homeassistant.components import http +from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import intent +from homeassistant.util.decorator import Registry + +from .const import DOMAIN, SYN_RESOLUTION_MATCH + +_LOGGER = logging.getLogger(__name__) + +HANDLERS = Registry() + +INTENTS_API_ENDPOINT = "/api/alexa" + + +class SpeechType(enum.Enum): + """The Alexa speech types.""" + + plaintext = "PlainText" + ssml = "SSML" + + +SPEECH_MAPPINGS = {"plain": SpeechType.plaintext, "ssml": SpeechType.ssml} + + +class CardType(enum.Enum): + """The Alexa card types.""" + + simple = "Simple" + link_account = "LinkAccount" + + +@callback +def async_setup(hass): + """Activate Alexa component.""" + hass.http.register_view(AlexaIntentsView) + + +class UnknownRequest(HomeAssistantError): + """When an unknown Alexa request is passed in.""" + + +class AlexaIntentsView(http.HomeAssistantView): + """Handle Alexa requests.""" + + url = INTENTS_API_ENDPOINT + name = "api:alexa" + + async def post(self, request): + """Handle Alexa.""" + hass = request.app["hass"] + message = await request.json() + + _LOGGER.debug("Received Alexa request: %s", message) + + try: + 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))) + + except intent.UnknownIntent as err: + _LOGGER.warning(str(err)) + return self.json( + intent_error_response( + hass, + message, + "This intent is not yet configured within Home Assistant.", + ) + ) + + except intent.InvalidSlotInfo as err: + _LOGGER.error("Received invalid slot data from Alexa: %s", err) + return self.json( + intent_error_response( + hass, message, "Invalid slot information received for this intent." + ) + ) + + except intent.IntentError as err: + _LOGGER.exception(str(err)) + return self.json( + intent_error_response(hass, message, "Error handling intent.") + ) + + +def intent_error_response(hass, message, error): + """Return an Alexa response that will speak the error message.""" + alexa_intent_info = message.get("request").get("intent") + alexa_response = AlexaResponse(hass, alexa_intent_info) + alexa_response.add_speech(SpeechType.plaintext, error) + return alexa_response.as_dict() + + +async def async_handle_message(hass, message): + """Handle an Alexa intent. + + Raises: + - UnknownRequest + - intent.UnknownIntent + - intent.InvalidSlotInfo + - intent.IntentError + + """ + req = message.get("request") + req_type = req["type"] + + handler = HANDLERS.get(req_type) + + if not handler: + raise UnknownRequest(f"Received unknown request {req_type}") + + return await handler(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") +async def async_handle_intent(hass, message): + """Handle an intent request. + + Raises: + - intent.UnknownIntent + - intent.InvalidSlotInfo + - intent.IntentError + + """ + req = message.get("request") + alexa_intent_info = req.get("intent") + alexa_response = AlexaResponse(hass, alexa_intent_info) + + if req["type"] == "LaunchRequest": + intent_name = ( + message.get("session", {}).get("application", {}).get("applicationId") + ) + else: + intent_name = alexa_intent_info["name"] + + intent_response = 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"] + ) + break + + if "simple" in intent_response.card: + alexa_response.add_card( + CardType.simple, + intent_response.card["simple"]["title"], + intent_response.card["simple"]["content"], + ) + + return alexa_response.as_dict() + + +def resolve_slot_synonyms(key, request): + """Check slot request for synonym resolutions.""" + # Default to the spoken slot value if more than one or none are found. For + # reference to the request object structure, see the Alexa docs: + # https://tinyurl.com/ybvm7jhs + resolved_value = request["value"] + + if ( + "resolutions" in request + and "resolutionsPerAuthority" in request["resolutions"] + and len(request["resolutions"]["resolutionsPerAuthority"]) >= 1 + ): + + # Extract all of the possible values from each authority with a + # successful match + possible_values = [] + + for entry in request["resolutions"]["resolutionsPerAuthority"]: + if entry["status"]["code"] != SYN_RESOLUTION_MATCH: + continue + + possible_values.extend([item["value"]["name"] for item in entry["values"]]) + + # If there is only one match use the resolved value, otherwise the + # resolution cannot be determined, so use the spoken slot value + if len(possible_values) == 1: + resolved_value = possible_values[0] + else: + _LOGGER.debug( + "Found multiple synonym resolutions for slot value: {%s: %s}", + key, + request["value"], + ) + + return resolved_value + + +class AlexaResponse: + """Help generating the response for Alexa.""" + + def __init__(self, hass, intent_info): + """Initialize the response.""" + self.hass = hass + self.speech = None + self.card = None + self.reprompt = None + self.session_attributes = {} + self.should_end_session = True + self.variables = {} + + # Intent is None if request was a LaunchRequest or SessionEndedRequest + if intent_info is not None: + for key, value in intent_info.get("slots", {}).items(): + # Only include slots with values + if "value" not in value: + continue + + _key = key.replace(".", "_") + + self.variables[_key] = resolve_slot_synonyms(key, value) + + def add_card(self, card_type, title, content): + """Add a card to the response.""" + assert self.card is None + + card = {"type": card_type.value} + + if card_type == CardType.link_account: + self.card = card + return + + card["title"] = title + card["content"] = content + self.card = card + + def add_speech(self, speech_type, text): + """Add speech to the response.""" + assert self.speech is None + + key = "ssml" if speech_type == SpeechType.ssml else "text" + + self.speech = {"type": speech_type.value, key: text} + + def add_reprompt(self, speech_type, text): + """Add reprompt if user does not answer.""" + assert self.reprompt is None + + key = "ssml" if speech_type == SpeechType.ssml else "text" + + self.reprompt = { + "type": speech_type.value, + key: text.async_render(self.variables), + } + + def as_dict(self): + """Return response in an Alexa valid dict.""" + response = {"shouldEndSession": self.should_end_session} + + if self.card is not None: + response["card"] = self.card + + if self.speech is not None: + response["outputSpeech"] = self.speech + + if self.reprompt is not None: + response["reprompt"] = {"outputSpeech": self.reprompt} + + return { + "version": "1.0", + "sessionAttributes": self.session_attributes, + "response": response, + } diff --git a/homeassistant/components/alexa/manifest.json b/homeassistant/components/alexa/manifest.json new file mode 100644 index 0000000000000..5334cf765b8dd --- /dev/null +++ b/homeassistant/components/alexa/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "alexa", + "name": "Amazon 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 0000000000000..cb78f269f8f3f --- /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 0000000000000..09927321c3671 --- /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 = min_value + self._maximum_value = max_value + self._precision = 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/homeassistant/components/alexa/services.yaml b/homeassistant/components/alexa/services.yaml new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py new file mode 100644 index 0000000000000..9b0955f8fcaca --- /dev/null +++ b/homeassistant/components/alexa/smart_home.py @@ -0,0 +1,68 @@ +"""Support for alexa Smart Home Skill API.""" +import logging + +import homeassistant.core as ha + +from .const import API_DIRECTIVE, API_HEADER +from .errors import AlexaBridgeUnreachableError, AlexaError +from .handlers import HANDLERS +from .messages import AlexaDirective + +_LOGGER = logging.getLogger(__name__) + +EVENT_ALEXA_SMART_HOME = "alexa_smart_home" + + +async def async_handle_message(hass, config, request, context=None, enabled=True): + """Handle incoming API messages. + + 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. + """ + assert request[API_DIRECTIVE][API_HEADER]["payloadVersion"] == "3" + + if context is None: + context = ha.Context() + + directive = AlexaDirective(request) + + try: + 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 + ) + + request_info = {"namespace": directive.namespace, "name": directive.name} + + if directive.has_endpoint: + request_info["entity_id"] = directive.entity_id + + 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 0000000000000..7c745f8afddaf --- /dev/null +++ b/homeassistant/components/alexa/smart_home_http.py @@ -0,0 +1,123 @@ +"""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, + CONF_LOCALE, +) +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 {} + + @property + def locale(self): + """Return config locale.""" + return self._config.get(CONF_LOCALE) + + 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 0000000000000..44e1b7f4f554a --- /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 0000000000000..3327e34e76581 --- /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 0000000000000..c626e2795ea5e --- /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/da.json b/homeassistant/components/almond/.translations/da.json new file mode 100644 index 0000000000000..93158cee94f28 --- /dev/null +++ b/homeassistant/components/almond/.translations/da.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_setup": "Du kan kun konfigurere en Almond-konto.", + "cannot_connect": "Kan ikke oprette forbindelse til Almond-serveren.", + "missing_configuration": "Tjek venligst dokumentationen om, hvordan man indstiller Almond." + }, + "step": { + "pick_implementation": { + "title": "V\u00e6lg godkendelsesmetode" + } + }, + "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 0000000000000..1495cabf9c911 --- /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 0000000000000..3b7b5b9aa636a --- /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 0000000000000..26eacb834b0ca --- /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 0000000000000..9ae881d332cdb --- /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 0000000000000..9d529e5e5c85a --- /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 0000000000000..9f1e71163d651 --- /dev/null +++ b/homeassistant/components/almond/.translations/ko.json @@ -0,0 +1,15 @@ +{ + "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." + }, + "step": { + "pick_implementation": { + "title": "\uc778\uc99d \ubc29\ubc95 \uc120\ud0dd" + } + }, + "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 0000000000000..ca836267d46cc --- /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 0000000000000..d77fe69f7fa8e --- /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 0000000000000..a25f5dc157451 --- /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 0000000000000..0272a120f21dd --- /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 0000000000000..56aa629e015b1 --- /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 0000000000000..94dfbefb86a22 --- /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 0000000000000..720400e72a5f2 --- /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 0000000000000..39dc41a39952e --- /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 0000000000000..086190590ac88 --- /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 0000000000000..4db6e0c936efa --- /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 0000000000000..8877107b9847c --- /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 0000000000000..42f9318a06f76 --- /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 0000000000000..34dca28e9571e --- /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 0000000000000..44404b504f6a0 --- /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 0000000000000..872367eb86237 --- /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 0000000000000..b8da9c190245b --- /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 0000000000000..33348c9d7b36f --- /dev/null +++ b/homeassistant/components/alpha_vantage/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..7d871c286e57c --- /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 0000000000000..0fab4af43e6f1 --- /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 0000000000000..4bfcff4ce7619 --- /dev/null +++ b/homeassistant/components/amazon_polly/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "amazon_polly", + "name": "Amazon Polly", + "documentation": "https://www.home-assistant.io/integrations/amazon_polly", + "requirements": ["boto3==1.9.252"], + "dependencies": [], + "codeowners": ["@robbiet480"] +} diff --git a/homeassistant/components/amazon_polly/tts.py b/homeassistant/components/amazon_polly/tts.py new file mode 100644 index 0000000000000..bcb4a24e95b92 --- /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 0000000000000..4795267cd5e81 --- /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 0000000000000..f446bf7390f97 --- /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 0000000000000..d34169edfc734 --- /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 0000000000000..b57a0e1579737 --- /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 0000000000000..68d714cfc1bea --- /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 0000000000000..da1e173b4a816 --- /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 0000000000000..607454f4402f0 --- /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 0000000000000..6447926f64ec9 --- /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 0000000000000..6d09fd6ee0542 --- /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 0000000000000..a13874b36764d --- /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 0000000000000..3b21726bcbeb1 --- /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 0000000000000..88be279ae7a26 --- /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 0000000000000..ca4d0b912ab7b --- /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 0000000000000..ce8a3ed9db6b4 --- /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 0000000000000..e84de4ffc2261 --- /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 0000000000000..18f5d043dbcef --- /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 0000000000000..4de4190d0558c --- /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 0000000000000..2a99430e43697 --- /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 0000000000000..c5d84f030fa12 --- /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 0000000000000..f52bb6697f9aa --- /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 0000000000000..1539429d0efc0 --- /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 0000000000000..e15f6dea2ec44 --- /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 0000000000000..a8ed166903e6b --- /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 0000000000000..4996a458a1f34 --- /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 0000000000000..833fef303f54e --- /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 0000000000000..151b761dff865 --- /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 0000000000000..19f47c6c35f42 --- /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 0000000000000..78386077af28e --- /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 0000000000000..2099038f00430 --- /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 0000000000000..d3c451f3e3ff8 --- /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 0000000000000..6cec31eca29a5 --- /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 0000000000000..1431efbf167b2 --- /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 0000000000000..5bd643da55cfa --- /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 0000000000000..268a6ba001e45 --- /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 0000000000000..d4222f1d2ebea --- /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 0000000000000..b28cb374eacdf --- /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 0000000000000..f5afbca71c0f2 --- /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 0000000000000..222b512c39f82 --- /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 0000000000000..b468ba3673cd5 --- /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 0000000000000..eb9209a6c3781 --- /dev/null +++ b/homeassistant/components/ambient_station/.translations/ko.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "\uc560\ud50c\ub9ac\ucf00\uc774\uc158 \ud0a4 \ud639\uc740 API \ud0a4\uac00 \uc774\ubbf8 \ub4f1\ub85d\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "invalid_key": "\uc560\ud50c\ub9ac\ucf00\uc774\uc158 \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": "\uc560\ud50c\ub9ac\ucf00\uc774\uc158 \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 0000000000000..0f0d60d445863 --- /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 0000000000000..a070128eefe41 --- /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 0000000000000..0f878b363c92f --- /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 0000000000000..0b9d377718ba2 --- /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 0000000000000..6ebd0848a632f --- /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 0000000000000..61f5cea5e26c0 --- /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 0000000000000..92746b29f3d6a --- /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 0000000000000..438b1cf87a7ff --- /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 0000000000000..906a6b404c463 --- /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 0000000000000..c429d4395030f --- /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 0000000000000..a6115413edcd8 --- /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 0000000000000..866c06316f1ca --- /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 0000000000000..6c7c88a804576 --- /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 0000000000000..58389dd183189 --- /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 0000000000000..3f02eb9f1e828 --- /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 0000000000000..c20b43598ca4f --- /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 0000000000000..b2df34f2f28e3 --- /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 0000000000000..25f60f63abfa5 --- /dev/null +++ b/homeassistant/components/ambient_station/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "ambient_station", + "name": "Ambient Weather 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 0000000000000..56425221e0d33 --- /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 0000000000000..657b3477bb225 --- /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/__init__.py b/homeassistant/components/amcrest/__init__.py new file mode 100644 index 0000000000000..b934a7e054982 --- /dev/null +++ b/homeassistant/components/amcrest/__init__.py @@ -0,0 +1,284 @@ +"""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.const import ( + ATTR_ENTITY_ID, + CONF_AUTHENTICATION, + CONF_BINARY_SENSORS, + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_SCAN_INTERVAL, + CONF_SENSORS, + 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_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 SENSORS + +_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 _has_unique_names(devices): + names = [device[CONF_NAME] for device in devices] + vol.Schema(vol.Unique())(names) + return devices + + +AMCREST_SCHEMA = 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)]), + vol.Optional(CONF_CONTROL_LIGHT, default=True): cv.boolean, + } +) + +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) + 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 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 0000000000000..ac16f0664aa43 --- /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 0000000000000..e9e1e2b5f8485 --- /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 0000000000000..98d613634b514 --- /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 0000000000000..a40d6ace50a31 --- /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 0000000000000..ee5b97b857975 --- /dev/null +++ b/homeassistant/components/amcrest/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..be03b3bedffa8 --- /dev/null +++ b/homeassistant/components/amcrest/sensor.py @@ -0,0 +1,129 @@ +"""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_PTZ_PRESET = "ptz_preset" +SENSOR_SDCARD = "sdcard" +# Sensor types are defined like: Name, units, icon +SENSORS = { + 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_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 0000000000000..d6e7a02a4f96b --- /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/ampio/__init__.py b/homeassistant/components/ampio/__init__.py new file mode 100644 index 0000000000000..5f7bb4a44fa2a --- /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 0000000000000..c925909a9a81b --- /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 0000000000000..99c84da633435 --- /dev/null +++ b/homeassistant/components/ampio/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "ampio", + "name": "Ampio Smart Smog System", + "documentation": "https://www.home-assistant.io/integrations/ampio", + "requirements": ["asmog==0.0.6"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/android_ip_webcam/__init__.py b/homeassistant/components/android_ip_webcam/__init__.py new file mode 100644 index 0000000000000..1f9df527c2820 --- /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 0000000000000..0e9cca46afbfd --- /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 0000000000000..f5181a7d33fea --- /dev/null +++ b/homeassistant/components/android_ip_webcam/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..05c1fe16c61c2 --- /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 0000000000000..2d5f2412d8528 --- /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 0000000000000..14832aef31587 --- /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 0000000000000..d81e7863503dd --- /dev/null +++ b/homeassistant/components/androidtv/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "androidtv", + "name": "Android TV", + "documentation": "https://www.home-assistant.io/integrations/androidtv", + "requirements": [ + "adb-shell==0.1.1", + "androidtv==0.0.38", + "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 0000000000000..63b27f17bb2e7 --- /dev/null +++ b/homeassistant/components/androidtv/media_player.py @@ -0,0 +1,687 @@ +"""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 +from androidtv.exceptions import LockNotAcquiredException +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 +) + +ATTR_DEVICE_PATH = "device_path" +ATTR_LOCAL_PATH = "local_path" + +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_DOWNLOAD = "download" +SERVICE_UPLOAD = "upload" + +SERVICE_ADB_COMMAND_SCHEMA = vol.Schema( + {vol.Required(ATTR_ENTITY_ID): cv.entity_ids, vol.Required(ATTR_COMMAND): cv.string} +) + +SERVICE_DOWNLOAD_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_DEVICE_PATH): cv.string, + vol.Required(ATTR_LOCAL_PATH): cv.string, + } +) + +SERVICE_UPLOAD_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_DEVICE_PATH): cv.string, + vol.Required(ATTR_LOCAL_PATH): 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, {}) + + address = f"{config[CONF_HOST]}:{config[CONF_PORT]}" + + if address in hass.data[ANDROIDTV_DOMAIN]: + _LOGGER.warning("Platform already setup on %s, skipping", address) + return + + 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, address, adb_log + ) + raise PlatformNotReady + + device_args = [ + aftv, + config[CONF_NAME], + config[CONF_APPS], + config[CONF_GET_SOURCES], + config.get(CONF_TURN_ON_COMMAND), + config.get(CONF_TURN_OFF_COMMAND), + ] + + if aftv.DEVICE_CLASS == DEVICE_ANDROIDTV: + device = AndroidTVDevice(*device_args) + device_name = config.get(CONF_NAME, "Android TV") + else: + device = FireTVDevice(*device_args) + device_name = config.get(CONF_NAME, "Fire TV") + + add_entities([device]) + _LOGGER.debug("Setup %s at %s %s", device_name, address, adb_log) + hass.data[ANDROIDTV_DOMAIN][address] = 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[ATTR_COMMAND] + entity_id = service.data[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 service_download(service): + """Download a file from your Android TV / Fire TV device to your Home Assistant instance.""" + local_path = service.data[ATTR_LOCAL_PATH] + if not hass.config.is_allowed_path(local_path): + _LOGGER.warning("'%s' is not secure to load data from!", local_path) + return + + device_path = service.data[ATTR_DEVICE_PATH] + entity_id = service.data[ATTR_ENTITY_ID] + target_device = [ + dev + for dev in hass.data[ANDROIDTV_DOMAIN].values() + if dev.entity_id in entity_id + ][0] + + target_device.adb_pull(local_path, device_path) + + hass.services.register( + ANDROIDTV_DOMAIN, + SERVICE_DOWNLOAD, + service_download, + schema=SERVICE_DOWNLOAD_SCHEMA, + ) + + def service_upload(service): + """Upload a file from your Home Assistant instance to an Android TV / Fire TV device.""" + local_path = service.data[ATTR_LOCAL_PATH] + if not hass.config.is_allowed_path(local_path): + _LOGGER.warning("'%s' is not secure to load data from!", local_path) + return + + device_path = service.data[ATTR_DEVICE_PATH] + entity_id = service.data[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: + target_device.adb_push(local_path, device_path) + + hass.services.register( + ANDROIDTV_DOMAIN, SERVICE_UPLOAD, service_upload, schema=SERVICE_UPLOAD_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 LockNotAcquiredException: + # If the ADB lock could not be acquired, skip this command + _LOGGER.info( + "ADB command not executed because the connection is currently in use" + ) + return + 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 + + try: + response = self.aftv.adb_shell(cmd) + except UnicodeDecodeError: + self._adb_response = None + self.schedule_update_ha_state() + return + + 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 + + @adb_decorator() + def adb_pull(self, local_path, device_path): + """Download a file from your Android TV / Fire TV device to your Home Assistant instance.""" + self.aftv.adb_pull(local_path, device_path) + + @adb_decorator() + def adb_push(self, local_path, device_path): + """Upload a file from your Home Assistant instance to an Android TV / Fire TV device.""" + self.aftv.adb_push(local_path, device_path) + + +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 0000000000000..96d70ef49985e --- /dev/null +++ b/homeassistant/components/androidtv/services.yaml @@ -0,0 +1,35 @@ +# 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' +download: + description: Download a file from your Android TV / Fire TV device to your Home Assistant instance. + fields: + entity_id: + description: Name of Android TV / Fire TV entity. + example: 'media_player.android_tv_living_room' + device_path: + description: The filepath on the Android TV / Fire TV device. + example: '/storage/emulated/0/Download/example.txt' + local_path: + description: The filepath on your Home Assistant instance. + example: '/config/example.txt' +upload: + description: Upload a file from your Home Assistant instance 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' + device_path: + description: The filepath on the Android TV / Fire TV device. + example: '/storage/emulated/0/Download/example.txt' + local_path: + description: The filepath on your Home Assistant instance. + example: '/config/example.txt' diff --git a/homeassistant/components/anel_pwrctrl/__init__.py b/homeassistant/components/anel_pwrctrl/__init__.py new file mode 100644 index 0000000000000..bd06aa87b3605 --- /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 0000000000000..d076d71b24a66 --- /dev/null +++ b/homeassistant/components/anel_pwrctrl/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "anel_pwrctrl", + "name": "Anel NET-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 0000000000000..3c181d7d04b4b --- /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 0000000000000..56b06e865c290 --- /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 0000000000000..df0de2079de7a --- /dev/null +++ b/homeassistant/components/anthemav/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "anthemav", + "name": "Anthem A/V Receivers", + "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 0000000000000..f7b385d80a221 --- /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 0000000000000..7bd23630bd089 --- /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 0000000000000..0061aecade992 --- /dev/null +++ b/homeassistant/components/apache_kafka/manifest.json @@ -0,0 +1,8 @@ +{ + "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/__init__.py b/homeassistant/components/apcupsd/__init__.py new file mode 100644 index 0000000000000..01f7416519009 --- /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 = "STATFLAG" + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) + +VALUE_ONLINE = 8 + +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 0000000000000..de4e1f17200aa --- /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 > 0 + + def update(self): + """Get the status report from APCUPSd and set this entity's state.""" + self._state = int(self._data.status[apcupsd.KEY_STATUS], 16) diff --git a/homeassistant/components/apcupsd/manifest.json b/homeassistant/components/apcupsd/manifest.json new file mode 100644 index 0000000000000..a8a5506fc0a00 --- /dev/null +++ b/homeassistant/components/apcupsd/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..255eb1624ff9f --- /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 b5cdb9cae6c05..0000000000000 --- a/homeassistant/components/api.py +++ /dev/null @@ -1,314 +0,0 @@ -""" -homeassistant.components.api -~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Provides a Rest API for Home Assistant. -""" -import re -import logging -import threading -import json - -import homeassistant as ha -from homeassistant.helpers.state import TrackStates -import homeassistant.remote as rem -from homeassistant.const import ( - URL_API, URL_API_STATES, URL_API_EVENTS, URL_API_SERVICES, URL_API_STREAM, - URL_API_EVENT_FORWARD, URL_API_STATES_ENTITY, URL_API_COMPONENTS, - EVENT_TIME_CHANGED, EVENT_HOMEASSISTANT_STOP, MATCH_ALL, - HTTP_OK, HTTP_CREATED, HTTP_BAD_REQUEST, HTTP_NOT_FOUND, - HTTP_UNPROCESSABLE_ENTITY) - - -DOMAIN = 'api' -DEPENDENCIES = ['http'] - -STREAM_PING_PAYLOAD = "ping" -STREAM_PING_INTERVAL = 50 # seconds - -_LOGGER = logging.getLogger(__name__) - - -def setup(hass, config): - """ Register the API with the HTTP interface. """ - - if 'http' not in hass.config.components: - _LOGGER.error('Dependency http is not loaded') - return False - - # /api - for validation purposes - hass.http.register_path('GET', URL_API, _handle_get_api) - - # /api/stream - hass.http.register_path('GET', URL_API_STREAM, _handle_get_api_stream) - - # /states - hass.http.register_path('GET', URL_API_STATES, _handle_get_api_states) - hass.http.register_path( - 'GET', re.compile(r'/api/states/(?P[a-zA-Z\._0-9]+)'), - _handle_get_api_states_entity) - hass.http.register_path( - 'POST', re.compile(r'/api/states/(?P[a-zA-Z\._0-9]+)'), - _handle_post_state_entity) - hass.http.register_path( - 'PUT', re.compile(r'/api/states/(?P[a-zA-Z\._0-9]+)'), - _handle_post_state_entity) - - # /events - hass.http.register_path('GET', URL_API_EVENTS, _handle_get_api_events) - hass.http.register_path( - 'POST', re.compile(r'/api/events/(?P[a-zA-Z\._0-9]+)'), - _handle_api_post_events_event) - - # /services - hass.http.register_path('GET', URL_API_SERVICES, _handle_get_api_services) - hass.http.register_path( - 'POST', - re.compile((r'/api/services/' - r'(?P[a-zA-Z\._0-9]+)/' - r'(?P[a-zA-Z\._0-9]+)')), - _handle_post_api_services_domain_service) - - # /event_forwarding - hass.http.register_path( - 'POST', URL_API_EVENT_FORWARD, _handle_post_api_event_forward) - hass.http.register_path( - 'DELETE', URL_API_EVENT_FORWARD, _handle_delete_api_event_forward) - - # /components - hass.http.register_path( - 'GET', URL_API_COMPONENTS, _handle_get_api_components) - - return True - - -def _handle_get_api(handler, path_match, data): - """ Renders the debug interface. """ - handler.write_json_message("API running.") - - -def _handle_get_api_stream(handler, path_match, data): - """ Provide a streaming interface for the event bus. """ - gracefully_closed = False - hass = handler.server.hass - wfile = handler.wfile - write_lock = threading.Lock() - block = threading.Event() - - def write_message(payload): - """ Writes a message to the output. """ - with write_lock: - msg = "data: {}\n\n".format(payload) - - try: - wfile.write(msg.encode("UTF-8")) - wfile.flush() - except IOError: - block.set() - - def forward_events(event): - """ Forwards events to the open request. """ - nonlocal gracefully_closed - - if block.is_set() or event.event_type == EVENT_TIME_CHANGED: - return - elif event.event_type == EVENT_HOMEASSISTANT_STOP: - gracefully_closed = True - block.set() - return - - write_message(json.dumps(event, cls=rem.JSONEncoder)) - - handler.send_response(HTTP_OK) - handler.send_header('Content-type', 'text/event-stream') - handler.end_headers() - - hass.bus.listen(MATCH_ALL, forward_events) - - while True: - write_message(STREAM_PING_PAYLOAD) - - block.wait(STREAM_PING_INTERVAL) - - if block.is_set(): - break - - if not gracefully_closed: - _LOGGER.info("Found broken event stream to %s, cleaning up", - handler.client_address[0]) - - hass.bus.remove_listener(MATCH_ALL, forward_events) - - -def _handle_get_api_states(handler, path_match, data): - """ Returns a dict containing all entity ids and their state. """ - handler.write_json(handler.server.hass.states.all()) - - -def _handle_get_api_states_entity(handler, path_match, data): - """ Returns the state of a specific entity. """ - entity_id = path_match.group('entity_id') - - state = handler.server.hass.states.get(entity_id) - - if state: - handler.write_json(state) - else: - handler.write_json_message("State does not exist.", HTTP_NOT_FOUND) - - -def _handle_post_state_entity(handler, path_match, data): - """ Handles updating the state of an entity. - - This handles the following paths: - /api/states/ - """ - entity_id = path_match.group('entity_id') - - try: - new_state = data['state'] - except KeyError: - handler.write_json_message("state not specified", HTTP_BAD_REQUEST) - return - - attributes = data['attributes'] if 'attributes' in data else None - - is_new_state = handler.server.hass.states.get(entity_id) is None - - # Write state - handler.server.hass.states.set(entity_id, new_state, attributes) - - state = handler.server.hass.states.get(entity_id) - - status_code = HTTP_CREATED if is_new_state else HTTP_OK - - handler.write_json( - state.as_dict(), - status_code=status_code, - location=URL_API_STATES_ENTITY.format(entity_id)) - - -def _handle_get_api_events(handler, path_match, data): - """ Handles getting overview of event listeners. """ - handler.write_json([{"event": key, "listener_count": value} - for key, value - in handler.server.hass.bus.listeners.items()]) - - -def _handle_api_post_events_event(handler, path_match, event_data): - """ Handles firing of an event. - - This handles the following paths: - /api/events/ - - Events from /api are threated as remote events. - """ - event_type = path_match.group('event_type') - - if event_data is not None and not isinstance(event_data, dict): - handler.write_json_message( - "event_data should be an object", HTTP_UNPROCESSABLE_ENTITY) - - event_origin = ha.EventOrigin.remote - - # 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 - - handler.server.hass.bus.fire(event_type, event_data, event_origin) - - handler.write_json_message("Event {} fired.".format(event_type)) - - -def _handle_get_api_services(handler, path_match, data): - """ Handles getting overview of services. """ - handler.write_json( - [{"domain": key, "services": value} - for key, value - in handler.server.hass.services.services.items()]) - - -# pylint: disable=invalid-name -def _handle_post_api_services_domain_service(handler, path_match, data): - """ Handles calling a service. - - This handles the following paths: - /api/services// - """ - domain = path_match.group('domain') - service = path_match.group('service') - - with TrackStates(handler.server.hass) as changed_states: - handler.server.hass.services.call(domain, service, data, True) - - handler.write_json(changed_states) - - -# pylint: disable=invalid-name -def _handle_post_api_event_forward(handler, path_match, data): - """ Handles adding an event forwarding target. """ - - try: - host = data['host'] - api_password = data['api_password'] - except KeyError: - handler.write_json_message( - "No host or api_password received.", HTTP_BAD_REQUEST) - return - - try: - port = int(data['port']) if 'port' in data else None - except ValueError: - handler.write_json_message( - "Invalid value received for port", HTTP_UNPROCESSABLE_ENTITY) - return - - api = rem.API(host, api_password, port) - - if not api.validate_api(): - handler.write_json_message( - "Unable to validate API", HTTP_UNPROCESSABLE_ENTITY) - return - - if handler.server.event_forwarder is None: - handler.server.event_forwarder = \ - rem.EventForwarder(handler.server.hass) - - handler.server.event_forwarder.connect(api) - - handler.write_json_message("Event forwarding setup.") - - -def _handle_delete_api_event_forward(handler, path_match, data): - """ Handles deleting an event forwarding target. """ - - try: - host = data['host'] - except KeyError: - handler.write_json_message("No host received.", HTTP_BAD_REQUEST) - return - - try: - port = int(data['port']) if 'port' in data else None - except ValueError: - handler.write_json_message( - "Invalid value received for port", HTTP_UNPROCESSABLE_ENTITY) - return - - if handler.server.event_forwarder is not None: - api = rem.API(host, None, port) - - handler.server.event_forwarder.disconnect(api) - - handler.write_json_message("Event forwarding cancelled.") - - -def _handle_get_api_components(handler, path_match, data): - """ Returns all the loaded components. """ - - handler.write_json(handler.server.hass.config.components) diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py new file mode 100644 index 0000000000000..fc2f01d418d91 --- /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 0000000000000..f5795a55f0432 --- /dev/null +++ b/homeassistant/components/api/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "api", + "name": "Home Assistant API", + "documentation": "https://www.home-assistant.io/integrations/api", + "requirements": [], + "dependencies": ["http"], + "codeowners": ["@home-assistant/core"], + "quality_scale": "internal" +} diff --git a/homeassistant/components/api/services.yaml b/homeassistant/components/api/services.yaml new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/homeassistant/components/apns/__init__.py b/homeassistant/components/apns/__init__.py new file mode 100644 index 0000000000000..9332b0d1ede59 --- /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 0000000000000..a8dc1204aa194 --- /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 0000000000000..b498e4476ec20 --- /dev/null +++ b/homeassistant/components/apns/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "apns", + "name": "Apple Push Notification Service (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 0000000000000..febe344a9c46b --- /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(f"{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 0000000000000..e69de29bb2d1d diff --git a/homeassistant/components/apple_tv/__init__.py b/homeassistant/components/apple_tv/__init__.py new file mode 100644 index 0000000000000..e11b246fd5e4c --- /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 0000000000000..2f37d941ac896 --- /dev/null +++ b/homeassistant/components/apple_tv/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..c816be52259ff --- /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 0000000000000..1229b756e7224 --- /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 0000000000000..01e26a5630b76 --- /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 0000000000000..6ffdaf690d940 --- /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 0000000000000..6e8a0567d064f --- /dev/null +++ b/homeassistant/components/apprise/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..0c8c5b26eeca4 --- /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 0000000000000..20a023166aeae --- /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 0000000000000..6258b470ebb8d --- /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 0000000000000..bc887505cd7e7 --- /dev/null +++ b/homeassistant/components/aprs/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..9f693966382c4 --- /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 0000000000000..f7f704e998b9c --- /dev/null +++ b/homeassistant/components/aqualogic/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..1cc06fc446f22 --- /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 0000000000000..74f1a9d9f9aaf --- /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 0000000000000..a7f39037fe146 --- /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 0000000000000..8922249e3fa46 --- /dev/null +++ b/homeassistant/components/aquostv/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "aquostv", + "name": "Sharp Aquos TV", + "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 0000000000000..f71f41dc29390 --- /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 0000000000000..b0ad4660d0fef --- /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 0000000000000..b0ad4660d0fef --- /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 0000000000000..b0ad4660d0fef --- /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 0000000000000..b0ad4660d0fef --- /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 0000000000000..b0ad4660d0fef --- /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 0000000000000..b0ad4660d0fef --- /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 0000000000000..b0ad4660d0fef --- /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 0000000000000..b0ad4660d0fef --- /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 0000000000000..b0ad4660d0fef --- /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 0000000000000..b0ad4660d0fef --- /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 0000000000000..b0ad4660d0fef --- /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 0000000000000..b0ad4660d0fef --- /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 0000000000000..b0ad4660d0fef --- /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 0000000000000..b0ad4660d0fef --- /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 0000000000000..b0ad4660d0fef --- /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 0000000000000..b0ad4660d0fef --- /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 0000000000000..b0ad4660d0fef --- /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 0000000000000..b0ad4660d0fef --- /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 0000000000000..b0ad4660d0fef --- /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 0000000000000..d818414753fd0 --- /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 0000000000000..a92a2ec52a621 --- /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 0000000000000..dc5a576acec06 --- /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 0000000000000..cb063c4c04781 --- /dev/null +++ b/homeassistant/components/arcam_fmj/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "arcam_fmj", + "name": "Arcam FMJ Receivers", + "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 0000000000000..8a54c745695ec --- /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 0000000000000..b0006dbb5ae66 --- /dev/null +++ b/homeassistant/components/arcam_fmj/strings.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Arcam FMJ" + } +} diff --git a/homeassistant/components/arduino/__init__.py b/homeassistant/components/arduino/__init__.py new file mode 100644 index 0000000000000..61b03a3160de6 --- /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 0000000000000..aded8c1c9ac37 --- /dev/null +++ b/homeassistant/components/arduino/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..c5863475512a0 --- /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 0000000000000..5b5b161a24a5f --- /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 0000000000000..37a104c08fe4f --- /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 0000000000000..3bd0a85c6f011 --- /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 0000000000000..58eaad4648bf2 --- /dev/null +++ b/homeassistant/components/arest/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..2533ce3619ed2 --- /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 0000000000000..ccc2c5d8bf58e --- /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/__init__.py b/homeassistant/components/arlo/__init__.py new file mode 100644 index 0000000000000..df24bdd1a920a --- /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 0000000000000..838f319abc145 --- /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 0000000000000..958c383765a10 --- /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 0000000000000..41d4fc40e5f95 --- /dev/null +++ b/homeassistant/components/arlo/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..aadd5a48d3724 --- /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 0000000000000..773bee4430aa7 --- /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 0000000000000..cd52f7310f308 --- /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 0000000000000..485c731ff6a3b --- /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 0000000000000..b871fa029cf92 --- /dev/null +++ b/homeassistant/components/aruba/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..726f3dba5de26 --- /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 0000000000000..d756fa457d3c8 --- /dev/null +++ b/homeassistant/components/arwn/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "arwn", + "name": "Ambient Radio Weather Network", + "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 0000000000000..685e5d90f5366 --- /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 0000000000000..d681a392c56ae --- /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 0000000000000..0bae6ebf3adf6 --- /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 0000000000000..ac18b14682eb1 --- /dev/null +++ b/homeassistant/components/asterisk_cdr/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "asterisk_cdr", + "name": "Asterisk Call Detail Records", + "documentation": "https://www.home-assistant.io/integrations/asterisk_cdr", + "requirements": [], + "dependencies": ["asterisk_mbox"], + "codeowners": [] +} diff --git a/homeassistant/components/asterisk_mbox/__init__.py b/homeassistant/components/asterisk_mbox/__init__.py new file mode 100644 index 0000000000000..1ecba9f4c8fba --- /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 0000000000000..3cd6fe059b6d7 --- /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 0000000000000..6a3591b001bf1 --- /dev/null +++ b/homeassistant/components/asterisk_mbox/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "asterisk_mbox", + "name": "Asterisk Voicemail", + "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 0000000000000..64d2d7c7a4bf1 --- /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 0000000000000..5e3297da8ff57 --- /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 0000000000000..02999ada68ba2 --- /dev/null +++ b/homeassistant/components/asuswrt/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..b5ce8539f440a --- /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 0000000000000..2a0fb277a48c3 --- /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 0000000000000..c7910a1254bf6 --- /dev/null +++ b/homeassistant/components/aten_pe/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "aten_pe", + "name": "ATEN Rack PDU", + "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 0000000000000..2ec6ec4b83d54 --- /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 0000000000000..6f524606a817b --- /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 0000000000000..493940329f8f6 --- /dev/null +++ b/homeassistant/components/atome/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "atome", + "name": "Atome Linky", + "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 0000000000000..f9dd6b2dd6129 --- /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/__init__.py b/homeassistant/components/august/__init__.py new file mode 100644 index 0000000000000..0bb0d6398961c --- /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 Home Assistant 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 0000000000000..14d03189c9243 --- /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 0000000000000..2492eb754181a --- /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 0000000000000..a541be6709737 --- /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 0000000000000..e3e417d20e045 --- /dev/null +++ b/homeassistant/components/august/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..2b3caa0684387 --- /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 0000000000000..454c3ad2405b5 --- /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 0000000000000..204327043f9c0 --- /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 0000000000000..087172d1bb546 --- /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 0000000000000..18e5a4b5ed9ad --- /dev/null +++ b/homeassistant/components/aurora_abb_powerone/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..a2645e5d7cb35 --- /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/ar.json b/homeassistant/components/auth/.translations/ar.json new file mode 100644 index 0000000000000..1ef902e6fe206 --- /dev/null +++ b/homeassistant/components/auth/.translations/ar.json @@ -0,0 +1,7 @@ +{ + "mfa_setup": { + "totp": { + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/auth/.translations/bg.json b/homeassistant/components/auth/.translations/bg.json new file mode 100644 index 0000000000000..d07e20a854cc7 --- /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 new file mode 100644 index 0000000000000..e5ece421a0b88 --- /dev/null +++ b/homeassistant/components/auth/.translations/ca.json @@ -0,0 +1,35 @@ +{ + "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "No hi ha serveis de notificaci\u00f3 disponibles." + }, + "error": { + "invalid_code": "Codi inv\u00e0lid, si us plau torna a provar-ho." + }, + "step": { + "init": { + "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" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/auth/.translations/cs.json b/homeassistant/components/auth/.translations/cs.json new file mode 100644 index 0000000000000..da234c3dd5dd5 --- /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 0000000000000..7877a813218b5 --- /dev/null +++ b/homeassistant/components/auth/.translations/da.json @@ -0,0 +1,35 @@ +{ + "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "Ingen meddelelsestjenester tilg\u00e6ngelige." + }, + "error": { + "invalid_code": "Ugyldig kode, pr\u00f8v venligst igen." + }, + "step": { + "init": { + "description": "V\u00e6lg venligst en af meddelelsestjenesterne:", + "title": "Ops\u00e6t engangsadgangskoder leveret af notify-komponenten" + }, + "setup": { + "description": "En engangsadgangskode er blevet sendt via **notify.{notify_service}**. Indtast den venligst nedenunder:", + "title": "Bekr\u00e6ft ops\u00e6tningen" + } + }, + "title": "Notify-engangsadgangskode" + }, + "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 tofaktorgodkendelse 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 tofaktorgodkendelse 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 new file mode 100644 index 0000000000000..06da3cde1a132 --- /dev/null +++ b/homeassistant/components/auth/.translations/de.json @@ -0,0 +1,35 @@ +{ + "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 du diesen Fehler regelm\u00e4\u00dfig erhalten, stelle sicher, dass die Uhr deines Home Assistant-Systems korrekt ist." + }, + "step": { + "init": { + "description": "Um die Zwei-Faktor-Authentifizierung mit zeitbasierten Einmalpassw\u00f6rtern zu aktivieren, scanne den QR-Code mit Ihrer Authentifizierungs-App. Wenn du keine hast, empfehlen wir entweder [Google Authenticator] (https://support.google.com/accounts/answer/1066447) oder [Authy] (https://authy.com/). \n\n {qr_code} \n \nNachdem du den Code gescannt hast, gebe den sechsstelligen Code aus der App ein, um das Setup zu \u00fcberpr\u00fcfen. Wenn es Probleme beim Scannen des QR-Codes gibt, f\u00fchre ein manuelles Setup mit dem Code ** ` {code} ` ** durch.", + "title": "Richte die Zwei-Faktor-Authentifizierung mit TOTP ein" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/auth/.translations/en.json b/homeassistant/components/auth/.translations/en.json new file mode 100644 index 0000000000000..66c0e92d9b5a6 --- /dev/null +++ b/homeassistant/components/auth/.translations/en.json @@ -0,0 +1,35 @@ +{ + "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." + }, + "step": { + "init": { + "description": "To activate two factor authentication using time-based one-time passwords, scan the QR code with your authentication app. If you don't have one, we recommend either [Google Authenticator](https://support.google.com/accounts/answer/1066447) or [Authy](https://authy.com/).\n\n{qr_code}\n\nAfter scanning the code, enter the six digit code from your app to verify the setup. If you have problems scanning the QR code, do a manual setup with code **`{code}`**.", + "title": "Set up two-factor authentication using TOTP" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/auth/.translations/es-419.json b/homeassistant/components/auth/.translations/es-419.json new file mode 100644 index 0000000000000..4ac9706890556 --- /dev/null +++ b/homeassistant/components/auth/.translations/es-419.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": "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" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/auth/.translations/es.json b/homeassistant/components/auth/.translations/es.json new file mode 100644 index 0000000000000..5603c14fe1a77 --- /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 0000000000000..290f4ee12a98e --- /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 new file mode 100644 index 0000000000000..cf0a1888495a4 --- /dev/null +++ b/homeassistant/components/auth/.translations/fr.json @@ -0,0 +1,35 @@ +{ + "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." + }, + "step": { + "init": { + "description": "Pour activer l'authentification \u00e0 deux facteurs \u00e0 l'aide de mots de passe \u00e0 utilisation unique bas\u00e9s sur l'heure, num\u00e9risez le code QR avec votre application d'authentification. Si vous n'en avez pas, nous vous recommandons d'utiliser [Google Authenticator] (https://support.google.com/accounts/answer/1066447) ou [Authy] (https://authy.com/). \n\n {qr_code} \n \n Apr\u00e8s avoir num\u00e9ris\u00e9 le code, entrez le code \u00e0 six chiffres de votre application pour v\u00e9rifier la configuration. Si vous rencontrez des probl\u00e8mes lors de l\u2019analyse du code QR, effectuez une configuration manuelle avec le code ** ` {code} ` **.", + "title": "Configurer une authentification \u00e0 deux facteurs \u00e0 l'aide de TOTP" + } + }, + "title": "TOTP (Mot de passe \u00e0 utilisation unique bas\u00e9 sur le temps)" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/auth/.translations/he.json b/homeassistant/components/auth/.translations/he.json new file mode 100644 index 0000000000000..bc1826d4d7975 --- /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 new file mode 100644 index 0000000000000..5e7b183509304 --- /dev/null +++ b/homeassistant/components/auth/.translations/hu.json @@ -0,0 +1,35 @@ +{ + "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." + }, + "step": { + "init": { + "description": "Ahhoz, hogy haszn\u00e1lhasd a k\u00e9tfaktoros hiteles\u00edt\u00e9st id\u0151alap\u00fa egyszeri jelszavakkal, szkenneld be a QR k\u00f3dot a hiteles\u00edt\u00e9si applik\u00e1ci\u00f3ddal. Ha m\u00e9g nincsen, akkor a [Google Hiteles\u00edt\u0151](https://support.google.com/accounts/answer/1066447)t vagy az [Authy](https://authy.com/)-t aj\u00e1nljuk.\n\n{qr_code}\n\nA k\u00f3d beolvas\u00e1sa ut\u00e1n add meg a hat sz\u00e1mjegy\u0171 k\u00f3dot az applik\u00e1ci\u00f3b\u00f3l a telep\u00edt\u00e9s ellen\u0151rz\u00e9s\u00e9hez. Ha probl\u00e9m\u00e1ba \u00fctk\u00f6z\u00f6l a QR k\u00f3d beolvas\u00e1s\u00e1n\u00e1l, akkor ind\u00edts egy k\u00e9zi be\u00e1ll\u00edt\u00e1st a **`{code}`** k\u00f3ddal.", + "title": "K\u00e9tfaktoros hiteles\u00edt\u00e9s be\u00e1ll\u00edt\u00e1sa TOTP haszn\u00e1lat\u00e1val" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/auth/.translations/id.json b/homeassistant/components/auth/.translations/id.json new file mode 100644 index 0000000000000..f6a22386f99bf --- /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 new file mode 100644 index 0000000000000..dbfe4acd6156a --- /dev/null +++ b/homeassistant/components/auth/.translations/it.json @@ -0,0 +1,35 @@ +{ + "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "Nessun servizio di notifica disponibile." + }, + "error": { + "invalid_code": "Codice non valido, per favore riprovare." + }, + "step": { + "init": { + "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" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/auth/.translations/ko.json b/homeassistant/components/auth/.translations/ko.json new file mode 100644 index 0000000000000..be160b185ac81 --- /dev/null +++ b/homeassistant/components/auth/.translations/ko.json @@ -0,0 +1,35 @@ +{ + "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 \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)" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/auth/.translations/lb.json b/homeassistant/components/auth/.translations/lb.json new file mode 100644 index 0000000000000..12ced930446f4 --- /dev/null +++ b/homeassistant/components/auth/.translations/lb.json @@ -0,0 +1,35 @@ +{ + "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." + }, + "step": { + "init": { + "description": "Fir d'Zwee-Faktor-Authentifikatioun m\u00ebttels engem Z\u00e4it bas\u00e9ierten eemolege Passwuert z'aktiv\u00e9ieren, scannt de QR Code mat enger Authentifikatioun's App.\nFalls dir keng hutt, recommand\u00e9iere mir entweder [Google Authenticator](https://support.google.com/accounts/answer/1066447) oder [Authy](https://authy.com/).\n\n{qr_code}\n\nNodeems de Code gescannt ass, gitt de sechs stellege Code vun der App a fir d'Konfiguratioun z'iwwerpr\u00e9iwen. Am Fall vu Problemer fir de QR Code ze scannen, gitt de folgende Code **`{code}`** a fir ee manuelle Setup.", + "title": "Zwee Faktor Authentifikatioun mat TOTP konfigur\u00e9ieren" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/auth/.translations/nl.json b/homeassistant/components/auth/.translations/nl.json new file mode 100644 index 0000000000000..d61613097dd7b --- /dev/null +++ b/homeassistant/components/auth/.translations/nl.json @@ -0,0 +1,35 @@ +{ + "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "Geen meldingsservices beschikbaar." + }, + "error": { + "invalid_code": "Ongeldige code, probeer opnieuw." + }, + "step": { + "init": { + "description": "Selecteer een van de meldingsservices:", + "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." + }, + "step": { + "init": { + "description": "Voor het activeren van twee-factor-authenticatie via tijdgebonden eenmalige wachtwoorden: scan de QR code met uw authenticatie-app. Als u nog geen app heeft, adviseren we [Google Authenticator (https://support.google.com/accounts/answer/1066447) of [Authy](https://authy.com/).\n\n{qr_code}\n\nNa het scannen van de code voert u de zescijferige code uit uw app in om de instelling te controleren. Als u problemen heeft met het scannen van de QR-code, voert u een handmatige configuratie uit met code **`{code}`**.", + "title": "Configureer twee-factor-authenticatie via TOTP" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/auth/.translations/nn.json b/homeassistant/components/auth/.translations/nn.json new file mode 100644 index 0000000000000..346c1cfe0c7ed --- /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 new file mode 100644 index 0000000000000..48b5db8a3b606 --- /dev/null +++ b/homeassistant/components/auth/.translations/no.json @@ -0,0 +1,35 @@ +{ + "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." + }, + "step": { + "init": { + "description": "For \u00e5 aktivere tofaktorautentisering ved hjelp av tidsbaserte engangspassord, skann QR-koden med autentiseringsappen din. Hvis du ikke har en, kan vi anbefale enten [Google Authenticator](https://support.google.com/accounts/answer/1066447) eller [Authy](https://authy.com/). \n\n {qr_code} \n \nEtter at du har skannet koden, skriver du inn den seks-sifrede koden fra appen din for \u00e5 kontrollere oppsettet. Dersom du har problemer med \u00e5 skanne QR-koden kan du taste inn f\u00f8lgende kode manuelt: **`{code}`**.", + "title": "Konfigurer tofaktorautentisering ved hjelp av TOTP" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/auth/.translations/pl.json b/homeassistant/components/auth/.translations/pl.json new file mode 100644 index 0000000000000..78610a5324fe3 --- /dev/null +++ b/homeassistant/components/auth/.translations/pl.json @@ -0,0 +1,35 @@ +{ + "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." + }, + "step": { + "init": { + "description": "Aby aktywowa\u0107 uwierzytelnianie dwusk\u0142adnikowe przy u\u017cyciu jednorazowych hase\u0142 opartych na czasie, zeskanuj kod QR za pomoc\u0105 aplikacji uwierzytelniaj\u0105cej. Je\u015bli jej nie masz, polecamy [Google Authenticator](https://support.google.com/accounts/answer/1066447) lub [Authy](https://authy.com/).\n\n{qr_code} \n \nPo zeskanowaniu kodu wprowad\u017a sze\u015bciocyfrowy kod z aplikacji, aby zweryfikowa\u0107 konfiguracj\u0119. Je\u015bli masz problemy z zeskanowaniem kodu QR, wykonaj r\u0119czn\u0105 konfiguracj\u0119 z kodem **`{code}`**.", + "title": "Skonfiguruj uwierzytelnianie dwusk\u0142adnikowe za pomoc\u0105 hase\u0142 jednorazowych opartych na czasie" + } + }, + "title": "Has\u0142a jednorazowe oparte na czasie" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/auth/.translations/pt-BR.json b/homeassistant/components/auth/.translations/pt-BR.json new file mode 100644 index 0000000000000..e08c27a32e6d1 --- /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 new file mode 100644 index 0000000000000..e25fe3139a4b5 --- /dev/null +++ b/homeassistant/components/auth/.translations/pt.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 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" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/auth/.translations/ro.json b/homeassistant/components/auth/.translations/ro.json new file mode 100644 index 0000000000000..19f9ec10c73aa --- /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 new file mode 100644 index 0000000000000..5092e0792504c --- /dev/null +++ b/homeassistant/components/auth/.translations/ru.json @@ -0,0 +1,35 @@ +{ + "mfa_setup": { + "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\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": "\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" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/auth/.translations/sl.json b/homeassistant/components/auth/.translations/sl.json new file mode 100644 index 0000000000000..f70bb81e7003c --- /dev/null +++ b/homeassistant/components/auth/.translations/sl.json @@ -0,0 +1,35 @@ +{ + "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 Assistanta to\u010dna." + }, + "step": { + "init": { + "description": "\u010ce \u017eelite aktivirati preverjanje pristnosti dveh faktorjev z enkratnimi gesli, ki temeljijo na \u010dasu, skenirajte kodo QR s svojo aplikacijo za preverjanje pristnosti. \u010ce je nimate, priporo\u010damo bodisi [Google Authenticator] (https://support.google.com/accounts/answer/1066447) ali [Authy] (https://authy.com/). \n\n {qr_code} \n \n Po skeniranju kode vnesite \u0161estmestno kodo iz aplikacije, da preverite nastavitev. \u010ce imate te\u017eave pri skeniranju kode QR, naredite ro\u010dno nastavitev s kodo ** ` {code} ` **.", + "title": "Nastavite dvofaktorsko avtentifikacijo s pomo\u010djo TOTP-ja" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/auth/.translations/sv.json b/homeassistant/components/auth/.translations/sv.json new file mode 100644 index 0000000000000..9246a88c51235 --- /dev/null +++ b/homeassistant/components/auth/.translations/sv.json @@ -0,0 +1,35 @@ +{ + "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." + }, + "step": { + "init": { + "description": "F\u00f6r att aktivera tv\u00e5faktorsautentisering som anv\u00e4nder tidsbaserade eng\u00e5ngsl\u00f6senord, skanna QR-koden med din autentiseringsapp. Om du inte har en, rekommenderar vi antingen [Google Authenticator] (https://support.google.com/accounts/answer/1066447) eller [Authy] (https://authy.com/). \n\n{qr_code} \n\nN\u00e4r du har skannat koden anger du den sexsiffriga koden fr\u00e5n din app f\u00f6r att verifiera inst\u00e4llningen. Om du har problem med att skanna QR-koden, g\u00f6r en manuell inst\u00e4llning med kod ** ` {code} ` **.", + "title": "St\u00e4ll in tv\u00e5faktorsautentisering med TOTP" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/auth/.translations/th.json b/homeassistant/components/auth/.translations/th.json new file mode 100644 index 0000000000000..735b7e2fad59c --- /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 0000000000000..f826075078e7b --- /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 0000000000000..02ac69bb98369 --- /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 new file mode 100644 index 0000000000000..1cb311f016fe6 --- /dev/null +++ b/homeassistant/components/auth/.translations/zh-Hans.json @@ -0,0 +1,35 @@ +{ + "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" + }, + "step": { + "init": { + "description": "\u8981\u6fc0\u6d3b\u57fa\u4e8e\u65f6\u95f4\u52a8\u6001\u53e3\u4ee4\u7684\u53cc\u91cd\u8ba4\u8bc1\uff0c\u8bf7\u7528\u8eab\u4efd\u9a8c\u8bc1\u5e94\u7528\u626b\u63cf\u4ee5\u4e0b\u4e8c\u7ef4\u7801\u3002\u5982\u679c\u60a8\u8fd8\u6ca1\u6709\u8eab\u4efd\u9a8c\u8bc1\u5e94\u7528\uff0c\u63a8\u8350\u4f7f\u7528 [Google \u8eab\u4efd\u9a8c\u8bc1\u5668](https://support.google.com/accounts/answer/1066447) \u6216 [Authy](https://authy.com/)\u3002\n\n{qr_code}\n\n\u626b\u63cf\u4e8c\u7ef4\u7801\u4ee5\u540e\uff0c\u8f93\u5165\u5e94\u7528\u4e0a\u7684\u516d\u4f4d\u6570\u5b57\u53e3\u4ee4\u6765\u9a8c\u8bc1\u914d\u7f6e\u3002\u5982\u679c\u5728\u626b\u63cf\u4e8c\u7ef4\u7801\u65f6\u9047\u5230\u95ee\u9898\uff0c\u8bf7\u4f7f\u7528\u4ee3\u7801 **`{code}`** \u624b\u52a8\u914d\u7f6e\u3002", + "title": "\u7528\u65f6\u95f4\u52a8\u6001\u53e3\u4ee4\u8bbe\u7f6e\u53cc\u91cd\u8ba4\u8bc1" + } + }, + "title": "\u65f6\u95f4\u52a8\u6001\u53e3\u4ee4" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/auth/.translations/zh-Hant.json b/homeassistant/components/auth/.translations/zh-Hant.json new file mode 100644 index 0000000000000..b7a26f5079c6c --- /dev/null +++ b/homeassistant/components/auth/.translations/zh-Hant.json @@ -0,0 +1,35 @@ +{ + "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\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" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py new file mode 100644 index 0000000000000..888ef98a582a1 --- /dev/null +++ b/homeassistant/components/auth/__init__.py @@ -0,0 +1,559 @@ +"""Component to allow users to login and get tokens. + +# POST /auth/token + +This is an OAuth2 endpoint for granting tokens. We currently support the grant +types "authorization_code" and "refresh_token". Because we follow the OAuth2 +spec, data should be send in formatted as x-www-form-urlencoded. Examples will +be in JSON as it's more readable. + +## Grant type authorization_code + +Exchange the authorization code retrieved from the login flow for tokens. + +{ + "client_id": "https://hassbian.local:8123/", + "grant_type": "authorization_code", + "code": "411ee2f916e648d691e937ae9344681e" +} + +Return value will be the access and refresh tokens. The access token will have +a limited expiration. New access tokens can be requested using the refresh +token. + +{ + "access_token": "ABCDEFGH", + "expires_in": 1800, + "refresh_token": "IJKLMNOPQRST", + "token_type": "Bearer" +} + +## Grant type refresh_token + +Request a new access token using a refresh token. + +{ + "client_id": "https://hassbian.local:8123/", + "grant_type": "refresh_token", + "refresh_token": "IJKLMNOPQRST" +} + +Return value will be a new access token. The access token will have +a limited expiration. + +{ + "access_token": "ABCDEFGH", + "expires_in": 1800, + "token_type": "Bearer" +} + +## Revoking a refresh token + +It is also possible to revoke a refresh token and all access tokens that have +ever been granted by that refresh token. Response code will ALWAYS be 200. + +{ + "token": "IJKLMNOPQRST", + "action": "revoke" +} + +# Websocket API + +## Get current user + +Send websocket command `auth/current_user` will return current user of the +active websocket connection. + +{ + "id": 10, + "type": "auth/current_user", +} + +The result payload likes + +{ + "id": 10, + "type": "result", + "success": true, + "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 + }] + } +} + +## Create a long-lived access token + +Send websocket command `auth/long_lived_access_token` will create +a long-lived access token for current user. Access token will not be saved in +Home Assistant. User need to record the token in secure place. + +{ + "id": 11, + "type": "auth/long_lived_access_token", + "client_name": "GPS Logger", + "lifespan": 365 +} + +Result will be a long-lived access token: + +{ + "id": 11, + "type": "result", + "success": true, + "result": "ABCDEFGH" +} + +""" +from datetime import timedelta +import logging +import uuid + +from aiohttp import web +import voluptuous as vol + +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 HomeAssistant, callback +from homeassistant.loader import bind_hass +from homeassistant.util import dt as dt_util + +from . import indieauth, login_flow, mfa_setup_flow + +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_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_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" + +_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 + ) + 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, + ) + hass.components.websocket_api.async_register_command( + 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, + ) + 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) + await mfa_setup_flow.async_setup(hass) + + return True + + +class TokenView(HomeAssistantView): + """View to issue or revoke tokens.""" + + url = "/auth/token" + name = "api:auth:token" + requires_auth = False + cors_allowed = True + + def __init__(self, retrieve_user): + """Initialize the token view.""" + self._retrieve_user = retrieve_user + + @log_invalid_auth + async def post(self, request): + """Grant a token.""" + hass = request.app["hass"] + data = await request.post() + + 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": + return await self._async_handle_revoke_token(hass, data) + + if grant_type == "authorization_code": + return await self._async_handle_auth_code( + hass, data, str(request[KEY_REAL_IP]) + ) + + if grant_type == "refresh_token": + return await self._async_handle_refresh_token( + hass, data, str(request[KEY_REAL_IP]) + ) + + return self.json({"error": "unsupported_grant_type"}, status_code=400) + + async def _async_handle_revoke_token(self, hass, data): + """Handle revoke token request.""" + # OAuth 2.0 Token Revocation [RFC7009] + # 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") + + if token is None: + return web.Response(status=200) + + refresh_token = await hass.auth.async_get_refresh_token_by_token(token) + + if refresh_token is None: + return web.Response(status=200) + + await hass.auth.async_remove_refresh_token(refresh_token) + return web.Response(status=200) + + async def _async_handle_auth_code(self, hass, data, remote_addr): + """Handle authorization code request.""" + 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, + ) + + code = data.get("code") + + if code is None: + 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, + ) + + # 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, + ) + + 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() + ), + } + ) + + async def _async_handle_refresh_token(self, hass, data, remote_addr): + """Handle authorization code request.""" + 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, + ) + + token = data.get("refresh_token") + + if token is None: + return self.json({"error": "invalid_request"}, status_code=400) + + refresh_token = await hass.auth.async_get_refresh_token_by_token(token) + + if refresh_token is None: + 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) + + 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() + ), + } + ) + + +class LinkUserView(HomeAssistantView): + """View to link existing users to new credentials.""" + + url = "/auth/link_user" + name = "api:auth:link_user" + + def __init__(self, retrieve_credentials): + """Initialize the link user view.""" + self._retrieve_credentials = retrieve_credentials + + @RequestDataValidator(vol.Schema({"code": str, "client_id": str})) + async def post(self, request, data): + """Link a user.""" + hass = request.app["hass"] + user = request["hass_user"] + + credentials = self._retrieve_credentials( + data["client_id"], RESULT_TYPE_CREDENTIALS, data["code"] + ) + + if credentials is None: + return self.json_message("Invalid code", status_code=400) + + await hass.auth.async_link_user(user, credentials) + return self.json_message("User linked") + + +@callback +def _create_auth_code_store(): + """Create an in memory store.""" + temp_results = {} + + @callback + def store_result(client_id, result): + """Store flow result and return a code to retrieve it.""" + if isinstance(result, User): + result_type = RESULT_TYPE_USER + elif isinstance(result, Credentials): + result_type = RESULT_TYPE_CREDENTIALS + else: + 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, + ) + return code + + @callback + def retrieve_result(client_id, result_type, code): + """Retrieve flow result.""" + key = (client_id, result_type, code) + + if key not in temp_results: + return None + + created, _, result = temp_results.pop(key) + + # OAuth 4.2.1 + # The authorization code MUST expire shortly after it is issued to + # mitigate the risk of leaks. A maximum authorization code lifetime of + # 10 minutes is RECOMMENDED. + if dt_util.utcnow() - created < timedelta(minutes=10): + return result + + return None + + return store_result, retrieve_result + + +@websocket_api.ws_require_user() +@websocket_api.async_response +async def websocket_current_user( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg +): + """Return the current user.""" + user = connection.user + enabled_modules = await hass.auth.async_get_enabled_mfa(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() +@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.""" + 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) + + 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 +): + """Return metadata of users refresh tokens.""" + 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_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 new file mode 100644 index 0000000000000..3266ae65d7a49 --- /dev/null +++ b/homeassistant/components/auth/indieauth.py @@ -0,0 +1,206 @@ +"""Helpers to resolve client ID/secret.""" +import asyncio +from html.parser import HTMLParser +from ipaddress import ip_address +import logging +from urllib.parse import urljoin, urlparse + +import aiohttp + +from homeassistant.util.network import is_local + +_LOGGER = logging.getLogger(__name__) + + +async def verify_redirect_uri(hass, client_id, redirect_uri): + """Verify that the client and redirect uri match.""" + try: + client_id_parts = _parse_client_id(client_id) + except ValueError: + return False + + redirect_parts = _parse_url(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 + ) + + if is_valid: + return True + + # Whitelist the iOS and Android callbacks so that people can link apps + # without being connected to the internet. + if redirect_uri == "homeassistant://auth-callback" and client_id in ( + "https://home-assistant.io/android", + "https://home-assistant.io/iOS", + ): + return True + + # IndieAuth 4.2.2 allows for redirect_uri to be on different domain + # but needs to be specified in link tag when fetching `client_id`. + redirect_uris = await fetch_redirect_uris(hass, client_id) + return redirect_uri in redirect_uris + + +class LinkTagParser(HTMLParser): + """Parser to find link tags.""" + + def __init__(self, rel): + """Initialize a link tag parser.""" + super().__init__() + self.rel = rel + self.found = [] + + def handle_starttag(self, tag, attrs): + """Handle finding a start tag.""" + if tag != "link": + return + + attrs = dict(attrs) + + if attrs.get("rel") == self.rel: + self.found.append(attrs.get("href")) + + +async def fetch_redirect_uris(hass, url): + """Find link tag with redirect_uri values. + + IndieAuth 4.2.2 + + The client SHOULD publish one or more tags or Link HTTP headers with + a rel attribute of redirect_uri at the client_id URL. + + We limit to the first 10kB of the page. + + We do not implement extracting redirect uris from headers. + """ + parser = LinkTagParser("redirect_uri") + chunks = 0 + try: + async with aiohttp.ClientSession() as session: + async with session.get(url, timeout=5) as resp: + async for data in resp.content.iter_chunked(1024): + parser.feed(data.decode()) + chunks += 1 + + if chunks == 10: + break + + 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 + # by a client MUST look for an exact match of the given redirect_uri in the + # request against the list of redirect_uris discovered after resolving any + # relative URLs. + return [urljoin(url, found) for found in parser.found] + + +def verify_client_id(client_id): + """Verify that the client id is valid.""" + try: + _parse_client_id(client_id) + return True + except ValueError: + return False + + +def _parse_url(url): + """Parse a url in parts and canonicalize according to IndieAuth.""" + parts = urlparse(url) + + # Canonicalize a url according to IndieAuth 3.2. + + # SHOULD convert the hostname to lowercase + parts = parts._replace(netloc=parts.netloc.lower()) + + # 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="/") + + return parts + + +def _parse_client_id(client_id): + """Test if client id is a valid URL according to IndieAuth section 3.2. + + https://indieauth.spec.indieweb.org/#client-identifier + """ + parts = _parse_url(client_id) + + # Client identifier URLs + # MUST have either an https or http scheme + 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("/")): + raise ValueError( + "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") + + # MUST NOT contain a username or password component + if parts.username is not None: + raise ValueError("Client ID cannot contain username") + + if parts.password is not None: + 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") + + # Additionally, hostnames + # MUST be domain names or a loopback interface and + # MUST NOT be IPv4 or IPv6 addresses except for IPv4 127.0.0.1 + # or IPv6 [::1] + + # We are not goint to follow the spec here. We are going to allow + # any internal network IP to be used inside a client id. + + address = None + + try: + netloc = parts.netloc + + # Strip the [, ] from ipv6 addresses before parsing + if netloc[0] == "[" and netloc[-1] == "]": + netloc = netloc[1:-1] + + address = ip_address(netloc) + except ValueError: + # Not an ip address + pass + + if address is None or is_local(address): + return parts + + 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 new file mode 100644 index 0000000000000..6f8d275101810 --- /dev/null +++ b/homeassistant/components/auth/login_flow.py @@ -0,0 +1,262 @@ +"""HTTP views handle login flow. + +# GET /auth/providers + +Return a list of auth providers. Example: + +[ + { + "name": "Local", + "id": null, + "type": "local_provider", + } +] + + +# POST /auth/login_flow + +Create a login flow. Will return the first step of the flow. + +Pass in parameter 'client_id' and 'redirect_url' validate by indieauth. + +Pass in parameter 'handler' to specify the auth provider to use. Auth providers +are identified by type and id. + +And optional parameter 'type' has to set as 'link_user' if login flow used for +link credential to exist user. Default 'type' is 'authorize'. + +{ + "client_id": "https://hassbian.local:8123/", + "handler": ["local_provider", null], + "redirect_url": "https://hassbian.local:8123/", + "type': "authorize" +} + +Return value will be a step in a data entry flow. See the docs for data entry +flow for details. + +{ + "data_schema": [ + {"name": "username", "type": "string"}, + {"name": "password", "type": "string"} + ], + "errors": {}, + "flow_id": "8f7e42faab604bcab7ac43c44ca34d58", + "handler": ["insecure_example", null], + "step_id": "init", + "type": "form" +} + + +# POST /auth/login_flow/{flow_id} + +Progress the flow. Most flows will be 1 page, but could optionally add extra +login challenges, like TFA. Once the flow has finished, the returned step will +have type "create_entry" and "result" key will contain an authorization code. +The authorization code associated with an authorized user by default, it will +associate with an credential if "type" set to "link_user" in +"/auth/login_flow" + +{ + "flow_id": "8f7e42faab604bcab7ac43c44ca34d58", + "handler": ["insecure_example", null], + "result": "411ee2f916e648d691e937ae9344681e", + "title": "Example", + "type": "create_entry", + "version": 1 +} +""" +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 ( + 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, 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" + requires_auth = False + + async def get(self, request): + """Get available auth providers.""" + hass = request.app["hass"] + if not hass.components.onboarding.async_is_user_onboarded(): + return self.json_message( + message="Onboarding not finished", + status_code=400, + message_code="onboarding_required", + ) + + 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: + data = result.copy() + data.pop("result") + data.pop("data") + return data + + if result["type"] != data_entry_flow.RESULT_TYPE_FORM: + return result + + data = result.copy() + + schema = data["data_schema"] + if schema is None: + data["data_schema"] = [] + else: + data["data_schema"] = voluptuous_serialize.convert(schema) + + return data + + +class LoginFlowIndexView(HomeAssistantView): + """View to create a config flow.""" + + url = "/auth/login_flow" + name = "api:auth:login_flow" + requires_auth = False + + 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, + } + ) + ) + @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) + + if isinstance(data["handler"], list): + handler = tuple(data["handler"]) + else: + 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", + }, + ) + except data_entry_flow.UnknownHandler: + return self.json_message("Invalid handler specified", 404) + except data_entry_flow.UnknownStep: + return self.json_message("Handler does not support init", 400) + + 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)) + + +class LoginFlowResourceView(HomeAssistantView): + """View to interact with the flow manager.""" + + url = "/auth/login_flow/{flow_id}" + name = "api:auth:login_flow:resource" + requires_auth = False + + def __init__(self, flow_mgr, store_result): + """Initialize the login flow resource view.""" + self._flow_mgr = flow_mgr + self._store_result = store_result + + async def get(self, request): + """Do not allow getting status of a flow in progress.""" + return self.json_message("Invalid flow specified", 404) + + @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") + + if not indieauth.verify_client_id(client_id): + 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) + + result = await self._flow_mgr.async_configure(flow_id, data) + except data_entry_flow.UnknownFlow: + return self.json_message("Invalid flow specified", 404) + except vol.Invalid: + return self.json_message("User input malformed", 400) + + if result["type"] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY: + # @log_invalid_auth does not work here since it returns HTTP 200 + # need manually log failed login attempts + 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"]) + + return self.json(result) + + async def delete(self, request, flow_id): + """Cancel a flow in progress.""" + try: + self._flow_mgr.async_abort(flow_id) + except data_entry_flow.UnknownFlow: + return self.json_message("Invalid flow specified", 404) + + return self.json_message("Flow aborted") diff --git a/homeassistant/components/auth/manifest.json b/homeassistant/components/auth/manifest.json new file mode 100644 index 0000000000000..e2b49ccfec134 --- /dev/null +++ b/homeassistant/components/auth/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "auth", + "name": "Auth", + "documentation": "https://www.home-assistant.io/integrations/auth", + "requirements": [], + "dependencies": ["http"], + "after_dependencies": ["onboarding"], + "codeowners": ["@home-assistant/core"], + "quality_scale": "internal" +} diff --git a/homeassistant/components/auth/mfa_setup_flow.py b/homeassistant/components/auth/mfa_setup_flow.py new file mode 100644 index 0000000000000..1b199551a14ef --- /dev/null +++ b/homeassistant/components/auth/mfa_setup_flow.py @@ -0,0 +1,148 @@ +"""Helpers to setup multi-factor auth module.""" +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 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_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" + +_LOGGER = logging.getLogger(__name__) + + +class MfaFlowManager(data_entry_flow.FlowManager): + """Manage multi factor authentication flows.""" + + async def async_create_flow(self, handler_key, *, context, data): + """Create a setup flow. handler is a mfa module.""" + mfa_module = self.hass.auth.get_auth_mfa_module(handler_key) + if mfa_module is None: + raise ValueError(f"Mfa module {handler_key} is not found") + + user_id = data.pop("user_id") + return await mfa_module.async_setup_flow(user_id) + + async def async_finish_flow(self, flow, result): + """Complete an mfs setup flow.""" + _LOGGER.debug("flow_result: %s", result) + return result + + +async def async_setup(hass): + """Init mfa setup flow manager.""" + hass.data[DATA_SETUP_FLOW_MGR] = MfaFlowManager(hass) + + hass.components.websocket_api.async_register_command( + 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 + ) + + +@callback +@websocket_api.ws_require_user(allow_system_user=False) +def websocket_setup_mfa( + 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") + if flow_id is not None: + 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 = hass.auth.get_auth_mfa_module(mfa_module_id) + if mfa_module is None: + 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} + ) + + connection.send_message( + websocket_api.result_message(msg["id"], _prepare_result_json(result)) + ) + + hass.async_create_task(async_setup_flow(msg)) + + +@callback +@websocket_api.ws_require_user(allow_system_user=False) +def websocket_depose_mfa( + 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"] + try: + await hass.auth.async_disable_user_mfa( + connection.user, msg["mfa_module_id"] + ) + except ValueError as 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(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: + data = result.copy() + return data + + if result["type"] != data_entry_flow.RESULT_TYPE_FORM: + return result + + data = result.copy() + + schema = data["data_schema"] + if schema is None: + data["data_schema"] = [] + else: + data["data_schema"] = voluptuous_serialize.convert(schema) + + return data diff --git a/homeassistant/components/auth/strings.json b/homeassistant/components/auth/strings.json new file mode 100644 index 0000000000000..57f5ed659b08d --- /dev/null +++ b/homeassistant/components/auth/strings.json @@ -0,0 +1,35 @@ +{ + "mfa_setup":{ + "totp": { + "title": "TOTP", + "step": { + "init": { + "title": "Set up two-factor authentication using TOTP", + "description": "To activate two factor authentication using time-based one-time passwords, scan the QR code with your authentication app. If you don't have one, we recommend either [Google Authenticator](https://support.google.com/accounts/answer/1066447) or [Authy](https://authy.com/).\n\n{qr_code}\n\nAfter scanning the code, enter the six digit code from your app to verify the setup. If you have problems scanning the QR code, do a manual setup with code **`{code}`**." + } + }, + "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 0000000000000..8a1cae16f1ed1 --- /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 0000000000000..3c9e33cdc844a --- /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 0000000000000..e0d06ff0f1f0a --- /dev/null +++ b/homeassistant/components/automatic/manifest.json @@ -0,0 +1,8 @@ +{ + "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 21bea96201bbb..6175646778fe7 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -1,69 +1,505 @@ -""" -homeassistant.components.automation -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Allows to setup simple automation rules via the config file. -""" +"""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.const import ( + 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 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 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 -from homeassistant.loader import get_component -from homeassistant.helpers import config_per_platform -from homeassistant.util import split_entity_id -from homeassistant.const import ATTR_ENTITY_ID +# mypy: allow-untyped-calls, allow-untyped-defs +# mypy: no-check-untyped-defs, no-warn-return-any DOMAIN = "automation" +ENTITY_ID_FORMAT = DOMAIN + ".{}" -DEPENDENCIES = ["group"] +GROUP_NAME_ALL_AUTOMATIONS = "all automations" CONF_ALIAS = "alias" -CONF_SERVICE = "execute_service" -CONF_SERVICE_ENTITY_ID = "service_entity_id" -CONF_SERVICE_DATA = "service_data" +CONF_DESCRIPTION = "description" +CONF_HIDE_ENTITY = "hide_entity" + +CONF_CONDITION = "condition" +CONF_ACTION = "action" +CONF_TRIGGER = "trigger" +CONF_CONDITION_TYPE = "condition_type" +CONF_INITIAL_STATE = "initial_state" +CONF_SKIP_CONDITION = "skip_condition" + +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" _LOGGER = logging.getLogger(__name__) +AutomationActionType = Callable[[HomeAssistant, TemplateVarsType], Awaitable[None]] -def setup(hass, config): - """ Sets up automation. """ - for p_type, p_config in config_per_platform(config, DOMAIN, _LOGGER): - platform = get_component('automation.{}'.format(p_type)) +def _platform_validator(config): + """Validate it is a valid platform.""" + try: + platform = importlib.import_module( + ".{}".format(config[CONF_PLATFORM]), __name__ + ) + except ImportError: + raise vol.Invalid("Invalid platform specified") from None - if platform is None: - _LOGGER.error("Unknown automation platform specified: %s", p_type) - continue + return platform.TRIGGER_SCHEMA(config) - if platform.register(hass, p_config, _get_action(hass, p_config)): - _LOGGER.info( - "Initialized %s rule %s", p_type, p_config.get(CONF_ALIAS, "")) - else: - _LOGGER.error( - "Error setting up rule %s", p_config.get(CONF_ALIAS, "")) + +_TRIGGER_SCHEMA = vol.All( + cv.ensure_list, + [ + vol.All( + 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_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, + } +) + +TRIGGER_SERVICE_SCHEMA = make_entity_service_schema( + { + vol.Optional(ATTR_VARIABLES, default={}): dict, + vol.Optional(CONF_SKIP_CONDITION, default=True): bool, + } +) + +RELOAD_SERVICE_SCHEMA = vol.Schema({}) + + +@bind_hass +def is_on(hass, entity_id): + """ + Return true if specified automation entity_id is on. + + Async friendly. + """ + return hass.states.is_state(entity_id, STATE_ON) + + +async def async_setup(hass, config): + """Set up the automation.""" + component = EntityComponent(_LOGGER, DOMAIN, hass) + + await _async_process_config(hass, config, component) + + async def trigger_service_handler(service_call): + """Handle automation triggers.""" + tasks = [] + for entity in await component.async_extract_from_service(service_call): + tasks.append( + entity.async_trigger( + service_call.data[ATTR_VARIABLES], + skip_condition=service_call.data[CONF_SKIP_CONDITION], + context=service_call.context, + ) + ) + + if tasks: + await asyncio.wait(tasks) + + async def turn_onoff_service_handler(service_call): + """Handle automation turn on/off service calls.""" + tasks = [] + 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) + + async def toggle_service_handler(service_call): + """Handle automation toggle service calls.""" + tasks = [] + 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) + + async def reload_service_handler(service_call): + """Remove all automations and load new ones from config.""" + conf = await component.async_prepare_reload() + if conf is None: + return + await _async_process_config(hass, conf, component) + + hass.services.async_register( + 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_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=make_entity_service_schema({}), + ) return True -def _get_action(hass, config): - """ Return an action based on a config. """ +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, + ): + """Initialize an automation entity.""" + self._id = automation_id + self._name = name + self._async_attach_triggers = async_attach_triggers + self._async_detach_triggers = None + self._cond_func = cond_func + self._async_action = async_action + self._last_triggered = None + self._hidden = hidden + self._initial_state = initial_state + self._is_enabled = False + + @property + def name(self): + """Name of the automation.""" + return self._name + + @property + def should_poll(self): + """No polling needed for automation entities.""" + return False + + @property + def state_attributes(self): + """Return the entity state attributes.""" + return {ATTR_LAST_TRIGGERED: self._last_triggered} + + @property + def hidden(self) -> bool: + """Return True if the automation entity should be hidden from UIs.""" + return self._hidden + + @property + def is_on(self) -> bool: + """Return True if entity is on.""" + 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 overridden from " + "config initial_state", + self.entity_id, + enable_automation, + ) + + if enable_automation: + await self.async_enable() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on and update the state.""" + await self.async_enable() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + await self.async_disable() + + async def async_trigger(self, variables, skip_condition=False, context=None): + """Trigger automation. - def action(): - """ Action to be executed. """ - _LOGGER.info("Executing rule %s", config.get(CONF_ALIAS, "")) + This method is a coroutine. + """ + if not skip_condition and not self._cond_func(variables): + return - if CONF_SERVICE in config: - domain, service = split_entity_id(config[CONF_SERVICE]) + # 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) - service_data = config.get(CONF_SERVICE_DATA, {}) + 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() - if not isinstance(service_data, dict): - _LOGGER.error( - "%s should be a serialized JSON object", CONF_SERVICE_DATA) - service_data = {} + async def async_will_remove_from_hass(self): + """Remove listeners when removing automation from Home Assistant.""" + await super().async_will_remove_from_hass() + await self.async_disable() - if CONF_SERVICE_ENTITY_ID in config: - service_data[ATTR_ENTITY_ID] = \ - config[CONF_SERVICE_ENTITY_ID].split(",") + async def async_enable(self): + """Enable this automation entity. - hass.services.call(domain, service, service_data) + This method is a coroutine. + """ + if self._is_enabled: + return + + 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): + """Return automation attributes.""" + if self._id is None: + return None + + return {CONF_ID: self._id} + + +async def _async_process_config(hass, config, component): + """Process config and add automations. + + This method is a coroutine. + """ + entities = [] + + for config_key in extract_domain_configs(config, DOMAIN): + conf = config[config_key] + + for list_no, config_block in enumerate(conf): + automation_id = config_block.get(CONF_ID) + 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) + + if CONF_CONDITION in 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, + ) + entity = AutomationEntity( + automation_id, + name, + async_attach_triggers, + cond_func, + action, + hidden, + initial_state, + ) + + entities.append(entity) + + if entities: + await component.async_add_entities(entities) + + +def _async_get_action(hass, config, name): + """Return an action based on a configuration.""" + script_obj = script.Script(hass, config, name) + + async def action(entity_id, variables, context): + """Execute an action.""" + _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 + + +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(await condition.async_from_config(hass, if_config, False)) + except HomeAssistantError as ex: + _LOGGER.warning("Invalid condition: %s", ex) + return None + + def if_action(variables=None): + """AND all conditions.""" + return all(check(hass, variables) for check in checks) + + return if_action + + +async def _async_process_trigger(hass, config, trigger_configs, name, action): + """Set up the triggers. + + This method is a coroutine. + """ + removes = [] + info = {"name": name} + + for conf in trigger_configs: + platform = importlib.import_module(".{}".format(conf[CONF_PLATFORM]), __name__) + + remove = await platform.async_attach_trigger(hass, conf, action, info) + + if not remove: + _LOGGER.error("Error setting up trigger %s", name) + continue + + _LOGGER.info("Initialized trigger %s", name) + removes.append(remove) + + if not removes: + return None + + def remove_triggers(): + """Remove attached triggers.""" + for remove in removes: + remove() + + return remove_triggers diff --git a/homeassistant/components/automation/config.py b/homeassistant/components/automation/config.py new file mode 100644 index 0000000000000..d11472a21289d --- /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 0000000000000..b2892d1abaa83 --- /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 8a78f20d48516..9fc78746a7c82 100644 --- a/homeassistant/components/automation/event.py +++ b/homeassistant/components/automation/event.py @@ -1,31 +1,56 @@ -""" -homeassistant.components.automation.event -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Offers event listening automation rules. -""" +"""Offer event 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 + +# 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, + } +) -def register(hass, config, action): - """ Listen for events based on config. """ - event_type = config.get(CONF_EVENT_TYPE) - - if event_type is None: - _LOGGER.error("Missing configuration key %s", CONF_EVENT_TYPE) - return False - event_data = config.get(CONF_EVENT_DATA, {}) +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 + ) + @callback def handle_event(event): - """ Listens for events and calls the action when data matches. """ - if event_data == event.data: - action() - - hass.bus.listen(event_type, handle_event) - return True + """Listen for events and calls the action when data matches.""" + if event_data_schema: + # Check that the event data matches the configured + # schema if one was provided + try: + event_data_schema(event.data) + except vol.Invalid: + # If event data doesn't match requested schema, skip event + return + + hass.async_run_job( + action( + {"trigger": {"platform": 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 0000000000000..5dc4f3c80f6e8 --- /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 new file mode 100644 index 0000000000000..743b169c86cc1 --- /dev/null +++ b/homeassistant/components/automation/homeassistant.py @@ -0,0 +1,48 @@ +"""Offer Home Assistant core automation rules.""" +import logging + +import voluptuous as vol + +from homeassistant.const import CONF_EVENT, CONF_PLATFORM, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import CoreState, callback + +# 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), + } +) + + +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, + ) + ) + + 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}}) + ) + + return lambda: None diff --git a/homeassistant/components/automation/litejet.py b/homeassistant/components/automation/litejet.py new file mode 100644 index 0000000000000..466fc941a9ae0 --- /dev/null +++ b/homeassistant/components/automation/litejet.py @@ -0,0 +1,99 @@ +"""Trigger an automation when a LiteJet switch is released.""" +import logging + +import voluptuous as vol + +from homeassistant.const import CONF_PLATFORM +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import track_point_in_utc_time +import homeassistant.util.dt as dt_util + +# 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" + +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 + ), + } +) + + +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) + held_less_than = config.get(CONF_HELD_LESS_THAN) + pressed_time = None + cancel_pressed_more_than = None + + @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, + } + }, + ) + + # held_more_than and held_less_than: trigger on released (if in time range) + # held_more_than: trigger after pressed with calculation + # held_less_than: trigger on released with calculation + # neither: trigger on pressed + + @callback + def pressed_more_than_satisfied(now): + """Handle the LiteJet's switch's button pressed >= held_more_than.""" + call_action() + + def pressed(): + """Handle the press of the LiteJet switch's button.""" + nonlocal cancel_pressed_more_than, pressed_time + nonlocal held_less_than, held_more_than + pressed_time = dt_util.utcnow() + if held_more_than is None and held_less_than is None: + 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 + ) + + def released(): + """Handle the release of the LiteJet switch's button.""" + nonlocal cancel_pressed_more_than, pressed_time + nonlocal held_less_than, held_more_than + # pylint: disable=not-callable + if cancel_pressed_more_than is not None: + cancel_pressed_more_than() + cancel_pressed_more_than = None + held_time = dt_util.utcnow() - pressed_time + if held_less_than is not None and held_time < held_less_than: + if held_more_than is None or held_time > held_more_than: + hass.add_job(call_action) + + hass.data["litejet_system"].on_switch_pressed(number, pressed) + hass.data["litejet_system"].on_switch_released(number, released) + + def async_remove(): + """Remove all subscriptions used for this trigger.""" + return + + return async_remove diff --git a/homeassistant/components/automation/manifest.json b/homeassistant/components/automation/manifest.json new file mode 100644 index 0000000000000..34261cba5a9b9 --- /dev/null +++ b/homeassistant/components/automation/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "automation", + "name": "Automation", + "documentation": "https://www.home-assistant.io/integrations/automation", + "requirements": [], + "dependencies": ["device_automation", "group", "webhook"], + "codeowners": ["@home-assistant/core"], + "quality_scale": "internal" +} diff --git a/homeassistant/components/automation/mqtt.py b/homeassistant/components/automation/mqtt.py new file mode 100644 index 0000000000000..fb0073c78d539 --- /dev/null +++ b/homeassistant/components/automation/mqtt.py @@ -0,0 +1,54 @@ +"""Offer MQTT listening automation rules.""" +import json + +import voluptuous as vol + +from homeassistant.components import mqtt +from homeassistant.const import CONF_PAYLOAD, CONF_PLATFORM +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv + +# mypy: allow-untyped-defs + +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, + vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): cv.string, + } +) + + +async def async_attach_trigger(hass, config, action, automation_info): + """Listen for state changes based on configuration.""" + topic = config[CONF_TOPIC] + payload = config.get(CONF_PAYLOAD) + encoding = config[CONF_ENCODING] or None + + @callback + def mqtt_automation_listener(mqttmsg): + """Listen for MQTT messages.""" + if payload is None or payload == mqttmsg.payload: + data = { + "platform": "mqtt", + "topic": mqttmsg.topic, + "payload": mqttmsg.payload, + "qos": mqttmsg.qos, + } + + try: + data["payload_json"] = json.loads(mqttmsg.payload) + except ValueError: + pass + + hass.async_run_job(action, {"trigger": data}) + + 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 new file mode 100644 index 0000000000000..e944b66751bc0 --- /dev/null +++ b/homeassistant/components/automation/numeric_state.py @@ -0,0 +1,163 @@ +"""Offer numeric state listening automation rules.""" +import logging + +import voluptuous as vol + +from homeassistant import exceptions +from homeassistant.const import ( + 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 + +# 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__) + + +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 + + @callback + def check_numeric_state(entity, from_s, to_s): + """Return True if criteria are now met.""" + if to_s is None: + return False + + variables = { + "trigger": { + "platform": "numeric_state", + "entity_id": entity, + "below": below, + "above": above, + } + } + return condition.async_numeric_state( + hass, to_s, below, above, value_template, variables + ) + + @callback + def state_automation_listener(entity, from_s, to_s): + """Listen for state changes and calls action.""" + + @callback + def call_action(): + """Call action with right 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) + + if not matching: + entities_triggered.discard(entity) + elif entity not in entities_triggered: + 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, + 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) + + @callback + def async_remove(): + """Remove state listeners async.""" + unsub() + for async_remove in unsub_track_same.values(): + async_remove() + unsub_track_same.clear() + + return async_remove diff --git a/homeassistant/components/automation/reproduce_state.py b/homeassistant/components/automation/reproduce_state.py new file mode 100644 index 0000000000000..4cfe519d585a0 --- /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/services.yaml b/homeassistant/components/automation/services.yaml new file mode 100644 index 0000000000000..ce54220aff468 --- /dev/null +++ b/homeassistant/components/automation/services.yaml @@ -0,0 +1,35 @@ +# Describes the format for available automation services + +turn_on: + description: Enable an automation. + fields: + entity_id: + description: Name of the automation to turn on. + example: 'automation.notify_home' + +turn_off: + description: Disable an automation. + fields: + entity_id: + description: Name of the automation to turn off. + example: 'automation.notify_home' + +toggle: + description: Toggle an automation. + fields: + entity_id: + description: Name of the automation to toggle on/off. + example: 'automation.notify_home' + +trigger: + description: Trigger the action of an automation. + fields: + entity_id: + description: Name of the automation to trigger. + example: 'automation.notify_home' + skip_condition: + description: Whether or not the condition will be skipped (defaults to True). + example: True + +reload: + description: Reload the automation configuration. diff --git a/homeassistant/components/automation/state.py b/homeassistant/components/automation/state.py index c8adfe95bbeb8..fc3fff475148f 100644 --- a/homeassistant/components/automation/state.py +++ b/homeassistant/components/automation/state.py @@ -1,36 +1,143 @@ -""" -homeassistant.components.automation.state -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Offers state listening automation rules. -""" +"""Offer state listening automation rules.""" +from datetime import timedelta import logging +from typing import Dict -from homeassistant.const import MATCH_ALL +import voluptuous as vol +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 = "state_entity_id" -CONF_FROM = "state_from" -CONF_TO = "state_to" +# mypy: allow-incomplete-defs, allow-untyped-calls, allow-untyped-defs +# mypy: no-check-untyped-defs +_LOGGER = logging.getLogger(__name__) -def register(hass, config, action): - """ Listen for state changes based on `config`. """ - entity_id = config.get(CONF_ENTITY_ID) +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), +) - if entity_id is None: - logging.getLogger(__name__).error( - "Missing configuration key %s", CONF_ENTITY_ID) - return False +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) + 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): - """ Listens for state changes and calls action. """ - action() + """Listen for state changes and calls action.""" + + @callback + def call_action(): + """Call action with right 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 + ): + 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, + period[entity], + call_action, + lambda _, _2, to_state: to_state.state == to_s.state, + entity_ids=entity, + ) + + unsub = async_track_state_change( + hass, entity_id, state_automation_listener, from_state, to_state + ) - hass.states.track_change( - entity_id, state_automation_listener, from_state, to_state) + @callback + def async_remove(): + """Remove state listeners async.""" + unsub() + for async_remove in unsub_track_same.values(): + async_remove() + unsub_track_same.clear() - return True + return async_remove diff --git a/homeassistant/components/automation/sun.py b/homeassistant/components/automation/sun.py new file mode 100644 index 0000000000000..c416742f397c7 --- /dev/null +++ b/homeassistant/components/automation/sun.py @@ -0,0 +1,44 @@ +"""Offer sun based automation rules.""" +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.const import ( + 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, + } +) + + +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) + + @callback + def call_action(): + """Call action with right context.""" + 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) + return async_track_sunset(hass, call_action, offset) diff --git a/homeassistant/components/automation/template.py b/homeassistant/components/automation/template.py new file mode 100644 index 0000000000000..ee4484410cd4c --- /dev/null +++ b/homeassistant/components/automation/template.py @@ -0,0 +1,110 @@ +"""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.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, + vol.Optional(CONF_FOR): vol.Any( + vol.All(cv.time_period, cv.positive_timedelta), + cv.template, + cv.template_complex, + ), + } +) + + +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.""" + nonlocal unsub_track_same + + @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 7e38960534d10..5f46195296022 100644 --- a/homeassistant/components/automation/time.py +++ b/homeassistant/components/automation/time.py @@ -1,28 +1,32 @@ -""" -homeassistant.components.automation.time -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +"""Offer time listening automation rules.""" +import logging -Offers time listening automation rules. -""" -from homeassistant.util import convert +import voluptuous as vol -CONF_HOURS = "time_hours" -CONF_MINUTES = "time_minutes" -CONF_SECONDS = "time_seconds" +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 +# mypy: allow-untyped-defs, no-check-untyped-defs -def register(hass, config, action): - """ Listen for state changes based on `config`. """ - hours = convert(config.get(CONF_HOURS), int) - minutes = convert(config.get(CONF_MINUTES), int) - seconds = convert(config.get(CONF_SECONDS), int) +_LOGGER = logging.getLogger(__name__) + +TRIGGER_SCHEMA = vol.Schema( + {vol.Required(CONF_PLATFORM): "time", vol.Required(CONF_AT): cv.time} +) - def time_automation_listener(now): - """ Listens for time changes and calls action. """ - action() - hass.track_time_change( - time_automation_listener, - hour=hours, minute=minutes, second=seconds) +async def async_attach_trigger(hass, config, action, automation_info): + """Listen for state changes based on configuration.""" + 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}}) - return True + 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 0000000000000..65d44f5b1caf4 --- /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 0000000000000..5d01c6454a80a --- /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 new file mode 100644 index 0000000000000..3dba1a4df355b --- /dev/null +++ b/homeassistant/components/automation/zone.py @@ -0,0 +1,83 @@ +"""Offer zone automation rules.""" +import voluptuous as vol + +from homeassistant.const import ( + 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 + +# 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 + ), + } +) + + +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) + event = config.get(CONF_EVENT) + + @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) + ): + return + + zone_state = hass.states.get(zone_entity_id) + if from_s: + from_match = condition.zone(hass, zone_state, from_s) + else: + from_match = False + 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, + ) + ) + + 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 0000000000000..861c4f655a10e --- /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 0000000000000..92d66a554daad --- /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 0000000000000..f6217eeed1822 --- /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 0000000000000..79e882225450b --- /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 0000000000000..0c95b2bf736f8 --- /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 0000000000000..cfdda5a0d845f --- /dev/null +++ b/homeassistant/components/avion/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "avion", + "name": "Avi-on", + "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 0000000000000..c9a08cb40b5cf --- /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 0000000000000..2741ad358f691 --- /dev/null +++ b/homeassistant/components/awair/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..f15e4a80e3698 --- /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 0000000000000..600874b0d25d3 --- /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 0000000000000..6ac332b251c7e --- /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 0000000000000..499f4413596d3 --- /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 0000000000000..7b706eb1bfa47 --- /dev/null +++ b/homeassistant/components/aws/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "aws", + "name": "Amazon Web Services (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 0000000000000..13fa189a31823 --- /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/.translations/bg.json b/homeassistant/components/axis/.translations/bg.json new file mode 100644 index 0000000000000..c56822ba5a47d --- /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 0000000000000..ecf7b552bba48 --- /dev/null +++ b/homeassistant/components/axis/.translations/ca.json @@ -0,0 +1,30 @@ +{ + "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", + "updated_configuration": "S'ha actualitzat la configuraci\u00f3 del dispositiu amb l'adre\u00e7a nova" + }, + "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 0000000000000..258f301e43290 --- /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 0000000000000..355dbad83d5ef --- /dev/null +++ b/homeassistant/components/axis/.translations/da.json @@ -0,0 +1,30 @@ +{ + "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", + "updated_configuration": "Opdaterede enhedskonfiguration med ny v\u00e6rtsadresse" + }, + "error": { + "already_configured": "Enheden er allerede konfigureret", + "already_in_progress": "Enhedskonfiguration 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": "Indstil 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 0000000000000..a92c948a2a7df --- /dev/null +++ b/homeassistant/components/axis/.translations/de.json @@ -0,0 +1,30 @@ +{ + "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", + "updated_configuration": "Ger\u00e4tekonfiguration mit neuer Hostadresse aktualisiert" + }, + "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" + }, + "flow_title": "Achsenger\u00e4t: {name} ({host})", + "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 0000000000000..abc1e2f17ec88 --- /dev/null +++ b/homeassistant/components/axis/.translations/en.json @@ -0,0 +1,30 @@ +{ + "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", + "updated_configuration": "Updated device configuration with new host address" + }, + "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 0000000000000..c5404a173f653 --- /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 0000000000000..885e8f6891365 --- /dev/null +++ b/homeassistant/components/axis/.translations/es.json @@ -0,0 +1,30 @@ +{ + "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", + "updated_configuration": "Configuraci\u00f3n del dispositivo actualizada con la nueva direcci\u00f3n de host" + }, + "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 0000000000000..608e12d020ac1 --- /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 0000000000000..41dd3c00d2b32 --- /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 0000000000000..3f303140c6840 --- /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 0000000000000..e471ae3ea7a00 --- /dev/null +++ b/homeassistant/components/axis/.translations/ko.json @@ -0,0 +1,30 @@ +{ + "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", + "updated_configuration": "\uc0c8\ub85c\uc6b4 \ud638\uc2a4\ud2b8 \uc8fc\uc18c\ub85c \uc5c5\ub370\uc774\ud2b8\ub41c \uae30\uae30 \uad6c\uc131" + }, + "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 0000000000000..24ee0e2412545 --- /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 0000000000000..10fc8c02d66ad --- /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 0000000000000..b6296d1acab70 --- /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 0000000000000..60db56146fa38 --- /dev/null +++ b/homeassistant/components/axis/.translations/no.json @@ -0,0 +1,30 @@ +{ + "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", + "updated_configuration": "Oppdatert enhetskonfigurasjonen med ny vertsadresse" + }, + "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 0000000000000..d5deb327a7558 --- /dev/null +++ b/homeassistant/components/axis/.translations/pl.json @@ -0,0 +1,30 @@ +{ + "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", + "updated_configuration": "Zaktualizowano konfiguracj\u0119 urz\u0105dzenia o nowy adres hosta" + }, + "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 0000000000000..ceb6325af60d4 --- /dev/null +++ b/homeassistant/components/axis/.translations/pt-BR.json @@ -0,0 +1,30 @@ +{ + "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", + "updated_configuration": "Configura\u00e7\u00e3o do dispositivo atualizada com novo endere\u00e7o de host" + }, + "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 0000000000000..77ce7025f70c9 --- /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 0000000000000..3506b636baa88 --- /dev/null +++ b/homeassistant/components/axis/.translations/ru.json @@ -0,0 +1,30 @@ +{ + "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.", + "updated_configuration": "\u0410\u0434\u0440\u0435\u0441 \u0445\u043e\u0441\u0442\u0430 \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d." + }, + "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 0000000000000..43a352c4bc0eb --- /dev/null +++ b/homeassistant/components/axis/.translations/sl.json @@ -0,0 +1,30 @@ +{ + "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", + "updated_configuration": "Posodobljena konfiguracija naprave z novim naslovom gostitelja" + }, + "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 0000000000000..a38ef2ef74540 --- /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 0000000000000..4226d4ddb3e5b --- /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 0000000000000..f7f6c8259ced1 --- /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 0000000000000..751a75442024c --- /dev/null +++ b/homeassistant/components/axis/.translations/zh-Hant.json @@ -0,0 +1,30 @@ +{ + "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", + "updated_configuration": "\u4f7f\u7528\u65b0\u4e3b\u6a5f\u7aef\u4f4d\u5740\u66f4\u65b0\u88dd\u7f6e\u8a2d\u5b9a" + }, + "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 0000000000000..5c928aa9f31de --- /dev/null +++ b/homeassistant/components/axis/__init__.py @@ -0,0 +1,66 @@ +"""Support for Axis devices.""" + +from homeassistant.const import ( + CONF_DEVICE, + CONF_MAC, + CONF_TRIGGER_TIME, + EVENT_HOMEASSISTANT_STOP, +) + +from .const import CONF_CAMERA, CONF_EVENTS, DEFAULT_TRIGGER_TIME, DOMAIN +from .device import AxisNetworkDevice, get_device + + +async def async_setup(hass, config): + """Old way to set up Axis devices.""" + 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 + + # 0.104 introduced config entry unique id, this makes upgrading possible + if config_entry.unique_id is None: + hass.config_entries.async_update_entry( + config_entry, unique_id=device.api.vapix.params.system_serialnumber + ) + + 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 0000000000000..f22a169a1023d --- /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 0000000000000..b3593179ffc41 --- /dev/null +++ b/homeassistant/components/axis/binary_sensor.py @@ -0,0 +1,86 @@ +"""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_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.""" + device = hass.data[AXIS_DOMAIN][config_entry.unique_id] + + @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 0000000000000..6b82c938a9967 --- /dev/null +++ b/homeassistant/components/axis/camera.py @@ -0,0 +1,93 @@ +"""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_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() + + device = hass.data[AXIS_DOMAIN][config_entry.unique_id] + + 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 0000000000000..88c1cab98c151 --- /dev/null +++ b/homeassistant/components/axis/config_flow.py @@ -0,0 +1,171 @@ +"""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 .const import CONF_MODEL, DOMAIN +from .device import get_device +from .errors import 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 + +DEFAULT_PORT = 80 + + +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 + config_entry = await self.async_set_unique_id(self.serial_number) + if config_entry: + return self._update_entry( + config_entry, + host=user_input[CONF_HOST], + port=user_input[CONF_PORT], + ) + + self.model = device.vapix.params.prodnbr + + return await self._create_entry() + + except AuthenticationRequired: + errors["base"] = "faulty_credentials" + + except CannotConnect: + errors["base"] = "device_unavailable" + + data = 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. + """ + 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) + + def _update_entry(self, entry, host, port): + """Update existing entry.""" + if ( + entry.data[CONF_DEVICE][CONF_HOST] == host + and entry.data[CONF_DEVICE][CONF_PORT] == port + ): + return self.async_abort(reason="already_configured") + + entry.data[CONF_DEVICE][CONF_HOST] = host + entry.data[CONF_DEVICE][CONF_PORT] = port + + self.hass.config_entries.async_update_entry(entry) + return self.async_abort(reason="updated_configuration") + + async def async_step_zeroconf(self, discovery_info): + """Prepare configuration for a discovered Axis device.""" + serial_number = discovery_info["properties"]["macaddress"] + + if serial_number[: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") + + config_entry = await self.async_set_unique_id(serial_number) + if config_entry: + return self._update_entry( + config_entry, + host=discovery_info[CONF_HOST], + port=discovery_info[CONF_PORT], + ) + + # 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], + } + + 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() diff --git a/homeassistant/components/axis/const.py b/homeassistant/components/axis/const.py new file mode 100644 index 0000000000000..7f0fd9c8947e7 --- /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 0000000000000..85ad59268df27 --- /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 serial number of this device.""" + return self.config_entry.unique_id + + 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 0000000000000..56105b28b1bfd --- /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 0000000000000..348f614838632 --- /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 0000000000000..7facd7060adf6 --- /dev/null +++ b/homeassistant/components/axis/strings.json @@ -0,0 +1,30 @@ +{ + "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", + "updated_configuration": "Updated device configuration with new host address" + } + } +} diff --git a/homeassistant/components/axis/switch.py b/homeassistant/components/axis/switch.py new file mode 100644 index 0000000000000..a83460bc5296d --- /dev/null +++ b/homeassistant/components/axis/switch.py @@ -0,0 +1,60 @@ +"""Support for Axis switches.""" + +from axis.event_stream import CLASS_OUTPUT + +from homeassistant.components.switch import SwitchDevice +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.""" + device = hass.data[AXIS_DOMAIN][config_entry.unique_id] + + @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 0000000000000..7e141cd8060cf --- /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 0000000000000..614fb0d98ef7b --- /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"] +} diff --git a/homeassistant/components/azure_service_bus/__init__.py b/homeassistant/components/azure_service_bus/__init__.py new file mode 100644 index 0000000000000..f18dc9eb66c95 --- /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 0000000000000..af1d9d889df66 --- /dev/null +++ b/homeassistant/components/azure_service_bus/manifest.json @@ -0,0 +1,8 @@ +{ + "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"] +} diff --git a/homeassistant/components/azure_service_bus/notify.py b/homeassistant/components/azure_service_bus/notify.py new file mode 100644 index 0000000000000..e7c85adede8f2 --- /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 0000000000000..8a332cf52e143 --- /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 0000000000000..2448f87778ba3 --- /dev/null +++ b/homeassistant/components/baidu/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..4208750b7fcc3 --- /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 0000000000000..971ff8427acf1 --- /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 0000000000000..1d3720f6723d1 --- /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 0000000000000..1b4dc73810fcc --- /dev/null +++ b/homeassistant/components/bayesian/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "bayesian", + "name": "Bayesian", + "documentation": "https://www.home-assistant.io/integrations/bayesian", + "requirements": [], + "dependencies": [], + "codeowners": [], + "quality_scale": "internal" +} diff --git a/homeassistant/components/bbb_gpio/__init__.py b/homeassistant/components/bbb_gpio/__init__.py new file mode 100644 index 0000000000000..30a4bacc4da3d --- /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 0000000000000..3ef13c117a219 --- /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 0000000000000..e919e0c66bf22 --- /dev/null +++ b/homeassistant/components/bbb_gpio/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "bbb_gpio", + "name": "BeagleBone Black 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 0000000000000..eb75c6f374cd5 --- /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 0000000000000..8c3bbf0d57f28 --- /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 0000000000000..8097c11eb89b9 --- /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 0000000000000..ed7f7270bd5dd --- /dev/null +++ b/homeassistant/components/bbox/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..f5e5865f6f00f --- /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 0000000000000..f907ce95ae639 --- /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 0000000000000..69adb76d3cbc9 --- /dev/null +++ b/homeassistant/components/beewi_smartclim/manifest.json @@ -0,0 +1,8 @@ +{ + "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"] +} diff --git a/homeassistant/components/beewi_smartclim/sensor.py b/homeassistant/components/beewi_smartclim/sensor.py new file mode 100644 index 0000000000000..187ca411988bf --- /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, Home Assistant 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 0000000000000..ce7ecc65366dd --- /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 0000000000000..1c9724b7dd857 --- /dev/null +++ b/homeassistant/components/bh1750/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..924bfcd55076a --- /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 0000000000000..373866ecd8c2f --- /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 0000000000000..8bbd19a0d45c0 --- /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 0000000000000..cb941e67883ff --- /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 0000000000000..19229c16cb343 --- /dev/null +++ b/homeassistant/components/binary_sensor/.translations/da.json @@ -0,0 +1,94 @@ +{ + "device_automation": { + "condition_type": { + "is_bat_low": "{entity_name} batteri er lavt", + "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_bat_low": "{entity_name} batteri er normalt", + "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_plugged_in": "{entity_name} er ikke tilsluttet str\u00f8m", + "is_not_powered": "{entity_name} er ikke tilsluttet str\u00f8m", + "is_not_present": "{entity_name} er ikke til stede", + "is_not_unsafe": "{entity_name} er sikker", + "is_occupied": "{entity_name} er optaget", + "is_off": "{entity_name} er sl\u00e5et fra", + "is_on": "{entity_name} er sl\u00e5et til", + "is_open": "{entity_name} er \u00e5ben", + "is_plugged_in": "{entity_name} er tilsluttet str\u00f8m", + "is_powered": "{entity_name} er tilsluttet str\u00f8m", + "is_present": "{entity_name} er til stede", + "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": { + "bat_low": "{entity_name} lavt batteriniveau", + "closed": "{entity_name} lukket", + "cold": "{entity_name} blev kold", + "connected": "{entity_name} tilsluttet", + "gas": "{entity_name} begyndte at registrere gas", + "hot": "{entity_name} blev varm", + "light": "{entity_name} begyndte at registrere lys", + "locked": "{entity_name} l\u00e5st", + "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_bat_low": "{entity_name} batteri normalt", + "not_cold": "{entity_name} blev ikke kold", + "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_moving": "{entity_name} stoppede med at bev\u00e6ge sig", + "not_occupied": "{entity_name} blev ikke optaget", + "not_opened": "{entity_name} lukket", + "not_plugged_in": "{entity_name} ikke tilsluttet str\u00f8m", + "not_powered": "{entity_name} ikke tilsluttet str\u00f8m", + "not_present": "{entity_name} ikke til stede", + "not_unsafe": "{entity_name} blev sikker", + "occupied": "{entity_name} blev optaget", + "opened": "{entity_name} \u00e5bnet", + "plugged_in": "{entity_name} tilsluttet str\u00f8m", + "powered": "{entity_name} tilsluttet str\u00f8m", + "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", + "turned_off": "{entity_name} slukkede", + "turned_on": "{entity_name} t\u00e6ndte", + "unsafe": "{entity_name} blev usikker", + "vibration": "{entity_name} begyndte at registrere vibration" + } + } +} \ 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 0000000000000..e246198864bb8 --- /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 0000000000000..93b61893980eb --- /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 0000000000000..f1c20e5346b51 --- /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 0000000000000..9720fb974f6bf --- /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": "{entity_name} 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": "{entity_name} 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 0000000000000..65abfbcd0bd88 --- /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 0000000000000..7ec9b5268e2ff --- /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 0000000000000..c69f5a07a41fb --- /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 0000000000000..4c1cba2bec5eb --- /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\ud558\uba74", + "is_cold": "{entity_name} \uc774(\uac00) \ucc28\uac00\uc6b0\uba74", + "is_connected": "{entity_name} \uc774(\uac00) \uc5f0\uacb0\ub418\uc5b4 \uc788\uc73c\uba74", + "is_gas": "{entity_name} \uc774(\uac00) \uac00\uc2a4\ub97c \uac10\uc9c0\ud558\uba74", + "is_hot": "{entity_name} \uc774(\uac00) \ub728\uac70\uc6b0\uba74", + "is_light": "{entity_name} \uc774(\uac00) \ube5b\uc744 \uac10\uc9c0\ud558\uba74", + "is_locked": "{entity_name} \uc774(\uac00) \uc7a0\uaca8\uc788\uc73c\uba74", + "is_moist": "{entity_name} \uc774(\uac00) \uc2b5\ud558\uba74", + "is_motion": "{entity_name} \uc774(\uac00) \uc6c0\uc9c1\uc784\uc744 \uac10\uc9c0\ud558\uba74", + "is_moving": "{entity_name} \uc774(\uac00) \uc6c0\uc9c1\uc774\uba74", + "is_no_gas": "{entity_name} \uc774(\uac00) \uac00\uc2a4\ub97c \uac10\uc9c0\ud558\uc9c0 \uc54a\uc73c\uba74", + "is_no_light": "{entity_name} \uc774(\uac00) \ube5b\uc744 \uac10\uc9c0\ud558\uc9c0 \uc54a\uc73c\uba74", + "is_no_motion": "{entity_name} \uc774(\uac00) \uc6c0\uc9c1\uc784\uc744 \uac10\uc9c0\ud558\uc9c0 \uc54a\uc73c\uba74", + "is_no_problem": "{entity_name} \uc774(\uac00) \ubb38\uc81c\ub97c \uac10\uc9c0\ud558\uc9c0 \uc54a\uc73c\uba74", + "is_no_smoke": "{entity_name} \uc774(\uac00) \uc5f0\uae30\ub97c \uac10\uc9c0\ud558\uc9c0 \uc54a\uc73c\uba74", + "is_no_sound": "{entity_name} \uc774(\uac00) \uc18c\ub9ac\ub97c \uac10\uc9c0\ud558\uc9c0 \uc54a\uc73c\uba74", + "is_no_vibration": "{entity_name} \uc774(\uac00) \uc9c4\ub3d9\uc744 \uac10\uc9c0\ud558\uc9c0 \uc54a\uc73c\uba74", + "is_not_bat_low": "{entity_name} \uc758 \ubc30\ud130\ub9ac\uac00 \uc815\uc0c1\uc774\uba74", + "is_not_cold": "{entity_name} \uc774(\uac00) \ucc28\uac11\uc9c0 \uc54a\ub2e4\uba74", + "is_not_connected": "{entity_name} \uc758 \uc5f0\uacb0\uc774 \ub04a\uc5b4\uc838 \uc788\ub2e4\uba74", + "is_not_hot": "{entity_name} \uc774(\uac00) \ub728\uac81\uc9c0 \uc54a\ub2e4\uba74", + "is_not_locked": "{entity_name} \uc774(\uac00) \uc7a0\uaca8\uc788\uc9c0 \uc54a\uc73c\uba74", + "is_not_moist": "{entity_name} \uc774(\uac00) \uac74\uc870\ud558\uba74", + "is_not_moving": "{entity_name} \uc774(\uac00) \uc6c0\uc9c1\uc774\uc9c0 \uc54a\uc73c\uba74", + "is_not_occupied": "{entity_name} \uc774(\uac00) \uc0ac\uc6a9 \uc911\uc774\uc9c0 \uc54a\uc73c\uba74", + "is_not_open": "{entity_name} \uc774(\uac00) \ub2eb\ud600 \uc788\uc73c\uba74", + "is_not_plugged_in": "{entity_name} \ud50c\ub7ec\uadf8\uac00 \ubf51\ud600 \uc788\uc73c\uba74", + "is_not_powered": "{entity_name} \uc5d0 \uc804\uc6d0\uc774 \uacf5\uae09\ub418\uc9c0 \uc54a\uc73c\uba74", + "is_not_present": "{entity_name} \uc774(\uac00) \uc678\ucd9c \uc911\uc774\uba74", + "is_not_unsafe": "{entity_name} \uc774(\uac00) \uc548\uc804\ud558\uba74", + "is_occupied": "{entity_name} \uc774(\uac00) \uc0ac\uc6a9 \uc911\uc774\uba74", + "is_off": "{entity_name} \uc774(\uac00) \uaebc\uc838 \uc788\uc73c\uba74", + "is_on": "{entity_name} \uc774(\uac00) \ucf1c\uc838 \uc788\uc73c\uba74", + "is_open": "{entity_name} \uc774(\uac00) \uc5f4\ub824 \uc788\uc73c\uba74", + "is_plugged_in": "{entity_name} \ud50c\ub7ec\uadf8\uac00 \uaf3d\ud600 \uc788\uc73c\uba74", + "is_powered": "{entity_name} \uc5d0 \uc804\uc6d0\uc774 \uacf5\uae09\ub418\uace0 \uc788\uc73c\uba74", + "is_present": "{entity_name} \uc774(\uac00) \uc7ac\uc2e4 \uc911\uc774\uba74", + "is_problem": "{entity_name} \uc774(\uac00) \ubb38\uc81c\ub97c \uac10\uc9c0\ud558\uba74", + "is_smoke": "{entity_name} \uc774(\uac00) \uc5f0\uae30\ub97c \uac10\uc9c0\ud558\uba74", + "is_sound": "{entity_name} \uc774(\uac00) \uc18c\ub9ac\ub97c \uac10\uc9c0\ud558\uba74", + "is_unsafe": "{entity_name} \uc740(\ub294) \uc548\uc804\ud558\uc9c0 \uc54a\uc73c\uba74", + "is_vibration": "{entity_name} \uc774(\uac00) \uc9c4\ub3d9\uc744 \uac10\uc9c0\ud558\uba74" + }, + "trigger_type": { + "bat_low": "{entity_name} \ubc30\ud130\ub9ac \uc794\ub7c9\uc774 \ubd80\uc871\ud574\uc9c8 \ub54c", + "closed": "{entity_name} \uc774(\uac00) \ub2eb\ud790 \ub54c", + "cold": "{entity_name} \uc774(\uac00) \ucc28\uac00\uc6cc\uc9c8 \ub54c", + "connected": "{entity_name} \uc774(\uac00) \uc5f0\uacb0\ub420 \ub54c", + "gas": "{entity_name} \uc774(\uac00) \uac00\uc2a4\ub97c \uac10\uc9c0\ud560 \ub54c", + "hot": "{entity_name} \uc774(\uac00) \ub728\uac70\uc6cc\uc9c8 \ub54c", + "light": "{entity_name} \uc774(\uac00) \ube5b\uc744 \uac10\uc9c0\ud560 \ub54c", + "locked": "{entity_name} \uc774(\uac00) \uc7a0\uae38 \ub54c", + "moist": "{entity_name} \uc774(\uac00) \uc2b5\ud574\uc9c8 \ub54c", + "moist\u00a7": "{entity_name} \uc774(\uac00) \uc2b5\ud574\uc9c8 \ub54c", + "motion": "{entity_name} \uc774(\uac00) \uc6c0\uc9c1\uc784\uc744 \uac10\uc9c0\ud560 \ub54c", + "moving": "{entity_name} \uc774(\uac00) \uc6c0\uc9c1\uc77c \ub54c", + "no_gas": "{entity_name} \uc774(\uac00) \uac00\uc2a4\ub97c \uac10\uc9c0\ud558\uc9c0 \uc54a\uac8c \ub420 \ub54c", + "no_light": "{entity_name} \uc774(\uac00) \ube5b\uc744 \uac10\uc9c0\ud558\uc9c0 \uc54a\uac8c \ub420 \ub54c", + "no_motion": "{entity_name} \uc774(\uac00) \uc6c0\uc9c1\uc784\uc744 \uac10\uc9c0\ud558\uc9c0 \uc54a\uac8c \ub420 \ub54c", + "no_problem": "{entity_name} \uc774(\uac00) \ubb38\uc81c\ub97c \uac10\uc9c0\ud558\uc9c0 \uc54a\uac8c \ub420 \ub54c", + "no_smoke": "{entity_name} \uc774(\uac00) \uc5f0\uae30\ub97c \uac10\uc9c0\ud558\uc9c0 \uc54a\uac8c \ub420 \ub54c", + "no_sound": "{entity_name} \uc774(\uac00) \uc18c\ub9ac\ub97c \uac10\uc9c0\ud558\uc9c0 \uc54a\uac8c \ub420 \ub54c", + "no_vibration": "{entity_name} \uc774(\uac00) \uc9c4\ub3d9\uc744 \uac10\uc9c0\ud558\uc9c0 \uc54a\uac8c \ub420 \ub54c", + "not_bat_low": "{entity_name} \ubc30\ud130\ub9ac\uac00 \uc815\uc0c1\uc774 \ub420 \ub54c", + "not_cold": "{entity_name} \uc774(\uac00) \ucc28\uac11\uc9c0 \uc54a\uac8c \ub420 \ub54c", + "not_connected": "{entity_name} \uc758 \uc5f0\uacb0\uc774 \ub04a\uc5b4\uc9c8 \ub54c", + "not_hot": "{entity_name} \uc774(\uac00) \ub728\uac81\uc9c0 \uc54a\uac8c \ub420 \ub54c", + "not_locked": "{entity_name} \uc758 \uc7a0\uae08\uc774 \ud574\uc81c\ub420 \ub54c", + "not_moist": "{entity_name} \uc774(\uac00) \uac74\uc870\ud574\uc9c8 \ub54c", + "not_moving": "{entity_name} \uc774(\uac00) \uc6c0\uc9c1\uc774\uc9c0 \uc54a\uc744 \ub54c", + "not_occupied": "{entity_name} \uc774(\uac00) \uc0ac\uc6a9 \uc911\uc774\uc9c0 \uc54a\uac8c \ub420 \ub54c", + "not_opened": "{entity_name} \uc774(\uac00) \ub2eb\ud790 \ub54c", + "not_plugged_in": "{entity_name} \ud50c\ub7ec\uadf8\uac00 \ubf51\ud790 \ub54c", + "not_powered": "{entity_name} \uc5d0 \uc804\uc6d0\uc774 \uacf5\uae09\ub418\uc9c0 \uc54a\uc744 \ub54c", + "not_present": "{entity_name} \uc774(\uac00) \uc678\ucd9c \uc0c1\ud0dc\uac00 \ub420 \ub54c", + "not_unsafe": "{entity_name} \uc740(\ub294) \uc548\uc804\ud574\uc9c8 \ub54c", + "occupied": "{entity_name} \uc774(\uac00) \uc0ac\uc6a9 \uc911\uc774 \ub420 \ub54c", + "opened": "{entity_name} \uc774(\uac00) \uc5f4\ub9b4 \ub54c", + "plugged_in": "{entity_name} \ud50c\ub7ec\uadf8\uac00 \uaf3d\ud790 \ub54c", + "powered": "{entity_name} \uc5d0 \uc804\uc6d0\uc774 \uacf5\uae09\ub420 \ub54c", + "present": "{entity_name} \uc774(\uac00) \uc7ac\uc2e4 \uc0c1\ud0dc\uac00 \ub420 \ub54c", + "problem": "{entity_name} \uc774(\uac00) \ubb38\uc81c\ub97c \uac10\uc9c0\ud560 \ub54c", + "smoke": "{entity_name} \uc774(\uac00) \uc5f0\uae30\ub97c \uac10\uc9c0\ud560 \ub54c", + "sound": "{entity_name} \uc774(\uac00) \uc18c\ub9ac\ub97c \uac10\uc9c0\ud560 \ub54c", + "turned_off": "{entity_name} \uc774(\uac00) \uaebc\uc9c8 \ub54c", + "turned_on": "{entity_name} \uc774(\uac00) \ucf1c\uc9c8 \ub54c", + "unsafe": "{entity_name} \uc774(\uac00) \uc548\uc804\ud558\uc9c0 \uc54a\uc744 \ub54c", + "vibration": "{entity_name} \uc774(\uac00) \uc9c4\ub3d9\uc744 \uac10\uc9c0\ud560 \ub54c" + } + } +} \ 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 0000000000000..c65ae94396bbf --- /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 0000000000000..7668dfa5ac865 --- /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 0000000000000..508a06b38a2a8 --- /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 0000000000000..4194102948b83 --- /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 0000000000000..bc474e3d514f0 --- /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 0000000000000..aa16576d2c139 --- /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 0000000000000..438822a97f5bf --- /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 0000000000000..4c9cfb99a1cc3 --- /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 0000000000000..2004caeb34289 --- /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 0000000000000..046b999cb8c08 --- /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 new file mode 100644 index 0000000000000..73d5e0be4584a --- /dev/null +++ b/homeassistant/components/binary_sensor/__init__.py @@ -0,0 +1,157 @@ +"""Component to interface with binary sensors.""" + +from datetime import timedelta +import logging + +import voluptuous as vol + +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.helpers.entity_component import EntityComponent + +# mypy: allow-untyped-defs, no-check-untyped-defs + +DOMAIN = "binary_sensor" +SCAN_INTERVAL = timedelta(seconds=30) + +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 = [ + 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)) + + +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 + ) + + await component.async_setup(config) + 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 BinarySensorDevice(Entity): + """Represent a binary sensor.""" + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return None + + @property + def state(self): + """Return the state of the binary sensor.""" + return STATE_ON if self.is_on else STATE_OFF + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return None diff --git a/homeassistant/components/binary_sensor/device_condition.py b/homeassistant/components/binary_sensor/device_condition.py new file mode 100644 index 0000000000000..842790e017852 --- /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 0000000000000..288cc101d930c --- /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/manifest.json b/homeassistant/components/binary_sensor/manifest.json new file mode 100644 index 0000000000000..cbe956847152a --- /dev/null +++ b/homeassistant/components/binary_sensor/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "binary_sensor", + "name": "Binary Sensor", + "documentation": "https://www.home-assistant.io/integrations/binary_sensor", + "requirements": [], + "dependencies": [], + "codeowners": [], + "quality_scale": "internal" +} diff --git a/homeassistant/components/binary_sensor/strings.json b/homeassistant/components/binary_sensor/strings.json new file mode 100644 index 0000000000000..e01af8d183ecb --- /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/bitcoin/__init__.py b/homeassistant/components/bitcoin/__init__.py new file mode 100644 index 0000000000000..cfdfb53c04434 --- /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 0000000000000..caf1aafcacff2 --- /dev/null +++ b/homeassistant/components/bitcoin/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..bc8394d51a580 --- /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:.0f}".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 0000000000000..e37c17e574407 --- /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 0000000000000..63c0494c2f18f --- /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 0000000000000..931fbbb834ddc --- /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 0000000000000..b901bda0469f7 --- /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 0000000000000..aa8d7e7d514e8 --- /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 0000000000000..d68703eee84e8 --- /dev/null +++ b/homeassistant/components/blackbird/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "blackbird", + "name": "Monoprice Blackbird Matrix Switch", + "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 0000000000000..a0ea369bb9b3b --- /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 0000000000000..d541e21049da1 --- /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/__init__.py b/homeassistant/components/blink/__init__.py new file mode 100644 index 0000000000000..e233a8b21d819 --- /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 0000000000000..9b23c1606d438 --- /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 0000000000000..e8c01953bffd6 --- /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 0000000000000..52043324a40f9 --- /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 0000000000000..4912f72b906d4 --- /dev/null +++ b/homeassistant/components/blink/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..81616b463ecfb --- /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 0000000000000..fc042b0d5986c --- /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 0000000000000..dd45fbcd690cb --- /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 0000000000000..197213f747370 --- /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 0000000000000..75ee7c8ad3658 --- /dev/null +++ b/homeassistant/components/blinksticklight/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "blinksticklight", + "name": "BlinkStick", + "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 0000000000000..0f61a21155926 --- /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 0000000000000..0fedc2b794bac --- /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 0000000000000..f61b674aa3a29 --- /dev/null +++ b/homeassistant/components/blinkt/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..a8ee9884bba43 --- /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 0000000000000..f57e91a92624e --- /dev/null +++ b/homeassistant/components/blockchain/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "blockchain", + "name": "Blockchain.info", + "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 0000000000000..6d17484bdd7f4 --- /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/__init__.py b/homeassistant/components/bloomsky/__init__.py new file mode 100644 index 0000000000000..6373471fe7a6c --- /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 0000000000000..cc6562a0bc125 --- /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 0000000000000..d62dede5abdab --- /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 0000000000000..fdaa649b344b1 --- /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 0000000000000..84871b7b30e5d --- /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 0000000000000..9dbe0f754fb04 --- /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 0000000000000..af1a8e5187c2c --- /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 0000000000000..df6aa5b03dead --- /dev/null +++ b/homeassistant/components/bluesound/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..04ba21555d484 --- /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 0000000000000..6c85c77e961c2 --- /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 0000000000000..d6886e1b3565d --- /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 0000000000000..4957356d26a18 --- /dev/null +++ b/homeassistant/components/bluetooth_le_tracker/device_tracker.py @@ -0,0 +1,137 @@ +"""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 name is not None: + name = name.strip("\x00") + + if new_device: + if address in new_devices: + new_devices[address]["seen"] += 1 + if name: + new_devices[address]["name"] = name + else: + name = new_devices[address]["name"] + _LOGGER.debug("Seen %s %s times", address, new_devices[address]["seen"]) + if new_devices[address]["seen"] < MIN_SEEN_NEW: + return + _LOGGER.debug("Adding %s to tracked devices", address) + devs_to_track.append(address) + else: + _LOGGER.debug("Seen %s for the first time", address) + new_devices[address] = {"seen": 1, "name": name} + return + + 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, pygatt.exceptions.BLEError) 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 0000000000000..52d2d40a99ba1 --- /dev/null +++ b/homeassistant/components/bluetooth_le_tracker/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..e58d5abab4afa --- /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 0000000000000..b481efa296f78 --- /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 0000000000000..d833f60c84f20 --- /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 0000000000000..b6ae27346f253 --- /dev/null +++ b/homeassistant/components/bluetooth_tracker/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..b48c48a896808 --- /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 0000000000000..87de36fdf02fa --- /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 0000000000000..393d3f451048f --- /dev/null +++ b/homeassistant/components/bme280/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "bme280", + "name": "Bosch BME280 Environmental Sensor", + "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 0000000000000..e1e33210c9b17 --- /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 0000000000000..dc88286a60356 --- /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 0000000000000..058bbf341e81d --- /dev/null +++ b/homeassistant/components/bme680/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "bme680", + "name": "Bosch BME680 Environmental Sensor", + "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 0000000000000..65c87890242ed --- /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 new file mode 100644 index 0000000000000..6e7723b16ec55 --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/__init__.py @@ -0,0 +1,154 @@ +"""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_PASSWORD, CONF_USERNAME +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv +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" + +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) + +SERVICE_SCHEMA = vol.Schema({vol.Required(ATTR_VIN): cv.string}) + + +BMW_COMPONENTS = ["binary_sensor", "device_tracker", "lock", "sensor"] +UPDATE_INTERVAL = 5 # in minutes + +SERVICE_UPDATE_STATE = "update_state" + +_SERVICE_MAP = { + "light_flash": "trigger_remote_light_flash", + "sound_horn": "trigger_remote_horn", + "activate_air_conditioning": "trigger_remote_air_conditioning", +} + + +def setup(hass, config: dict): + """Set up the BMW connected drive components.""" + accounts = [] + for name, account_config in config[DOMAIN].items(): + accounts.append(setup_account(account_config, hass, name)) + + hass.data[DOMAIN] = accounts + + def _update_all(call) -> None: + """Update all BMW accounts.""" + for cd_account in hass.data[DOMAIN]: + cd_account.update() + + # Service to manually trigger updates for all accounts. + hass.services.register(DOMAIN, SERVICE_UPDATE_STATE, _update_all) + + _update_all(None) + + for component in BMW_COMPONENTS: + discovery.load_platform(hass, component, DOMAIN, {}, config) + + return True + + +def setup_account(account_config: dict, hass, name: str) -> "BMWConnectedDriveAccount": + """Set up a new BMWConnectedDriveAccount based on the config.""" + username = account_config[CONF_USERNAME] + password = account_config[CONF_PASSWORD] + region = account_config[CONF_REGION] + read_only = account_config[CONF_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. + + This must be a member function as we need access to the cd_account + object here. + """ + vin = call.data[ATTR_VIN] + vehicle = cd_account.account.get_vehicle(vin) + if not vehicle: + _LOGGER.error("Could not find a vehicle for VIN %s", vin) + return + function_name = _SERVICE_MAP[call.service] + function_call = getattr(vehicle.remote_services, function_name) + function_call() + + if not read_only: + # register the remote services + for service in _SERVICE_MAP: + hass.services.register( + DOMAIN, service, execute_service, schema=SERVICE_SCHEMA + ) + + # update every UPDATE_INTERVAL minutes, starting now + # this should even out the load on the servers + now = dt_util.utcnow() + track_utc_time_change( + hass, + cd_account.update, + minute=range(now.minute % UPDATE_INTERVAL, 60, UPDATE_INTERVAL), + second=now.second, + ) + + return cd_account + + +class BMWConnectedDriveAccount: + """Representation of a BMW vehicle.""" + + def __init__( + self, username: str, password: str, region_str: str, name: str, read_only + ) -> None: + """Initialize account.""" + + region = get_region_from_name(region_str) + + self.read_only = read_only + self.account = ConnectedDriveAccount(username, password, region) + self.name = name + self._update_listeners = [] + + def update(self, *_): + """Update the state of all vehicles. + + Notify all listeners about the update. + """ + _LOGGER.debug( + "Updating vehicle state for account %s, notifying %d listeners", + self.name, + len(self._update_listeners), + ) + try: + self.account.update_vehicle_states() + for listener in self._update_listeners: + listener() + except 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): + """Add a listener for update notifications.""" + self._update_listeners.append(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 0000000000000..591cdadda3525 --- /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 0000000000000..fa732b64e7706 --- /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 0000000000000..5323e94c1c38d --- /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 0000000000000..c6e6ab9d89aec --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -0,0 +1,8 @@ +{ + "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"] +} diff --git a/homeassistant/components/bmw_connected_drive/sensor.py b/homeassistant/components/bmw_connected_drive/sensor.py new file mode 100644 index 0000000000000..3c40900bed8df --- /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/bmw_connected_drive/services.yaml b/homeassistant/components/bmw_connected_drive/services.yaml new file mode 100644 index 0000000000000..b9605429a8efa --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/services.yaml @@ -0,0 +1,42 @@ +# Describes the format for available services for bmw_connected_drive +# +# The services related to locking/unlocking are implemented in the lock +# component to avoid redundancy. + +light_flash: + description: > + Flash the lights of the vehicle. The vehicle is identified via the vin + (see below). + fields: + vin: + description: > + The vehicle identification number (VIN) of the vehicle, 17 characters + example: WBANXXXXXX1234567 + +sound_horn: + description: > + Sound the horn of the vehicle. The vehicle is identified via the vin + (see below). + fields: + vin: + description: > + The vehicle identification number (VIN) of the vehicle, 17 characters + example: WBANXXXXXX1234567 + +activate_air_conditioning: + description: > + Start the air conditioning of the vehicle. What exactly is started here + depends on the type of vehicle. It might range from just ventilation over + auxiliary heating to real air conditioning. The vehicle is identified via + the vin (see below). + fields: + vin: + description: > + The vehicle identification number (VIN) of the vehicle, 17 characters + example: WBANXXXXXX1234567 + +update_state: + description: > + Fetch the last state of the vehicles of all your accounts from the BMW + server. This does *not* trigger an update from the vehicle, it just gets + the data from the BMW servers. This service does not require any attributes. diff --git a/homeassistant/components/bom/__init__.py b/homeassistant/components/bom/__init__.py new file mode 100644 index 0000000000000..7b83a5c981bde --- /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 0000000000000..7460b84f73438 --- /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 0000000000000..211d1dafc7bb9 --- /dev/null +++ b/homeassistant/components/bom/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "bom", + "name": "Australian Bureau of Meteorology (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 0000000000000..7d951968cb2be --- /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 0000000000000..2513c7c4c4051 --- /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 0000000000000..47c6f4cf24d0d --- /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 0000000000000..1cb3efdd2ccd1 --- /dev/null +++ b/homeassistant/components/braviatv/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "braviatv", + "name": "Sony Bravia TV", + "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 0000000000000..ef0640c8e8725 --- /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 = f"{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 0000000000000..3f9b5cd4597c1 --- /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 0000000000000..2281cb1cc4d05 --- /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 0000000000000..78738870aaa32 --- /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/brother/.translations/ca.json b/homeassistant/components/brother/.translations/ca.json new file mode 100644 index 0000000000000..62dd1807676f2 --- /dev/null +++ b/homeassistant/components/brother/.translations/ca.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "unsupported_model": "Aquest model d'impressora no \u00e9s compatible." + }, + "error": { + "connection_error": "Error de connexi\u00f3.", + "snmp_error": "El servidor SNMP s'ha tancat o la impressora no \u00e9s compatible.", + "wrong_host": "Nom de l'amfitri\u00f3 o adre\u00e7a IP inv\u00e0lids." + }, + "step": { + "user": { + "data": { + "host": "Nom de l'amfitri\u00f3 o adre\u00e7a IP de la impressora", + "type": "Tipus d'impressora" + }, + "description": "Configura la integraci\u00f3 d'impressora Brother. Si tens problemes amb la configuraci\u00f3, visita: https://www.home-assistant.io/integrations/brother", + "title": "Impressora Brother" + } + }, + "title": "Impressora Brother" + } +} \ No newline at end of file diff --git a/homeassistant/components/brother/.translations/da.json b/homeassistant/components/brother/.translations/da.json new file mode 100644 index 0000000000000..70b5857796bfc --- /dev/null +++ b/homeassistant/components/brother/.translations/da.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "unsupported_model": "Denne printermodel underst\u00f8ttes ikke." + }, + "error": { + "connection_error": "Forbindelsesfejl.", + "snmp_error": "SNMP-server er sl\u00e5et fra, eller printeren underst\u00f8ttes ikke.", + "wrong_host": "Ugyldigt v\u00e6rtsnavn eller IP-adresse." + }, + "step": { + "user": { + "data": { + "host": "Printerens v\u00e6rtsnavn eller IP-adresse", + "type": "Type af printer" + }, + "description": "Konfigurer Brother-printerintegration. Hvis du har problemer med konfiguration, kan du g\u00e5 til: https://www.home-assistant.io/integrations/brother", + "title": "Brother-printer" + } + }, + "title": "Brother-printer" + } +} \ No newline at end of file diff --git a/homeassistant/components/brother/.translations/en.json b/homeassistant/components/brother/.translations/en.json new file mode 100644 index 0000000000000..b9b3bd5565182 --- /dev/null +++ b/homeassistant/components/brother/.translations/en.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "unsupported_model": "This printer model is not supported." + }, + "error": { + "connection_error": "Connection error.", + "snmp_error": "SNMP server turned off or printer not supported.", + "wrong_host": "Invalid hostname or IP address." + }, + "step": { + "user": { + "data": { + "host": "Printer hostname or IP address", + "type": "Type of the printer" + }, + "description": "Set up Brother printer integration. If you have problems with configuration go to: https://www.home-assistant.io/integrations/brother", + "title": "Brother Printer" + } + }, + "title": "Brother Printer" + } +} \ No newline at end of file diff --git a/homeassistant/components/brother/.translations/ko.json b/homeassistant/components/brother/.translations/ko.json new file mode 100644 index 0000000000000..4d2e213cbeeb1 --- /dev/null +++ b/homeassistant/components/brother/.translations/ko.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "unsupported_model": "\uc774 \ud504\ub9b0\ud130 \ubaa8\ub378\uc740 \uc9c0\uc6d0\ub418\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4." + }, + "error": { + "connection_error": "\uc5f0\uacb0 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4.", + "snmp_error": "SNMP \uc11c\ubc84\uac00 \uaebc\uc838 \uc788\uac70\ub098 \uc9c0\uc6d0\ub418\uc9c0 \uc54a\ub294 \ud504\ub9b0\ud130\uc785\ub2c8\ub2e4.", + "wrong_host": "\ud638\uc2a4\ud2b8 \uc774\ub984 \ub610\ub294 IP \uc8fc\uc18c\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + }, + "step": { + "user": { + "data": { + "host": "\ud504\ub9b0\ud130 \ud638\uc2a4\ud2b8 \uc774\ub984 \ub610\ub294 IP \uc8fc\uc18c", + "type": "\ud504\ub9b0\ud130\uc758 \uc885\ub958" + }, + "description": "\ube0c\ub77c\ub354 \ud504\ub9b0\ud130 \ud1b5\ud569 \uad6c\uc131\uc694\uc18c\ub97c \uc124\uc815\ud569\ub2c8\ub2e4. \uad6c\uc131\uc5d0 \ubb38\uc81c\uac00\uc788\ub294 \uacbd\uc6b0 https://www.home-assistant.io/integrations/brother \ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694", + "title": "\ube0c\ub77c\ub354 \ud504\ub9b0\ud130" + } + }, + "title": "\ube0c\ub77c\ub354 \ud504\ub9b0\ud130" + } +} \ No newline at end of file diff --git a/homeassistant/components/brother/.translations/no.json b/homeassistant/components/brother/.translations/no.json new file mode 100644 index 0000000000000..57cfd03d21623 --- /dev/null +++ b/homeassistant/components/brother/.translations/no.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "unsupported_model": "Denne skrivermodellen er ikke st\u00f8ttet." + }, + "error": { + "connection_error": "Tilkoblingen mislyktes.", + "snmp_error": "SNMP verten er skrudd av eller printeren er ikke st\u00f8ttet.", + "wrong_host": "Ugyldig vertsnavn eller IP-adresse." + }, + "step": { + "user": { + "data": { + "host": "Vertsnavn eller IP-adresse til skriveren", + "type": "Skriver type" + }, + "description": "Konfigurer Brother skriver integrasjonen. Hvis du har problemer med konfigurasjonen, bes\u00f8k dokumentasjonen her: https://www.home-assistant.io/integrations/brother", + "title": "Brother skriver" + } + }, + "title": "Brother skriver" + } +} \ No newline at end of file diff --git a/homeassistant/components/brother/.translations/pl.json b/homeassistant/components/brother/.translations/pl.json new file mode 100644 index 0000000000000..658c54354acbb --- /dev/null +++ b/homeassistant/components/brother/.translations/pl.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "unsupported_model": "Ten model drukarki nie jest obs\u0142ugiwany." + }, + "error": { + "connection_error": "B\u0142\u0105d po\u0142\u0105czenia.", + "snmp_error": "Serwer SNMP wy\u0142\u0105czony lub drukarka nie jest obs\u0142ugiwana.", + "wrong_host": "Niepoprawna nazwa hosta lub adres IP drukarki." + }, + "step": { + "user": { + "data": { + "host": "Nazwa hosta lub adres IP drukarki", + "type": "Typ drukarki" + }, + "description": "Konfiguracja integracji drukarek Brother. Je\u015bli masz problemy z konfiguracj\u0105, przejd\u017a na stron\u0119: https://www.home-assistant.io/integrations/brother", + "title": "Drukarka Brother" + } + }, + "title": "Drukarka Brother" + } +} \ No newline at end of file diff --git a/homeassistant/components/brother/.translations/pt-BR.json b/homeassistant/components/brother/.translations/pt-BR.json new file mode 100644 index 0000000000000..454cde320d73e --- /dev/null +++ b/homeassistant/components/brother/.translations/pt-BR.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "unsupported_model": "Este modelo de impressora n\u00e3o \u00e9 suportado." + }, + "error": { + "connection_error": "Erro de conex\u00e3o.", + "snmp_error": "Servidor SNMP desligado ou impressora n\u00e3o suportada.", + "wrong_host": "Nome de host ou endere\u00e7o IP inv\u00e1lido." + }, + "step": { + "user": { + "data": { + "host": "Nome do host ou endere\u00e7o IP da impressora", + "type": "Tipo de impressora" + }, + "description": "Configure a integra\u00e7\u00e3o da impressora Brother. Se voc\u00ea tiver problemas com a configura\u00e7\u00e3o, acesse: https://www.home-assistant.io/integrations/brother", + "title": "Impressora Brother" + } + }, + "title": "Impressora Brother" + } +} \ No newline at end of file diff --git a/homeassistant/components/brother/.translations/ru.json b/homeassistant/components/brother/.translations/ru.json new file mode 100644 index 0000000000000..995ddeec3d493 --- /dev/null +++ b/homeassistant/components/brother/.translations/ru.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "unsupported_model": "\u042d\u0442\u0430 \u043c\u043e\u0434\u0435\u043b\u044c \u043f\u0440\u0438\u043d\u0442\u0435\u0440\u0430 \u043d\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442\u0441\u044f." + }, + "error": { + "connection_error": "\u041e\u0448\u0438\u0431\u043a\u0430 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f.", + "snmp_error": "\u0421\u0435\u0440\u0432\u0435\u0440 SNMP \u0432\u044b\u043a\u043b\u044e\u0447\u0435\u043d \u0438\u043b\u0438 \u043f\u0440\u0438\u043d\u0442\u0435\u0440 \u043d\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442\u0441\u044f.", + "wrong_host": "\u041d\u0435\u0432\u0435\u0440\u043d\u043e\u0435 \u0434\u043e\u043c\u0435\u043d\u043d\u043e\u0435 \u0438\u043c\u044f \u0438\u043b\u0438 IP-\u0430\u0434\u0440\u0435\u0441." + }, + "step": { + "user": { + "data": { + "host": "\u0414\u043e\u043c\u0435\u043d\u043d\u043e\u0435 \u0438\u043c\u044f \u0438\u043b\u0438 IP-\u0430\u0434\u0440\u0435\u0441", + "type": "\u0422\u0438\u043f \u043f\u0440\u0438\u043d\u0442\u0435\u0440\u0430" + }, + "description": "\u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 \u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u0435\u0439 \u043f\u043e \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0435 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438: https://www.home-assistant.io/integrations/brother.", + "title": "\u041f\u0440\u0438\u043d\u0442\u0435\u0440 Brother" + } + }, + "title": "\u041f\u0440\u0438\u043d\u0442\u0435\u0440 Brother" + } +} \ No newline at end of file diff --git a/homeassistant/components/brother/.translations/zh-Hant.json b/homeassistant/components/brother/.translations/zh-Hant.json new file mode 100644 index 0000000000000..0ee27bf77d411 --- /dev/null +++ b/homeassistant/components/brother/.translations/zh-Hant.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "unsupported_model": "\u4e0d\u652f\u63f4\u6b64\u6b3e\u5370\u8868\u6a5f\u3002" + }, + "error": { + "connection_error": "\u9023\u7dda\u932f\u8aa4\u3002", + "snmp_error": "SNMP \u4f3a\u670d\u5668\u70ba\u95dc\u9589\u72c0\u614b\u6216\u5370\u8868\u6a5f\u4e0d\u652f\u63f4\u3002", + "wrong_host": "\u7121\u6548\u4e3b\u6a5f\u540d\u6216 IP \u4f4d\u5740" + }, + "step": { + "user": { + "data": { + "host": "\u5370\u8868\u6a5f\u4e3b\u6a5f\u540d\u6216 IP \u4f4d\u5740", + "type": "\u5370\u8868\u6a5f\u985e\u578b" + }, + "description": "\u8a2d\u5b9a Brother \u5370\u8868\u6a5f\u6574\u5408\u3002\u5047\u5982\u9700\u8981\u5354\u52a9\uff0c\u8acb\u53c3\u8003\uff1ahttps://www.home-assistant.io/integrations/brother", + "title": "Brother \u5370\u8868\u6a5f" + } + }, + "title": "Brother \u5370\u8868\u6a5f" + } +} \ No newline at end of file diff --git a/homeassistant/components/brother/__init__.py b/homeassistant/components/brother/__init__.py new file mode 100644 index 0000000000000..ada740c5f1033 --- /dev/null +++ b/homeassistant/components/brother/__init__.py @@ -0,0 +1,102 @@ +"""The Brother component.""" +import asyncio +from datetime import timedelta +import logging + +from brother import Brother, SnmpError, UnsupportedModel + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_TYPE +from homeassistant.core import Config, HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.util import Throttle + +from .const import DOMAIN + +PLATFORMS = ["sensor"] + +DEFAULT_SCAN_INTERVAL = timedelta(seconds=30) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistant, config: Config): + """Set up the Brother component.""" + hass.data[DOMAIN] = {} + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Brother from a config entry.""" + host = entry.data[CONF_HOST] + kind = entry.data[CONF_TYPE] + + brother = BrotherPrinterData(host, kind) + + await brother.async_update() + + if not brother.available: + raise ConfigEntryNotReady() + + hass.data[DOMAIN][entry.entry_id] = brother + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +class BrotherPrinterData: + """Define an object to hold sensor data.""" + + def __init__(self, host, kind): + """Initialize.""" + self._brother = Brother(host, kind=kind) + self.host = host + self.model = None + self.serial = None + self.firmware = None + self.available = False + self.data = {} + self.unavailable_logged = False + + @Throttle(DEFAULT_SCAN_INTERVAL) + async def async_update(self): + """Update data via library.""" + try: + await self._brother.async_update() + except (ConnectionError, SnmpError, UnsupportedModel) as error: + if not self.unavailable_logged: + _LOGGER.error( + "Could not fetch data from %s, error: %s", self.host, error + ) + self.unavailable_logged = True + self.available = self._brother.available + return + + self.model = self._brother.model + self.serial = self._brother.serial + self.firmware = self._brother.firmware + self.available = self._brother.available + self.data = self._brother.data + if self.available and self.unavailable_logged: + _LOGGER.info("Printer %s is available again", self.host) + self.unavailable_logged = False diff --git a/homeassistant/components/brother/config_flow.py b/homeassistant/components/brother/config_flow.py new file mode 100644 index 0000000000000..b95469977a7ff --- /dev/null +++ b/homeassistant/components/brother/config_flow.py @@ -0,0 +1,69 @@ +"""Adds config flow for Brother Printer.""" +import ipaddress +import re + +from brother import Brother, SnmpError, UnsupportedModel +import voluptuous as vol + +from homeassistant import config_entries, exceptions +from homeassistant.const import CONF_HOST, CONF_TYPE + +from .const import DOMAIN, PRINTER_TYPES # pylint:disable=unused-import + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST, default=""): str, + vol.Optional(CONF_TYPE, default="laser"): vol.In(PRINTER_TYPES), + } +) + + +def host_valid(host): + """Return True if hostname or IP address is valid.""" + try: + if ipaddress.ip_address(host).version == (4 or 6): + return True + except ValueError: + disallowed = re.compile(r"[^a-zA-Z\d\-]") + return all(x and not disallowed.search(x) for x in host.split(".")) + + +class BrotherConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Brother Printer.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + + if user_input is not None: + try: + if not host_valid(user_input[CONF_HOST]): + raise InvalidHost() + + brother = Brother(user_input[CONF_HOST]) + await brother.async_update() + + await self.async_set_unique_id(brother.serial.lower()) + self._abort_if_unique_id_configured() + + title = f"{brother.model} {brother.serial}" + return self.async_create_entry(title=title, data=user_input) + except InvalidHost: + errors[CONF_HOST] = "wrong_host" + except ConnectionError: + errors["base"] = "connection_error" + except SnmpError: + errors["base"] = "snmp_error" + except UnsupportedModel: + return self.async_abort(reason="unsupported_model") + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + +class InvalidHost(exceptions.HomeAssistantError): + """Error to indicate that hostname/IP address is invalid.""" diff --git a/homeassistant/components/brother/const.py b/homeassistant/components/brother/const.py new file mode 100644 index 0000000000000..fdb7cd82b9cae --- /dev/null +++ b/homeassistant/components/brother/const.py @@ -0,0 +1,132 @@ +"""Constants for Brother integration.""" +ATTR_BELT_UNIT_REMAINING_LIFE = "belt_unit_remaining_life" +ATTR_BLACK_INK_REMAINING = "black_ink_remaining" +ATTR_BLACK_TONER_REMAINING = "black_toner_remaining" +ATTR_BW_COUNTER = "b/w_counter" +ATTR_COLOR_COUNTER = "color_counter" +ATTR_CYAN_INK_REMAINING = "cyan_ink_remaining" +ATTR_CYAN_TONER_REMAINING = "cyan_toner_remaining" +ATTR_DRUM_COUNTER = "drum_counter" +ATTR_DRUM_REMAINING_LIFE = "drum_remaining_life" +ATTR_DRUM_REMAINING_PAGES = "drum_remaining_pages" +ATTR_FUSER_REMAINING_LIFE = "fuser_remaining_life" +ATTR_ICON = "icon" +ATTR_LABEL = "label" +ATTR_LASER_REMAINING_LIFE = "laser_remaining_life" +ATTR_MAGENTA_INK_REMAINING = "magenta_ink_remaining" +ATTR_MAGENTA_TONER_REMAINING = "magenta_toner_remaining" +ATTR_MANUFACTURER = "Brother" +ATTR_PAGE_COUNTER = "page_counter" +ATTR_PF_KIT_1_REMAINING_LIFE = "pf_kit_1_remaining_life" +ATTR_PF_KIT_MP_REMAINING_LIFE = "pf_kit_mp_remaining_life" +ATTR_STATUS = "status" +ATTR_UNIT = "unit" +ATTR_UPTIME = "uptime" +ATTR_YELLOW_INK_REMAINING = "yellow_ink_remaining" +ATTR_YELLOW_TONER_REMAINING = "yellow_toner_remaining" + +DOMAIN = "brother" + +UNIT_PAGES = "p" +UNIT_DAYS = "days" +UNIT_PERCENT = "%" + +PRINTER_TYPES = ["laser", "ink"] + +SENSOR_TYPES = { + ATTR_STATUS: { + ATTR_ICON: "icon:mdi:printer", + ATTR_LABEL: ATTR_STATUS.title(), + ATTR_UNIT: None, + }, + ATTR_PAGE_COUNTER: { + ATTR_ICON: "mdi:file-document-outline", + ATTR_LABEL: ATTR_PAGE_COUNTER.replace("_", " ").title(), + ATTR_UNIT: UNIT_PAGES, + }, + ATTR_BW_COUNTER: { + ATTR_ICON: "mdi:file-document-outline", + ATTR_LABEL: ATTR_BW_COUNTER.replace("_", " ").title(), + ATTR_UNIT: UNIT_PAGES, + }, + ATTR_COLOR_COUNTER: { + ATTR_ICON: "mdi:file-document-outline", + ATTR_LABEL: ATTR_COLOR_COUNTER.replace("_", " ").title(), + ATTR_UNIT: UNIT_PAGES, + }, + ATTR_DRUM_REMAINING_LIFE: { + ATTR_ICON: "mdi:chart-donut", + ATTR_LABEL: ATTR_DRUM_REMAINING_LIFE.replace("_", " ").title(), + ATTR_UNIT: UNIT_PERCENT, + }, + ATTR_BELT_UNIT_REMAINING_LIFE: { + ATTR_ICON: "mdi:current-ac", + ATTR_LABEL: ATTR_BELT_UNIT_REMAINING_LIFE.replace("_", " ").title(), + ATTR_UNIT: UNIT_PERCENT, + }, + ATTR_FUSER_REMAINING_LIFE: { + ATTR_ICON: "mdi:water-outline", + ATTR_LABEL: ATTR_FUSER_REMAINING_LIFE.replace("_", " ").title(), + ATTR_UNIT: UNIT_PERCENT, + }, + ATTR_LASER_REMAINING_LIFE: { + ATTR_ICON: "mdi:spotlight-beam", + ATTR_LABEL: ATTR_LASER_REMAINING_LIFE.replace("_", " ").title(), + ATTR_UNIT: UNIT_PERCENT, + }, + ATTR_PF_KIT_1_REMAINING_LIFE: { + ATTR_ICON: "mdi:printer-3d", + ATTR_LABEL: ATTR_PF_KIT_1_REMAINING_LIFE.replace("_", " ").title(), + ATTR_UNIT: UNIT_PERCENT, + }, + ATTR_PF_KIT_MP_REMAINING_LIFE: { + ATTR_ICON: "mdi:printer-3d", + ATTR_LABEL: ATTR_PF_KIT_MP_REMAINING_LIFE.replace("_", " ").title(), + ATTR_UNIT: UNIT_PERCENT, + }, + ATTR_BLACK_TONER_REMAINING: { + ATTR_ICON: "mdi:printer-3d-nozzle", + ATTR_LABEL: ATTR_BLACK_TONER_REMAINING.replace("_", " ").title(), + ATTR_UNIT: UNIT_PERCENT, + }, + ATTR_CYAN_TONER_REMAINING: { + ATTR_ICON: "mdi:printer-3d-nozzle", + ATTR_LABEL: ATTR_CYAN_TONER_REMAINING.replace("_", " ").title(), + ATTR_UNIT: UNIT_PERCENT, + }, + ATTR_MAGENTA_TONER_REMAINING: { + ATTR_ICON: "mdi:printer-3d-nozzle", + ATTR_LABEL: ATTR_MAGENTA_TONER_REMAINING.replace("_", " ").title(), + ATTR_UNIT: UNIT_PERCENT, + }, + ATTR_YELLOW_TONER_REMAINING: { + ATTR_ICON: "mdi:printer-3d-nozzle", + ATTR_LABEL: ATTR_YELLOW_TONER_REMAINING.replace("_", " ").title(), + ATTR_UNIT: UNIT_PERCENT, + }, + ATTR_BLACK_INK_REMAINING: { + ATTR_ICON: "mdi:printer-3d-nozzle", + ATTR_LABEL: ATTR_BLACK_INK_REMAINING.replace("_", " ").title(), + ATTR_UNIT: UNIT_PERCENT, + }, + ATTR_CYAN_INK_REMAINING: { + ATTR_ICON: "mdi:printer-3d-nozzle", + ATTR_LABEL: ATTR_CYAN_INK_REMAINING.replace("_", " ").title(), + ATTR_UNIT: UNIT_PERCENT, + }, + ATTR_MAGENTA_INK_REMAINING: { + ATTR_ICON: "mdi:printer-3d-nozzle", + ATTR_LABEL: ATTR_MAGENTA_INK_REMAINING.replace("_", " ").title(), + ATTR_UNIT: UNIT_PERCENT, + }, + ATTR_YELLOW_INK_REMAINING: { + ATTR_ICON: "mdi:printer-3d-nozzle", + ATTR_LABEL: ATTR_YELLOW_INK_REMAINING.replace("_", " ").title(), + ATTR_UNIT: UNIT_PERCENT, + }, + ATTR_UPTIME: { + ATTR_ICON: "mdi:timer", + ATTR_LABEL: ATTR_UPTIME.title(), + ATTR_UNIT: UNIT_DAYS, + }, +} diff --git a/homeassistant/components/brother/manifest.json b/homeassistant/components/brother/manifest.json new file mode 100644 index 0000000000000..d080ee4fd6c5c --- /dev/null +++ b/homeassistant/components/brother/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "brother", + "name": "Brother Printer", + "documentation": "https://www.home-assistant.io/integrations/brother", + "dependencies": [], + "codeowners": ["@bieniu"], + "requirements": ["brother==0.1.4"], + "config_flow": true +} diff --git a/homeassistant/components/brother/sensor.py b/homeassistant/components/brother/sensor.py new file mode 100644 index 0000000000000..9ad075f81cd40 --- /dev/null +++ b/homeassistant/components/brother/sensor.py @@ -0,0 +1,104 @@ +"""Support for the Brother service.""" +import logging + +from homeassistant.helpers.entity import Entity + +from .const import ( + ATTR_DRUM_COUNTER, + ATTR_DRUM_REMAINING_LIFE, + ATTR_DRUM_REMAINING_PAGES, + ATTR_ICON, + ATTR_LABEL, + ATTR_MANUFACTURER, + ATTR_UNIT, + DOMAIN, + SENSOR_TYPES, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Add Brother entities from a config_entry.""" + brother = hass.data[DOMAIN][config_entry.entry_id] + + sensors = [] + + name = brother.model + device_info = { + "identifiers": {(DOMAIN, brother.serial)}, + "name": brother.model, + "manufacturer": ATTR_MANUFACTURER, + "model": brother.model, + "sw_version": brother.firmware, + } + + for sensor in SENSOR_TYPES: + if sensor in brother.data: + sensors.append(BrotherPrinterSensor(brother, name, sensor, device_info)) + async_add_entities(sensors, True) + + +class BrotherPrinterSensor(Entity): + """Define an Brother Printer sensor.""" + + def __init__(self, printer, name, kind, device_info): + """Initialize.""" + self.printer = printer + self._name = name + self._device_info = device_info + self._unique_id = f"{self.printer.serial.lower()}_{kind}" + self.kind = kind + self._state = None + self._attrs = {} + + @property + def name(self): + """Return the name.""" + return f"{self._name} {SENSOR_TYPES[self.kind][ATTR_LABEL]}" + + @property + def state(self): + """Return the state.""" + return self._state + + @property + def device_state_attributes(self): + """Return the state attributes.""" + if self.kind == ATTR_DRUM_REMAINING_LIFE: + self._attrs["remaining_pages"] = self.printer.data.get( + ATTR_DRUM_REMAINING_PAGES + ) + self._attrs["counter"] = self.printer.data.get(ATTR_DRUM_COUNTER) + return self._attrs + + @property + def icon(self): + """Return the icon.""" + return SENSOR_TYPES[self.kind][ATTR_ICON] + + @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 self.printer.available + + @property + def device_info(self): + """Return the device info.""" + return self._device_info + + async def async_update(self): + """Update the data from printer.""" + await self.printer.async_update() + + self._state = self.printer.data.get(self.kind) diff --git a/homeassistant/components/brother/strings.json b/homeassistant/components/brother/strings.json new file mode 100644 index 0000000000000..b636b7c02025a --- /dev/null +++ b/homeassistant/components/brother/strings.json @@ -0,0 +1,24 @@ +{ + "config": { + "title": "Brother Printer", + "step": { + "user": { + "title": "Brother Printer", + "description": "Set up Brother printer integration. If you have problems with configuration go to: https://www.home-assistant.io/integrations/brother", + "data": { + "host": "Printer hostname or IP address", + "type": "Type of the printer" + } + } + }, + "error": { + "wrong_host": "Invalid hostname or IP address.", + "connection_error": "Connection error.", + "snmp_error": "SNMP server turned off or printer not supported." + }, + "abort": { + "unsupported_model": "This printer model is not supported.", + "already_configured": "This printer is already configured." + } + } +} diff --git a/homeassistant/components/brottsplatskartan/__init__.py b/homeassistant/components/brottsplatskartan/__init__.py new file mode 100644 index 0000000000000..d519909b290af --- /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 0000000000000..dfc1a385c6b86 --- /dev/null +++ b/homeassistant/components/brottsplatskartan/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..282433aa7a46f --- /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 c5a55afad403a..0000000000000 --- a/homeassistant/components/browser.py +++ /dev/null @@ -1,26 +0,0 @@ -""" -homeassistant.components.browser -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Provides functionality to launch a webbrowser on the host machine. -""" - -DOMAIN = "browser" -DEPENDENCIES = [] - -SERVICE_BROWSE_URL = "browse_url" - - -def setup(hass, config): - """ Listen for browse_url events and open - the url in the default webbrowser. """ - - import webbrowser - - hass.services.register(DOMAIN, SERVICE_BROWSE_URL, - lambda service: - webbrowser.open( - service.data.get( - 'url', 'https://www.google.com'))) - - return True diff --git a/homeassistant/components/browser/__init__.py b/homeassistant/components/browser/__init__.py new file mode 100644 index 0000000000000..fc0e9eccb3a09 --- /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 0000000000000..bb6c5e783fd8d --- /dev/null +++ b/homeassistant/components/browser/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "browser", + "name": "Browser", + "documentation": "https://www.home-assistant.io/integrations/browser", + "requirements": [], + "dependencies": [], + "codeowners": [], + "quality_scale": "internal" +} diff --git a/homeassistant/components/browser/services.yaml b/homeassistant/components/browser/services.yaml new file mode 100644 index 0000000000000..460def22dc144 --- /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 0000000000000..f89d57cdec1ae --- /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 0000000000000..373c33394413c --- /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 0000000000000..4af42fb28de80 --- /dev/null +++ b/homeassistant/components/brunt/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "brunt", + "name": "Brunt Blind Engine", + "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 0000000000000..54816d6556da3 --- /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 0000000000000..32b8e2aa0507c --- /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 0000000000000..fde6dc6e54629 --- /dev/null +++ b/homeassistant/components/bt_home_hub_5/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..07419a2bacd69 --- /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 0000000000000..45b18b963c542 --- /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 0000000000000..0c474584f3647 --- /dev/null +++ b/homeassistant/components/bt_smarthub/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "bt_smarthub", + "name": "BT Smart Hub", + "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 0000000000000..680351f9b817b --- /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 0000000000000..6928879d40595 --- /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 0000000000000..b91d2497d77e3 --- /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 0000000000000..23e282c34bb93 --- /dev/null +++ b/homeassistant/components/buienradar/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..f642fc2e24977 --- /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 Home Assistant 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 0000000000000..37c518cef7a3c --- /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 0000000000000..98cbb2f5e43e4 --- /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 0000000000000..6fe9a8d4d1978 --- /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 0000000000000..ad9dac1f7274f --- /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 0000000000000..2f48fb5fc273a --- /dev/null +++ b/homeassistant/components/caldav/manifest.json @@ -0,0 +1,8 @@ +{ + "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 new file mode 100644 index 0000000000000..53edf48ae8070 --- /dev/null +++ b/homeassistant/components/calendar/__init__.py @@ -0,0 +1,216 @@ +"""Support for Google Calendar event device sensors.""" +from datetime import timedelta +import logging +import re + +from aiohttp import web + +from homeassistant.components import http +from homeassistant.const import STATE_OFF, STATE_ON +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 + +# mypy: allow-untyped-defs, no-check-untyped-defs + +_LOGGER = logging.getLogger(__name__) + +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 = hass.data[DOMAIN] = EntityComponent( + _LOGGER, DOMAIN, hass, SCAN_INTERVAL + ) + + 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 + # hass.components.frontend.async_register_built_in_panel( + # 'calendar', 'calendar', 'hass:calendar') + + await component.async_setup(config) + return True + + +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"])) + + +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.""" + + @property + def event(self): + """Return the next upcoming event.""" + raise NotImplementedError() + + @property + def state_attributes(self): + """Return the entity state attributes.""" + event = self.event + if event is None: + return None + + event = normalize_event(event) + return { + "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.""" + 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 + + now = dt.now() + + if start <= now < end: + return STATE_ON + + return STATE_OFF + + 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" + + def __init__(self, component): + """Initialize calendar view.""" + self.component = component + + 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") + if None in (start, end, entity): + return web.Response(status=400) + try: + start_date = dt.parse_datetime(start) + end_date = dt.parse_datetime(end) + except (ValueError, AttributeError): + return web.Response(status=400) + event_list = await entity.async_get_events( + request.app["hass"], start_date, end_date + ) + return self.json(event_list) + + +class CalendarListView(http.HomeAssistantView): + """View to retrieve calendar list.""" + + url = "/api/calendars" + name = "api:calendars" + + def __init__(self, component): + """Initialize calendar view.""" + self.component = component + + async def get(self, request): + """Retrieve calendar list.""" + hass = request.app["hass"] + calendar_list = [] + + for entity in self.component.entities: + 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"])) diff --git a/homeassistant/components/calendar/manifest.json b/homeassistant/components/calendar/manifest.json new file mode 100644 index 0000000000000..abcff158bfb83 --- /dev/null +++ b/homeassistant/components/calendar/manifest.json @@ -0,0 +1,8 @@ +{ + "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 new file mode 100644 index 0000000000000..d8a0575bcedd2 --- /dev/null +++ b/homeassistant/components/calendar/services.yaml @@ -0,0 +1,2 @@ +# Describes the format for available calendar services + diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py new file mode 100644 index 0000000000000..4fe52a7d16453 --- /dev/null +++ b/homeassistant/components/camera/__init__.py @@ -0,0 +1,712 @@ +"""Component to interface with cameras.""" +import asyncio +import base64 +import collections +from contextlib import suppress +from datetime import timedelta +import hashlib +import logging +from random import SystemRandom + +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.exceptions import HomeAssistantError +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.loader import bind_hass +from homeassistant.setup import async_when_setup + +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_PLAY_STREAM = "play_stream" + +SCAN_INTERVAL = timedelta(seconds=30) +ENTITY_ID_FORMAT = DOMAIN + ".{}" + +ATTR_FILENAME = "filename" +ATTR_MEDIA_PLAYER = "media_player" +ATTR_FORMAT = "format" + +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}" + +TOKEN_CHANGE_INTERVAL = timedelta(minutes=5) +_RND = SystemRandom() + +MIN_STREAM_INTERVAL = 0.5 # seconds + +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_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 +class Image: + """Represent an image.""" + + content_type = attr.ib(type=str) + content = attr.ib(type=bytes) + + +@bind_hass +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() + + if not source: + raise HomeAssistantError( + f"{camera.entity_id} does not support play stream service" + ) + + return request_stream(hass, source, fmt=fmt, keepalive=camera_prefs.preload_stream) + + +@bind_hass +async def async_get_image(hass, entity_id, timeout=10): + """Fetch an image from a camera entity.""" + camera = _get_camera_from_entity_id(hass, entity_id) + + with suppress(asyncio.CancelledError, asyncio.TimeoutError): + 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") + + +@bind_hass +async def async_get_mjpeg_stream(hass, request, entity_id): + """Fetch an mjpeg stream from a camera entity.""" + camera = _get_camera_from_entity_id(hass, entity_id) + + return await camera.handle_async_mjpeg_stream(request) + + +async def async_get_still_stream(request, image_cb, content_type, interval): + """Generate an HTTP MJPEG stream from camera images. + + This method must be run in the event loop. + """ + response = web.StreamResponse() + response.content_type = "multipart/x-mixed-replace; boundary=--frameboundary" + 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" + ) + + last_image = None + + while True: + img_bytes = await image_cb() + if not img_bytes: + break + + if img_bytes != last_image: + await write_to_mjpeg_stream(img_bytes) + + # Chrome seems to always ignore first picture, + # print it twice. + if last_image is None: + await write_to_mjpeg_stream(img_bytes) + last_image = img_bytes + + await asyncio.sleep(interval) + + return response + + +def _get_camera_from_entity_id(hass, entity_id): + """Get camera component from entity_id.""" + component = hass.data.get(DOMAIN) + + if component is None: + raise HomeAssistantError("Camera integration not set up") + + camera = component.get_entity(entity_id) + + if camera is None: + raise HomeAssistantError("Camera not found") + + if not camera.is_on: + 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 + ) + + 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 + ) + 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_create_task(entity.async_update_ha_state()) + + 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" + ) + component.async_register_entity_service( + SERVICE_DISABLE_MOTION, CAMERA_SERVICE_SCHEMA, "async_disable_motion_detection" + ) + component.async_register_entity_service( + SERVICE_TURN_OFF, CAMERA_SERVICE_SCHEMA, "async_turn_off" + ) + component.async_register_entity_service( + SERVICE_TURN_ON, CAMERA_SERVICE_SCHEMA, "async_turn_on" + ) + component.async_register_entity_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 + + +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 Camera(Entity): + """The base class for camera entities.""" + + def __init__(self): + """Initialize a camera.""" + self.is_streaming = False + self.content_type = DEFAULT_CONTENT_TYPE + self.access_tokens: collections.deque = collections.deque([], 2) + self.async_update_token() + + @property + def should_poll(self): + """No need to poll cameras.""" + return False + + @property + def entity_picture(self): + """Return a link to the camera feed as entity picture.""" + return ENTITY_IMAGE_URL.format(self.entity_id, self.access_tokens[-1]) + + @property + def supported_features(self): + """Flag supported features.""" + return 0 + + @property + def is_recording(self): + """Return true if the device is recording.""" + return False + + @property + def brand(self): + """Return the camera brand.""" + return None + + @property + def motion_detection_enabled(self): + """Return the camera motion detection status.""" + return None + + @property + def model(self): + """Return the camera model.""" + return None + + @property + def frame_interval(self): + """Return the interval between frames of the mjpeg stream.""" + return 0.5 + + async def stream_source(self): + """Return the source of the stream.""" + return None + + def camera_image(self): + """Return bytes of camera image.""" + raise NotImplementedError() + + @callback + def async_camera_image(self): + """Return bytes of camera image. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_job(self.camera_image) + + async def handle_async_still_stream(self, request, interval): + """Generate an HTTP MJPEG stream from camera images. + + This method must be run in the event loop. + """ + 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. + + This method can be overridden by camera plaforms to proxy + a direct stream from the camera. + This method must be run in the event loop. + """ + return await self.handle_async_still_stream(request, self.frame_interval) + + @property + def state(self): + """Return the camera state.""" + if self.is_recording: + return STATE_RECORDING + if self.is_streaming: + return STATE_STREAMING + return STATE_IDLE + + @property + def is_on(self): + """Return true if on.""" + return True + + def turn_off(self): + """Turn off camera.""" + raise NotImplementedError() + + @callback + def async_turn_off(self): + """Turn off camera.""" + return self.hass.async_add_job(self.turn_off) + + def turn_on(self): + """Turn off camera.""" + raise NotImplementedError() + + @callback + def async_turn_on(self): + """Turn off camera.""" + return self.hass.async_add_job(self.turn_on) + + def enable_motion_detection(self): + """Enable motion detection in the camera.""" + raise NotImplementedError() + + @callback + def async_enable_motion_detection(self): + """Call the job and enable motion detection.""" + return self.hass.async_add_job(self.enable_motion_detection) + + def disable_motion_detection(self): + """Disable motion detection in camera.""" + raise NotImplementedError() + + @callback + def async_disable_motion_detection(self): + """Call the job and disable motion detection.""" + return self.hass.async_add_job(self.disable_motion_detection) + + @property + def state_attributes(self): + """Return the camera state attributes.""" + attrs = {"access_token": self.access_tokens[-1]} + + if self.model: + attrs["model_name"] = self.model + + if self.brand: + attrs["brand"] = self.brand + + if self.motion_detection_enabled: + attrs["motion_detection"] = self.motion_detection_enabled + + return attrs + + @callback + def async_update_token(self): + """Update the used token.""" + self.access_tokens.append( + hashlib.sha256(_RND.getrandbits(256).to_bytes(32, "little")).hexdigest() + ) + + +class CameraView(HomeAssistantView): + """Base CameraView.""" + + requires_auth = False + + def __init__(self, component): + """Initialize a basic camera view.""" + self.component = component + + async def get(self, request, entity_id): + """Start a GET request.""" + camera = self.component.get_entity(entity_id) + + if camera is None: + raise web.HTTPNotFound() + + 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.") + raise web.HTTPServiceUnavailable() + + return await self.handle(request, camera) + + async def handle(self, request, camera): + """Handle the camera request.""" + raise NotImplementedError() + + +class CameraImageView(CameraView): + """Camera view to serve an 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): + async with async_timeout.timeout(10): + image = await camera.async_camera_image() + + if image: + return web.Response(body=image, content_type=camera.content_type) + + raise web.HTTPInternalServerError() + + +class CameraMjpegStream(CameraView): + """Camera View to serve an MJPEG 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") + if interval is None: + return await camera.handle_async_mjpeg_stream(request) + + try: + # Compose camera stream from stills + interval = float(request.query.get("interval")) + if interval < 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() + + +@websocket_api.async_response +async def websocket_camera_thumbnail(hass, connection, msg): + """Handle get camera thumbnail websocket command. + + Async friendly. + """ + 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" + ) + ) + + +@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): + """Handle snapshot services calls.""" + hass = camera.hass + filename = service.data[ATTR_FILENAME] + filename.hass = hass + + 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) + 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: + img_file.write(image_data) + + try: + 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/const.py b/homeassistant/components/camera/const.py new file mode 100644 index 0000000000000..563f0554f0fa6 --- /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/manifest.json b/homeassistant/components/camera/manifest.json new file mode 100644 index 0000000000000..e3a2400ac8b17 --- /dev/null +++ b/homeassistant/components/camera/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "camera", + "name": "Camera", + "documentation": "https://www.home-assistant.io/integrations/camera", + "requirements": [], + "dependencies": ["http"], + "after_dependencies": ["media_player"], + "codeowners": [], + "quality_scale": "internal" +} diff --git a/homeassistant/components/camera/prefs.py b/homeassistant/components/camera/prefs.py new file mode 100644 index 0000000000000..ae182c62dc638 --- /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/services.yaml b/homeassistant/components/camera/services.yaml new file mode 100644 index 0000000000000..c50e2926a3fdc --- /dev/null +++ b/homeassistant/components/camera/services.yaml @@ -0,0 +1,85 @@ +# Describes the format for available camera services + +turn_off: + description: Turn off camera. + fields: + entity_id: + description: Entity id. + example: 'camera.living_room' + +turn_on: + description: Turn on camera. + fields: + entity_id: + description: Entity id. + example: 'camera.living_room' + +enable_motion_detection: + description: Enable the motion detection in a camera. + fields: + entity_id: + description: Name(s) of entities to enable motion detection. + example: 'camera.living_room_camera' + +disable_motion_detection: + description: Disable the motion detection in a camera. + fields: + entity_id: + description: Name(s) of entities to disable motion detection. + example: 'camera.living_room_camera' + +snapshot: + description: Take a snapshot from a camera. + fields: + entity_id: + description: Name(s) of entities to create snapshots from. + example: 'camera.living_room_camera' + filename: + description: Template of a Filename. Variable is entity_id. + example: '/tmp/snapshot_{{ entity_id }}' + +play_stream: + description: Play camera stream on supported media player. + fields: + entity_id: + 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. + fields: + entity_id: + description: Name(s) of entities to pan, tilt or zoom. + example: 'camera.living_room_camera' + pan: + description: "Direction of pan. Allowed values: LEFT, RIGHT." + example: 'LEFT' + tilt: + description: "Direction of tilt. Allowed values: DOWN, UP." + example: 'DOWN' + zoom: + description: "Zoom. Allowed values: ZOOM_IN, ZOOM_OUT" + example: "ZOOM_IN" diff --git a/homeassistant/components/canary/__init__.py b/homeassistant/components/canary/__init__.py new file mode 100644 index 0000000000000..a8a45f5b94695 --- /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 0000000000000..cceb78743d3ee --- /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 0000000000000..7ed1e62ab8a10 --- /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 0000000000000..e383cb7514b9f --- /dev/null +++ b/homeassistant/components/canary/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..67654c99f3eb8 --- /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 0000000000000..c56bf118dc102 --- /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 new file mode 100644 index 0000000000000..26236397dec77 --- /dev/null +++ b/homeassistant/components/cast/.translations/ca.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "No s'han trobat dispositius de Google Cast a la xarxa.", + "single_instance_allowed": "Nom\u00e9s cal una sola configuraci\u00f3 de Google Cast." + }, + "step": { + "confirm": { + "description": "Vols configurar Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/cs.json b/homeassistant/components/cast/.translations/cs.json new file mode 100644 index 0000000000000..82f063b365f1e --- /dev/null +++ b/homeassistant/components/cast/.translations/cs.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "V s\u00edti nebyly nalezena \u017e\u00e1dn\u00e1 za\u0159\u00edzen\u00ed Google Cast.", + "single_instance_allowed": "Pouze jedin\u00e1 konfigurace Google Cast je nezbytn\u00e1." + }, + "step": { + "confirm": { + "description": "Chcete nastavit Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/da.json b/homeassistant/components/cast/.translations/da.json new file mode 100644 index 0000000000000..5d8ab2362377c --- /dev/null +++ b/homeassistant/components/cast/.translations/da.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Ingen Google Cast enheder kunne findes p\u00e5 netv\u00e6rket.", + "single_instance_allowed": "Det er kun n\u00f8dvendigt med en ops\u00e6tning af Google Cast" + }, + "step": { + "confirm": { + "description": "Vil du ops\u00e6tte Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/de.json b/homeassistant/components/cast/.translations/de.json new file mode 100644 index 0000000000000..ac1ebbeb23653 --- /dev/null +++ b/homeassistant/components/cast/.translations/de.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Keine Google Cast Ger\u00e4te im Netzwerk gefunden.", + "single_instance_allowed": "Nur eine einzige Konfiguration von Google Cast ist notwendig." + }, + "step": { + "confirm": { + "description": "M\u00f6chtest du Google Cast einrichten?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/en.json b/homeassistant/components/cast/.translations/en.json new file mode 100644 index 0000000000000..f908f41e3289e --- /dev/null +++ b/homeassistant/components/cast/.translations/en.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "No Google Cast devices found on the network.", + "single_instance_allowed": "Only a single configuration of Google Cast is necessary." + }, + "step": { + "confirm": { + "description": "Do you want to set up Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/es-419.json b/homeassistant/components/cast/.translations/es-419.json new file mode 100644 index 0000000000000..2f8d4982afdd0 --- /dev/null +++ b/homeassistant/components/cast/.translations/es-419.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "No se encontraron dispositivos Google Cast en la red.", + "single_instance_allowed": "S\u00f3lo es necesaria una \u00fanica configuraci\u00f3n de Google Cast." + }, + "step": { + "confirm": { + "description": "\u00bfDesea configurar Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/es.json b/homeassistant/components/cast/.translations/es.json new file mode 100644 index 0000000000000..6dc41196af56f --- /dev/null +++ b/homeassistant/components/cast/.translations/es.json @@ -0,0 +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 0000000000000..987c54955f20e --- /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/fr.json b/homeassistant/components/cast/.translations/fr.json new file mode 100644 index 0000000000000..99feeb3c89837 --- /dev/null +++ b/homeassistant/components/cast/.translations/fr.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Aucun appareil Google Cast trouv\u00e9 sur le r\u00e9seau.", + "single_instance_allowed": "Une seule configuration de Google Cast est n\u00e9cessaire." + }, + "step": { + "confirm": { + "description": "Voulez-vous configurer Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/he.json b/homeassistant/components/cast/.translations/he.json new file mode 100644 index 0000000000000..40d2514b59ce0 --- /dev/null +++ b/homeassistant/components/cast/.translations/he.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05de\u05db\u05e9\u05d9\u05e8\u05d9 Google Cast \u05d1\u05e8\u05e9\u05ea.", + "single_instance_allowed": "\u05e8\u05e7 \u05d4\u05d2\u05d3\u05e8\u05d4 \u05d0\u05d7\u05ea \u05e9\u05dc Google Cast \u05e0\u05d7\u05d5\u05e6\u05d4." + }, + "step": { + "confirm": { + "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05d2\u05d3\u05d9\u05e8 \u05d0\u05ea Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ 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 0000000000000..91dafab020164 --- /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 new file mode 100644 index 0000000000000..66dc4ea8dd843 --- /dev/null +++ b/homeassistant/components/cast/.translations/hu.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "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": { + "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a Google Cast szolg\u00e1ltat\u00e1st?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/id.json b/homeassistant/components/cast/.translations/id.json new file mode 100644 index 0000000000000..86fb32c0844fa --- /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/it.json b/homeassistant/components/cast/.translations/it.json new file mode 100644 index 0000000000000..21c8e60518e2a --- /dev/null +++ b/homeassistant/components/cast/.translations/it.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nessun dispositivo Google Cast trovato in rete.", + "single_instance_allowed": "\u00c8 necessaria una sola configurazione di Google Cast." + }, + "step": { + "confirm": { + "description": "Vuoi configurare Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/ja.json b/homeassistant/components/cast/.translations/ja.json new file mode 100644 index 0000000000000..25b9c10b2e743 --- /dev/null +++ b/homeassistant/components/cast/.translations/ja.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u4e0a\u306bGoogle Cast\u30c7\u30d0\u30a4\u30b9\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093\u3067\u3057\u305f\u3002" + }, + "step": { + "confirm": { + "description": "Google Cast\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u304b\uff1f", + "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 new file mode 100644 index 0000000000000..f0eebf4b7b9e5 --- /dev/null +++ b/homeassistant/components/cast/.translations/ko.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "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 \uce90\uc2a4\ud2b8\ub97c \uc124\uc815\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "title": "Google \uce90\uc2a4\ud2b8" + } + }, + "title": "Google \uce90\uc2a4\ud2b8" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/lb.json b/homeassistant/components/cast/.translations/lb.json new file mode 100644 index 0000000000000..f1daff8306955 --- /dev/null +++ b/homeassistant/components/cast/.translations/lb.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Keng Google Cast Apparater am Netzwierk fonnt.", + "single_instance_allowed": "N\u00ebmmen eng eenzeg Konfiguratioun vun Google Cast ass n\u00e9ideg." + }, + "step": { + "confirm": { + "description": "Soll Google Cast konfigur\u00e9iert ginn?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/nl.json b/homeassistant/components/cast/.translations/nl.json new file mode 100644 index 0000000000000..91c428770f5fc --- /dev/null +++ b/homeassistant/components/cast/.translations/nl.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Geen Google Cast-apparaten gevonden op het netwerk.", + "single_instance_allowed": "Er is slechts \u00e9\u00e9n configuratie van Google Cast nodig." + }, + "step": { + "confirm": { + "description": "Wilt u Google Cast instellen?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ 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 0000000000000..7f55015565892 --- /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 new file mode 100644 index 0000000000000..6b8166f23c0cd --- /dev/null +++ b/homeassistant/components/cast/.translations/no.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Ingen Google Cast enheter funnet p\u00e5 nettverket.", + "single_instance_allowed": "Kun en konfigurasjon av Google Cast er n\u00f8dvendig." + }, + "step": { + "confirm": { + "description": "\u00d8nsker du \u00e5 sette opp Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/pl.json b/homeassistant/components/cast/.translations/pl.json new file mode 100644 index 0000000000000..c4399f95defe8 --- /dev/null +++ b/homeassistant/components/cast/.translations/pl.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nie znaleziono w sieci urz\u0105dze\u0144 Google Cast.", + "single_instance_allowed": "Wymagana jest tylko jedna konfiguracja Google Cast." + }, + "step": { + "confirm": { + "description": "Czy chcesz skonfigurowa\u0107 Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/pt-BR.json b/homeassistant/components/cast/.translations/pt-BR.json new file mode 100644 index 0000000000000..e26a829480c6b --- /dev/null +++ b/homeassistant/components/cast/.translations/pt-BR.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nenhum dispositivo Google Cast encontrado na rede.", + "single_instance_allowed": "Apenas uma \u00fanica configura\u00e7\u00e3o do Google Cast \u00e9 necess\u00e1ria." + }, + "step": { + "confirm": { + "description": "Deseja configurar o Google Cast?", + "title": "Google Cast" + } + }, + "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 new file mode 100644 index 0000000000000..85d1b14484d17 --- /dev/null +++ b/homeassistant/components/cast/.translations/pt.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nenhum dispositivo Google Cast descoberto na rede.", + "single_instance_allowed": "Apenas uma \u00fanica configura\u00e7\u00e3o do Google Cast \u00e9 necess\u00e1ria." + }, + "step": { + "confirm": { + "description": "Deseja configurar o Google Cast?", + "title": "Google Cast" + } + }, + "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 0000000000000..8a1d19c0ecf56 --- /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 new file mode 100644 index 0000000000000..da03eae701dd9 --- /dev/null +++ b/homeassistant/components/cast/.translations/ru.json @@ -0,0 +1,15 @@ +{ + "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\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 Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/sl.json b/homeassistant/components/cast/.translations/sl.json new file mode 100644 index 0000000000000..24a7215574dbd --- /dev/null +++ b/homeassistant/components/cast/.translations/sl.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "V omre\u017eju niso najdene naprave Google Cast.", + "single_instance_allowed": "Potrebna je samo ena konfiguracija Google Cast-a." + }, + "step": { + "confirm": { + "description": "Ali \u017eelite nastaviti Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/sv.json b/homeassistant/components/cast/.translations/sv.json new file mode 100644 index 0000000000000..aea55058d108f --- /dev/null +++ b/homeassistant/components/cast/.translations/sv.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Inga Google Cast-enheter hittades i n\u00e4tverket.", + "single_instance_allowed": "Endast en enda konfiguration av Google Cast \u00e4r n\u00f6dv\u00e4ndig." + }, + "step": { + "confirm": { + "description": "Vill du konfigurera Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/th.json b/homeassistant/components/cast/.translations/th.json new file mode 100644 index 0000000000000..372a9cf0760df --- /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 0000000000000..783defdca258a --- /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/.translations/vi.json b/homeassistant/components/cast/.translations/vi.json new file mode 100644 index 0000000000000..2f2982293cfda --- /dev/null +++ b/homeassistant/components/cast/.translations/vi.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Kh\u00f4ng t\u00ecm th\u1ea5y thi\u1ebft b\u1ecb Google Cast n\u00e0o tr\u00ean m\u1ea1ng.", + "single_instance_allowed": "Ch\u1ec9 c\u1ea7n m\u1ed9t c\u1ea5u h\u00ecnh duy nh\u1ea5t c\u1ee7a Google Cast l\u00e0 \u0111\u1ee7." + }, + "step": { + "confirm": { + "description": "B\u1ea1n c\u00f3 mu\u1ed1n thi\u1ebft l\u1eadp Google Cast kh\u00f4ng?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/zh-Hans.json b/homeassistant/components/cast/.translations/zh-Hans.json new file mode 100644 index 0000000000000..d4f1cf4c1a590 --- /dev/null +++ b/homeassistant/components/cast/.translations/zh-Hans.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u6ca1\u6709\u5728\u7f51\u7edc\u4e0a\u627e\u5230 Google Cast \u8bbe\u5907\u3002", + "single_instance_allowed": "Google Cast \u53ea\u9700\u8981\u914d\u7f6e\u4e00\u6b21\u3002" + }, + "step": { + "confirm": { + "description": "\u60a8\u60f3\u8981\u914d\u7f6e Google Cast \u5417\uff1f", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/zh-Hant.json b/homeassistant/components/cast/.translations/zh-Hant.json new file mode 100644 index 0000000000000..711ac3203978c --- /dev/null +++ b/homeassistant/components/cast/.translations/zh-Hant.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u5728\u7db2\u8def\u4e0a\u627e\u4e0d\u5230 Google Cast \u8a2d\u5099\u3002", + "single_instance_allowed": "\u50c5\u9700\u8a2d\u5b9a\u4e00\u6b21 Google Cast \u5373\u53ef\u3002" + }, + "step": { + "confirm": { + "description": "\u662f\u5426\u8981\u8a2d\u5b9a Google Cast\uff1f", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/__init__.py b/homeassistant/components/cast/__init__.py new file mode 100644 index 0000000000000..4dfb58ef3b7d6 --- /dev/null +++ b/homeassistant/components/cast/__init__.py @@ -0,0 +1,31 @@ +"""Component to embed Google Cast.""" +from homeassistant import config_entries + +from . import home_assistant_cast +from .const import DOMAIN + + +async def async_setup(hass, config): + """Set up the Cast component.""" + conf = config.get(DOMAIN) + + 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} + ) + ) + + return True + + +async def async_setup_entry(hass, entry: config_entries.ConfigEntry): + """Set up Cast from a config entry.""" + 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 diff --git a/homeassistant/components/cast/config_flow.py b/homeassistant/components/cast/config_flow.py new file mode 100644 index 0000000000000..5c2b6dca9325a --- /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 0000000000000..c6164484dbbb1 --- /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 0000000000000..54f165889afe1 --- /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 0000000000000..e82f6c9e4ed18 --- /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 0000000000000..0b8633e191626 --- /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 0000000000000..c6db2d897e45e --- /dev/null +++ b/homeassistant/components/cast/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "cast", + "name": "Google 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 0000000000000..0317413450203 --- /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 0000000000000..24bc7b16a903c --- /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/cast/strings.json b/homeassistant/components/cast/strings.json new file mode 100644 index 0000000000000..eecdecbfdf9e5 --- /dev/null +++ b/homeassistant/components/cast/strings.json @@ -0,0 +1,15 @@ +{ + "config": { + "title": "Google Cast", + "step": { + "confirm": { + "title": "Google Cast", + "description": "Do you want to set up Google Cast?" + } + }, + "abort": { + "single_instance_allowed": "Only a single configuration of Google Cast is necessary.", + "no_devices_found": "No Google Cast devices found on the network." + } + } +} diff --git a/homeassistant/components/cert_expiry/.translations/bg.json b/homeassistant/components/cert_expiry/.translations/bg.json new file mode 100644 index 0000000000000..a4a36cb04dcaa --- /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 0000000000000..f1df9a06be1c1 --- /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 0000000000000..58a5a281ea275 --- /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 0000000000000..26ee436860aad --- /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": "Certifikatets udl\u00f8bsdato" + } +} \ 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 0000000000000..4df2ebe4fd99b --- /dev/null +++ b/homeassistant/components/cert_expiry/.translations/de.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "host_port_exists": "Diese Kombination aus Host und Port ist bereits konfiguriert." + }, + "error": { + "certificate_error": "Zertifikat konnte nicht validiert werden", + "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", + "wrong_host": "Zertifikat stimmt nicht mit Hostname \u00fcberein" + }, + "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 0000000000000..19e237a6d0527 --- /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 0000000000000..392dbf35f5ae1 --- /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 0000000000000..4432edac56313 --- /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 0000000000000..9e7df5564a21d --- /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 0000000000000..584f4c2b75961 --- /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 0000000000000..d95b9cd84a16c --- /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 0000000000000..25c518f8629a5 --- /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 0000000000000..14d12967a384c --- /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 0000000000000..0544c8c02c141 --- /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 0000000000000..fc2e98b725d0c --- /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 0000000000000..671cbfcd1ffd5 --- /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 0000000000000..06534314e0099 --- /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 0000000000000..8c0f230382a38 --- /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 0000000000000..d375c626c667b --- /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 0000000000000..07affc990a817 --- /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 0000000000000..c710deae5c1ea --- /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 0000000000000..28a79a3e5058a --- /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 0000000000000..14532aea65f9b --- /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 0000000000000..4129781f2a0fb --- /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 0000000000000..cd49588ec89f3 --- /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 0000000000000..dc26006d711d2 --- /dev/null +++ b/homeassistant/components/cert_expiry/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "cert_expiry", + "name": "Certificate 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 0000000000000..3a76575dfddd3 --- /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 0000000000000..e5e670d214fc4 --- /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 0000000000000..70a8c0677deb8 --- /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 0000000000000..5ae7fdebb0b19 --- /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 0000000000000..3a61d0636bca9 --- /dev/null +++ b/homeassistant/components/channels/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..e4acc2f907c86 --- /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 0000000000000..cbb1dd201a67b --- /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 0000000000000..77e7c6621eb68 --- /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 0000000000000..5a42ef1c8b80b --- /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 0000000000000..0cdcddb56df87 --- /dev/null +++ b/homeassistant/components/cisco_ios/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..625a71a5b0577 --- /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 0000000000000..db504e3d19b60 --- /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 0000000000000..4c83116747ba2 --- /dev/null +++ b/homeassistant/components/cisco_mobility_express/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..0a8714806a18f --- /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 0000000000000..c9d6d14c10936 --- /dev/null +++ b/homeassistant/components/cisco_webex_teams/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..7be53d1fb6cd1 --- /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 0000000000000..f872a0257f7dd --- /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 0000000000000..4fd87a8a5e434 --- /dev/null +++ b/homeassistant/components/ciscospark/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "ciscospark", + "name": "Cisco Spark", + "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 0000000000000..e765aff05f63a --- /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 0000000000000..03ae63eb03418 --- /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 0000000000000..488997378ef92 --- /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 0000000000000..8e0b883b72693 --- /dev/null +++ b/homeassistant/components/citybikes/sensor.py @@ -0,0 +1,312 @@ +"""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 0000000000000..668ba937345f4 --- /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 0000000000000..dadb28c0392b2 --- /dev/null +++ b/homeassistant/components/clementine/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "clementine", + "name": "Clementine Music Player", + "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 0000000000000..9e05b831359fd --- /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 0000000000000..6c39bc749ada3 --- /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 0000000000000..a10da6e1cc0ca --- /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 0000000000000..d59a553a4f62f --- /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 0000000000000..3037224b9a117 --- /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 0000000000000..18f048d1efc97 --- /dev/null +++ b/homeassistant/components/clicksend/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "clicksend", + "name": "ClickSend SMS", + "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 0000000000000..42136e9a09cb5 --- /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 0000000000000..53b59309701b2 --- /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 0000000000000..75b9ec2619fe1 --- /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 0000000000000..400e72a7d0cb8 --- /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 0000000000000..ac1b05b096a6c --- /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 0000000000000..bde91c26b7ed4 --- /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/da.json b/homeassistant/components/climate/.translations/da.json new file mode 100644 index 0000000000000..78731dd1577b1 --- /dev/null +++ b/homeassistant/components/climate/.translations/da.json @@ -0,0 +1,17 @@ +{ + "device_automation": { + "action_type": { + "set_hvac_mode": "Skift af klimaanl\u00e6gstilstand p\u00e5 {entity_name}", + "set_preset_mode": "Skift af forudindstilling p\u00e5 {entity_name}" + }, + "condition_type": { + "is_hvac_mode": "{entity_name} er indstillet til en bestemt klimaanl\u00e6gstilstand", + "is_preset_mode": "{entity_name} er indstillet til en bestemt forudindstillet tilstand" + }, + "trigger_type": { + "current_humidity_changed": "{entity_name} m\u00e5lte luftfugtighed \u00e6ndret", + "current_temperature_changed": "{entity_name} m\u00e5lte temperatur \u00e6ndret", + "hvac_mode_changed": "{entity_name} klimaanl\u00e6gstilstand \u00e6ndret" + } + } +} \ 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 0000000000000..444c2cc460b96 --- /dev/null +++ b/homeassistant/components/climate/.translations/de.json @@ -0,0 +1,17 @@ +{ + "device_automation": { + "action_type": { + "set_hvac_mode": "HVAC-Modus auf {entity_name} \u00e4ndern", + "set_preset_mode": "Voreinstellung von {entity_name} \u00e4ndern" + }, + "condition_type": { + "is_hvac_mode": "{entity_name} ist auf einen bestimmten HVAC-Modus festgelegt", + "is_preset_mode": "{entity_name} ist auf einen bestimmten voreingestellten Modus eingestellt" + }, + "trigger_type": { + "current_humidity_changed": "Gemessene Luftfeuchtigkeit von {entity_name} ge\u00e4ndert", + "current_temperature_changed": "Gemessene Temperatur von {entity_name} ge\u00e4ndert", + "hvac_mode_changed": "{entity_name} HVAC-Modus 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 0000000000000..2a56426e98864 --- /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 0000000000000..e873427e6948c --- /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 0000000000000..0358a60f180bb --- /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 0000000000000..25a09b7d66da0 --- /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/ko.json b/homeassistant/components/climate/.translations/ko.json new file mode 100644 index 0000000000000..299172958e823 --- /dev/null +++ b/homeassistant/components/climate/.translations/ko.json @@ -0,0 +1,17 @@ +{ + "device_automation": { + "action_type": { + "set_hvac_mode": "{entity_name} \uc758 HVAC \ubaa8\ub4dc \ubcc0\uacbd", + "set_preset_mode": "{entity_name} \uc758 \uc0ac\uc804 \uc124\uc815 \ubcc0\uacbd" + }, + "condition_type": { + "is_hvac_mode": "{entity_name} \uc774(\uac00) \ud2b9\uc815 HVAC \ubaa8\ub4dc\ub85c \uc124\uc815\ub418\uc5b4\uc788\uc73c\uba74", + "is_preset_mode": "{entity_name} \uc774(\uac00) \ud2b9\uc815 \uc0ac\uc804 \uc124\uc815 \ubaa8\ub4dc\ub85c \uc124\uc815\ub418\uc5b4\uc788\uc73c\uba74" + }, + "trigger_type": { + "current_humidity_changed": "{entity_name} \uc774(\uac00) \uc2b5\ub3c4 \ubcc0\ud654\ub97c \uac10\uc9c0\ud560 \ub54c", + "current_temperature_changed": "{entity_name} \uc774(\uac00) \uc628\ub3c4 \ubcc0\ud654\ub97c \uac10\uc9c0\ud560 \ub54c", + "hvac_mode_changed": "{entity_name} HVAC \ubaa8\ub4dc\uac00 \ubcc0\uacbd\ub420 \ub54c" + } + } +} \ 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 0000000000000..2b6ca061fd870 --- /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": "{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/nl.json b/homeassistant/components/climate/.translations/nl.json new file mode 100644 index 0000000000000..87e16c1c885a9 --- /dev/null +++ b/homeassistant/components/climate/.translations/nl.json @@ -0,0 +1,17 @@ +{ + "device_automation": { + "action_type": { + "set_hvac_mode": "Wijzig de HVAC-modus op {entity_name}", + "set_preset_mode": "Wijzig voorinstelling op {entity_name}" + }, + "condition_type": { + "is_hvac_mode": "{entity_name} is ingesteld op een specifieke HVAC-modus", + "is_preset_mode": "{entity_name} is ingesteld op een specifieke vooraf ingestelde modus" + }, + "trigger_type": { + "current_humidity_changed": "{entity_name} gemeten vochtigheid veranderd", + "current_temperature_changed": "{entity_name} gemeten temperatuur veranderd", + "hvac_mode_changed": "{entity_name} HVAC-modus gewijzigd" + } + } +} \ 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 0000000000000..bc6e97b9aa50f --- /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 0000000000000..f2a09eee3ef62 --- /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 0000000000000..6a9c52be209b6 --- /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 0000000000000..ecaf24fed80ab --- /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 0000000000000..17e6c955046bf --- /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 new file mode 100644 index 0000000000000..f3aff44ff4d1b --- /dev/null +++ b/homeassistant/components/climate/__init__.py @@ -0,0 +1,536 @@ +"""Provides functionality to interact with climate devices.""" +from abc import abstractmethod +from datetime import timedelta +import functools as ft +import logging +from typing import Any, Dict, List, Optional + +import voluptuous as vol + +from homeassistant.const import ( + 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_HUMIDITY = 30 +DEFAULT_MAX_HUMIDITY = 99 + +ENTITY_ID_FORMAT = DOMAIN + ".{}" +SCAN_INTERVAL = timedelta(seconds=60) + +CONVERTIBLE_ATTRIBUTE = [ATTR_TEMPERATURE, ATTR_TARGET_TEMP_LOW, ATTR_TARGET_TEMP_HIGH] + +_LOGGER = logging.getLogger(__name__) + + +SET_TEMPERATURE_SCHEMA = vol.All( + cv.has_at_least_one_key( + 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), + } + ), +) + + +async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: + """Set up climate devices.""" + 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_HVAC_MODE, + {vol.Required(ATTR_HVAC_MODE): vol.In(HVAC_MODES)}, + "async_set_hvac_mode", + ) + component.async_register_entity_service( + SERVICE_SET_PRESET_MODE, + {vol.Required(ATTR_PRESET_MODE): cv.string}, + "async_set_preset_mode", + ) + component.async_register_entity_service( + 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, + ) + component.async_register_entity_service( + SERVICE_SET_HUMIDITY, + {vol.Required(ATTR_HUMIDITY): vol.Coerce(float)}, + "async_set_humidity", + ) + component.async_register_entity_service( + SERVICE_SET_FAN_MODE, + {vol.Required(ATTR_FAN_MODE): cv.string}, + "async_set_fan_mode", + ) + component.async_register_entity_service( + SERVICE_SET_SWING_MODE, + {vol.Required(ATTR_SWING_MODE): cv.string}, + "async_set_swing_mode", + ) + + return True + + +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: HomeAssistantType, entry): + """Unload a config entry.""" + return await hass.data[DOMAIN].async_unload_entry(entry) + + +class ClimateDevice(Entity): + """Representation of a climate device.""" + + @property + def state(self) -> str: + """Return the current state.""" + return self.hvac_mode + + @property + 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 capability_attributes(self) -> Optional[Dict[str, Any]]: + """Return the capability attributes.""" + supported_features = self.supported_features + data = { + ATTR_HVAC_MODES: self.hvac_modes, + ATTR_MIN_TEMP: show_temp( + self.hass, self.min_temp, self.temperature_unit, self.precision + ), + ATTR_MAX_TEMP: show_temp( + self.hass, self.max_temp, self.temperature_unit, self.precision + ), + } + + if self.target_temperature_step: + data[ATTR_TARGET_TEMP_STEP] = self.target_temperature_step + + if supported_features & SUPPORT_TARGET_HUMIDITY: + data[ATTR_MIN_HUMIDITY] = self.min_humidity + data[ATTR_MAX_HUMIDITY] = self.max_humidity + + if supported_features & SUPPORT_FAN_MODE: + data[ATTR_FAN_MODES] = self.fan_modes + + if supported_features & SUPPORT_PRESET_MODE: + data[ATTR_PRESET_MODES] = self.preset_modes + + if supported_features & SUPPORT_SWING_MODE: + data[ATTR_SWING_MODES] = self.swing_modes + + return data + + @property + def state_attributes(self) -> Dict[str, Any]: + """Return the optional state attributes.""" + supported_features = self.supported_features + data = { + ATTR_CURRENT_TEMPERATURE: show_temp( + self.hass, + self.current_temperature, + 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_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, + ) + + 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 + + if supported_features & SUPPORT_FAN_MODE: + data[ATTR_FAN_MODE] = self.fan_mode + + if self.hvac_action: + data[ATTR_HVAC_ACTION] = self.hvac_action + + if supported_features & SUPPORT_PRESET_MODE: + data[ATTR_PRESET_MODE] = self.preset_mode + + if supported_features & SUPPORT_SWING_MODE: + data[ATTR_SWING_MODE] = self.swing_mode + + if supported_features & SUPPORT_AUX_HEAT: + data[ATTR_AUX_HEAT] = STATE_ON if self.is_aux_heat else STATE_OFF + + return data + + @property + def temperature_unit(self) -> str: + """Return the unit of measurement used by the platform.""" + raise NotImplementedError() + + @property + def current_humidity(self) -> Optional[int]: + """Return the current humidity.""" + return None + + @property + def target_humidity(self) -> Optional[int]: + """Return the humidity we try to reach.""" + return None + + @property + @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 current_temperature(self) -> Optional[float]: + """Return the current temperature.""" + return None + + @property + def target_temperature(self) -> Optional[float]: + """Return the temperature we try to reach.""" + return None + + @property + def target_temperature_step(self) -> Optional[float]: + """Return the supported step of target temperature.""" + return None + + @property + 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) -> Optional[float]: + """Return the lowbound target temperature we try to reach. + + Requires SUPPORT_TARGET_TEMPERATURE_RANGE. + """ + raise NotImplementedError + + @property + def preset_mode(self) -> Optional[str]: + """Return the current preset mode, e.g., home, away, temp. + + Requires SUPPORT_PRESET_MODE. + """ + raise NotImplementedError + + @property + def preset_modes(self) -> Optional[List[str]]: + """Return a list of available preset modes. + + Requires SUPPORT_PRESET_MODE. + """ + raise NotImplementedError + + @property + def is_aux_heat(self) -> Optional[bool]: + """Return true if aux heater. + + Requires SUPPORT_AUX_HEAT. + """ + raise NotImplementedError + + @property + def fan_mode(self) -> Optional[str]: + """Return the fan setting. + + Requires SUPPORT_FAN_MODE. + """ + raise NotImplementedError + + @property + def fan_modes(self) -> Optional[List[str]]: + """Return the list of available fan modes. + + Requires SUPPORT_FAN_MODE. + """ + raise NotImplementedError + + @property + def swing_mode(self) -> Optional[str]: + """Return the swing setting. + + Requires SUPPORT_SWING_MODE. + """ + raise NotImplementedError + + @property + def swing_modes(self) -> Optional[List[str]]: + """Return the list of available swing modes. + + Requires SUPPORT_SWING_MODE. + """ + raise NotImplementedError + + def set_temperature(self, **kwargs) -> None: + """Set new target temperature.""" + raise NotImplementedError() + + 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) + ) + + def set_humidity(self, humidity: int) -> None: + """Set new target humidity.""" + raise NotImplementedError() + + async def async_set_humidity(self, humidity: int) -> None: + """Set new target humidity.""" + await self.hass.async_add_executor_job(self.set_humidity, humidity) + + def set_fan_mode(self, fan_mode: str) -> None: + """Set new target fan mode.""" + raise NotImplementedError() + + 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) + + def set_hvac_mode(self, hvac_mode: str) -> None: + """Set new target hvac mode.""" + raise NotImplementedError() + + 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) + + def set_swing_mode(self, swing_mode: str) -> None: + """Set new target swing operation.""" + raise NotImplementedError() + + 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) + + def set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + raise NotImplementedError() + + 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) + + def turn_aux_heat_on(self) -> None: + """Turn auxiliary heater on.""" + raise NotImplementedError() + + 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) + + def turn_aux_heat_off(self) -> None: + """Turn auxiliary heater off.""" + raise NotImplementedError() + + 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) + + 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 + + # 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 + + 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 + + # 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) -> int: + """Return the list of supported features.""" + raise NotImplementedError() + + @property + def min_temp(self) -> float: + """Return the minimum temperature.""" + return convert_temperature( + DEFAULT_MIN_TEMP, TEMP_CELSIUS, self.temperature_unit + ) + + @property + def max_temp(self) -> float: + """Return the maximum temperature.""" + return convert_temperature( + DEFAULT_MAX_TEMP, TEMP_CELSIUS, self.temperature_unit + ) + + @property + def min_humidity(self) -> int: + """Return the minimum humidity.""" + return DEFAULT_MIN_HUMIDITY + + @property + def max_humidity(self) -> int: + """Return the maximum humidity.""" + return DEFAULT_MAX_HUMIDITY + + +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() + else: + await entity.async_turn_aux_heat_off() + + +async def async_service_temperature_set( + entity: ClimateDevice, service: ServiceDataType +) -> None: + """Handle set temperature service.""" + hass = entity.hass + kwargs = {} + + 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 + ) + else: + kwargs[value] = temp + + await entity.async_set_temperature(**kwargs) diff --git a/homeassistant/components/climate/const.py b/homeassistant/components/climate/const.py new file mode 100644 index 0000000000000..26cec7efbeb65 --- /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/device_action.py b/homeassistant/components/climate/device_action.py new file mode 100644 index 0000000000000..6f7725ac83577 --- /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 0000000000000..cf393a035ec9e --- /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 0000000000000..4c5dcb0ee0439 --- /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/manifest.json b/homeassistant/components/climate/manifest.json new file mode 100644 index 0000000000000..4ac1f55b2b0ce --- /dev/null +++ b/homeassistant/components/climate/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "climate", + "name": "Climate", + "documentation": "https://www.home-assistant.io/integrations/climate", + "requirements": [], + "dependencies": [], + "codeowners": [], + "quality_scale": "internal" +} diff --git a/homeassistant/components/climate/reproduce_state.py b/homeassistant/components/climate/reproduce_state.py new file mode 100644 index 0000000000000..82ca4f4e85c86 --- /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/services.yaml b/homeassistant/components/climate/services.yaml new file mode 100644 index 0000000000000..34e89d5734689 --- /dev/null +++ b/homeassistant/components/climate/services.yaml @@ -0,0 +1,93 @@ +# Describes the format for available climate services + +set_aux_heat: + description: Turn auxiliary heater on/off for climate device. + fields: + entity_id: + description: Name(s) of entities to change. + example: 'climate.kitchen' + aux_heat: + description: New value of axillary heater. + example: true + +set_preset_mode: + description: Set preset mode for climate device. + fields: + entity_id: + description: Name(s) of entities to change. + example: 'climate.kitchen' + preset_mode: + description: New value of preset mode + example: 'away' + +set_temperature: + description: Set target temperature of climate device. + fields: + entity_id: + description: Name(s) of entities to change. + example: 'climate.kitchen' + temperature: + description: New target temperature for HVAC. + example: 25 + target_temp_high: + description: New target high tempereature for HVAC. + example: 26 + target_temp_low: + description: New target low temperature for HVAC. + example: 20 + hvac_mode: + description: HVAC operation mode to set temperature to. + example: 'heat' + +set_humidity: + description: Set target humidity of climate device. + fields: + entity_id: + description: Name(s) of entities to change. + example: 'climate.kitchen' + humidity: + description: New target humidity for climate device. + example: 60 + +set_fan_mode: + description: Set fan operation for climate device. + fields: + entity_id: + description: Name(s) of entities to change. + example: 'climate.nest' + fan_mode: + description: New value of fan mode. + example: On Low + +set_hvac_mode: + description: Set HVAC operation mode for climate device. + fields: + entity_id: + description: Name(s) of entities to change. + example: 'climate.nest' + hvac_mode: + description: New value of operation mode. + example: heat + +set_swing_mode: + description: Set swing operation for climate device. + fields: + entity_id: + description: Name(s) of entities to change. + example: 'climate.nest' + swing_mode: + description: New value of swing mode. + +turn_on: + description: Turn climate device on. + fields: + entity_id: + description: Name(s) of entities to change. + example: 'climate.kitchen' + +turn_off: + description: Turn climate device off. + fields: + entity_id: + description: Name(s) of entities to change. + example: 'climate.kitchen' diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json new file mode 100644 index 0000000000000..ff071aed083d5 --- /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/cloud/__init__.py b/homeassistant/components/cloud/__init__.py new file mode 100644 index 0000000000000..0a0b9f0fe8895 --- /dev/null +++ b/homeassistant/components/cloud/__init__.py @@ -0,0 +1,251 @@ +"""Component to integrate the Home Assistant cloud.""" +import logging + +from hass_nabucasa import Cloud +import voluptuous as vol + +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 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_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__) + +DEFAULT_MODE = MODE_PROD + +SERVICE_REMOTE_CONNECT = "remote_connect" +SERVICE_REMOTE_DISCONNECT = "remote_disconnect" + + +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_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].client.prefs.remote_enabled: + raise CloudNotAvailable + + if not hass.data[DOMAIN].remote.instance_domain: + raise CloudNotAvailable + + return f"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({}) + + # Cloud settings + prefs = CloudPreferences(hass) + await prefs.async_initialize() + + # 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 + ) + + loaded = False + + async def _on_connect(): + """Discover RemoteUI binary sensor.""" + nonlocal loaded + + # Prevent multiple discovery + if loaded: + return + loaded = True + + 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) + ) + + cloud.iot.register_on_connect(_on_connect) + + await http_api.async_setup(hass) + + account_link.async_setup(hass) + + return True diff --git a/homeassistant/components/cloud/account_link.py b/homeassistant/components/cloud/account_link.py new file mode 100644 index 0000000000000..1d0de26918d8f --- /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 0000000000000..45e1fab1101df --- /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/binary_sensor.py b/homeassistant/components/cloud/binary_sensor.py new file mode 100644 index 0000000000000..056105f807101 --- /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 0000000000000..24947ed795216 --- /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 new file mode 100644 index 0000000000000..3d930f0c2e5b3 --- /dev/null +++ b/homeassistant/components/cloud/const.py @@ -0,0 +1,58 @@ +"""Constants for the cloud component.""" +DOMAIN = "cloud" +REQUEST_TIMEOUT = 10 + +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 + +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_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" + +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 0000000000000..1074aaa68b3c4 --- /dev/null +++ b/homeassistant/components/cloud/google_config.py @@ -0,0 +1,167 @@ +"""Google config for Cloud.""" +import asyncio +import logging + +from hass_nabucasa import cloud_api +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 + + async def async_initialize(self): + """Perform async initialization of config.""" + await super().async_initialize() + # Remove bad data that was there until 0.103.6 - Jan 6, 2020 + self._store.pop_agent_user_id(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) + + @property + def agent_user_id(self): + """Return Agent User Id to use for query responses.""" + return self._cloud.username + + def get_agent_user_id(self, context): + """Get agent user ID making request.""" + return self.agent_user_id + + 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 + + async with self._sync_entities_lock: + resp = await cloud_api.async_google_actions_request_sync(self._cloud) + return resp.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 new file mode 100644 index 0000000000000..9afaad422ba23 --- /dev/null +++ b/homeassistant/components/cloud/http_api.py @@ -0,0 +1,608 @@ +"""The HTTP api to control the cloud integration.""" +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.websocket_api import const as ws_const +from homeassistant.core import callback + +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(CloudRegisterView) + hass.http.register_view(CloudResendConfirmView) + hass.http.register_view(CloudForgotPasswordView) + + _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): + """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.""" + try: + result = await handler(view, request, *args, **kwargs) + return result + + 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" + + @_handle_cloud_errors + async def post(self, request): + """Trigger a Google Actions sync.""" + hass = request.app["hass"] + cloud: Cloud = hass.data[DOMAIN] + gconf = await cloud.client.get_google_config() + status = await gconf.async_sync_entities(gconf.agent_user_id) + return self.json({}, status_code=status) + + +class CloudLoginView(HomeAssistantView): + """Login to Home Assistant cloud.""" + + url = "/api/cloud/login" + name = "api:cloud:login" + + @_handle_cloud_errors + @RequestDataValidator( + vol.Schema({vol.Required("email"): str, vol.Required("password"): str}) + ) + async def post(self, request, data): + """Handle login request.""" + hass = request.app["hass"] + cloud = hass.data[DOMAIN] + 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" + + @_handle_cloud_errors + async def post(self, request): + """Handle logout request.""" + hass = request.app["hass"] + cloud = hass.data[DOMAIN] + + with async_timeout.timeout(REQUEST_TIMEOUT): + await cloud.logout() + + return self.json_message("ok") + + +class CloudRegisterView(HomeAssistantView): + """Register on the Home Assistant cloud.""" + + url = "/api/cloud/register" + name = "api:cloud:register" + + @_handle_cloud_errors + @RequestDataValidator( + vol.Schema( + { + vol.Required("email"): str, + vol.Required("password"): vol.All(str, vol.Length(min=6)), + } + ) + ) + async def post(self, request, data): + """Handle registration request.""" + hass = request.app["hass"] + cloud = hass.data[DOMAIN] + + with async_timeout.timeout(REQUEST_TIMEOUT): + await hass.async_add_job( + cloud.auth.register, data["email"], data["password"] + ) + + return self.json_message("ok") + + +class CloudResendConfirmView(HomeAssistantView): + """Resend email confirmation code.""" + + url = "/api/cloud/resend_confirm" + name = "api:cloud:resend_confirm" + + @_handle_cloud_errors + @RequestDataValidator(vol.Schema({vol.Required("email"): str})) + async def post(self, request, data): + """Handle resending confirm email code request.""" + hass = request.app["hass"] + cloud = hass.data[DOMAIN] + + with async_timeout.timeout(REQUEST_TIMEOUT): + await hass.async_add_job(cloud.auth.resend_email_confirm, data["email"]) + + return self.json_message("ok") + + +class CloudForgotPasswordView(HomeAssistantView): + """View to start Forgot Password flow..""" + + url = "/api/cloud/forgot_password" + name = "api:cloud:forgot_password" + + @_handle_cloud_errors + @RequestDataValidator(vol.Schema({vol.Required("email"): str})) + async def post(self, request, data): + """Handle forgot password request.""" + hass = request.app["hass"] + cloud = hass.data[DOMAIN] + + with async_timeout.timeout(REQUEST_TIMEOUT): + await hass.async_add_job(cloud.auth.forgot_password, data["email"]) + + 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 { + "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/manifest.json b/homeassistant/components/cloud/manifest.json new file mode 100644 index 0000000000000..34ef7a6dfa56b --- /dev/null +++ b/homeassistant/components/cloud/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "cloud", + "name": "Home Assistant Cloud", + "documentation": "https://www.home-assistant.io/integrations/cloud", + "requirements": ["hass-nabucasa==0.31"], + "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 0000000000000..a7d1b59fd3988 --- /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 0000000000000..20c25225ce245 --- /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 0000000000000..6c069ce16d777 --- /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: + """Home Assistant 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 0000000000000..ea769c6a054df --- /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 0000000000000..36599b42ad336 --- /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/__init__.py b/homeassistant/components/cloudflare/__init__.py new file mode 100644 index 0000000000000..265621b625068 --- /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 0000000000000..44beaaa213ae8 --- /dev/null +++ b/homeassistant/components/cloudflare/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..23ffdd14d5f03 --- /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 0000000000000..f46f661ece3bf --- /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 0000000000000..22585f7766b00 --- /dev/null +++ b/homeassistant/components/cmus/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..3daf0bac828e8 --- /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 0000000000000..a9c6422b4c635 --- /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 0000000000000..5caab7fe89c8f --- /dev/null +++ b/homeassistant/components/co2signal/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "co2signal", + "name": "CO2 Signal", + "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 0000000000000..7160d140b3f81 --- /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/__init__.py b/homeassistant/components/coinbase/__init__.py new file mode 100644 index 0000000000000..d52c0867e24ce --- /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 0000000000000..dfd0547570347 --- /dev/null +++ b/homeassistant/components/coinbase/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..4a3e85d5e4313 --- /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 0000000000000..0cdb5a16a4aec --- /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 0000000000000..2aa7e64587a6f --- /dev/null +++ b/homeassistant/components/coinmarketcap/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..ca166aa793a1a --- /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 0000000000000..db6c2100e4845 --- /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 0000000000000..27698a7b94a60 --- /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 0000000000000..90830d5223672 --- /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/__init__.py b/homeassistant/components/comfoconnect/__init__.py new file mode 100644 index 0000000000000..f1fd67cc4bbb3 --- /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 0000000000000..432b25ac602b0 --- /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 0000000000000..c55b6895e80df --- /dev/null +++ b/homeassistant/components/comfoconnect/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "comfoconnect", + "name": "Zehnder ComfoAir Q", + "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 0000000000000..3e3507ea48ddc --- /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 0000000000000..fe0640d3efa78 --- /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 0000000000000..eaa371be1a304 --- /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 0000000000000..1d996614caa00 --- /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 0000000000000..9d625ebcc7e25 --- /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 0000000000000..21653171f34d9 --- /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 0000000000000..85ba78ecd989e --- /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 0000000000000..62dcbe2f15a26 --- /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 0000000000000..aec6c38ed5c9b --- /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 0000000000000..81a54a182d46e --- /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 0000000000000..2d119e2cf8601 --- /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 0000000000000..e0060490cfe5e --- /dev/null +++ b/homeassistant/components/concord232/manifest.json @@ -0,0 +1,8 @@ +{ + "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 new file mode 100644 index 0000000000000..5873cdc32712d --- /dev/null +++ b/homeassistant/components/config/__init__.py @@ -0,0 +1,248 @@ +"""Component to configure Home Assistant via an API.""" +import asyncio +import importlib +import os + +import voluptuous as vol + +from homeassistant.components.http import HomeAssistantView +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" +SECTIONS = ( + "area_registry", + "auth", + "auth_provider_homeassistant", + "automation", + "config_entries", + "core", + "customize", + "device_registry", + "entity_registry", + "group", + "script", + "scene", +) +ON_DEMAND = ("zwave",) + + +async def async_setup(hass, config): + """Set up the config component.""" + 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 = importlib.import_module(f".{panel_name}", __name__) + + if not panel: + return + + success = await panel.async_setup(hass) + + if success: + key = f"{DOMAIN}.{panel_name}" + hass.bus.async_fire(EVENT_COMPONENT_LOADED, {ATTR_COMPONENT: 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_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] + + 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) + + return True + + +class BaseEditConfigView(HomeAssistantView): + """Configure a Group endpoint.""" + + def __init__( + self, + component, + config_type, + path, + key_schema, + data_schema, + *, + post_write_hook=None, + data_validator=None, + ): + """Initialize a config view.""" + 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.""" + raise NotImplementedError + + def _get_value(self, hass, data, config_key): + """Get value.""" + raise NotImplementedError + + def _write_value(self, hass, data, config_key, new_value): + """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"] + 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(value) + + async def post(self, request, config_key): + """Validate config and return results.""" + try: + data = await request.json() + except ValueError: + return self.json_message("Invalid JSON specified", 400) + + try: + self.key_schema(config_key) + except vol.Invalid as err: + 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. + 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) + + path = hass.config.path(self.path) + + current = await self.read_config(hass) + self._write_value(hass, current, config_key, data) + + 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 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)) + if not current: + current = self._empty_config() + return current + + +class EditKeyBasedConfigView(BaseEditConfigView): + """Configure a list of entries.""" + + def _empty_config(self): + """Return an empty config.""" + return {} + + def _get_value(self, hass, data, config_key): + """Get value.""" + return data.get(config_key) + + def _write_value(self, hass, data, config_key, new_value): + """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.""" + + def _empty_config(self): + """Return an empty config.""" + return [] + + 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) + + def _write_value(self, hass, data, config_key, new_value): + """Set value.""" + value = self._get_value(hass, data, config_key) + + if value is None: + value = {CONF_ID: config_key} + data.append(value) + + 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.""" + if not os.path.isfile(path): + return None + + return load_yaml(path) + + +def _write(path, data): + """Write YAML helper.""" + # 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: + outfile.write(data) diff --git a/homeassistant/components/config/area_registry.py b/homeassistant/components/config/area_registry.py new file mode 100644 index 0000000000000..81daf35339e88 --- /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 new file mode 100644 index 0000000000000..361367ffb4d52 --- /dev/null +++ b/homeassistant/components/config/auth.py @@ -0,0 +1,134 @@ +"""Offer API to configure Home Assistant auth.""" +import voluptuous as vol + +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_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} +) + + +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 + ) + hass.components.websocket_api.async_register_command( + WS_TYPE_DELETE, websocket_delete, SCHEMA_WS_DELETE + ) + hass.components.websocket_api.async_register_command( + WS_TYPE_CREATE, websocket_create, SCHEMA_WS_CREATE + ) + hass.components.websocket_api.async_register_command(websocket_update) + return True + + +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_list(hass, connection, msg): + """Return a list of users.""" + result = [_user_info(u) for u in await hass.auth.async_get_users()] + + connection.send_message(websocket_api.result_message(msg["id"], result)) + + +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_delete(hass, connection, msg): + """Delete a user.""" + 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"]) + + 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) + + connection.send_message(websocket_api.result_message(msg["id"])) + + +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_create(hass, connection, msg): + """Create a user.""" + user = await hass.auth.async_create_user(msg["name"]) + + connection.send_message( + websocket_api.result_message(msg["id"], {"user": _user_info(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, + "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 new file mode 100644 index 0000000000000..dec7fb24d27f7 --- /dev/null +++ b/homeassistant/components/config/auth_provider_homeassistant.py @@ -0,0 +1,176 @@ +"""Offer API to configure the Home Assistant auth provider.""" +import voluptuous as vol + +from homeassistant.auth.providers import homeassistant as auth_ha +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_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, + } +) + + +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 + ) + hass.components.websocket_api.async_register_command( + 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 + ) + return True + + +def _get_provider(hass): + """Get homeassistant auth provider.""" + for prv in hass.auth.auth_providers: + if prv.type == "homeassistant": + return prv + + raise RuntimeError("Provider not found") + + +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_create(hass, connection, msg): + """Create credentials and attach to a user.""" + provider = _get_provider(hass) + await provider.async_initialize() + + user = await hass.auth.async_get_user(msg["user_id"]) + + 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( + 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( + 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(websocket_api.result_message(msg["id"])) + + +@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 new file mode 100644 index 0000000000000..d7bb1ef988316 --- /dev/null +++ b/homeassistant/components/config/automation.py @@ -0,0 +1,67 @@ +"""Provide configuration end points for Automations.""" +from collections import OrderedDict +import uuid + +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 + +from . import EditIdBasedConfigView + + +async def async_setup(hass): + """Set up the Automation config API.""" + + 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 + + +class EditAutomationConfigView(EditIdBasedConfigView): + """Edit automation config.""" + + def _write_value(self, hass, data, config_key, new_value): + """Set value.""" + index = None + for index, cur_value in enumerate(data): + # When people copy paste their automations to the config file, + # they sometimes forget to add IDs. Fix it here. + if CONF_ID not in cur_value: + cur_value[CONF_ID] = uuid.uuid4().hex + + elif cur_value[CONF_ID] == config_key: + break + else: + cur_value = OrderedDict() + cur_value[CONF_ID] = config_key + index = len(data) + data.append(cur_value) + + # Iterate through some keys that we want to have ordered in the output + updated_value = OrderedDict() + for key in ("id", "alias", "description", "trigger", "condition", "action"): + if key in cur_value: + updated_value[key] = cur_value[key] + if key in new_value: + updated_value[key] = new_value[key] + + # We cover all current fields above, but just in case we start + # supporting more fields in the future. + updated_value.update(cur_value) + updated_value.update(new_value) + data[index] = updated_value diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py new file mode 100644 index 0000000000000..22df26cce4e6c --- /dev/null +++ b/homeassistant/components/config/config_entries.py @@ -0,0 +1,317 @@ +"""Http views to control the config manager.""" +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, +) +from homeassistant.loader import async_get_config_flows + + +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(ConfigManagerAvailableFlowView) + + hass.http.register_view(OptionManagerFlowIndexView(hass.config_entries.options)) + hass.http.register_view(OptionManagerFlowResourceView(hass.config_entries.options)) + + 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: + return result + + data = result.copy() + + schema = data["data_schema"] + if schema is None: + data["data_schema"] = [] + else: + data["data_schema"] = voluptuous_serialize.convert(schema) + + return data + + +class ConfigManagerEntryIndexView(HomeAssistantView): + """View to get available config entries.""" + + url = "/api/config/config_entries/entry" + name = "api:config:config_entries:entry" + + 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" + + async def delete(self, request, entry_id): + """Delete a config entry.""" + if not request["hass_user"].is_admin: + raise Unauthorized(config_entry_id=entry_id, permission="remove") + + hass = request.app["hass"] + + try: + result = await hass.config_entries.async_remove(entry_id) + except config_entries.UnknownEntry: + return self.json_message("Invalid entry specified", 404) + + return self.json(result) + + +class ConfigManagerFlowIndexView(FlowManagerIndexView): + """View to create config flows.""" + + url = "/api/config/config_entries/flow" + name = "api:config:config_entries:flow" + + async def get(self, request): + """Not implemented.""" + raise aiohttp.web_exceptions.HTTPMethodNotAllowed("GET", ["POST"]) + + # 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") + + # 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" + + 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" + + async def get(self, request): + """List available flow handlers.""" + 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 new file mode 100644 index 0000000000000..e9ceb7eac57e4 --- /dev/null +++ b/homeassistant/components/config/core.py @@ -0,0 +1,90 @@ +"""Component to interact with Hassbian tools.""" + +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 + + +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" + + async def post(self, request): + """Validate configuration and return results.""" + errors = await async_check_ha_config_file(request.app["hass"]) + + state = "invalid" if errors else "valid" + + 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 new file mode 100644 index 0000000000000..ed75a8a04a6d1 --- /dev/null +++ b/homeassistant/components/config/customize.py @@ -0,0 +1,43 @@ +"""Provide configuration end points for Customize.""" +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 + +from . import EditKeyBasedConfigView + +CONFIG_PATH = "customize.yaml" + + +async def async_setup(hass): + """Set up the Customize config API.""" + + 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 + + +class CustomizeConfigView(EditKeyBasedConfigView): + """Configure a list of entries.""" + + 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, {})} + + def _write_value(self, hass, data, config_key, new_value): + """Set value.""" + data[config_key] = new_value + + state = hass.states.get(config_key) + state_attributes = dict(state.attributes) + state_attributes.update(new_value) + hass.states.async_set(config_key, state.state, state_attributes) diff --git a/homeassistant/components/config/device_registry.py b/homeassistant/components/config/device_registry.py new file mode 100644 index 0000000000000..08f53f948fe09 --- /dev/null +++ b/homeassistant/components/config/device_registry.py @@ -0,0 +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 + +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 Device Registry views.""" + hass.components.websocket_api.async_register_command( + 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 _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 new file mode 100644 index 0000000000000..458a9dd3ecb74 --- /dev/null +++ b/homeassistant/components/config/entity_registry.py @@ -0,0 +1,155 @@ +"""HTTP views to interact with the entity registry.""" +import voluptuous as vol + +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 +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(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 + + +@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. + """ + registry = await async_get_registry(hass) + connection.send_message( + websocket_api.result_message( + msg["id"], [_entry_dict(entry) for entry in registry.entities.values()] + ) + ) + + +@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. + """ + registry = await async_get_registry(hass) + entry = registry.entities.get(msg["entity_id"]) + + if entry is None: + connection.send_message( + websocket_api.error_message(msg["id"], ERR_NOT_FOUND, "Entity not found") + ) + return + + connection.send_message(websocket_api.result_message(msg["id"], _entry_dict(entry))) + + +@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. + """ + registry = await async_get_registry(hass) + + 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 + + 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)) + ) + + +@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. + + Async friendly. + """ + registry = await async_get_registry(hass) + + 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 { + "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 new file mode 100644 index 0000000000000..d95891af6556c --- /dev/null +++ b/homeassistant/components/config/group.py @@ -0,0 +1,27 @@ +"""Provide configuration end points for Groups.""" +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 + +from . import EditKeyBasedConfigView + + +async def async_setup(hass): + """Set up the Group config API.""" + + 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/manifest.json b/homeassistant/components/config/manifest.json new file mode 100644 index 0000000000000..809db4ffecc7a --- /dev/null +++ b/homeassistant/components/config/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "config", + "name": "Config", + "documentation": "https://www.home-assistant.io/integrations/config", + "requirements": [], + "dependencies": ["http"], + "codeowners": ["@home-assistant/core"], + "quality_scale": "internal" +} diff --git a/homeassistant/components/config/scene.py b/homeassistant/components/config/scene.py new file mode 100644 index 0000000000000..79a30177e470f --- /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 new file mode 100644 index 0000000000000..032774de47343 --- /dev/null +++ b/homeassistant/components/config/script.py @@ -0,0 +1,27 @@ +"""Provide configuration end points for scripts.""" +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 + +from . import EditKeyBasedConfigView + + +async def async_setup(hass): + """Set up the script config API.""" + + 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 0000000000000..e69de29bb2d1d diff --git a/homeassistant/components/config/zwave.py b/homeassistant/components/config/zwave.py new file mode 100644 index 0000000000000..eaed84fe24d9e --- /dev/null +++ b/homeassistant/components/config/zwave.py @@ -0,0 +1,260 @@ +"""Provide configuration end points for Z-Wave.""" +from collections import deque +import logging + +from aiohttp.web import Response + +from homeassistant.components.http import HomeAssistantView +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" + + +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(ZWaveNodeValueView) + hass.http.register_view(ZWaveNodeGroupView) + hass.http.register_view(ZWaveNodeConfigView) + hass.http.register_view(ZWaveUserCodeView) + hass.http.register_view(ZWaveLogView) + hass.http.register_view(ZWaveConfigWriteView) + hass.http.register_view(ZWaveProtectionView) + + return True + + +class ZWaveLogView(HomeAssistantView): + """View to read the ZWave log file.""" + + url = "/api/zwave/ozwlog" + name = "api:zwave:ozwlog" + + # pylint: disable=no-self-use + async def get(self, request): + """Retrieve the lines from ZWave log.""" + try: + lines = int(request.query.get("lines", 0)) + except ValueError: + return Response(text="Invalid datetime", status=400) + + hass = request.app["hass"] + response = await hass.async_add_job(self._get_log, hass, lines) + + return Response(text="\n".join(response)) + + def _get_log(self, hass, lines): + """Retrieve the logfile content.""" + logfilepath = hass.config.path(OZW_LOG_FILENAME) + with open(logfilepath, "r") as logfile: + data = (line.rstrip() for line in logfile) + if lines == 0: + loglines = list(data) + else: + loglines = deque(data, lines) + return loglines + + +class ZWaveConfigWriteView(HomeAssistantView): + """View to save the ZWave configuration to zwcfg_xxxxx.xml.""" + + url = "/api/zwave/saveconfig" + name = "api:zwave:saveconfig" + + @ha.callback + def post(self, request): + """Save cache configuration to zwcfg_xxxxx.xml.""" + hass = request.app["hass"] + network = hass.data.get(const.DATA_NETWORK) + if network is None: + return self.json_message("No Z-Wave network data found", HTTP_NOT_FOUND) + _LOGGER.info("Z-Wave configuration written to file.") + network.write_config() + return self.json_message("Z-Wave configuration saved to file.", HTTP_OK) + + +class ZWaveNodeValueView(HomeAssistantView): + """View to return the node values.""" + + url = r"/api/zwave/values/{node_id:\d+}" + name = "api:zwave:values" + + @ha.callback + def get(self, request, node_id): + """Retrieve groups of node.""" + nodeid = int(node_id) + hass = request.app["hass"] + values_list = hass.data[const.DATA_ENTITY_VALUES] + + values_data = {} + # Return a list of values for this node that are used as a + # primary value for an entity + for entity_values in values_list: + if entity_values.primary.node.node_id != nodeid: + 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, + } + return self.json(values_data) + + +class ZWaveNodeGroupView(HomeAssistantView): + """View to return the nodes group configuration.""" + + url = r"/api/zwave/groups/{node_id:\d+}" + name = "api:zwave:groups" + + @ha.callback + def get(self, request, node_id): + """Retrieve groups of node.""" + nodeid = int(node_id) + 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) + 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, + } + return self.json(groups) + + +class ZWaveNodeConfigView(HomeAssistantView): + """View to return the nodes configuration options.""" + + url = r"/api/zwave/config/{node_id:\d+}" + name = "api:zwave:config" + + @ha.callback + def get(self, request, node_id): + """Retrieve configurations of node.""" + nodeid = int(node_id) + 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) + 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, + } + return self.json(config) + + +class ZWaveUserCodeView(HomeAssistantView): + """View to return the nodes usercode configuration.""" + + url = r"/api/zwave/usercodes/{node_id:\d+}" + name = "api:zwave:usercodes" + + @ha.callback + def get(self, request, node_id): + """Retrieve usercodes of node.""" + nodeid = int(node_id) + 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) + 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(): + if value.genre != const.GENRE_USER: + continue + usercodes[value.index] = { + "code": value.data, + "label": value.label, + "length": len(value.data), + } + return self.json(usercodes) + + +class ZWaveProtectionView(HomeAssistantView): + """View for the protection commandclass of a node.""" + + url = r"/api/zwave/protection/{node_id:\d+}" + name = "api:zwave:protection" + + async def get(self, request, node_id): + """Retrieve the protection commandclass options of node.""" + nodeid = int(node_id) + 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) + 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]), + } + return self.json(protection_options) + + return await hass.async_add_executor_job(_fetch_protection) + + async def post(self, request, node_id): + """Change the selected option in protection commandclass.""" + nodeid = int(node_id) + hass = request.app["hass"] + network = hass.data.get(const.DATA_NETWORK) + protection_data = await request.json() + + def _set_protection(): + """Set protection data.""" + node = network.nodes.get(nodeid) + 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) + if not node.has_command_class(const.COMMAND_CLASS_PROTECTION): + return self.json_message( + "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 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 8bec580abf976..0000000000000 --- a/homeassistant/components/configurator.py +++ /dev/null @@ -1,190 +0,0 @@ -""" -homeassistant.components.configurator -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -A component 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 logging - -from homeassistant.helpers import generate_entity_id -from homeassistant.const import EVENT_TIME_CHANGED - -DOMAIN = "configurator" -DEPENDENCIES = [] -ENTITY_ID_FORMAT = DOMAIN + ".{}" - -SERVICE_CONFIGURE = "configure" - -STATE_CONFIGURE = "configure" -STATE_CONFIGURED = "configured" - -ATTR_CONFIGURE_ID = "configure_id" -ATTR_DESCRIPTION = "description" -ATTR_DESCRIPTION_IMAGE = "description_image" -ATTR_SUBMIT_CAPTION = "submit_caption" -ATTR_FIELDS = "fields" -ATTR_ERRORS = "errors" - -_REQUESTS = {} -_INSTANCES = {} -_LOGGER = logging.getLogger(__name__) - - -# pylint: disable=too-many-arguments -def request_config( - hass, name, callback, description=None, description_image=None, - submit_caption=None, fields=None): - """ Create a new request for config. - Will return an ID to be used for sequent calls. """ - - instance = _get_instance(hass) - - request_id = instance.request_config( - name, callback, - description, description_image, submit_caption, fields) - - _REQUESTS[request_id] = instance - - return request_id - - -def notify_errors(request_id, error): - """ Add errors to a config request. """ - try: - _REQUESTS[request_id].notify_errors(request_id, error) - except KeyError: - # If request_id does not exist - pass - - -def request_done(request_id): - """ Mark a config request as done. """ - try: - _REQUESTS.pop(request_id).request_done(request_id) - except KeyError: - # If request_id does not exist - pass - - -def setup(hass, config): - """ Set up Configurator. """ - return True - - -def _get_instance(hass): - """ Get an instance per hass object. """ - try: - return _INSTANCES[hass] - except KeyError: - _INSTANCES[hass] = Configurator(hass) - - if DOMAIN not in hass.config.components: - hass.config.components.append(DOMAIN) - - return _INSTANCES[hass] - - -class Configurator(object): - """ - Class to keep track of current configuration requests. - """ - - def __init__(self, hass): - self.hass = hass - self._cur_id = 0 - self._requests = {} - hass.services.register( - DOMAIN, SERVICE_CONFIGURE, self.handle_service_call) - - # pylint: disable=too-many-arguments - def request_config( - self, name, callback, - description, description_image, submit_caption, fields): - """ Setup a request for configuration. """ - - entity_id = 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, - } - - data.update({ - key: value for key, value in [ - (ATTR_DESCRIPTION, description), - (ATTR_DESCRIPTION_IMAGE, description_image), - (ATTR_SUBMIT_CAPTION, submit_caption), - ] if value is not None - }) - - self.hass.states.set(entity_id, STATE_CONFIGURE, data) - - return request_id - - def 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 = state.attributes - new_data[ATTR_ERRORS] = error - - self.hass.states.set(entity_id, STATE_CONFIGURE, new_data) - - def request_done(self, request_id): - """ Remove the config 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.set(entity_id, STATE_CONFIGURED) - - def deferred_remove(event): - """ Remove the request state. """ - self.hass.states.remove(entity_id) - - self.hass.bus.listen_once(EVENT_TIME_CHANGED, deferred_remove) - - def 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? - - callback(call.data.get(ATTR_FIELDS, {})) - - def _generate_unique_id(self): - """ Generates 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 0000000000000..78333d96355a8 --- /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 0000000000000..560798874500a --- /dev/null +++ b/homeassistant/components/configurator/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "configurator", + "name": "Configurator", + "documentation": "https://www.home-assistant.io/integrations/configurator", + "requirements": [], + "dependencies": [], + "codeowners": ["@home-assistant/core"], + "quality_scale": "internal" +} diff --git a/homeassistant/components/configurator/services.yaml b/homeassistant/components/configurator/services.yaml new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/homeassistant/components/conversation.py b/homeassistant/components/conversation.py deleted file mode 100644 index bf78e13a09416..0000000000000 --- a/homeassistant/components/conversation.py +++ /dev/null @@ -1,72 +0,0 @@ -""" -homeassistant.components.conversation -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Provides functionality to have conversations with Home Assistant. -This is more a proof of concept. -""" -import logging -import re - -import homeassistant -from homeassistant.const import ( - ATTR_FRIENDLY_NAME, ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF) - -DOMAIN = "conversation" -DEPENDENCIES = [] - -SERVICE_PROCESS = "process" - -ATTR_TEXT = "text" - -REGEX_TURN_COMMAND = re.compile(r'turn (?P(?: |\w)+) (?P\w+)') - - -def setup(hass, config): - """ Registers the process service. """ - logger = logging.getLogger(__name__) - - def process(service): - """ Parses text into commands for Home Assistant. """ - if ATTR_TEXT not in service.data: - logger.error("Received process service call without a text") - return - - text = service.data[ATTR_TEXT].lower() - - match = REGEX_TURN_COMMAND.match(text) - - if not match: - logger.error("Unable to process: %s", text) - return - - name, command = match.groups() - - entity_ids = [ - state.entity_id for state in hass.states.all() - if state.attributes.get(ATTR_FRIENDLY_NAME, "").lower() == name] - - if not entity_ids: - logger.error( - "Could not find entity id %s from text %s", name, text) - return - - if command == 'on': - hass.services.call( - homeassistant.DOMAIN, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: entity_ids, - }, blocking=True) - - elif command == 'off': - hass.services.call( - homeassistant.DOMAIN, SERVICE_TURN_OFF, { - ATTR_ENTITY_ID: entity_ids, - }, blocking=True) - - else: - logger.error( - 'Got unsupported command %s from text %s', command, text) - - hass.services.register(DOMAIN, SERVICE_PROCESS, process) - - return True diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py new file mode 100644 index 0000000000000..158a365981b83 --- /dev/null +++ b/homeassistant/components/conversation/__init__.py @@ -0,0 +1,165 @@ +"""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, 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 .agent import AbstractConversationAgent +from .default_agent import DefaultAgent, async_register + +_LOGGER = logging.getLogger(__name__) + +ATTR_TEXT = "text" + +DOMAIN = "conversation" + +REGEX_TYPE = type(re.compile("")) +DATA_AGENT = "conversation_agent" +DATA_CONFIG = "conversation_config" + +SERVICE_PROCESS = "process" + +SERVICE_PROCESS_SCHEMA = vol.Schema({vol.Required(ATTR_TEXT): cv.string}) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Optional("intents"): vol.Schema( + {cv.string: vol.All(cv.ensure_list, [cv.string])} + ) + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + +async_register = bind_hass(async_register) # pylint: disable=invalid-name + + +@core.callback +@bind_hass +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.""" + hass.data[DATA_CONFIG] = config + + async def handle_service(service): + """Parse text into commands.""" + text = service.data[ATTR_TEXT] + _LOGGER.debug("Processing: <%s>", text) + agent = await _get_agent(hass) + try: + await agent.async_process(text, service.context) + except intent.IntentHandleError as err: + _LOGGER.error("Error processing %s: %s", text, err) + + hass.services.async_register( + 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 + + +@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) + ), + ) + + +@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) + + 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 process text.""" + + url = "/api/conversation/process" + name = "api:conversation:process" + + @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"] + + 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 0000000000000..c9c2ab46cf9fd --- /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 0000000000000..04bfa37306138 --- /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 0000000000000..2f09cba2eb196 --- /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 0000000000000..7e2decb2bffca --- /dev/null +++ b/homeassistant/components/conversation/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "conversation", + "name": "Conversation", + "documentation": "https://www.home-assistant.io/integrations/conversation", + "requirements": [], + "dependencies": ["http"], + "codeowners": ["@home-assistant/core"], + "quality_scale": "internal" +} diff --git a/homeassistant/components/conversation/services.yaml b/homeassistant/components/conversation/services.yaml new file mode 100644 index 0000000000000..a1b980d8e05a3 --- /dev/null +++ b/homeassistant/components/conversation/services.yaml @@ -0,0 +1,10 @@ +# Describes the format for available component services + +process: + description: Launch a conversation from a transcribed text. + fields: + text: + description: Transcribed text + example: Turn all lights on + + diff --git a/homeassistant/components/conversation/util.py b/homeassistant/components/conversation/util.py new file mode 100644 index 0000000000000..4904cb9f990da --- /dev/null +++ b/homeassistant/components/conversation/util.py @@ -0,0 +1,34 @@ +"""Util for Conversation.""" +import re + + +def create_matcher(utterance): + """Create a regex that matches the utterance.""" + # Split utterance into parts that are type: NORMAL, GROUP or OPTIONAL + # Pattern matches (GROUP|OPTIONAL): Change light to [the color] {name} + parts = re.split(r"({\w+}|\[[\w\s]+\] *)", utterance) + # Pattern to extract name from GROUP part. Matches {name} + group_matcher = re.compile(r"{(\w+)}") + # Pattern to extract text from OPTIONAL part. Matches [the color] + optional_matcher = re.compile(r"\[([\w ]+)\] *") + + pattern = ["^"] + for part in parts: + group_match = group_matcher.match(part) + optional_match = optional_matcher.match(part) + + # Normal part + if group_match is None and optional_match is None: + pattern.append(part) + continue + + # Group part + if group_match is not None: + pattern.append(r"(?P<{}>[\w ]+?)\s*".format(group_match.groups()[0])) + + # Optional part + elif optional_match is not None: + pattern.append(r"(?:{} *)?".format(optional_match.groups()[0])) + + pattern.append("$") + return re.compile("".join(pattern), re.I) diff --git a/homeassistant/components/coolmaster/.translations/bg.json b/homeassistant/components/coolmaster/.translations/bg.json new file mode 100644 index 0000000000000..9e484f5d38c07 --- /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 0000000000000..65816e696fe12 --- /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 0000000000000..f1e18f8fcb4bb --- /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 0000000000000..882bc5de35953 --- /dev/null +++ b/homeassistant/components/coolmaster/.translations/da.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "connection_error": "Kunne ikke oprette forbindelse til CoolMasterNet-instansen. Tjek din v\u00e6rt.", + "no_units": "Kunne ikke finde nogen klimaanl\u00e6g i CoolMasterNet-v\u00e6rt." + }, + "step": { + "user": { + "data": { + "cool": "Underst\u00f8tter k\u00f8lingstilstand", + "dry": "Underst\u00f8tter t\u00f8rringstilstand", + "fan_only": "Underst\u00f8tter kun-bl\u00e6ser-tilstand", + "heat": "Underst\u00f8tter varmetilstand", + "heat_cool": "Underst\u00f8tter 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 0000000000000..c312de1493515 --- /dev/null +++ b/homeassistant/components/coolmaster/.translations/de.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "connection_error": "Verbindung zur CoolMasterNet-Instanz fehlgeschlagen. Bitte \u00fcberpr\u00fcfen Sie Ihren Host.", + "no_units": "Es wurden keine HVAC-Ger\u00e4te im CoolMasterNet-Host gefunden." + }, + "step": { + "user": { + "data": { + "cool": "Unterst\u00fctzt K\u00fchl-Modus", + "dry": "Unterst\u00fctzt Trockenmodus", + "fan_only": "Unterst\u00fctzt Fan-Only-Modus", + "heat": "Unterst\u00fctzt Heiz-Modus", + "heat_cool": "Unterst\u00fctzung automatische Heiz-/K\u00fchlmodus", + "host": "Host", + "off": "Kann ausgeschaltet werden" + }, + "title": "Richten Sie Ihre CoolMasterNet-Verbindungsdaten ein." + } + }, + "title": "CoolMasterNet" + } +} \ 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 0000000000000..6c30efc594a23 --- /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 0000000000000..aedd81baccca8 --- /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 0000000000000..97b1753ddded8 --- /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 0000000000000..b543a10d32d1d --- /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 0000000000000..4d96e606c7b5c --- /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 \ub044\uae30 \ud5c8\uc6a9" + }, + "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 0000000000000..ed54abac03e66 --- /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 0000000000000..e5b1683790f29 --- /dev/null +++ b/homeassistant/components/coolmaster/.translations/nl.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "connection_error": "Kan geen verbinding maken met CoolMasterNet-instantie. Controleer uw host", + "no_units": "Kon geen HVAC units vinden in CoolMasterNet host." + }, + "step": { + "user": { + "data": { + "cool": "Ondersteuning afkoelen modus", + "dry": "Ondersteuning droog modus", + "fan_only": "Ondersteunt alleen ventilatormodus", + "heat": "Ondersteuning warmtemodus", + "heat_cool": "Ondersteuning van automatische warmte/koelmodus", + "host": "Host", + "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 0000000000000..90c40aaa617c5 --- /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 0000000000000..118c4bc424b59 --- /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 0000000000000..bb821341818ed --- /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 0000000000000..4c2f74440cd78 --- /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 0000000000000..a59b5215e7fb8 --- /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 0000000000000..bc61e82b98ac1 --- /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 0000000000000..c666c39cfb3bd --- /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 0000000000000..a52431dd89be2 --- /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 0000000000000..e9cef562647ff --- /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 0000000000000..d4cfea738209e --- /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 0000000000000..0041895a290a8 --- /dev/null +++ b/homeassistant/components/coolmaster/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "coolmaster", + "name": "CoolMasterNet", + "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 0000000000000..d309f8c9c9338 --- /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 new file mode 100644 index 0000000000000..5580518a9a38a --- /dev/null +++ b/homeassistant/components/counter/__init__.py @@ -0,0 +1,208 @@ +"""Component to count within automations.""" +import logging + +import voluptuous as vol + +from homeassistant.const import CONF_ICON, CONF_MAXIMUM, CONF_MINIMUM, CONF_NAME +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.restore_state import RestoreEntity + +_LOGGER = logging.getLogger(__name__) + +ATTR_INITIAL = "initial" +ATTR_STEP = "step" +ATTR_MINIMUM = "minimum" +ATTR_MAXIMUM = "maximum" +VALUE = "value" + +CONF_INITIAL = "initial" +CONF_RESTORE = "restore" +CONF_STEP = "step" + +DEFAULT_INITIAL = 0 +DEFAULT_STEP = 1 +DOMAIN = "counter" + +ENTITY_ID_FORMAT = DOMAIN + ".{}" + +SERVICE_DECREMENT = "decrement" +SERVICE_INCREMENT = "increment" +SERVICE_RESET = "reset" +SERVICE_CONFIGURE = "configure" + + +def _none_to_empty_dict(value): + if value is None: + return {} + return value + + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: cv.schema_with_slug_keys( + vol.All( + _none_to_empty_dict, + { + 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, + }, + ) + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, config): + """Set up the counters.""" + component = EntityComponent(_LOGGER, DOMAIN, hass) + + entities = [] + + for object_id, cfg in config[DOMAIN].items(): + if not cfg: + cfg = {} + + name = cfg.get(CONF_NAME) + initial = cfg[CONF_INITIAL] + restore = cfg[CONF_RESTORE] + step = cfg[CONF_STEP] + icon = cfg.get(CONF_ICON) + minimum = cfg[CONF_MINIMUM] + maximum = cfg[CONF_MAXIMUM] + + 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_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(RestoreEntity): + """Representation of a counter.""" + + 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 + def should_poll(self): + """If entity should be polled.""" + return False + + @property + def name(self): + """Return name of the counter.""" + return self._name + + @property + def icon(self): + """Return the icon to be used for this entity.""" + return self._icon + + @property + def state(self): + """Return the current value of the counter.""" + return self._state + + @property + def state_attributes(self): + """Return the state attributes.""" + 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.""" + 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.compute_next_state(self._state - self._step) + await self.async_update_ha_state() + + async def async_increment(self): + """Increment a counter.""" + 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.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 0000000000000..f22c7b252df94 --- /dev/null +++ b/homeassistant/components/counter/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "counter", + "name": "Counter", + "documentation": "https://www.home-assistant.io/integrations/counter", + "requirements": [], + "dependencies": [], + "codeowners": ["@fabaff"], + "quality_scale": "internal" +} diff --git a/homeassistant/components/counter/reproduce_state.py b/homeassistant/components/counter/reproduce_state.py new file mode 100644 index 0000000000000..b37fcea719e17 --- /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 new file mode 100644 index 0000000000000..449ae6841ffea --- /dev/null +++ b/homeassistant/components/counter/services.yaml @@ -0,0 +1,41 @@ +# Describes the format for available counter services + +decrement: + description: Decrement a counter. + fields: + entity_id: + description: Entity id of the counter to decrement. + example: 'counter.count0' +increment: + description: Increment a counter. + fields: + entity_id: + description: Entity id of the counter to increment. + example: 'counter.count0' +reset: + description: Reset a counter. + fields: + entity_id: + description: Entity id of the counter to reset. + example: 'counter.count0' +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 0000000000000..4651fb4aebecf --- /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 0000000000000..b2c2371db5c9f --- /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 0000000000000..bed9bc976d39d --- /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 0000000000000..64b89be526726 --- /dev/null +++ b/homeassistant/components/cover/.translations/da.json @@ -0,0 +1,20 @@ +{ + "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", + "is_position": "Aktuel {entity_name} position er", + "is_tilt_position": "Aktuel {entity_name} vippeposition er" + }, + "trigger_type": { + "closed": "{entity_name} lukket", + "closing": "{entity_name} lukning", + "opened": "{entity_name} \u00e5bnet", + "opening": "{entity_name} \u00e5bning", + "position": "{entity_name} position \u00e6ndres", + "tilt_position": "{entity_name} vippeposition \u00e6ndres" + } + } +} \ 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 0000000000000..24589c733b863 --- /dev/null +++ b/homeassistant/components/cover/.translations/de.json @@ -0,0 +1,20 @@ +{ + "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", + "is_position": "Die Aktuelle Position von {entity_name} ist", + "is_tilt_position": "Die Aktuelle Neigungsposition von {entity_name} ist" + }, + "trigger_type": { + "closed": "{entity_name} geschlossen", + "closing": "{entity_name} wird geschlossen", + "opened": "{entity_name} ge\u00f6ffnet", + "opening": "{entity_name} wird ge\u00f6ffnet", + "position": "{entity_name} ver\u00e4ndert die Position", + "tilt_position": "{entity_name} ver\u00e4ndert die Neigungsposition" + } + } +} \ 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 0000000000000..27710f7943627 --- /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 0000000000000..490583b54c4bc --- /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 0000000000000..3aa877637d966 --- /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 0000000000000..d460c53109dd1 --- /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 0000000000000..bc9413d4a006c --- /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 0000000000000..145938b6f2424 --- /dev/null +++ b/homeassistant/components/cover/.translations/ko.json @@ -0,0 +1,20 @@ +{ + "device_automation": { + "condition_type": { + "is_closed": "{entity_name} \uc774(\uac00) \ub2eb\ud600 \uc788\uc73c\uba74", + "is_closing": "{entity_name} \uc774(\uac00) \ub2eb\ud788\ub294 \uc911\uc774\uba74", + "is_open": "{entity_name} \uc774(\uac00) \uc5f4\ub824 \uc788\uc73c\uba74", + "is_opening": "{entity_name} \uc774(\uac00) \uc5f4\ub9ac\ub294 \uc911\uc774\uba74", + "is_position": "\ud604\uc7ac {entity_name} \uac1c\ud3d0 \uc704\uce58\uac00 ~ \uc774\uba74", + "is_tilt_position": "\ud604\uc7ac {entity_name} \uac1c\ud3d0 \uae30\uc6b8\uae30\uac00 ~ \uc774\uba74" + }, + "trigger_type": { + "closed": "{entity_name} \uc774(\uac00) \ub2eb\ud790 \ub54c", + "closing": "{entity_name} \uc774(\uac00) \ub2eb\ud788\ub294 \uc911\uc77c \ub54c", + "opened": "{entity_name} \uc774(\uac00) \uc5f4\ub9b4 \ub54c", + "opening": "{entity_name} \uc774(\uac00) \uc5f4\ub9ac\ub294 \uc911\uc77c \ub54c", + "position": "{entity_name} \uac1c\ud3d0 \uc704\uce58\uac00 \ubcc0\ud560 \ub54c", + "tilt_position": "{entity_name} \uac1c\ud3d0 \uae30\uc6b8\uae30\uac00 \ubcc0\ud560 \ub54c" + } + } +} \ 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 0000000000000..b2645f3e00167 --- /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 0000000000000..472583687dd8f --- /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 0000000000000..cc045e43624f5 --- /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 0000000000000..718c4b86fbdd7 --- /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 0000000000000..6234d2685f4b6 --- /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 0000000000000..ebe81486cf537 --- /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 0000000000000..cd3570d39ba14 --- /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 0000000000000..f2880a72e614e --- /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 new file mode 100644 index 0000000000000..2fe4022fb3942 --- /dev/null +++ b/homeassistant/components/cover/__init__.py @@ -0,0 +1,355 @@ +"""Support for Cover devices.""" +from datetime import timedelta +import functools as ft +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.const import ( + 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" +SCAN_INTERVAL = timedelta(seconds=15) + +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 = [ + 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 +SUPPORT_CLOSE = 2 +SUPPORT_SET_POSITION = 4 +SUPPORT_STOP = 8 +SUPPORT_OPEN_TILT = 16 +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" + + +@bind_hass +def is_closed(hass, entity_id): + """Return if the cover is closed based on the statemachine.""" + return hass.states.is_state(entity_id, STATE_CLOSED) + + +async def async_setup(hass, config): + """Track states and offer events for covers.""" + component = hass.data[DOMAIN] = EntityComponent( + _LOGGER, DOMAIN, hass, SCAN_INTERVAL + ) + + await component.async_setup(config) + + component.async_register_entity_service(SERVICE_OPEN_COVER, {}, "async_open_cover") + + component.async_register_entity_service( + SERVICE_CLOSE_COVER, {}, "async_close_cover" + ) + + component.async_register_entity_service( + 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_CLOSE_COVER_TILT, {}, "async_close_cover_tilt" + ) + + component.async_register_entity_service( + SERVICE_STOP_COVER_TILT, {}, "async_stop_cover_tilt" + ) + + component.async_register_entity_service( + 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_TOGGLE_COVER_TILT, {}, "async_toggle_tilt" + ) + + 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 of a cover.""" + + @property + def current_cover_position(self): + """Return current position of cover. + + None is unknown, 0 is closed, 100 is fully open. + """ + pass + + @property + def current_cover_tilt_position(self): + """Return current position of cover tilt. + + None is unknown, 0 is closed, 100 is fully open. + """ + pass + + @property + def state(self): + """Return the state of the cover.""" + if self.is_opening: + return STATE_OPENING + if self.is_closing: + return STATE_CLOSING + + closed = self.is_closed + + if closed is None: + return None + + return STATE_CLOSED if closed else STATE_OPEN + + @property + def state_attributes(self): + """Return the state attributes.""" + data = {} + + current = self.current_cover_position + if current is not None: + data[ATTR_CURRENT_POSITION] = self.current_cover_position + + current_tilt = self.current_cover_tilt_position + if current_tilt is not None: + data[ATTR_CURRENT_TILT_POSITION] = self.current_cover_tilt_position + + return data + + @property + def supported_features(self): + """Flag supported features.""" + supported_features = SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_STOP + + if self.current_cover_position is not None: + supported_features |= SUPPORT_SET_POSITION + + if self.current_cover_tilt_position is not None: + supported_features |= ( + SUPPORT_OPEN_TILT + | SUPPORT_CLOSE_TILT + | SUPPORT_STOP_TILT + | SUPPORT_SET_TILT_POSITION + ) + + return supported_features + + @property + def is_opening(self): + """Return if the cover is opening or not.""" + pass + + @property + def is_closing(self): + """Return if the cover is closing or not.""" + pass + + @property + def is_closed(self): + """Return if the cover is closed or not.""" + raise NotImplementedError() + + def open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + raise NotImplementedError() + + def async_open_cover(self, **kwargs): + """Open the cover. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_job(ft.partial(self.open_cover, **kwargs)) + + def close_cover(self, **kwargs: Any) -> None: + """Close cover.""" + raise NotImplementedError() + + def async_close_cover(self, **kwargs): + """Close cover. + + This method must be run in the event loop and returns a coroutine. + """ + 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 + + def async_set_cover_position(self, **kwargs): + """Move the cover to a specific position. + + 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)) + + def stop_cover(self, **kwargs): + """Stop the cover.""" + pass + + def async_stop_cover(self, **kwargs): + """Stop the cover. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_job(ft.partial(self.stop_cover, **kwargs)) + + def open_cover_tilt(self, **kwargs: Any) -> None: + """Open the cover tilt.""" + pass + + def async_open_cover_tilt(self, **kwargs): + """Open the cover tilt. + + 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)) + + def close_cover_tilt(self, **kwargs: Any) -> None: + """Close the cover tilt.""" + pass + + def async_close_cover_tilt(self, **kwargs): + """Close the cover tilt. + + 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)) + + def set_cover_tilt_position(self, **kwargs): + """Move the cover tilt to a specific position.""" + pass + + def async_set_cover_tilt_position(self, **kwargs): + """Move the cover tilt to a specific position. + + 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) + ) + + def stop_cover_tilt(self, **kwargs): + """Stop the cover.""" + pass + + def async_stop_cover_tilt(self, **kwargs): + """Stop the cover. + + 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)) + + 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/device_condition.py b/homeassistant/components/cover/device_condition.py new file mode 100644 index 0000000000000..ec6da84e5f6ee --- /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 0000000000000..988427003e7a0 --- /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/intent.py b/homeassistant/components/cover/intent.py new file mode 100644 index 0000000000000..36402025bfa38 --- /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/manifest.json b/homeassistant/components/cover/manifest.json new file mode 100644 index 0000000000000..aa43e934dc968 --- /dev/null +++ b/homeassistant/components/cover/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "cover", + "name": "Cover", + "documentation": "https://www.home-assistant.io/integrations/cover", + "requirements": [], + "dependencies": ["group"], + "codeowners": ["@home-assistant/core"], + "quality_scale": "internal" +} diff --git a/homeassistant/components/cover/reproduce_state.py b/homeassistant/components/cover/reproduce_state.py new file mode 100644 index 0000000000000..64ea410ce933f --- /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/services.yaml b/homeassistant/components/cover/services.yaml new file mode 100644 index 0000000000000..64534e409742e --- /dev/null +++ b/homeassistant/components/cover/services.yaml @@ -0,0 +1,77 @@ +# Describes the format for available cover services + +open_cover: + description: Open all or specified cover. + fields: + entity_id: + description: Name(s) of cover(s) to open. + example: 'cover.living_room' + +close_cover: + description: Close all or specified cover. + fields: + entity_id: + description: Name(s) of cover(s) to close. + example: 'cover.living_room' + +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: + entity_id: + description: Name(s) of cover(s) to set cover position. + example: 'cover.living_room' + position: + description: Position of the cover (0 to 100). + example: 30 + +stop_cover: + description: Stop all or specified cover. + fields: + entity_id: + description: Name(s) of cover(s) to stop. + example: 'cover.living_room' + +open_cover_tilt: + description: Open all or specified cover tilt. + fields: + entity_id: + description: Name(s) of cover(s) tilt to open. + example: 'cover.living_room_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_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_blinds' + tilt_position: + description: Tilt position of the cover (0 to 100). + example: 30 + +stop_cover_tilt: + description: Stop all or specified cover. + fields: + entity_id: + description: Name(s) of cover(s) to stop. + example: 'cover.living_room_blinds' diff --git a/homeassistant/components/cover/strings.json b/homeassistant/components/cover/strings.json new file mode 100644 index 0000000000000..36492cc5ed551 --- /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/cppm_tracker/__init__.py b/homeassistant/components/cppm_tracker/__init__.py new file mode 100644 index 0000000000000..cb6aa87881d1c --- /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 0000000000000..1bb723091d446 --- /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 0000000000000..8407aee07d5db --- /dev/null +++ b/homeassistant/components/cppm_tracker/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "cppm_tracker", + "name": "Aruba ClearPass", + "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 0000000000000..c6121a6883583 --- /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 0000000000000..7e8f44648f1ec --- /dev/null +++ b/homeassistant/components/cpuspeed/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "cpuspeed", + "name": "CPU Speed", + "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 0000000000000..53598e24c70df --- /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 0000000000000..57af9df4dbfe6 --- /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 0000000000000..6d64c31303931 --- /dev/null +++ b/homeassistant/components/crimereports/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "crimereports", + "name": "Crime Reports", + "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 0000000000000..cf5b2e374e2cf --- /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 0000000000000..7cd5ce4ca0a52 --- /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 0000000000000..d9b193e6dc643 --- /dev/null +++ b/homeassistant/components/cups/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..7581891af6aa5 --- /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 0000000000000..237392ec13dc3 --- /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 0000000000000..162091de9ad92 --- /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 0000000000000..cbad07c0284e4 --- /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/.translations/bg.json b/homeassistant/components/daikin/.translations/bg.json new file mode 100644 index 0000000000000..b0ddcbf490377 --- /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 0000000000000..2fa60015ca33c --- /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 0000000000000..856bb1445c71b --- /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 0000000000000..0a09c7b5cfabd --- /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 0000000000000..1605e1dc8f695 --- /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 0000000000000..6fa2b664a3016 --- /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 0000000000000..d3a733a3f9be1 --- /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 0000000000000..cfd4b7442d6cd --- /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 0000000000000..f433a6215b85a --- /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 0000000000000..0b8151d23f658 --- /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 0000000000000..4b1d1bd86e5b8 --- /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 0000000000000..cdf98f5e597ee --- /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 0000000000000..683bb61dd44ac --- /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 0000000000000..67d4f85262572 --- /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 0000000000000..806106c5e52e5 --- /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 0000000000000..5d5448a93dbff --- /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 0000000000000..bbdf68ed79489 --- /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 0000000000000..34b4c86e77d33 --- /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 0000000000000..00a517f701fe3 --- /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 0000000000000..088b354fbb1bf --- /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 0000000000000..0f1247197aa93 --- /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 0000000000000..8f0fdda371168 --- /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 0000000000000..5123dc2366b98 --- /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 0000000000000..457b7d1b89c52 --- /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 0000000000000..209bf71e594fb --- /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 0000000000000..d46ea26d4870d --- /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 0000000000000..bd90a87db86e2 --- /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 0000000000000..ef24a51be8967 --- /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 0000000000000..52d1b516d3276 --- /dev/null +++ b/homeassistant/components/daikin/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "daikin", + "name": "Daikin AC", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/daikin", + "requirements": ["pydaikin==1.6.1"], + "dependencies": [], + "codeowners": ["@fredrike", "@rofrantz"], + "quality_scale": "platinum" +} diff --git a/homeassistant/components/daikin/sensor.py b/homeassistant/components/daikin/sensor.py new file mode 100644 index 0000000000000..f83566e66e8a7 --- /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 0000000000000..4badc8b72d789 --- /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 0000000000000..4d3b0d3eadea7 --- /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 0000000000000..b1dbf890eb970 --- /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 0000000000000..d5aab8b35bb12 --- /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 0000000000000..bbfbd3791b259 --- /dev/null +++ b/homeassistant/components/danfoss_air/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..ea0002d0ac3c4 --- /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 0000000000000..8a1e0f9c746e8 --- /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 0000000000000..90a5d06dc0e56 --- /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 0000000000000..94123ceba8591 --- /dev/null +++ b/homeassistant/components/darksky/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "darksky", + "name": "Dark Sky", + "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 0000000000000..9f99b37a2013c --- /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 = f"{attr}_{i!s}" + 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 0000000000000..41f063399c1a3 --- /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/__init__.py b/homeassistant/components/datadog/__init__.py new file mode 100644 index 0000000000000..adb8bb1f95c79 --- /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 0000000000000..4df780b200f35 --- /dev/null +++ b/homeassistant/components/datadog/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..a2c3681128579 --- /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 0000000000000..bd2728d03dcab --- /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 0000000000000..d50fd2627294a --- /dev/null +++ b/homeassistant/components/ddwrt/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "ddwrt", + "name": "DD-WRT", + "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 new file mode 100644 index 0000000000000..fb75fc81f5fe0 --- /dev/null +++ b/homeassistant/components/deconz/.translations/bg.json @@ -0,0 +1,109 @@ +{ + "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", + "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": "\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\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 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 new file mode 100644 index 0000000000000..a51bfa056f64e --- /dev/null +++ b/homeassistant/components/deconz/.translations/ca.json @@ -0,0 +1,109 @@ +{ + "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", + "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" + }, + "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 -> 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": "Permetre la importaci\u00f3 de grups deCONZ" + }, + "title": "Opcions de configuraci\u00f3 addicionals de 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 new file mode 100644 index 0000000000000..c665690796dd4 --- /dev/null +++ b/homeassistant/components/deconz/.translations/cs.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "P\u0159emost\u011bn\u00ed je ji\u017e nakonfigurov\u00e1no", + "no_bridges": "\u017d\u00e1dn\u00e9 deCONZ p\u0159emost\u011bn\u00ed nebyly nalezeny", + "one_instance_only": "Komponent podporuje pouze jednu instanci deCONZ" + }, + "error": { + "no_key": "Nelze z\u00edskat kl\u00ed\u010d API" + }, + "flow_title": "Br\u00e1na deCONZ ZigBee ({host})", + "step": { + "init": { + "data": { + "host": "Hostitel", + "port": "Port" + }, + "title": "Definujte br\u00e1nu deCONZ" + }, + "link": { + "description": "Odemkn\u011bte br\u00e1nu deCONZ, pro registraci v Home Assistant. \n\n 1. P\u0159ejd\u011bte do nastaven\u00ed syst\u00e9mu deCONZ \n 2. Stiskn\u011bte tla\u010d\u00edtko \"Unlock Gateway\"", + "title": "Propojit s deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "Povolit import virtu\u00e1ln\u00edch \u010didel", + "allow_deconz_groups": "Povolit import skupin deCONZ" + }, + "title": "Dal\u0161\u00ed mo\u017enosti konfigurace pro deCONZ" + } + }, + "title": "Br\u00e1na deCONZ Zigbee" + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/cy.json b/homeassistant/components/deconz/.translations/cy.json new file mode 100644 index 0000000000000..fff54bb3f6cfd --- /dev/null +++ b/homeassistant/components/deconz/.translations/cy.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Pont eisoes wedi'i ffurfweddu", + "no_bridges": "Dim pontydd deCONZ wedi eu darganfod", + "one_instance_only": "Elfen dim ond yn cefnogi enghraifft deCONZ" + }, + "error": { + "no_key": "Methu cael allwedd API" + }, + "step": { + "init": { + "data": { + "host": "Gwesteiwr", + "port": "Port (gwerth diofyn: '80')" + }, + "title": "Diffiniwch porth dad-adeiladu" + }, + "link": { + "description": "Datgloi eich porth deCONZ i gofrestru gyda Cynorthwydd Cartref.\n\n1. Ewch i osodiadau system deCONZ \n2. Bwyso botwm \"Datgloi porth\"", + "title": "Cysylltu \u00e2 deCONZ" + } + }, + "title": "deCONZ" + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/da.json b/homeassistant/components/deconz/.translations/da.json new file mode 100644 index 0000000000000..ed1f0b06e643a --- /dev/null +++ b/homeassistant/components/deconz/.translations/da.json @@ -0,0 +1,115 @@ +{ + "config": { + "abort": { + "already_configured": "Bridge er allerede konfigureret", + "already_in_progress": "Konfigurationsflow for bro er allerede i gang.", + "no_bridges": "Ingen deConz-bridge fundet", + "not_deconz_bridge": "Ikke en deCONZ-bro", + "one_instance_only": "Komponenten underst\u00f8tter kun \u00e9n deCONZ-instans", + "updated_instance": "Opdaterede deCONZ-instans med ny v\u00e6rtadresse" + }, + "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'en leveret af Hass.io-tilf\u00f8jelsen {addon}?", + "title": "deCONZ Zigbee-gateway via Hass.io-tilf\u00f8jelse" + }, + "init": { + "data": { + "host": "V\u00e6rt", + "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": "Forbind med deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "Tillad import af virtuelle sensorer", + "allow_deconz_groups": "Tillad import af deCONZ-grupper" + }, + "title": "Ekstra konfigurationsindstillinger 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", + "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": "Sluk", + "turn_on": "T\u00e6nd" + }, + "trigger_type": { + "remote_awakened": "Enheden v\u00e6kket", + "remote_button_double_press": "\"{subtype}\"-knappen er dobbeltklikket", + "remote_button_long_press": "\"{subtype}\"-knappen trykket p\u00e5 konstant", + "remote_button_long_release": "\"{subtype}\"-knappen frigivet efter langt tryk", + "remote_button_quadruple_press": "\"{subtype}\"-knappen firedobbelt-klikket", + "remote_button_quintuple_press": "\"{subtype}\"-knappen femdobbelt-klikket", + "remote_button_rotated": "Knap roteret \"{subtype}\"", + "remote_button_rotation_stopped": "Knaprotation \"{subtype}\" er stoppet", + "remote_button_short_press": "\"{subtype}\"-knappen trykket p\u00e5", + "remote_button_short_release": "\"{subtype}\"-knappen frigivet", + "remote_button_triple_press": "\"{subtype}\"-knappen tredobbeltklikkes", + "remote_double_tap": "Enheden \"{subtype}\" dobbelttappet", + "remote_double_tap_any_side": "Enhed dobbelttappet p\u00e5 enhver side", + "remote_falling": "Enheden er i frit fald", + "remote_flip_180_degrees": "Enhed vendt 180 grader", + "remote_flip_90_degrees": "Enhed vendt 90 grader", + "remote_gyro_activated": "Enhed rystet", + "remote_moved": "Enheden flyttede med \"{subtype}\" op", + "remote_moved_any_side": "Enhed flyttet med enhver side opad", + "remote_rotate_from_side_1": "Enhed roteret fra \"side 1\" til \"{subtype}\"", + "remote_rotate_from_side_2": "Enhed roteret fra \"side 2\" til \"{subtype}\"", + "remote_rotate_from_side_3": "Enhed roteret fra \"side 3\" til \"{subtype}\"", + "remote_rotate_from_side_4": "Enhed roteret fra \"side 4\" til \"{subtype}\"", + "remote_rotate_from_side_5": "Enhed roteret fra \"side 5\" til \"{subtype}\"", + "remote_rotate_from_side_6": "Enhed roteret fra \"side 6\" til \"{subtype}\"", + "remote_turned_clockwise": "Enhed drejet med uret", + "remote_turned_counter_clockwise": "Enhed drejet mod uret" + } + }, + "options": { + "step": { + "async_step_deconz_devices": { + "data": { + "allow_clip_sensor": "Tillad deCONZ CLIP-sensorer", + "allow_deconz_groups": "Tillad deCONZ-lysgrupper" + }, + "description": "Konfigurer synligheden af deCONZ-enhedstyper" + }, + "deconz_devices": { + "data": { + "allow_clip_sensor": "Tillad deCONZ CLIP-sensorer", + "allow_deconz_groups": "Tillad deCONZ-lysgrupper" + }, + "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 new file mode 100644 index 0000000000000..d177448f4fdfd --- /dev/null +++ b/homeassistant/components/deconz/.translations/de.json @@ -0,0 +1,108 @@ +{ + "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", + "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" + }, + "title": "Definiere das deCONZ-Gateway" + }, + "link": { + "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": { + "data": { + "allow_clip_sensor": "Import virtueller Sensoren zulassen", + "allow_deconz_groups": "Import von deCONZ-Gruppen zulassen" + }, + "title": "Weitere Konfigurationsoptionen f\u00fcr deCONZ" + } + }, + "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", + "side_1": "Seite 1", + "side_2": "Seite 2", + "side_3": "Seite 3", + "side_4": "Seite 4", + "side_5": "Seite 5", + "side_6": "Seite 6", + "turn_off": "Ausschalten", + "turn_on": "Einschalten" + }, + "trigger_type": { + "remote_awakened": "Ger\u00e4t aufgeweckt", + "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_double_tap": "Ger\u00e4t \"{subtype}\" doppelt getippt", + "remote_falling": "Ger\u00e4t im freien Fall", + "remote_gyro_activated": "Ger\u00e4t ersch\u00fcttert", + "remote_rotate_from_side_1": "Ger\u00e4t von \"Seite 1\" auf \"{subtype}\" gedreht", + "remote_rotate_from_side_2": "Ger\u00e4t von \"Seite 2\" auf \"{subtype}\" gedreht", + "remote_rotate_from_side_3": "Ger\u00e4t von \"Seite 3\" auf \"{subtype}\" gedreht", + "remote_rotate_from_side_4": "Ger\u00e4t von \"Seite 4\" auf \"{subtype}\" gedreht", + "remote_rotate_from_side_5": "Ger\u00e4t von \"Seite 5\" auf \"{subtype}\" gedreht", + "remote_rotate_from_side_6": "Ger\u00e4t von \"Seite 6\" auf \"{subtype}\" gedreht" + } + }, + "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 new file mode 100644 index 0000000000000..b3d9e00bfe6f5 --- /dev/null +++ b/homeassistant/components/deconz/.translations/en.json @@ -0,0 +1,115 @@ +{ + "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", + "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" + }, + "title": "Define deCONZ gateway" + }, + "link": { + "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": { + "data": { + "allow_clip_sensor": "Allow importing virtual sensors", + "allow_deconz_groups": "Allow importing deCONZ groups" + }, + "title": "Extra configuration options for deCONZ" + } + }, + "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_double_tap_any_side": "Device double tapped on any side", + "remote_falling": "Device in free fall", + "remote_flip_180_degrees": "Device flipped 180 degrees", + "remote_flip_90_degrees": "Device flipped 90 degrees", + "remote_gyro_activated": "Device shaken", + "remote_moved": "Device moved with \"{subtype}\" up", + "remote_moved_any_side": "Device moved with any side 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}\"", + "remote_turned_clockwise": "Device turned clockwise", + "remote_turned_counter_clockwise": "Device turned counter clockwise" + } + }, + "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 new file mode 100644 index 0000000000000..448b654c86e42 --- /dev/null +++ b/homeassistant/components/deconz/.translations/es-419.json @@ -0,0 +1,74 @@ +{ + "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", + "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" + }, + "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 0000000000000..adbe68153f758 --- /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 \"{subtype}\" 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 0000000000000..93c54b3915cb7 --- /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 new file mode 100644 index 0000000000000..1a4232e0817f2 --- /dev/null +++ b/homeassistant/components/deconz/.translations/fr.json @@ -0,0 +1,109 @@ +{ + "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", + "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" + }, + "title": "Initialiser la passerelle deCONZ" + }, + "link": { + "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": { + "data": { + "allow_clip_sensor": "Autoriser l'importation de capteurs virtuels", + "allow_deconz_groups": "Autoriser l'importation des groupes deCONZ" + }, + "title": "Options de configuration suppl\u00e9mentaires pour deCONZ" + } + }, + "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 new file mode 100644 index 0000000000000..89a2d69950e41 --- /dev/null +++ b/homeassistant/components/deconz/.translations/he.json @@ -0,0 +1,33 @@ +{ + "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", + "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" + }, + "step": { + "init": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7", + "port": "\u05e4\u05d5\u05e8\u05d8 (\u05d1\u05e8\u05d9\u05e8\u05ea \u05de\u05d7\u05d3\u05dc: '80')" + }, + "title": "\u05d4\u05d2\u05d3\u05e8 \u05de\u05d2\u05e9\u05e8 deCONZ Zigbee" + }, + "link": { + "description": "\u05d1\u05d8\u05dc \u05d0\u05ea \u05e0\u05e2\u05d9\u05dc\u05ea \u05d4\u05de\u05e9\u05e8 deCONZ \u05e9\u05dc\u05da \u05db\u05d3\u05d9 \u05dc\u05d4\u05ea\u05d7\u05d1\u05e8 \u05e2\u05dd Home Assistant.\n\n 1. \u05e2\u05d1\u05d5\u05e8 \u05d0\u05dc \u05d4\u05d2\u05d3\u05e8\u05d5\u05ea \u05de\u05e2\u05e8\u05db\u05ea deCONZ \n .2 \u05dc\u05d7\u05e5 \u05e2\u05dc \"Unlock Gateway\"", + "title": "\u05e7\u05e9\u05e8 \u05e2\u05dd deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "\u05d0\u05e4\u05e9\u05e8 \u05dc\u05d9\u05d9\u05d1\u05d0 \u05d7\u05d9\u05d9\u05e9\u05e0\u05d9\u05dd \u05d5\u05d9\u05e8\u05d8\u05d5\u05d0\u05dc\u05d9\u05d9\u05dd", + "allow_deconz_groups": "\u05d0\u05e4\u05e9\u05e8 \u05dc\u05d9\u05d9\u05d1\u05d0 \u05e7\u05d1\u05d5\u05e6\u05d5\u05ea deCONZ" + }, + "title": "\u05d0\u05e4\u05e9\u05e8\u05d5\u05d9\u05d5\u05ea \u05ea\u05e6\u05d5\u05e8\u05d4 \u05e0\u05d5\u05e1\u05e4\u05d5\u05ea \u05e2\u05d1\u05d5\u05e8 deCONZ" + } + }, + "title": "\u05de\u05d2\u05e9\u05e8 deCONZ Zigbee" + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/hr.json b/homeassistant/components/deconz/.translations/hr.json new file mode 100644 index 0000000000000..2f2eb6df214b2 --- /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 new file mode 100644 index 0000000000000..9e8109107436d --- /dev/null +++ b/homeassistant/components/deconz/.translations/hu.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "A bridge m\u00e1r konfigur\u00e1lva van", + "no_bridges": "Nem tal\u00e1ltam deCONZ bridget", + "one_instance_only": "Ez a komponens csak egy deCONZ egys\u00e9get t\u00e1mogat" + }, + "error": { + "no_key": "API kulcs lek\u00e9r\u00e9se nem siker\u00fclt" + }, + "step": { + "init": { + "data": { + "host": "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 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 0000000000000..7d0b3163a40b8 --- /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 new file mode 100644 index 0000000000000..99e5622129fa5 --- /dev/null +++ b/homeassistant/components/deconz/.translations/it.json @@ -0,0 +1,109 @@ +{ + "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", + "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" + }, + "title": "Definisci il gateway deCONZ" + }, + "link": { + "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": { + "data": { + "allow_clip_sensor": "Consenti l'importazione di sensori virtuali", + "allow_deconz_groups": "Consenti l'importazione di gruppi deCONZ" + }, + "title": "Opzioni di configurazione extra per 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/ja.json b/homeassistant/components/deconz/.translations/ja.json new file mode 100644 index 0000000000000..5148ebeaa86b2 --- /dev/null +++ b/homeassistant/components/deconz/.translations/ja.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "no_key": "API\u30ad\u30fc\u3092\u53d6\u5f97\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f" + }, + "step": { + "init": { + "data": { + "host": "\u30db\u30b9\u30c8", + "port": "\u30dd\u30fc\u30c8\uff08\u30c7\u30d5\u30a9\u30eb\u30c8\u5024\uff1a'80'\uff09" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/ko.json b/homeassistant/components/deconz/.translations/ko.json new file mode 100644 index 0000000000000..5cf1cb32ca2ae --- /dev/null +++ b/homeassistant/components/deconz/.translations/ko.json @@ -0,0 +1,109 @@ +{ + "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", + "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\uc5d0\uc11c \uc81c\uacf5\ub41c 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" + }, + "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. \"Authenticate app\" \ubc84\ud2bc\uc744 \ub20c\ub7ec\uc8fc\uc138\uc694", + "title": "deCONZ\uc640 \uc5f0\uacb0" + }, + "options": { + "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" + }, + "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\ub420 \ub54c", + "remote_button_double_press": "\"{subtype}\" \ubc84\ud2bc\uc774 \ub450 \ubc88 \ub20c\ub9b4 \ub54c", + "remote_button_long_press": "\"{subtype}\" \ubc84\ud2bc\uc774 \uacc4\uc18d \ub20c\ub824\uc9c8 \ub54c", + "remote_button_long_release": "\"{subtype}\" \ubc84\ud2bc\uc774 \uae38\uac8c \ub20c\ub838\ub2e4\uac00 \uc190\uc744 \ub5c4 \ub54c", + "remote_button_quadruple_press": "\"{subtype}\" \ubc84\ud2bc\uc774 \ub124 \ubc88 \ub20c\ub9b4 \ub54c", + "remote_button_quintuple_press": "\"{subtype}\" \ubc84\ud2bc\uc774 \ub2e4\uc12f \ubc88 \ub20c\ub9b4 \ub54c", + "remote_button_rotated": "\"{subtype}\" \ub85c \ubc84\ud2bc\uc774 \ud68c\uc804\ub420 \ub54c", + "remote_button_rotation_stopped": "\"{subtype}\" \ub85c \ubc84\ud2bc\uc774 \ud68c\uc804\uc744 \uba48\ucd9c \ub54c", + "remote_button_short_press": "\"{subtype}\" \ubc84\ud2bc\uc774 \ub20c\ub9b4 \ub54c", + "remote_button_short_release": "\"{subtype}\" \ubc84\ud2bc\uc5d0\uc11c \uc190\uc744 \ub5c4 \ub54c", + "remote_button_triple_press": "\"{subtype}\" \ubc84\ud2bc\uc774 \uc138 \ubc88 \ub20c\ub9b4 \ub54c", + "remote_double_tap": "\uae30\uae30\uc758 \"{subtype}\" \uac00 \ub354\ube14 \ud0ed \ub420 \ub54c", + "remote_falling": "\uae30\uae30\uac00 \ub5a8\uc5b4\uc9c8 \ub54c", + "remote_gyro_activated": "\uae30\uae30\uac00 \ud754\ub4e4\ub9b4 \ub54c", + "remote_moved": "\uae30\uae30\uc758 \"{subtype}\" \uac00 \uc704\ub85c \ud5a5\ud55c\ucc44\ub85c \uc6c0\uc9c1\uc77c \ub54c", + "remote_rotate_from_side_1": "\"\uba74 1\" \uc5d0\uc11c \"{subtype}\" \ub85c \uae30\uae30\uac00 \ud68c\uc804\ub420 \ub54c", + "remote_rotate_from_side_2": "\"\uba74 2\" \uc5d0\uc11c \"{subtype}\" \ub85c \uae30\uae30\uac00 \ud68c\uc804\ub420 \ub54c", + "remote_rotate_from_side_3": "\"\uba74 3\" \uc5d0\uc11c \"{subtype}\" \ub85c \uae30\uae30\uac00 \ud68c\uc804\ub420 \ub54c", + "remote_rotate_from_side_4": "\"\uba74 4\" \uc5d0\uc11c \"{subtype}\" \ub85c \uae30\uae30\uac00 \ud68c\uc804\ub420 \ub54c", + "remote_rotate_from_side_5": "\"\uba74 5\" \uc5d0\uc11c \"{subtype}\" \ub85c \uae30\uae30\uac00 \ud68c\uc804\ub420 \ub54c", + "remote_rotate_from_side_6": "\"\uba74 6\" \uc5d0\uc11c \"{subtype}\" \ub85c \uae30\uae30\uac00 \ud68c\uc804\ub420 \ub54c" + } + }, + "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 new file mode 100644 index 0000000000000..07f88732c6250 --- /dev/null +++ b/homeassistant/components/deconz/.translations/lb.json @@ -0,0 +1,109 @@ +{ + "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", + "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" + }, + "title": "deCONZ gateway d\u00e9fin\u00e9ieren" + }, + "link": { + "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": { + "data": { + "allow_clip_sensor": "Erlaabt den Import vun virtuellen Sensoren", + "allow_deconz_groups": "Erlaabt den Import vun deCONZ Gruppen" + }, + "title": "Extra Konfiguratiouns Optiounen fir 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 new file mode 100644 index 0000000000000..c0ee391b0c767 --- /dev/null +++ b/homeassistant/components/deconz/.translations/nl.json @@ -0,0 +1,109 @@ +{ + "config": { + "abort": { + "already_configured": "Bridge is al geconfigureerd", + "already_in_progress": "Configuratiestroom voor bridge wordt al ingesteld.", + "no_bridges": "Geen deCONZ bruggen ontdekt", + "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" + }, + "title": "Definieer deCONZ gateway" + }, + "link": { + "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": { + "data": { + "allow_clip_sensor": "Sta het importeren van virtuele sensoren toe", + "allow_deconz_groups": "Sta de import van deCONZ-groepen toe" + }, + "title": "Extra configuratieopties voor deCONZ" + } + }, + "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 0000000000000..46933ced42761 --- /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 new file mode 100644 index 0000000000000..6d1969e98a7e6 --- /dev/null +++ b/homeassistant/components/deconz/.translations/no.json @@ -0,0 +1,109 @@ +{ + "config": { + "abort": { + "already_configured": "Broen er allerede konfigurert", + "already_in_progress": "Konfigurasjonsflyt for bro p\u00e5g\u00e5r allerede.", + "no_bridges": "Ingen deCONZ broer oppdaget", + "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": "Vil du konfigurere Home Assistant til \u00e5 koble seg til deCONZ-gateway levert av Hass.io-tillegget {addon} ?", + "title": "deCONZ Zigbee gateway via Hass.io tillegg" + }, + "init": { + "data": { + "host": "Vert", + "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 -> Gateway -> Avansert \n 2. Trykk p\u00e5 \"L\u00e5s opp gateway\" knappen", + "title": "Koble til deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "Tillat import av virtuelle sensorer", + "allow_deconz_groups": "Tillat import av deCONZ grupper" + }, + "title": "Ekstra konfigurasjonsalternativer for deCONZ" + } + }, + "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 new file mode 100644 index 0000000000000..eafecf87d0371 --- /dev/null +++ b/homeassistant/components/deconz/.translations/pl.json @@ -0,0 +1,109 @@ +{ + "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", + "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" + }, + "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 ustawienia deCONZ > bramka > Zaawansowane\n 2. Naci\u015bnij przycisk \"Uwierzytelnij aplikacj\u0119\"", + "title": "Po\u0142\u0105cz z deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "Zezwalaj na importowanie wirtualnych sensor\u00f3w", + "allow_deconz_groups": "Zezw\u00f3l na importowanie grup deCONZ" + }, + "title": "Dodatkowe opcje konfiguracji dla 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 new file mode 100644 index 0000000000000..8d54c470846b2 --- /dev/null +++ b/homeassistant/components/deconz/.translations/pt-BR.json @@ -0,0 +1,55 @@ +{ + "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", + "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", + "port": "Porta (valor padr\u00e3o: '80')" + }, + "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": "Linkar 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 extras de configura\u00e7\u00e3o para deCONZ" + } + }, + "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 new file mode 100644 index 0000000000000..f24d7692a555e --- /dev/null +++ b/homeassistant/components/deconz/.translations/pt.json @@ -0,0 +1,44 @@ +{ + "config": { + "abort": { + "already_configured": "Bridge j\u00e1 est\u00e1 configurada", + "no_bridges": "Nenhum deCONZ descoberto", + "one_instance_only": "Componente suporta apenas uma conex\u00e3o deCONZ" + }, + "error": { + "no_key": "N\u00e3o foi poss\u00edvel obter uma chave de API" + }, + "step": { + "init": { + "data": { + "host": "Servidor", + "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": "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 de configura\u00e7\u00e3o extra para 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 0000000000000..2d6fc6a39fbcc --- /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 new file mode 100644 index 0000000000000..57ec138c402e3 --- /dev/null +++ b/homeassistant/components/deconz/.translations/ru.json @@ -0,0 +1,109 @@ +{ + "config": { + "abort": { + "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 \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." + }, + "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" + }, + "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 -> 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": { + "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" + }, + "title": "\u0414\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0435 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u0434\u043b\u044f deCONZ" + } + }, + "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 new file mode 100644 index 0000000000000..ca4c6a3d63609 --- /dev/null +++ b/homeassistant/components/deconz/.translations/sl.json @@ -0,0 +1,109 @@ +{ + "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", + "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": "Ali \u017eelite konfigurirati Home Assistant za povezavo s prehodom deCONZ, ki ga ponuja dodatek Hass.io {addon} ?", + "title": "deCONZ Zigbee prehod preko dodatka Hass.io" + }, + "init": { + "data": { + "host": "Gostitelj", + "port": "Vrata" + }, + "title": "Dolo\u010dite deCONZ prehod" + }, + "link": { + "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": { + "data": { + "allow_clip_sensor": "Dovoli uvoz virtualnih senzorjev", + "allow_deconz_groups": "Dovoli uvoz deCONZ skupin" + }, + "title": "Dodatne mo\u017enosti konfiguracije za 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 new file mode 100644 index 0000000000000..a7b5160e8a3c5 --- /dev/null +++ b/homeassistant/components/deconz/.translations/sv.json @@ -0,0 +1,44 @@ +{ + "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", + "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", + "port": "Port (standardv\u00e4rde: '80')" + }, + "title": "Definiera deCONZ-gatewaye" + }, + "link": { + "description": "L\u00e5s upp din deCONZ-gateway f\u00f6r att registrera dig med Home Assistant. \n\n 1. G\u00e5 till deCONZ-systeminst\u00e4llningarna \n 2. Tryck p\u00e5 \"L\u00e5s upp gateway\"-knappen", + "title": "L\u00e4nka med deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "Till\u00e5t import av virtuella sensorer", + "allow_deconz_groups": "Till\u00e5t import av deCONZ-grupper" + }, + "title": "Extra konfigurationsalternativ f\u00f6r 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 0000000000000..e40765e8220a6 --- /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/vi.json b/homeassistant/components/deconz/.translations/vi.json new file mode 100644 index 0000000000000..00f1d9be57f07 --- /dev/null +++ b/homeassistant/components/deconz/.translations/vi.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "C\u1ea7u \u0111\u00e3 \u0111\u01b0\u1ee3c c\u1ea5u h\u00ecnh", + "no_bridges": "Kh\u00f4ng t\u00ecm th\u1ea5y c\u1ea7u deCONZ n\u00e0o", + "one_instance_only": "Th\u00e0nh ph\u1ea7n ch\u1ec9 h\u1ed7 tr\u1ee3 m\u1ed9t c\u00e1 th\u1ec3 deCONZ" + }, + "error": { + "no_key": "Kh\u00f4ng th\u1ec3 l\u1ea5y kh\u00f3a API" + }, + "step": { + "init": { + "data": { + "port": "C\u1ed5ng (gi\u00e1 tr\u1ecb m\u1eb7c \u0111\u1ecbnh: '80')" + } + }, + "options": { + "data": { + "allow_clip_sensor": "Cho ph\u00e9p nh\u1eadp c\u1ea3m bi\u1ebfn \u1ea3o", + "allow_deconz_groups": "Cho ph\u00e9p nh\u1eadp c\u00e1c nh\u00f3m deCONZ" + }, + "title": "T\u00f9y ch\u1ecdn c\u1ea5u h\u00ecnh b\u1ed5 sung cho deCONZ" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/zh-Hans.json b/homeassistant/components/deconz/.translations/zh-Hans.json new file mode 100644 index 0000000000000..2e5a216c77dde --- /dev/null +++ b/homeassistant/components/deconz/.translations/zh-Hans.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "\u6865\u63a5\u5668\u5df2\u914d\u7f6e\u5b8c\u6210", + "no_bridges": "\u6ca1\u6709\u53d1\u73b0 deCONZ \u7684\u6865\u63a5\u8bbe\u5907", + "one_instance_only": "\u7ec4\u4ef6\u53ea\u652f\u6301\u4e00\u4e2a deCONZ \u5b9e\u4f8b" + }, + "error": { + "no_key": "\u65e0\u6cd5\u83b7\u53d6 API \u5bc6\u94a5" + }, + "step": { + "init": { + "data": { + "host": "\u4e3b\u673a", + "port": "\u7aef\u53e3\uff08\u9ed8\u8ba4\u503c\uff1a'80'\uff09" + }, + "title": "\u5b9a\u4e49 deCONZ \u7f51\u5173" + }, + "link": { + "description": "\u89e3\u9501\u60a8\u7684 deCONZ \u7f51\u5173\u4ee5\u6ce8\u518c\u5230 Home Assistant\u3002 \n\n 1. \u524d\u5f80 deCONZ \u7cfb\u7edf\u8bbe\u7f6e\n 2. \u70b9\u51fb\u201c\u89e3\u9501\u7f51\u5173\u201d\u6309\u94ae", + "title": "\u8fde\u63a5 deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "\u5141\u8bb8\u5bfc\u5165\u865a\u62df\u4f20\u611f\u5668", + "allow_deconz_groups": "\u5141\u8bb8\u5bfc\u5165 deCONZ \u7fa4\u7ec4" + }, + "title": "deCONZ \u7684\u9644\u52a0\u914d\u7f6e\u9879" + } + }, + "title": "deCONZ" + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/zh-Hant.json b/homeassistant/components/deconz/.translations/zh-Hant.json new file mode 100644 index 0000000000000..4df506ad76f3f --- /dev/null +++ b/homeassistant/components/deconz/.translations/zh-Hant.json @@ -0,0 +1,109 @@ +{ + "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", + "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\u6574\u5408 {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" + }, + "title": "\u5b9a\u7fa9 deCONZ \u9598\u9053\u5668" + }, + "link": { + "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": { + "data": { + "allow_clip_sensor": "\u5141\u8a31\u532f\u5165\u865b\u64ec\u611f\u61c9\u5668", + "allow_deconz_groups": "\u5141\u8a31\u532f\u5165 deCONZ \u7fa4\u7d44" + }, + "title": "deCONZ \u9644\u52a0\u8a2d\u5b9a\u9078\u9805" + } + }, + "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 new file mode 100644 index 0000000000000..096bc6c2904c6 --- /dev/null +++ b/homeassistant/components/deconz/__init__.py @@ -0,0 +1,79 @@ +"""Support for deCONZ devices.""" +import voluptuous as vol + +from homeassistant.const import EVENT_HOMEASSISTANT_STOP + +from .config_flow import get_master_gateway +from .const import CONF_MASTER_GATEWAY, DOMAIN +from .gateway import DeconzGateway +from .services import async_setup_services, async_unload_services + +CONFIG_SCHEMA = vol.Schema( + {DOMAIN: vol.Schema({}, extra=vol.ALLOW_EXTRA)}, extra=vol.ALLOW_EXTRA +) + + +async def async_setup(hass, config): + """Old way of setting up deCONZ integrations.""" + return True + + +async def async_setup_entry(hass, config_entry): + """Set up a deCONZ bridge for a config entry. + + Load config, group, light and sensor data for server information. + Start websocket for push notification of state changes from deCONZ. + """ + 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 + + # 0.104 introduced config entry unique id, this makes upgrading possible + if config_entry.unique_id is None: + hass.config_entries.async_update_entry( + config_entry, unique_id=gateway.api.config.bridgeid + ) + + hass.data[DOMAIN][config_entry.unique_id] = gateway + + await gateway.async_update_device_registry() + + await async_setup_services(hass) + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, gateway.shutdown) + + return True + + +async def async_unload_entry(hass, config_entry): + """Unload deCONZ config entry.""" + gateway = hass.data[DOMAIN].pop(config_entry.unique_id) + + if not hass.data[DOMAIN]: + await async_unload_services(hass) + + 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) + + return await gateway.async_reset() + + +async def async_update_master_gateway(hass, config_entry): + """Update master gateway boolean. + + 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} + + hass.config_entries.async_update_entry(config_entry, options=options) diff --git a/homeassistant/components/deconz/binary_sensor.py b/homeassistant/components/deconz/binary_sensor.py new file mode 100644 index 0000000000000..6261473bb0e54 --- /dev/null +++ b/homeassistant/components/deconz/binary_sensor.py @@ -0,0 +1,98 @@ +"""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[key] for key in sorted(gateway.api.sensors, key=int)] + ) + + +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 0000000000000..ba1f1ce846af6 --- /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 new file mode 100644 index 0000000000000..dd37cc31faee5 --- /dev/null +++ b/homeassistant/components/deconz/config_flow.py @@ -0,0 +1,290 @@ +"""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_bridge_id, + normalize_bridge_id, +) +import voluptuous as vol + +from homeassistant import config_entries +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 .const import ( + CONF_ALLOW_CLIP_SENSOR, + CONF_ALLOW_DECONZ_GROUPS, + CONF_BRIDGEID, + DEFAULT_ALLOW_CLIP_SENSOR, + DEFAULT_ALLOW_DECONZ_GROUPS, + DEFAULT_PORT, + DOMAIN, +) + +DECONZ_MANUFACTURERURL = "http://www.dresden-elektronik.de" +CONF_SERIAL = "serial" + + +@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.bridge_id = None + self.bridges = [] + self.deconz_config = {} + + 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. + + 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. + """ + if user_input is not None: + for bridge in self.bridges: + if bridge[CONF_HOST] == user_input[CONF_HOST]: + self.bridge_id = bridge[CONF_BRIDGEID] + self.deconz_config = { + CONF_HOST: bridge[CONF_HOST], + CONF_PORT: bridge[CONF_PORT], + } + 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) + + try: + with async_timeout.timeout(10): + self.bridges = await async_discovery(session) + + except (asyncio.TimeoutError, ResponseError): + self.bridges = [] + + if len(self.bridges) == 1: + return await self.async_step_user(self.bridges[0]) + + 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)}), + ) + + 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.""" + errors = {} + + if user_input is not None: + session = aiohttp_client.async_get_clientsession(self.hass) + + 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._create_entry() + + return self.async_show_form(step_id="link", errors=errors) + + async def _create_entry(self): + """Create entry for gateway.""" + if not self.bridge_id: + session = aiohttp_client.async_get_clientsession(self.hass) + + try: + with async_timeout.timeout(10): + self.bridge_id = await async_get_bridge_id( + session, **self.deconz_config + ) + + for entry in self.hass.config_entries.async_entries(DOMAIN): + if self.bridge_id == entry.unique_id: + return self._update_entry( + entry, + host=self.deconz_config[CONF_HOST], + port=self.deconz_config[CONF_PORT], + api_key=self.deconz_config[CONF_API_KEY], + ) + + await self.async_set_unique_id(self.bridge_id) + + except asyncio.TimeoutError: + return self.async_abort(reason="no_bridges") + + return self.async_create_entry(title=self.bridge_id, data=self.deconz_config) + + 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") + + entry.data[CONF_HOST] = host + entry.data[CONF_PORT] = port + + if api_key is not None: + entry.data[CONF_API_KEY] = api_key + + self.hass.config_entries.async_update_entry(entry) + return self.async_abort(reason="updated_instance") + + async def async_step_ssdp(self, discovery_info): + """Handle a discovered deCONZ bridge.""" + if ( + discovery_info.get(ssdp.ATTR_UPNP_MANUFACTURER_URL) + != DECONZ_MANUFACTURERURL + ): + return self.async_abort(reason="not_deconz_bridge") + + self.bridge_id = normalize_bridge_id(discovery_info[ssdp.ATTR_UPNP_SERIAL]) + parsed_url = urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION]) + + for entry in self.hass.config_entries.async_entries(DOMAIN): + if self.bridge_id == entry.unique_id: + if entry.source == "hassio": + return self.async_abort(reason="already_configured") + return self._update_entry(entry, parsed_url.hostname, parsed_url.port) + + await self.async_set_unique_id(self.bridge_id) + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + 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. + """ + self.bridge_id = normalize_bridge_id(user_input[CONF_SERIAL]) + gateway = self.hass.data.get(DOMAIN, {}).get(self.bridge_id) + + if gateway: + return self._update_entry( + gateway.config_entry, + user_input[CONF_HOST], + user_input[CONF_PORT], + user_input[CONF_API_KEY], + ) + + await self.async_set_unique_id(self.bridge_id) + 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.""" + if user_input is not None: + self.deconz_config = { + CONF_HOST: self._hassio_discovery[CONF_HOST], + CONF_PORT: self._hassio_discovery[CONF_PORT], + CONF_API_KEY: self._hassio_discovery[CONF_API_KEY], + } + + return await self._create_entry() + + return self.async_show_form( + step_id="hassio_confirm", + description_placeholders={"addon": self._hassio_discovery["addon"]}, + ) + + +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 new file mode 100644 index 0000000000000..41ef80b367f40 --- /dev/null +++ b/homeassistant/components/deconz/const.py @@ -0,0 +1,53 @@ +"""Constants for the deCONZ component.""" +import logging + +_LOGGER = logging.getLogger(__package__) + +DOMAIN = "deconz" + +CONF_BRIDGEID = "bridgeid" + +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"] +SWITCH_TYPES = POWER_PLUGS + SIRENS + +CONF_GESTURE = "gesture" diff --git a/homeassistant/components/deconz/cover.py b/homeassistant/components/deconz/cover.py new file mode 100644 index 0000000000000..6e5e616fbb896 --- /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 0000000000000..68daee6cf260c --- /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 0000000000000..3c2442994a5e9 --- /dev/null +++ b/homeassistant/components/deconz/deconz_event.py @@ -0,0 +1,63 @@ +"""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, CONF_GESTURE +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, + } + if self._device.gesture: + data[CONF_GESTURE] = self._device.gesture + 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 0000000000000..e8322c18e9a96 --- /dev/null +++ b/homeassistant/components/deconz/device_trigger.py @@ -0,0 +1,381 @@ +"""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 .deconz_event import CONF_DECONZ_EVENT, CONF_GESTURE, CONF_UNIQUE_ID + +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_FLIP_90 = "remote_flip_90_degrees" +CONF_FLIP_180 = "remote_flip_180_degrees" +CONF_MOVE_ANY = "remote_moved_any_side" +CONF_DOUBLE_TAP_ANY = "remote_double_tap_any_side" +CONF_TURN_CW = "remote_turned_clockwise" +CONF_TURN_CCW = "remote_turned_counter_clockwise" +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): {CONF_EVENT: 1000}, + (CONF_SHORT_RELEASE, CONF_TURN_ON): {CONF_EVENT: 1002}, + (CONF_LONG_PRESS, CONF_TURN_ON): {CONF_EVENT: 1001}, + (CONF_LONG_RELEASE, CONF_TURN_ON): {CONF_EVENT: 1003}, + (CONF_SHORT_PRESS, CONF_DIM_UP): {CONF_EVENT: 2000}, + (CONF_SHORT_RELEASE, CONF_DIM_UP): {CONF_EVENT: 2002}, + (CONF_LONG_PRESS, CONF_DIM_UP): {CONF_EVENT: 2001}, + (CONF_LONG_RELEASE, CONF_DIM_UP): {CONF_EVENT: 2003}, + (CONF_SHORT_PRESS, CONF_DIM_DOWN): {CONF_EVENT: 3000}, + (CONF_SHORT_RELEASE, CONF_DIM_DOWN): {CONF_EVENT: 3002}, + (CONF_LONG_PRESS, CONF_DIM_DOWN): {CONF_EVENT: 3001}, + (CONF_LONG_RELEASE, CONF_DIM_DOWN): {CONF_EVENT: 3003}, + (CONF_SHORT_PRESS, CONF_TURN_OFF): {CONF_EVENT: 4000}, + (CONF_SHORT_RELEASE, CONF_TURN_OFF): {CONF_EVENT: 4002}, + (CONF_LONG_PRESS, CONF_TURN_OFF): {CONF_EVENT: 4001}, + (CONF_LONG_RELEASE, CONF_TURN_OFF): {CONF_EVENT: 4003}, +} + +HUE_TAP_REMOTE_MODEL = "ZGPSWITCH" +HUE_TAP_REMOTE = { + (CONF_SHORT_PRESS, CONF_BUTTON_1): {CONF_EVENT: 34}, + (CONF_SHORT_PRESS, CONF_BUTTON_2): {CONF_EVENT: 16}, + (CONF_SHORT_PRESS, CONF_BUTTON_3): {CONF_EVENT: 17}, + (CONF_SHORT_PRESS, CONF_BUTTON_4): {CONF_EVENT: 18}, +} + +SYMFONISK_SOUND_CONTROLLER_MODEL = "SYMFONISK Sound Controller" +SYMFONISK_SOUND_CONTROLLER = { + (CONF_SHORT_PRESS, CONF_TURN_ON): {CONF_EVENT: 1002}, + (CONF_DOUBLE_PRESS, CONF_TURN_ON): {CONF_EVENT: 1004}, + (CONF_TRIPLE_PRESS, CONF_TURN_ON): {CONF_EVENT: 1005}, + (CONF_ROTATED, CONF_LEFT): {CONF_EVENT: 2001}, + (CONF_ROTATION_STOPPED, CONF_LEFT): {CONF_EVENT: 2003}, + (CONF_ROTATED, CONF_RIGHT): {CONF_EVENT: 3001}, + (CONF_ROTATION_STOPPED, CONF_RIGHT): {CONF_EVENT: 3003}, +} + +TRADFRI_ON_OFF_SWITCH_MODEL = "TRADFRI on/off switch" +TRADFRI_ON_OFF_SWITCH = { + (CONF_SHORT_PRESS, CONF_TURN_ON): {CONF_EVENT: 1002}, + (CONF_LONG_PRESS, CONF_TURN_ON): {CONF_EVENT: 1001}, + (CONF_LONG_RELEASE, CONF_TURN_ON): {CONF_EVENT: 1003}, + (CONF_SHORT_PRESS, CONF_TURN_OFF): {CONF_EVENT: 2002}, + (CONF_LONG_PRESS, CONF_TURN_OFF): {CONF_EVENT: 2001}, + (CONF_LONG_RELEASE, CONF_TURN_OFF): {CONF_EVENT: 2003}, +} + +TRADFRI_OPEN_CLOSE_REMOTE_MODEL = "TRADFRI open/close remote" +TRADFRI_OPEN_CLOSE_REMOTE = { + (CONF_SHORT_PRESS, CONF_OPEN): {CONF_EVENT: 1002}, + (CONF_LONG_PRESS, CONF_OPEN): {CONF_EVENT: 1003}, + (CONF_SHORT_PRESS, CONF_CLOSE): {CONF_EVENT: 2002}, + (CONF_LONG_PRESS, CONF_CLOSE): {CONF_EVENT: 2003}, +} + +TRADFRI_REMOTE_MODEL = "TRADFRI remote control" +TRADFRI_REMOTE = { + (CONF_SHORT_PRESS, CONF_TURN_ON): {CONF_EVENT: 1002}, + (CONF_LONG_PRESS, CONF_TURN_ON): {CONF_EVENT: 1001}, + (CONF_SHORT_PRESS, CONF_DIM_UP): {CONF_EVENT: 2002}, + (CONF_LONG_PRESS, CONF_DIM_UP): {CONF_EVENT: 2001}, + (CONF_LONG_RELEASE, CONF_DIM_UP): {CONF_EVENT: 2003}, + (CONF_SHORT_PRESS, CONF_DIM_DOWN): {CONF_EVENT: 3002}, + (CONF_LONG_PRESS, CONF_DIM_DOWN): {CONF_EVENT: 3001}, + (CONF_LONG_RELEASE, CONF_DIM_DOWN): {CONF_EVENT: 3003}, + (CONF_SHORT_PRESS, CONF_LEFT): {CONF_EVENT: 4002}, + (CONF_LONG_PRESS, CONF_LEFT): {CONF_EVENT: 4001}, + (CONF_LONG_RELEASE, CONF_LEFT): {CONF_EVENT: 4003}, + (CONF_SHORT_PRESS, CONF_RIGHT): {CONF_EVENT: 5002}, + (CONF_LONG_PRESS, CONF_RIGHT): {CONF_EVENT: 5001}, + (CONF_LONG_RELEASE, CONF_RIGHT): {CONF_EVENT: 5003}, +} + +TRADFRI_WIRELESS_DIMMER_MODEL = "TRADFRI wireless dimmer" +TRADFRI_WIRELESS_DIMMER = { + (CONF_ROTATED, CONF_LEFT): {CONF_EVENT: 3002}, + (CONF_ROTATED, CONF_RIGHT): {CONF_EVENT: 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): {CONF_EVENT: 6002}, + (CONF_ROTATE_FROM_SIDE_1, CONF_SIDE_3): {CONF_EVENT: 3002}, + (CONF_ROTATE_FROM_SIDE_1, CONF_SIDE_4): {CONF_EVENT: 4002}, + (CONF_ROTATE_FROM_SIDE_1, CONF_SIDE_5): {CONF_EVENT: 1002}, + (CONF_ROTATE_FROM_SIDE_1, CONF_SIDE_6): {CONF_EVENT: 5002}, + (CONF_ROTATE_FROM_SIDE_2, CONF_SIDE_1): {CONF_EVENT: 2006}, + (CONF_ROTATE_FROM_SIDE_2, CONF_SIDE_3): {CONF_EVENT: 3006}, + (CONF_ROTATE_FROM_SIDE_2, CONF_SIDE_4): {CONF_EVENT: 4006}, + (CONF_ROTATE_FROM_SIDE_2, CONF_SIDE_5): {CONF_EVENT: 1006}, + (CONF_ROTATE_FROM_SIDE_2, CONF_SIDE_6): {CONF_EVENT: 5006}, + (CONF_ROTATE_FROM_SIDE_3, CONF_SIDE_1): {CONF_EVENT: 2003}, + (CONF_ROTATE_FROM_SIDE_3, CONF_SIDE_2): {CONF_EVENT: 6003}, + (CONF_ROTATE_FROM_SIDE_3, CONF_SIDE_4): {CONF_EVENT: 4003}, + (CONF_ROTATE_FROM_SIDE_3, CONF_SIDE_5): {CONF_EVENT: 1003}, + (CONF_ROTATE_FROM_SIDE_3, CONF_SIDE_6): {CONF_EVENT: 5003}, + (CONF_ROTATE_FROM_SIDE_4, CONF_SIDE_1): {CONF_EVENT: 2004}, + (CONF_ROTATE_FROM_SIDE_4, CONF_SIDE_2): {CONF_EVENT: 6004}, + (CONF_ROTATE_FROM_SIDE_4, CONF_SIDE_3): {CONF_EVENT: 3004}, + (CONF_ROTATE_FROM_SIDE_4, CONF_SIDE_5): {CONF_EVENT: 1004}, + (CONF_ROTATE_FROM_SIDE_4, CONF_SIDE_6): {CONF_EVENT: 5004}, + (CONF_ROTATE_FROM_SIDE_5, CONF_SIDE_1): {CONF_EVENT: 2001}, + (CONF_ROTATE_FROM_SIDE_5, CONF_SIDE_2): {CONF_EVENT: 6001}, + (CONF_ROTATE_FROM_SIDE_5, CONF_SIDE_3): {CONF_EVENT: 3001}, + (CONF_ROTATE_FROM_SIDE_5, CONF_SIDE_4): {CONF_EVENT: 4001}, + (CONF_ROTATE_FROM_SIDE_5, CONF_SIDE_6): {CONF_EVENT: 5001}, + (CONF_ROTATE_FROM_SIDE_6, CONF_SIDE_1): {CONF_EVENT: 2005}, + (CONF_ROTATE_FROM_SIDE_6, CONF_SIDE_2): {CONF_EVENT: 6005}, + (CONF_ROTATE_FROM_SIDE_6, CONF_SIDE_3): {CONF_EVENT: 3005}, + (CONF_ROTATE_FROM_SIDE_6, CONF_SIDE_4): {CONF_EVENT: 4005}, + (CONF_ROTATE_FROM_SIDE_6, CONF_SIDE_5): {CONF_EVENT: 1005}, + (CONF_MOVE, CONF_SIDE_1): {CONF_EVENT: 2000}, + (CONF_MOVE, CONF_SIDE_2): {CONF_EVENT: 6000}, + (CONF_MOVE, CONF_SIDE_3): {CONF_EVENT: 3000}, + (CONF_MOVE, CONF_SIDE_4): {CONF_EVENT: 4000}, + (CONF_MOVE, CONF_SIDE_5): {CONF_EVENT: 1000}, + (CONF_MOVE, CONF_SIDE_6): {CONF_EVENT: 5000}, + (CONF_DOUBLE_TAP, CONF_SIDE_1): {CONF_EVENT: 2002}, + (CONF_DOUBLE_TAP, CONF_SIDE_2): {CONF_EVENT: 6002}, + (CONF_DOUBLE_TAP, CONF_SIDE_3): {CONF_EVENT: 3003}, + (CONF_DOUBLE_TAP, CONF_SIDE_4): {CONF_EVENT: 4004}, + (CONF_DOUBLE_TAP, CONF_SIDE_5): {CONF_EVENT: 1001}, + (CONF_DOUBLE_TAP, CONF_SIDE_6): {CONF_EVENT: 5005}, + (CONF_AWAKE, ""): {CONF_GESTURE: 0}, + (CONF_SHAKE, ""): {CONF_GESTURE: 1}, + (CONF_FREE_FALL, ""): {CONF_GESTURE: 2}, + (CONF_FLIP_90, ""): {CONF_GESTURE: 3}, + (CONF_FLIP_180, ""): {CONF_GESTURE: 4}, + (CONF_MOVE_ANY, ""): {CONF_GESTURE: 5}, + (CONF_DOUBLE_TAP_ANY, ""): {CONF_GESTURE: 6}, + (CONF_TURN_CW, ""): {CONF_GESTURE: 7}, + (CONF_TURN_CCW, ""): {CONF_GESTURE: 8}, +} + +AQARA_DOUBLE_WALL_SWITCH_MODEL = "lumi.remote.b286acn01" +AQARA_DOUBLE_WALL_SWITCH = { + (CONF_SHORT_PRESS, CONF_LEFT): {CONF_EVENT: 1002}, + (CONF_LONG_PRESS, CONF_LEFT): {CONF_EVENT: 1001}, + (CONF_DOUBLE_PRESS, CONF_LEFT): {CONF_EVENT: 1004}, + (CONF_SHORT_PRESS, CONF_RIGHT): {CONF_EVENT: 2002}, + (CONF_LONG_PRESS, CONF_RIGHT): {CONF_EVENT: 2001}, + (CONF_DOUBLE_PRESS, CONF_RIGHT): {CONF_EVENT: 2004}, + (CONF_SHORT_PRESS, CONF_BOTH_BUTTONS): {CONF_EVENT: 3002}, + (CONF_LONG_PRESS, CONF_BOTH_BUTTONS): {CONF_EVENT: 3001}, + (CONF_DOUBLE_PRESS, CONF_BOTH_BUTTONS): {CONF_EVENT: 3004}, +} + +AQARA_DOUBLE_WALL_SWITCH_WXKG02LM_MODEL = "lumi.sensor_86sw2" +AQARA_DOUBLE_WALL_SWITCH_WXKG02LM = { + (CONF_SHORT_PRESS, CONF_LEFT): {CONF_EVENT: 1002}, + (CONF_SHORT_PRESS, CONF_RIGHT): {CONF_EVENT: 2002}, + (CONF_SHORT_PRESS, CONF_BOTH_BUTTONS): {CONF_EVENT: 3002}, +} + +AQARA_SINGLE_WALL_SWITCH_WXKG03LM_MODEL = "lumi.remote.b186acn01" +AQARA_SINGLE_WALL_SWITCH_WXKG03LM = { + (CONF_SHORT_PRESS, CONF_TURN_ON): {CONF_EVENT: 1002}, + (CONF_LONG_PRESS, CONF_TURN_ON): {CONF_EVENT: 1001}, + (CONF_DOUBLE_PRESS, CONF_TURN_ON): {CONF_EVENT: 1004}, +} + +AQARA_MINI_SWITCH_MODEL = "lumi.remote.b1acn01" +AQARA_MINI_SWITCH = { + (CONF_SHORT_PRESS, CONF_TURN_ON): {CONF_EVENT: 1002}, + (CONF_DOUBLE_PRESS, CONF_TURN_ON): {CONF_EVENT: 1004}, + (CONF_LONG_PRESS, CONF_TURN_ON): {CONF_EVENT: 1001}, + (CONF_LONG_RELEASE, CONF_TURN_ON): {CONF_EVENT: 1003}, +} + +AQARA_ROUND_SWITCH_MODEL = "lumi.sensor_switch" +AQARA_ROUND_SWITCH = { + (CONF_SHORT_PRESS, CONF_TURN_ON): {CONF_EVENT: 1000}, + (CONF_SHORT_RELEASE, CONF_TURN_ON): {CONF_EVENT: 1002}, + (CONF_DOUBLE_PRESS, CONF_TURN_ON): {CONF_EVENT: 1004}, + (CONF_TRIPLE_PRESS, CONF_TURN_ON): {CONF_EVENT: 1005}, + (CONF_QUADRUPLE_PRESS, CONF_TURN_ON): {CONF_EVENT: 1006}, + (CONF_QUINTUPLE_PRESS, CONF_TURN_ON): {CONF_EVENT: 1010}, + (CONF_LONG_PRESS, CONF_TURN_ON): {CONF_EVENT: 1001}, + (CONF_LONG_RELEASE, CONF_TURN_ON): {CONF_EVENT: 1003}, +} + +AQARA_SQUARE_SWITCH_MODEL = "lumi.sensor_switch.aq3" +AQARA_SQUARE_SWITCH = { + (CONF_SHORT_PRESS, CONF_TURN_ON): {CONF_EVENT: 1002}, + (CONF_DOUBLE_PRESS, CONF_TURN_ON): {CONF_EVENT: 1004}, + (CONF_LONG_PRESS, CONF_TURN_ON): {CONF_EVENT: 1001}, + (CONF_LONG_RELEASE, CONF_TURN_ON): {CONF_EVENT: 1003}, + (CONF_SHAKE, ""): {CONF_EVENT: 1007}, +} + +AQARA_SQUARE_SWITCH_WXKG11LM_2016_MODEL = "lumi.sensor_switch.aq2" +AQARA_SQUARE_SWITCH_WXKG11LM_2016 = { + (CONF_SHORT_PRESS, CONF_TURN_ON): {CONF_EVENT: 1002}, + (CONF_DOUBLE_PRESS, CONF_TURN_ON): {CONF_EVENT: 1004}, + (CONF_TRIPLE_PRESS, CONF_TURN_ON): {CONF_EVENT: 1005}, + (CONF_QUADRUPLE_PRESS, CONF_TURN_ON): {CONF_EVENT: 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_SINGLE_WALL_SWITCH_WXKG03LM_MODEL: AQARA_SINGLE_WALL_SWITCH_WXKG03LM, + 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.""" + for gateway in hass.data.get(DOMAIN, {}).values(): + + 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, **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 0000000000000..be13e579ce095 --- /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 0000000000000..04452cc313cae --- /dev/null +++ b/homeassistant/components/deconz/gateway.py @@ -0,0 +1,264 @@ +"""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_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.unique_id] + + +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.unique_id + + @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.""" + try: + self.api = await get_gateway( + self.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: + self.hass.async_create_task( + self.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 0000000000000..af708a1539179 --- /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 0000000000000..a327d7106fca7 --- /dev/null +++ b/homeassistant/components/deconz/manifest.json @@ -0,0 +1,15 @@ +{ + "domain": "deconz", + "name": "deCONZ", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/deconz", + "requirements": ["pydeconz==67"], + "ssdp": [ + { + "manufacturer": "Royal Philips Electronics" + } + ], + "dependencies": [], + "codeowners": ["@kane610"], + "quality_scale": "platinum" +} diff --git a/homeassistant/components/deconz/scene.py b/homeassistant/components/deconz/scene.py new file mode 100644 index 0000000000000..a84e799d44d47 --- /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 0000000000000..8194dd145dc03 --- /dev/null +++ b/homeassistant/components/deconz/sensor.py @@ -0,0 +1,254 @@ +"""Support for deCONZ sensors.""" +from pydeconz.sensor import ( + Battery, + 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 sensor.BINARY is False + and sensor.type not in Battery.ZHATYPE + 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[key] for key in sorted(gateway.api.sensors, key=int)] + ) + + +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 0000000000000..9d133acdb1dfb --- /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 new file mode 100644 index 0000000000000..bd5c2eb6a0c71 --- /dev/null +++ b/homeassistant/components/deconz/services.yaml @@ -0,0 +1,25 @@ +configure: + description: Set attribute of device in deCONZ. See https://home-assistant.io/integrations/deconz/#device-services for details. + fields: + 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. + 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 new file mode 100644 index 0000000000000..b61ea6236dafd --- /dev/null +++ b/homeassistant/components/deconz/strings.json @@ -0,0 +1,108 @@ +{ + "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" + } + }, + "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_flip_90_degrees": "Device flipped 90 degrees", + "remote_flip_180_degrees": "Device flipped 180 degrees", + "remote_moved_any_side": "Device moved with any side up", + "remote_double_tap_any_side": "Device double tapped on any side", + "remote_turned_clockwise": "Device turned clockwise", + "remote_turned_counter_clockwise": "Device turned counter clockwise", + "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 0000000000000..1b51256580aeb --- /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 0000000000000..694ff77fdb3d0 --- /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 0000000000000..f4035352e51ce --- /dev/null +++ b/homeassistant/components/decora/light.py @@ -0,0 +1,161 @@ +"""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 0000000000000..e16632718d193 --- /dev/null +++ b/homeassistant/components/decora/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "decora", + "name": "Leviton Decora", + "documentation": "https://www.home-assistant.io/integrations/decora", + "requirements": ["bluepy==1.3.0", "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 0000000000000..b4bea73456ed6 --- /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 0000000000000..7d8aa104bb031 --- /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 0000000000000..d340fb00d9408 --- /dev/null +++ b/homeassistant/components/decora_wifi/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "decora_wifi", + "name": "Leviton Decora Wi-Fi", + "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 0000000000000..506904a500af6 --- /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 0000000000000..c0a27b667c579 --- /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 0000000000000..cdec126589b92 --- /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 0000000000000..2d550a0851f83 --- /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 0000000000000..2cd238d078718 --- /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 0000000000000..ad40b688fcf42 --- /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 0000000000000..cefc645725e46 --- /dev/null +++ b/homeassistant/components/deluge/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..7df87490c6093 --- /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 0000000000000..7ac98f284c8a9 --- /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 48d8476759f91..0000000000000 --- a/homeassistant/components/demo.py +++ /dev/null @@ -1,131 +0,0 @@ -""" -homeassistant.components.demo -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Sets up a demo environment that mimics interaction with devices -""" -import time - -import homeassistant as ha -import homeassistant.bootstrap as bootstrap -import homeassistant.loader as loader -from homeassistant.const import ( - CONF_PLATFORM, ATTR_ENTITY_PICTURE, ATTR_ENTITY_ID) - -DOMAIN = "demo" - -DEPENDENCIES = [] - -COMPONENTS_WITH_DEMO_PLATFORM = [ - 'switch', 'light', 'thermostat', 'sensor', 'media_player', 'notify'] - - -def setup(hass, config): - """ Setup a demo environment. """ - group = loader.get_component('group') - configurator = loader.get_component('configurator') - - config.setdefault(ha.DOMAIN, {}) - config.setdefault(DOMAIN, {}) - - if config[DOMAIN].get('hide_demo_state') != 1: - hass.states.set('a.Demo_Mode', 'Enabled') - - # Setup sun - loader.get_component('sun').setup(hass, config) - - # Setup demo platforms - for component in COMPONENTS_WITH_DEMO_PLATFORM: - bootstrap.setup_component( - hass, component, {component: {CONF_PLATFORM: 'demo'}}) - - # Setup room groups - lights = hass.states.entity_ids('light') - switches = hass.states.entity_ids('switch') - group.setup_group(hass, 'living room', [lights[0], lights[1], switches[0]]) - group.setup_group(hass, 'bedroom', [lights[2], switches[1]]) - - # Setup scripts - bootstrap.setup_component( - hass, 'script', - {'script': { - 'demo': { - 'alias': 'Demo {}'.format(lights[0]), - 'sequence': [{ - 'execute_service': 'light.turn_off', - 'service_data': {ATTR_ENTITY_ID: lights[0]} - }, { - 'delay': {'seconds': 5} - }, { - 'execute_service': 'light.turn_on', - 'service_data': {ATTR_ENTITY_ID: lights[0]} - }, { - 'delay': {'seconds': 5} - }, { - 'execute_service': 'light.turn_off', - 'service_data': {ATTR_ENTITY_ID: lights[0]} - }] - }}}) - - # Setup scenes - bootstrap.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, - }}, - ]}) - - # Setup fake device tracker - hass.states.set("device_tracker.paulus", "home", - {ATTR_ENTITY_PICTURE: - "http://graph.facebook.com/schoutsen/picture"}) - hass.states.set("device_tracker.anne_therese", "not_home", - {ATTR_ENTITY_PICTURE: - "http://graph.facebook.com/anne.t.frederiksen/picture"}) - - hass.states.set("group.all_devices", "home", - { - "auto": True, - ATTR_ENTITY_ID: [ - "device_tracker.Paulus", - "device_tracker.Anne_Therese" - ] - }) - - # Setup 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]) - - request_id = configurator.request_config( - hass, "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", - submit_caption="I have pressed the button" - ) - - configurator_ids.append(request_id) - - return True diff --git a/homeassistant/components/demo/.translations/bg.json b/homeassistant/components/demo/.translations/bg.json new file mode 100644 index 0000000000000..3b1f5f8a8d235 --- /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 0000000000000..944d358e73913 --- /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/da.json b/homeassistant/components/demo/.translations/da.json new file mode 100644 index 0000000000000..ef01fcb4f3c35 --- /dev/null +++ b/homeassistant/components/demo/.translations/da.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Demo" + } +} \ 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 0000000000000..ef01fcb4f3c35 --- /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 0000000000000..ef01fcb4f3c35 --- /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 0000000000000..ef01fcb4f3c35 --- /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 0000000000000..bc093330c26de --- /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 0000000000000..51f0cd0064250 --- /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 0000000000000..ef01fcb4f3c35 --- /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 0000000000000..529170b111d40 --- /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/ko.json b/homeassistant/components/demo/.translations/ko.json new file mode 100644 index 0000000000000..d20943c7b3635 --- /dev/null +++ b/homeassistant/components/demo/.translations/ko.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "\ub370\ubaa8" + } +} \ 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 0000000000000..ef01fcb4f3c35 --- /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 0000000000000..ef01fcb4f3c35 --- /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 0000000000000..ef01fcb4f3c35 --- /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 0000000000000..ef01fcb4f3c35 --- /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 0000000000000..8183f28aed3f3 --- /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 0000000000000..8183f28aed3f3 --- /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 0000000000000..0438252a42991 --- /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 0000000000000..ef01fcb4f3c35 --- /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 0000000000000..cfb0fced0c2db --- /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 0000000000000..b6845d9d6a424 --- /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 0000000000000..9fe0f675d9d03 --- /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 0000000000000..0323b68b1b04a --- /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 0000000000000..0f6dfa9f35770 --- /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 0000000000000..42cb2b137a113 --- /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 0000000000000..f639dae9757c7 --- /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 0000000000000..0edcf618ba634 --- /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 0000000000000..e6b275920c8c1 --- /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 0000000000000..e11b0b0731a3b --- /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 0000000000000..20e3a52aa8d25 --- /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/demo/demo_0.jpg b/homeassistant/components/demo/demo_0.jpg new file mode 100644 index 0000000000000..f062b26bad798 Binary files /dev/null and b/homeassistant/components/demo/demo_0.jpg differ diff --git a/homeassistant/components/demo/demo_1.jpg b/homeassistant/components/demo/demo_1.jpg new file mode 100644 index 0000000000000..a349f22b1523e Binary files /dev/null and b/homeassistant/components/demo/demo_1.jpg differ diff --git a/homeassistant/components/demo/demo_2.jpg b/homeassistant/components/demo/demo_2.jpg new file mode 100644 index 0000000000000..e21d7457ebf01 Binary files /dev/null and b/homeassistant/components/demo/demo_2.jpg differ diff --git a/homeassistant/components/demo/demo_3.jpg b/homeassistant/components/demo/demo_3.jpg new file mode 100644 index 0000000000000..a349f22b1523e Binary files /dev/null and b/homeassistant/components/demo/demo_3.jpg differ diff --git a/homeassistant/components/demo/device_tracker.py b/homeassistant/components/demo/device_tracker.py new file mode 100644 index 0000000000000..02864111527b6 --- /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 0000000000000..966ba51cacb79 --- /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 0000000000000..6fc8e9c2e89f4 --- /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 0000000000000..9183609509e53 --- /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 0000000000000..a2c06b72986e8 --- /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 0000000000000..5074741d83d43 --- /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 0000000000000..77030623c9d43 --- /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 0000000000000..a3a647e097483 --- /dev/null +++ b/homeassistant/components/demo/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "demo", + "name": "Demo", + "documentation": "https://www.home-assistant.io/integrations/demo", + "requirements": [], + "dependencies": ["conversation", "zone", "group", "configurator"], + "codeowners": ["@home-assistant/core"], + "quality_scale": "internal" +} diff --git a/homeassistant/components/demo/media_player.py b/homeassistant/components/demo/media_player.py new file mode 100644 index 0000000000000..33fe4ee364709 --- /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 0000000000000..f390c042ce4f8 --- /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 0000000000000..70e0d3c8b6e13 --- /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 0000000000000..d2b2464468bde --- /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 0000000000000..a8a96b21c192b --- /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 0000000000000..a2c0103280bee --- /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 0000000000000..e0367fad6a920 --- /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 0000000000000..5c651198f5c3a --- /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/demo/tts.mp3 b/homeassistant/components/demo/tts.mp3 new file mode 100644 index 0000000000000..f34241c769856 Binary files /dev/null and b/homeassistant/components/demo/tts.mp3 differ diff --git a/homeassistant/components/demo/tts.py b/homeassistant/components/demo/tts.py new file mode 100644 index 0000000000000..b7be6349d98d9 --- /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 0000000000000..fb64f17a4521c --- /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 0000000000000..f9aca14124565 --- /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 0000000000000..8cc1b2f95fdfc --- /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 0000000000000..ab8cd1b896e84 --- /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 0000000000000..1c4e8b652f5df --- /dev/null +++ b/homeassistant/components/denon/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "denon", + "name": "Denon Network Receivers", + "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 0000000000000..cd9d6e8feb7cb --- /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 0000000000000..8877a7dfb3bd9 --- /dev/null +++ b/homeassistant/components/denonavr/__init__.py @@ -0,0 +1,35 @@ +"""The denonavr component.""" +import voluptuous as vol + +from homeassistant.const import ATTR_ENTITY_ID +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import dispatcher_send + +DOMAIN = "denonavr" + +SERVICE_GET_COMMAND = "get_command" +ATTR_COMMAND = "command" + +CALL_SCHEMA = vol.Schema({vol.Required(ATTR_ENTITY_ID): cv.comp_entity_ids}) + +GET_COMMAND_SCHEMA = CALL_SCHEMA.extend({vol.Required(ATTR_COMMAND): cv.string}) + +SERVICE_TO_METHOD = { + SERVICE_GET_COMMAND: {"method": "get_command", "schema": GET_COMMAND_SCHEMA} +} + + +def setup(hass, config): + """Set up the denonavr platform.""" + + def service_handler(service): + method = SERVICE_TO_METHOD.get(service.service) + data = service.data.copy() + data["method"] = method["method"] + dispatcher_send(hass, DOMAIN, data) + + for service in SERVICE_TO_METHOD: + schema = SERVICE_TO_METHOD[service]["schema"] + hass.services.register(DOMAIN, service, service_handler, schema=schema) + + return True diff --git a/homeassistant/components/denonavr/manifest.json b/homeassistant/components/denonavr/manifest.json new file mode 100644 index 0000000000000..1ecbe5b939f43 --- /dev/null +++ b/homeassistant/components/denonavr/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "denonavr", + "name": "Denon AVR Network Receivers", + "documentation": "https://www.home-assistant.io/integrations/denonavr", + "requirements": ["denonavr==0.7.11"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/denonavr/media_player.py b/homeassistant/components/denonavr/media_player.py new file mode 100644 index 0000000000000..350d065f9d926 --- /dev/null +++ b/homeassistant/components/denonavr/media_player.py @@ -0,0 +1,431 @@ +"""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 ( + ATTR_ENTITY_ID, + CONF_HOST, + CONF_NAME, + CONF_TIMEOUT, + CONF_ZONE, + ENTITY_MATCH_ALL, + STATE_OFF, + STATE_ON, + STATE_PAUSED, + STATE_PLAYING, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from . import DOMAIN + +_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 + ) + + async def async_added_to_hass(self): + """Register signal handler.""" + async_dispatcher_connect(self.hass, DOMAIN, self.signal_handler) + + def signal_handler(self, data): + """Handle domain-specific signal by calling appropriate method.""" + entity_ids = data[ATTR_ENTITY_ID] + if entity_ids == ENTITY_MATCH_ALL or self.entity_id in entity_ids: + params = { + key: value + for key, value in data.items() + if key not in ["entity_id", "method"] + } + getattr(self, data["method"])(**params) + + 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): + """Play or pause the media player.""" + return self._receiver.toggle_play_pause() + + def media_play(self): + """Send play command.""" + return self._receiver.play() + + def media_pause(self): + """Send pause command.""" + return self._receiver.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) + + def get_command(self, command, **kwargs): + """Send generic command.""" + self._receiver.send_get_command(command) diff --git a/homeassistant/components/denonavr/services.yaml b/homeassistant/components/denonavr/services.yaml new file mode 100644 index 0000000000000..889adc3af058c --- /dev/null +++ b/homeassistant/components/denonavr/services.yaml @@ -0,0 +1,11 @@ +# Describes the format for available webostv services + +get_command: + description: 'Send a generic http get command.' + fields: + entity_id: + description: Name(s) of the denonavr entities where to run the API method. + example: 'media_player.living_room_receiver' + command: + description: Endpoint of the command, including associated parameters. + example: '/goform/formiPhoneAppDirect.xml?RCKSK0410370' diff --git a/homeassistant/components/deutsche_bahn/__init__.py b/homeassistant/components/deutsche_bahn/__init__.py new file mode 100644 index 0000000000000..0b696174fd539 --- /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 0000000000000..7bca10f761dac --- /dev/null +++ b/homeassistant/components/deutsche_bahn/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..204518b2ce32b --- /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 0000000000000..56e087f0e5f81 --- /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.websocket_command( + { + vol.Required("type"): "device_automation/action/list", + vol.Required("device_id"): str, + } +) +@websocket_api.async_response +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.websocket_command( + { + vol.Required("type"): "device_automation/condition/list", + vol.Required("device_id"): str, + } +) +@websocket_api.async_response +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.websocket_command( + { + vol.Required("type"): "device_automation/trigger/list", + vol.Required("device_id"): str, + } +) +@websocket_api.async_response +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.websocket_command( + { + vol.Required("type"): "device_automation/action/capabilities", + vol.Required("action"): dict, + } +) +@websocket_api.async_response +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.websocket_command( + { + vol.Required("type"): "device_automation/condition/capabilities", + vol.Required("condition"): dict, + } +) +@websocket_api.async_response +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.websocket_command( + { + vol.Required("type"): "device_automation/trigger/capabilities", + vol.Required("trigger"): dict, + } +) +@websocket_api.async_response +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 0000000000000..40bfc4ca0a13e --- /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 0000000000000..2f7c0df01876f --- /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 0000000000000..291ade0f6079a --- /dev/null +++ b/homeassistant/components/device_automation/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "device_automation", + "name": "Device Automation", + "documentation": "https://www.home-assistant.io/integrations/device_automation", + "requirements": [], + "dependencies": ["webhook"], + "codeowners": ["@home-assistant/core"], + "quality_scale": "internal" +} diff --git a/homeassistant/components/device_automation/toggle_entity.py b/homeassistant/components/device_automation/toggle_entity.py new file mode 100644 index 0000000000000..7d84eb921e907 --- /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 4740336767f63..0000000000000 --- a/homeassistant/components/device_sun_light_trigger.py +++ /dev/null @@ -1,168 +0,0 @@ -""" -homeassistant.components.device_sun_light_trigger -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Provides functionality to turn on lights based on -the state of the sun and devices. -""" -import logging -from datetime import datetime, timedelta - -from homeassistant.const import STATE_HOME, STATE_NOT_HOME -from . import light, sun, device_tracker, group - -DOMAIN = "device_sun_light_trigger" -DEPENDENCIES = ['light', 'device_tracker', 'group', 'sun'] - -LIGHT_TRANSITION_TIME = timedelta(minutes=15) - -# Light profile to be used if none given -LIGHT_PROFILE = 'relax' - -CONF_LIGHT_PROFILE = 'light_profile' -CONF_LIGHT_GROUP = 'light_group' -CONF_DEVICE_GROUP = 'device_group' - - -# pylint: disable=too-many-branches -def setup(hass, config): - """ Triggers to turn lights on or off based on device precense. """ - - disable_turn_off = 'disable_turn_off' in config[DOMAIN] - - light_group = config[DOMAIN].get(CONF_LIGHT_GROUP, - light.ENTITY_ID_ALL_LIGHTS) - - light_profile = config[DOMAIN].get(CONF_LIGHT_PROFILE, LIGHT_PROFILE) - - device_group = config[DOMAIN].get(CONF_DEVICE_GROUP, - device_tracker.ENTITY_ID_ALL_DEVICES) - - logger = logging.getLogger(__name__) - - device_entity_ids = group.get_entity_ids(hass, device_group, - device_tracker.DOMAIN) - - if not device_entity_ids: - logger.error("No devices found to track") - - return False - - # Get the light IDs from the specified group - light_ids = group.get_entity_ids(hass, light_group, light.DOMAIN) - - if not light_ids: - logger.error("No lights found to turn on ") - - return False - - def calc_time_for_light_when_sunset(): - """ Calculates the time when to start fading lights in when sun sets. - Returns None if no next_setting data available. """ - next_setting = sun.next_setting(hass) - - if next_setting: - return next_setting - LIGHT_TRANSITION_TIME * len(light_ids) - else: - return None - - def schedule_light_on_sun_rise(entity, old_state, new_state): - """The moment sun sets we want to have all the lights on. - We will schedule to have each light start after one another - and slowly transition in.""" - - def turn_light_on_before_sunset(light_id): - """ Helper function to turn on lights slowly if there - are devices home and the light is not on yet. """ - if device_tracker.is_on(hass) and not light.is_on(hass, light_id): - - light.turn_on(hass, light_id, - transition=LIGHT_TRANSITION_TIME.seconds, - profile=light_profile) - - def turn_on(light_id): - """ Lambda can keep track of function parameters but not local - parameters. If we put the lambda directly in the below statement - only the last light will be turned on.. """ - return lambda now: turn_light_on_before_sunset(light_id) - - start_point = calc_time_for_light_when_sunset() - - if start_point: - for index, light_id in enumerate(light_ids): - hass.track_point_in_time(turn_on(light_id), - (start_point + - index * LIGHT_TRANSITION_TIME)) - - # Track every time sun rises so we can schedule a time-based - # pre-sun set event - hass.states.track_change(sun.ENTITY_ID, schedule_light_on_sun_rise, - sun.STATE_BELOW_HORIZON, sun.STATE_ABOVE_HORIZON) - - # If the sun is already above horizon - # schedule the time-based pre-sun set event - if sun.is_on(hass): - schedule_light_on_sun_rise(None, None, None) - - def check_light_on_dev_state_change(entity, old_state, new_state): - """ Function to handle tracked device state changes. """ - lights_are_on = group.is_on(hass, light_group) - - light_needed = not (lights_are_on or sun.is_on(hass)) - - # Specific device came home ? - if entity != device_tracker.ENTITY_ID_ALL_DEVICES and \ - new_state.state == STATE_HOME: - - # These variables are needed for the elif check - now = datetime.now() - 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.turn_on(hass, 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 < sun.next_setting(hass)): - - # 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.turn_on(hass, light_id) - - else: - # If this light didn't happen to be turned on yet so - # will all the following then, break. - break - - # Did all devices leave the house? - elif (entity == device_group and - new_state.state == STATE_NOT_HOME and lights_are_on and - not disable_turn_off): - - logger.info( - "Everyone has left but there are lights on. Turning them off") - - light.turn_off(hass, light_ids) - - # Track home coming of each device - hass.states.track_change( - device_entity_ids, check_light_on_dev_state_change, - STATE_NOT_HOME, STATE_HOME) - - # Track when all devices are gone to shut down lights - hass.states.track_change( - device_group, check_light_on_dev_state_change, - 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 0000000000000..af6abf544c66f --- /dev/null +++ b/homeassistant/components/device_sun_light_trigger/__init__.py @@ -0,0 +1,252 @@ +"""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_profile = conf.get(CONF_LIGHT_PROFILE) + + device_group = conf.get(CONF_DEVICE_GROUP) + + if device_group is None: + device_entity_ids = hass.states.async_entity_ids(device_tracker.DOMAIN) + else: + 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 + if light_group is None: + light_ids = hass.states.async_entity_ids(light.DOMAIN) + else: + light_ids = group.get_entity_ids(light_group, light.DOMAIN) + + if not light_ids: + logger.error("No lights found to turn on") + return False + + @callback + def anyone_home(): + """Test if anyone is home.""" + return any(device_tracker.is_on(dt_id) for dt_id in device_entity_ids) + + @callback + def any_light_on(): + """Test if any light on.""" + return any(light.is_on(light_id) for light_id in light_ids) + + 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 anyone_home() 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 = any_light_on() + 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.""" + # Make sure there is not someone home + if anyone_home(): + return + + # Check if any light is on + if not any_light_on(): + 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_entity_ids, + 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 0000000000000..702f870456454 --- /dev/null +++ b/homeassistant/components/device_sun_light_trigger/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "device_sun_light_trigger", + "name": "Presence-based Lights", + "documentation": "https://www.home-assistant.io/integrations/device_sun_light_trigger", + "requirements": [], + "dependencies": ["device_tracker", "group", "light", "person"], + "codeowners": [], + "quality_scale": "internal" +} 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 0000000000000..e69de29bb2d1d diff --git a/homeassistant/components/device_tracker/.translations/bg.json b/homeassistant/components/device_tracker/.translations/bg.json new file mode 100644 index 0000000000000..68affa5afd046 --- /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 0000000000000..3a95841559b22 --- /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 0000000000000..7e82f1a34f867 --- /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/da.json b/homeassistant/components/device_tracker/.translations/da.json new file mode 100644 index 0000000000000..d714b5b7d31c2 --- /dev/null +++ b/homeassistant/components/device_tracker/.translations/da.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/de.json b/homeassistant/components/device_tracker/.translations/de.json new file mode 100644 index 0000000000000..90a81db6b905b --- /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 0000000000000..1022608477eb1 --- /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 0000000000000..cfbf7bcfe3eba --- /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 0000000000000..4c59d5ea1c8a9 --- /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 0000000000000..112afc6689f36 --- /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 0000000000000..1834767222a97 --- /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\uc73c\uba74", + "is_not_home": "{entity_name} \uc774(\uac00) \uc678\ucd9c \uc911\uc774\uba74" + } + } +} \ 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 0000000000000..2c49f69266277 --- /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 0000000000000..31ab788f171fa --- /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 0000000000000..d714b5b7d31c2 --- /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 0000000000000..3930031ad38f9 --- /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 0000000000000..8a8f662183a29 --- /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 0000000000000..58767361fd4cc --- /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 0000000000000..11d876883d373 --- /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 0000000000000..456e09ebf0e8a --- /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 6d4db7ad7ed1d..7b42554b4c1e0 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -1,310 +1,176 @@ -""" -homeassistant.components.tracker -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Provides functionality to keep track of devices. -""" -import logging -import threading -import os -import csv -from datetime import datetime, timedelta - -from homeassistant.loader import get_component -from homeassistant.helpers import validate_config -import homeassistant.util as util - -from homeassistant.const import ( - STATE_HOME, STATE_NOT_HOME, ATTR_ENTITY_PICTURE, ATTR_FRIENDLY_NAME, - CONF_PLATFORM) -from homeassistant.components import group - -DOMAIN = "device_tracker" -DEPENDENCIES = [] - -SERVICE_DEVICE_TRACKER_RELOAD = "reload_devices_csv" - -GROUP_NAME_ALL_DEVICES = 'all devices' -ENTITY_ID_ALL_DEVICES = group.ENTITY_ID_FORMAT.format('all_devices') - -ENTITY_ID_FORMAT = DOMAIN + '.{}' - -# After how much time do we consider a device not home if -# it does not show up on scans -TIME_DEVICE_NOT_FOUND = timedelta(minutes=3) - -# Filename to save known devices to -KNOWN_DEVICES_FILE = "known_devices.csv" - -CONF_SECONDS = "interval_seconds" - -DEFAULT_CONF_SECONDS = 12 - -_LOGGER = logging.getLogger(__name__) - - -def is_on(hass, entity_id=None): - """ Returns if any or specified device is home. """ - entity = entity_id or ENTITY_ID_ALL_DEVICES - - return hass.states.is_state(entity, STATE_HOME) - - -def setup(hass, config): - """ Sets up the device tracker. """ - - if not validate_config(config, {DOMAIN: [CONF_PLATFORM]}, _LOGGER): - return False - - tracker_type = config[DOMAIN].get(CONF_PLATFORM) - - tracker_implementation = get_component( - 'device_tracker.{}'.format(tracker_type)) - - if tracker_implementation is None: - _LOGGER.error("Unknown device_tracker type specified.") - - return False - - device_scanner = tracker_implementation.get_scanner(hass, config) - - if device_scanner is None: - _LOGGER.error("Failed to initialize device scanner for %s", - tracker_type) - - return False - - seconds = util.convert(config[DOMAIN].get(CONF_SECONDS), int, - DEFAULT_CONF_SECONDS) - - tracker = DeviceTracker(hass, device_scanner, seconds) - - # We only succeeded if we got to parse the known devices file - return not tracker.invalid_known_devices_file - - -class DeviceTracker(object): - """ Class that tracks which devices are home and which are not. """ - - def __init__(self, hass, device_scanner, seconds): - self.hass = hass - - self.device_scanner = device_scanner - - self.lock = threading.Lock() - - # Dictionary to keep track of known devices and devices we track - self.tracked = {} - self.untracked_devices = set() - - # Did we encounter an invalid known devices file - self.invalid_known_devices_file = False - - # Wrap it in a func instead of lambda so it can be identified in - # the bus by its __name__ attribute. - def update_device_state(now): - """ Triggers update of the device states. """ - self.update_devices(now) - - dev_group = group.Group( - hass, GROUP_NAME_ALL_DEVICES, user_defined=False) - - def reload_known_devices_service(service): - """ Reload known devices file. """ - self._read_known_devices_file() - - self.update_devices(datetime.now()) - - dev_group.update_tracked_entity_ids(self.device_entity_ids) - - reload_known_devices_service(None) - - if self.invalid_known_devices_file: +"""Provide functionality to keep track of devices.""" +import asyncio + +import voluptuous as vol + +from homeassistant.const import ATTR_GPS_ACCURACY, STATE_HOME +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import async_track_utc_time_change +from homeassistant.helpers.typing import ConfigType, GPSType, HomeAssistantType +from homeassistant.loader import bind_hass + +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 + +SERVICE_SEE = "see" + +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, + } +) +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 +def is_on(hass: HomeAssistantType, entity_id: str): + """Return the state if any or a specified device is home.""" + return hass.states.is_state(entity_id, 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, +): + """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 + } + if attributes: + data[ATTR_ATTRIBUTES] = attributes + hass.services.call(DOMAIN, SERVICE_SEE, data) + + +async def async_setup(hass: HomeAssistantType, config: ConfigType): + """Set up the device tracker.""" + tracker = await legacy.get_tracker(hass, config) + + legacy_platforms = await setup.async_extract_config(hass, config) + + setup_tasks = [ + legacy_platform.async_setup_legacy(hass, tracker) + for legacy_platform in legacy_platforms + ] + + if setup_tasks: + await asyncio.wait(setup_tasks) + + async def async_platform_discovered(p_type, info): + """Load a platform.""" + platform = await setup.async_create_platform_type(hass, config, p_type, {}) + + if platform is None or platform.type != PLATFORM_TYPE_LEGACY: return - seconds = range(0, 60, seconds) - - _LOGGER.info("Device tracker interval second=%s", seconds) - hass.track_time_change(update_device_state, second=seconds) - - hass.services.register(DOMAIN, - SERVICE_DEVICE_TRACKER_RELOAD, - reload_known_devices_service) - - @property - def device_entity_ids(self): - """ Returns a set containing all device entity ids - that are being tracked. """ - return set(device['entity_id'] for device in self.tracked.values()) - - def _update_state(self, now, device, is_home): - """ Update the state of a device. """ - dev_info = self.tracked[device] - - if is_home: - # Update last seen if at home - dev_info['last_seen'] = now - else: - # State remains at home if it has been seen in the last - # TIME_DEVICE_NOT_FOUND - is_home = now - dev_info['last_seen'] < TIME_DEVICE_NOT_FOUND - - state = STATE_HOME if is_home else STATE_NOT_HOME - - self.hass.states.set( - dev_info['entity_id'], state, - dev_info['state_attr']) - - def update_devices(self, now): - """ Update device states based on the found devices. """ - if not self.lock.acquire(False): - return - - found_devices = set(dev.upper() for dev in - self.device_scanner.scan_devices()) - - for device in self.tracked: - is_home = device in found_devices - - self._update_state(now, device, is_home) - - if is_home: - found_devices.remove(device) - - # Did we find any devices that we didn't know about yet? - new_devices = found_devices - self.untracked_devices - - if new_devices: - self.untracked_devices.update(new_devices) - - # Write new devices to known devices file - if not self.invalid_known_devices_file: - - known_dev_path = self.hass.config.path(KNOWN_DEVICES_FILE) - - try: - # If file does not exist we will write the header too - is_new_file = not os.path.isfile(known_dev_path) - - with open(known_dev_path, 'a') as outp: - _LOGGER.info( - "Found %d new devices, updating %s", - len(new_devices), known_dev_path) - - writer = csv.writer(outp) - - if is_new_file: - writer.writerow(( - "device", "name", "track", "picture")) - - for device in new_devices: - # See if the device scanner knows the name - # else defaults to unknown device - dname = self.device_scanner.get_device_name(device) - name = dname or "unknown device" - - writer.writerow((device, name, 0, "")) - - except IOError: - _LOGGER.exception( - "Error updating %s with %d new devices", - known_dev_path, len(new_devices)) - - self.lock.release() - - # pylint: disable=too-many-branches - def _read_known_devices_file(self): - """ Parse and process the known devices file. """ - known_dev_path = self.hass.config.path(KNOWN_DEVICES_FILE) - - # Return if no known devices file exists - if not os.path.isfile(known_dev_path): - return - - self.lock.acquire() - - self.untracked_devices.clear() - - with open(known_dev_path) as inp: - default_last_seen = datetime(1990, 1, 1) - - # To track which devices need an entity_id assigned - need_entity_id = [] - - # All devices that are still in this set after we read the CSV file - # have been removed from the file and thus need to be cleaned up. - removed_devices = set(self.tracked.keys()) - - try: - for row in csv.DictReader(inp): - device = row['device'].upper() - - if row['track'] == '1': - if device in self.tracked: - # Device exists - removed_devices.remove(device) - else: - # We found a new device - need_entity_id.append(device) - - self.tracked[device] = { - 'name': row['name'], - 'last_seen': default_last_seen - } - - # Update state_attr with latest from file - state_attr = { - ATTR_FRIENDLY_NAME: row['name'] - } - - if row['picture']: - state_attr[ATTR_ENTITY_PICTURE] = row['picture'] - - self.tracked[device]['state_attr'] = state_attr - - else: - self.untracked_devices.add(device) - - # Remove existing devices that we no longer track - for device in removed_devices: - entity_id = self.tracked[device]['entity_id'] - - _LOGGER.info("Removing entity %s", entity_id) - - self.hass.states.remove(entity_id) - - self.tracked.pop(device) - - # Setup entity_ids for the new devices - used_entity_ids = [info['entity_id'] for device, info - in self.tracked.items() - if device not in need_entity_id] - - for device in need_entity_id: - name = self.tracked[device]['name'] - - entity_id = util.ensure_unique_string( - ENTITY_ID_FORMAT.format(util.slugify(name)), - used_entity_ids) - - used_entity_ids.append(entity_id) - - self.tracked[device]['entity_id'] = entity_id + await platform.async_setup_legacy(hass, tracker, info) - if not self.tracked: - _LOGGER.warning( - "No devices to track. Please update %s.", - known_dev_path) + discovery.async_listen_platform(hass, DOMAIN, async_platform_discovered) - _LOGGER.info("Loaded devices from %s", known_dev_path) + # Clean up stale devices + async_track_utc_time_change( + hass, tracker.async_update_stale, second=range(0, 60, 5) + ) - except KeyError: - self.invalid_known_devices_file = True + 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) + await tracker.async_see(**data) - _LOGGER.warning( - ("Invalid known devices file: %s. " - "We won't update it with new found devices."), - known_dev_path) + hass.services.async_register( + DOMAIN, SERVICE_SEE, async_see_service, SERVICE_SEE_PAYLOAD_SCHEMA + ) - finally: - self.lock.release() + # restore + await tracker.async_setup_tracked_device() + return True diff --git a/homeassistant/components/device_tracker/config_entry.py b/homeassistant/components/device_tracker/config_entry.py new file mode 100644 index 0000000000000..6c5cacac59173 --- /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 0000000000000..1778a87b36a75 --- /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 3d63c49209cd4..0000000000000 --- a/homeassistant/components/device_tracker/ddwrt.py +++ /dev/null @@ -1,161 +0,0 @@ -""" Supports scanning a DD-WRT router. """ -import logging -from datetime import timedelta -import re -import threading -import requests - -from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD -from homeassistant.helpers import validate_config -from homeassistant.util import Throttle -from homeassistant.components.device_tracker import DOMAIN - -# Return cached results if last scan was less then this time ago -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) - -_LOGGER = logging.getLogger(__name__) - -_DDWRT_DATA_REGEX = re.compile(r'\{(\w+)::([^\}]*)\}') - - -# pylint: disable=unused-argument -def get_scanner(hass, config): - """ Validates config and returns a DdWrt scanner. """ - if not validate_config(config, - {DOMAIN: [CONF_HOST, CONF_USERNAME, CONF_PASSWORD]}, - _LOGGER): - return None - - scanner = DdWrtDeviceScanner(config[DOMAIN]) - - return scanner if scanner.success_init else None - - -# pylint: disable=too-many-instance-attributes -class DdWrtDeviceScanner(object): - """ This class queries a wireless router running DD-WRT firmware - for connected devices. Adapted from Tomato scanner. - """ - - def __init__(self, config): - self.host = config[CONF_HOST] - self.username = config[CONF_USERNAME] - self.password = config[CONF_PASSWORD] - - self.lock = threading.Lock() - - self.last_results = {} - - self.mac2name = None - - # Test the router is accessible - url = 'http://{}/Status_Wireless.live.asp'.format(self.host) - data = self.get_ddwrt_data(url) - self.success_init = data is not None - - def scan_devices(self): - """ Scans for new devices and return a - list containing found device ids. """ - - self._update_info() - - return self.last_results - - def get_device_name(self, device): - """ Returns the name of the given device or None if we don't know. """ - - with self.lock: - # if not initialised and not already scanned and not found - if self.mac2name is None or device not in self.mac2name: - url = 'http://{}/Status_Lan.live.asp'.format(self.host) - data = self.get_ddwrt_data(url) - - if not data: - return - - dhcp_leases = data.get('dhcp_leases', None) - if dhcp_leases: - # remove leading and trailing single quotes - cleaned_str = dhcp_leases.strip().strip('"') - elements = cleaned_str.split('","') - num_clients = int(len(elements)/5) - self.mac2name = {} - for idx in range(0, num_clients): - # this is stupid but the data is a single array - # every 5 elements represents one hosts, 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, None) - - @Throttle(MIN_TIME_BETWEEN_SCANS) - def _update_info(self): - """ Ensures the information from the DdWrt router is up to date. - Returns boolean if scanning successful. """ - if not self.success_init: - return False - - with self.lock: - _LOGGER.info("Checking ARP") - - url = 'http://{}/Status_Wireless.live.asp'.format(self.host) - data = self.get_ddwrt_data(url) - - if not data: - return False - - if data: - self.last_results = [] - active_clients = data.get('active_wireless', None) - if active_clients: - # This is really lame, instead of using JSON the ddwrt UI - # uses it's own data format for some reason and then - # regex's out values so I guess I have to do the same, - # LAME!!! - - # remove leading and trailing single quotes - clean_str = active_clients.strip().strip("'") - elements = clean_str.split("','") - - num_clients = int(len(elements)/9) - for idx in range(0, num_clients): - # get every 9th element which is the MAC address - index = idx * 9 - if index < len(elements): - self.last_results.append(elements[index]) - - return True - - return False - - 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) - elif response.status_code == 401: - # Authentication error - _LOGGER.exception( - "Failed to authenticate, " - "please check your username and password") - return - else: - _LOGGER.error("Invalid response from ddwrt: %s", response) - - -def _parse_ddwrt_response(data_str): - """ Parse the awful DD-WRT data format, why didn't they use JSON????. - This code is a python version of how they are parsing in the JS """ - return { - key: val for key, val in _DDWRT_DATA_REGEX - .findall(data_str)} diff --git a/homeassistant/components/device_tracker/device_condition.py b/homeassistant/components/device_tracker/device_condition.py new file mode 100644 index 0000000000000..9bdfc12db3991 --- /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/legacy.py b/homeassistant/components/device_tracker/legacy.py new file mode 100644 index 0000000000000..b7d529f18acf2 --- /dev/null +++ b/homeassistant/components/device_tracker/legacy.py @@ -0,0 +1,593 @@ +"""Legacy device tracker classes.""" +import asyncio +from datetime import timedelta +import hashlib +from typing import Any, List, Sequence + +import voluptuous as vol + +from homeassistant import util +from homeassistant.components import zone +from homeassistant.config import async_log_exception, load_yaml_config_file +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_GPS_ACCURACY, + ATTR_ICON, + ATTR_LATITUDE, + ATTR_LONGITUDE, + ATTR_NAME, + CONF_ICON, + CONF_MAC, + CONF_NAME, + DEVICE_DEFAULT_NAME, + STATE_HOME, + STATE_NOT_HOME, +) +from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_registry import async_get_registry +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.typing import GPSType, HomeAssistantType +import homeassistant.util.dt as dt_util +from homeassistant.util.yaml import dump + +from .const import ( + ATTR_BATTERY, + ATTR_HOST_NAME, + ATTR_MAC, + ATTR_SOURCE_TYPE, + CONF_AWAY_HIDE, + CONF_CONSIDER_HOME, + CONF_NEW_DEVICE_DEFAULTS, + CONF_TRACK_NEW, + DEFAULT_AWAY_HIDE, + DEFAULT_CONSIDER_HOME, + DEFAULT_TRACK_NEW, + DOMAIN, + ENTITY_ID_FORMAT, + LOGGER, + SOURCE_TYPE_GPS, +) + +YAML_DEVICES = "known_devices.yaml" +EVENT_NEW_DEVICE = "device_tracker_new_device" + + +async def get_tracker(hass, config): + """Create a tracker.""" + yaml_path = hass.config.path(YAML_DEVICES) + + conf = config.get(DOMAIN, []) + conf = conf[0] if conf else {} + consider_home = conf.get(CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME) + + defaults = conf.get(CONF_NEW_DEVICE_DEFAULTS, {}) + track_new = conf.get(CONF_TRACK_NEW) + if track_new is None: + track_new = defaults.get(CONF_TRACK_NEW, DEFAULT_TRACK_NEW) + + devices = await async_load_config(yaml_path, hass, consider_home) + tracker = DeviceTracker(hass, consider_home, track_new, defaults, devices) + return tracker + + +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._is_updating = asyncio.Lock() + + 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, + ) + ) + + async 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. + """ + registry = await async_get_registry(self.hass) + if mac is None and dev_id is None: + raise HomeAssistantError("Neither mac or device id passed in") + if 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: + await device.async_seen( + host_name, + location_name, + gps, + gps_accuracy, + battery, + attributes, + source_type, + consider_home, + ) + if device.track: + await device.async_update_ha_state() + return + + # Guard from calling see on entity registry entities. + entity_id = ENTITY_ID_FORMAT.format(dev_id) + if registry.async_is_registered(entity_id): + LOGGER.error( + "The see service is not supported for this entity %s", entity_id + ) + 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 + + await device.async_seen( + host_name, + location_name, + gps, + gps_accuracy, + battery, + attributes, + source_type, + ) + + if device.track: + await device.async_update_ha_state() + + self.hass.bus.async_fire( + EVENT_NEW_DEVICE, + { + ATTR_ENTITY_ID: device.entity_id, + ATTR_HOST_NAME: device.host_name, + 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_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_create_task(device.async_update_ha_state(True)) + + async def async_setup_tracked_device(self): + """Set up all not exists tracked devices. + + This method is a coroutine. + """ + + async def async_init_single_device(dev): + """Init a single device_tracker entity.""" + await dev.async_added_to_hass() + await 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_create_task(async_init_single_device(device)) + ) + + if tasks: + await asyncio.wait(tasks) + + +class Device(RestoreEntity): + """Represent a tracked device.""" + + host_name: str = None + location_name: str = None + gps: GPSType = None + gps_accuracy: int = 0 + last_seen: dt_util.dt.datetime = None + consider_home: dt_util.dt.timedelta = None + battery: int = None + attributes: dict = None + icon: str = None + + # 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 + + async 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 + await self.async_update() + + def stale(self, now: dt_util.dt.datetime = None): + """Return if device state is stale. + + Async friendly. + """ + return ( + self.last_seen is None + or (now or dt_util.utcnow()) - self.last_seen > self.consider_home + ) + + def mark_stale(self): + """Mark the device state as stale.""" + self._state = STATE_NOT_HOME + self.gps = None + self.last_update_home = False + + async 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 = zone.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.mark_stale() + else: + self._state = STATE_HOME + self.last_update_home = True + + async def async_added_to_hass(self): + """Add an entity.""" + await super().async_added_to_hass() + state = await self.async_get_last_state() + if not state: + return + self._state = state.state + self.last_update_home = state.state == STATE_HOME + self.last_seen = dt_util.utcnow() + + 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: HomeAssistantType = None + + 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) + + +async 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 + ), + } + ) + result = [] + try: + devices = await hass.async_add_job(load_yaml_config_file, path) + except HomeAssistantError as err: + LOGGER.error("Unable to load %s: %s", path, str(err)) + return [] + except FileNotFoundError: + 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 + + +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. + """ + + 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/luci.py b/homeassistant/components/device_tracker/luci.py deleted file mode 100644 index 637c48ddf261b..0000000000000 --- a/homeassistant/components/device_tracker/luci.py +++ /dev/null @@ -1,148 +0,0 @@ -""" Supports scanning a OpenWRT router. """ -import logging -import json -from datetime import timedelta -import re -import threading -import requests - -from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD -from homeassistant.helpers import validate_config -from homeassistant.util import Throttle -from homeassistant.components.device_tracker import DOMAIN - -# Return cached results if last scan was less then this time ago -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) - -_LOGGER = logging.getLogger(__name__) - - -def get_scanner(hass, config): - """ Validates config and returns a Luci scanner. """ - if not validate_config(config, - {DOMAIN: [CONF_HOST, CONF_USERNAME, CONF_PASSWORD]}, - _LOGGER): - return None - - scanner = LuciDeviceScanner(config[DOMAIN]) - - return scanner if scanner.success_init else None - - -# pylint: disable=too-many-instance-attributes -class LuciDeviceScanner(object): - """ This class queries a wireless router running OpenWrt firmware - for connected devices. Adapted from Tomato scanner. - - # opkg install luci-mod-rpc - for this to work on the router. - - The API is described here: - http://luci.subsignal.org/trac/wiki/Documentation/JsonRpcHowTo - - (Currently, we do only wifi iwscan, and no DHCP lease access.) - """ - - def __init__(self, config): - host = config[CONF_HOST] - username, password = config[CONF_USERNAME], config[CONF_PASSWORD] - - self.parse_api_pattern = re.compile(r"(?P\w*) = (?P.*);") - - self.lock = threading.Lock() - - self.last_results = {} - - self.token = _get_token(host, username, password) - self.host = host - - self.mac2name = None - self.success_init = self.token is not None - - def scan_devices(self): - """ Scans for new devices and return a - list containing found device ids. """ - - self._update_info() - - return self.last_results - - def get_device_name(self, device): - """ Returns the name of the given device or None if we don't know. """ - - with self.lock: - if self.mac2name is None: - url = 'http://{}/cgi-bin/luci/rpc/uci'.format(self.host) - result = _req_json_rpc(url, 'get_all', 'dhcp', - params={'auth': self.token}) - if result: - hosts = [x for x in result.values() - if x['.type'] == 'host' and - 'mac' in x and 'name' in x] - mac2name_list = [(x['mac'], x['name']) for x in hosts] - self.mac2name = dict(mac2name_list) - else: - # Error, handled in the _req_json_rpc - return - return self.mac2name.get(device, None) - - @Throttle(MIN_TIME_BETWEEN_SCANS) - def _update_info(self): - """ Ensures the information from the Luci router is up to date. - Returns boolean if scanning successful. """ - if not self.success_init: - return False - - with self.lock: - _LOGGER.info("Checking ARP") - - url = 'http://{}/cgi-bin/luci/rpc/sys'.format(self.host) - result = _req_json_rpc(url, 'net.arptable', - params={'auth': self.token}) - if result: - self.last_results = [] - for device_entry in result: - # Check if the Flags for each device contain - # NUD_REACHABLE and if so, add it to last_results - if int(device_entry['Flags'], 16) & 0x2: - self.last_results.append(device_entry['HW address']) - - return True - - return False - - -def _req_json_rpc(url, method, *args, **kwargs): - """ Perform one JSON RPC operation. """ - data = json.dumps({'method': method, 'params': args}) - try: - res = requests.post(url, data=data, timeout=5, **kwargs) - except requests.exceptions.Timeout: - _LOGGER.exception("Connection to the router timed out") - return - if res.status_code == 200: - try: - result = res.json() - except ValueError: - # If json decoder could not parse the response - _LOGGER.exception("Failed to parse response from luci") - return - try: - return result['result'] - except KeyError: - _LOGGER.exception("No result in response from luci") - return - elif res.status_code == 401: - # Authentication error - _LOGGER.exception( - "Failed to authenticate, " - "please check your username and password") - return - else: - _LOGGER.error("Invalid response from luci: %s", res) - - -def _get_token(host, username, password): - """ Get authentication token for the given host+username+password """ - url = 'http://{}/cgi-bin/luci/rpc/auth'.format(host) - return _req_json_rpc(url, 'login', username, password) diff --git a/homeassistant/components/device_tracker/manifest.json b/homeassistant/components/device_tracker/manifest.json new file mode 100644 index 0000000000000..35b9a4a3fdb30 --- /dev/null +++ b/homeassistant/components/device_tracker/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "device_tracker", + "name": "Device Tracker", + "documentation": "https://www.home-assistant.io/integrations/device_tracker", + "requirements": [], + "dependencies": ["group", "zone"], + "codeowners": [], + "quality_scale": "internal" +} diff --git a/homeassistant/components/device_tracker/netgear.py b/homeassistant/components/device_tracker/netgear.py deleted file mode 100644 index aac0953674650..0000000000000 --- a/homeassistant/components/device_tracker/netgear.py +++ /dev/null @@ -1,89 +0,0 @@ -""" Supports scanning a Netgear router. """ -import logging -from datetime import timedelta -import threading - -from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD -from homeassistant.helpers import validate_config -from homeassistant.util import Throttle -from homeassistant.components.device_tracker import DOMAIN - -# Return cached results if last scan was less then this time ago -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) - -_LOGGER = logging.getLogger(__name__) - - -def get_scanner(hass, config): - """ Validates config and returns a Netgear scanner. """ - if not validate_config(config, - {DOMAIN: [CONF_HOST, CONF_USERNAME, CONF_PASSWORD]}, - _LOGGER): - return None - - info = config[DOMAIN] - - scanner = NetgearDeviceScanner( - info[CONF_HOST], info[CONF_USERNAME], info[CONF_PASSWORD]) - - return scanner if scanner.success_init else None - - -class NetgearDeviceScanner(object): - """ This class queries a Netgear wireless router using the SOAP-api. """ - - def __init__(self, host, username, password): - self.last_results = [] - - try: - # Pylint does not play nice if not every folders has an __init__.py - # pylint: disable=no-name-in-module, import-error - import homeassistant.external.pynetgear.pynetgear as pynetgear - except ImportError: - _LOGGER.exception( - ("Failed to import pynetgear. " - "Did you maybe not run `git submodule init` " - "and `git submodule update`?")) - - self.success_init = False - - return - - self._api = pynetgear.Netgear(host, username, password) - self.lock = threading.Lock() - - _LOGGER.info("Logging in") - - self.success_init = self._api.login() - - if self.success_init: - self._update_info() - else: - _LOGGER.error("Failed to Login") - - def scan_devices(self): - """ Scans for new devices and return a - list containing found device ids. """ - self._update_info() - - return (device.mac for device in self.last_results) - - def get_device_name(self, mac): - """ Returns the name of the given device or None if we don't know. """ - try: - return next(device.name for device in self.last_results - if device.mac == mac) - except StopIteration: - return None - - @Throttle(MIN_TIME_BETWEEN_SCANS) - def _update_info(self): - """ Retrieves latest information from the Netgear router. - Returns boolean if scanning successful. """ - if not self.success_init: - return - - with self.lock: - _LOGGER.info("Scanning") - - self.last_results = self._api.get_attached_devices() or [] diff --git a/homeassistant/components/device_tracker/nmap_tracker.py b/homeassistant/components/device_tracker/nmap_tracker.py deleted file mode 100644 index b221a815fb8e0..0000000000000 --- a/homeassistant/components/device_tracker/nmap_tracker.py +++ /dev/null @@ -1,140 +0,0 @@ -""" Supports scanning using nmap. """ -import logging -from datetime import timedelta, datetime -from collections import namedtuple -import subprocess -import re - -from libnmap.process import NmapProcess -from libnmap.parser import NmapParser, NmapParserException - -from homeassistant.const import CONF_HOSTS -from homeassistant.helpers import validate_config -from homeassistant.util import Throttle, convert -from homeassistant.components.device_tracker import DOMAIN - -# Return cached results if last scan was less then this time ago -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) - -_LOGGER = logging.getLogger(__name__) - -# interval in minutes to exclude devices from a scan while they are home -CONF_HOME_INTERVAL = "home_interval" - - -def get_scanner(hass, config): - """ Validates config and returns a Nmap scanner. """ - if not validate_config(config, {DOMAIN: [CONF_HOSTS]}, - _LOGGER): - return None - - scanner = NmapDeviceScanner(config[DOMAIN]) - - return scanner if scanner.success_init else None - -Device = namedtuple("Device", ["mac", "name", "ip", "last_update"]) - - -def _arp(ip_address): - """ Get the MAC address for a given IP """ - cmd = ['arp', '-n', ip_address] - arp = subprocess.Popen(cmd, stdout=subprocess.PIPE) - out, _ = arp.communicate() - match = re.search(r'(([0-9A-Fa-f]{1,2}\:){5}[0-9A-Fa-f]{1,2})', str(out)) - if match: - return match.group(0) - _LOGGER.info("No MAC address found for %s", ip_address) - return '' - - -class NmapDeviceScanner(object): - """ This class scans for devices using nmap """ - - def __init__(self, config): - self.last_results = [] - - self.hosts = config[CONF_HOSTS] - minutes = convert(config.get(CONF_HOME_INTERVAL), int, 0) - self.home_interval = timedelta(minutes=minutes) - - self.success_init = True - self._update_info() - _LOGGER.info("nmap scanner initialized") - - def scan_devices(self): - """ Scans for new devices and return a - list containing found device ids. """ - - self._update_info() - - return [device.mac for device in self.last_results] - - def get_device_name(self, mac): - """ Returns the name of the given device or None if we don't know. """ - - filter_named = [device.name for device in self.last_results - if device.mac == mac] - - if filter_named: - return filter_named[0] - else: - return None - - def _parse_results(self, stdout): - """ Parses results from an nmap scan. - Returns True if successful, False otherwise. """ - try: - results = NmapParser.parse(stdout) - now = datetime.now() - self.last_results = [] - for host in results.hosts: - if host.is_up(): - if host.hostnames: - name = host.hostnames[0] - else: - name = host.ipv4 - if host.mac: - mac = host.mac - else: - mac = _arp(host.ipv4) - if mac: - device = Device(mac, name, host.ipv4, now) - self.last_results.append(device) - _LOGGER.info("nmap scan successful") - return True - except NmapParserException as parse_exc: - _LOGGER.error("failed to parse nmap results: %s", parse_exc.msg) - self.last_results = [] - return False - - @Throttle(MIN_TIME_BETWEEN_SCANS) - def _update_info(self): - """ Scans the network for devices. - Returns boolean if scanning successful. """ - if not self.success_init: - return False - - _LOGGER.info("Scanning") - - options = "-F --host-timeout 5" - exclude_targets = set() - if self.home_interval: - now = datetime.now() - for host in self.last_results: - if host.last_update + self.home_interval > now: - exclude_targets.add(host) - if len(exclude_targets) > 0: - target_list = [t.ip for t in exclude_targets] - options += " --exclude {}".format(",".join(target_list)) - - nmap = NmapProcess(targets=self.hosts, options=options) - - nmap.run() - - if nmap.rc == 0: - if self._parse_results(nmap.stdout): - self.last_results.extend(exclude_targets) - else: - self.last_results = [] - _LOGGER.error(nmap.stderr) - return False diff --git a/homeassistant/components/device_tracker/services.yaml b/homeassistant/components/device_tracker/services.yaml new file mode 100644 index 0000000000000..51865034b00fb --- /dev/null +++ b/homeassistant/components/device_tracker/services.yaml @@ -0,0 +1,26 @@ +# Describes the format for available device tracker services + +see: + description: Control tracked device. + fields: + mac: + description: MAC address of device + example: 'FF:FF:FF:FF:FF:FF' + dev_id: + description: Id of device (find id in known_devices.yaml). + example: 'phonedave' + host_name: + description: Hostname of device + example: 'Dave' + location_name: + description: Name of location where device is located (not_home is away). + example: 'home' + gps: + description: GPS coordinates where device is located (latitude, longitude). + example: '[51.509802, -0.086692]' + gps_accuracy: + description: Accuracy of GPS coordinates. + example: '80' + battery: + description: Battery level of device. + example: '100' diff --git a/homeassistant/components/device_tracker/setup.py b/homeassistant/components/device_tracker/setup.py new file mode 100644 index 0000000000000..42751b1a7845d --- /dev/null +++ b/homeassistant/components/device_tracker/setup.py @@ -0,0 +1,198 @@ +"""Device tracker helpers.""" +import asyncio +from types import ModuleType +from typing import Any, Callable, Dict, Optional + +import attr + +from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE +from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_per_platform +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.typing import ConfigType, HomeAssistantType +from homeassistant.setup import async_prepare_setup_platform +from homeassistant.util import dt as dt_util + +from .const import ( + CONF_SCAN_INTERVAL, + DOMAIN, + LOGGER, + PLATFORM_TYPE_LEGACY, + SCAN_INTERVAL, + SOURCE_TYPE_ROUTER, +) + + +@attr.s +class DeviceTrackerPlatform: + """Class to hold platform information.""" + + LEGACY_SETUP = ( + "async_get_scanner", + "get_scanner", + "async_setup_scanner", + "setup_scanner", + ) + + name = attr.ib(type=str) + platform = attr.ib(type=ModuleType) + config = attr.ib(type=Dict) + + @property + def type(self): + """Return platform type.""" + for methods, platform_type in ((self.LEGACY_SETUP, PLATFORM_TYPE_LEGACY),): + for meth in methods: + if hasattr(self.platform, meth): + return platform_type + + return None + + async def async_setup_legacy(self, hass, tracker, discovery_info=None): + """Set up a legacy platform.""" + LOGGER.info("Setting up %s.%s", DOMAIN, self.type) + try: + scanner = None + setup = None + if hasattr(self.platform, "async_get_scanner"): + scanner = await self.platform.async_get_scanner( + hass, {DOMAIN: self.config} + ) + elif hasattr(self.platform, "get_scanner"): + scanner = await hass.async_add_job( + self.platform.get_scanner, hass, {DOMAIN: self.config} + ) + elif hasattr(self.platform, "async_setup_scanner"): + setup = await self.platform.async_setup_scanner( + hass, self.config, tracker.async_see, discovery_info + ) + elif hasattr(self.platform, "setup_scanner"): + setup = await hass.async_add_job( + self.platform.setup_scanner, + hass, + self.config, + tracker.see, + discovery_info, + ) + else: + raise HomeAssistantError("Invalid legacy device_tracker platform.") + + if scanner: + async_setup_scanner_platform( + hass, self.config, scanner, tracker.async_see, self.type + ) + return + + if not setup: + LOGGER.error("Error setting up platform %s", self.type) + return + + except Exception: # pylint: disable=broad-except + LOGGER.exception("Error setting up platform %s", self.type) + + +async def async_extract_config(hass, config): + """Extract device tracker config and split between legacy and modern.""" + legacy = [] + + for platform in await asyncio.gather( + *( + async_create_platform_type(hass, config, p_type, p_config) + for p_type, p_config in config_per_platform(config, DOMAIN) + ) + ): + if platform is None: + continue + + if platform.type == PLATFORM_TYPE_LEGACY: + legacy.append(platform) + else: + raise ValueError( + "Unable to determine type for {}: {}".format( + platform.name, platform.type + ) + ) + + return legacy + + +async def async_create_platform_type( + hass, config, p_type, p_config +) -> Optional[DeviceTrackerPlatform]: + """Determine type of platform.""" + platform = await async_prepare_setup_platform(hass, config, DOMAIN, p_type) + + if platform is None: + return None + + return DeviceTrackerPlatform(p_type, platform, p_config) + + +@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, SCAN_INTERVAL) + update_lock = asyncio.Lock() + scanner.hass = hass + + # Initial scan of each mac we also tell about host name for config + seen: Any = set() + + 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(hass.components.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_create_task(async_see_device(**kwargs)) + + async_track_time_interval(hass, async_device_tracker_scan, interval) + hass.async_create_task(async_device_tracker_scan(None)) diff --git a/homeassistant/components/device_tracker/strings.json b/homeassistant/components/device_tracker/strings.json new file mode 100644 index 0000000000000..285bac2cb4bbd --- /dev/null +++ b/homeassistant/components/device_tracker/strings.json @@ -0,0 +1,8 @@ +{ + "device_automation": { + "condition_type": { + "is_home": "{entity_name} is home", + "is_not_home": "{entity_name} is not home" + } + } +} diff --git a/homeassistant/components/device_tracker/tomato.py b/homeassistant/components/device_tracker/tomato.py deleted file mode 100644 index 265dcf84b571e..0000000000000 --- a/homeassistant/components/device_tracker/tomato.py +++ /dev/null @@ -1,137 +0,0 @@ -""" Supports scanning a Tomato router. """ -import logging -import json -from datetime import timedelta -import re -import threading - -import requests - -from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD -from homeassistant.helpers import validate_config -from homeassistant.util import Throttle -from homeassistant.components.device_tracker import DOMAIN - -# Return cached results if last scan was less then this time ago -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) - -CONF_HTTP_ID = "http_id" - -_LOGGER = logging.getLogger(__name__) - - -def get_scanner(hass, config): - """ Validates config and returns a Tomato scanner. """ - if not validate_config(config, - {DOMAIN: [CONF_HOST, CONF_USERNAME, - CONF_PASSWORD, CONF_HTTP_ID]}, - _LOGGER): - return None - - return TomatoDeviceScanner(config[DOMAIN]) - - -class TomatoDeviceScanner(object): - """ This class queries a wireless router running Tomato firmware - for connected devices. - - A description of the Tomato API can be found on - http://paulusschoutsen.nl/blog/2013/10/tomato-api-documentation/ - """ - - def __init__(self, config): - host, http_id = config[CONF_HOST], config[CONF_HTTP_ID] - username, password = config[CONF_USERNAME], config[CONF_PASSWORD] - - self.req = requests.Request('POST', - 'http://{}/update.cgi'.format(host), - data={'_http_id': http_id, - 'exec': 'devlist'}, - auth=requests.auth.HTTPBasicAuth( - username, password)).prepare() - - self.parse_api_pattern = re.compile(r"(?P\w*) = (?P.*);") - - self.logger = logging.getLogger("{}.{}".format(__name__, "Tomato")) - self.lock = threading.Lock() - - self.last_results = {"wldev": [], "dhcpd_lease": []} - - self.success_init = self._update_tomato_info() - - def scan_devices(self): - """ Scans for new devices and return a - list containing found device ids. """ - - self._update_tomato_info() - - return [item[1] for item in self.last_results['wldev']] - - def get_device_name(self, device): - """ Returns the name of the given device or None if we don't know. """ - - filter_named = [item[0] for item in self.last_results['dhcpd_lease'] - if item[2] == device] - - if not filter_named or not filter_named[0]: - return None - else: - return filter_named[0] - - @Throttle(MIN_TIME_BETWEEN_SCANS) - def _update_tomato_info(self): - """ Ensures the information from the Tomato router is up to date. - Returns boolean if scanning successful. """ - - with self.lock: - self.logger.info("Scanning") - - try: - response = requests.Session().send(self.req, timeout=3) - - # Calling and parsing the Tomato api here. We only need the - # wldev and dhcpd_lease values. For API description see: - # http://paulusschoutsen.nl/ - # blog/2013/10/tomato-api-documentation/ - if response.status_code == 200: - - for param, value in \ - self.parse_api_pattern.findall(response.text): - - if param == 'wldev' or param == 'dhcpd_lease': - self.last_results[param] = \ - json.loads(value.replace("'", '"')) - - return True - - elif response.status_code == 401: - # Authentication error - self.logger.exception(( - "Failed to authenticate, " - "please check your username and password")) - - return False - - except requests.exceptions.ConnectionError: - # We get this if we could not connect to the router or - # an invalid http_id was supplied - self.logger.exception(( - "Failed to connect to the router" - " or invalid http_id supplied")) - - return False - - except requests.exceptions.Timeout: - # We get this if we could not connect to the router or - # an invalid http_id was supplied - self.logger.exception( - "Connection to the router timed out") - - return False - - except ValueError: - # If json decoder could not parse the response - self.logger.exception( - "Failed to parse response from router") - - return False diff --git a/homeassistant/components/dht/__init__.py b/homeassistant/components/dht/__init__.py new file mode 100644 index 0000000000000..23aa2b9d9df06 --- /dev/null +++ b/homeassistant/components/dht/__init__.py @@ -0,0 +1 @@ +"""The dht component.""" diff --git a/homeassistant/components/dht/manifest.json b/homeassistant/components/dht/manifest.json new file mode 100644 index 0000000000000..bb0e2b8f2482c --- /dev/null +++ b/homeassistant/components/dht/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "dht", + "name": "DHT Sensor", + "documentation": "https://www.home-assistant.io/integrations/dht", + "requirements": ["Adafruit-DHT==1.4.0"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/dht/sensor.py b/homeassistant/components/dht/sensor.py new file mode 100644 index 0000000000000..26b0493cb9905 --- /dev/null +++ b/homeassistant/components/dht/sensor.py @@ -0,0 +1,172 @@ +"""Support for Adafruit DHT temperature and humidity sensor.""" +from datetime import timedelta +import logging + +import Adafruit_DHT # 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_PIN = "pin" +CONF_SENSOR = "sensor" +CONF_HUMIDITY_OFFSET = "humidity_offset" +CONF_TEMPERATURE_OFFSET = "temperature_offset" + +DEFAULT_NAME = "DHT Sensor" + +# DHT11 is able to deliver data once per second, DHT22 once every two +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) + +SENSOR_TEMPERATURE = "temperature" +SENSOR_HUMIDITY = "humidity" +SENSOR_TYPES = { + SENSOR_TEMPERATURE: ["Temperature", None], + SENSOR_HUMIDITY: ["Humidity", "%"], +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_SENSOR): cv.string, + vol.Required(CONF_PIN): cv.string, + vol.Optional(CONF_MONITORED_CONDITIONS, default=[]): vol.All( + cv.ensure_list, [vol.In(SENSOR_TYPES)] + ), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_TEMPERATURE_OFFSET, default=0): vol.All( + vol.Coerce(float), vol.Range(min=-100, max=100) + ), + vol.Optional(CONF_HUMIDITY_OFFSET, default=0): vol.All( + vol.Coerce(float), vol.Range(min=-100, max=100) + ), + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the DHT sensor.""" + + SENSOR_TYPES[SENSOR_TEMPERATURE][1] = hass.config.units.temperature_unit + available_sensors = { + "AM2302": Adafruit_DHT.AM2302, + "DHT11": Adafruit_DHT.DHT11, + "DHT22": Adafruit_DHT.DHT22, + } + sensor = available_sensors.get(config.get(CONF_SENSOR)) + pin = config.get(CONF_PIN) + temperature_offset = config.get(CONF_TEMPERATURE_OFFSET) + humidity_offset = config.get(CONF_HUMIDITY_OFFSET) + + if not sensor: + _LOGGER.error("DHT sensor type is not supported") + return False + + data = DHTClient(Adafruit_DHT, sensor, pin) + dev = [] + name = config.get(CONF_NAME) + + try: + for variable in config[CONF_MONITORED_CONDITIONS]: + dev.append( + DHTSensor( + data, + variable, + SENSOR_TYPES[variable][1], + name, + temperature_offset, + humidity_offset, + ) + ) + except KeyError: + pass + + add_entities(dev, True) + + +class DHTSensor(Entity): + """Implementation of the DHT sensor.""" + + def __init__( + self, + dht_client, + sensor_type, + temp_unit, + name, + temperature_offset, + humidity_offset, + ): + """Initialize the sensor.""" + self.client_name = name + self._name = SENSOR_TYPES[sensor_type][0] + self.dht_client = dht_client + self.temp_unit = temp_unit + self.type = sensor_type + self.temperature_offset = temperature_offset + self.humidity_offset = humidity_offset + 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 this entity, if any.""" + return self._unit_of_measurement + + def update(self): + """Get the latest data from the DHT and updates the states.""" + self.dht_client.update() + temperature_offset = self.temperature_offset + humidity_offset = self.humidity_offset + data = self.dht_client.data + + if self.type == SENSOR_TEMPERATURE and SENSOR_TEMPERATURE in data: + temperature = data[SENSOR_TEMPERATURE] + _LOGGER.debug( + "Temperature %.1f \u00b0C + offset %.1f", + temperature, + temperature_offset, + ) + if -20 <= temperature < 80: + self._state = round(temperature + temperature_offset, 1) + if self.temp_unit == TEMP_FAHRENHEIT: + self._state = round(celsius_to_fahrenheit(temperature), 1) + elif self.type == SENSOR_HUMIDITY and SENSOR_HUMIDITY in data: + humidity = data[SENSOR_HUMIDITY] + _LOGGER.debug("Humidity %.1f%% + offset %.1f", humidity, humidity_offset) + if 0 <= humidity <= 100: + self._state = round(humidity + humidity_offset, 1) + + +class DHTClient: + """Get the latest data from the DHT sensor.""" + + def __init__(self, adafruit_dht, sensor, pin): + """Initialize the sensor.""" + self.adafruit_dht = adafruit_dht + self.sensor = sensor + self.pin = pin + self.data = dict() + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data the DHT sensor.""" + humidity, temperature = self.adafruit_dht.read_retry(self.sensor, self.pin) + if temperature: + self.data[SENSOR_TEMPERATURE] = temperature + if humidity: + self.data[SENSOR_HUMIDITY] = humidity diff --git a/homeassistant/components/dialogflow/.translations/bg.json b/homeassistant/components/dialogflow/.translations/bg.json new file mode 100644 index 0000000000000..af1237ce21101 --- /dev/null +++ b/homeassistant/components/dialogflow/.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 Dialogflow.", + "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 Dialogflow]({dialogflow_url}). \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 - Content Type: application/json\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 Dialogflow?", + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0432\u0430\u043d\u0435 \u043d\u0430 Dialogflow Webhook" + } + }, + "title": "Dialogflow" + } +} \ No newline at end of file diff --git a/homeassistant/components/dialogflow/.translations/ca.json b/homeassistant/components/dialogflow/.translations/ca.json new file mode 100644 index 0000000000000..f6dfc9399c280 --- /dev/null +++ b/homeassistant/components/dialogflow/.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 Dialogflow.", + "one_instance_allowed": "Nom\u00e9s cal una sola inst\u00e0ncia." + }, + "create_entry": { + "default": "Per enviar esdeveniments a Home Assistant, haur\u00e0s de configurar la [integraci\u00f3 webhook de Dialogflow]({dialogflow_url}). \n\n Completa la seg\u00fcent informaci\u00f3: \n\n- URL: `{webhook_url}` \n- M\u00e8tode: POST \n- Tipus de contingut: application/json\n\nConsulta la [documentaci\u00f3]({docs_url}) per a m\u00e9s detalls." + }, + "step": { + "user": { + "description": "Est\u00e0s segur que vols configurar Dialogflow?", + "title": "Configuraci\u00f3 del Webhook de Dialogflow" + } + }, + "title": "Dialogflow" + } +} \ No newline at end of file diff --git a/homeassistant/components/dialogflow/.translations/cs.json b/homeassistant/components/dialogflow/.translations/cs.json new file mode 100644 index 0000000000000..db41ee98e0174 --- /dev/null +++ b/homeassistant/components/dialogflow/.translations/cs.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Va\u0161e Home Assistant instance mus\u00ed b\u00fdt p\u0159\u00edstupn\u00e1 z internetu aby mohla p\u0159ij\u00edmat zpr\u00e1vy Dialogflow.", + "one_instance_allowed": "Povolena je pouze jedna instance." + }, + "create_entry": { + "default": "Chcete-li odeslat ud\u00e1losti do aplikace Home Assistant, budete muset nastavit [integraci Dialogflow]({dialogflow_url}). \n\n Vypl\u0148te n\u00e1sleduj\u00edc\u00ed informace: \n\n - URL: `{webhook_url}' \n - Metoda: POST \n - Typ obsahu: application/json\n\n Podrobn\u011bj\u0161\u00ed informace naleznete v [dokumentaci]({docs_url})." + }, + "step": { + "user": { + "description": "Opravdu chcete nastavit Dialogflow?", + "title": "Nastavit Dialogflow Webhook" + } + }, + "title": "Dialogflow" + } +} \ No newline at end of file diff --git a/homeassistant/components/dialogflow/.translations/da.json b/homeassistant/components/dialogflow/.translations/da.json new file mode 100644 index 0000000000000..c682c07a8b9fe --- /dev/null +++ b/homeassistant/components/dialogflow/.translations/da.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Din Home Assistant-instans skal v\u00e6re tilg\u00e6ngelig fra internettet for at modtage Dialogflow-meddelelser", + "one_instance_allowed": "Det er kun n\u00f8dvendigt med en ops\u00e6tning" + }, + "create_entry": { + "default": "For at sende h\u00e6ndelser til Home Assistant skal du konfigurere [webhook-integration med Dialogflow]({dialogflow_url}).\n\n Udfyld f\u00f8lgende oplysninger: \n\n - Webadresse: `{webhook_url}`\n - Metode: POST\n - Indholdstype: application/json\n\nSe [dokumentationen]({docs_url}) for yderligere oplysninger." + }, + "step": { + "user": { + "description": "Er du sikker p\u00e5 at du vil konfigurere Dialogflow?", + "title": "Konfigurer Dialogflow Webhook" + } + }, + "title": "Dialogflow" + } +} \ No newline at end of file diff --git a/homeassistant/components/dialogflow/.translations/de.json b/homeassistant/components/dialogflow/.translations/de.json new file mode 100644 index 0000000000000..f585799391e2a --- /dev/null +++ b/homeassistant/components/dialogflow/.translations/de.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Ihre Home Assistant-Instanz muss \u00fcber das Internet erreichbar sein, um Dialogflow-Nachrichten empfangen zu k\u00f6nnen.", + "one_instance_allowed": "Nur eine einzige Instanz ist notwendig." + }, + "create_entry": { + "default": "Um Ereignisse an den Home Assistant zu senden, m\u00fcssen Sie [Webhook-Integration von Dialogflow]({dialogflow_url}) einrichten. \n\nF\u00fcllen Sie die folgenden Informationen aus: \n\n - URL: ` {webhook_url} ` \n - Methode: POST \n - Inhaltstyp: application / json \n\nWeitere Informationen finden Sie in der [Dokumentation]({docs_url})." + }, + "step": { + "user": { + "description": "M\u00f6chten Sie Dialogflow wirklich einrichten?", + "title": "Dialogflow Webhook einrichten" + } + }, + "title": "Dialogflow" + } +} \ No newline at end of file diff --git a/homeassistant/components/dialogflow/.translations/en.json b/homeassistant/components/dialogflow/.translations/en.json new file mode 100644 index 0000000000000..9e1cbbb636ef6 --- /dev/null +++ b/homeassistant/components/dialogflow/.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 Dialogflow messages.", + "one_instance_allowed": "Only a single instance is necessary." + }, + "create_entry": { + "default": "To send events to Home Assistant, you will need to setup [webhook integration of Dialogflow]({dialogflow_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\nSee [the documentation]({docs_url}) for further details." + }, + "step": { + "user": { + "description": "Are you sure you want to set up Dialogflow?", + "title": "Set up the Dialogflow Webhook" + } + }, + "title": "Dialogflow" + } +} \ No newline at end of file diff --git a/homeassistant/components/dialogflow/.translations/es-419.json b/homeassistant/components/dialogflow/.translations/es-419.json new file mode 100644 index 0000000000000..41a66b038f5a2 --- /dev/null +++ b/homeassistant/components/dialogflow/.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 Dialogflow.", + "one_instance_allowed": "Solo una instancia es necesaria." + }, + "create_entry": { + "default": "Para enviar eventos a Home Assistant, deber\u00e1 configurar [integraci\u00f3n de webhook de Dialogflow] ( {dialogflow_url} ). \n\n Complete la siguiente informaci\u00f3n: \n\n - URL: ` {webhook_url} ` \n - M\u00e9todo: POST \n - Tipo de contenido: aplicaci\u00f3n / json \n\n Vea [la documentaci\u00f3n] ( {docs_url} ) para m\u00e1s detalles." + }, + "step": { + "user": { + "description": "\u00bfEst\u00e1 seguro de que desea configurar Dialogflow?", + "title": "Configurar el Webhook de Dialogflow" + } + }, + "title": "Dialogflow" + } +} \ No newline at end of file diff --git a/homeassistant/components/dialogflow/.translations/es.json b/homeassistant/components/dialogflow/.translations/es.json new file mode 100644 index 0000000000000..c106543e158b4 --- /dev/null +++ b/homeassistant/components/dialogflow/.translations/es.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Tu instancia de Home Assistant debe ser accesible desde Internet para recibir mensajes de Dialogflow.", + "one_instance_allowed": "Solo una instancia es necesaria." + }, + "create_entry": { + "default": "Para enviar eventos a Home Assistant, necesitas configurar [Integraci\u00f3n de flujos de dialogo de webhook]({dialogflow_url}).\n\nRellena la siguiente informaci\u00f3n:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\nVer [Documentaci\u00f3n]({docs_url}) para m\u00e1s detalles." + }, + "step": { + "user": { + "description": "\u00bfEst\u00e1s seguro de que quieres configurar Dialogflow?", + "title": "Configurar el Webhook de Dialogflow" + } + }, + "title": "Dialogflow" + } +} \ No newline at end of file diff --git a/homeassistant/components/dialogflow/.translations/fr.json b/homeassistant/components/dialogflow/.translations/fr.json new file mode 100644 index 0000000000000..0be75b94be94b --- /dev/null +++ b/homeassistant/components/dialogflow/.translations/fr.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Votre instance de Home Assistant doit \u00eatre accessible depuis Internet pour recevoir les messages Dialogflow.", + "one_instance_allowed": "Une seule instance est n\u00e9cessaire." + }, + "create_entry": { + "default": "Pour envoyer des \u00e9v\u00e9nements \u00e0 Home Assistant, vous devez configurer [Webhooks avec Dialogflow] ( {dialogflow_url} ). \n\n Remplissez les informations suivantes: \n\n - URL: ` {webhook_url} ` \n - M\u00e9thode: POST \n - Type de contenu: application / json \n\n Voir [la documentation] ( {docs_url} ) pour savoir comment configurer les automatisations pour g\u00e9rer les donn\u00e9es entrantes." + }, + "step": { + "user": { + "description": "\u00cates-vous s\u00fbr de vouloir configurer Dialogflow?", + "title": "Configurer le Webhook Dialogflow" + } + }, + "title": "Dialogflow" + } +} \ No newline at end of file diff --git a/homeassistant/components/dialogflow/.translations/hu.json b/homeassistant/components/dialogflow/.translations/hu.json new file mode 100644 index 0000000000000..89889fd60481c --- /dev/null +++ b/homeassistant/components/dialogflow/.translations/hu.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "A Home Assistant rendszerednek el\u00e9rhet\u0151nek kell lennie az internetr\u0151l a Dialogflow \u00fczenetek fogad\u00e1s\u00e1hoz.", + "one_instance_allowed": "Csak egyetlen konfigur\u00e1ci\u00f3 sz\u00fcks\u00e9ges." + }, + "step": { + "user": { + "description": "Biztosan be szeretn\u00e9d \u00e1ll\u00edtani az Dialogflowt?", + "title": "Dialogflow Webhook be\u00e1ll\u00edt\u00e1sa" + } + }, + "title": "Dialogflow" + } +} \ No newline at end of file diff --git a/homeassistant/components/dialogflow/.translations/it.json b/homeassistant/components/dialogflow/.translations/it.json new file mode 100644 index 0000000000000..cc1a7ac851074 --- /dev/null +++ b/homeassistant/components/dialogflow/.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 Dialogflow.", + "one_instance_allowed": "\u00c8 necessaria una sola istanza." + }, + "create_entry": { + "default": "Per inviare eventi a Home Assistant, dovrai configurare [l'integrazione webhook di Dialogflow]({dialogflow_url})\n\n Compila le seguenti informazioni: \n\n - URL: ` {webhook_url} ` \n - Method: POST \n - Content Type: application/json \n\n Vedi [la documentazione]({docs_url}) for ulteriori dettagli." + }, + "step": { + "user": { + "description": "Sei sicuro di voler configurare Dialogflow?", + "title": "Configura il webhook di Dialogflow" + } + }, + "title": "Dialogflow" + } +} \ No newline at end of file diff --git a/homeassistant/components/dialogflow/.translations/ko.json b/homeassistant/components/dialogflow/.translations/ko.json new file mode 100644 index 0000000000000..2010495d959e8 --- /dev/null +++ b/homeassistant/components/dialogflow/.translations/ko.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Dialogflow \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 [Dialogflow Webhook]({dialogflow_url}) \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 - Content Type: application/json\n \n\uc790\uc138\ud55c \uc815\ubcf4\ub294 [\uc548\ub0b4]({docs_url}) \ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694." + }, + "step": { + "user": { + "description": "Dialogflow \uc744 \uc124\uc815\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "title": "Dialogflow Webhook \uc124\uc815" + } + }, + "title": "Dialogflow" + } +} \ No newline at end of file diff --git a/homeassistant/components/dialogflow/.translations/lb.json b/homeassistant/components/dialogflow/.translations/lb.json new file mode 100644 index 0000000000000..752acbdecd3ac --- /dev/null +++ b/homeassistant/components/dialogflow/.translations/lb.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "\u00c4r Home Assistant Instanz muss iwwert Internet accessibel si fir Dialogflow 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 [Webhook Integratioun mat Dialogflow]({dialogflow_url}) ageriicht ginn.\n\nF\u00ebllt folgend Informatiounen aus:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/x-www-form-urlencoded\n\nLiest [Dokumentatioun]({docs_url}) fir w\u00e9ider D\u00e9tailer." + }, + "step": { + "user": { + "description": "S\u00e9cher fir Dialogflowanzeriichten?", + "title": "Dialogflow Webhook ariichten" + } + }, + "title": "Dialogflow" + } +} \ No newline at end of file diff --git a/homeassistant/components/dialogflow/.translations/nl.json b/homeassistant/components/dialogflow/.translations/nl.json new file mode 100644 index 0000000000000..9871df0d26259 --- /dev/null +++ b/homeassistant/components/dialogflow/.translations/nl.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Uw Home Assistant instantie moet toegankelijk zijn vanaf het internet om Dialogflow-berichten te ontvangen.", + "one_instance_allowed": "Slechts \u00e9\u00e9n instantie is nodig." + }, + "create_entry": { + "default": "Om evenementen naar de Home Assistant te verzenden, moet u [webhookintegratie van Dialogflow]({dialogflow_url}) instellen. \n\n Vul de volgende info in: \n\n - URL: `{webhook_url}` \n - Method: POST \n - Content Type: application/json \n\nZie [de documentatie]({docs_url}) voor verdere informatie." + }, + "step": { + "user": { + "description": "Weet u zeker dat u Dialogflow wilt instellen?", + "title": "Stel de Twilio Dialogflow in" + } + }, + "title": "Dialogflow" + } +} \ No newline at end of file diff --git a/homeassistant/components/dialogflow/.translations/nn.json b/homeassistant/components/dialogflow/.translations/nn.json new file mode 100644 index 0000000000000..5a96b853eb094 --- /dev/null +++ b/homeassistant/components/dialogflow/.translations/nn.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Dialogflow" + } +} \ No newline at end of file diff --git a/homeassistant/components/dialogflow/.translations/no.json b/homeassistant/components/dialogflow/.translations/no.json new file mode 100644 index 0000000000000..4d23ac8aaba79 --- /dev/null +++ b/homeassistant/components/dialogflow/.translations/no.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Din Home Assistant forekomst m\u00e5 v\u00e6re tilgjengelig fra internett for \u00e5 kunne motta Dialogflow meldinger.", + "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 integrasjon av Dialogflow]({dialogflow_url}). \n\nFyll ut f\u00f8lgende informasjon: \n\n- URL: `{webhook_url}` \n- Metode: POST\n- Innholdstype: application/json\n\nSe [dokumentasjonen]({docs_url}) for ytterligere detaljer." + }, + "step": { + "user": { + "description": "Er du sikker p\u00e5 at du \u00f8nsker \u00e5 sette opp Dialogflow?", + "title": "Sett opp Dialogflow Webhook" + } + }, + "title": "Dialogflow" + } +} \ No newline at end of file diff --git a/homeassistant/components/dialogflow/.translations/pl.json b/homeassistant/components/dialogflow/.translations/pl.json new file mode 100644 index 0000000000000..c555a3e09b369 --- /dev/null +++ b/homeassistant/components/dialogflow/.translations/pl.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Tw\u00f3j Home Assistant musi by\u0107 dost\u0119pny z Internetu, aby odbiera\u0107 komunikaty Dialogflow.", + "one_instance_allowed": "Wymagana jest tylko jedna instancja." + }, + "create_entry": { + "default": "Aby wysy\u0142a\u0107 zdarzenia do Home Assistant'a, musisz skonfigurowa\u0107 [Dialogflow Webhook]({dialogflow_url}). \n\n Wprowad\u017a nast\u0119puj\u0105ce dane:\n\n - URL: `{webhook_url}` \n - Metoda: POST \n - Typ zawarto\u015bci: application/json\n\nZapoznaj si\u0119 z [dokumentacj\u0105]({docs_url}), by pozna\u0107 szczeg\u00f3\u0142y." + }, + "step": { + "user": { + "description": "Na pewno chcesz skonfigurowa\u0107 Dialogflow?", + "title": "Konfiguracja Dialogflow Webhook" + } + }, + "title": "Dialogflow" + } +} \ No newline at end of file diff --git a/homeassistant/components/dialogflow/.translations/pt-BR.json b/homeassistant/components/dialogflow/.translations/pt-BR.json new file mode 100644 index 0000000000000..6d709875771c8 --- /dev/null +++ b/homeassistant/components/dialogflow/.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 da Dialogflow.", + "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 [Integra\u00e7\u00e3o do webhook da Dialogflow] ( {dialogflow_url} ). \n\n Preencha as seguintes informa\u00e7\u00f5es: \n\n - URL: ` {webhook_url} ` \n - M\u00e9todo: POST \n - Tipo de Conte\u00fado: application / json \n\n Veja [a documenta\u00e7\u00e3o] ( {docs_url} ) para mais detalhes." + }, + "step": { + "user": { + "description": "Tem certeza de que deseja configurar o Dialogflow?", + "title": "Configurar o Dialogflow Webhook" + } + }, + "title": "Dialogflow" + } +} \ No newline at end of file diff --git a/homeassistant/components/dialogflow/.translations/pt.json b/homeassistant/components/dialogflow/.translations/pt.json new file mode 100644 index 0000000000000..de754080f1744 --- /dev/null +++ b/homeassistant/components/dialogflow/.translations/pt.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "A sua inst\u00e2ncia Home Assistant precisa de ser acess\u00edvel a partir da internet para receber mensagens Dialogflow.", + "one_instance_allowed": "Apenas uma \u00fanica inst\u00e2ncia \u00e9 necess\u00e1ria." + }, + "create_entry": { + "default": "Para enviar eventos para o Home Assistant, \u00e9 necess\u00e1rio configurar o [Dialogflow Webhook] ({dialogflow_url}). \n\n Preencha as seguintes informa\u00e7\u00f5es: \n\n - URL: `{webhook_url}`\n - M\u00e9todo: POST \n - Tipo de Conte\u00fado: application/json\n\n Veja [a documenta\u00e7\u00e3o] ({docs_url}) para obter mais detalhes." + }, + "step": { + "user": { + "description": "Tem certeza de que deseja configurar o Dialogflow?", + "title": "Configurar o Dialogflow Webhook" + } + }, + "title": "Dialogflow" + } +} \ No newline at end of file diff --git a/homeassistant/components/dialogflow/.translations/ru.json b/homeassistant/components/dialogflow/.translations/ru.json new file mode 100644 index 0000000000000..884053288962e --- /dev/null +++ b/homeassistant/components/dialogflow/.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 Dialogflow.", + "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 [Dialogflow]({dialogflow_url}).\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- Content Type: application/json\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 Dialogflow?", + "title": "Dialogflow" + } + }, + "title": "Dialogflow" + } +} \ No newline at end of file diff --git a/homeassistant/components/dialogflow/.translations/sl.json b/homeassistant/components/dialogflow/.translations/sl.json new file mode 100644 index 0000000000000..b6bd30f7997a2 --- /dev/null +++ b/homeassistant/components/dialogflow/.translations/sl.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "\u010ce \u017eelite prejemati sporo\u010dila dialogflow, mora biti Home Assistant dostopen prek interneta.", + "one_instance_allowed": "Potrebna je samo ena instanca." + }, + "create_entry": { + "default": "Za po\u0161iljanje dogodkov Home Assistant-u, boste morali nastaviti [webhook z Dialogflow]({twilio_url}).\n\nIzpolnite naslednje informacije:\n\n- URL: `{webhook_url}`\n- Metoda: POST\n- Vrsta vsebine: application/x-www-form-urlencoded\n\nGlej [dokumentacijo]({docs_url}) za nadaljna navodila." + }, + "step": { + "user": { + "description": "Ali ste prepri\u010dani, da \u017eelite nastaviti dialogflow?", + "title": "Nastavite Dialogflow Webhook" + } + }, + "title": "Dialogflow" + } +} \ No newline at end of file diff --git a/homeassistant/components/dialogflow/.translations/sv.json b/homeassistant/components/dialogflow/.translations/sv.json new file mode 100644 index 0000000000000..07fe5e112172c --- /dev/null +++ b/homeassistant/components/dialogflow/.translations/sv.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Din Home Assistant instans m\u00e5ste vara tillg\u00e4nglig fr\u00e5n internet f\u00f6r att ta emot Dialogflow meddelanden.", + "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 Dialogflow]({dialogflow_url}).\n\n Fyll i f\u00f6ljande information:\n \n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\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 Dialogflow?", + "title": "Konfigurera Dialogflow Webhook" + } + }, + "title": "Dialogflow" + } +} \ No newline at end of file diff --git a/homeassistant/components/dialogflow/.translations/zh-Hans.json b/homeassistant/components/dialogflow/.translations/zh-Hans.json new file mode 100644 index 0000000000000..8a542dd0d6293 --- /dev/null +++ b/homeassistant/components/dialogflow/.translations/zh-Hans.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "\u60a8\u7684 Home Assistant \u5b9e\u4f8b\u9700\u8981\u63a5\u5165\u4e92\u8054\u7f51\u4ee5\u63a5\u6536 Dialogflow \u6d88\u606f\u3002", + "one_instance_allowed": "\u4ec5\u9700\u4e00\u4e2a\u5b9e\u4f8b" + }, + "create_entry": { + "default": "\u8981\u5411 Home Assistant \u53d1\u9001\u4e8b\u4ef6\uff0c\u60a8\u9700\u8981\u914d\u7f6e [Dialogflow \u7684 Webhook \u96c6\u6210]({dialogflow_url})\u3002\n\n\u586b\u5199\u4ee5\u4e0b\u4fe1\u606f\uff1a\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\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 Dialogflow \u5417?", + "title": "\u8bbe\u7f6e Dialogflow Webhook" + } + }, + "title": "Dialogflow" + } +} \ No newline at end of file diff --git a/homeassistant/components/dialogflow/.translations/zh-Hant.json b/homeassistant/components/dialogflow/.translations/zh-Hant.json new file mode 100644 index 0000000000000..3cb54145ad857 --- /dev/null +++ b/homeassistant/components/dialogflow/.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 Dialogflow \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\u8a2d\u5b9a [webhook integration of Dialogflow]({dialogflow_url})\u3002\n\n\u8acb\u586b\u5beb\u4e0b\u5217\u8cc7\u8a0a\uff1a\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\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 Dialogflow\uff1f", + "title": "\u8a2d\u5b9a Dialogflow Webhook" + } + }, + "title": "Dialogflow" + } +} \ No newline at end of file diff --git a/homeassistant/components/dialogflow/__init__.py b/homeassistant/components/dialogflow/__init__.py new file mode 100644 index 0000000000000..ae3c0288aed58 --- /dev/null +++ b/homeassistant/components/dialogflow/__init__.py @@ -0,0 +1,175 @@ +"""Support for Dialogflow webhook.""" +import logging + +from aiohttp import web +import voluptuous as vol + +from homeassistant.const import CONF_WEBHOOK_ID +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_entry_flow, intent, template + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +SOURCE = "Home Assistant Dialogflow" + +CONFIG_SCHEMA = vol.Schema({DOMAIN: {}}, extra=vol.ALLOW_EXTRA) + +V1 = 1 +V2 = 2 + + +class DialogFlowError(HomeAssistantError): + """Raised when a DialogFlow error happens.""" + + +async def async_setup(hass, config): + """Set up the Dialogflow component.""" + return True + + +async def handle_webhook(hass, webhook_id, request): + """Handle incoming webhook with Dialogflow requests.""" + message = await request.json() + + _LOGGER.debug("Received Dialogflow request: %s", message) + + try: + response = await async_handle_message(hass, message) + return b"" if response is None else web.json_response(response) + + except DialogFlowError as err: + _LOGGER.warning(str(err)) + return web.json_response(dialogflow_error_response(message, str(err))) + + except intent.UnknownIntent as err: + _LOGGER.warning(str(err)) + return web.json_response( + dialogflow_error_response( + message, "This intent is not yet configured within Home Assistant." + ) + ) + + except intent.InvalidSlotInfo as err: + _LOGGER.warning(str(err)) + return web.json_response( + dialogflow_error_response( + message, "Invalid slot information received for this intent." + ) + ) + + except intent.IntentError as err: + _LOGGER.warning(str(err)) + return web.json_response( + dialogflow_error_response(message, "Error handling intent.") + ) + + +async def async_setup_entry(hass, entry): + """Configure based on config entry.""" + hass.components.webhook.async_register( + DOMAIN, "DialogFlow", entry.data[CONF_WEBHOOK_ID], handle_webhook + ) + return True + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID]) + return True + + +# pylint: disable=invalid-name +async_remove_entry = config_entry_flow.webhook_async_remove_entry + + +def dialogflow_error_response(message, error): + """Return a response saying the error message.""" + api_version = get_api_version(message) + if api_version is V1: + parameters = message["result"]["parameters"] + elif api_version is V2: + parameters = message["queryResult"]["parameters"] + dialogflow_response = DialogflowResponse(parameters, api_version) + dialogflow_response.add_speech(error) + return dialogflow_response.as_dict() + + +def get_api_version(message): + """Get API version of Dialogflow message.""" + if message.get("id") is not None: + return V1 + if message.get("responseId") is not None: + return V2 + + +async def async_handle_message(hass, message): + """Handle a DialogFlow message.""" + _api_version = get_api_version(message) + if _api_version is V1: + _LOGGER.warning( + "Dialogflow V1 API will be removed on October 23, 2019. Please change your DialogFlow settings to use the V2 api" + ) + req = message.get("result") + action_incomplete = req.get("actionIncomplete", True) + if action_incomplete: + return + + elif _api_version is V2: + req = message.get("queryResult") + if req.get("allRequiredParamsPresent", False) is False: + return + + action = req.get("action", "") + parameters = req.get("parameters").copy() + parameters["dialogflow_query"] = message + dialogflow_response = DialogflowResponse(parameters, _api_version) + + if action == "": + raise DialogFlowError( + "You have not defined an action in your Dialogflow intent." + ) + + intent_response = await intent.async_handle( + hass, + DOMAIN, + action, + {key: {"value": value} for key, value in parameters.items()}, + ) + + if "plain" in intent_response.speech: + dialogflow_response.add_speech(intent_response.speech["plain"]["speech"]) + + return dialogflow_response.as_dict() + + +class DialogflowResponse: + """Help generating the response for Dialogflow.""" + + def __init__(self, parameters, api_version): + """Initialize the Dialogflow response.""" + self.speech = None + self.parameters = {} + self.api_version = api_version + # Parameter names replace '.' and '-' for '_' + for key, value in parameters.items(): + underscored_key = key.replace(".", "_").replace("-", "_") + self.parameters[underscored_key] = value + + def add_speech(self, text): + """Add speech to the response.""" + assert self.speech is None + + if isinstance(text, template.Template): + text = text.async_render(self.parameters) + + self.speech = text + + def as_dict(self): + """Return response in a Dialogflow valid dictionary.""" + if self.api_version is V1: + return {"speech": self.speech, "displayText": self.speech, "source": SOURCE} + + if self.api_version is V2: + return {"fulfillmentText": self.speech, "source": SOURCE} diff --git a/homeassistant/components/dialogflow/config_flow.py b/homeassistant/components/dialogflow/config_flow.py new file mode 100644 index 0000000000000..fee99898ccc17 --- /dev/null +++ b/homeassistant/components/dialogflow/config_flow.py @@ -0,0 +1,13 @@ +"""Config flow for DialogFlow.""" +from homeassistant.helpers import config_entry_flow + +from .const import DOMAIN + +config_entry_flow.register_webhook_flow( + DOMAIN, + "Dialogflow Webhook", + { + "dialogflow_url": "https://dialogflow.com/docs/fulfillment#webhook", + "docs_url": "https://www.home-assistant.io/integrations/dialogflow/", + }, +) diff --git a/homeassistant/components/dialogflow/const.py b/homeassistant/components/dialogflow/const.py new file mode 100644 index 0000000000000..476cb480d9457 --- /dev/null +++ b/homeassistant/components/dialogflow/const.py @@ -0,0 +1,3 @@ +"""Const for DialogFlow.""" + +DOMAIN = "dialogflow" diff --git a/homeassistant/components/dialogflow/manifest.json b/homeassistant/components/dialogflow/manifest.json new file mode 100644 index 0000000000000..493351c2641f3 --- /dev/null +++ b/homeassistant/components/dialogflow/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "dialogflow", + "name": "Dialogflow", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/dialogflow", + "requirements": [], + "dependencies": ["webhook"], + "codeowners": [] +} diff --git a/homeassistant/components/dialogflow/strings.json b/homeassistant/components/dialogflow/strings.json new file mode 100644 index 0000000000000..4a3e91a3e501e --- /dev/null +++ b/homeassistant/components/dialogflow/strings.json @@ -0,0 +1,18 @@ +{ + "config": { + "title": "Dialogflow", + "step": { + "user": { + "title": "Set up the Dialogflow Webhook", + "description": "Are you sure you want to set up Dialogflow?" + } + }, + "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 Dialogflow messages." + }, + "create_entry": { + "default": "To send events to Home Assistant, you will need to setup [webhook integration of Dialogflow]({dialogflow_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\nSee [the documentation]({docs_url}) for further details." + } + } +} diff --git a/homeassistant/components/digital_ocean/__init__.py b/homeassistant/components/digital_ocean/__init__.py new file mode 100644 index 0000000000000..33663f121d1cc --- /dev/null +++ b/homeassistant/components/digital_ocean/__init__.py @@ -0,0 +1,85 @@ +"""Support for Digital Ocean.""" +from datetime import timedelta +import logging + +import digitalocean +import voluptuous as vol + +from homeassistant.const import CONF_ACCESS_TOKEN +import homeassistant.helpers.config_validation as cv +from homeassistant.util import Throttle + +_LOGGER = logging.getLogger(__name__) + +ATTR_CREATED_AT = "created_at" +ATTR_DROPLET_ID = "droplet_id" +ATTR_DROPLET_NAME = "droplet_name" +ATTR_FEATURES = "features" +ATTR_IPV4_ADDRESS = "ipv4_address" +ATTR_IPV6_ADDRESS = "ipv6_address" +ATTR_MEMORY = "memory" +ATTR_REGION = "region" +ATTR_VCPUS = "vcpus" + +ATTRIBUTION = "Data provided by Digital Ocean" + +CONF_DROPLETS = "droplets" + +DATA_DIGITAL_OCEAN = "data_do" +DIGITAL_OCEAN_PLATFORMS = ["switch", "binary_sensor"] +DOMAIN = "digital_ocean" + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) + +CONFIG_SCHEMA = vol.Schema( + {DOMAIN: vol.Schema({vol.Required(CONF_ACCESS_TOKEN): cv.string})}, + extra=vol.ALLOW_EXTRA, +) + + +def setup(hass, config): + """Set up the Digital Ocean component.""" + + conf = config[DOMAIN] + access_token = conf.get(CONF_ACCESS_TOKEN) + + digital = DigitalOcean(access_token) + + try: + if not digital.manager.get_account(): + _LOGGER.error("No account found for the given API token") + return False + except digitalocean.baseapi.DataReadError: + _LOGGER.error("API token not valid for authentication") + return False + + hass.data[DATA_DIGITAL_OCEAN] = digital + + return True + + +class DigitalOcean: + """Handle all communication with the Digital Ocean API.""" + + def __init__(self, access_token): + """Initialize the Digital Ocean connection.""" + + self._access_token = access_token + self.data = None + self.manager = digitalocean.Manager(token=self._access_token) + + def get_droplet_id(self, droplet_name): + """Get the status of a Digital Ocean droplet.""" + droplet_id = None + + all_droplets = self.manager.get_all_droplets() + for droplet in all_droplets: + if droplet_name == droplet.name: + droplet_id = droplet.id + + return droplet_id + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Use the data from Digital Ocean API.""" + self.data = self.manager.get_all_droplets() diff --git a/homeassistant/components/digital_ocean/binary_sensor.py b/homeassistant/components/digital_ocean/binary_sensor.py new file mode 100644 index 0000000000000..50c87907774a0 --- /dev/null +++ b/homeassistant/components/digital_ocean/binary_sensor.py @@ -0,0 +1,100 @@ +"""Support for monitoring the state of Digital Ocean droplets.""" +import logging + +import voluptuous as vol + +from homeassistant.components.binary_sensor import PLATFORM_SCHEMA, BinarySensorDevice +from homeassistant.const import ATTR_ATTRIBUTION +import homeassistant.helpers.config_validation as cv + +from . import ( + ATTR_CREATED_AT, + ATTR_DROPLET_ID, + ATTR_DROPLET_NAME, + ATTR_FEATURES, + ATTR_IPV4_ADDRESS, + ATTR_IPV6_ADDRESS, + ATTR_MEMORY, + ATTR_REGION, + ATTR_VCPUS, + ATTRIBUTION, + CONF_DROPLETS, + DATA_DIGITAL_OCEAN, +) + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = "Droplet" +DEFAULT_DEVICE_CLASS = "moving" +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: 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/digital_ocean/manifest.json b/homeassistant/components/digital_ocean/manifest.json new file mode 100644 index 0000000000000..8bf916a802dd1 --- /dev/null +++ b/homeassistant/components/digital_ocean/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "digital_ocean", + "name": "Digital Ocean", + "documentation": "https://www.home-assistant.io/integrations/digital_ocean", + "requirements": ["python-digitalocean==1.13.2"], + "dependencies": [], + "codeowners": ["@fabaff"] +} diff --git a/homeassistant/components/digital_ocean/switch.py b/homeassistant/components/digital_ocean/switch.py new file mode 100644 index 0000000000000..95d2e15a51095 --- /dev/null +++ b/homeassistant/components/digital_ocean/switch.py @@ -0,0 +1,105 @@ +"""Support for interacting with Digital Ocean droplets.""" +import logging + +import voluptuous as vol + +from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice +from homeassistant.const import ATTR_ATTRIBUTION +import homeassistant.helpers.config_validation as cv + +from . import ( + ATTR_CREATED_AT, + ATTR_DROPLET_ID, + ATTR_DROPLET_NAME, + ATTR_FEATURES, + ATTR_IPV4_ADDRESS, + ATTR_IPV6_ADDRESS, + ATTR_MEMORY, + ATTR_REGION, + ATTR_VCPUS, + ATTRIBUTION, + CONF_DROPLETS, + DATA_DIGITAL_OCEAN, +) + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = "Droplet" + +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 switch.""" + 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(DigitalOceanSwitch(digital, droplet_id)) + + add_entities(dev, True) + + +class DigitalOceanSwitch(SwitchDevice): + """Representation of a Digital Ocean droplet switch.""" + + def __init__(self, do, droplet_id): + """Initialize a new Digital Ocean sensor.""" + self._digital_ocean = do + self._droplet_id = droplet_id + self.data = None + self._state = None + + @property + def name(self): + """Return the name of the switch.""" + return self.data.name + + @property + def is_on(self): + """Return true if switch is on.""" + return self.data.status == "active" + + @property + def device_state_attributes(self): + """Return the state attributes of the Digital Ocean droplet.""" + return { + ATTR_ATTRIBUTION: 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 turn_on(self, **kwargs): + """Boot-up the droplet.""" + if self.data.status != "active": + self.data.power_on() + + def turn_off(self, **kwargs): + """Shutdown the droplet.""" + if self.data.status == "active": + self.data.power_off() + + def update(self): + """Get the latest data from the device and update the data.""" + 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/digitalloggers/__init__.py b/homeassistant/components/digitalloggers/__init__.py new file mode 100644 index 0000000000000..6db88ca93d259 --- /dev/null +++ b/homeassistant/components/digitalloggers/__init__.py @@ -0,0 +1 @@ +"""The digitalloggers component.""" diff --git a/homeassistant/components/digitalloggers/manifest.json b/homeassistant/components/digitalloggers/manifest.json new file mode 100644 index 0000000000000..723930666c4e9 --- /dev/null +++ b/homeassistant/components/digitalloggers/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "digitalloggers", + "name": "Digital Loggers", + "documentation": "https://www.home-assistant.io/integrations/digitalloggers", + "requirements": ["dlipower==0.7.165"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/digitalloggers/switch.py b/homeassistant/components/digitalloggers/switch.py new file mode 100644 index 0000000000000..824af4416880b --- /dev/null +++ b/homeassistant/components/digitalloggers/switch.py @@ -0,0 +1,136 @@ +"""Support for Digital Loggers DIN III Relays.""" +from datetime import timedelta +import logging + +import dlipower +import voluptuous as vol + +from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_TIMEOUT, + CONF_USERNAME, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.util import Throttle + +_LOGGER = logging.getLogger(__name__) + +CONF_CYCLETIME = "cycletime" + +DEFAULT_NAME = "DINRelay" +DEFAULT_USERNAME = "admin" +DEFAULT_PASSWORD = "admin" +DEFAULT_TIMEOUT = 20 +DEFAULT_CYCLETIME = 2 + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=5) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + 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): vol.All( + vol.Coerce(int), vol.Range(min=1, max=600) + ), + vol.Optional(CONF_CYCLETIME, default=DEFAULT_CYCLETIME): vol.All( + vol.Coerce(int), vol.Range(min=1, max=600) + ), + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Find and return DIN III Relay switch.""" + + host = config.get(CONF_HOST) + controller_name = config.get(CONF_NAME) + user = config.get(CONF_USERNAME) + pswd = config.get(CONF_PASSWORD) + tout = config.get(CONF_TIMEOUT) + cycl = config.get(CONF_CYCLETIME) + + power_switch = dlipower.PowerSwitch( + hostname=host, userid=user, password=pswd, timeout=tout, cycletime=cycl + ) + + if not power_switch.verify(): + _LOGGER.error("Could not connect to DIN III Relay") + return False + + outlets = [] + parent_device = DINRelayDevice(power_switch) + + outlets.extend( + DINRelay(controller_name, parent_device, outlet) for outlet in power_switch[0:] + ) + + add_entities(outlets) + + +class DINRelay(SwitchDevice): + """Representation of an individual DIN III relay port.""" + + def __init__(self, controller_name, parent_device, outlet): + """Initialize the DIN III Relay switch.""" + self._controller_name = controller_name + self._parent_device = parent_device + self._outlet = outlet + + self._outlet_number = self._outlet.outlet_number + self._name = self._outlet.description + self._state = self._outlet.state == "ON" + + @property + def name(self): + """Return the display name of this relay.""" + return f"{self._controller_name}_{self._name}" + + @property + def is_on(self): + """Return true if relay is on.""" + return self._state + + @property + def should_poll(self): + """Return the polling state.""" + return True + + def turn_on(self, **kwargs): + """Instruct the relay to turn on.""" + self._outlet.on() + + def turn_off(self, **kwargs): + """Instruct the relay to turn off.""" + self._outlet.off() + + def update(self): + """Trigger update for all switches on the parent device.""" + self._parent_device.update() + + outlet_status = self._parent_device.get_outlet_status(self._outlet_number) + + self._name = outlet_status[1] + self._state = outlet_status[2] == "ON" + + +class DINRelayDevice: + """Device representation for per device throttling.""" + + def __init__(self, power_switch): + """Initialize the DINRelay device.""" + self._power_switch = power_switch + self._statuslist = None + + def get_outlet_status(self, outlet_number): + """Get status of outlet from cached status list.""" + return self._statuslist[outlet_number - 1] + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Fetch new state data for this device.""" + self._statuslist = self._power_switch.statuslist() diff --git a/homeassistant/components/directv/__init__.py b/homeassistant/components/directv/__init__.py new file mode 100644 index 0000000000000..5934e1b6c5129 --- /dev/null +++ b/homeassistant/components/directv/__init__.py @@ -0,0 +1 @@ +"""The directv component.""" diff --git a/homeassistant/components/directv/manifest.json b/homeassistant/components/directv/manifest.json new file mode 100644 index 0000000000000..adf05621a2cec --- /dev/null +++ b/homeassistant/components/directv/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "directv", + "name": "DirecTV", + "documentation": "https://www.home-assistant.io/integrations/directv", + "requirements": ["directpy==0.5"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/directv/media_player.py b/homeassistant/components/directv/media_player.py new file mode 100644 index 0000000000000..cd4f910c70727 --- /dev/null +++ b/homeassistant/components/directv/media_player.py @@ -0,0 +1,452 @@ +"""Support for the DirecTV receivers.""" +import logging + +from DirectPy import DIRECTV +import requests +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_MOVIE, + MEDIA_TYPE_TVSHOW, + SUPPORT_NEXT_TRACK, + SUPPORT_PAUSE, + SUPPORT_PLAY, + SUPPORT_PLAY_MEDIA, + SUPPORT_PREVIOUS_TRACK, + SUPPORT_STOP, + SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, +) +from homeassistant.const import ( + CONF_DEVICE, + CONF_HOST, + CONF_NAME, + CONF_PORT, + STATE_OFF, + STATE_PAUSED, + STATE_PLAYING, +) +import homeassistant.helpers.config_validation as cv +import homeassistant.util.dt as dt_util + +_LOGGER = logging.getLogger(__name__) + +ATTR_MEDIA_CURRENTLY_RECORDING = "media_currently_recording" +ATTR_MEDIA_RATING = "media_rating" +ATTR_MEDIA_RECORDED = "media_recorded" +ATTR_MEDIA_START_TIME = "media_start_time" + +DEFAULT_DEVICE = "0" +DEFAULT_NAME = "DirecTV Receiver" +DEFAULT_PORT = 8080 + +SUPPORT_DTV = ( + SUPPORT_PAUSE + | SUPPORT_TURN_ON + | SUPPORT_TURN_OFF + | SUPPORT_PLAY_MEDIA + | SUPPORT_STOP + | SUPPORT_NEXT_TRACK + | SUPPORT_PREVIOUS_TRACK + | SUPPORT_PLAY +) + +SUPPORT_DTV_CLIENT = ( + SUPPORT_PAUSE + | SUPPORT_PLAY_MEDIA + | SUPPORT_STOP + | SUPPORT_NEXT_TRACK + | SUPPORT_PREVIOUS_TRACK + | SUPPORT_PLAY +) + +DATA_DIRECTV = "data_directv" + +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_DEVICE, default=DEFAULT_DEVICE): cv.string, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the DirecTV platform.""" + known_devices = hass.data.get(DATA_DIRECTV, set()) + hosts = [] + + if CONF_HOST in config: + _LOGGER.debug( + "Adding configured device %s with client address %s ", + config.get(CONF_NAME), + config.get(CONF_DEVICE), + ) + hosts.append( + [ + config.get(CONF_NAME), + config.get(CONF_HOST), + config.get(CONF_PORT), + config.get(CONF_DEVICE), + ] + ) + + elif discovery_info: + host = discovery_info.get("host") + name = "DirecTV_{}".format(discovery_info.get("serial", "")) + + # Attempt to discover additional RVU units + _LOGGER.debug("Doing discovery of DirecTV devices on %s", host) + + dtv = DIRECTV(host, DEFAULT_PORT) + try: + resp = dtv.get_locations() + except requests.exceptions.RequestException as ex: + # Bail out and just go forward with uPnP data + # Make sure that this device is not already configured + # Comparing based on host (IP) and clientAddr. + _LOGGER.debug("Request exception %s trying to get locations", ex) + resp = {"locations": [{"locationName": name, "clientAddr": DEFAULT_DEVICE}]} + + _LOGGER.debug("Known devices: %s", known_devices) + for loc in resp.get("locations") or []: + if "locationName" not in loc or "clientAddr" not in loc: + continue + + # Make sure that this device is not already configured + # Comparing based on host (IP) and clientAddr. + if (host, loc["clientAddr"]) in known_devices: + _LOGGER.debug( + "Discovered device %s on host %s with " + "client address %s is already " + "configured", + str.title(loc["locationName"]), + host, + loc["clientAddr"], + ) + else: + _LOGGER.debug( + "Adding discovered device %s with client address %s", + str.title(loc["locationName"]), + loc["clientAddr"], + ) + hosts.append( + [ + str.title(loc["locationName"]), + host, + DEFAULT_PORT, + loc["clientAddr"], + ] + ) + + dtvs = [] + + for host in hosts: + dtvs.append(DirecTvDevice(*host)) + hass.data.setdefault(DATA_DIRECTV, set()).add((host[1], host[3])) + + add_entities(dtvs) + + +class DirecTvDevice(MediaPlayerDevice): + """Representation of a DirecTV receiver on the network.""" + + def __init__(self, name, host, port, device): + """Initialize the device.""" + + self.dtv = DIRECTV(host, port, device) + self._name = name + self._is_standby = True + self._current = None + self._last_update = None + self._paused = None + self._last_position = None + self._is_recorded = None + self._is_client = device != "0" + self._assumed_state = None + self._available = False + self._first_error_timestamp = None + + if self._is_client: + _LOGGER.debug("Created DirecTV client %s for device %s", self._name, device) + else: + _LOGGER.debug("Created DirecTV device for %s", self._name) + + def update(self): + """Retrieve latest state.""" + _LOGGER.debug("%s: Updating status", self.entity_id) + try: + self._available = True + self._is_standby = self.dtv.get_standby() + if self._is_standby: + self._current = None + self._is_recorded = None + self._paused = None + self._assumed_state = False + self._last_position = None + self._last_update = None + else: + self._current = self.dtv.get_tuned() + if self._current["status"]["code"] == 200: + self._first_error_timestamp = None + self._is_recorded = self._current.get("uniqueId") is not None + self._paused = self._last_position == self._current["offset"] + self._assumed_state = self._is_recorded + self._last_position = self._current["offset"] + self._last_update = ( + dt_util.utcnow() + if not self._paused or self._last_update is None + else self._last_update + ) + else: + # If an error is received then only set to unavailable if + # this started at least 1 minute ago. + log_message = "{}: Invalid status {} received".format( + self.entity_id, self._current["status"]["code"] + ) + if self._check_state_available(): + _LOGGER.debug(log_message) + else: + _LOGGER.error(log_message) + + except requests.RequestException as ex: + _LOGGER.error( + "%s: Request error trying to update current status: %s", + self.entity_id, + ex, + ) + self._check_state_available() + + except Exception as ex: + _LOGGER.error( + "%s: Exception trying to update current status: %s", self.entity_id, ex + ) + self._available = False + if not self._first_error_timestamp: + self._first_error_timestamp = dt_util.utcnow() + raise + + def _check_state_available(self): + """Set to unavailable if issue been occurring over 1 minute.""" + if not self._first_error_timestamp: + self._first_error_timestamp = dt_util.utcnow() + else: + tdelta = dt_util.utcnow() - self._first_error_timestamp + if tdelta.total_seconds() >= 60: + self._available = False + + return self._available + + @property + def device_state_attributes(self): + """Return device specific state attributes.""" + attributes = {} + if not self._is_standby: + attributes[ATTR_MEDIA_CURRENTLY_RECORDING] = self.media_currently_recording + attributes[ATTR_MEDIA_RATING] = self.media_rating + attributes[ATTR_MEDIA_RECORDED] = self.media_recorded + attributes[ATTR_MEDIA_START_TIME] = self.media_start_time + + return attributes + + @property + def name(self): + """Return the name of the device.""" + return self._name + + # MediaPlayerDevice properties and methods + @property + def state(self): + """Return the state of the device.""" + if self._is_standby: + return STATE_OFF + + # For recorded media we can determine if it is paused or not. + # For live media we're unable to determine and will always return + # playing instead. + if self._paused: + return STATE_PAUSED + + return STATE_PLAYING + + @property + def available(self): + """Return if able to retrieve information from DVR or not.""" + return self._available + + @property + def assumed_state(self): + """Return if we assume the state or not.""" + return self._assumed_state + + @property + def media_content_id(self): + """Return the content ID of current playing media.""" + if self._is_standby: + return None + + return self._current["programId"] + + @property + def media_content_type(self): + """Return the content type of current playing media.""" + if self._is_standby: + return None + + if "episodeTitle" in self._current: + return MEDIA_TYPE_TVSHOW + + return MEDIA_TYPE_MOVIE + + @property + def media_duration(self): + """Return the duration of current playing media in seconds.""" + if self._is_standby: + return None + + return self._current["duration"] + + @property + def media_position(self): + """Position of current playing media in seconds.""" + if self._is_standby: + return None + + return self._last_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._is_standby: + return None + + return self._last_update + + @property + def media_title(self): + """Return the title of current playing media.""" + if self._is_standby: + return None + + return self._current["title"] + + @property + def media_series_title(self): + """Return the title of current episode of TV show.""" + if self._is_standby: + return None + + return self._current.get("episodeTitle") + + @property + def media_channel(self): + """Return the channel current playing media.""" + if self._is_standby: + return None + + return "{} ({})".format(self._current["callsign"], self._current["major"]) + + @property + def source(self): + """Name of the current input source.""" + if self._is_standby: + return None + + return self._current["major"] + + @property + def supported_features(self): + """Flag media player features that are supported.""" + return SUPPORT_DTV_CLIENT if self._is_client else SUPPORT_DTV + + @property + def media_currently_recording(self): + """If the media is currently being recorded or not.""" + if self._is_standby: + return None + + return self._current["isRecording"] + + @property + def media_rating(self): + """TV Rating of the current playing media.""" + if self._is_standby: + return None + + return self._current["rating"] + + @property + def media_recorded(self): + """If the media was recorded or live.""" + if self._is_standby: + return None + + return self._is_recorded + + @property + def media_start_time(self): + """Start time the program aired.""" + if self._is_standby: + return None + + return dt_util.as_local(dt_util.utc_from_timestamp(self._current["startTime"])) + + def turn_on(self): + """Turn on the receiver.""" + if self._is_client: + raise NotImplementedError() + + _LOGGER.debug("Turn on %s", self._name) + self.dtv.key_press("poweron") + + def turn_off(self): + """Turn off the receiver.""" + if self._is_client: + raise NotImplementedError() + + _LOGGER.debug("Turn off %s", self._name) + self.dtv.key_press("poweroff") + + def media_play(self): + """Send play command.""" + _LOGGER.debug("Play on %s", self._name) + self.dtv.key_press("play") + + def media_pause(self): + """Send pause command.""" + _LOGGER.debug("Pause on %s", self._name) + self.dtv.key_press("pause") + + def media_stop(self): + """Send stop command.""" + _LOGGER.debug("Stop on %s", self._name) + self.dtv.key_press("stop") + + def media_previous_track(self): + """Send rewind command.""" + _LOGGER.debug("Rewind on %s", self._name) + self.dtv.key_press("rew") + + def media_next_track(self): + """Send fast forward command.""" + _LOGGER.debug("Fast forward on %s", self._name) + self.dtv.key_press("ffwd") + + def play_media(self, media_type, media_id, **kwargs): + """Select input source.""" + if media_type != MEDIA_TYPE_CHANNEL: + _LOGGER.error( + "Invalid media type %s. Only %s is supported", + media_type, + MEDIA_TYPE_CHANNEL, + ) + return + + _LOGGER.debug("Changing channel on %s to %s", self._name, media_id) + self.dtv.tune_channel(media_id) diff --git a/homeassistant/components/discogs/__init__.py b/homeassistant/components/discogs/__init__.py new file mode 100644 index 0000000000000..90a17763ea652 --- /dev/null +++ b/homeassistant/components/discogs/__init__.py @@ -0,0 +1 @@ +"""The discogs component.""" diff --git a/homeassistant/components/discogs/manifest.json b/homeassistant/components/discogs/manifest.json new file mode 100644 index 0000000000000..61080b12c20d6 --- /dev/null +++ b/homeassistant/components/discogs/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "discogs", + "name": "Discogs", + "documentation": "https://www.home-assistant.io/integrations/discogs", + "requirements": ["discogs_client==2.2.2"], + "dependencies": [], + "codeowners": ["@thibmaek"] +} diff --git a/homeassistant/components/discogs/sensor.py b/homeassistant/components/discogs/sensor.py new file mode 100644 index 0000000000000..b5e488cc19c62 --- /dev/null +++ b/homeassistant/components/discogs/sensor.py @@ -0,0 +1,162 @@ +"""Show the amount of records in a user's Discogs collection.""" +from datetime import timedelta +import logging +import random + +import discogs_client +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + ATTR_ATTRIBUTION, + CONF_MONITORED_CONDITIONS, + CONF_NAME, + CONF_TOKEN, +) +from homeassistant.helpers.aiohttp_client import SERVER_SOFTWARE +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +ATTR_IDENTITY = "identity" + +ATTRIBUTION = "Data provided by Discogs" + +DEFAULT_NAME = "Discogs" + +ICON_RECORD = "mdi:album" +ICON_PLAYER = "mdi:record-player" +UNIT_RECORDS = "records" + +SCAN_INTERVAL = timedelta(minutes=10) + +SENSOR_COLLECTION_TYPE = "collection" +SENSOR_WANTLIST_TYPE = "wantlist" +SENSOR_RANDOM_RECORD_TYPE = "random_record" + +SENSORS = { + SENSOR_COLLECTION_TYPE: { + "name": "Collection", + "icon": ICON_RECORD, + "unit_of_measurement": UNIT_RECORDS, + }, + SENSOR_WANTLIST_TYPE: { + "name": "Wantlist", + "icon": ICON_RECORD, + "unit_of_measurement": UNIT_RECORDS, + }, + SENSOR_RANDOM_RECORD_TYPE: { + "name": "Random Record", + "icon": ICON_PLAYER, + "unit_of_measurement": None, + }, +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_TOKEN): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSORS)): vol.All( + cv.ensure_list, [vol.In(SENSORS)] + ), + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Discogs sensor.""" + token = config[CONF_TOKEN] + name = config[CONF_NAME] + + try: + _discogs_client = discogs_client.Client(SERVER_SOFTWARE, user_token=token) + + discogs_data = { + "user": _discogs_client.identity().name, + "folders": _discogs_client.identity().collection_folders, + "collection_count": _discogs_client.identity().num_collection, + "wantlist_count": _discogs_client.identity().num_wantlist, + } + except discogs_client.exceptions.HTTPError: + _LOGGER.error("API token is not valid") + return + + sensors = [] + for sensor_type in config.get(CONF_MONITORED_CONDITIONS): + sensors.append(DiscogsSensor(discogs_data, name, sensor_type)) + + add_entities(sensors, True) + + +class DiscogsSensor(Entity): + """Create a new Discogs sensor for a specific type.""" + + def __init__(self, discogs_data, name, sensor_type): + """Initialize the Discogs sensor.""" + self._discogs_data = discogs_data + self._name = name + self._type = sensor_type + self._state = None + self._attrs = {} + + @property + def name(self): + """Return the name of the sensor.""" + return f"{self._name} {SENSORS[self._type]['name']}" + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return SENSORS[self._type]["icon"] + + @property + def unit_of_measurement(self): + """Return the unit this state is expressed in.""" + return SENSORS[self._type]["unit_of_measurement"] + + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + if self._state is None or self._attrs is None: + return None + + if self._type != SENSOR_RANDOM_RECORD_TYPE: + return { + ATTR_ATTRIBUTION: ATTRIBUTION, + ATTR_IDENTITY: self._discogs_data["user"], + } + + return { + "cat_no": self._attrs["labels"][0]["catno"], + "cover_image": self._attrs["cover_image"], + "format": f"{self._attrs['formats'][0]['name']} ({self._attrs['formats'][0]['descriptions'][0]})", + "label": self._attrs["labels"][0]["name"], + "released": self._attrs["year"], + ATTR_ATTRIBUTION: ATTRIBUTION, + ATTR_IDENTITY: self._discogs_data["user"], + } + + def get_random_record(self): + """Get a random record suggestion from the user's collection.""" + # Index 0 in the folders is the 'All' folder + collection = self._discogs_data["folders"][0] + random_index = random.randrange(collection.count) + random_record = collection.releases[random_index].release + + self._attrs = random_record.data + return f"{random_record.data['artists'][0]['name']} - {random_record.data['title']}" + + def update(self): + """Set state to the amount of records in user's collection.""" + if self._type == SENSOR_COLLECTION_TYPE: + self._state = self._discogs_data["collection_count"] + elif self._type == SENSOR_WANTLIST_TYPE: + self._state = self._discogs_data["wantlist_count"] + else: + self._state = self.get_random_record() diff --git a/homeassistant/components/discord/__init__.py b/homeassistant/components/discord/__init__.py new file mode 100644 index 0000000000000..67b9f1b39ba30 --- /dev/null +++ b/homeassistant/components/discord/__init__.py @@ -0,0 +1 @@ +"""The discord integration.""" diff --git a/homeassistant/components/discord/manifest.json b/homeassistant/components/discord/manifest.json new file mode 100644 index 0000000000000..a9aeea27aeffd --- /dev/null +++ b/homeassistant/components/discord/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "discord", + "name": "Discord", + "documentation": "https://www.home-assistant.io/integrations/discord", + "requirements": ["discord.py==1.2.5"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/discord/notify.py b/homeassistant/components/discord/notify.py new file mode 100644 index 0000000000000..864b7da5e55a0 --- /dev/null +++ b/homeassistant/components/discord/notify.py @@ -0,0 +1,98 @@ +"""Discord platform for notify component.""" +import logging +import os.path + +import discord +import voluptuous as vol + +from homeassistant.components.notify import ( + ATTR_DATA, + ATTR_TARGET, + PLATFORM_SCHEMA, + BaseNotificationService, +) +from homeassistant.const import CONF_TOKEN +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({vol.Required(CONF_TOKEN): cv.string}) + +ATTR_IMAGES = "images" + + +def get_service(hass, config, discovery_info=None): + """Get the Discord notification service.""" + token = config.get(CONF_TOKEN) + return DiscordNotificationService(hass, token) + + +class DiscordNotificationService(BaseNotificationService): + """Implement the notification service for Discord.""" + + def __init__(self, hass, token): + """Initialize the service.""" + self.token = token + self.hass = hass + + def file_exists(self, filename): + """Check if a file exists on disk and is in authorized path.""" + if not self.hass.config.is_allowed_path(filename): + return False + + return os.path.isfile(filename) + + async def async_send_message(self, message, **kwargs): + """Login to Discord, send message to channel(s) and log out.""" + + discord.VoiceClient.warn_nacl = False + discord_bot = discord.Client() + images = None + + if ATTR_TARGET not in kwargs: + _LOGGER.error("No target specified") + return None + + data = kwargs.get(ATTR_DATA) or {} + + if ATTR_IMAGES in data: + images = list() + + for image in data.get(ATTR_IMAGES): + image_exists = await self.hass.async_add_executor_job( + self.file_exists, image + ) + + if image_exists: + images.append(image) + else: + _LOGGER.warning("Image not found: %s", image) + + # pylint: disable=unused-variable + @discord_bot.event + async def on_ready(): + """Send the messages when the bot is ready.""" + try: + for channelid in kwargs[ATTR_TARGET]: + channelid = int(channelid) + channel = discord_bot.get_channel(channelid) + + if channel is None: + _LOGGER.warning("Channel not found for id: %s", channelid) + continue + + # Must create new instances of File for each channel. + files = None + if images: + files = list() + for image in images: + files.append(discord.File(image)) + + await channel.send(message, files=files) + except (discord.errors.HTTPException, discord.errors.NotFound) as error: + _LOGGER.warning("Communication error: %s", error) + await discord_bot.logout() + await discord_bot.close() + + # Using reconnect=False prevents multiple ready events to be fired. + await discord_bot.start(self.token, reconnect=False) diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py deleted file mode 100644 index 2a9cb64f6ecb6..0000000000000 --- a/homeassistant/components/discovery.py +++ /dev/null @@ -1,98 +0,0 @@ -""" -Starts a service to scan in intervals for new devices. - -Will emit EVENT_PLATFORM_DISCOVERED whenever a new service has been discovered. - -Knows which components handle certain types, will make sure they are -loaded before the EVENT_PLATFORM_DISCOVERED is fired. - -""" -import logging -import threading - -# pylint: disable=no-name-in-module, import-error -import homeassistant.external.netdisco.netdisco.const as services - -from homeassistant import bootstrap -from homeassistant.const import ( - EVENT_HOMEASSISTANT_START, EVENT_PLATFORM_DISCOVERED, - ATTR_SERVICE, ATTR_DISCOVERED) - -DOMAIN = "discovery" -DEPENDENCIES = [] - -SCAN_INTERVAL = 300 # seconds - -SERVICE_HANDLERS = { - services.BELKIN_WEMO: "switch", - services.GOOGLE_CAST: "media_player", - services.PHILIPS_HUE: "light", -} - - -def listen(hass, service, callback): - """ - Setup listener for discovery of specific service. - Service can be a string or a list/tuple. - """ - - if isinstance(service, str): - service = (service,) - else: - service = tuple(service) - - def discovery_event_listener(event): - """ Listens for discovery events. """ - if event.data[ATTR_SERVICE] in service: - callback(event.data[ATTR_SERVICE], event.data[ATTR_DISCOVERED]) - - hass.bus.listen(EVENT_PLATFORM_DISCOVERED, discovery_event_listener) - - -def setup(hass, config): - """ Starts a discovery service. """ - logger = logging.getLogger(__name__) - - try: - from homeassistant.external.netdisco.netdisco.service import \ - DiscoveryService - except ImportError: - logger.exception( - "Unable to import netdisco. " - "Did you install all the zeroconf dependency?") - return False - - # Disable zeroconf logging, it spams - logging.getLogger('zeroconf').setLevel(logging.CRITICAL) - - lock = threading.Lock() - - def new_service_listener(service, info): - """ Called when a new service is found. """ - with lock: - logger.info("Found new service: %s %s", service, info) - - component = SERVICE_HANDLERS.get(service) - - # We do not know how to handle this service - if not component: - return - - # This component cannot be setup. - if not bootstrap.setup_component(hass, component, config): - return - - hass.bus.fire(EVENT_PLATFORM_DISCOVERED, { - ATTR_SERVICE: service, - ATTR_DISCOVERED: info - }) - - def start_discovery(event): - """ Start discovering. """ - netdisco = DiscoveryService(SCAN_INTERVAL) - netdisco.add_listener(new_service_listener) - netdisco.start() - - hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_discovery) - - return True diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py new file mode 100644 index 0000000000000..965782d1228fc --- /dev/null +++ b/homeassistant/components/discovery/__init__.py @@ -0,0 +1,239 @@ +""" +Starts a service to scan in intervals for new devices. + +Will emit EVENT_PLATFORM_DISCOVERED whenever a new service has been discovered. + +Knows which components handle certain types, will make sure they are +loaded before the EVENT_PLATFORM_DISCOVERED is fired. +""" +from datetime import timedelta +import json +import logging + +from netdisco.discovery import NetworkDiscovery +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import EVENT_HOMEASSISTANT_START +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.discovery import async_discover, async_load_platform +from homeassistant.helpers.event import async_track_point_in_utc_time +import homeassistant.util.dt as dt_util + +DOMAIN = "discovery" + +SCAN_INTERVAL = timedelta(seconds=300) +SERVICE_APPLE_TV = "apple_tv" +SERVICE_DAIKIN = "daikin" +SERVICE_DLNA_DMR = "dlna_dmr" +SERVICE_ENIGMA2 = "enigma2" +SERVICE_FREEBOX = "freebox" +SERVICE_HASS_IOS_APP = "hass_ios" +SERVICE_HASSIO = "hassio" +SERVICE_HEOS = "heos" +SERVICE_IGD = "igd" +SERVICE_KONNECTED = "konnected" +SERVICE_MOBILE_APP = "hass_mobile_app" +SERVICE_NETGEAR = "netgear_router" +SERVICE_OCTOPRINT = "octoprint" +SERVICE_PLEX = "plex_mediaserver" +SERVICE_ROKU = "roku" +SERVICE_SABNZBD = "sabnzbd" +SERVICE_SAMSUNG_PRINTER = "samsung_printer" +SERVICE_TELLDUSLIVE = "tellstick" +SERVICE_YEELIGHT = "yeelight" +SERVICE_WEMO = "belkin_wemo" +SERVICE_WINK = "wink" +SERVICE_XIAOMI_GW = "xiaomi_gw" + +CONFIG_ENTRY_HANDLERS = { + SERVICE_DAIKIN: "daikin", + SERVICE_TELLDUSLIVE: "tellduslive", + SERVICE_IGD: "upnp", + SERVICE_PLEX: "plex", +} + +SERVICE_HANDLERS = { + SERVICE_MOBILE_APP: ("mobile_app", None), + SERVICE_HASS_IOS_APP: ("ios", None), + SERVICE_NETGEAR: ("device_tracker", None), + SERVICE_HASSIO: ("hassio", None), + SERVICE_APPLE_TV: ("apple_tv", None), + SERVICE_ENIGMA2: ("media_player", "enigma2"), + SERVICE_ROKU: ("roku", None), + SERVICE_WINK: ("wink", None), + SERVICE_XIAOMI_GW: ("xiaomi_aqara", None), + SERVICE_SABNZBD: ("sabnzbd", None), + SERVICE_SAMSUNG_PRINTER: ("sensor", "syncthru"), + SERVICE_KONNECTED: ("konnected", None), + SERVICE_OCTOPRINT: ("octoprint", None), + SERVICE_FREEBOX: ("freebox", None), + SERVICE_YEELIGHT: ("yeelight", None), + "panasonic_viera": ("media_player", "panasonic_viera"), + "yamaha": ("media_player", "yamaha"), + "logitech_mediaserver": ("media_player", "squeezebox"), + "directv": ("media_player", "directv"), + "denonavr": ("media_player", "denonavr"), + "samsung_tv": ("media_player", "samsungtv"), + "frontier_silicon": ("media_player", "frontier_silicon"), + "openhome": ("media_player", "openhome"), + "harmony": ("remote", "harmony"), + "bose_soundtouch": ("media_player", "soundtouch"), + "bluesound": ("media_player", "bluesound"), + "songpal": ("media_player", "songpal"), + "kodi": ("media_player", "kodi"), + "volumio": ("media_player", "volumio"), + "lg_smart_device": ("media_player", "lg_soundbar"), + "nanoleaf_aurora": ("light", "nanoleaf"), +} + +OPTIONAL_SERVICE_HANDLERS = {SERVICE_DLNA_DMR: ("media_player", "dlna_dmr")} + +MIGRATED_SERVICE_HANDLERS = [ + "axis", + "deconz", + "esphome", + "google_cast", + SERVICE_HEOS, + "homekit", + "ikea_tradfri", + "philips_hue", + "sonos", + SERVICE_WEMO, +] + +DEFAULT_ENABLED = ( + list(CONFIG_ENTRY_HANDLERS) + list(SERVICE_HANDLERS) + MIGRATED_SERVICE_HANDLERS +) +DEFAULT_DISABLED = list(OPTIONAL_SERVICE_HANDLERS) + MIGRATED_SERVICE_HANDLERS + +CONF_IGNORE = "ignore" +CONF_ENABLE = "enable" + +CONFIG_SCHEMA = vol.Schema( + { + vol.Optional(DOMAIN): vol.Schema( + { + vol.Optional(CONF_IGNORE, default=[]): vol.All( + cv.ensure_list, [vol.In(DEFAULT_ENABLED)] + ), + vol.Optional(CONF_ENABLE, default=[]): vol.All( + cv.ensure_list, [vol.In(DEFAULT_DISABLED + DEFAULT_ENABLED)] + ), + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, config): + """Start a discovery service.""" + + logger = logging.getLogger(__name__) + netdisco = NetworkDiscovery() + already_discovered = set() + + # Disable zeroconf logging, it spams + logging.getLogger("zeroconf").setLevel(logging.CRITICAL) + + if DOMAIN in config: + # Platforms ignore by config + ignored_platforms = config[DOMAIN][CONF_IGNORE] + + # Optional platforms enabled by config + enabled_platforms = config[DOMAIN][CONF_ENABLE] + else: + ignored_platforms = [] + enabled_platforms = [] + + for platform in enabled_platforms: + if platform in DEFAULT_ENABLED: + logger.warning( + "Please remove %s from your discovery.enable configuration " + "as it is now enabled by default", + platform, + ) + + async def new_service_found(service, info): + """Handle a new service if one is found.""" + if service in MIGRATED_SERVICE_HANDLERS: + return + + if service in ignored_platforms: + logger.info("Ignoring service: %s %s", service, info) + return + + discovery_hash = json.dumps([service, info], sort_keys=True) + if discovery_hash in already_discovered: + logger.debug("Already discovered service %s %s.", service, info) + return + + already_discovered.add(discovery_hash) + + if service in CONFIG_ENTRY_HANDLERS: + await hass.config_entries.flow.async_init( + CONFIG_ENTRY_HANDLERS[service], + context={"source": config_entries.SOURCE_DISCOVERY}, + data=info, + ) + return + + comp_plat = SERVICE_HANDLERS.get(service) + + if not comp_plat and service in enabled_platforms: + comp_plat = OPTIONAL_SERVICE_HANDLERS[service] + + # We do not know how to handle this service. + if not comp_plat: + logger.info("Unknown service discovered: %s %s", service, info) + return + + logger.info("Found new service: %s %s", service, info) + + component, platform = comp_plat + + if platform is None: + await async_discover(hass, service, info, component, config) + else: + await async_load_platform(hass, component, platform, info, config) + + async def scan_devices(now): + """Scan for devices.""" + try: + results = await hass.async_add_job(_discover, netdisco) + + for result in results: + hass.async_create_task(new_service_found(*result)) + except OSError: + logger.error("Network is unreachable") + + async_track_point_in_utc_time( + hass, scan_devices, dt_util.utcnow() + SCAN_INTERVAL + ) + + @callback + def schedule_first(event): + """Schedule the first discovery when Home Assistant starts up.""" + async_track_point_in_utc_time(hass, scan_devices, dt_util.utcnow()) + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, schedule_first) + + return True + + +def _discover(netdisco): + """Discover devices.""" + results = [] + try: + netdisco.scan() + + for disc in netdisco.discover(): + for service in netdisco.get_info(disc): + results.append((disc, service)) + + finally: + netdisco.stop() + + return results diff --git a/homeassistant/components/discovery/manifest.json b/homeassistant/components/discovery/manifest.json new file mode 100644 index 0000000000000..83a6222d3575e --- /dev/null +++ b/homeassistant/components/discovery/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "discovery", + "name": "Discovery", + "documentation": "https://www.home-assistant.io/integrations/discovery", + "requirements": ["netdisco==2.6.0"], + "dependencies": [], + "codeowners": [], + "quality_scale": "internal" +} diff --git a/homeassistant/components/dlib_face_detect/__init__.py b/homeassistant/components/dlib_face_detect/__init__.py new file mode 100644 index 0000000000000..a732132955ff0 --- /dev/null +++ b/homeassistant/components/dlib_face_detect/__init__.py @@ -0,0 +1 @@ +"""The dlib_face_detect component.""" diff --git a/homeassistant/components/dlib_face_detect/image_processing.py b/homeassistant/components/dlib_face_detect/image_processing.py new file mode 100644 index 0000000000000..430878ca44f33 --- /dev/null +++ b/homeassistant/components/dlib_face_detect/image_processing.py @@ -0,0 +1,72 @@ +"""Component that will help set the Dlib face detect processing.""" +import io +import logging + +import face_recognition # pylint: disable=import-error + +from homeassistant.components.image_processing import ( + CONF_ENTITY_ID, + CONF_NAME, + CONF_SOURCE, + ImageProcessingFaceEntity, +) +from homeassistant.core import split_entity_id + +# pylint: disable=unused-import +from homeassistant.components.image_processing import ( # noqa: F401, isort:skip + PLATFORM_SCHEMA, +) + +_LOGGER = logging.getLogger(__name__) + +ATTR_LOCATION = "location" + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Dlib Face detection platform.""" + entities = [] + for camera in config[CONF_SOURCE]: + entities.append( + DlibFaceDetectEntity(camera[CONF_ENTITY_ID], camera.get(CONF_NAME)) + ) + + add_entities(entities) + + +class DlibFaceDetectEntity(ImageProcessingFaceEntity): + """Dlib Face API entity for identify.""" + + def __init__(self, camera_entity, name=None): + """Initialize Dlib face entity.""" + super().__init__() + + self._camera = camera_entity + + if name: + self._name = name + else: + self._name = "Dlib Face {0}".format(split_entity_id(camera_entity)[1]) + + @property + def camera_entity(self): + """Return camera entity id from process pictures.""" + return self._camera + + @property + def name(self): + """Return the name of the entity.""" + return self._name + + def process_image(self, image): + """Process image.""" + + fak_file = io.BytesIO(image) + fak_file.name = "snapshot.jpg" + fak_file.seek(0) + + image = face_recognition.load_image_file(fak_file) + face_locations = face_recognition.face_locations(image) + + face_locations = [{ATTR_LOCATION: location} for location in face_locations] + + self.process_faces(face_locations, len(face_locations)) diff --git a/homeassistant/components/dlib_face_detect/manifest.json b/homeassistant/components/dlib_face_detect/manifest.json new file mode 100644 index 0000000000000..672368e0f8c82 --- /dev/null +++ b/homeassistant/components/dlib_face_detect/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "dlib_face_detect", + "name": "Dlib Face Detect", + "documentation": "https://www.home-assistant.io/integrations/dlib_face_detect", + "requirements": ["face_recognition==1.2.3"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/dlib_face_identify/__init__.py b/homeassistant/components/dlib_face_identify/__init__.py new file mode 100644 index 0000000000000..79b9e4ec4bcc1 --- /dev/null +++ b/homeassistant/components/dlib_face_identify/__init__.py @@ -0,0 +1 @@ +"""The dlib_face_identify component.""" diff --git a/homeassistant/components/dlib_face_identify/image_processing.py b/homeassistant/components/dlib_face_identify/image_processing.py new file mode 100644 index 0000000000000..d6fbf106b0c96 --- /dev/null +++ b/homeassistant/components/dlib_face_identify/image_processing.py @@ -0,0 +1,103 @@ +"""Component that will help set the Dlib face detect processing.""" +import io +import logging + +# pylint: disable=import-error +import face_recognition +import voluptuous as vol + +from homeassistant.components.image_processing import ( + CONF_CONFIDENCE, + CONF_ENTITY_ID, + CONF_NAME, + CONF_SOURCE, + PLATFORM_SCHEMA, + ImageProcessingFaceEntity, +) +from homeassistant.core import split_entity_id +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +ATTR_NAME = "name" +CONF_FACES = "faces" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_FACES): {cv.string: cv.isfile}, + vol.Optional(CONF_CONFIDENCE, default=0.6): vol.Coerce(float), + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Dlib Face detection platform.""" + entities = [] + for camera in config[CONF_SOURCE]: + entities.append( + DlibFaceIdentifyEntity( + camera[CONF_ENTITY_ID], + config[CONF_FACES], + camera.get(CONF_NAME), + config[CONF_CONFIDENCE], + ) + ) + + add_entities(entities) + + +class DlibFaceIdentifyEntity(ImageProcessingFaceEntity): + """Dlib Face API entity for identify.""" + + def __init__(self, camera_entity, faces, name, tolerance): + """Initialize Dlib face identify entry.""" + + super().__init__() + + self._camera = camera_entity + + if name: + self._name = name + else: + self._name = "Dlib Face {0}".format(split_entity_id(camera_entity)[1]) + + self._faces = {} + for face_name, face_file in faces.items(): + try: + image = face_recognition.load_image_file(face_file) + self._faces[face_name] = face_recognition.face_encodings(image)[0] + except IndexError as err: + _LOGGER.error("Failed to parse %s. Error: %s", face_file, err) + + self._tolerance = tolerance + + @property + def camera_entity(self): + """Return camera entity id from process pictures.""" + return self._camera + + @property + def name(self): + """Return the name of the entity.""" + return self._name + + def process_image(self, image): + """Process image.""" + + fak_file = io.BytesIO(image) + fak_file.name = "snapshot.jpg" + fak_file.seek(0) + + image = face_recognition.load_image_file(fak_file) + unknowns = face_recognition.face_encodings(image) + + found = [] + for unknown_face in unknowns: + for name, face in self._faces.items(): + result = face_recognition.compare_faces( + [face], unknown_face, tolerance=self._tolerance + ) + if result[0]: + found.append({ATTR_NAME: name}) + + self.process_faces(found, len(unknowns)) diff --git a/homeassistant/components/dlib_face_identify/manifest.json b/homeassistant/components/dlib_face_identify/manifest.json new file mode 100644 index 0000000000000..f6c85a7e9a7e4 --- /dev/null +++ b/homeassistant/components/dlib_face_identify/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "dlib_face_identify", + "name": "Dlib Face Identify", + "documentation": "https://www.home-assistant.io/integrations/dlib_face_identify", + "requirements": ["face_recognition==1.2.3"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/dlink/__init__.py b/homeassistant/components/dlink/__init__.py new file mode 100644 index 0000000000000..644e7975a0e64 --- /dev/null +++ b/homeassistant/components/dlink/__init__.py @@ -0,0 +1 @@ +"""The dlink component.""" diff --git a/homeassistant/components/dlink/manifest.json b/homeassistant/components/dlink/manifest.json new file mode 100644 index 0000000000000..7f5ff6cfd029a --- /dev/null +++ b/homeassistant/components/dlink/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "dlink", + "name": "D-Link Wi-Fi Smart Plugs", + "documentation": "https://www.home-assistant.io/integrations/dlink", + "requirements": ["pyW215==0.6.0"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/dlink/switch.py b/homeassistant/components/dlink/switch.py new file mode 100644 index 0000000000000..7fa391e806061 --- /dev/null +++ b/homeassistant/components/dlink/switch.py @@ -0,0 +1,167 @@ +"""Support for D-Link W215 smart switch.""" +from datetime import timedelta +import logging +import urllib + +from pyW215.pyW215 import SmartPlug +import voluptuous as vol + +from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice +from homeassistant.const import ( + ATTR_TEMPERATURE, + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_USERNAME, + TEMP_CELSIUS, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.util import dt as dt_util + +_LOGGER = logging.getLogger(__name__) + +ATTR_TOTAL_CONSUMPTION = "total_consumption" + +CONF_USE_LEGACY_PROTOCOL = "use_legacy_protocol" + +DEFAULT_NAME = "D-Link Smart Plug W215" +DEFAULT_PASSWORD = "" +DEFAULT_USERNAME = "admin" + +SCAN_INTERVAL = timedelta(minutes=2) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, + vol.Required(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_USE_LEGACY_PROTOCOL, default=False): cv.boolean, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up a D-Link Smart Plug.""" + + host = config.get(CONF_HOST) + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + use_legacy_protocol = config.get(CONF_USE_LEGACY_PROTOCOL) + name = config.get(CONF_NAME) + + smartplug = SmartPlug(host, password, username, use_legacy_protocol) + data = SmartPlugData(smartplug) + + add_entities([SmartPlugSwitch(hass, data, name)], True) + + +class SmartPlugSwitch(SwitchDevice): + """Representation of a D-Link Smart Plug switch.""" + + def __init__(self, hass, data, name): + """Initialize the switch.""" + self.units = hass.config.units + self.data = data + self._name = name + + @property + def name(self): + """Return the name of the Smart Plug.""" + return self._name + + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + try: + ui_temp = self.units.temperature(int(self.data.temperature), TEMP_CELSIUS) + temperature = ui_temp + except (ValueError, TypeError): + temperature = None + + try: + total_consumption = float(self.data.total_consumption) + except (ValueError, TypeError): + total_consumption = None + + attrs = { + ATTR_TOTAL_CONSUMPTION: total_consumption, + ATTR_TEMPERATURE: temperature, + } + + return attrs + + @property + def current_power_w(self): + """Return the current power usage in Watt.""" + try: + return float(self.data.current_consumption) + except (ValueError, TypeError): + return None + + @property + def is_on(self): + """Return true if switch is on.""" + return self.data.state == "ON" + + def turn_on(self, **kwargs): + """Turn the switch on.""" + self.data.smartplug.state = "ON" + + def turn_off(self, **kwargs): + """Turn the switch off.""" + self.data.smartplug.state = "OFF" + + def update(self): + """Get the latest data from the smart plug and updates the states.""" + self.data.update() + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self.data.available + + +class SmartPlugData: + """Get the latest data from smart plug.""" + + def __init__(self, smartplug): + """Initialize the data object.""" + self.smartplug = smartplug + self.state = None + self.temperature = None + self.current_consumption = None + self.total_consumption = None + self.available = False + self._n_tried = 0 + self._last_tried = None + + def update(self): + """Get the latest data from the smart plug.""" + if self._last_tried is not None: + last_try_s = (dt_util.now() - self._last_tried).total_seconds() / 60 + retry_seconds = min(self._n_tried * 2, 10) - last_try_s + if self._n_tried > 0 and retry_seconds > 0: + _LOGGER.warning("Waiting %s s to retry", retry_seconds) + return + + _state = "unknown" + + try: + self._last_tried = dt_util.now() + _state = self.smartplug.state + except urllib.error.HTTPError: + _LOGGER.error("D-Link connection problem") + if _state == "unknown": + self._n_tried += 1 + self.available = False + _LOGGER.warning("Failed to connect to D-Link switch") + return + + self.state = _state + self.available = True + + self.temperature = self.smartplug.temperature + self.current_consumption = self.smartplug.current_consumption + self.total_consumption = self.smartplug.total_consumption + self._n_tried = 0 diff --git a/homeassistant/components/dlna_dmr/__init__.py b/homeassistant/components/dlna_dmr/__init__.py new file mode 100644 index 0000000000000..f38456ec6ee3f --- /dev/null +++ b/homeassistant/components/dlna_dmr/__init__.py @@ -0,0 +1 @@ +"""The dlna_dmr component.""" diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json new file mode 100644 index 0000000000000..8380c3b10fbae --- /dev/null +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "dlna_dmr", + "name": "DLNA Digital Media Renderer", + "documentation": "https://www.home-assistant.io/integrations/dlna_dmr", + "requirements": ["async-upnp-client==0.14.12"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/dlna_dmr/media_player.py b/homeassistant/components/dlna_dmr/media_player.py new file mode 100644 index 0000000000000..28843aacbe44a --- /dev/null +++ b/homeassistant/components/dlna_dmr/media_player.py @@ -0,0 +1,436 @@ +"""Support for DLNA DMR (Device Media Renderer).""" +import asyncio +from datetime import timedelta +import functools +import logging +from typing import Optional + +import aiohttp +from async_upnp_client import UpnpFactory +from async_upnp_client.aiohttp import AiohttpNotifyServer, AiohttpSessionRequester +from async_upnp_client.profiles.dlna import DeviceState, DmrDevice +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_IMAGE, + MEDIA_TYPE_MOVIE, + MEDIA_TYPE_MUSIC, + MEDIA_TYPE_PLAYLIST, + MEDIA_TYPE_TVSHOW, + MEDIA_TYPE_VIDEO, + SUPPORT_NEXT_TRACK, + SUPPORT_PAUSE, + SUPPORT_PLAY, + SUPPORT_PLAY_MEDIA, + SUPPORT_PREVIOUS_TRACK, + SUPPORT_SEEK, + SUPPORT_STOP, + SUPPORT_VOLUME_MUTE, + SUPPORT_VOLUME_SET, +) +from homeassistant.const import ( + CONF_NAME, + CONF_URL, + EVENT_HOMEASSISTANT_STOP, + STATE_IDLE, + STATE_OFF, + STATE_ON, + STATE_PAUSED, + STATE_PLAYING, +) +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.util import get_local_ip +import homeassistant.util.dt as dt_util + +_LOGGER = logging.getLogger(__name__) + +DLNA_DMR_DATA = "dlna_dmr" + +DEFAULT_NAME = "DLNA Digital Media Renderer" +DEFAULT_LISTEN_PORT = 8301 + +CONF_LISTEN_IP = "listen_ip" +CONF_LISTEN_PORT = "listen_port" +CONF_CALLBACK_URL_OVERRIDE = "callback_url_override" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_URL): cv.string, + vol.Optional(CONF_LISTEN_IP): cv.string, + vol.Optional(CONF_LISTEN_PORT, default=DEFAULT_LISTEN_PORT): cv.port, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_CALLBACK_URL_OVERRIDE): cv.url, + } +) + +HOME_ASSISTANT_UPNP_CLASS_MAPPING = { + MEDIA_TYPE_MUSIC: "object.item.audioItem", + MEDIA_TYPE_TVSHOW: "object.item.videoItem", + MEDIA_TYPE_MOVIE: "object.item.videoItem", + MEDIA_TYPE_VIDEO: "object.item.videoItem", + MEDIA_TYPE_EPISODE: "object.item.videoItem", + MEDIA_TYPE_CHANNEL: "object.item.videoItem", + MEDIA_TYPE_IMAGE: "object.item.imageItem", + MEDIA_TYPE_PLAYLIST: "object.item.playlist", +} +UPNP_CLASS_DEFAULT = "object.item" +HOME_ASSISTANT_UPNP_MIME_TYPE_MAPPING = { + MEDIA_TYPE_MUSIC: "audio/*", + MEDIA_TYPE_TVSHOW: "video/*", + MEDIA_TYPE_MOVIE: "video/*", + MEDIA_TYPE_VIDEO: "video/*", + MEDIA_TYPE_EPISODE: "video/*", + MEDIA_TYPE_CHANNEL: "video/*", + MEDIA_TYPE_IMAGE: "image/*", + MEDIA_TYPE_PLAYLIST: "playlist/*", +} + + +def catch_request_errors(): + """Catch asyncio.TimeoutError, aiohttp.ClientError errors.""" + + def call_wrapper(func): + """Call wrapper for decorator.""" + + @functools.wraps(func) + def wrapper(self, *args, **kwargs): + """Catch asyncio.TimeoutError, aiohttp.ClientError errors.""" + try: + return func(self, *args, **kwargs) + except (asyncio.TimeoutError, aiohttp.ClientError): + _LOGGER.error("Error during call %s", func.__name__) + + return wrapper + + return call_wrapper + + +async def async_start_event_handler( + hass: HomeAssistantType, + server_host: str, + server_port: int, + requester, + callback_url_override: Optional[str] = None, +): + """Register notify view.""" + hass_data = hass.data[DLNA_DMR_DATA] + if "event_handler" in hass_data: + return hass_data["event_handler"] + + # start event handler + server = AiohttpNotifyServer( + requester, + listen_port=server_port, + listen_host=server_host, + callback_url=callback_url_override, + ) + await server.start_server() + _LOGGER.info("UPNP/DLNA event handler listening, url: %s", server.callback_url) + hass_data["notify_server"] = server + hass_data["event_handler"] = server.event_handler + + # register for graceful shutdown + async def async_stop_server(event): + """Stop server.""" + _LOGGER.debug("Stopping UPNP/DLNA event handler") + await server.stop_server() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop_server) + + return hass_data["event_handler"] + + +async def async_setup_platform( + hass: HomeAssistantType, config, async_add_entities, discovery_info=None +): + """Set up DLNA DMR platform.""" + if config.get(CONF_URL) is not None: + url = config[CONF_URL] + name = config.get(CONF_NAME) + elif discovery_info is not None: + url = discovery_info["ssdp_description"] + name = discovery_info.get("name") + + if DLNA_DMR_DATA not in hass.data: + hass.data[DLNA_DMR_DATA] = {} + + if "lock" not in hass.data[DLNA_DMR_DATA]: + hass.data[DLNA_DMR_DATA]["lock"] = asyncio.Lock() + + # build upnp/aiohttp requester + session = async_get_clientsession(hass) + requester = AiohttpSessionRequester(session, True) + + # ensure event handler has been started + with await hass.data[DLNA_DMR_DATA]["lock"]: + server_host = config.get(CONF_LISTEN_IP) + if server_host is None: + server_host = get_local_ip() + server_port = config.get(CONF_LISTEN_PORT, DEFAULT_LISTEN_PORT) + callback_url_override = config.get(CONF_CALLBACK_URL_OVERRIDE) + event_handler = await async_start_event_handler( + hass, server_host, server_port, requester, callback_url_override + ) + + # create upnp device + factory = UpnpFactory(requester, disable_state_variable_validation=True) + try: + upnp_device = await factory.async_create_device(url) + except (asyncio.TimeoutError, aiohttp.ClientError): + raise PlatformNotReady() + + # wrap with DmrDevice + dlna_device = DmrDevice(upnp_device, event_handler) + + # create our own device + device = DlnaDmrDevice(dlna_device, name) + _LOGGER.debug("Adding device: %s", device) + async_add_entities([device], True) + + +class DlnaDmrDevice(MediaPlayerDevice): + """Representation of a DLNA DMR device.""" + + def __init__(self, dmr_device, name=None): + """Initialize DLNA DMR device.""" + self._device = dmr_device + self._name = name + + self._available = False + self._subscription_renew_time = None + + async def async_added_to_hass(self): + """Handle addition.""" + self._device.on_event = self._on_event + + # Register unsubscribe on stop + bus = self.hass.bus + bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._async_on_hass_stop) + + @property + def available(self): + """Device is available.""" + return self._available + + async def _async_on_hass_stop(self, event): + """Event handler on Home Assistant stop.""" + with await self.hass.data[DLNA_DMR_DATA]["lock"]: + await self._device.async_unsubscribe_services() + + async def async_update(self): + """Retrieve the latest data.""" + was_available = self._available + + try: + await self._device.async_update() + self._available = True + except (asyncio.TimeoutError, aiohttp.ClientError): + self._available = False + _LOGGER.debug("Device unavailable") + return + + # do we need to (re-)subscribe? + now = dt_util.utcnow() + should_renew = ( + self._subscription_renew_time and now >= self._subscription_renew_time + ) + if should_renew or not was_available and self._available: + try: + timeout = await self._device.async_subscribe_services() + self._subscription_renew_time = dt_util.utcnow() + timeout / 2 + except (asyncio.TimeoutError, aiohttp.ClientError): + self._available = False + _LOGGER.debug("Could not (re)subscribe") + + def _on_event(self, service, state_variables): + """State variable(s) changed, let home-assistant know.""" + self.schedule_update_ha_state() + + @property + def supported_features(self): + """Flag media player features that are supported.""" + supported_features = 0 + + if self._device.has_volume_level: + supported_features |= SUPPORT_VOLUME_SET + if self._device.has_volume_mute: + supported_features |= SUPPORT_VOLUME_MUTE + if self._device.has_play: + supported_features |= SUPPORT_PLAY + if self._device.has_pause: + supported_features |= SUPPORT_PAUSE + if self._device.has_stop: + supported_features |= SUPPORT_STOP + if self._device.has_previous: + supported_features |= SUPPORT_PREVIOUS_TRACK + if self._device.has_next: + supported_features |= SUPPORT_NEXT_TRACK + if self._device.has_play_media: + supported_features |= SUPPORT_PLAY_MEDIA + if self._device.has_seek_rel_time: + supported_features |= SUPPORT_SEEK + + return supported_features + + @property + def volume_level(self): + """Volume level of the media player (0..1).""" + return self._device.volume_level + + @catch_request_errors() + async def async_set_volume_level(self, volume): + """Set volume level, range 0..1.""" + await self._device.async_set_volume_level(volume) + + @property + def is_volume_muted(self): + """Boolean if volume is currently muted.""" + return self._device.is_volume_muted + + @catch_request_errors() + async def async_mute_volume(self, mute): + """Mute the volume.""" + desired_mute = bool(mute) + await self._device.async_mute_volume(desired_mute) + + @catch_request_errors() + async def async_media_pause(self): + """Send pause command.""" + if not self._device.can_pause: + _LOGGER.debug("Cannot do Pause") + return + + await self._device.async_pause() + + @catch_request_errors() + async def async_media_play(self): + """Send play command.""" + if not self._device.can_play: + _LOGGER.debug("Cannot do Play") + return + + await self._device.async_play() + + @catch_request_errors() + async def async_media_stop(self): + """Send stop command.""" + if not self._device.can_stop: + _LOGGER.debug("Cannot do Stop") + return + + await self._device.async_stop() + + @catch_request_errors() + async def async_media_seek(self, position): + """Send seek command.""" + if not self._device.can_seek_rel_time: + _LOGGER.debug("Cannot do Seek/rel_time") + return + + time = timedelta(seconds=position) + await self._device.async_seek_rel_time(time) + + @catch_request_errors() + async def async_play_media(self, media_type, media_id, **kwargs): + """Play a piece of media.""" + title = "Home Assistant" + mime_type = HOME_ASSISTANT_UPNP_MIME_TYPE_MAPPING.get(media_type, media_type) + upnp_class = HOME_ASSISTANT_UPNP_CLASS_MAPPING.get( + media_type, UPNP_CLASS_DEFAULT + ) + + # Stop current playing media + if self._device.can_stop: + await self.async_media_stop() + + # Queue media + await self._device.async_set_transport_uri( + media_id, title, mime_type, upnp_class + ) + await self._device.async_wait_for_can_play() + + # If already playing, no need to call Play + if self._device.state == DeviceState.PLAYING: + return + + # Play it + await self.async_media_play() + + @catch_request_errors() + async def async_media_previous_track(self): + """Send previous track command.""" + if not self._device.can_previous: + _LOGGER.debug("Cannot do Previous") + return + + await self._device.async_previous() + + @catch_request_errors() + async def async_media_next_track(self): + """Send next track command.""" + if not self._device.can_next: + _LOGGER.debug("Cannot do Next") + return + + await self._device.async_next() + + @property + def media_title(self): + """Title of current playing media.""" + return self._device.media_title + + @property + def media_image_url(self): + """Image url of current playing media.""" + return self._device.media_image_url + + @property + def state(self): + """State of the player.""" + if not self._available: + return STATE_OFF + + if self._device.state is None: + return STATE_ON + if self._device.state == DeviceState.PLAYING: + return STATE_PLAYING + if self._device.state == DeviceState.PAUSED: + return STATE_PAUSED + + return STATE_IDLE + + @property + def media_duration(self): + """Duration of current playing media in seconds.""" + return self._device.media_duration + + @property + def media_position(self): + """Position of current playing media in seconds.""" + return self._device.media_position + + @property + def media_position_updated_at(self): + """When was the position of the current playing media valid. + + Returns value from homeassistant.util.dt.utcnow(). + """ + return self._device.media_position_updated_at + + @property + def name(self) -> str: + """Return the name of the device.""" + if self._name: + return self._name + return self._device.name + + @property + def unique_id(self) -> str: + """Return an unique ID.""" + return self._device.udn diff --git a/homeassistant/components/dnsip/__init__.py b/homeassistant/components/dnsip/__init__.py new file mode 100644 index 0000000000000..603e8403e745f --- /dev/null +++ b/homeassistant/components/dnsip/__init__.py @@ -0,0 +1 @@ +"""The dnsip component.""" diff --git a/homeassistant/components/dnsip/manifest.json b/homeassistant/components/dnsip/manifest.json new file mode 100644 index 0000000000000..75d747da4ea6a --- /dev/null +++ b/homeassistant/components/dnsip/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "dnsip", + "name": "DNS IP", + "documentation": "https://www.home-assistant.io/integrations/dnsip", + "requirements": ["aiodns==2.0.0"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/dnsip/sensor.py b/homeassistant/components/dnsip/sensor.py new file mode 100644 index 0000000000000..fb57040f2c2ec --- /dev/null +++ b/homeassistant/components/dnsip/sensor.py @@ -0,0 +1,93 @@ +"""Get your own public IP address or that of any host.""" +from datetime import timedelta +import logging + +import aiodns +from aiodns.error import DNSError +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__) + +CONF_HOSTNAME = "hostname" +CONF_IPV6 = "ipv6" +CONF_RESOLVER = "resolver" +CONF_RESOLVER_IPV6 = "resolver_ipv6" + +DEFAULT_HOSTNAME = "myip.opendns.com" +DEFAULT_IPV6 = False +DEFAULT_NAME = "myip" +DEFAULT_RESOLVER = "208.67.222.222" +DEFAULT_RESOLVER_IPV6 = "2620:0:ccc::2" + +SCAN_INTERVAL = timedelta(seconds=120) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_HOSTNAME, default=DEFAULT_HOSTNAME): cv.string, + vol.Optional(CONF_RESOLVER, default=DEFAULT_RESOLVER): cv.string, + vol.Optional(CONF_RESOLVER_IPV6, default=DEFAULT_RESOLVER_IPV6): cv.string, + vol.Optional(CONF_IPV6, default=DEFAULT_IPV6): cv.boolean, + } +) + + +async def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the DNS IP sensor.""" + hostname = config.get(CONF_HOSTNAME) + name = config.get(CONF_NAME) + if not name: + if hostname == DEFAULT_HOSTNAME: + name = DEFAULT_NAME + else: + name = hostname + ipv6 = config.get(CONF_IPV6) + if ipv6: + resolver = config.get(CONF_RESOLVER_IPV6) + else: + resolver = config.get(CONF_RESOLVER) + + async_add_devices([WanIpSensor(hass, name, hostname, resolver, ipv6)], True) + + +class WanIpSensor(Entity): + """Implementation of a DNS IP sensor.""" + + def __init__(self, hass, name, hostname, resolver, ipv6): + """Initialize the DNS IP sensor.""" + + self.hass = hass + self._name = name + self.hostname = hostname + self.resolver = aiodns.DNSResolver() + self.resolver.nameservers = [resolver] + self.querytype = "AAAA" if ipv6 else "A" + self._state = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the current DNS IP address for hostname.""" + return self._state + + async def async_update(self): + """Get the current DNS IP address for hostname.""" + + try: + response = await self.resolver.query(self.hostname, self.querytype) + except DNSError as err: + _LOGGER.warning("Exception while resolving host: %s", err) + response = None + if response: + self._state = response[0].host + else: + self._state = None diff --git a/homeassistant/components/dominos/__init__.py b/homeassistant/components/dominos/__init__.py new file mode 100644 index 0000000000000..78852fa2699ad --- /dev/null +++ b/homeassistant/components/dominos/__init__.py @@ -0,0 +1,249 @@ +"""Support for Dominos Pizza ordering.""" +from datetime import timedelta +import logging + +from pizzapi import Address, Customer, Order +from pizzapi.address import StoreException +import voluptuous as vol + +from homeassistant.components import http +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.util import Throttle + +_LOGGER = logging.getLogger(__name__) + +# The domain of your component. Should be equal to the name of your component. +DOMAIN = "dominos" +ENTITY_ID_FORMAT = DOMAIN + ".{}" + +ATTR_COUNTRY = "country_code" +ATTR_FIRST_NAME = "first_name" +ATTR_LAST_NAME = "last_name" +ATTR_EMAIL = "email" +ATTR_PHONE = "phone" +ATTR_ADDRESS = "address" +ATTR_ORDERS = "orders" +ATTR_SHOW_MENU = "show_menu" +ATTR_ORDER_ENTITY = "order_entity_id" +ATTR_ORDER_NAME = "name" +ATTR_ORDER_CODES = "codes" + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10) +MIN_TIME_BETWEEN_STORE_UPDATES = timedelta(minutes=3330) + +_ORDERS_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ORDER_NAME): cv.string, + vol.Required(ATTR_ORDER_CODES): vol.All(cv.ensure_list, [cv.string]), + } +) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(ATTR_COUNTRY): cv.string, + vol.Required(ATTR_FIRST_NAME): cv.string, + vol.Required(ATTR_LAST_NAME): cv.string, + vol.Required(ATTR_EMAIL): cv.string, + vol.Required(ATTR_PHONE): cv.string, + vol.Required(ATTR_ADDRESS): cv.string, + vol.Optional(ATTR_SHOW_MENU): cv.boolean, + vol.Optional(ATTR_ORDERS, default=[]): vol.All( + cv.ensure_list, [_ORDERS_SCHEMA] + ), + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +def setup(hass, config): + """Set up is called when Home Assistant is loading our component.""" + dominos = Dominos(hass, config) + + component = EntityComponent(_LOGGER, DOMAIN, hass) + hass.data[DOMAIN] = {} + entities = [] + conf = config[DOMAIN] + + hass.services.register(DOMAIN, "order", dominos.handle_order) + + if conf.get(ATTR_SHOW_MENU): + hass.http.register_view(DominosProductListView(dominos)) + + for order_info in conf.get(ATTR_ORDERS): + order = DominosOrder(order_info, dominos) + entities.append(order) + + if entities: + component.add_entities(entities) + + # Return boolean to indicate that initialization was successfully. + return True + + +class Dominos: + """Main Dominos service.""" + + def __init__(self, hass, config): + """Set up main service.""" + conf = config[DOMAIN] + + self.hass = hass + self.customer = Customer( + conf.get(ATTR_FIRST_NAME), + conf.get(ATTR_LAST_NAME), + conf.get(ATTR_EMAIL), + conf.get(ATTR_PHONE), + conf.get(ATTR_ADDRESS), + ) + self.address = Address( + *self.customer.address.split(","), country=conf.get(ATTR_COUNTRY) + ) + self.country = conf.get(ATTR_COUNTRY) + try: + self.closest_store = self.address.closest_store() + except StoreException: + self.closest_store = None + + def handle_order(self, call): + """Handle ordering pizza.""" + entity_ids = call.data.get(ATTR_ORDER_ENTITY, None) + + target_orders = [ + order + for order in self.hass.data[DOMAIN]["entities"] + if order.entity_id in entity_ids + ] + + for order in target_orders: + order.place() + + @Throttle(MIN_TIME_BETWEEN_STORE_UPDATES) + def update_closest_store(self): + """Update the shared closest store (if open).""" + try: + self.closest_store = self.address.closest_store() + return True + except StoreException: + self.closest_store = None + return False + + def get_menu(self): + """Return the products from the closest stores menu.""" + self.update_closest_store() + if self.closest_store is None: + _LOGGER.warning("Cannot get menu. Store may be closed") + return [] + menu = self.closest_store.get_menu() + product_entries = [] + + for product in menu.products: + item = {} + if isinstance(product.menu_data["Variants"], list): + variants = ", ".join(product.menu_data["Variants"]) + else: + variants = product.menu_data["Variants"] + item["name"] = product.name + item["variants"] = variants + product_entries.append(item) + + return product_entries + + +class DominosProductListView(http.HomeAssistantView): + """View to retrieve product list content.""" + + url = "/api/dominos" + name = "api:dominos" + + def __init__(self, dominos): + """Initialize suite view.""" + self.dominos = dominos + + @callback + def get(self, request): + """Retrieve if API is running.""" + return self.json(self.dominos.get_menu()) + + +class DominosOrder(Entity): + """Represents a Dominos order entity.""" + + def __init__(self, order_info, dominos): + """Set up the entity.""" + self._name = order_info["name"] + self._product_codes = order_info["codes"] + self._orderable = False + self.dominos = dominos + + @property + def name(self): + """Return the orders name.""" + return self._name + + @property + def product_codes(self): + """Return the orders product codes.""" + return self._product_codes + + @property + def orderable(self): + """Return the true if orderable.""" + return self._orderable + + @property + def state(self): + """Return the state either closed, orderable or unorderable.""" + if self.dominos.closest_store is None: + return "closed" + return "orderable" if self._orderable else "unorderable" + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Update the order state and refreshes the store.""" + try: + self.dominos.update_closest_store() + except StoreException: + self._orderable = False + return + + try: + order = self.order() + order.pay_with() + self._orderable = True + except StoreException: + self._orderable = False + + def order(self): + """Create the order object.""" + if self.dominos.closest_store is None: + raise StoreException + + order = Order( + self.dominos.closest_store, + self.dominos.customer, + self.dominos.address, + self.dominos.country, + ) + + for code in self._product_codes: + order.add_item(code) + + return order + + def place(self): + """Place the order.""" + try: + order = self.order() + order.place() + except StoreException: + self._orderable = False + _LOGGER.warning( + "Attempted to order Dominos - Order invalid or store closed" + ) diff --git a/homeassistant/components/dominos/manifest.json b/homeassistant/components/dominos/manifest.json new file mode 100644 index 0000000000000..0137cafc169b6 --- /dev/null +++ b/homeassistant/components/dominos/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "dominos", + "name": "Dominos Pizza", + "documentation": "https://www.home-assistant.io/integrations/dominos", + "requirements": ["pizzapi==0.0.3"], + "dependencies": ["http"], + "codeowners": [] +} diff --git a/homeassistant/components/dominos/services.yaml b/homeassistant/components/dominos/services.yaml new file mode 100644 index 0000000000000..93f8b2851f17c --- /dev/null +++ b/homeassistant/components/dominos/services.yaml @@ -0,0 +1,6 @@ +order: + description: Places a set of orders with Dominos Pizza. + fields: + order_entity_id: + description: The ID (as specified in the configuration) of an order to place. If provided as an array, all of the identified orders will be placed. + example: dominos.medium_pan diff --git a/homeassistant/components/doods/__init__.py b/homeassistant/components/doods/__init__.py new file mode 100644 index 0000000000000..b6edb9be87bda --- /dev/null +++ b/homeassistant/components/doods/__init__.py @@ -0,0 +1 @@ +"""The doods component.""" diff --git a/homeassistant/components/doods/image_processing.py b/homeassistant/components/doods/image_processing.py new file mode 100644 index 0000000000000..9525f9e8ddfd8 --- /dev/null +++ b/homeassistant/components/doods/image_processing.py @@ -0,0 +1,378 @@ +"""Support for the DOODS service.""" +import io +import logging +import time + +from PIL import Image, ImageDraw +from pydoods import PyDOODS +import voluptuous as vol + +from homeassistant.components.image_processing import ( + CONF_CONFIDENCE, + CONF_ENTITY_ID, + CONF_NAME, + CONF_SOURCE, + PLATFORM_SCHEMA, + ImageProcessingEntity, +) +from homeassistant.const import CONF_TIMEOUT +from homeassistant.core import split_entity_id +from homeassistant.helpers import template +import homeassistant.helpers.config_validation as cv +from homeassistant.util.pil import draw_box + +_LOGGER = logging.getLogger(__name__) + +ATTR_MATCHES = "matches" +ATTR_SUMMARY = "summary" +ATTR_TOTAL_MATCHES = "total_matches" + +CONF_URL = "url" +CONF_AUTH_KEY = "auth_key" +CONF_DETECTOR = "detector" +CONF_LABELS = "labels" +CONF_AREA = "area" +CONF_COVERS = "covers" +CONF_TOP = "top" +CONF_BOTTOM = "bottom" +CONF_RIGHT = "right" +CONF_LEFT = "left" +CONF_FILE_OUT = "file_out" + +AREA_SCHEMA = vol.Schema( + { + vol.Optional(CONF_BOTTOM, default=1): cv.small_float, + vol.Optional(CONF_LEFT, default=0): cv.small_float, + vol.Optional(CONF_RIGHT, default=1): cv.small_float, + vol.Optional(CONF_TOP, default=0): cv.small_float, + vol.Optional(CONF_COVERS, default=True): cv.boolean, + } +) + +LABEL_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_AREA): AREA_SCHEMA, + vol.Optional(CONF_CONFIDENCE): vol.Range(min=0, max=100), + } +) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_URL): cv.string, + vol.Required(CONF_DETECTOR): cv.string, + vol.Required(CONF_TIMEOUT, default=90): cv.positive_int, + vol.Optional(CONF_AUTH_KEY, default=""): cv.string, + vol.Optional(CONF_FILE_OUT, default=[]): vol.All(cv.ensure_list, [cv.template]), + vol.Optional(CONF_CONFIDENCE, default=0.0): vol.Range(min=0, max=100), + vol.Optional(CONF_LABELS, default=[]): vol.All( + cv.ensure_list, [vol.Any(cv.string, LABEL_SCHEMA)] + ), + vol.Optional(CONF_AREA): AREA_SCHEMA, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Doods client.""" + url = config[CONF_URL] + auth_key = config[CONF_AUTH_KEY] + detector_name = config[CONF_DETECTOR] + timeout = config[CONF_TIMEOUT] + + doods = PyDOODS(url, auth_key, timeout) + response = doods.get_detectors() + if not isinstance(response, dict): + _LOGGER.warning("Could not connect to doods server: %s", url) + return + + detector = {} + for server_detector in response["detectors"]: + if server_detector["name"] == detector_name: + detector = server_detector + break + + if not detector: + _LOGGER.warning( + "Detector %s is not supported by doods server %s", detector_name, url + ) + return + + entities = [] + for camera in config[CONF_SOURCE]: + entities.append( + Doods( + hass, + camera[CONF_ENTITY_ID], + camera.get(CONF_NAME), + doods, + detector, + config, + ) + ) + add_entities(entities) + + +class Doods(ImageProcessingEntity): + """Doods image processing service client.""" + + def __init__(self, hass, camera_entity, name, doods, detector, config): + """Initialize the DOODS entity.""" + self.hass = hass + self._camera_entity = camera_entity + if name: + self._name = name + else: + name = split_entity_id(camera_entity)[1] + self._name = f"Doods {name}" + self._doods = doods + self._file_out = config[CONF_FILE_OUT] + self._detector_name = detector["name"] + + # detector config and aspect ratio + self._width = None + self._height = None + self._aspect = None + if detector["width"] and detector["height"]: + self._width = detector["width"] + self._height = detector["height"] + self._aspect = self._width / self._height + + # the base confidence + dconfig = {} + confidence = config[CONF_CONFIDENCE] + + # handle labels and specific detection areas + labels = config[CONF_LABELS] + self._label_areas = {} + self._label_covers = {} + for label in labels: + if isinstance(label, dict): + label_name = label[CONF_NAME] + if label_name not in detector["labels"] and label_name != "*": + _LOGGER.warning("Detector does not support label %s", label_name) + continue + + # If label confidence is not specified, use global confidence + label_confidence = label.get(CONF_CONFIDENCE) + if not label_confidence: + label_confidence = confidence + if label_name not in dconfig or dconfig[label_name] > label_confidence: + dconfig[label_name] = label_confidence + + # Label area + label_area = label.get(CONF_AREA) + self._label_areas[label_name] = [0, 0, 1, 1] + self._label_covers[label_name] = True + if label_area: + self._label_areas[label_name] = [ + label_area[CONF_TOP], + label_area[CONF_LEFT], + label_area[CONF_BOTTOM], + label_area[CONF_RIGHT], + ] + self._label_covers[label_name] = label_area[CONF_COVERS] + else: + if label not in detector["labels"] and label != "*": + _LOGGER.warning("Detector does not support label %s", label) + continue + self._label_areas[label] = [0, 0, 1, 1] + self._label_covers[label] = True + if label not in dconfig or dconfig[label] > confidence: + dconfig[label] = confidence + + if not dconfig: + dconfig["*"] = confidence + + # Handle global detection area + self._area = [0, 0, 1, 1] + self._covers = True + area_config = config.get(CONF_AREA) + if area_config: + self._area = [ + area_config[CONF_TOP], + area_config[CONF_LEFT], + area_config[CONF_BOTTOM], + area_config[CONF_RIGHT], + ] + self._covers = area_config[CONF_COVERS] + + template.attach(hass, self._file_out) + + self._dconfig = dconfig + self._matches = {} + self._total_matches = 0 + self._last_image = None + + @property + def camera_entity(self): + """Return camera entity id from process pictures.""" + return self._camera_entity + + @property + def name(self): + """Return the name of the image processor.""" + return self._name + + @property + def state(self): + """Return the state of the entity.""" + return self._total_matches + + @property + def device_state_attributes(self): + """Return device specific state attributes.""" + return { + ATTR_MATCHES: self._matches, + ATTR_SUMMARY: { + label: len(values) for label, values in self._matches.items() + }, + ATTR_TOTAL_MATCHES: self._total_matches, + } + + def _save_image(self, image, matches, paths): + img = Image.open(io.BytesIO(bytearray(image))).convert("RGB") + img_width, img_height = img.size + draw = ImageDraw.Draw(img) + + # Draw custom global region/area + if self._area != [0, 0, 1, 1]: + draw_box( + draw, self._area, img_width, img_height, "Detection Area", (0, 255, 255) + ) + + for label, values in matches.items(): + + # Draw custom label regions/areas + if label in self._label_areas and self._label_areas[label] != [0, 0, 1, 1]: + box_label = f"{label.capitalize()} Detection Area" + draw_box( + draw, + self._label_areas[label], + img_width, + img_height, + box_label, + (0, 255, 0), + ) + + # Draw detected objects + for instance in values: + box_label = f'{label} {instance["score"]:.1f}%' + # Already scaled, use 1 for width and height + draw_box( + draw, + instance["box"], + img_width, + img_height, + box_label, + (255, 255, 0), + ) + + for path in paths: + _LOGGER.info("Saving results image to %s", path) + img.save(path) + + def process_image(self, image): + """Process the image.""" + img = Image.open(io.BytesIO(bytearray(image))) + img_width, img_height = img.size + + if self._aspect and abs((img_width / img_height) - self._aspect) > 0.1: + _LOGGER.debug( + "The image aspect: %s and the detector aspect: %s differ by more than 0.1", + (img_width / img_height), + self._aspect, + ) + + # Run detection + start = time.time() + response = self._doods.detect( + image, dconfig=self._dconfig, detector_name=self._detector_name + ) + _LOGGER.debug( + "doods detect: %s response: %s duration: %s", + self._dconfig, + response, + time.time() - start, + ) + + matches = {} + total_matches = 0 + + if not response or "error" in response: + if "error" in response: + _LOGGER.error(response["error"]) + self._matches = matches + self._total_matches = total_matches + return + + for detection in response["detections"]: + score = detection["confidence"] + boxes = [ + detection["top"], + detection["left"], + detection["bottom"], + detection["right"], + ] + label = detection["label"] + + # Exclude unlisted labels + if "*" not in self._dconfig and label not in self._dconfig: + continue + + # Exclude matches outside global area definition + if self._covers: + if ( + boxes[0] < self._area[0] + or boxes[1] < self._area[1] + or boxes[2] > self._area[2] + or boxes[3] > self._area[3] + ): + continue + else: + if ( + boxes[0] > self._area[2] + or boxes[1] > self._area[3] + or boxes[2] < self._area[0] + or boxes[3] < self._area[1] + ): + continue + + # Exclude matches outside label specific area definition + if self._label_areas.get(label): + if self._label_covers[label]: + if ( + boxes[0] < self._label_areas[label][0] + or boxes[1] < self._label_areas[label][1] + or boxes[2] > self._label_areas[label][2] + or boxes[3] > self._label_areas[label][3] + ): + continue + else: + if ( + boxes[0] > self._label_areas[label][2] + or boxes[1] > self._label_areas[label][3] + or boxes[2] < self._label_areas[label][0] + or boxes[3] < self._label_areas[label][1] + ): + continue + + if label not in matches: + matches[label] = [] + matches[label].append({"score": float(score), "box": boxes}) + total_matches += 1 + + # Save Images + if total_matches and self._file_out: + paths = [] + for path_template in self._file_out: + if isinstance(path_template, template.Template): + paths.append( + path_template.render(camera_entity=self._camera_entity) + ) + else: + paths.append(path_template) + self._save_image(image, matches, paths) + + self._matches = matches + self._total_matches = total_matches diff --git a/homeassistant/components/doods/manifest.json b/homeassistant/components/doods/manifest.json new file mode 100644 index 0000000000000..551af839b5c47 --- /dev/null +++ b/homeassistant/components/doods/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "doods", + "name": "DOODS - Distributed Outside Object Detection Service", + "documentation": "https://www.home-assistant.io/integrations/doods", + "requirements": ["pydoods==1.0.2", "pillow==6.2.1"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/doorbird/__init__.py b/homeassistant/components/doorbird/__init__.py new file mode 100644 index 0000000000000..680ee1354eb23 --- /dev/null +++ b/homeassistant/components/doorbird/__init__.py @@ -0,0 +1,299 @@ +"""Support for DoorBird devices.""" +import logging +from urllib.error import HTTPError + +from doorbirdpy import DoorBird +import voluptuous as vol + +from homeassistant.components.http import HomeAssistantView +from homeassistant.const import ( + CONF_DEVICES, + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_TOKEN, + CONF_USERNAME, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.util import dt as dt_util, slugify + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "doorbird" + +API_URL = f"/api/{DOMAIN}" + +CONF_CUSTOM_URL = "hass_url_override" +CONF_EVENTS = "events" + +RESET_DEVICE_FAVORITES = "doorbird_reset_favorites" + +DEVICE_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_TOKEN): cv.string, + vol.Optional(CONF_EVENTS, default=[]): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_CUSTOM_URL): cv.string, + vol.Optional(CONF_NAME): cv.string, + } +) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + {vol.Required(CONF_DEVICES): vol.All(cv.ensure_list, [DEVICE_SCHEMA])} + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +def setup(hass, config): + """Set up the DoorBird component.""" + + # Provide an endpoint for the doorstations to call to trigger events + hass.http.register_view(DoorBirdRequestView) + + doorstations = [] + + for index, doorstation_config in enumerate(config[DOMAIN][CONF_DEVICES]): + device_ip = doorstation_config.get(CONF_HOST) + username = doorstation_config.get(CONF_USERNAME) + password = doorstation_config.get(CONF_PASSWORD) + custom_url = doorstation_config.get(CONF_CUSTOM_URL) + events = doorstation_config.get(CONF_EVENTS) + token = doorstation_config.get(CONF_TOKEN) + name = doorstation_config.get(CONF_NAME) or "DoorBird {}".format(index + 1) + + try: + device = DoorBird(device_ip, username, password) + status = device.ready() + except OSError as oserr: + _LOGGER.error( + "Failed to setup doorbird at %s: %s; not retrying", device_ip, oserr + ) + continue + + if status[0]: + doorstation = ConfiguredDoorBird(device, name, events, custom_url, token) + doorstations.append(doorstation) + _LOGGER.info( + 'Connected to DoorBird "%s" as %s@%s', + doorstation.name, + username, + device_ip, + ) + elif status[1] == 401: + _LOGGER.error( + "Authorization rejected by DoorBird for %s@%s", username, device_ip + ) + return False + else: + _LOGGER.error( + "Could not connect to DoorBird as %s@%s: Error %s", + username, + device_ip, + str(status[1]), + ) + return False + + # Subscribe to doorbell or motion events + if events: + try: + doorstation.register_events(hass) + except HTTPError: + hass.components.persistent_notification.create( + "Doorbird configuration failed. Please verify that API " + "Operator permission is enabled for the Doorbird user. " + "A restart will be required once permissions have been " + "verified.", + title="Doorbird Configuration Failure", + notification_id="doorbird_schedule_error", + ) + + return False + + hass.data[DOMAIN] = doorstations + + def _reset_device_favorites_handler(event): + """Handle clearing favorites on device.""" + token = event.data.get("token") + + if token is None: + return + + doorstation = get_doorstation_by_token(hass, token) + + if doorstation is None: + _LOGGER.error("Device not found for provided token.") + + # Clear webhooks + favorites = doorstation.device.favorites() + + for favorite_type in favorites: + for favorite_id in favorites[favorite_type]: + doorstation.device.delete_favorite(favorite_type, favorite_id) + + hass.bus.listen(RESET_DEVICE_FAVORITES, _reset_device_favorites_handler) + + return True + + +def get_doorstation_by_token(hass, token): + """Get doorstation by slug.""" + for doorstation in hass.data[DOMAIN]: + if token == doorstation.token: + return doorstation + + +class ConfiguredDoorBird: + """Attach additional information to pass along with configured device.""" + + def __init__(self, device, name, events, custom_url, token): + """Initialize configured device.""" + self._name = name + self._device = device + self._custom_url = custom_url + self._events = events + self._token = token + + @property + def name(self): + """Get custom device name.""" + return self._name + + @property + def device(self): + """Get the configured device.""" + return self._device + + @property + def custom_url(self): + """Get custom url for device.""" + return self._custom_url + + @property + def token(self): + """Get token for device.""" + return self._token + + def register_events(self, hass): + """Register events on device.""" + # Get the URL of this server + hass_url = hass.config.api.base_url + + # Override url if another is specified in the configuration + if self.custom_url is not None: + hass_url = self.custom_url + + for event in self._events: + event = self._get_event_name(event) + + self._register_event(hass_url, event) + + _LOGGER.info("Successfully registered URL for %s on %s", event, self.name) + + @property + def slug(self): + """Get device slug.""" + return slugify(self._name) + + def _get_event_name(self, event): + return f"{self.slug}_{event}" + + def _register_event(self, hass_url, event): + """Add a schedule entry in the device for a sensor.""" + url = f"{hass_url}{API_URL}/{event}?token={self._token}" + + # Register HA URL as webhook if not already, then get the ID + if not self.webhook_is_registered(url): + self.device.change_favorite("http", f"Home Assistant ({event})", url) + + fav_id = self.get_webhook_id(url) + + if not fav_id: + _LOGGER.warning( + 'Could not find favorite for URL "%s". ' 'Skipping sensor "%s"', + url, + event, + ) + return + + def webhook_is_registered(self, url, favs=None) -> bool: + """Return whether the given URL is registered as a device favorite.""" + favs = favs if favs else self.device.favorites() + + if "http" not in favs: + return False + + for fav in favs["http"].values(): + if fav["value"] == url: + return True + + return False + + def get_webhook_id(self, url, favs=None) -> str or None: + """ + Return the device favorite ID for the given URL. + + The favorite must exist or there will be problems. + """ + favs = favs if favs else self.device.favorites() + + if "http" not in favs: + return None + + for fav_id in favs["http"]: + if favs["http"][fav_id]["value"] == url: + return fav_id + + return None + + def get_event_data(self): + """Get data to pass along with HA event.""" + return { + "timestamp": dt_util.utcnow().isoformat(), + "live_video_url": self._device.live_video_url, + "live_image_url": self._device.live_image_url, + "rtsp_live_video_url": self._device.rtsp_live_video_url, + "html5_viewer_url": self._device.html5_viewer_url, + } + + +class DoorBirdRequestView(HomeAssistantView): + """Provide a page for the device to call.""" + + requires_auth = False + url = API_URL + name = API_URL[1:].replace("/", ":") + extra_urls = [API_URL + "/{event}"] + + async def get(self, request, event): + """Respond to requests from the device.""" + from aiohttp import web + + hass = request.app["hass"] + + token = request.query.get("token") + + device = get_doorstation_by_token(hass, token) + + if device is None: + return web.Response(status=401, text="Invalid token provided.") + + if device: + event_data = device.get_event_data() + else: + event_data = {} + + if event == "clear": + hass.bus.async_fire(RESET_DEVICE_FAVORITES, {"token": token}) + + message = f"HTTP Favorites cleared for {device.slug}" + return web.Response(status=200, text=message) + + hass.bus.async_fire(f"{DOMAIN}_{event}", event_data) + + return web.Response(status=200, text="OK") diff --git a/homeassistant/components/doorbird/camera.py b/homeassistant/components/doorbird/camera.py new file mode 100644 index 0000000000000..d9a802f071f5c --- /dev/null +++ b/homeassistant/components/doorbird/camera.py @@ -0,0 +1,99 @@ +"""Support for viewing the camera feed from a DoorBird video doorbell.""" +import asyncio +import datetime +import logging + +import aiohttp +import async_timeout + +from homeassistant.components.camera import SUPPORT_STREAM, Camera +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.util.dt as dt_util + +from . import DOMAIN as DOORBIRD_DOMAIN + +_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 + + +async 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, + device.rtsp_live_video_url, + ), + 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, stream_url=None): + """Initialize the camera on a DoorBird device.""" + self._url = url + self._stream_url = stream_url + self._name = name + self._last_image = None + self._supported_features = SUPPORT_STREAM if self._stream_url else 0 + self._interval = interval or datetime.timedelta + self._last_update = datetime.datetime.min + super().__init__() + + async def stream_source(self): + """Return the stream source.""" + return self._stream_url + + @property + def supported_features(self): + """Return supported features.""" + return self._supported_features + + @property + def name(self): + """Get the name of the camera.""" + return self._name + + async def async_camera_image(self): + """Pull a still image from the camera.""" + now = dt_util.utcnow() + + 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): + response = await websession.get(self._url) + + self._last_image = await 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/doorbird/manifest.json b/homeassistant/components/doorbird/manifest.json new file mode 100644 index 0000000000000..97b54adb4ab42 --- /dev/null +++ b/homeassistant/components/doorbird/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "doorbird", + "name": "DoorBird", + "documentation": "https://www.home-assistant.io/integrations/doorbird", + "requirements": ["doorbirdpy==2.0.8"], + "dependencies": ["http"], + "codeowners": ["@oblogic7"] +} diff --git a/homeassistant/components/doorbird/switch.py b/homeassistant/components/doorbird/switch.py new file mode 100644 index 0000000000000..7a0dfa82e76ee --- /dev/null +++ b/homeassistant/components/doorbird/switch.py @@ -0,0 +1,81 @@ +"""Support for powering relays in a DoorBird video doorbell.""" +import datetime +import logging + +from homeassistant.components.switch import SwitchDevice +import homeassistant.util.dt as dt_util + +from . import DOMAIN as DOORBIRD_DOMAIN + +_LOGGER = logging.getLogger(__name__) + +IR_RELAY = "__ir_light__" + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the DoorBird switch platform.""" + switches = [] + + for doorstation in hass.data[DOORBIRD_DOMAIN]: + relays = doorstation.device.info()["RELAYS"] + relays.append(IR_RELAY) + + for relay in relays: + switch = DoorBirdSwitch(doorstation, relay) + switches.append(switch) + + add_entities(switches) + + +class DoorBirdSwitch(SwitchDevice): + """A relay in a DoorBird device.""" + + def __init__(self, doorstation, relay): + """Initialize a relay in a DoorBird device.""" + self._doorstation = doorstation + self._relay = relay + self._state = False + self._assume_off = datetime.datetime.min + + if relay == IR_RELAY: + self._time = datetime.timedelta(minutes=5) + else: + self._time = datetime.timedelta(seconds=5) + + @property + def name(self): + """Return the name of the switch.""" + if self._relay == IR_RELAY: + return f"{self._doorstation.name} IR" + + return f"{self._doorstation.name} Relay {self._relay}" + + @property + def icon(self): + """Return the icon to display.""" + return "mdi:lightbulb" if self._relay == IR_RELAY else "mdi:dip-switch" + + @property + def is_on(self): + """Get the assumed state of the relay.""" + return self._state + + def turn_on(self, **kwargs): + """Power the relay.""" + if self._relay == IR_RELAY: + self._state = self._doorstation.device.turn_light_on() + else: + self._state = self._doorstation.device.energize_relay(self._relay) + + now = dt_util.utcnow() + self._assume_off = now + self._time + + def turn_off(self, **kwargs): + """Turn off the relays is not needed. They are time-based.""" + raise NotImplementedError("DoorBird relays cannot be manually turned off.") + + def update(self): + """Wait for the correct amount of assumed time to pass.""" + if self._state and self._assume_off <= dt_util.utcnow(): + self._state = False + self._assume_off = datetime.datetime.min diff --git a/homeassistant/components/dovado/__init__.py b/homeassistant/components/dovado/__init__.py new file mode 100644 index 0000000000000..b8d18d9083318 --- /dev/null +++ b/homeassistant/components/dovado/__init__.py @@ -0,0 +1,82 @@ +"""Support for Dovado router.""" +from datetime import timedelta +import logging + +import dovado +import voluptuous as vol + +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + DEVICE_DEFAULT_NAME, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.util import Throttle + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "dovado" + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_HOST): cv.string, + vol.Optional(CONF_PORT): cv.port, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) + + +def setup(hass, config): + """Set up the Dovado component.""" + + hass.data[DOMAIN] = DovadoData( + dovado.Dovado( + config[DOMAIN].get(CONF_USERNAME), + config[DOMAIN].get(CONF_PASSWORD), + config[DOMAIN].get(CONF_HOST), + config[DOMAIN].get(CONF_PORT), + ) + ) + return True + + +class DovadoData: + """Maintain a connection to the router.""" + + def __init__(self, client): + """Set up a new Dovado connection.""" + self._client = client + self.state = {} + + @property + def name(self): + """Name of the router.""" + return self.state.get("product name", DEVICE_DEFAULT_NAME) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Update device state.""" + try: + self.state = self._client.state or {} + if not self.state: + return False + self.state.update(connected=self.state.get("modem status") == "CONNECTED") + _LOGGER.debug("Received: %s", self.state) + return True + except OSError as error: + _LOGGER.warning("Could not contact the router: %s", error) + + @property + def client(self): + """Dovado client instance.""" + return self._client diff --git a/homeassistant/components/dovado/manifest.json b/homeassistant/components/dovado/manifest.json new file mode 100644 index 0000000000000..cc18e48d3b516 --- /dev/null +++ b/homeassistant/components/dovado/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "dovado", + "name": "Dovado", + "documentation": "https://www.home-assistant.io/integrations/dovado", + "requirements": ["dovado==0.4.1"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/dovado/notify.py b/homeassistant/components/dovado/notify.py new file mode 100644 index 0000000000000..02ce994b1df95 --- /dev/null +++ b/homeassistant/components/dovado/notify.py @@ -0,0 +1,31 @@ +"""Support for SMS notifications from the Dovado router.""" +import logging + +from homeassistant.components.notify import ATTR_TARGET, BaseNotificationService + +from . import DOMAIN as DOVADO_DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +def get_service(hass, config, discovery_info=None): + """Get the Dovado Router SMS notification service.""" + return DovadoSMSNotificationService(hass.data[DOVADO_DOMAIN].client) + + +class DovadoSMSNotificationService(BaseNotificationService): + """Implement the notification service for the Dovado SMS component.""" + + def __init__(self, client): + """Initialize the service.""" + self._client = client + + def send_message(self, message, **kwargs): + """Send SMS to the specified target phone number.""" + target = kwargs.get(ATTR_TARGET) + + if not target: + _LOGGER.error("One target is required") + return + + self._client.send_sms(target, message) diff --git a/homeassistant/components/dovado/sensor.py b/homeassistant/components/dovado/sensor.py new file mode 100644 index 0000000000000..d3374c8d02ab6 --- /dev/null +++ b/homeassistant/components/dovado/sensor.py @@ -0,0 +1,103 @@ +"""Support for sensors from the Dovado router.""" +from datetime import timedelta +import logging +import re + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_SENSORS +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +from . import DOMAIN as DOVADO_DOMAIN + +_LOGGER = logging.getLogger(__name__) + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) + +SENSOR_UPLOAD = "upload" +SENSOR_DOWNLOAD = "download" +SENSOR_SIGNAL = "signal" +SENSOR_NETWORK = "network" +SENSOR_SMS_UNREAD = "sms" + +SENSORS = { + SENSOR_NETWORK: ("signal strength", "Network", None, "mdi:access-point-network"), + SENSOR_SIGNAL: ("signal strength", "Signal Strength", "%", "mdi:signal"), + SENSOR_SMS_UNREAD: ("sms unread", "SMS unread", "", "mdi:message-text-outline"), + SENSOR_UPLOAD: ("traffic modem tx", "Sent", "GB", "mdi:cloud-upload"), + SENSOR_DOWNLOAD: ("traffic modem rx", "Received", "GB", "mdi:cloud-download"), +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + {vol.Required(CONF_SENSORS): vol.All(cv.ensure_list, [vol.In(SENSORS)])} +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Dovado sensor platform.""" + dovado = hass.data[DOVADO_DOMAIN] + + entities = [] + for sensor in config[CONF_SENSORS]: + entities.append(DovadoSensor(dovado, sensor)) + + add_entities(entities) + + +class DovadoSensor(Entity): + """Representation of a Dovado sensor.""" + + def __init__(self, data, sensor): + """Initialize the sensor.""" + self._data = data + self._sensor = sensor + self._state = self._compute_state() + + def _compute_state(self): + """Compute the state of the sensor.""" + state = self._data.state.get(SENSORS[self._sensor][0]) + if self._sensor == SENSOR_NETWORK: + match = re.search(r"\((.+)\)", state) + return match.group(1) if match else None + if self._sensor == SENSOR_SIGNAL: + try: + return int(state.split()[0]) + except ValueError: + return None + if self._sensor == SENSOR_SMS_UNREAD: + return int(state) + if self._sensor in [SENSOR_UPLOAD, SENSOR_DOWNLOAD]: + return round(float(state) / 1e6, 1) + return state + + def update(self): + """Update sensor values.""" + self._data.update() + self._state = self._compute_state() + + @property + def name(self): + """Return the name of the sensor.""" + return "{} {}".format(self._data.name, SENSORS[self._sensor][1]) + + @property + def state(self): + """Return the sensor state.""" + return self._state + + @property + def icon(self): + """Return the icon for the sensor.""" + return SENSORS[self._sensor][3] + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return SENSORS[self._sensor][2] + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return {k: v for k, v in self._data.state.items() if k not in ["date", "time"]} diff --git a/homeassistant/components/downloader.py b/homeassistant/components/downloader.py deleted file mode 100644 index 6978dbd7fa9ce..0000000000000 --- a/homeassistant/components/downloader.py +++ /dev/null @@ -1,138 +0,0 @@ -""" -homeassistant.components.downloader -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Provides functionality to download files. -""" -import os -import logging -import re -import threading - -from homeassistant.helpers import validate_config -from homeassistant.util import sanitize_filename - -DOMAIN = "downloader" -DEPENDENCIES = [] - -SERVICE_DOWNLOAD_FILE = "download_file" - -ATTR_URL = "url" -ATTR_SUBDIR = "subdir" - -CONF_DOWNLOAD_DIR = 'download_dir' - - -# pylint: disable=too-many-branches -def setup(hass, config): - """ Listens for download events to download files. """ - - logger = logging.getLogger(__name__) - - try: - import requests - except ImportError: - logger.exception(("Failed to import requests. " - "Did you maybe not execute 'pip install requests'?")) - - return False - - if not validate_config(config, {DOMAIN: [CONF_DOWNLOAD_DIR]}, logger): - return False - - download_path = config[DOMAIN][CONF_DOWNLOAD_DIR] - - if not os.path.isdir(download_path): - - logger.error( - "Download path %s does not exist. File Downloader not active.", - download_path) - - return False - - def download_file(service): - """ Starts thread to download file specified in the url. """ - - if ATTR_URL not in service.data: - logger.error("Service called but 'url' parameter not specified.") - return - - def do_download(): - """ Downloads the file. """ - try: - url = service.data[ATTR_URL] - - subdir = service.data.get(ATTR_SUBDIR) - - if subdir: - subdir = sanitize_filename(subdir) - - final_path = None - - req = requests.get(url, stream=True, timeout=10) - - if req.status_code == 200: - filename = None - - if 'content-disposition' in req.headers: - match = re.findall(r"filename=(\S+)", - req.headers['content-disposition']) - - if len(match) > 0: - filename = match[0].strip("'\" ") - - if not filename: - filename = os.path.basename( - url).strip() - - if not filename: - filename = "ha_download" - - # Remove stuff to ruin paths - filename = sanitize_filename(filename) - - # Do we want to download to subdir, create if needed - if subdir: - subdir_path = os.path.join(download_path, subdir) - - # Ensure subdir exist - if not os.path.isdir(subdir_path): - os.makedirs(subdir_path) - - final_path = os.path.join(subdir_path, filename) - - else: - final_path = os.path.join(download_path, filename) - - path, ext = os.path.splitext(final_path) - - # If file exist append a number. - # We test filename, filename_2.. - tries = 1 - final_path = path + ext - while os.path.isfile(final_path): - tries += 1 - - final_path = "{}_{}.{}".format(path, tries, ext) - - logger.info("%s -> %s", url, final_path) - - with open(final_path, 'wb') as fil: - for chunk in req.iter_content(1024): - fil.write(chunk) - - logger.info("Downloading of %s done", url) - - except requests.exceptions.ConnectionError: - logger.exception("ConnectionError occured for %s", url) - - # Remove file if we started downloading but failed - if final_path and os.path.isfile(final_path): - os.remove(final_path) - - threading.Thread(target=do_download).start() - - hass.services.register(DOMAIN, SERVICE_DOWNLOAD_FILE, - download_file) - - return True diff --git a/homeassistant/components/downloader/__init__.py b/homeassistant/components/downloader/__init__.py new file mode 100644 index 0000000000000..9054943ca525d --- /dev/null +++ b/homeassistant/components/downloader/__init__.py @@ -0,0 +1,163 @@ +"""Support for functionality to download files.""" +import logging +import os +import re +import threading + +import requests +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.util import sanitize_filename + +_LOGGER = logging.getLogger(__name__) + +ATTR_FILENAME = "filename" +ATTR_SUBDIR = "subdir" +ATTR_URL = "url" +ATTR_OVERWRITE = "overwrite" + +CONF_DOWNLOAD_DIR = "download_dir" + +DOMAIN = "downloader" +DOWNLOAD_FAILED_EVENT = "download_failed" +DOWNLOAD_COMPLETED_EVENT = "download_completed" + +SERVICE_DOWNLOAD_FILE = "download_file" + +SERVICE_DOWNLOAD_FILE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_URL): cv.url, + vol.Optional(ATTR_SUBDIR): cv.string, + vol.Optional(ATTR_FILENAME): cv.string, + vol.Optional(ATTR_OVERWRITE, default=False): cv.boolean, + } +) + +CONFIG_SCHEMA = vol.Schema( + {DOMAIN: vol.Schema({vol.Required(CONF_DOWNLOAD_DIR): cv.string})}, + extra=vol.ALLOW_EXTRA, +) + + +def setup(hass, config): + """Listen for download events to download files.""" + download_path = config[DOMAIN][CONF_DOWNLOAD_DIR] + + # If path is relative, we assume relative to Home Assistant config dir + if not os.path.isabs(download_path): + download_path = hass.config.path(download_path) + + if not os.path.isdir(download_path): + _LOGGER.error( + "Download path %s does not exist. File Downloader not active", download_path + ) + + return False + + def download_file(service): + """Start thread to download file specified in the URL.""" + + def do_download(): + """Download the file.""" + try: + url = service.data[ATTR_URL] + + subdir = service.data.get(ATTR_SUBDIR) + + filename = service.data.get(ATTR_FILENAME) + + overwrite = service.data.get(ATTR_OVERWRITE) + + if subdir: + subdir = sanitize_filename(subdir) + + final_path = None + + req = requests.get(url, stream=True, timeout=10) + + if req.status_code != 200: + _LOGGER.warning( + "downloading '%s' failed, status_code=%d", url, req.status_code + ) + hass.bus.fire( + f"{DOMAIN}_{DOWNLOAD_FAILED_EVENT}", + {"url": url, "filename": filename}, + ) + + else: + if filename is None and "content-disposition" in req.headers: + match = re.findall( + r"filename=(\S+)", req.headers["content-disposition"] + ) + + if match: + filename = match[0].strip("'\" ") + + if not filename: + filename = os.path.basename(url).strip() + + if not filename: + filename = "ha_download" + + # Remove stuff to ruin paths + filename = sanitize_filename(filename) + + # Do we want to download to subdir, create if needed + if subdir: + subdir_path = os.path.join(download_path, subdir) + + # Ensure subdir exist + if not os.path.isdir(subdir_path): + os.makedirs(subdir_path) + + final_path = os.path.join(subdir_path, filename) + + else: + final_path = os.path.join(download_path, filename) + + path, ext = os.path.splitext(final_path) + + # If file exist append a number. + # We test filename, filename_2.. + if not overwrite: + tries = 1 + final_path = path + ext + while os.path.isfile(final_path): + tries += 1 + + final_path = f"{path}_{tries}.{ext}" + + _LOGGER.debug("%s -> %s", url, final_path) + + with open(final_path, "wb") as fil: + for chunk in req.iter_content(1024): + fil.write(chunk) + + _LOGGER.debug("Downloading of %s done", url) + hass.bus.fire( + f"{DOMAIN}_{DOWNLOAD_COMPLETED_EVENT}", + {"url": url, "filename": filename}, + ) + + except requests.exceptions.ConnectionError: + _LOGGER.exception("ConnectionError occurred for %s", url) + hass.bus.fire( + f"{DOMAIN}_{DOWNLOAD_FAILED_EVENT}", + {"url": url, "filename": filename}, + ) + + # Remove file if we started downloading but failed + if final_path and os.path.isfile(final_path): + os.remove(final_path) + + threading.Thread(target=do_download).start() + + hass.services.register( + DOMAIN, + SERVICE_DOWNLOAD_FILE, + download_file, + schema=SERVICE_DOWNLOAD_FILE_SCHEMA, + ) + + return True diff --git a/homeassistant/components/downloader/manifest.json b/homeassistant/components/downloader/manifest.json new file mode 100644 index 0000000000000..fde980fa5ca16 --- /dev/null +++ b/homeassistant/components/downloader/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "downloader", + "name": "Downloader", + "documentation": "https://www.home-assistant.io/integrations/downloader", + "requirements": [], + "dependencies": [], + "codeowners": [], + "quality_scale": "internal" +} diff --git a/homeassistant/components/downloader/services.yaml b/homeassistant/components/downloader/services.yaml new file mode 100644 index 0000000000000..d16b2788c70ae --- /dev/null +++ b/homeassistant/components/downloader/services.yaml @@ -0,0 +1,15 @@ +download_file: + description: Downloads a file to the download location. + fields: + url: + description: The URL of the file to download. + example: 'http://example.org/myfile' + subdir: + description: Download into subdirectory. + example: 'download_dir' + filename: + description: Determine the filename. + example: 'my_file_name' + overwrite: + description: Whether to overwrite the file or not. + example: 'false' \ No newline at end of file diff --git a/homeassistant/components/dsmr/__init__.py b/homeassistant/components/dsmr/__init__.py new file mode 100644 index 0000000000000..107e221a4658b --- /dev/null +++ b/homeassistant/components/dsmr/__init__.py @@ -0,0 +1 @@ +"""The dsmr component.""" diff --git a/homeassistant/components/dsmr/manifest.json b/homeassistant/components/dsmr/manifest.json new file mode 100644 index 0000000000000..8f607dc299eab --- /dev/null +++ b/homeassistant/components/dsmr/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "dsmr", + "name": "DSMR Slimme Meter", + "documentation": "https://www.home-assistant.io/integrations/dsmr", + "requirements": ["dsmr_parser==0.12"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py new file mode 100644 index 0000000000000..253e8409f1b3a --- /dev/null +++ b/homeassistant/components/dsmr/sensor.py @@ -0,0 +1,297 @@ +"""Support for Dutch Smart Meter (also known as Smartmeter or P1 port).""" +import asyncio +from datetime import timedelta +from functools import partial +import logging + +from dsmr_parser import obis_references as obis_ref +from dsmr_parser.clients.protocol import create_dsmr_reader, create_tcp_dsmr_reader +import serial +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import CoreState +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +CONF_DSMR_VERSION = "dsmr_version" +CONF_RECONNECT_INTERVAL = "reconnect_interval" +CONF_PRECISION = "precision" + +DEFAULT_DSMR_VERSION = "2.2" +DEFAULT_PORT = "/dev/ttyUSB0" +DEFAULT_PRECISION = 3 + +DOMAIN = "dsmr" + +ICON_GAS = "mdi:fire" +ICON_POWER = "mdi:flash" +ICON_POWER_FAILURE = "mdi:flash-off" +ICON_SWELL_SAG = "mdi:pulse" + +# Smart meter sends telegram every 10 seconds +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10) + +RECONNECT_INTERVAL = 5 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.string, + vol.Optional(CONF_HOST): cv.string, + vol.Optional(CONF_DSMR_VERSION, default=DEFAULT_DSMR_VERSION): vol.All( + cv.string, vol.In(["5", "4", "2.2"]) + ), + vol.Optional(CONF_RECONNECT_INTERVAL, default=30): int, + vol.Optional(CONF_PRECISION, default=DEFAULT_PRECISION): vol.Coerce(int), + } +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the DSMR sensor.""" + # Suppress logging + logging.getLogger("dsmr_parser").setLevel(logging.ERROR) + + dsmr_version = config[CONF_DSMR_VERSION] + + # Define list of name,obis mappings to generate entities + obis_mapping = [ + ["Power Consumption", obis_ref.CURRENT_ELECTRICITY_USAGE], + ["Power Production", obis_ref.CURRENT_ELECTRICITY_DELIVERY], + ["Power Tariff", obis_ref.ELECTRICITY_ACTIVE_TARIFF], + ["Power Consumption (total)", obis_ref.ELECTRICITY_IMPORTED_TOTAL], + ["Power Consumption (low)", obis_ref.ELECTRICITY_USED_TARIFF_1], + ["Power Consumption (normal)", obis_ref.ELECTRICITY_USED_TARIFF_2], + ["Power Production (low)", obis_ref.ELECTRICITY_DELIVERED_TARIFF_1], + ["Power Production (normal)", obis_ref.ELECTRICITY_DELIVERED_TARIFF_2], + ["Power Consumption Phase L1", obis_ref.INSTANTANEOUS_ACTIVE_POWER_L1_POSITIVE], + ["Power Consumption Phase L2", obis_ref.INSTANTANEOUS_ACTIVE_POWER_L2_POSITIVE], + ["Power Consumption Phase L3", obis_ref.INSTANTANEOUS_ACTIVE_POWER_L3_POSITIVE], + ["Power Production Phase L1", obis_ref.INSTANTANEOUS_ACTIVE_POWER_L1_NEGATIVE], + ["Power Production Phase L2", obis_ref.INSTANTANEOUS_ACTIVE_POWER_L2_NEGATIVE], + ["Power Production Phase L3", obis_ref.INSTANTANEOUS_ACTIVE_POWER_L3_NEGATIVE], + ["Long Power Failure Count", obis_ref.LONG_POWER_FAILURE_COUNT], + ["Voltage Sags Phase L1", obis_ref.VOLTAGE_SAG_L1_COUNT], + ["Voltage Sags Phase L2", obis_ref.VOLTAGE_SAG_L2_COUNT], + ["Voltage Sags Phase L3", obis_ref.VOLTAGE_SAG_L3_COUNT], + ["Voltage Swells Phase L1", obis_ref.VOLTAGE_SWELL_L1_COUNT], + ["Voltage Swells Phase L2", obis_ref.VOLTAGE_SWELL_L2_COUNT], + ["Voltage Swells Phase L3", obis_ref.VOLTAGE_SWELL_L3_COUNT], + ["Voltage Phase L1", obis_ref.INSTANTANEOUS_VOLTAGE_L1], + ["Voltage Phase L2", obis_ref.INSTANTANEOUS_VOLTAGE_L2], + ["Voltage Phase L3", obis_ref.INSTANTANEOUS_VOLTAGE_L3], + ] + + # Generate device entities + devices = [DSMREntity(name, obis, config) for name, obis in obis_mapping] + + # Protocol version specific obis + if dsmr_version in ("4", "5"): + gas_obis = obis_ref.HOURLY_GAS_METER_READING + else: + gas_obis = obis_ref.GAS_METER_READING + + # Add gas meter reading and derivative for usage + devices += [ + DSMREntity("Gas Consumption", gas_obis, config), + DerivativeDSMREntity("Hourly Gas Consumption", gas_obis, config), + ] + + async_add_entities(devices) + + def update_entities_telegram(telegram): + """Update entities with latest telegram and trigger state update.""" + # Make all device entities aware of new telegram + for device in devices: + device.telegram = telegram + hass.async_create_task(device.async_update_ha_state()) + + # Creates an asyncio.Protocol factory for reading DSMR telegrams from + # serial and calls update_entities_telegram to update entities on arrival + if CONF_HOST in config: + reader_factory = partial( + create_tcp_dsmr_reader, + config[CONF_HOST], + config[CONF_PORT], + config[CONF_DSMR_VERSION], + update_entities_telegram, + loop=hass.loop, + ) + else: + reader_factory = partial( + create_dsmr_reader, + config[CONF_PORT], + config[CONF_DSMR_VERSION], + update_entities_telegram, + loop=hass.loop, + ) + + async def connect_and_reconnect(): + """Connect to DSMR and keep reconnecting until Home Assistant stops.""" + while hass.state != CoreState.stopping: + # Start DSMR asyncio.Protocol reader + try: + transport, protocol = await hass.loop.create_task(reader_factory()) + except ( + serial.serialutil.SerialException, + ConnectionRefusedError, + TimeoutError, + ): + # Log any error while establishing connection and drop to retry + # connection wait + _LOGGER.exception("Error connecting to DSMR") + transport = None + + if transport: + # Register listener to close transport on HA shutdown + stop_listener = hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, transport.close + ) + + # Wait for reader to close + await protocol.wait_closed() + + if hass.state != CoreState.stopping: + # Unexpected disconnect + if transport: + # remove listener + stop_listener() + + # Reflect disconnect state in devices state by setting an + # empty telegram resulting in `unknown` states + update_entities_telegram({}) + + # throttle reconnect attempts + await asyncio.sleep(config[CONF_RECONNECT_INTERVAL]) + + # Can't be hass.async_add_job because job runs forever + hass.loop.create_task(connect_and_reconnect()) + + +class DSMREntity(Entity): + """Entity reading values from DSMR telegram.""" + + def __init__(self, name, obis, config): + """Initialize entity.""" + self._name = name + self._obis = obis + self._config = config + self.telegram = {} + + def get_dsmr_object_attr(self, attribute): + """Read attribute from last received telegram for this DSMR object.""" + # Make sure telegram contains an object for this entities obis + if self._obis not in self.telegram: + return None + + # Get the attribute value if the object has it + dsmr_object = self.telegram[self._obis] + return getattr(dsmr_object, attribute, None) + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + if "Sags" in self._name or "Swells" in self.name: + return ICON_SWELL_SAG + if "Failure" in self._name: + return ICON_POWER_FAILURE + if "Power" in self._name: + return ICON_POWER + if "Gas" in self._name: + return ICON_GAS + + @property + def state(self): + """Return the state of sensor, if available, translate if needed.""" + value = self.get_dsmr_object_attr("value") + + if self._obis == obis_ref.ELECTRICITY_ACTIVE_TARIFF: + return self.translate_tariff(value) + + try: + value = round(float(value), self._config[CONF_PRECISION]) + except TypeError: + pass + + if value is not None: + return value + + return None + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self.get_dsmr_object_attr("unit") + + @staticmethod + def translate_tariff(value): + """Convert 2/1 to normal/low.""" + # DSMR V2.2: Note: Rate code 1 is used for low rate and rate code 2 is + # used for normal rate. + if value == "0002": + return "normal" + if value == "0001": + return "low" + + return None + + +class DerivativeDSMREntity(DSMREntity): + """Calculated derivative for values where the DSMR doesn't offer one. + + Gas readings are only reported per hour and don't offer a rate only + the current meter reading. This entity converts subsequents readings + into a hourly rate. + """ + + _previous_reading = None + _previous_timestamp = None + _state = None + + @property + def state(self): + """Return the calculated current hourly rate.""" + return self._state + + async def async_update(self): + """Recalculate hourly rate if timestamp has changed. + + DSMR updates gas meter reading every hour. Along with the new + value a timestamp is provided for the reading. Test if the last + known timestamp differs from the current one then calculate a + new rate for the previous hour. + + """ + # check if the timestamp for the object differs from the previous one + timestamp = self.get_dsmr_object_attr("datetime") + if timestamp and timestamp != self._previous_timestamp: + current_reading = self.get_dsmr_object_attr("value") + + if self._previous_reading is None: + # Can't calculate rate without previous datapoint + # just store current point + pass + else: + # Recalculate the rate + diff = current_reading - self._previous_reading + timediff = timestamp - self._previous_timestamp + total_seconds = timediff.total_seconds() + self._state = round(float(diff) / total_seconds * 3600, 3) + + self._previous_reading = current_reading + self._previous_timestamp = timestamp + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, per hour, if any.""" + unit = self.get_dsmr_object_attr("unit") + if unit: + return unit + "/h" diff --git a/homeassistant/components/dsmr_reader/__init__.py b/homeassistant/components/dsmr_reader/__init__.py new file mode 100644 index 0000000000000..946be91d1a561 --- /dev/null +++ b/homeassistant/components/dsmr_reader/__init__.py @@ -0,0 +1 @@ +"""The DSMR Reader component.""" diff --git a/homeassistant/components/dsmr_reader/definitions.py b/homeassistant/components/dsmr_reader/definitions.py new file mode 100644 index 0000000000000..45bebfeda92c2 --- /dev/null +++ b/homeassistant/components/dsmr_reader/definitions.py @@ -0,0 +1,245 @@ +"""Definitions for DSMR Reader sensors added to MQTT.""" + + +def dsmr_transform(value): + """Transform DSMR version value to right format.""" + if value.isdigit(): + return float(value) / 10 + return value + + +def tariff_transform(value): + """Transform tariff from number to description.""" + if value == "1": + return "low" + return "high" + + +DEFINITIONS = { + "dsmr/reading/electricity_delivered_1": { + "name": "Low tariff usage", + "icon": "mdi:flash", + "unit": "kWh", + }, + "dsmr/reading/electricity_returned_1": { + "name": "Low tariff returned", + "icon": "mdi:flash-outline", + "unit": "kWh", + }, + "dsmr/reading/electricity_delivered_2": { + "name": "High tariff usage", + "icon": "mdi:flash", + "unit": "kWh", + }, + "dsmr/reading/electricity_returned_2": { + "name": "High tariff returned", + "icon": "mdi:flash-outline", + "unit": "kWh", + }, + "dsmr/reading/electricity_currently_delivered": { + "name": "Current power usage", + "icon": "mdi:flash", + "unit": "kW", + }, + "dsmr/reading/electricity_currently_returned": { + "name": "Current power return", + "icon": "mdi:flash-outline", + "unit": "kW", + }, + "dsmr/reading/phase_currently_delivered_l1": { + "name": "Current power usage L1", + "icon": "mdi:flash", + "unit": "kW", + }, + "dsmr/reading/phase_currently_delivered_l2": { + "name": "Current power usage L2", + "icon": "mdi:flash", + "unit": "kW", + }, + "dsmr/reading/phase_currently_delivered_l3": { + "name": "Current power usage L3", + "icon": "mdi:flash", + "unit": "kW", + }, + "dsmr/reading/phase_currently_returned_l1": { + "name": "Current power return L1", + "icon": "mdi:flash-outline", + "unit": "kW", + }, + "dsmr/reading/phase_currently_returned_l2": { + "name": "Current power return L2", + "icon": "mdi:flash-outline", + "unit": "kW", + }, + "dsmr/reading/phase_currently_returned_l3": { + "name": "Current power return L3", + "icon": "mdi:flash-outline", + "unit": "kW", + }, + "dsmr/reading/extra_device_delivered": { + "name": "Gas meter usage", + "icon": "mdi:fire", + "unit": "m3", + }, + "dsmr/reading/phase_voltage_l1": { + "name": "Current voltage L1", + "icon": "mdi:flash", + "unit": "V", + }, + "dsmr/reading/phase_voltage_l2": { + "name": "Current voltage L2", + "icon": "mdi:flash", + "unit": "V", + }, + "dsmr/reading/phase_voltage_l3": { + "name": "Current voltage L3", + "icon": "mdi:flash", + "unit": "V", + }, + "dsmr/consumption/gas/delivered": { + "name": "Gas usage", + "icon": "mdi:fire", + "unit": "m3", + }, + "dsmr/consumption/gas/currently_delivered": { + "name": "Current gas usage", + "icon": "mdi:fire", + "unit": "m3", + }, + "dsmr/consumption/gas/read_at": { + "name": "Gas meter read", + "icon": "mdi:clock", + "unit": "", + }, + "dsmr/day-consumption/electricity1": { + "name": "Low tariff usage", + "icon": "mdi:counter", + "unit": "kWh", + }, + "dsmr/day-consumption/electricity2": { + "name": "High tariff usage", + "icon": "mdi:counter", + "unit": "kWh", + }, + "dsmr/day-consumption/electricity1_returned": { + "name": "Low tariff return", + "icon": "mdi:counter", + "unit": "kWh", + }, + "dsmr/day-consumption/electricity2_returned": { + "name": "High tariff return", + "icon": "mdi:counter", + "unit": "kWh", + }, + "dsmr/day-consumption/electricity_merged": { + "name": "Power usage total", + "icon": "mdi:counter", + "unit": "kWh", + }, + "dsmr/day-consumption/electricity_returned_merged": { + "name": "Power return total", + "icon": "mdi:counter", + "unit": "kWh", + }, + "dsmr/day-consumption/electricity1_cost": { + "name": "Low tariff cost", + "icon": "mdi:currency-eur", + "unit": "€", + }, + "dsmr/day-consumption/electricity2_cost": { + "name": "High tariff cost", + "icon": "mdi:currency-eur", + "unit": "€", + }, + "dsmr/day-consumption/electricity_cost_merged": { + "name": "Power total cost", + "icon": "mdi:currency-eur", + "unit": "€", + }, + "dsmr/day-consumption/gas": { + "name": "Gas usage", + "icon": "mdi:counter", + "unit": "m3", + }, + "dsmr/day-consumption/gas_cost": { + "name": "Gas cost", + "icon": "mdi:currency-eur", + "unit": "€", + }, + "dsmr/day-consumption/total_cost": { + "name": "Total cost", + "icon": "mdi:currency-eur", + "unit": "€", + }, + "dsmr/day-consumption/energy_supplier_price_electricity_delivered_1": { + "name": "Low tariff delivered price", + "icon": "mdi:currency-eur", + "unit": "€", + }, + "dsmr/day-consumption/energy_supplier_price_electricity_delivered_2": { + "name": "High tariff delivered price", + "icon": "mdi:currency-eur", + "unit": "€", + }, + "dsmr/day-consumption/energy_supplier_price_electricity_returned_1": { + "name": "Low tariff returned price", + "icon": "mdi:currency-eur", + "unit": "€", + }, + "dsmr/day-consumption/energy_supplier_price_electricity_returned_2": { + "name": "High tariff returned price", + "icon": "mdi:currency-eur", + "unit": "€", + }, + "dsmr/day-consumption/energy_supplier_price_gas": { + "name": "Gas price", + "icon": "mdi:currency-eur", + "unit": "€", + }, + "dsmr/meter-stats/dsmr_version": { + "name": "DSMR version", + "icon": "mdi:alert-circle", + "transform": dsmr_transform, + }, + "dsmr/meter-stats/electricity_tariff": { + "name": "Electricity tariff", + "icon": "mdi:flash", + "transform": tariff_transform, + }, + "dsmr/meter-stats/power_failure_count": { + "name": "Power failure count", + "icon": "mdi:flash", + }, + "dsmr/meter-stats/long_power_failure_count": { + "name": "Long power failure count", + "icon": "mdi:flash", + }, + "dsmr/meter-stats/voltage_sag_count_l1": { + "name": "Voltage sag L1", + "icon": "mdi:flash", + }, + "dsmr/meter-stats/voltage_sag_count_l2": { + "name": "Voltage sag L2", + "icon": "mdi:flash", + }, + "dsmr/meter-stats/voltage_sag_count_l3": { + "name": "Voltage sag L3", + "icon": "mdi:flash", + }, + "dsmr/meter-stats/voltage_swell_count_l1": { + "name": "Voltage swell L1", + "icon": "mdi:flash", + }, + "dsmr/meter-stats/voltage_swell_count_l2": { + "name": "Voltage swell L2", + "icon": "mdi:flash", + }, + "dsmr/meter-stats/voltage_swell_count_l3": { + "name": "Voltage swell L3", + "icon": "mdi:flash", + }, + "dsmr/meter-stats/rejected_telegrams": { + "name": "Rejected telegrams", + "icon": "mdi:flash", + }, +} diff --git a/homeassistant/components/dsmr_reader/manifest.json b/homeassistant/components/dsmr_reader/manifest.json new file mode 100644 index 0000000000000..0ec70b027bac9 --- /dev/null +++ b/homeassistant/components/dsmr_reader/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "dsmr_reader", + "name": "DSMR Reader", + "documentation": "https://www.home-assistant.io/integrations/dsmr_reader", + "requirements": [], + "dependencies": ["mqtt"], + "codeowners": ["@depl0y"] +} diff --git a/homeassistant/components/dsmr_reader/sensor.py b/homeassistant/components/dsmr_reader/sensor.py new file mode 100644 index 0000000000000..01c010c4971ad --- /dev/null +++ b/homeassistant/components/dsmr_reader/sensor.py @@ -0,0 +1,78 @@ +"""Support for DSMR Reader through MQTT.""" +from homeassistant.components import mqtt +from homeassistant.core import callback +from homeassistant.helpers.entity import Entity +from homeassistant.util import slugify + +from .definitions import DEFINITIONS + +DOMAIN = "dsmr_reader" + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up DSMR Reader sensors.""" + + sensors = [] + for topic in DEFINITIONS: + sensors.append(DSMRSensor(topic)) + + async_add_entities(sensors) + + +class DSMRSensor(Entity): + """Representation of a DSMR sensor that is updated via MQTT.""" + + def __init__(self, topic): + """Initialize the sensor.""" + + self._definition = DEFINITIONS[topic] + + self._entity_id = slugify(topic.replace("/", "_")) + self._topic = topic + + self._name = self._definition.get("name", topic.split("/")[-1]) + self._unit_of_measurement = self._definition.get("unit") + self._icon = self._definition.get("icon") + self._transform = self._definition.get("transform") + self._state = None + + async def async_added_to_hass(self): + """Subscribe to MQTT events.""" + + @callback + def message_received(message): + """Handle new MQTT messages.""" + + if self._transform is not None: + self._state = self._transform(message.payload) + else: + self._state = message.payload + + self.async_schedule_update_ha_state() + + await mqtt.async_subscribe(self.hass, self._topic, message_received, 1) + + @property + def name(self): + """Return the name of the sensor supplied in constructor.""" + return self._name + + @property + def entity_id(self): + """Return the entity ID for this sensor.""" + return f"sensor.{self._entity_id}" + + @property + def state(self): + """Return the current state of the entity.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit_of_measurement of this sensor.""" + return self._unit_of_measurement + + @property + def icon(self): + """Return the icon of this sensor.""" + return self._icon diff --git a/homeassistant/components/dte_energy_bridge/__init__.py b/homeassistant/components/dte_energy_bridge/__init__.py new file mode 100644 index 0000000000000..2525d047bcee6 --- /dev/null +++ b/homeassistant/components/dte_energy_bridge/__init__.py @@ -0,0 +1 @@ +"""The dte_energy_bridge component.""" diff --git a/homeassistant/components/dte_energy_bridge/manifest.json b/homeassistant/components/dte_energy_bridge/manifest.json new file mode 100644 index 0000000000000..c056c7cbeb674 --- /dev/null +++ b/homeassistant/components/dte_energy_bridge/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "dte_energy_bridge", + "name": "DTE Energy Bridge", + "documentation": "https://www.home-assistant.io/integrations/dte_energy_bridge", + "requirements": [], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/dte_energy_bridge/sensor.py b/homeassistant/components/dte_energy_bridge/sensor.py new file mode 100644 index 0000000000000..aa822da0d6a4b --- /dev/null +++ b/homeassistant/components/dte_energy_bridge/sensor.py @@ -0,0 +1,119 @@ +"""Support for monitoring energy usage using the DTE energy bridge.""" +import logging + +import requests +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__) + +CONF_IP_ADDRESS = "ip" +CONF_VERSION = "version" + +DEFAULT_NAME = "Current Energy Usage" +DEFAULT_VERSION = 1 + +ICON = "mdi:flash" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_IP_ADDRESS): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_VERSION, default=DEFAULT_VERSION): vol.All( + vol.Coerce(int), vol.Any(1, 2) + ), + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the DTE energy bridge sensor.""" + name = config.get(CONF_NAME) + ip_address = config.get(CONF_IP_ADDRESS) + version = config.get(CONF_VERSION, 1) + + add_entities([DteEnergyBridgeSensor(ip_address, name, version)], True) + + +class DteEnergyBridgeSensor(Entity): + """Implementation of the DTE Energy Bridge sensors.""" + + def __init__(self, ip_address, name, version): + """Initialize the sensor.""" + self._version = version + + if self._version == 1: + url_template = "http://{}/instantaneousdemand" + elif self._version == 2: + url_template = "http://{}:8888/zigbee/se/instantaneousdemand" + + self._url = url_template.format(ip_address) + + self._name = name + self._unit_of_measurement = "kW" + self._state = None + + @property + def name(self): + """Return the name of th 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 icon(self): + """Icon to use in the frontend, if any.""" + return ICON + + def update(self): + """Get the energy usage data from the DTE energy bridge.""" + try: + response = requests.get(self._url, timeout=5) + except (requests.exceptions.RequestException, ValueError): + _LOGGER.warning( + "Could not update status for DTE Energy Bridge (%s)", self._name + ) + return + + if response.status_code != 200: + _LOGGER.warning( + "Invalid status_code from DTE Energy Bridge: %s (%s)", + response.status_code, + self._name, + ) + return + + response_split = response.text.split() + + if len(response_split) != 2: + _LOGGER.warning( + 'Invalid response from DTE Energy Bridge: "%s" (%s)', + response.text, + self._name, + ) + return + + val = float(response_split[0]) + + # A workaround for a bug in the DTE energy bridge. + # The returned value can randomly be in W or kW. Checking for a + # a decimal seems to be a reliable way to determine the units. + # Limiting to version 1 because version 2 apparently always returns + # values in the format 000000.000 kW, but the scaling is Watts + # NOT kWatts + if self._version == 1 and "." in response_split[0]: + self._state = val + else: + self._state = val / 1000 diff --git a/homeassistant/components/dublin_bus_transport/__init__.py b/homeassistant/components/dublin_bus_transport/__init__.py new file mode 100644 index 0000000000000..138950af2b565 --- /dev/null +++ b/homeassistant/components/dublin_bus_transport/__init__.py @@ -0,0 +1 @@ +"""The dublin_bus_transport component.""" diff --git a/homeassistant/components/dublin_bus_transport/manifest.json b/homeassistant/components/dublin_bus_transport/manifest.json new file mode 100644 index 0000000000000..f4412b6933e5e --- /dev/null +++ b/homeassistant/components/dublin_bus_transport/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "dublin_bus_transport", + "name": "Dublin Bus", + "documentation": "https://www.home-assistant.io/integrations/dublin_bus_transport", + "requirements": [], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/dublin_bus_transport/sensor.py b/homeassistant/components/dublin_bus_transport/sensor.py new file mode 100644 index 0000000000000..a5fe8fd6b30a3 --- /dev/null +++ b/homeassistant/components/dublin_bus_transport/sensor.py @@ -0,0 +1,183 @@ +""" +Support for Dublin RTPI information from data.dublinked.ie. + +For more info on the API see : +https://data.gov.ie/dataset/real-time-passenger-information-rtpi-for-dublin-bus-bus-eireann-luas-and-irish-rail/resource/4b9f2c4f-6bf5-4958-a43a-f12dab04cf61 + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.dublin_public_transport/ +""" +from datetime import datetime, timedelta +import logging + +import requests +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 +import homeassistant.util.dt as dt_util + +_LOGGER = logging.getLogger(__name__) +_RESOURCE = "https://data.dublinked.ie/cgi-bin/rtpi/realtimebusinformation" + +ATTR_STOP_ID = "Stop ID" +ATTR_ROUTE = "Route" +ATTR_DUE_IN = "Due in" +ATTR_DUE_AT = "Due at" +ATTR_NEXT_UP = "Later Bus" + +ATTRIBUTION = "Data provided by data.dublinked.ie" + +CONF_STOP_ID = "stopid" +CONF_ROUTE = "route" + +DEFAULT_NAME = "Next Bus" +ICON = "mdi:bus" + +SCAN_INTERVAL = timedelta(minutes=1) +TIME_STR_FORMAT = "%H:%M" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_STOP_ID): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_ROUTE, default=""): cv.string, + } +) + + +def due_in_minutes(timestamp): + """Get the time in minutes from a timestamp. + + The timestamp should be in the format day/month/year hour/minute/second + """ + diff = datetime.strptime(timestamp, "%d/%m/%Y %H:%M:%S") - dt_util.now().replace( + tzinfo=None + ) + + return str(int(diff.total_seconds() / 60)) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Dublin public transport sensor.""" + name = config.get(CONF_NAME) + stop = config.get(CONF_STOP_ID) + route = config.get(CONF_ROUTE) + + data = PublicTransportData(stop, route) + add_entities([DublinPublicTransportSensor(data, stop, route, name)], True) + + +class DublinPublicTransportSensor(Entity): + """Implementation of an Dublin public transport sensor.""" + + def __init__(self, data, stop, route, name): + """Initialize the sensor.""" + self.data = data + self._name = name + self._stop = stop + self._route = route + self._times = 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.""" + if self._times is not None: + next_up = "None" + if len(self._times) > 1: + next_up = self._times[1][ATTR_ROUTE] + " in " + next_up += self._times[1][ATTR_DUE_IN] + + return { + ATTR_DUE_IN: self._times[0][ATTR_DUE_IN], + ATTR_DUE_AT: self._times[0][ATTR_DUE_AT], + ATTR_STOP_ID: self._stop, + ATTR_ROUTE: self._times[0][ATTR_ROUTE], + ATTR_ATTRIBUTION: ATTRIBUTION, + ATTR_NEXT_UP: next_up, + } + + @property + def unit_of_measurement(self): + """Return the unit this state is expressed in.""" + return "min" + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return ICON + + def update(self): + """Get the latest data from opendata.ch and update the states.""" + self.data.update() + self._times = self.data.info + try: + self._state = self._times[0][ATTR_DUE_IN] + except TypeError: + pass + + +class PublicTransportData: + """The Class for handling the data retrieval.""" + + def __init__(self, stop, route): + """Initialize the data object.""" + self.stop = stop + self.route = route + self.info = [{ATTR_DUE_AT: "n/a", ATTR_ROUTE: self.route, ATTR_DUE_IN: "n/a"}] + + def update(self): + """Get the latest data from opendata.ch.""" + params = {} + params["stopid"] = self.stop + + if self.route: + params["routeid"] = self.route + + params["maxresults"] = 2 + params["format"] = "json" + + response = requests.get(_RESOURCE, params, timeout=10) + + if response.status_code != 200: + self.info = [ + {ATTR_DUE_AT: "n/a", ATTR_ROUTE: self.route, ATTR_DUE_IN: "n/a"} + ] + return + + result = response.json() + + if str(result["errorcode"]) != "0": + self.info = [ + {ATTR_DUE_AT: "n/a", ATTR_ROUTE: self.route, ATTR_DUE_IN: "n/a"} + ] + return + + self.info = [] + for item in result["results"]: + due_at = item.get("departuredatetime") + route = item.get("route") + if due_at is not None and route is not None: + bus_data = { + ATTR_DUE_AT: due_at, + ATTR_ROUTE: route, + ATTR_DUE_IN: due_in_minutes(due_at), + } + self.info.append(bus_data) + + if not self.info: + self.info = [ + {ATTR_DUE_AT: "n/a", ATTR_ROUTE: self.route, ATTR_DUE_IN: "n/a"} + ] diff --git a/homeassistant/components/duckdns/__init__.py b/homeassistant/components/duckdns/__init__.py new file mode 100644 index 0000000000000..b3da1ec275253 --- /dev/null +++ b/homeassistant/components/duckdns/__init__.py @@ -0,0 +1,132 @@ +"""Integrate with DuckDNS.""" +from asyncio import iscoroutinefunction +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN +from homeassistant.core import CALLBACK_TYPE, callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.loader import bind_hass +from homeassistant.util import dt as dt_util + +_LOGGER = logging.getLogger(__name__) + +ATTR_TXT = "txt" + +DOMAIN = "duckdns" + +INTERVAL = timedelta(minutes=5) + +SERVICE_SET_TXT = "set_txt" + +UPDATE_URL = "https://www.duckdns.org/update" + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_DOMAIN): cv.string, + vol.Required(CONF_ACCESS_TOKEN): cv.string, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + +SERVICE_TXT_SCHEMA = vol.Schema({vol.Required(ATTR_TXT): vol.Any(None, cv.string)}) + + +async def async_setup(hass, config): + """Initialize the DuckDNS component.""" + domain = config[DOMAIN][CONF_DOMAIN] + token = config[DOMAIN][CONF_ACCESS_TOKEN] + session = async_get_clientsession(hass) + + async def update_domain_interval(_now): + """Update the DuckDNS entry.""" + return await _update_duckdns(session, domain, token) + + intervals = ( + INTERVAL, + timedelta(minutes=1), + timedelta(minutes=5), + timedelta(minutes=15), + timedelta(minutes=30), + ) + async_track_time_interval_backoff(hass, update_domain_interval, intervals) + + async def update_domain_service(call): + """Update the DuckDNS entry.""" + await _update_duckdns(session, domain, token, txt=call.data[ATTR_TXT]) + + hass.services.async_register( + DOMAIN, SERVICE_SET_TXT, update_domain_service, schema=SERVICE_TXT_SCHEMA + ) + + return True + + +_SENTINEL = object() + + +async def _update_duckdns(session, domain, token, *, txt=_SENTINEL, clear=False): + """Update DuckDNS.""" + params = {"domains": domain, "token": token} + + if txt is not _SENTINEL: + if txt is None: + # Pass in empty txt value to indicate it's clearing txt record + params["txt"] = "" + clear = True + else: + params["txt"] = txt + + if clear: + params["clear"] = "true" + + resp = await session.get(UPDATE_URL, params=params) + body = await resp.text() + + if body != "OK": + _LOGGER.warning("Updating DuckDNS domain failed: %s", domain) + return False + + return True + + +@callback +@bind_hass +def async_track_time_interval_backoff(hass, action, intervals) -> CALLBACK_TYPE: + """Add a listener that fires repetitively at every timedelta interval.""" + if not iscoroutinefunction: + _LOGGER.error("action needs to be a coroutine and return True/False") + return + + if not isinstance(intervals, (list, tuple)): + intervals = (intervals,) + remove = None + failed = 0 + + async def interval_listener(now): + """Handle elapsed intervals with backoff.""" + nonlocal failed, remove + try: + failed += 1 + if await action(now): + failed = 0 + finally: + delay = intervals[failed] if failed < len(intervals) else intervals[-1] + remove = async_track_point_in_utc_time(hass, interval_listener, now + delay) + + hass.async_run_job(interval_listener, dt_util.utcnow()) + + def remove_listener(): + """Remove interval listener.""" + if remove: + remove() # pylint: disable=not-callable + + return remove_listener diff --git a/homeassistant/components/duckdns/manifest.json b/homeassistant/components/duckdns/manifest.json new file mode 100644 index 0000000000000..f6ab4e3a570c4 --- /dev/null +++ b/homeassistant/components/duckdns/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "duckdns", + "name": "Duck DNS", + "documentation": "https://www.home-assistant.io/integrations/duckdns", + "requirements": [], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/duckdns/services.yaml b/homeassistant/components/duckdns/services.yaml new file mode 100644 index 0000000000000..8c353a0b3cd2d --- /dev/null +++ b/homeassistant/components/duckdns/services.yaml @@ -0,0 +1,6 @@ +set_txt: + description: Set the TXT record of your DuckDNS subdomain. + fields: + txt: + description: Payload for the TXT record. + example: 'This domain name is reserved for use in documentation' diff --git a/homeassistant/components/duke_energy/__init__.py b/homeassistant/components/duke_energy/__init__.py new file mode 100644 index 0000000000000..5a1f29add438d --- /dev/null +++ b/homeassistant/components/duke_energy/__init__.py @@ -0,0 +1 @@ +"""The duke_energy component.""" diff --git a/homeassistant/components/duke_energy/manifest.json b/homeassistant/components/duke_energy/manifest.json new file mode 100644 index 0000000000000..cebbf45df1195 --- /dev/null +++ b/homeassistant/components/duke_energy/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "duke_energy", + "name": "Duke Energy", + "documentation": "https://www.home-assistant.io/integrations/duke_energy", + "requirements": ["pydukeenergy==0.0.6"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/duke_energy/sensor.py b/homeassistant/components/duke_energy/sensor.py new file mode 100644 index 0000000000000..cd30ae96caf33 --- /dev/null +++ b/homeassistant/components/duke_energy/sensor.py @@ -0,0 +1,76 @@ +"""Support for Duke Energy Gas and Electric meters.""" +import logging + +from pydukeenergy.api import DukeEnergy, DukeEnergyException +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + {vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string} +) + +LAST_BILL_USAGE = "last_bills_usage" +LAST_BILL_AVERAGE_USAGE = "last_bills_average_usage" +LAST_BILL_DAYS_BILLED = "last_bills_days_billed" + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up all Duke Energy meters.""" + + try: + duke = DukeEnergy( + config[CONF_USERNAME], config[CONF_PASSWORD], update_interval=120 + ) + except DukeEnergyException: + _LOGGER.error("Failed to set up Duke Energy") + return + + add_entities([DukeEnergyMeter(meter) for meter in duke.get_meters()]) + + +class DukeEnergyMeter(Entity): + """Representation of a Duke Energy meter.""" + + def __init__(self, meter): + """Initialize the meter.""" + self.duke_meter = meter + + @property + def name(self): + """Return the name.""" + return f"duke_energy_{self.duke_meter.id}" + + @property + def unique_id(self): + """Return the unique ID.""" + return self.duke_meter.id + + @property + def state(self): + """Return yesterdays usage.""" + return self.duke_meter.get_usage() + + @property + def unit_of_measurement(self): + """Return the unit of measurement this sensor expresses itself in.""" + return self.duke_meter.get_unit() + + @property + def device_state_attributes(self): + """Return the state attributes.""" + attributes = { + LAST_BILL_USAGE: self.duke_meter.get_total(), + LAST_BILL_AVERAGE_USAGE: self.duke_meter.get_average(), + LAST_BILL_DAYS_BILLED: self.duke_meter.get_days_billed(), + } + return attributes + + def update(self): + """Update meter.""" + self.duke_meter.update() diff --git a/homeassistant/components/dunehd/__init__.py b/homeassistant/components/dunehd/__init__.py new file mode 100644 index 0000000000000..a8b8a8cf7af8d --- /dev/null +++ b/homeassistant/components/dunehd/__init__.py @@ -0,0 +1 @@ +"""The dunehd component.""" diff --git a/homeassistant/components/dunehd/manifest.json b/homeassistant/components/dunehd/manifest.json new file mode 100644 index 0000000000000..0160d5ec91843 --- /dev/null +++ b/homeassistant/components/dunehd/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "dunehd", + "name": "DuneHD", + "documentation": "https://www.home-assistant.io/integrations/dunehd", + "requirements": ["pdunehd==1.3"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/dunehd/media_player.py b/homeassistant/components/dunehd/media_player.py new file mode 100644 index 0000000000000..bb32bff2a15a2 --- /dev/null +++ b/homeassistant/components/dunehd/media_player.py @@ -0,0 +1,182 @@ +"""DuneHD implementation of the media player.""" +from pdunehd import DuneHDPlayer +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, +) +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + STATE_OFF, + STATE_ON, + STATE_PAUSED, + STATE_PLAYING, +) +import homeassistant.helpers.config_validation as cv + +DEFAULT_NAME = "DuneHD" + +CONF_SOURCES = "sources" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_SOURCES): vol.Schema({cv.string: cv.string}), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + } +) + +DUNEHD_PLAYER_SUPPORT = ( + SUPPORT_PAUSE + | SUPPORT_TURN_ON + | SUPPORT_TURN_OFF + | SUPPORT_SELECT_SOURCE + | SUPPORT_PREVIOUS_TRACK + | SUPPORT_NEXT_TRACK + | SUPPORT_PLAY +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the DuneHD media player platform.""" + + sources = config.get(CONF_SOURCES, {}) + host = config.get(CONF_HOST) + name = config.get(CONF_NAME) + + add_entities([DuneHDPlayerEntity(DuneHDPlayer(host), name, sources)], True) + + +class DuneHDPlayerEntity(MediaPlayerDevice): + """Implementation of the Dune HD player.""" + + def __init__(self, player, name, sources): + """Initialize entity to control Dune HD.""" + self._player = player + self._name = name + self._sources = sources + self._media_title = None + self._state = None + + def update(self): + """Update internal status of the entity.""" + self._state = self._player.update_state() + self.__update_title() + return True + + @property + def state(self): + """Return player state.""" + state = STATE_OFF + if "playback_position" in self._state: + state = STATE_PLAYING + if self._state["player_state"] in ("playing", "buffering"): + state = STATE_PLAYING + if int(self._state.get("playback_speed", 1234)) == 0: + state = STATE_PAUSED + if self._state["player_state"] == "navigator": + state = STATE_ON + return state + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def volume_level(self): + """Return the volume level of the media player (0..1).""" + return int(self._state.get("playback_volume", 0)) / 100 + + @property + def is_volume_muted(self): + """Return a boolean if volume is currently muted.""" + return int(self._state.get("playback_mute", 0)) == 1 + + @property + def source_list(self): + """Return a list of available input sources.""" + return list(self._sources.keys()) + + @property + def supported_features(self): + """Flag media player features that are supported.""" + return DUNEHD_PLAYER_SUPPORT + + def volume_up(self): + """Volume up media player.""" + self._state = self._player.volume_up() + + def volume_down(self): + """Volume down media player.""" + self._state = self._player.volume_down() + + def mute_volume(self, mute): + """Mute/unmute player volume.""" + self._state = self._player.mute(mute) + + def turn_off(self): + """Turn off media player.""" + self._media_title = None + self._state = self._player.turn_off() + self.schedule_update_ha_state() + + def turn_on(self): + """Turn off media player.""" + self._state = self._player.turn_on() + self.schedule_update_ha_state() + + def media_play(self): + """Play media player.""" + self._state = self._player.play() + self.schedule_update_ha_state() + + def media_pause(self): + """Pause media player.""" + self._state = self._player.pause() + self.schedule_update_ha_state() + + @property + def media_title(self): + """Return the current media source.""" + self.__update_title() + if self._media_title: + return self._media_title + return self._state.get("playback_url", "Not playing") + + def __update_title(self): + if self._state["player_state"] == "bluray_playback": + self._media_title = "Blu-Ray" + elif "playback_url" in self._state: + sources = self._sources + sval = sources.values() + skey = sources.keys() + pburl = self._state["playback_url"] + if pburl in sval: + self._media_title = list(skey)[list(sval).index(pburl)] + else: + self._media_title = pburl + + def select_source(self, source): + """Select input source.""" + self._media_title = source + self._state = self._player.launch_media_url(self._sources.get(source)) + self.schedule_update_ha_state() + + def media_previous_track(self): + """Send previous track command.""" + self._state = self._player.previous_track() + self.schedule_update_ha_state() + + def media_next_track(self): + """Send next track command.""" + self._state = self._player.next_track() + self.schedule_update_ha_state() diff --git a/homeassistant/components/dwd_weather_warnings/__init__.py b/homeassistant/components/dwd_weather_warnings/__init__.py new file mode 100644 index 0000000000000..1841291f7a97c --- /dev/null +++ b/homeassistant/components/dwd_weather_warnings/__init__.py @@ -0,0 +1 @@ +"""The dwd_weather_warnings component.""" diff --git a/homeassistant/components/dwd_weather_warnings/manifest.json b/homeassistant/components/dwd_weather_warnings/manifest.json new file mode 100644 index 0000000000000..19dcf2860d74c --- /dev/null +++ b/homeassistant/components/dwd_weather_warnings/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "dwd_weather_warnings", + "name": "Deutsche Wetter Dienst (DWD) Weather Warnings", + "documentation": "https://www.home-assistant.io/integrations/dwd_weather_warnings", + "requirements": [], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/dwd_weather_warnings/sensor.py b/homeassistant/components/dwd_weather_warnings/sensor.py new file mode 100644 index 0000000000000..695b839d18cf8 --- /dev/null +++ b/homeassistant/components/dwd_weather_warnings/sensor.py @@ -0,0 +1,255 @@ +""" +Support for getting statistical data from a DWD Weather Warnings. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.dwd_weather_warnings/ + +Data is fetched from DWD: +https://rcccm.dwd.de/DE/wetter/warnungen_aktuell/objekt_einbindung/objekteinbindung.html + +Warnungen vor extremem Unwetter (Stufe 4) +Unwetterwarnungen (Stufe 3) +Warnungen vor markantem Wetter (Stufe 2) +Wetterwarnungen (Stufe 1) +""" +from datetime import timedelta +import json +import logging + +import voluptuous as vol + +from homeassistant.components.rest.sensor import RestData +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ATTR_ATTRIBUTION, CONF_MONITORED_CONDITIONS, CONF_NAME +from homeassistant.helpers.aiohttp_client import SERVER_SOFTWARE as HA_USER_AGENT +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 = "Data provided by DWD" + +DEFAULT_NAME = "DWD-Weather-Warnings" + +CONF_REGION_NAME = "region_name" + +SCAN_INTERVAL = timedelta(minutes=15) + +MONITORED_CONDITIONS = { + "current_warning_level": [ + "Current Warning Level", + None, + "mdi:close-octagon-outline", + ], + "advance_warning_level": [ + "Advance Warning Level", + None, + "mdi:close-octagon-outline", + ], +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_REGION_NAME): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional( + CONF_MONITORED_CONDITIONS, default=list(MONITORED_CONDITIONS) + ): vol.All(cv.ensure_list, [vol.In(MONITORED_CONDITIONS)]), + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the DWD-Weather-Warnings sensor.""" + name = config.get(CONF_NAME) + region_name = config.get(CONF_REGION_NAME) + + api = DwdWeatherWarningsAPI(region_name) + + sensors = [ + DwdWeatherWarningsSensor(api, name, condition) + for condition in config[CONF_MONITORED_CONDITIONS] + ] + + add_entities(sensors, True) + + +class DwdWeatherWarningsSensor(Entity): + """Representation of a DWD-Weather-Warnings sensor.""" + + def __init__(self, api, name, variable): + """Initialize a DWD-Weather-Warnings sensor.""" + self._api = api + self._name = name + self._var_id = variable + + variable_info = MONITORED_CONDITIONS[variable] + self._var_name = variable_info[0] + 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 state(self): + """Return the state of the device.""" + try: + return round(self._api.data[self._var_id], 2) + except TypeError: + return self._api.data[self._var_id] + + @property + def device_state_attributes(self): + """Return the state attributes of the DWD-Weather-Warnings.""" + data = {ATTR_ATTRIBUTION: ATTRIBUTION, "region_name": self._api.region_name} + + if self._api.region_id is not None: + data["region_id"] = self._api.region_id + + if self._api.region_state is not None: + data["region_state"] = self._api.region_state + + if self._api.data["time"] is not None: + data["last_update"] = dt_util.as_local( + dt_util.utc_from_timestamp(self._api.data["time"] / 1000) + ) + + if self._var_id == "current_warning_level": + prefix = "current" + elif self._var_id == "advance_warning_level": + prefix = "advance" + else: + raise Exception("Unknown warning type") + + data["warning_count"] = self._api.data[prefix + "_warning_count"] + i = 0 + for event in self._api.data[prefix + "_warnings"]: + i = i + 1 + + data[f"warning_{i}_name"] = event["event"] + data[f"warning_{i}_level"] = event["level"] + data[f"warning_{i}_type"] = event["type"] + if event["headline"]: + data[f"warning_{i}_headline"] = event["headline"] + if event["description"]: + data[f"warning_{i}_description"] = event["description"] + if event["instruction"]: + data[f"warning_{i}_instruction"] = event["instruction"] + + if event["start"] is not None: + data[f"warning_{i}_start"] = dt_util.as_local( + dt_util.utc_from_timestamp(event["start"] / 1000) + ) + + if event["end"] is not None: + data[f"warning_{i}_end"] = dt_util.as_local( + dt_util.utc_from_timestamp(event["end"] / 1000) + ) + + return data + + @property + def available(self): + """Could the device be accessed during the last update call.""" + return self._api.available + + def update(self): + """Get the latest data from the DWD-Weather-Warnings API.""" + self._api.update() + + +class DwdWeatherWarningsAPI: + """Get the latest data and update the states.""" + + def __init__(self, region_name): + """Initialize the data object.""" + resource = "{}{}{}?{}".format( + "https://", + "www.dwd.de", + "/DWD/warnungen/warnapp_landkreise/json/warnings.json", + "jsonp=loadWarnings", + ) + + # a User-Agent is necessary for this rest api endpoint (#29496) + headers = {"User-Agent": HA_USER_AGENT} + + self._rest = RestData("GET", resource, None, headers, None, True) + self.region_name = region_name + self.region_id = None + self.region_state = None + self.data = None + self.available = True + self.update() + + @Throttle(SCAN_INTERVAL) + def update(self): + """Get the latest data from the DWD-Weather-Warnings.""" + try: + self._rest.update() + + json_string = self._rest.data[24 : len(self._rest.data) - 2] + json_obj = json.loads(json_string) + + data = {"time": json_obj["time"]} + + for mykey, myvalue in { + "current": "warnings", + "advance": "vorabInformation", + }.items(): + + _LOGGER.debug( + "Found %d %s global DWD warnings", len(json_obj[myvalue]), mykey + ) + + data[f"{mykey}_warning_level"] = 0 + my_warnings = [] + + if self.region_id is not None: + # get a specific region_id + if self.region_id in json_obj[myvalue]: + my_warnings = json_obj[myvalue][self.region_id] + + else: + # loop through all items to find warnings, region_id + # and region_state for region_name + for key in json_obj[myvalue]: + my_region = json_obj[myvalue][key][0]["regionName"] + if my_region != self.region_name: + continue + my_warnings = json_obj[myvalue][key] + my_state = json_obj[myvalue][key][0]["stateShort"] + self.region_id = key + self.region_state = my_state + break + + # Get max warning level + maxlevel = data[f"{mykey}_warning_level"] + for event in my_warnings: + if event["level"] >= maxlevel: + data[f"{mykey}_warning_level"] = event["level"] + + data[f"{mykey}_warning_count"] = len(my_warnings) + data[f"{mykey}_warnings"] = my_warnings + + _LOGGER.debug("Found %d %s local DWD warnings", len(my_warnings), mykey) + + self.data = data + self.available = True + except TypeError: + _LOGGER.error("Unable to fetch data from DWD-Weather-Warnings") + self.available = False diff --git a/homeassistant/components/dweet/__init__.py b/homeassistant/components/dweet/__init__.py new file mode 100644 index 0000000000000..db985e57a41bd --- /dev/null +++ b/homeassistant/components/dweet/__init__.py @@ -0,0 +1,76 @@ +"""Support for sending data to Dweet.io.""" +from datetime import timedelta +import logging + +import dweepy +import voluptuous as vol + +from homeassistant.const import ( + CONF_NAME, + CONF_WHITELIST, + EVENT_STATE_CHANGED, + STATE_UNKNOWN, +) +from homeassistant.helpers import state as state_helper +import homeassistant.helpers.config_validation as cv +from homeassistant.util import Throttle + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "dweet" + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_WHITELIST, default=[]): vol.All( + cv.ensure_list, [cv.entity_id] + ), + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +def setup(hass, config): + """Set up the Dweet.io component.""" + conf = config[DOMAIN] + name = conf.get(CONF_NAME) + whitelist = conf.get(CONF_WHITELIST) + json_body = {} + + def dweet_event_listener(event): + """Listen for new messages on the bus and sends them to Dweet.io.""" + state = event.data.get("new_state") + if ( + state is None + or state.state in (STATE_UNKNOWN, "") + or state.entity_id not in whitelist + ): + return + + try: + _state = state_helper.state_as_number(state) + except ValueError: + _state = state.state + + json_body[state.attributes.get("friendly_name")] = _state + + send_data(name, json_body) + + hass.bus.listen(EVENT_STATE_CHANGED, dweet_event_listener) + + return True + + +@Throttle(MIN_TIME_BETWEEN_UPDATES) +def send_data(name, msg): + """Send the collected data to Dweet.io.""" + try: + dweepy.dweet_for(name, msg) + except dweepy.DweepyError: + _LOGGER.error("Error saving data to Dweet.io: %s", msg) diff --git a/homeassistant/components/dweet/manifest.json b/homeassistant/components/dweet/manifest.json new file mode 100644 index 0000000000000..be21605196ae0 --- /dev/null +++ b/homeassistant/components/dweet/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "dweet", + "name": "dweet.io", + "documentation": "https://www.home-assistant.io/integrations/dweet", + "requirements": ["dweepy==0.3.0"], + "dependencies": [], + "codeowners": ["@fabaff"] +} diff --git a/homeassistant/components/dweet/sensor.py b/homeassistant/components/dweet/sensor.py new file mode 100644 index 0000000000000..f3f604ff3692b --- /dev/null +++ b/homeassistant/components/dweet/sensor.py @@ -0,0 +1,113 @@ +"""Support for showing values from Dweet.io.""" +from datetime import timedelta +import json +import logging + +import dweepy +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_DEVICE, + CONF_NAME, + CONF_UNIT_OF_MEASUREMENT, + CONF_VALUE_TEMPLATE, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = "Dweet.io Sensor" + +SCAN_INTERVAL = timedelta(minutes=1) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_DEVICE): cv.string, + vol.Required(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Dweet sensor.""" + name = config.get(CONF_NAME) + device = config.get(CONF_DEVICE) + value_template = config.get(CONF_VALUE_TEMPLATE) + unit = config.get(CONF_UNIT_OF_MEASUREMENT) + if value_template is not None: + value_template.hass = hass + + try: + content = json.dumps(dweepy.get_latest_dweet_for(device)[0]["content"]) + except dweepy.DweepyError: + _LOGGER.error("Device/thing %s could not be found", device) + return + + if value_template.render_with_possible_json_value(content) == "": + _LOGGER.error("%s was not found", value_template) + return + + dweet = DweetData(device) + + add_entities([DweetSensor(hass, dweet, name, value_template, unit)], True) + + +class DweetSensor(Entity): + """Representation of a Dweet sensor.""" + + def __init__(self, hass, dweet, name, value_template, unit_of_measurement): + """Initialize the sensor.""" + self.hass = hass + self.dweet = dweet + self._name = name + self._value_template = value_template + self._state = None + self._unit_of_measurement = unit_of_measurement + + @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.""" + return self._state + + def update(self): + """Get the latest data from REST API.""" + self.dweet.update() + + if self.dweet.data is None: + self._state = None + else: + values = json.dumps(self.dweet.data[0]["content"]) + self._state = self._value_template.render_with_possible_json_value( + values, None + ) + + +class DweetData: + """The class for handling the data retrieval.""" + + def __init__(self, device): + """Initialize the sensor.""" + self._device = device + self.data = None + + def update(self): + """Get the latest data from Dweet.io.""" + try: + self.data = dweepy.get_latest_dweet_for(self._device) + except dweepy.DweepyError: + _LOGGER.warning("Device %s doesn't contain any data", self._device) + self.data = None diff --git a/homeassistant/components/dyson/__init__.py b/homeassistant/components/dyson/__init__.py new file mode 100644 index 0000000000000..fbe7897e6bb20 --- /dev/null +++ b/homeassistant/components/dyson/__init__.py @@ -0,0 +1,110 @@ +"""Support for Dyson Pure Cool Link devices.""" +import logging + +from libpurecool.dyson import DysonAccount +import voluptuous as vol + +from homeassistant.const import CONF_DEVICES, CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +CONF_LANGUAGE = "language" +CONF_RETRY = "retry" + +DEFAULT_TIMEOUT = 5 +DEFAULT_RETRY = 10 +DYSON_DEVICES = "dyson_devices" +DYSON_PLATFORMS = ["sensor", "fan", "vacuum", "climate", "air_quality"] + +DOMAIN = "dyson" + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_LANGUAGE): cv.string, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + vol.Optional(CONF_RETRY, default=DEFAULT_RETRY): cv.positive_int, + vol.Optional(CONF_DEVICES, default=[]): vol.All(cv.ensure_list, [dict]), + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +def setup(hass, config): + """Set up the Dyson parent component.""" + _LOGGER.info("Creating new Dyson component") + + if DYSON_DEVICES not in hass.data: + hass.data[DYSON_DEVICES] = [] + + dyson_account = DysonAccount( + config[DOMAIN].get(CONF_USERNAME), + config[DOMAIN].get(CONF_PASSWORD), + config[DOMAIN].get(CONF_LANGUAGE), + ) + + logged = dyson_account.login() + + timeout = config[DOMAIN].get(CONF_TIMEOUT) + retry = config[DOMAIN].get(CONF_RETRY) + + if not logged: + _LOGGER.error("Not connected to Dyson account. Unable to add devices") + return False + + _LOGGER.info("Connected to Dyson account") + dyson_devices = dyson_account.devices() + if CONF_DEVICES in config[DOMAIN] and config[DOMAIN].get(CONF_DEVICES): + configured_devices = config[DOMAIN].get(CONF_DEVICES) + for device in configured_devices: + dyson_device = next( + (d for d in dyson_devices if d.serial == device["device_id"]), None + ) + if dyson_device: + try: + connected = dyson_device.connect(device["device_ip"]) + if connected: + _LOGGER.info("Connected to device %s", dyson_device) + hass.data[DYSON_DEVICES].append(dyson_device) + else: + _LOGGER.warning("Unable to connect to device %s", dyson_device) + except OSError as ose: + _LOGGER.error( + "Unable to connect to device %s: %s", + str(dyson_device.network_device), + str(ose), + ) + else: + _LOGGER.warning( + "Unable to find device %s in Dyson account", device["device_id"] + ) + else: + # Not yet reliable + for device in dyson_devices: + _LOGGER.info( + "Trying to connect to device %s with timeout=%i and retry=%i", + device, + timeout, + retry, + ) + connected = device.auto_connect(timeout, retry) + if connected: + _LOGGER.info("Connected to device %s", device) + hass.data[DYSON_DEVICES].append(device) + else: + _LOGGER.warning("Unable to connect to device %s", device) + + # Start fan/sensors components + if hass.data[DYSON_DEVICES]: + _LOGGER.debug("Starting sensor/fan components") + for platform in DYSON_PLATFORMS: + discovery.load_platform(hass, platform, DOMAIN, {}, config) + + return True diff --git a/homeassistant/components/dyson/air_quality.py b/homeassistant/components/dyson/air_quality.py new file mode 100644 index 0000000000000..647fb2367074f --- /dev/null +++ b/homeassistant/components/dyson/air_quality.py @@ -0,0 +1,129 @@ +"""Support for Dyson Pure Cool Air Quality Sensors.""" +import logging + +from libpurecool.dyson_pure_cool import DysonPureCool +from libpurecool.dyson_pure_state_v2 import DysonEnvironmentalSensorV2State + +from homeassistant.components.air_quality import DOMAIN, AirQualityEntity + +from . import DYSON_DEVICES + +ATTRIBUTION = "Dyson purifier air quality sensor" + +_LOGGER = logging.getLogger(__name__) + +DYSON_AIQ_DEVICES = "dyson_aiq_devices" + +ATTR_VOC = "volatile_organic_compounds" + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Dyson Sensors.""" + + if discovery_info is None: + return + + hass.data.setdefault(DYSON_AIQ_DEVICES, []) + + # Get Dyson Devices from parent component + device_ids = [device.unique_id for device in hass.data[DYSON_AIQ_DEVICES]] + for device in hass.data[DYSON_DEVICES]: + if isinstance(device, DysonPureCool) and device.serial not in device_ids: + hass.data[DYSON_AIQ_DEVICES].append(DysonAirSensor(device)) + add_entities(hass.data[DYSON_AIQ_DEVICES]) + + +class DysonAirSensor(AirQualityEntity): + """Representation of a generic Dyson air quality sensor.""" + + def __init__(self, device): + """Create a new generic air quality Dyson sensor.""" + self._device = device + self._old_value = None + self._name = device.name + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + self.hass.async_add_executor_job( + self._device.add_message_listener, self.on_message + ) + + def on_message(self, message): + """Handle new messages which are received from the fan.""" + _LOGGER.debug( + "%s: Message received for %s device: %s", DOMAIN, self.name, message + ) + if ( + self._old_value is None + or self._old_value != self._device.environmental_state + ) and isinstance(message, DysonEnvironmentalSensorV2State): + self._old_value = self._device.environmental_state + self.schedule_update_ha_state() + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def name(self): + """Return the name of the Dyson sensor.""" + return self._name + + @property + def attribution(self): + """Return the attribution.""" + return ATTRIBUTION + + @property + def air_quality_index(self): + """Return the Air Quality Index (AQI).""" + return max( + self.particulate_matter_2_5, + self.particulate_matter_10, + self.nitrogen_dioxide, + self.volatile_organic_compounds, + ) + + @property + def particulate_matter_2_5(self): + """Return the particulate matter 2.5 level.""" + if self._device.environmental_state: + return int(self._device.environmental_state.particulate_matter_25) + return None + + @property + def particulate_matter_10(self): + """Return the particulate matter 10 level.""" + if self._device.environmental_state: + return int(self._device.environmental_state.particulate_matter_10) + return None + + @property + def nitrogen_dioxide(self): + """Return the NO2 (nitrogen dioxide) level.""" + if self._device.environmental_state: + return int(self._device.environmental_state.nitrogen_dioxide) + return None + + @property + def volatile_organic_compounds(self): + """Return the VOC (Volatile Organic Compounds) level.""" + if self._device.environmental_state: + return int(self._device.environmental_state.volatile_organic_compounds) + return None + + @property + def unique_id(self): + """Return the sensor's unique id.""" + return self._device.serial + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + data = {} + + voc = self.volatile_organic_compounds + if voc is not None: + data[ATTR_VOC] = voc + return data diff --git a/homeassistant/components/dyson/climate.py b/homeassistant/components/dyson/climate.py new file mode 100644 index 0000000000000..df97358d55008 --- /dev/null +++ b/homeassistant/components/dyson/climate.py @@ -0,0 +1,190 @@ +"""Support for Dyson Pure Hot+Cool link fan.""" +import logging + +from libpurecool.const import FocusMode, HeatMode, HeatState, HeatTarget +from libpurecool.dyson_pure_hotcool_link import DysonPureHotCoolLink +from libpurecool.dyson_pure_state import DysonPureHotCoolState + +from homeassistant.components.climate import ClimateDevice +from homeassistant.components.climate.const import ( + CURRENT_HVAC_COOL, + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, + FAN_DIFFUSE, + FAN_FOCUS, + HVAC_MODE_COOL, + HVAC_MODE_HEAT, + SUPPORT_FAN_MODE, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS + +from . import DYSON_DEVICES + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_FAN = [FAN_FOCUS, FAN_DIFFUSE] +SUPPORT_HVAG = [HVAC_MODE_COOL, HVAC_MODE_HEAT] +SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Dyson fan components.""" + if discovery_info is None: + return + + # Get Dyson Devices from parent component. + add_devices( + [ + DysonPureHotCoolLinkDevice(device) + for device in hass.data[DYSON_DEVICES] + if isinstance(device, DysonPureHotCoolLink) + ] + ) + + +class DysonPureHotCoolLinkDevice(ClimateDevice): + """Representation of a Dyson climate fan.""" + + def __init__(self, device): + """Initialize the fan.""" + self._device = device + self._current_temp = None + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + self.hass.async_add_job(self._device.add_message_listener, self.on_message) + + def on_message(self, message): + """Call when new messages received from the climate.""" + if not isinstance(message, DysonPureHotCoolState): + return + + _LOGGER.debug("Message received for climate device %s : %s", self.name, message) + self.schedule_update_ha_state() + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS + + @property + def name(self): + """Return the display name of this climate.""" + return self._device.name + + @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.environmental_state: + temperature_kelvin = self._device.environmental_state.temperature + if temperature_kelvin != 0: + self._current_temp = float("{0:.1f}".format(temperature_kelvin - 273)) + return self._current_temp + + @property + def target_temperature(self): + """Return the target temperature.""" + heat_target = int(self._device.state.heat_target) / 10 + return int(heat_target - 273) + + @property + def current_humidity(self): + """Return the current humidity.""" + if self._device.environmental_state: + if self._device.environmental_state.humidity == 0: + return None + return self._device.environmental_state.humidity + return None + + @property + def hvac_mode(self): + """Return hvac operation ie. heat, cool mode. + + Need to be one of HVAC_MODE_*. + """ + if self._device.state.heat_mode == HeatMode.HEAT_ON.value: + return HVAC_MODE_HEAT + return HVAC_MODE_COOL + + @property + def hvac_modes(self): + """Return the list of available hvac operation modes. + + Need to be a subset of HVAC_MODES. + """ + return SUPPORT_HVAG + + @property + def hvac_action(self): + """Return the current running hvac operation if supported. + + Need to be one of CURRENT_HVAC_*. + """ + if self._device.state.heat_mode == HeatMode.HEAT_ON.value: + if self._device.state.heat_state == HeatState.HEAT_STATE_ON.value: + return CURRENT_HVAC_HEAT + return CURRENT_HVAC_IDLE + return CURRENT_HVAC_COOL + + @property + def fan_mode(self): + """Return the fan setting.""" + if self._device.state.focus_mode == FocusMode.FOCUS_ON.value: + return FAN_FOCUS + return FAN_DIFFUSE + + @property + def fan_modes(self): + """Return the list of available fan modes.""" + return SUPPORT_FAN + + def set_temperature(self, **kwargs): + """Set new target temperature.""" + target_temp = kwargs.get(ATTR_TEMPERATURE) + if target_temp is None: + return + target_temp = int(target_temp) + _LOGGER.debug("Set %s temperature %s", self.name, target_temp) + # Limit the target temperature into acceptable range. + target_temp = min(self.max_temp, target_temp) + target_temp = max(self.min_temp, target_temp) + self._device.set_configuration( + heat_target=HeatTarget.celsius(target_temp), heat_mode=HeatMode.HEAT_ON + ) + + def set_fan_mode(self, fan_mode): + """Set new fan mode.""" + _LOGGER.debug("Set %s focus mode %s", self.name, fan_mode) + if fan_mode == FAN_FOCUS: + self._device.set_configuration(focus_mode=FocusMode.FOCUS_ON) + elif fan_mode == FAN_DIFFUSE: + self._device.set_configuration(focus_mode=FocusMode.FOCUS_OFF) + + def set_hvac_mode(self, hvac_mode): + """Set new target hvac mode.""" + _LOGGER.debug("Set %s heat mode %s", self.name, hvac_mode) + if hvac_mode == HVAC_MODE_HEAT: + self._device.set_configuration(heat_mode=HeatMode.HEAT_ON) + elif hvac_mode == HVAC_MODE_COOL: + self._device.set_configuration(heat_mode=HeatMode.HEAT_OFF) + + @property + def min_temp(self): + """Return the minimum temperature.""" + return 1 + + @property + def max_temp(self): + """Return the maximum temperature.""" + return 37 diff --git a/homeassistant/components/dyson/fan.py b/homeassistant/components/dyson/fan.py new file mode 100644 index 0000000000000..1fdbed0d20451 --- /dev/null +++ b/homeassistant/components/dyson/fan.py @@ -0,0 +1,580 @@ +"""Support for Dyson Pure Cool link fan. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/fan.dyson/ +""" +import logging + +from libpurecool.const import FanMode, FanSpeed, NightMode, Oscillation +from libpurecool.dyson_pure_cool import DysonPureCool +from libpurecool.dyson_pure_cool_link import DysonPureCoolLink +from libpurecool.dyson_pure_state import DysonPureCoolState +from libpurecool.dyson_pure_state_v2 import DysonPureCoolV2State +import voluptuous as vol + +from homeassistant.components.fan import ( + SPEED_HIGH, + SPEED_LOW, + SPEED_MEDIUM, + SUPPORT_OSCILLATE, + SUPPORT_SET_SPEED, + FanEntity, +) +from homeassistant.const import ATTR_ENTITY_ID +import homeassistant.helpers.config_validation as cv + +from . import DYSON_DEVICES + +_LOGGER = logging.getLogger(__name__) + +ATTR_NIGHT_MODE = "night_mode" +ATTR_AUTO_MODE = "auto_mode" +ATTR_ANGLE_LOW = "angle_low" +ATTR_ANGLE_HIGH = "angle_high" +ATTR_FLOW_DIRECTION_FRONT = "flow_direction_front" +ATTR_TIMER = "timer" +ATTR_HEPA_FILTER = "hepa_filter" +ATTR_CARBON_FILTER = "carbon_filter" +ATTR_DYSON_SPEED = "dyson_speed" +ATTR_DYSON_SPEED_LIST = "dyson_speed_list" + +DYSON_DOMAIN = "dyson" +DYSON_FAN_DEVICES = "dyson_fan_devices" + +SERVICE_SET_NIGHT_MODE = "set_night_mode" +SERVICE_SET_AUTO_MODE = "set_auto_mode" +SERVICE_SET_ANGLE = "set_angle" +SERVICE_SET_FLOW_DIRECTION_FRONT = "set_flow_direction_front" +SERVICE_SET_TIMER = "set_timer" +SERVICE_SET_DYSON_SPEED = "set_speed" + +DYSON_SET_NIGHT_MODE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_NIGHT_MODE): cv.boolean, + } +) + +SET_AUTO_MODE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_AUTO_MODE): cv.boolean, + } +) + +SET_ANGLE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_ANGLE_LOW): cv.positive_int, + vol.Required(ATTR_ANGLE_HIGH): cv.positive_int, + } +) + +SET_FLOW_DIRECTION_FRONT_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_FLOW_DIRECTION_FRONT): cv.boolean, + } +) + +SET_TIMER_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_TIMER): cv.positive_int, + } +) + +SET_DYSON_SPEED_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_DYSON_SPEED): cv.positive_int, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Dyson fan components.""" + + if discovery_info is None: + return + + _LOGGER.debug("Creating new Dyson fans") + if DYSON_FAN_DEVICES not in hass.data: + hass.data[DYSON_FAN_DEVICES] = [] + + # Get Dyson Devices from parent component + has_purecool_devices = False + device_serials = [device.serial for device in hass.data[DYSON_FAN_DEVICES]] + for device in hass.data[DYSON_DEVICES]: + if device.serial not in device_serials: + if isinstance(device, DysonPureCool): + has_purecool_devices = True + dyson_entity = DysonPureCoolDevice(device) + hass.data[DYSON_FAN_DEVICES].append(dyson_entity) + elif isinstance(device, DysonPureCoolLink): + dyson_entity = DysonPureCoolLinkDevice(hass, device) + hass.data[DYSON_FAN_DEVICES].append(dyson_entity) + + add_entities(hass.data[DYSON_FAN_DEVICES]) + + def service_handle(service): + """Handle the Dyson services.""" + entity_id = service.data[ATTR_ENTITY_ID] + fan_device = next( + (fan for fan in hass.data[DYSON_FAN_DEVICES] if fan.entity_id == entity_id), + None, + ) + if fan_device is None: + _LOGGER.warning("Unable to find Dyson fan device %s", str(entity_id)) + return + + if service.service == SERVICE_SET_NIGHT_MODE: + fan_device.set_night_mode(service.data[ATTR_NIGHT_MODE]) + + if service.service == SERVICE_SET_AUTO_MODE: + fan_device.set_auto_mode(service.data[ATTR_AUTO_MODE]) + + if service.service == SERVICE_SET_ANGLE: + fan_device.set_angle( + service.data[ATTR_ANGLE_LOW], service.data[ATTR_ANGLE_HIGH] + ) + + if service.service == SERVICE_SET_FLOW_DIRECTION_FRONT: + fan_device.set_flow_direction_front(service.data[ATTR_FLOW_DIRECTION_FRONT]) + + if service.service == SERVICE_SET_TIMER: + fan_device.set_timer(service.data[ATTR_TIMER]) + + if service.service == SERVICE_SET_DYSON_SPEED: + fan_device.set_dyson_speed(service.data[ATTR_DYSON_SPEED]) + + # Register dyson service(s) + hass.services.register( + DYSON_DOMAIN, + SERVICE_SET_NIGHT_MODE, + service_handle, + schema=DYSON_SET_NIGHT_MODE_SCHEMA, + ) + + hass.services.register( + DYSON_DOMAIN, + SERVICE_SET_AUTO_MODE, + service_handle, + schema=SET_AUTO_MODE_SCHEMA, + ) + if has_purecool_devices: + hass.services.register( + DYSON_DOMAIN, SERVICE_SET_ANGLE, service_handle, schema=SET_ANGLE_SCHEMA + ) + + hass.services.register( + DYSON_DOMAIN, + SERVICE_SET_FLOW_DIRECTION_FRONT, + service_handle, + schema=SET_FLOW_DIRECTION_FRONT_SCHEMA, + ) + + hass.services.register( + DYSON_DOMAIN, SERVICE_SET_TIMER, service_handle, schema=SET_TIMER_SCHEMA + ) + + hass.services.register( + DYSON_DOMAIN, + SERVICE_SET_DYSON_SPEED, + service_handle, + schema=SET_DYSON_SPEED_SCHEMA, + ) + + +class DysonPureCoolLinkDevice(FanEntity): + """Representation of a Dyson fan.""" + + def __init__(self, hass, device): + """Initialize the fan.""" + _LOGGER.debug("Creating device %s", device.name) + self.hass = hass + self._device = device + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + self.hass.async_add_job(self._device.add_message_listener, self.on_message) + + def on_message(self, message): + """Call when new messages received from the fan.""" + + if isinstance(message, DysonPureCoolState): + _LOGGER.debug("Message received for fan device %s: %s", self.name, message) + self.schedule_update_ha_state() + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def name(self): + """Return the display name of this fan.""" + return self._device.name + + def set_speed(self, speed: str) -> None: + """Set the speed of the fan. Never called ??.""" + _LOGGER.debug("Set fan speed to: %s", speed) + + if speed == FanSpeed.FAN_SPEED_AUTO.value: + self._device.set_configuration(fan_mode=FanMode.AUTO) + else: + fan_speed = FanSpeed("{0:04d}".format(int(speed))) + self._device.set_configuration(fan_mode=FanMode.FAN, fan_speed=fan_speed) + + def turn_on(self, speed: str = None, **kwargs) -> None: + """Turn on the fan.""" + _LOGGER.debug("Turn on fan %s with speed %s", self.name, speed) + if speed: + if speed == FanSpeed.FAN_SPEED_AUTO.value: + self._device.set_configuration(fan_mode=FanMode.AUTO) + else: + fan_speed = FanSpeed("{0:04d}".format(int(speed))) + self._device.set_configuration( + fan_mode=FanMode.FAN, fan_speed=fan_speed + ) + else: + # Speed not set, just turn on + self._device.set_configuration(fan_mode=FanMode.FAN) + + def turn_off(self, **kwargs) -> None: + """Turn off the fan.""" + _LOGGER.debug("Turn off fan %s", self.name) + self._device.set_configuration(fan_mode=FanMode.OFF) + + def oscillate(self, oscillating: bool) -> None: + """Turn on/off oscillating.""" + _LOGGER.debug("Turn oscillation %s for device %s", oscillating, self.name) + + if oscillating: + self._device.set_configuration(oscillation=Oscillation.OSCILLATION_ON) + else: + self._device.set_configuration(oscillation=Oscillation.OSCILLATION_OFF) + + @property + def oscillating(self): + """Return the oscillation state.""" + return self._device.state and self._device.state.oscillation == "ON" + + @property + def is_on(self): + """Return true if the entity is on.""" + if self._device.state: + return self._device.state.fan_mode == "FAN" + return False + + @property + def speed(self) -> str: + """Return the current speed.""" + if self._device.state: + if self._device.state.speed == FanSpeed.FAN_SPEED_AUTO.value: + return self._device.state.speed + return int(self._device.state.speed) + return None + + @property + def current_direction(self): + """Return direction of the fan [forward, reverse].""" + return None + + @property + def night_mode(self): + """Return Night mode.""" + return self._device.state.night_mode == "ON" + + def set_night_mode(self, night_mode: bool) -> None: + """Turn fan in night mode.""" + _LOGGER.debug("Set %s night mode %s", self.name, night_mode) + if night_mode: + self._device.set_configuration(night_mode=NightMode.NIGHT_MODE_ON) + else: + self._device.set_configuration(night_mode=NightMode.NIGHT_MODE_OFF) + + @property + def auto_mode(self): + """Return auto mode.""" + return self._device.state.fan_mode == "AUTO" + + def set_auto_mode(self, auto_mode: bool) -> None: + """Turn fan in auto mode.""" + _LOGGER.debug("Set %s auto mode %s", self.name, auto_mode) + if auto_mode: + self._device.set_configuration(fan_mode=FanMode.AUTO) + else: + self._device.set_configuration(fan_mode=FanMode.FAN) + + @property + def speed_list(self) -> list: + """Get the list of available speeds.""" + supported_speeds = [ + FanSpeed.FAN_SPEED_AUTO.value, + int(FanSpeed.FAN_SPEED_1.value), + int(FanSpeed.FAN_SPEED_2.value), + int(FanSpeed.FAN_SPEED_3.value), + int(FanSpeed.FAN_SPEED_4.value), + int(FanSpeed.FAN_SPEED_5.value), + int(FanSpeed.FAN_SPEED_6.value), + int(FanSpeed.FAN_SPEED_7.value), + int(FanSpeed.FAN_SPEED_8.value), + int(FanSpeed.FAN_SPEED_9.value), + int(FanSpeed.FAN_SPEED_10.value), + ] + + return supported_speeds + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return SUPPORT_OSCILLATE | SUPPORT_SET_SPEED + + @property + def device_state_attributes(self) -> dict: + """Return optional state attributes.""" + return {ATTR_NIGHT_MODE: self.night_mode, ATTR_AUTO_MODE: self.auto_mode} + + +class DysonPureCoolDevice(FanEntity): + """Representation of a Dyson Purecool (TP04/DP04) fan.""" + + def __init__(self, device): + """Initialize the fan.""" + self._device = device + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + self.hass.async_add_executor_job( + self._device.add_message_listener, self.on_message + ) + + def on_message(self, message): + """Call when new messages received from the fan.""" + if isinstance(message, DysonPureCoolV2State): + _LOGGER.debug("Message received for fan device %s: %s", self.name, message) + self.schedule_update_ha_state() + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def name(self): + """Return the display name of this fan.""" + return self._device.name + + def turn_on(self, speed: str = None, **kwargs) -> None: + """Turn on the fan.""" + _LOGGER.debug("Turn on fan %s", self.name) + + if speed is not None: + self.set_speed(speed) + else: + self._device.turn_on() + + def set_speed(self, speed: str) -> None: + """Set the speed of the fan.""" + if speed == SPEED_LOW: + self._device.set_fan_speed(FanSpeed.FAN_SPEED_4) + elif speed == SPEED_MEDIUM: + self._device.set_fan_speed(FanSpeed.FAN_SPEED_7) + elif speed == SPEED_HIGH: + self._device.set_fan_speed(FanSpeed.FAN_SPEED_10) + + def turn_off(self, **kwargs): + """Turn off the fan.""" + _LOGGER.debug("Turn off fan %s", self.name) + self._device.turn_off() + + def set_dyson_speed(self, speed: str = None) -> None: + """Set the exact speed of the purecool fan.""" + _LOGGER.debug("Set exact speed for fan %s", self.name) + + fan_speed = FanSpeed("{0:04d}".format(int(speed))) + self._device.set_fan_speed(fan_speed) + + def oscillate(self, oscillating: bool) -> None: + """Turn on/off oscillating.""" + _LOGGER.debug("Turn oscillation %s for device %s", oscillating, self.name) + + if oscillating: + self._device.enable_oscillation() + else: + self._device.disable_oscillation() + + def set_night_mode(self, night_mode: bool) -> None: + """Turn on/off night mode.""" + _LOGGER.debug("Turn night mode %s for device %s", night_mode, self.name) + + if night_mode: + self._device.enable_night_mode() + else: + self._device.disable_night_mode() + + def set_auto_mode(self, auto_mode: bool) -> None: + """Turn auto mode on/off.""" + _LOGGER.debug("Turn auto mode %s for device %s", auto_mode, self.name) + if auto_mode: + self._device.enable_auto_mode() + else: + self._device.disable_auto_mode() + + def set_angle(self, angle_low: int, angle_high: int) -> None: + """Set device angle.""" + _LOGGER.debug( + "set low %s and high angle %s for device %s", + angle_low, + angle_high, + self.name, + ) + self._device.enable_oscillation(angle_low, angle_high) + + def set_flow_direction_front(self, flow_direction_front: bool) -> None: + """Set frontal airflow direction.""" + _LOGGER.debug( + "Set frontal flow direction to %s for device %s", + flow_direction_front, + self.name, + ) + + if flow_direction_front: + self._device.enable_frontal_direction() + else: + self._device.disable_frontal_direction() + + def set_timer(self, timer) -> None: + """Set timer.""" + _LOGGER.debug("Set timer to %s for device %s", timer, self.name) + + if timer == 0: + self._device.disable_sleep_timer() + else: + self._device.enable_sleep_timer(timer) + + @property + def oscillating(self): + """Return the oscillation state.""" + return self._device.state and self._device.state.oscillation == "OION" + + @property + def is_on(self): + """Return true if the entity is on.""" + if self._device.state: + return self._device.state.fan_power == "ON" + + @property + def speed(self): + """Return the current speed.""" + speed_map = { + FanSpeed.FAN_SPEED_1.value: SPEED_LOW, + FanSpeed.FAN_SPEED_2.value: SPEED_LOW, + FanSpeed.FAN_SPEED_3.value: SPEED_LOW, + FanSpeed.FAN_SPEED_4.value: SPEED_LOW, + FanSpeed.FAN_SPEED_AUTO.value: SPEED_MEDIUM, + FanSpeed.FAN_SPEED_5.value: SPEED_MEDIUM, + FanSpeed.FAN_SPEED_6.value: SPEED_MEDIUM, + FanSpeed.FAN_SPEED_7.value: SPEED_MEDIUM, + FanSpeed.FAN_SPEED_8.value: SPEED_HIGH, + FanSpeed.FAN_SPEED_9.value: SPEED_HIGH, + FanSpeed.FAN_SPEED_10.value: SPEED_HIGH, + } + + return speed_map[self._device.state.speed] + + @property + def dyson_speed(self): + """Return the current speed.""" + if self._device.state: + if self._device.state.speed == FanSpeed.FAN_SPEED_AUTO.value: + return self._device.state.speed + return int(self._device.state.speed) + + @property + def night_mode(self): + """Return Night mode.""" + return self._device.state.night_mode == "ON" + + @property + def auto_mode(self): + """Return Auto mode.""" + return self._device.state.auto_mode == "ON" + + @property + def angle_low(self): + """Return angle high.""" + return int(self._device.state.oscillation_angle_low) + + @property + def angle_high(self): + """Return angle low.""" + return int(self._device.state.oscillation_angle_high) + + @property + def flow_direction_front(self): + """Return frontal flow direction.""" + return self._device.state.front_direction == "ON" + + @property + def timer(self): + """Return timer.""" + return self._device.state.sleep_timer + + @property + def hepa_filter(self): + """Return the HEPA filter state.""" + return int(self._device.state.hepa_filter_state) + + @property + def carbon_filter(self): + """Return the carbon filter state.""" + return int(self._device.state.carbon_filter_state) + + @property + def speed_list(self) -> list: + """Get the list of available speeds.""" + return [SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] + + @property + def dyson_speed_list(self) -> list: + """Get the list of available dyson speeds.""" + return [ + int(FanSpeed.FAN_SPEED_1.value), + int(FanSpeed.FAN_SPEED_2.value), + int(FanSpeed.FAN_SPEED_3.value), + int(FanSpeed.FAN_SPEED_4.value), + int(FanSpeed.FAN_SPEED_5.value), + int(FanSpeed.FAN_SPEED_6.value), + int(FanSpeed.FAN_SPEED_7.value), + int(FanSpeed.FAN_SPEED_8.value), + int(FanSpeed.FAN_SPEED_9.value), + int(FanSpeed.FAN_SPEED_10.value), + ] + + @property + def device_serial(self): + """Return fan's serial number.""" + return self._device.serial + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return SUPPORT_OSCILLATE | SUPPORT_SET_SPEED + + @property + def device_state_attributes(self) -> dict: + """Return optional state attributes.""" + return { + ATTR_NIGHT_MODE: self.night_mode, + ATTR_AUTO_MODE: self.auto_mode, + ATTR_ANGLE_LOW: self.angle_low, + ATTR_ANGLE_HIGH: self.angle_high, + ATTR_FLOW_DIRECTION_FRONT: self.flow_direction_front, + ATTR_TIMER: self.timer, + ATTR_HEPA_FILTER: self.hepa_filter, + ATTR_CARBON_FILTER: self.carbon_filter, + ATTR_DYSON_SPEED: self.dyson_speed, + ATTR_DYSON_SPEED_LIST: self.dyson_speed_list, + } diff --git a/homeassistant/components/dyson/manifest.json b/homeassistant/components/dyson/manifest.json new file mode 100644 index 0000000000000..915c6aa3b796c --- /dev/null +++ b/homeassistant/components/dyson/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "dyson", + "name": "Dyson", + "documentation": "https://www.home-assistant.io/integrations/dyson", + "requirements": ["libpurecool==0.6.0"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/dyson/sensor.py b/homeassistant/components/dyson/sensor.py new file mode 100644 index 0000000000000..2fdd3cd6c1fc0 --- /dev/null +++ b/homeassistant/components/dyson/sensor.py @@ -0,0 +1,199 @@ +"""Support for Dyson Pure Cool Link Sensors.""" +import logging + +from libpurecool.dyson_pure_cool import DysonPureCool +from libpurecool.dyson_pure_cool_link import DysonPureCoolLink + +from homeassistant.const import STATE_OFF, TEMP_CELSIUS +from homeassistant.helpers.entity import Entity + +from . import DYSON_DEVICES + +SENSOR_UNITS = { + "air_quality": None, + "dust": None, + "filter_life": "hours", + "humidity": "%", +} + +SENSOR_ICONS = { + "air_quality": "mdi:fan", + "dust": "mdi:cloud", + "filter_life": "mdi:filter-outline", + "humidity": "mdi:water-percent", + "temperature": "mdi:thermometer", +} + +DYSON_SENSOR_DEVICES = "dyson_sensor_devices" + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Dyson Sensors.""" + + if discovery_info is None: + return + + hass.data.setdefault(DYSON_SENSOR_DEVICES, []) + unit = hass.config.units.temperature_unit + devices = hass.data[DYSON_SENSOR_DEVICES] + + # Get Dyson Devices from parent component + device_ids = [device.unique_id for device in hass.data[DYSON_SENSOR_DEVICES]] + for device in hass.data[DYSON_DEVICES]: + if isinstance(device, DysonPureCool): + if "{}-{}".format(device.serial, "temperature") not in device_ids: + devices.append(DysonTemperatureSensor(device, unit)) + if "{}-{}".format(device.serial, "humidity") not in device_ids: + devices.append(DysonHumiditySensor(device)) + elif isinstance(device, DysonPureCoolLink): + devices.append(DysonFilterLifeSensor(device)) + devices.append(DysonDustSensor(device)) + devices.append(DysonHumiditySensor(device)) + devices.append(DysonTemperatureSensor(device, unit)) + devices.append(DysonAirQualitySensor(device)) + add_entities(devices) + + +class DysonSensor(Entity): + """Representation of a generic Dyson sensor.""" + + def __init__(self, device, sensor_type): + """Create a new generic Dyson sensor.""" + self._device = device + self._old_value = None + self._name = None + self._sensor_type = sensor_type + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + self.hass.async_add_executor_job( + self._device.add_message_listener, self.on_message + ) + + def on_message(self, message): + """Handle new messages which are received from the fan.""" + # Prevent refreshing if not needed + if self._old_value is None or self._old_value != self.state: + _LOGGER.debug("Message received for %s device: %s", self.name, message) + self._old_value = self.state + self.schedule_update_ha_state() + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def name(self): + """Return the name of the Dyson sensor name.""" + return self._name + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return SENSOR_UNITS[self._sensor_type] + + @property + def icon(self): + """Return the icon for this sensor.""" + return SENSOR_ICONS[self._sensor_type] + + @property + def unique_id(self): + """Return the sensor's unique id.""" + return f"{self._device.serial}-{self._sensor_type}" + + +class DysonFilterLifeSensor(DysonSensor): + """Representation of Dyson Filter Life sensor (in hours).""" + + def __init__(self, device): + """Create a new Dyson Filter Life sensor.""" + super().__init__(device, "filter_life") + self._name = f"{self._device.name} Filter Life" + + @property + def state(self): + """Return filter life in hours.""" + if self._device.state: + return int(self._device.state.filter_life) + return None + + +class DysonDustSensor(DysonSensor): + """Representation of Dyson Dust sensor (lower is better).""" + + def __init__(self, device): + """Create a new Dyson Dust sensor.""" + super().__init__(device, "dust") + self._name = f"{self._device.name} Dust" + + @property + def state(self): + """Return Dust value.""" + if self._device.environmental_state: + return self._device.environmental_state.dust + return None + + +class DysonHumiditySensor(DysonSensor): + """Representation of Dyson Humidity sensor.""" + + def __init__(self, device): + """Create a new Dyson Humidity sensor.""" + super().__init__(device, "humidity") + self._name = f"{self._device.name} Humidity" + + @property + def state(self): + """Return Humidity value.""" + if self._device.environmental_state: + if self._device.environmental_state.humidity == 0: + return STATE_OFF + return self._device.environmental_state.humidity + return None + + +class DysonTemperatureSensor(DysonSensor): + """Representation of Dyson Temperature sensor.""" + + def __init__(self, device, unit): + """Create a new Dyson Temperature sensor.""" + super().__init__(device, "temperature") + self._name = f"{self._device.name} Temperature" + self._unit = unit + + @property + def state(self): + """Return Temperature value.""" + if self._device.environmental_state: + temperature_kelvin = self._device.environmental_state.temperature + if temperature_kelvin == 0: + return STATE_OFF + if self._unit == TEMP_CELSIUS: + return float("{0:.1f}".format(temperature_kelvin - 273.15)) + return float("{0:.1f}".format(temperature_kelvin * 9 / 5 - 459.67)) + return None + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self._unit + + +class DysonAirQualitySensor(DysonSensor): + """Representation of Dyson Air Quality sensor (lower is better).""" + + def __init__(self, device): + """Create a new Dyson Air Quality sensor.""" + super().__init__(device, "air_quality") + self._name = f"{self._device.name} AQI" + + @property + def state(self): + """Return Air Quality value.""" + if self._device.environmental_state: + return int(self._device.environmental_state.volatil_organic_compounds) + return None diff --git a/homeassistant/components/dyson/services.yaml b/homeassistant/components/dyson/services.yaml new file mode 100644 index 0000000000000..1b59217f6ab7f --- /dev/null +++ b/homeassistant/components/dyson/services.yaml @@ -0,0 +1,64 @@ +# Describes the format for available fan services + +set_night_mode: + description: Set the fan in night mode. + fields: + entity_id: + description: Name(s) of the entities to enable/disable night mode + example: 'fan.living_room' + night_mode: + description: Night mode status + example: true + +set_auto_mode: + description: Set the fan in auto mode. + fields: + entity_id: + description: Name(s) of the entities to enable/disable auto mode + example: 'fan.living_room' + auto_mode: + description: Auto mode status + example: true + +set_angle: + description: Set the oscillation angle of the selected fan(s). + fields: + entity_id: + description: Name(s) of the entities for which to set the angle + example: 'fan.living_room' + angle_low: + description: The angle at which the oscillation should start + example: 1 + angle_high: + description: The angle at which the oscillation should end + example: 255 + +flow_direction_front: + description: Set the fan flow direction. + fields: + entity_id: + description: Name(s) of the entities to set frontal flow direction for + example: 'fan.living_room' + flow_direction_front: + description: Frontal flow direction + example: true + +set_timer: + description: Set the sleep timer. + fields: + entity_id: + description: Name(s) of the entities to set the sleep timer for + example: 'fan.living_room' + timer: + description: The value in minutes to set the timer to, 0 to disable it + example: 30 + +set_speed: + description: Set the exact speed of the fan. + fields: + entity_id: + description: Name(s) of the entities to set the speed for + example: 'fan.living_room' + dyson_speed: + description: Speed + example: 1 \ No newline at end of file diff --git a/homeassistant/components/dyson/vacuum.py b/homeassistant/components/dyson/vacuum.py new file mode 100644 index 0000000000000..6203b65c9db52 --- /dev/null +++ b/homeassistant/components/dyson/vacuum.py @@ -0,0 +1,194 @@ +"""Support for the Dyson 360 eye vacuum cleaner robot.""" +import logging + +from libpurecool.const import Dyson360EyeMode, PowerMode +from libpurecool.dyson_360_eye import Dyson360Eye + +from homeassistant.components.vacuum import ( + SUPPORT_BATTERY, + SUPPORT_FAN_SPEED, + SUPPORT_PAUSE, + SUPPORT_RETURN_HOME, + SUPPORT_STATUS, + SUPPORT_STOP, + SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, + VacuumDevice, +) +from homeassistant.helpers.icon import icon_for_battery_level + +from . import DYSON_DEVICES + +_LOGGER = logging.getLogger(__name__) + +ATTR_CLEAN_ID = "clean_id" +ATTR_FULL_CLEAN_TYPE = "full_clean_type" +ATTR_POSITION = "position" + +DYSON_360_EYE_DEVICES = "dyson_360_eye_devices" + +SUPPORT_DYSON = ( + SUPPORT_TURN_ON + | SUPPORT_TURN_OFF + | SUPPORT_PAUSE + | SUPPORT_RETURN_HOME + | SUPPORT_FAN_SPEED + | SUPPORT_STATUS + | SUPPORT_BATTERY + | SUPPORT_STOP +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Dyson 360 Eye robot vacuum platform.""" + _LOGGER.debug("Creating new Dyson 360 Eye robot vacuum") + if DYSON_360_EYE_DEVICES not in hass.data: + hass.data[DYSON_360_EYE_DEVICES] = [] + + # Get Dyson Devices from parent component + for device in [d for d in hass.data[DYSON_DEVICES] if isinstance(d, Dyson360Eye)]: + dyson_entity = Dyson360EyeDevice(device) + hass.data[DYSON_360_EYE_DEVICES].append(dyson_entity) + + add_entities(hass.data[DYSON_360_EYE_DEVICES]) + return True + + +class Dyson360EyeDevice(VacuumDevice): + """Dyson 360 Eye robot vacuum device.""" + + def __init__(self, device): + """Dyson 360 Eye robot vacuum device.""" + _LOGGER.debug("Creating device %s", device.name) + self._device = device + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + self.hass.async_add_job(self._device.add_message_listener, self.on_message) + + def on_message(self, message): + """Handle a new messages that was received from the vacuum.""" + _LOGGER.debug("Message received for %s device: %s", self.name, message) + self.schedule_update_ha_state() + + @property + def should_poll(self) -> bool: + """Return True if entity has to be polled for state. + + False if entity pushes its state to HA. + """ + return False + + @property + def name(self): + """Return the name of the device.""" + return self._device.name + + @property + def status(self): + """Return the status of the vacuum cleaner.""" + dyson_labels = { + Dyson360EyeMode.INACTIVE_CHARGING: "Stopped - Charging", + Dyson360EyeMode.INACTIVE_CHARGED: "Stopped - Charged", + Dyson360EyeMode.FULL_CLEAN_PAUSED: "Paused", + Dyson360EyeMode.FULL_CLEAN_RUNNING: "Cleaning", + Dyson360EyeMode.FULL_CLEAN_ABORTED: "Returning home", + Dyson360EyeMode.FULL_CLEAN_INITIATED: "Start cleaning", + Dyson360EyeMode.FAULT_USER_RECOVERABLE: "Error - device blocked", + Dyson360EyeMode.FAULT_REPLACE_ON_DOCK: "Error - Replace device on dock", + Dyson360EyeMode.FULL_CLEAN_FINISHED: "Finished", + Dyson360EyeMode.FULL_CLEAN_NEEDS_CHARGE: "Need charging", + } + return dyson_labels.get(self._device.state.state, self._device.state.state) + + @property + def battery_level(self): + """Return the battery level of the vacuum cleaner.""" + return self._device.state.battery_level + + @property + def fan_speed(self): + """Return the fan speed of the vacuum cleaner.""" + speed_labels = {PowerMode.MAX: "Max", PowerMode.QUIET: "Quiet"} + return speed_labels[self._device.state.power_mode] + + @property + def fan_speed_list(self): + """Get the list of available fan speed steps of the vacuum cleaner.""" + return ["Quiet", "Max"] + + @property + def device_state_attributes(self): + """Return the specific state attributes of this vacuum cleaner.""" + return {ATTR_POSITION: str(self._device.state.position)} + + @property + def is_on(self) -> bool: + """Return True if entity is on.""" + return self._device.state.state in [ + Dyson360EyeMode.FULL_CLEAN_INITIATED, + Dyson360EyeMode.FULL_CLEAN_ABORTED, + Dyson360EyeMode.FULL_CLEAN_RUNNING, + ] + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return True + + @property + def supported_features(self): + """Flag vacuum cleaner robot features that are supported.""" + return SUPPORT_DYSON + + @property + def battery_icon(self): + """Return the battery icon for the vacuum cleaner.""" + charging = self._device.state.state in [Dyson360EyeMode.INACTIVE_CHARGING] + return icon_for_battery_level( + battery_level=self.battery_level, charging=charging + ) + + def turn_on(self, **kwargs): + """Turn the vacuum on.""" + _LOGGER.debug("Turn on device %s", self.name) + if self._device.state.state in [Dyson360EyeMode.FULL_CLEAN_PAUSED]: + self._device.resume() + else: + self._device.start() + + def turn_off(self, **kwargs): + """Turn the vacuum off and return to home.""" + _LOGGER.debug("Turn off device %s", self.name) + self._device.pause() + + def stop(self, **kwargs): + """Stop the vacuum cleaner.""" + _LOGGER.debug("Stop device %s", self.name) + self._device.pause() + + def set_fan_speed(self, fan_speed, **kwargs): + """Set fan speed.""" + _LOGGER.debug("Set fan speed %s on device %s", fan_speed, self.name) + power_modes = {"Quiet": PowerMode.QUIET, "Max": PowerMode.MAX} + self._device.set_power_mode(power_modes[fan_speed]) + + def start_pause(self, **kwargs): + """Start, pause or resume the cleaning task.""" + if self._device.state.state in [Dyson360EyeMode.FULL_CLEAN_PAUSED]: + _LOGGER.debug("Resume device %s", self.name) + self._device.resume() + elif self._device.state.state in [ + Dyson360EyeMode.INACTIVE_CHARGED, + Dyson360EyeMode.INACTIVE_CHARGING, + ]: + _LOGGER.debug("Start device %s", self.name) + self._device.start() + else: + _LOGGER.debug("Pause device %s", self.name) + self._device.pause() + + def return_to_base(self, **kwargs): + """Set the vacuum cleaner to return to the dock.""" + _LOGGER.debug("Return to base device %s", self.name) + self._device.abort() diff --git a/homeassistant/components/ebox/__init__.py b/homeassistant/components/ebox/__init__.py new file mode 100644 index 0000000000000..3f807666a4bac --- /dev/null +++ b/homeassistant/components/ebox/__init__.py @@ -0,0 +1 @@ +"""The ebox component.""" diff --git a/homeassistant/components/ebox/manifest.json b/homeassistant/components/ebox/manifest.json new file mode 100644 index 0000000000000..706bca862dfdc --- /dev/null +++ b/homeassistant/components/ebox/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "ebox", + "name": "EBox", + "documentation": "https://www.home-assistant.io/integrations/ebox", + "requirements": ["pyebox==1.1.4"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/ebox/sensor.py b/homeassistant/components/ebox/sensor.py new file mode 100644 index 0000000000000..55504e8edf7e5 --- /dev/null +++ b/homeassistant/components/ebox/sensor.py @@ -0,0 +1,149 @@ +""" +Support for EBox. + +Get data from 'My Usage Page' page: https://client.ebox.ca/myusage + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.ebox/ +""" +from datetime import timedelta +import logging + +from pyebox import EboxClient +from pyebox.client import PyEboxError +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_MONITORED_VARIABLES, + CONF_NAME, + CONF_PASSWORD, + CONF_USERNAME, +) +from homeassistant.exceptions import PlatformNotReady +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +_LOGGER = logging.getLogger(__name__) + +GIGABITS = "Gb" +PRICE = "CAD" +DAYS = "days" +PERCENT = "%" + +DEFAULT_NAME = "EBox" + +REQUESTS_TIMEOUT = 15 +SCAN_INTERVAL = timedelta(minutes=15) +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) + +SENSOR_TYPES = { + "usage": ["Usage", PERCENT, "mdi:percent"], + "balance": ["Balance", PRICE, "mdi:square-inc-cash"], + "limit": ["Data limit", GIGABITS, "mdi:download"], + "days_left": ["Days left", DAYS, "mdi:calendar-today"], + "before_offpeak_download": ["Download before offpeak", GIGABITS, "mdi:download"], + "before_offpeak_upload": ["Upload before offpeak", GIGABITS, "mdi:upload"], + "before_offpeak_total": ["Total before offpeak", GIGABITS, "mdi:download"], + "offpeak_download": ["Offpeak download", GIGABITS, "mdi:download"], + "offpeak_upload": ["Offpeak Upload", GIGABITS, "mdi:upload"], + "offpeak_total": ["Offpeak Total", GIGABITS, "mdi:download"], + "download": ["Download", GIGABITS, "mdi:download"], + "upload": ["Upload", GIGABITS, "mdi:upload"], + "total": ["Total", GIGABITS, "mdi:download"], +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_MONITORED_VARIABLES): vol.All( + cv.ensure_list, [vol.In(SENSOR_TYPES)] + ), + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): 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 EBox sensor.""" + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + + httpsession = hass.helpers.aiohttp_client.async_get_clientsession() + ebox_data = EBoxData(username, password, httpsession) + + name = config.get(CONF_NAME) + + try: + await ebox_data.async_update() + except PyEboxError as exp: + _LOGGER.error("Failed login: %s", exp) + raise PlatformNotReady + + sensors = [] + for variable in config[CONF_MONITORED_VARIABLES]: + sensors.append(EBoxSensor(ebox_data, variable, name)) + + async_add_entities(sensors, True) + + +class EBoxSensor(Entity): + """Implementation of a EBox sensor.""" + + def __init__(self, ebox_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.ebox_data = ebox_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 + + async def async_update(self): + """Get the latest data from EBox and update the state.""" + await self.ebox_data.async_update() + if self.type in self.ebox_data.data: + self._state = round(self.ebox_data.data[self.type], 2) + + +class EBoxData: + """Get data from Ebox.""" + + def __init__(self, username, password, httpsession): + """Initialize the data object.""" + self.client = EboxClient(username, password, REQUESTS_TIMEOUT, httpsession) + self.data = {} + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + async def async_update(self): + """Get the latest data from Ebox.""" + try: + await self.client.fetch_data() + except PyEboxError as exp: + _LOGGER.error("Error on receive last EBox data: %s", exp) + return + # Update data + self.data = self.client.get_data() diff --git a/homeassistant/components/ebusd/.translations/bg.json b/homeassistant/components/ebusd/.translations/bg.json new file mode 100644 index 0000000000000..f188fe09a48f9 --- /dev/null +++ b/homeassistant/components/ebusd/.translations/bg.json @@ -0,0 +1,6 @@ +{ + "state": { + "day": "\u0414\u0435\u043d", + "night": "\u041d\u043e\u0449" + } +} \ No newline at end of file diff --git a/homeassistant/components/ebusd/.translations/ca.json b/homeassistant/components/ebusd/.translations/ca.json new file mode 100644 index 0000000000000..88b76539deb80 --- /dev/null +++ b/homeassistant/components/ebusd/.translations/ca.json @@ -0,0 +1,6 @@ +{ + "state": { + "day": "Dia", + "night": "Nit" + } +} \ No newline at end of file diff --git a/homeassistant/components/ebusd/.translations/cs.json b/homeassistant/components/ebusd/.translations/cs.json new file mode 100644 index 0000000000000..3ac4bf1cfa4c5 --- /dev/null +++ b/homeassistant/components/ebusd/.translations/cs.json @@ -0,0 +1,6 @@ +{ + "state": { + "day": "Den", + "night": "Noc" + } +} \ No newline at end of file diff --git a/homeassistant/components/ebusd/.translations/da.json b/homeassistant/components/ebusd/.translations/da.json new file mode 100644 index 0000000000000..00b499e2a39d2 --- /dev/null +++ b/homeassistant/components/ebusd/.translations/da.json @@ -0,0 +1,6 @@ +{ + "state": { + "day": "Dag", + "night": "Nat" + } +} \ No newline at end of file diff --git a/homeassistant/components/ebusd/.translations/de.json b/homeassistant/components/ebusd/.translations/de.json new file mode 100644 index 0000000000000..347c6e6eeb56a --- /dev/null +++ b/homeassistant/components/ebusd/.translations/de.json @@ -0,0 +1,6 @@ +{ + "state": { + "day": "Tag", + "night": "Nacht" + } +} \ No newline at end of file diff --git a/homeassistant/components/ebusd/.translations/ebusd.en.json b/homeassistant/components/ebusd/.translations/ebusd.en.json new file mode 100644 index 0000000000000..16ab79fc58263 --- /dev/null +++ b/homeassistant/components/ebusd/.translations/ebusd.en.json @@ -0,0 +1,7 @@ +{ + "state": { + "day": "Day", + "night": "Night", + "auto": "Automatic" + } +} \ No newline at end of file diff --git a/homeassistant/components/ebusd/.translations/ebusd.it.json b/homeassistant/components/ebusd/.translations/ebusd.it.json new file mode 100644 index 0000000000000..d0b95daaafa99 --- /dev/null +++ b/homeassistant/components/ebusd/.translations/ebusd.it.json @@ -0,0 +1,7 @@ +{ + "state": { + "day": "Giorno", + "night": "Notte", + "auto": "Automatico" + } +} \ No newline at end of file diff --git a/homeassistant/components/ebusd/.translations/en.json b/homeassistant/components/ebusd/.translations/en.json new file mode 100644 index 0000000000000..abe5fff8fec88 --- /dev/null +++ b/homeassistant/components/ebusd/.translations/en.json @@ -0,0 +1,6 @@ +{ + "state": { + "day": "Day", + "night": "Night" + } +} \ No newline at end of file diff --git a/homeassistant/components/ebusd/.translations/es-419.json b/homeassistant/components/ebusd/.translations/es-419.json new file mode 100644 index 0000000000000..7a6291e3f17e2 --- /dev/null +++ b/homeassistant/components/ebusd/.translations/es-419.json @@ -0,0 +1,6 @@ +{ + "state": { + "day": "D\u00eda", + "night": "Noche" + } +} \ No newline at end of file diff --git a/homeassistant/components/ebusd/.translations/es.json b/homeassistant/components/ebusd/.translations/es.json new file mode 100644 index 0000000000000..7a6291e3f17e2 --- /dev/null +++ b/homeassistant/components/ebusd/.translations/es.json @@ -0,0 +1,6 @@ +{ + "state": { + "day": "D\u00eda", + "night": "Noche" + } +} \ No newline at end of file diff --git a/homeassistant/components/ebusd/.translations/fr.json b/homeassistant/components/ebusd/.translations/fr.json new file mode 100644 index 0000000000000..66a79f926a3dc --- /dev/null +++ b/homeassistant/components/ebusd/.translations/fr.json @@ -0,0 +1,6 @@ +{ + "state": { + "day": "journ\u00e9e", + "night": "Nuit" + } +} \ No newline at end of file diff --git a/homeassistant/components/ebusd/.translations/he.json b/homeassistant/components/ebusd/.translations/he.json new file mode 100644 index 0000000000000..0232fc3044d37 --- /dev/null +++ b/homeassistant/components/ebusd/.translations/he.json @@ -0,0 +1,6 @@ +{ + "state": { + "day": "\u05d9\u05d5\u05dd", + "night": "\u05dc\u05d9\u05dc\u05d4" + } +} \ No newline at end of file diff --git a/homeassistant/components/ebusd/.translations/hu.json b/homeassistant/components/ebusd/.translations/hu.json new file mode 100644 index 0000000000000..a5ab8f0d194e1 --- /dev/null +++ b/homeassistant/components/ebusd/.translations/hu.json @@ -0,0 +1,6 @@ +{ + "state": { + "day": "Nappal", + "night": "\u00c9jszaka" + } +} \ No newline at end of file diff --git a/homeassistant/components/ebusd/.translations/it.json b/homeassistant/components/ebusd/.translations/it.json new file mode 100644 index 0000000000000..dd70cfd2c6e2b --- /dev/null +++ b/homeassistant/components/ebusd/.translations/it.json @@ -0,0 +1,6 @@ +{ + "state": { + "day": "Giorno", + "night": "Notte" + } +} \ No newline at end of file diff --git a/homeassistant/components/ebusd/.translations/ko.json b/homeassistant/components/ebusd/.translations/ko.json new file mode 100644 index 0000000000000..5a302af79e1fc --- /dev/null +++ b/homeassistant/components/ebusd/.translations/ko.json @@ -0,0 +1,6 @@ +{ + "state": { + "day": "\uc8fc\uac04", + "night": "\uc57c\uac04" + } +} \ No newline at end of file diff --git a/homeassistant/components/ebusd/.translations/lb.json b/homeassistant/components/ebusd/.translations/lb.json new file mode 100644 index 0000000000000..624744de470d3 --- /dev/null +++ b/homeassistant/components/ebusd/.translations/lb.json @@ -0,0 +1,6 @@ +{ + "state": { + "day": "Dag", + "night": "Nuecht" + } +} \ No newline at end of file diff --git a/homeassistant/components/ebusd/.translations/nl.json b/homeassistant/components/ebusd/.translations/nl.json new file mode 100644 index 0000000000000..db4627790fd93 --- /dev/null +++ b/homeassistant/components/ebusd/.translations/nl.json @@ -0,0 +1,6 @@ +{ + "state": { + "day": "Dag", + "night": "Nacht" + } +} \ No newline at end of file diff --git a/homeassistant/components/ebusd/.translations/no.json b/homeassistant/components/ebusd/.translations/no.json new file mode 100644 index 0000000000000..92f4355066dd9 --- /dev/null +++ b/homeassistant/components/ebusd/.translations/no.json @@ -0,0 +1,6 @@ +{ + "state": { + "day": "Dag", + "night": "Natt" + } +} \ No newline at end of file diff --git a/homeassistant/components/ebusd/.translations/pl.json b/homeassistant/components/ebusd/.translations/pl.json new file mode 100644 index 0000000000000..0c926a0335cd1 --- /dev/null +++ b/homeassistant/components/ebusd/.translations/pl.json @@ -0,0 +1,6 @@ +{ + "state": { + "day": "Dzie\u0144", + "night": "Noc" + } +} \ No newline at end of file diff --git a/homeassistant/components/ebusd/.translations/pt-BR.json b/homeassistant/components/ebusd/.translations/pt-BR.json new file mode 100644 index 0000000000000..9925fdfab9cc3 --- /dev/null +++ b/homeassistant/components/ebusd/.translations/pt-BR.json @@ -0,0 +1,6 @@ +{ + "state": { + "day": "Dia", + "night": "Noite" + } +} \ No newline at end of file diff --git a/homeassistant/components/ebusd/.translations/pt.json b/homeassistant/components/ebusd/.translations/pt.json new file mode 100644 index 0000000000000..9925fdfab9cc3 --- /dev/null +++ b/homeassistant/components/ebusd/.translations/pt.json @@ -0,0 +1,6 @@ +{ + "state": { + "day": "Dia", + "night": "Noite" + } +} \ No newline at end of file diff --git a/homeassistant/components/ebusd/.translations/ru.json b/homeassistant/components/ebusd/.translations/ru.json new file mode 100644 index 0000000000000..7b013a4d7bcd1 --- /dev/null +++ b/homeassistant/components/ebusd/.translations/ru.json @@ -0,0 +1,6 @@ +{ + "state": { + "day": "\u0414\u0435\u043d\u044c", + "night": "\u041d\u043e\u0447\u044c" + } +} \ No newline at end of file diff --git a/homeassistant/components/ebusd/.translations/sl.json b/homeassistant/components/ebusd/.translations/sl.json new file mode 100644 index 0000000000000..de2ca81f8a8e3 --- /dev/null +++ b/homeassistant/components/ebusd/.translations/sl.json @@ -0,0 +1,6 @@ +{ + "state": { + "day": "Dan", + "night": "No\u010d" + } +} \ No newline at end of file diff --git a/homeassistant/components/ebusd/.translations/sv.json b/homeassistant/components/ebusd/.translations/sv.json new file mode 100644 index 0000000000000..92f4355066dd9 --- /dev/null +++ b/homeassistant/components/ebusd/.translations/sv.json @@ -0,0 +1,6 @@ +{ + "state": { + "day": "Dag", + "night": "Natt" + } +} \ No newline at end of file diff --git a/homeassistant/components/ebusd/.translations/th.json b/homeassistant/components/ebusd/.translations/th.json new file mode 100644 index 0000000000000..0f12574d8b96a --- /dev/null +++ b/homeassistant/components/ebusd/.translations/th.json @@ -0,0 +1,6 @@ +{ + "state": { + "day": "\u0e27\u0e31\u0e19", + "night": "\u0e01\u0e25\u0e32\u0e07\u0e04\u0e37\u0e19" + } +} \ No newline at end of file diff --git a/homeassistant/components/ebusd/.translations/uk.json b/homeassistant/components/ebusd/.translations/uk.json new file mode 100644 index 0000000000000..2e7a22e49a379 --- /dev/null +++ b/homeassistant/components/ebusd/.translations/uk.json @@ -0,0 +1,6 @@ +{ + "state": { + "day": "\u0414\u0435\u043d\u044c", + "night": "\u041d\u0456\u0447" + } +} \ No newline at end of file diff --git a/homeassistant/components/ebusd/.translations/zh-Hans.json b/homeassistant/components/ebusd/.translations/zh-Hans.json new file mode 100644 index 0000000000000..c43ca27b22a09 --- /dev/null +++ b/homeassistant/components/ebusd/.translations/zh-Hans.json @@ -0,0 +1,6 @@ +{ + "state": { + "day": "\u65e5", + "night": "\u591c" + } +} \ No newline at end of file diff --git a/homeassistant/components/ebusd/.translations/zh-Hant.json b/homeassistant/components/ebusd/.translations/zh-Hant.json new file mode 100644 index 0000000000000..1d2851acb6b6f --- /dev/null +++ b/homeassistant/components/ebusd/.translations/zh-Hant.json @@ -0,0 +1,6 @@ +{ + "state": { + "day": "\u767d\u5929", + "night": "\u591c\u665a" + } +} \ No newline at end of file diff --git a/homeassistant/components/ebusd/__init__.py b/homeassistant/components/ebusd/__init__.py new file mode 100644 index 0000000000000..eafa42ba22aa7 --- /dev/null +++ b/homeassistant/components/ebusd/__init__.py @@ -0,0 +1,127 @@ +"""Support for Ebusd daemon for communication with eBUS heating systems.""" +from datetime import timedelta +import logging +import socket + +import ebusdpy +import voluptuous as vol + +from homeassistant.const import ( + CONF_HOST, + CONF_MONITORED_CONDITIONS, + CONF_NAME, + CONF_PORT, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.discovery import load_platform +from homeassistant.util import Throttle + +from .const import DOMAIN, SENSOR_TYPES + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = "ebusd" +DEFAULT_PORT = 8888 +CONF_CIRCUIT = "circuit" +CACHE_TTL = 900 +SERVICE_EBUSD_WRITE = "ebusd_write" + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=15) + + +def verify_ebusd_config(config): + """Verify eBusd config.""" + circuit = config[CONF_CIRCUIT] + for condition in config[CONF_MONITORED_CONDITIONS]: + if condition not in SENSOR_TYPES[circuit]: + raise vol.Invalid(f"Condition '{condition}' not in '{circuit}'.") + return config + + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + vol.All( + { + vol.Required(CONF_CIRCUIT): cv.string, + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_MONITORED_CONDITIONS, default=[]): cv.ensure_list, + }, + verify_ebusd_config, + ) + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +def setup(hass, config): + """Set up the eBusd component.""" + conf = config[DOMAIN] + name = conf[CONF_NAME] + circuit = conf[CONF_CIRCUIT] + monitored_conditions = conf.get(CONF_MONITORED_CONDITIONS) + server_address = (conf.get(CONF_HOST), conf.get(CONF_PORT)) + + try: + _LOGGER.debug("Ebusd integration setup started") + + ebusdpy.init(server_address) + hass.data[DOMAIN] = EbusdData(server_address, circuit) + + sensor_config = { + CONF_MONITORED_CONDITIONS: monitored_conditions, + "client_name": name, + "sensor_types": SENSOR_TYPES[circuit], + } + load_platform(hass, "sensor", DOMAIN, sensor_config, config) + + hass.services.register(DOMAIN, SERVICE_EBUSD_WRITE, hass.data[DOMAIN].write) + + _LOGGER.debug("Ebusd integration setup completed") + return True + except (socket.timeout, socket.error): + return False + + +class EbusdData: + """Get the latest data from Ebusd.""" + + def __init__(self, address, circuit): + """Initialize the data object.""" + self._circuit = circuit + self._address = address + self.value = {} + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self, name, stype): + """Call the Ebusd API to update the data.""" + try: + _LOGGER.debug("Opening socket to ebusd %s", name) + command_result = ebusdpy.read( + self._address, self._circuit, name, stype, CACHE_TTL + ) + if command_result is not None: + if "ERR:" in command_result: + _LOGGER.warning(command_result) + else: + self.value[name] = command_result + except RuntimeError as err: + _LOGGER.error(err) + raise RuntimeError(err) + + def write(self, call): + """Call write methon on ebusd.""" + name = call.data.get("name") + value = call.data.get("value") + + try: + _LOGGER.debug("Opening socket to ebusd %s", name) + command_result = ebusdpy.write(self._address, self._circuit, name, value) + if command_result is not None: + if "done" not in command_result: + _LOGGER.warning("Write command failed: %s", name) + except RuntimeError as err: + _LOGGER.error(err) diff --git a/homeassistant/components/ebusd/const.py b/homeassistant/components/ebusd/const.py new file mode 100644 index 0000000000000..ec097a153c97d --- /dev/null +++ b/homeassistant/components/ebusd/const.py @@ -0,0 +1,134 @@ +"""Constants for ebus component.""" +from homeassistant.const import ENERGY_KILO_WATT_HOUR, PRESSURE_BAR, TEMP_CELSIUS + +DOMAIN = "ebusd" +TIME_SECONDS = "seconds" + +# SensorTypes from ebusdpy module : +# 0='decimal', 1='time-schedule', 2='switch', 3='string', 4='value;status' + +SENSOR_TYPES = { + "700": { + "ActualFlowTemperatureDesired": [ + "Hc1ActualFlowTempDesired", + TEMP_CELSIUS, + "mdi:thermometer", + 0, + ], + "MaxFlowTemperatureDesired": [ + "Hc1MaxFlowTempDesired", + TEMP_CELSIUS, + "mdi:thermometer", + 0, + ], + "MinFlowTemperatureDesired": [ + "Hc1MinFlowTempDesired", + TEMP_CELSIUS, + "mdi:thermometer", + 0, + ], + "PumpStatus": ["Hc1PumpStatus", None, "mdi:toggle-switch", 2], + "HCSummerTemperatureLimit": [ + "Hc1SummerTempLimit", + TEMP_CELSIUS, + "mdi:weather-sunny", + 0, + ], + "HolidayTemperature": ["HolidayTemp", TEMP_CELSIUS, "mdi:thermometer", 0], + "HWTemperatureDesired": ["HwcTempDesired", TEMP_CELSIUS, "mdi:thermometer", 0], + "HWTimerMonday": ["hwcTimer.Monday", None, "mdi:timer", 1], + "HWTimerTuesday": ["hwcTimer.Tuesday", None, "mdi:timer", 1], + "HWTimerWednesday": ["hwcTimer.Wednesday", None, "mdi:timer", 1], + "HWTimerThursday": ["hwcTimer.Thursday", None, "mdi:timer", 1], + "HWTimerFriday": ["hwcTimer.Friday", None, "mdi:timer", 1], + "HWTimerSaturday": ["hwcTimer.Saturday", None, "mdi:timer", 1], + "HWTimerSunday": ["hwcTimer.Sunday", None, "mdi:timer", 1], + "WaterPressure": ["WaterPressure", PRESSURE_BAR, "mdi:water-pump", 0], + "Zone1RoomZoneMapping": ["z1RoomZoneMapping", None, "mdi:label", 0], + "Zone1NightTemperature": ["z1NightTemp", TEMP_CELSIUS, "mdi:weather-night", 0], + "Zone1DayTemperature": ["z1DayTemp", TEMP_CELSIUS, "mdi:weather-sunny", 0], + "Zone1HolidayTemperature": [ + "z1HolidayTemp", + TEMP_CELSIUS, + "mdi:thermometer", + 0, + ], + "Zone1RoomTemperature": ["z1RoomTemp", TEMP_CELSIUS, "mdi:thermometer", 0], + "Zone1ActualRoomTemperatureDesired": [ + "z1ActualRoomTempDesired", + TEMP_CELSIUS, + "mdi:thermometer", + 0, + ], + "Zone1TimerMonday": ["z1Timer.Monday", None, "mdi:timer", 1], + "Zone1TimerTuesday": ["z1Timer.Tuesday", None, "mdi:timer", 1], + "Zone1TimerWednesday": ["z1Timer.Wednesday", None, "mdi:timer", 1], + "Zone1TimerThursday": ["z1Timer.Thursday", None, "mdi:timer", 1], + "Zone1TimerFriday": ["z1Timer.Friday", None, "mdi:timer", 1], + "Zone1TimerSaturday": ["z1Timer.Saturday", None, "mdi:timer", 1], + "Zone1TimerSunday": ["z1Timer.Sunday", None, "mdi:timer", 1], + "Zone1OperativeMode": ["z1OpMode", None, "mdi:math-compass", 3], + "ContinuosHeating": ["ContinuosHeating", TEMP_CELSIUS, "mdi:weather-snowy", 0], + "PowerEnergyConsumptionLastMonth": [ + "PrEnergySumHcLastMonth", + ENERGY_KILO_WATT_HOUR, + "mdi:flash", + 0, + ], + "PowerEnergyConsumptionThisMonth": [ + "PrEnergySumHcThisMonth", + ENERGY_KILO_WATT_HOUR, + "mdi:flash", + 0, + ], + }, + "ehp": { + "HWTemperature": ["HwcTemp", TEMP_CELSIUS, "mdi:thermometer", 4], + "OutsideTemp": ["OutsideTemp", TEMP_CELSIUS, "mdi:thermometer", 4], + }, + "bai": { + "HotWaterTemperature": ["HwcTemp", TEMP_CELSIUS, "mdi:thermometer", 4], + "StorageTemperature": ["StorageTemp", TEMP_CELSIUS, "mdi:thermometer", 4], + "DesiredStorageTemperature": [ + "StorageTempDesired", + TEMP_CELSIUS, + "mdi:thermometer", + 0, + ], + "OutdoorsTemperature": [ + "OutdoorstempSensor", + TEMP_CELSIUS, + "mdi:thermometer", + 4, + ], + "WaterPreasure": ["WaterPressure", PRESSURE_BAR, "mdi:pipe", 4], + "AverageIgnitionTime": ["averageIgnitiontime", TIME_SECONDS, "mdi:av-timer", 0], + "MaximumIgnitionTime": ["maxIgnitiontime", TIME_SECONDS, "mdi:av-timer", 0], + "MinimumIgnitionTime": ["minIgnitiontime", TIME_SECONDS, "mdi:av-timer", 0], + "ReturnTemperature": ["ReturnTemp", TEMP_CELSIUS, "mdi:thermometer", 4], + "CentralHeatingPump": ["WP", None, "mdi:toggle-switch", 2], + "HeatingSwitch": ["HeatingSwitch", None, "mdi:toggle-switch", 2], + "DesiredFlowTemperature": [ + "FlowTempDesired", + TEMP_CELSIUS, + "mdi:thermometer", + 0, + ], + "FlowTemperature": ["FlowTemp", TEMP_CELSIUS, "mdi:thermometer", 4], + "Flame": ["Flame", None, "mdi:toggle-switch", 2], + "PowerEnergyConsumptionHeatingCircuit": [ + "PrEnergySumHc1", + ENERGY_KILO_WATT_HOUR, + "mdi:flash", + 0, + ], + "PowerEnergyConsumptionHotWaterCircuit": [ + "PrEnergySumHwc1", + ENERGY_KILO_WATT_HOUR, + "mdi:flash", + 0, + ], + "RoomThermostat": ["DCRoomthermostat", None, "mdi:toggle-switch", 2], + "HeatingPartLoad": ["PartloadHcKW", ENERGY_KILO_WATT_HOUR, "mdi:flash", 0], + }, +} diff --git a/homeassistant/components/ebusd/manifest.json b/homeassistant/components/ebusd/manifest.json new file mode 100644 index 0000000000000..dc3f34e9ed93d --- /dev/null +++ b/homeassistant/components/ebusd/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "ebusd", + "name": "ebusd", + "documentation": "https://www.home-assistant.io/integrations/ebusd", + "requirements": ["ebusdpy==0.0.16"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/ebusd/sensor.py b/homeassistant/components/ebusd/sensor.py new file mode 100644 index 0000000000000..63f72a89ccd8a --- /dev/null +++ b/homeassistant/components/ebusd/sensor.py @@ -0,0 +1,97 @@ +"""Support for Ebusd sensors.""" +import datetime +import logging + +from homeassistant.helpers.entity import Entity +import homeassistant.util.dt as dt_util + +from .const import DOMAIN + +TIME_FRAME1_BEGIN = "time_frame1_begin" +TIME_FRAME1_END = "time_frame1_end" +TIME_FRAME2_BEGIN = "time_frame2_begin" +TIME_FRAME2_END = "time_frame2_end" +TIME_FRAME3_BEGIN = "time_frame3_begin" +TIME_FRAME3_END = "time_frame3_end" + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Ebus sensor.""" + ebusd_api = hass.data[DOMAIN] + monitored_conditions = discovery_info["monitored_conditions"] + name = discovery_info["client_name"] + + dev = [] + for condition in monitored_conditions: + dev.append( + EbusdSensor(ebusd_api, discovery_info["sensor_types"][condition], name) + ) + + add_entities(dev, True) + + +class EbusdSensor(Entity): + """Ebusd component sensor methods definition.""" + + def __init__(self, data, sensor, name): + """Initialize the sensor.""" + self._state = None + self._client_name = name + self._name, self._unit_of_measurement, self._icon, self._type = sensor + self.data = data + + @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 device_state_attributes(self): + """Return the device state attributes.""" + if self._type == 1 and self._state is not None: + schedule = { + TIME_FRAME1_BEGIN: None, + TIME_FRAME1_END: None, + TIME_FRAME2_BEGIN: None, + TIME_FRAME2_END: None, + TIME_FRAME3_BEGIN: None, + TIME_FRAME3_END: None, + } + time_frame = self._state.split(";") + for index, item in enumerate(sorted(schedule.items())): + if index < len(time_frame): + parsed = datetime.datetime.strptime(time_frame[index], "%H:%M") + parsed = parsed.replace( + dt_util.now().year, dt_util.now().month, dt_util.now().day + ) + schedule[item[0]] = parsed.isoformat() + return schedule + return None + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return self._icon + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit_of_measurement + + def update(self): + """Fetch new state data for the sensor.""" + try: + self.data.update(self._name, self._type) + if self._name not in self.data.value: + return + + self._state = self.data.value[self._name] + except RuntimeError: + _LOGGER.debug("EbusdData.update exception") diff --git a/homeassistant/components/ebusd/services.yaml b/homeassistant/components/ebusd/services.yaml new file mode 100644 index 0000000000000..0f64533f7f156 --- /dev/null +++ b/homeassistant/components/ebusd/services.yaml @@ -0,0 +1,6 @@ +write: + description: Call ebusd write command. + fields: + call: + description: Property name and value to set + example: '{"name": "Hc1MaxFlowTempDesired", "value": 21}' \ No newline at end of file diff --git a/homeassistant/components/ebusd/strings.json b/homeassistant/components/ebusd/strings.json new file mode 100644 index 0000000000000..ee62df8ddad5f --- /dev/null +++ b/homeassistant/components/ebusd/strings.json @@ -0,0 +1,6 @@ +{ + "state": { + "day": "Day", + "night": "Night" + } +} \ No newline at end of file diff --git a/homeassistant/components/ecoal_boiler/__init__.py b/homeassistant/components/ecoal_boiler/__init__.py new file mode 100644 index 0000000000000..b0ca7aec5ccde --- /dev/null +++ b/homeassistant/components/ecoal_boiler/__init__.py @@ -0,0 +1,107 @@ +"""Support to control ecoal/esterownik.pl coal/wood boiler controller.""" +import logging + +from ecoaliface.simple import ECoalController +import voluptuous as vol + +from homeassistant.const import ( + CONF_HOST, + CONF_MONITORED_CONDITIONS, + CONF_PASSWORD, + CONF_SENSORS, + CONF_SWITCHES, + CONF_USERNAME, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.discovery import load_platform + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "ecoal_boiler" +DATA_ECOAL_BOILER = f"data_{DOMAIN}" + +DEFAULT_USERNAME = "admin" +DEFAULT_PASSWORD = "admin" + + +# Available pump ids with assigned HA names +# Available as switches +AVAILABLE_PUMPS = { + "central_heating_pump": "Central heating pump", + "central_heating_pump2": "Central heating pump2", + "domestic_hot_water_pump": "Domestic hot water pump", +} + +# Available temp sensor ids with assigned HA names +# Available as sensors +AVAILABLE_SENSORS = { + "outdoor_temp": "Outdoor temperature", + "indoor_temp": "Indoor temperature", + "indoor2_temp": "Indoor temperature 2", + "domestic_hot_water_temp": "Domestic hot water temperature", + "target_domestic_hot_water_temp": "Target hot water temperature", + "feedwater_in_temp": "Feedwater input temperature", + "feedwater_out_temp": "Feedwater output temperature", + "target_feedwater_temp": "Target feedwater temperature", + "fuel_feeder_temp": "Fuel feeder temperature", + "exhaust_temp": "Exhaust temperature", +} + +SWITCH_SCHEMA = vol.Schema( + { + vol.Optional(CONF_MONITORED_CONDITIONS, default=list(AVAILABLE_PUMPS)): vol.All( + cv.ensure_list, [vol.In(AVAILABLE_PUMPS)] + ) + } +) + +SENSOR_SCHEMA = vol.Schema( + { + vol.Optional( + CONF_MONITORED_CONDITIONS, default=list(AVAILABLE_SENSORS) + ): vol.All(cv.ensure_list, [vol.In(AVAILABLE_SENSORS)]) + } +) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, + vol.Optional(CONF_SENSORS, default={}): SENSOR_SCHEMA, + vol.Optional(CONF_SWITCHES, default={}): SWITCH_SCHEMA, + vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +def setup(hass, hass_config): + """Set up global ECoalController instance same for sensors and switches.""" + + conf = hass_config[DOMAIN] + host = conf[CONF_HOST] + username = conf[CONF_USERNAME] + passwd = conf[CONF_PASSWORD] + # Creating ECoalController instance makes HTTP request to controller. + ecoal_contr = ECoalController(host, username, passwd) + if ecoal_contr.version is None: + # Wrong credentials nor network config + _LOGGER.error( + "Unable to read controller status from %s@%s (wrong host/credentials)", + username, + host, + ) + return False + _LOGGER.debug("Detected controller version: %r @%s", ecoal_contr.version, host) + hass.data[DATA_ECOAL_BOILER] = ecoal_contr + # Setup switches + switches = conf[CONF_SWITCHES][CONF_MONITORED_CONDITIONS] + load_platform(hass, "switch", DOMAIN, switches, hass_config) + # Setup temp sensors + sensors = conf[CONF_SENSORS][CONF_MONITORED_CONDITIONS] + load_platform(hass, "sensor", DOMAIN, sensors, hass_config) + return True diff --git a/homeassistant/components/ecoal_boiler/manifest.json b/homeassistant/components/ecoal_boiler/manifest.json new file mode 100644 index 0000000000000..11820f781d774 --- /dev/null +++ b/homeassistant/components/ecoal_boiler/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "ecoal_boiler", + "name": "eSterownik eCoal.pl Boiler", + "documentation": "https://www.home-assistant.io/integrations/ecoal_boiler", + "requirements": ["ecoaliface==0.4.0"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/ecoal_boiler/sensor.py b/homeassistant/components/ecoal_boiler/sensor.py new file mode 100644 index 0000000000000..f1998dd5b2e3d --- /dev/null +++ b/homeassistant/components/ecoal_boiler/sensor.py @@ -0,0 +1,56 @@ +"""Allows reading temperatures from ecoal/esterownik.pl controller.""" +import logging + +from homeassistant.const import TEMP_CELSIUS +from homeassistant.helpers.entity import Entity + +from . import AVAILABLE_SENSORS, DATA_ECOAL_BOILER + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the ecoal sensors.""" + if discovery_info is None: + return + devices = [] + ecoal_contr = hass.data[DATA_ECOAL_BOILER] + for sensor_id in discovery_info: + name = AVAILABLE_SENSORS[sensor_id] + devices.append(EcoalTempSensor(ecoal_contr, name, sensor_id)) + add_entities(devices, True) + + +class EcoalTempSensor(Entity): + """Representation of a temperature sensor using ecoal status data.""" + + def __init__(self, ecoal_contr, name, status_attr): + """Initialize the sensor.""" + self._ecoal_contr = ecoal_contr + self._name = name + self._status_attr = status_attr + 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 TEMP_CELSIUS + + def update(self): + """Fetch new state data for the sensor. + + This is the only method that should fetch new data for Home Assistant. + """ + # Old values read 0.5 back can still be used + status = self._ecoal_contr.get_cached_status() + self._state = getattr(status, self._status_attr) diff --git a/homeassistant/components/ecoal_boiler/switch.py b/homeassistant/components/ecoal_boiler/switch.py new file mode 100644 index 0000000000000..00bfd7f3e5b97 --- /dev/null +++ b/homeassistant/components/ecoal_boiler/switch.py @@ -0,0 +1,78 @@ +"""Allows to configuration ecoal (esterownik.pl) pumps as switches.""" +import logging +from typing import Optional + +from homeassistant.components.switch import SwitchDevice + +from . import AVAILABLE_PUMPS, DATA_ECOAL_BOILER + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up switches based on ecoal interface.""" + if discovery_info is None: + return + ecoal_contr = hass.data[DATA_ECOAL_BOILER] + switches = [] + for pump_id in discovery_info: + name = AVAILABLE_PUMPS[pump_id] + switches.append(EcoalSwitch(ecoal_contr, name, pump_id)) + add_entities(switches, True) + + +class EcoalSwitch(SwitchDevice): + """Representation of Ecoal switch.""" + + def __init__(self, ecoal_contr, name, state_attr): + """ + Initialize switch. + + Sets HA switch to state as read from controller. + """ + self._ecoal_contr = ecoal_contr + self._name = name + self._state_attr = state_attr + # Ecoalcotroller holds convention that same postfix is used + # to set attribute + # set_() + # as attribute name in status instance: + # status. + self._contr_set_fun = getattr(self._ecoal_contr, f"set_{state_attr}") + # No value set, will be read from controller instead + self._state = None + + @property + def name(self) -> Optional[str]: + """Return the name of the switch.""" + return self._name + + def update(self): + """Fetch new state data for the sensor. + + This is the only method that should fetch new data for Home Assistant. + """ + status = self._ecoal_contr.get_cached_status() + self._state = getattr(status, self._state_attr) + + def invalidate_ecoal_cache(self): + """Invalidate ecoal interface cache. + + Forces that next read from ecaol interface to not use cache. + """ + self._ecoal_contr.status = None + + @property + def is_on(self) -> bool: + """Return true if device is on.""" + return self._state + + def turn_on(self, **kwargs) -> None: + """Turn the device on.""" + self._contr_set_fun(1) + self.invalidate_ecoal_cache() + + def turn_off(self, **kwargs) -> None: + """Turn the device off.""" + self._contr_set_fun(0) + self.invalidate_ecoal_cache() diff --git a/homeassistant/components/ecobee/.translations/bg.json b/homeassistant/components/ecobee/.translations/bg.json new file mode 100644 index 0000000000000..bd8503fabd8b4 --- /dev/null +++ b/homeassistant/components/ecobee/.translations/bg.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "one_instance_only": "\u0422\u0430\u0437\u0438 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f \u043f\u043e\u0434\u0434\u044a\u0440\u0436\u0430 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u043e \u043a\u043e\u043f\u0438\u0435 \u043d\u0430 ecobee." + }, + "error": { + "pin_request_failed": "\u0413\u0440\u0435\u0448\u043a\u0430 \u043f\u0440\u0438 \u0438\u0441\u043a\u0430\u043d\u0435 \u043d\u0430 \u041f\u0418\u041d \u043e\u0442 ecobee; \u043c\u043e\u043b\u044f, \u043f\u0440\u043e\u0432\u0435\u0440\u0435\u0442\u0435 \u0434\u0430\u043b\u0438 API \u043a\u043b\u044e\u0447\u044a\u0442 \u0435 \u043f\u0440\u0430\u0432\u0438\u043b\u0435\u043d.", + "token_request_failed": "\u0413\u0440\u0435\u0448\u043a\u0430 \u043f\u0440\u0438 \u0438\u0441\u043a\u0430\u043d\u0435 \u043d\u0430 \u043a\u043e\u0434\u043e\u0432\u0435 \u043e\u0442 ecobee; \u043c\u043e\u043b\u044f, \u043e\u043f\u0438\u0442\u0430\u0439\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e." + }, + "step": { + "authorize": { + "description": "\u041c\u043e\u043b\u044f, \u043e\u0442\u043e\u0440\u0438\u0437\u0438\u0440\u0430\u0439\u0442\u0435 \u0442\u043e\u0432\u0430 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u043d\u0430 https://www.ecobee.com/consumerportal/index.html \u0441 \u043f\u0438\u043d \u043a\u043e\u0434: \n\n {pin} \n \n \u0421\u043b\u0435\u0434 \u0442\u043e\u0432\u0430 \u043d\u0430\u0442\u0438\u0441\u043d\u0435\u0442\u0435 \u0418\u0437\u043f\u0440\u0430\u0449\u0430\u043d\u0435.", + "title": "\u041e\u0442\u043e\u0440\u0438\u0437\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u043d\u0430 ecobee.com" + }, + "user": { + "data": { + "api_key": "API \u043a\u043b\u044e\u0447" + }, + "description": "\u041c\u043e\u043b\u044f, \u0432\u044a\u0432\u0435\u0434\u0435\u0442\u0435 API \u043a\u043b\u044e\u0447\u0430, \u043f\u043e\u043b\u0443\u0447\u0435\u043d \u043e\u0442 ecobee.com.", + "title": "ecobee API \u043a\u043b\u044e\u0447" + } + }, + "title": "ecobee" + } +} \ No newline at end of file diff --git a/homeassistant/components/ecobee/.translations/ca.json b/homeassistant/components/ecobee/.translations/ca.json new file mode 100644 index 0000000000000..2c4d16b5787cb --- /dev/null +++ b/homeassistant/components/ecobee/.translations/ca.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "one_instance_only": "Aquesta integraci\u00f3 nom\u00e9s admet una sola inst\u00e0ncia ecobee." + }, + "error": { + "pin_request_failed": "Error al sol\u00b7licitar els PIN d'ecobee; verifica que la clau API \u00e9s correcta.", + "token_request_failed": "Error al sol\u00b7licitar els testimonis d'autenticaci\u00f3 d'ecobee; torna-ho a provar." + }, + "step": { + "authorize": { + "description": "Autoritza aquesta aplicaci\u00f3 a https://www.ecobee.com/consumerportal/index.html amb el codi pin seg\u00fcent: \n\n {pin} \n \n A continuaci\u00f3, prem Enviar.", + "title": "Autoritzaci\u00f3 de l'aplicaci\u00f3 a ecobee.com" + }, + "user": { + "data": { + "api_key": "Clau API" + }, + "description": "Introdueix la clau API obteinguda a trav\u00e9s del lloc web ecobee.com.", + "title": "Clau API d'ecobee" + } + }, + "title": "ecobee" + } +} \ No newline at end of file diff --git a/homeassistant/components/ecobee/.translations/da.json b/homeassistant/components/ecobee/.translations/da.json new file mode 100644 index 0000000000000..614811db45aee --- /dev/null +++ b/homeassistant/components/ecobee/.translations/da.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "one_instance_only": "Denne integration underst\u00f8tter i \u00f8jeblikket kun en ecobee-instans." + }, + "error": { + "pin_request_failed": "Fejl ved anmodning om pinkode fra ecobee. Kontroller at API-n\u00f8glen er korrekt.", + "token_request_failed": "Fejl ved anmodning om tokens fra ecobee. Pr\u00f8v igen." + }, + "step": { + "authorize": { + "description": "Godkend denne app p\u00e5 https://www.ecobee.com/consumerportal/index.html med PIN-kode:\n\n{pin}\n\nTryk derefter p\u00e5 Indsend.", + "title": "Godkend app p\u00e5 ecobee.com" + }, + "user": { + "data": { + "api_key": "API-n\u00f8gle" + }, + "description": "Indtast API-n\u00f8glen, du har f\u00e5et fra ecobee.com.", + "title": "ecobee API-n\u00f8gle" + } + }, + "title": "ecobee" + } +} \ No newline at end of file diff --git a/homeassistant/components/ecobee/.translations/de.json b/homeassistant/components/ecobee/.translations/de.json new file mode 100644 index 0000000000000..33d493f6db008 --- /dev/null +++ b/homeassistant/components/ecobee/.translations/de.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "one_instance_only": "Diese Integration unterst\u00fctzt derzeit nur eine Ecobee-Instanz." + }, + "error": { + "pin_request_failed": "Fehler beim Anfordern der PIN von ecobee; Bitte \u00fcberpr\u00fcfe, ob der API-Schl\u00fcssel korrekt ist.", + "token_request_failed": "Fehler beim Anfordern eines Token von ecobee; Bitte versuche es erneut." + }, + "step": { + "authorize": { + "description": "Bitte autorisiere diese App unter https://www.ecobee.com/consumerportal/index.html mit Pincode:\n\n{pin}\n\nDr\u00fccke dann auf Senden.", + "title": "App auf ecobee.com autorisieren" + }, + "user": { + "data": { + "api_key": "API Key" + }, + "description": "Bitte geben Sie den von ecobee.com erhaltenen API-Schl\u00fcssel ein.", + "title": "ecobee API-Schl\u00fcssel" + } + }, + "title": "ecobee" + } +} \ No newline at end of file diff --git a/homeassistant/components/ecobee/.translations/en.json b/homeassistant/components/ecobee/.translations/en.json new file mode 100644 index 0000000000000..39072f70d8202 --- /dev/null +++ b/homeassistant/components/ecobee/.translations/en.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "one_instance_only": "This integration currently supports only one ecobee instance." + }, + "error": { + "pin_request_failed": "Error requesting PIN from ecobee; please verify API key is correct.", + "token_request_failed": "Error requesting tokens from ecobee; please try again." + }, + "step": { + "authorize": { + "description": "Please authorize this app at https://www.ecobee.com/consumerportal/index.html with pin code:\n\n{pin}\n\nThen, press Submit.", + "title": "Authorize app on ecobee.com" + }, + "user": { + "data": { + "api_key": "API Key" + }, + "description": "Please enter the API key obtained from ecobee.com.", + "title": "ecobee API key" + } + }, + "title": "ecobee" + } +} \ No newline at end of file diff --git a/homeassistant/components/ecobee/.translations/es.json b/homeassistant/components/ecobee/.translations/es.json new file mode 100644 index 0000000000000..5544d2e7f7bfd --- /dev/null +++ b/homeassistant/components/ecobee/.translations/es.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "one_instance_only": "Esta integraci\u00f3n actualmente solo admite una instancia de ecobee." + }, + "error": { + "pin_request_failed": "Error al solicitar el PIN de ecobee; verifique que la clave API sea correcta.", + "token_request_failed": "Error al solicitar tokens de ecobee; Int\u00e9ntalo de nuevo." + }, + "step": { + "authorize": { + "description": "Por favor, autorizar esta aplicaci\u00f3n en https://www.ecobee.com/consumerportal/index.html con c\u00f3digo pin:\n\n{pin}\n\nA continuaci\u00f3n, pulse Enviar.", + "title": "Autorizar aplicaci\u00f3n en ecobee.com" + }, + "user": { + "data": { + "api_key": "Clave API" + }, + "description": "Introduzca la clave de API obtenida de ecobee.com.", + "title": "Clave API de ecobee" + } + }, + "title": "ecobee" + } +} \ No newline at end of file diff --git a/homeassistant/components/ecobee/.translations/fr.json b/homeassistant/components/ecobee/.translations/fr.json new file mode 100644 index 0000000000000..7f308fdf3a3e9 --- /dev/null +++ b/homeassistant/components/ecobee/.translations/fr.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "one_instance_only": "Cette int\u00e9gration ne prend actuellement en charge qu'une seule instance ecobee." + }, + "error": { + "pin_request_failed": "Erreur lors de la demande du code PIN \u00e0 ecobee; veuillez v\u00e9rifier que la cl\u00e9 API est correcte.", + "token_request_failed": "Erreur lors de la demande de jetons \u00e0 ecobee; Veuillez r\u00e9essayer." + }, + "step": { + "authorize": { + "description": "Veuillez autoriser cette application \u00e0 https://www.ecobee.com/consumerportal/index.html avec un code PIN :\n\n{pin}\n\nEnsuite, appuyez sur Soumettre.", + "title": "Autoriser l'application sur ecobee.com" + }, + "user": { + "data": { + "api_key": "Cl\u00e9 API" + }, + "description": "Veuillez entrer la cl\u00e9 API obtenue aupr\u00e8s d'ecobee.com.", + "title": "Cl\u00e9 API ecobee" + } + }, + "title": "ecobee" + } +} \ No newline at end of file diff --git a/homeassistant/components/ecobee/.translations/it.json b/homeassistant/components/ecobee/.translations/it.json new file mode 100644 index 0000000000000..2ecb587f19e13 --- /dev/null +++ b/homeassistant/components/ecobee/.translations/it.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "one_instance_only": "Questa integrazione supporta attualmente una sola istanza ecobee." + }, + "error": { + "pin_request_failed": "Errore durante la richiesta del PIN da ecobee; verificare che la chiave API sia corretta.", + "token_request_failed": "Errore durante la richiesta di token da ecobee; per favore riprova." + }, + "step": { + "authorize": { + "description": "Autorizza questa app su https://www.ecobee.com/consumerportal/index.html con il codice PIN: \n\n {pin} \n \n Quindi, premi Invia.", + "title": "Autorizza l'app su ecobee.com" + }, + "user": { + "data": { + "api_key": "API Key" + }, + "description": "Inserisci la chiave API ottenuta da ecobee.com.", + "title": "chiave API ecobee" + } + }, + "title": "ecobee" + } +} \ No newline at end of file diff --git a/homeassistant/components/ecobee/.translations/ko.json b/homeassistant/components/ecobee/.translations/ko.json new file mode 100644 index 0000000000000..2fea66a9d3858 --- /dev/null +++ b/homeassistant/components/ecobee/.translations/ko.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "one_instance_only": "\uc774 \ud1b5\ud569 \uad6c\uc131\uc694\uc18c\ub294 \ud604\uc7ac \ud558\ub098\uc758 ecobee \uc778\uc2a4\ud134\uc2a4\ub9cc \uc9c0\uc6d0\ud569\ub2c8\ub2e4." + }, + "error": { + "pin_request_failed": "ecobee \ub85c\ubd80\ud130 PIN \uc694\uccad\uc5d0 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4; API \ud0a4\uac00 \uc62c\ubc14\ub978\uc9c0 \ud655\uc778\ud574\uc8fc\uc138\uc694.", + "token_request_failed": "ecobee \ub85c\ubd80\ud130 \ud1a0\ud070 \uc694\uccad\uc5d0 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4; \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694." + }, + "step": { + "authorize": { + "description": "https://www.ecobee.com/consumerportal/index.html \uc5d0\uc11c PIN \ucf54\ub4dc\ub97c \uc0ac\uc6a9\ud558\uc5ec \uc774 \uc571\uc744 \uc2b9\uc778\ud574\uc8fc\uc138\uc694:\n\n {pin} \n \n \uadf8\ub7f0 \ub2e4\uc74c Submit \uc744 \ub20c\ub7ec\uc8fc\uc138\uc694.", + "title": "ecobee.com \uc5d0\uc11c \uc571 \uc2b9\uc778\ud558\uae30" + }, + "user": { + "data": { + "api_key": "API \ud0a4" + }, + "description": "ecobee.com \uc5d0\uc11c \uc5bb\uc740 API \ud0a4\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694.", + "title": "ecobee API \ud0a4" + } + }, + "title": "ecobee" + } +} \ No newline at end of file diff --git a/homeassistant/components/ecobee/.translations/lb.json b/homeassistant/components/ecobee/.translations/lb.json new file mode 100644 index 0000000000000..ee1fd5246c078 --- /dev/null +++ b/homeassistant/components/ecobee/.translations/lb.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "one_instance_only": "D\u00ebs Integratioun \u00ebnnerst\u00ebtzt n\u00ebmmen eng ecobee Instanz." + }, + "error": { + "pin_request_failed": "Feeler beim ufroe vum PIN vun ecobee; iwwerpr\u00e9ift op den API Schl\u00ebssel korrekt ass.", + "token_request_failed": "Feeler beim ufroe vum Jeton vun ecobee; prob\u00e9iert nach emol." + }, + "step": { + "authorize": { + "description": "Autoris\u00e9iert d\u00ebs App op https://www.ecobee.com/consumerportal/index.html mam Pin Code:\n\n{pin}\n\nKlickt dann op ofsch\u00e9cken.", + "title": "App autoris\u00e9ieren op ecobee.com" + }, + "user": { + "data": { + "api_key": "API Schl\u00ebssel" + }, + "description": "Gitt den API Schl\u00ebssel vun ecobee.com an:", + "title": "ecobee API Schl\u00ebssel" + } + }, + "title": "ecobee" + } +} \ No newline at end of file diff --git a/homeassistant/components/ecobee/.translations/nl.json b/homeassistant/components/ecobee/.translations/nl.json new file mode 100644 index 0000000000000..56bb3ace26f57 --- /dev/null +++ b/homeassistant/components/ecobee/.translations/nl.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "one_instance_only": "Deze integratie ondersteunt momenteel slechts \u00e9\u00e9n ecobee-instantie." + }, + "error": { + "pin_request_failed": "Fout bij het aanvragen van pincode bij ecobee; Controleer of de API-sleutel correct is.", + "token_request_failed": "Fout bij het aanvragen van tokens bij ecobee; probeer het opnieuw." + }, + "step": { + "authorize": { + "description": "Autoriseer deze app op https://www.ecobee.com/consumerportal/index.html met pincode: \n\n {pin} \n \nDruk vervolgens op Submit.", + "title": "Autoriseer app op ecobee.com" + }, + "user": { + "data": { + "api_key": "API-sleutel" + }, + "description": "Voer de API-sleutel in die u van ecobee.com hebt gekregen.", + "title": "ecobee API-sleutel" + } + }, + "title": "ecobee" + } +} \ No newline at end of file diff --git a/homeassistant/components/ecobee/.translations/nn.json b/homeassistant/components/ecobee/.translations/nn.json new file mode 100644 index 0000000000000..301239cf31a6c --- /dev/null +++ b/homeassistant/components/ecobee/.translations/nn.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "ecobee" + } +} \ No newline at end of file diff --git a/homeassistant/components/ecobee/.translations/no.json b/homeassistant/components/ecobee/.translations/no.json new file mode 100644 index 0000000000000..efaa566c4240c --- /dev/null +++ b/homeassistant/components/ecobee/.translations/no.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "one_instance_only": "Denne integrasjonen st\u00f8tter forel\u00f8pig bare \u00e9n ecobee-forekomst." + }, + "error": { + "pin_request_failed": "Feil under foresp\u00f8rsel om PIN-kode fra ecobee. Kontroller at API-n\u00f8kkelen er riktig.", + "token_request_failed": "Feil ved foresp\u00f8rsel om tokener fra ecobee: Pr\u00f8v p\u00e5 nytt." + }, + "step": { + "authorize": { + "description": "Vennligst autoriser denne appen p\u00e5 https://www.ecobee.com/consumerportal/index.html med pin-kode:\n\n{pin}\n\nTrykk deretter p\u00e5 Send.", + "title": "Autoriser app p\u00e5 ecobee.com" + }, + "user": { + "data": { + "api_key": "API-n\u00f8kkel" + }, + "description": "Vennligst skriv inn API-n\u00f8kkel som er innhentet fra ecobee.com.", + "title": "ecobee API-n\u00f8kkel" + } + }, + "title": "ecobee" + } +} \ No newline at end of file diff --git a/homeassistant/components/ecobee/.translations/pl.json b/homeassistant/components/ecobee/.translations/pl.json new file mode 100644 index 0000000000000..bd4e7aa1ddc10 --- /dev/null +++ b/homeassistant/components/ecobee/.translations/pl.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "one_instance_only": "Komponent obs\u0142uguje tylko jedn\u0105 instancj\u0119 ecobee" + }, + "error": { + "pin_request_failed": "B\u0142\u0105d podczas \u017c\u0105dania kodu PIN od ecobee; sprawd\u017a, czy klucz API jest poprawny.", + "token_request_failed": "B\u0142\u0105d podczas \u017c\u0105dania token\u00f3w od ecobee. Spr\u00f3buj ponownie." + }, + "step": { + "authorize": { + "description": "Autoryzuj t\u0119 aplikacj\u0119 na https://www.ecobee.com/consumerportal/index.html za pomoc\u0105 kodu PIN: \n\n {pin} \n \n Nast\u0119pnie naci\u015bnij przycisk Prze\u015blij.", + "title": "Autoryzuj aplikacj\u0119 na ecobee.com" + }, + "user": { + "data": { + "api_key": "Klucz API" + }, + "description": "Prosz\u0119 wprowadzi\u0107 klucz API uzyskany na ecobee.com.", + "title": "Klucz API" + } + }, + "title": "ecobee" + } +} \ No newline at end of file diff --git a/homeassistant/components/ecobee/.translations/pt-BR.json b/homeassistant/components/ecobee/.translations/pt-BR.json new file mode 100644 index 0000000000000..65394faba177c --- /dev/null +++ b/homeassistant/components/ecobee/.translations/pt-BR.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "one_instance_only": "Essa integra\u00e7\u00e3o atualmente suporta apenas uma inst\u00e2ncia ecobee." + }, + "error": { + "token_request_failed": "Erro ao solicitar tokens da ecobee; Por favor, tente novamente." + }, + "step": { + "authorize": { + "description": "Por favor, autorize este aplicativo em https://www.ecobee.com/consumerportal/index.html com c\u00f3digo PIN:\n\n{pin}\n\nEm seguida, pressione Submit.", + "title": "Autorizar aplicativo em ecobee.com" + }, + "user": { + "data": { + "api_key": "Chave API" + }, + "description": "Por favor, insira a chave de API obtida em ecobee.com.", + "title": "chave da API ecobee" + } + }, + "title": "ecobee" + } +} \ No newline at end of file diff --git a/homeassistant/components/ecobee/.translations/pt.json b/homeassistant/components/ecobee/.translations/pt.json new file mode 100644 index 0000000000000..20bba0ede4bf1 --- /dev/null +++ b/homeassistant/components/ecobee/.translations/pt.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_key": "Chave da API" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ecobee/.translations/ru.json b/homeassistant/components/ecobee/.translations/ru.json new file mode 100644 index 0000000000000..660e0064bb6a7 --- /dev/null +++ b/homeassistant/components/ecobee/.translations/ru.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "one_instance_only": "\u042d\u0442\u0430 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f \u0432 \u043d\u0430\u0441\u0442\u043e\u044f\u0449\u0435\u0435 \u0432\u0440\u0435\u043c\u044f \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 ecobee." + }, + "error": { + "pin_request_failed": "\u041f\u0440\u043e\u0438\u0437\u043e\u0448\u043b\u0430 \u043e\u0448\u0438\u0431\u043a\u0430 \u0432\u043e \u0432\u0440\u0435\u043c\u044f \u0437\u0430\u043f\u0440\u043e\u0441\u0430 PIN-\u043a\u043e\u0434\u0430 \u0443 ecobee; \u043f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u043f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u043e\u0441\u0442\u044c \u043a\u043b\u044e\u0447\u0430 API.", + "token_request_failed": "\u041f\u0440\u043e\u0438\u0437\u043e\u0448\u043b\u0430 \u043e\u0448\u0438\u0431\u043a\u0430 \u0432\u043e \u0432\u0440\u0435\u043c\u044f \u0437\u0430\u043f\u0440\u043e\u0441\u0430 \u0442\u043e\u043a\u0435\u043d\u043e\u0432 \u0443 ecobee; \u043f\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." + }, + "step": { + "authorize": { + "description": "\u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u0440\u043e\u0439\u0434\u0438\u0442\u0435 \u043f\u043e \u0430\u0434\u0440\u0435\u0441\u0443 https://www.ecobee.com/consumerportal/index.html \u0438 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0443\u0439\u0442\u0435\u0441\u044c \u0441 \u043f\u043e\u043c\u043e\u0449\u044c\u044e PIN-\u043a\u043e\u0434\u0430: \n\n {pin} \n \n \u0417\u0430\u0442\u0435\u043c \u043d\u0430\u0436\u043c\u0438\u0442\u0435 \u041e\u0442\u043f\u0440\u0430\u0432\u0438\u0442\u044c.", + "title": "\u0410\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u044f \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u043d\u0430 ecobee.com" + }, + "user": { + "data": { + "api_key": "\u041a\u043b\u044e\u0447 API" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043a\u043b\u044e\u0447 API, \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u043d\u044b\u0439 \u043e\u0442 ecobee.com.", + "title": "ecobee" + } + }, + "title": "ecobee" + } +} \ No newline at end of file diff --git a/homeassistant/components/ecobee/.translations/sl.json b/homeassistant/components/ecobee/.translations/sl.json new file mode 100644 index 0000000000000..d70be59afb5d4 --- /dev/null +++ b/homeassistant/components/ecobee/.translations/sl.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "one_instance_only": "Ta integracija trenutno podpira samo en primerek ecobee." + }, + "error": { + "pin_request_failed": "Napaka pri zahtevi PIN-a od ecobee; preverite, ali je klju\u010d API pravilen.", + "token_request_failed": "Napaka pri zahtevanju \u017eetonov od ecobeeja; prosim poskusite ponovno." + }, + "step": { + "authorize": { + "description": "Prosimo, pooblastite to aplikacijo na https://www.ecobee.com/consumerportal/index.html s kodo PIN:\n\n{pin}\n\nNato pritisnite Po\u0161lji.", + "title": "Pooblasti aplikacijo na ecobee.com" + }, + "user": { + "data": { + "api_key": "API Klju\u010d" + }, + "description": "Prosimo vnesite API klju\u010d, pridobljen iz ecobee.com.", + "title": "ecobee API klju\u010d" + } + }, + "title": "ecobee" + } +} \ No newline at end of file diff --git a/homeassistant/components/ecobee/.translations/sv.json b/homeassistant/components/ecobee/.translations/sv.json new file mode 100644 index 0000000000000..f4a63bb449d1f --- /dev/null +++ b/homeassistant/components/ecobee/.translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_key": "API-nyckel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ecobee/.translations/zh-Hant.json b/homeassistant/components/ecobee/.translations/zh-Hant.json new file mode 100644 index 0000000000000..e1eb6ebd3570b --- /dev/null +++ b/homeassistant/components/ecobee/.translations/zh-Hant.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "one_instance_only": "\u6b64\u6574\u5408\u76ee\u524d\u50c5\u652f\u63f4\u4e00\u7d44 ecobee \u7269\u4ef6" + }, + "error": { + "pin_request_failed": "ecobee \u6240\u9700\u4ee3\u78bc\u932f\u8aa4\uff0c\u8acb\u78ba\u8a8d\u5bc6\u9470\u6b63\u78ba\u6027\u3002", + "token_request_failed": "ecobee \u6240\u9700\u5bc6\u9470\u932f\u8aa4\uff0c\u8acb\u518d\u8a66\u4e00\u6b21\u3002" + }, + "step": { + "authorize": { + "description": "\u8acb\u65bc https://www.ecobee.com/consumerportal/index.html \u8f38\u5165\u4e0b\u65b9\u4ee3\u78bc\uff0c\u8a8d\u8b49\u6b64 App\uff1a\n\n{pin}\n\n\u7136\u5f8c\u6309\u4e0b\u300cSubmit\u300d\u3002", + "title": "\u65bc ecobee.com \u4e0a\u8a8d\u8b49 App" + }, + "user": { + "data": { + "api_key": "API \u5bc6\u9470" + }, + "description": "\u8acb\u8f38\u5165\u7531 ecobee.com \u6240\u7372\u5f97\u7684 API \u5bc6\u9470\u3002", + "title": "ecobee API \u5bc6\u9470" + } + }, + "title": "ecobee" + } +} \ No newline at end of file diff --git a/homeassistant/components/ecobee/__init__.py b/homeassistant/components/ecobee/__init__.py new file mode 100644 index 0000000000000..80c3be7954b4a --- /dev/null +++ b/homeassistant/components/ecobee/__init__.py @@ -0,0 +1,130 @@ +"""Support for ecobee.""" +import asyncio +from datetime import timedelta + +from pyecobee import ECOBEE_API_KEY, ECOBEE_REFRESH_TOKEN, Ecobee, ExpiredTokenError +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import CONF_API_KEY +from homeassistant.helpers import config_validation as cv +from homeassistant.util import Throttle + +from .const import ( + _LOGGER, + CONF_REFRESH_TOKEN, + DATA_ECOBEE_CONFIG, + DOMAIN, + ECOBEE_PLATFORMS, +) + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=180) + +CONFIG_SCHEMA = vol.Schema( + {DOMAIN: vol.Schema({vol.Optional(CONF_API_KEY): cv.string})}, extra=vol.ALLOW_EXTRA +) + + +async def async_setup(hass, config): + """ + Ecobee uses config flow for configuration. + + But, an "ecobee:" entry in configuration.yaml will trigger an import flow + if a config entry doesn't already exist. If ecobee.conf exists, the import + flow will attempt to import it and create a config entry, to assist users + migrating from the old ecobee component. Otherwise, the user will have to + continue setting up the integration via the config flow. + """ + hass.data[DATA_ECOBEE_CONFIG] = config.get(DOMAIN, {}) + + if not hass.config_entries.async_entries(DOMAIN) and hass.data[DATA_ECOBEE_CONFIG]: + # No config entry exists and configuration.yaml config exists, trigger the import flow. + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT} + ) + ) + + return True + + +async def async_setup_entry(hass, entry): + """Set up ecobee via a config entry.""" + api_key = entry.data[CONF_API_KEY] + refresh_token = entry.data[CONF_REFRESH_TOKEN] + + data = EcobeeData(hass, entry, api_key=api_key, refresh_token=refresh_token) + + if not await data.refresh(): + return False + + await data.update() + + if data.ecobee.thermostats is None: + _LOGGER.error("No ecobee devices found to set up") + return False + + hass.data[DOMAIN] = data + + for component in ECOBEE_PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +class EcobeeData: + """ + Handle getting the latest data from ecobee.com so platforms can use it. + + Also handle refreshing tokens and updating config entry with refreshed tokens. + """ + + def __init__(self, hass, entry, api_key, refresh_token): + """Initialize the Ecobee data object.""" + self._hass = hass + self._entry = entry + self.ecobee = Ecobee( + config={ECOBEE_API_KEY: api_key, ECOBEE_REFRESH_TOKEN: refresh_token} + ) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + async def update(self): + """Get the latest data from ecobee.com.""" + try: + await self._hass.async_add_executor_job(self.ecobee.update) + _LOGGER.debug("Updating ecobee") + except ExpiredTokenError: + _LOGGER.warning( + "Ecobee update failed; attempting to refresh expired tokens" + ) + await self.refresh() + + async def refresh(self) -> bool: + """Refresh ecobee tokens and update config entry.""" + _LOGGER.debug("Refreshing ecobee tokens and updating config entry") + if await self._hass.async_add_executor_job(self.ecobee.refresh_tokens): + self._hass.config_entries.async_update_entry( + self._entry, + data={ + CONF_API_KEY: self.ecobee.config[ECOBEE_API_KEY], + CONF_REFRESH_TOKEN: self.ecobee.config[ECOBEE_REFRESH_TOKEN], + }, + ) + return True + _LOGGER.error("Error updating ecobee tokens") + return False + + +async def async_unload_entry(hass, config_entry): + """Unload the config entry and platforms.""" + hass.data.pop(DOMAIN) + + tasks = [] + for platform in ECOBEE_PLATFORMS: + tasks.append( + hass.config_entries.async_forward_entry_unload(config_entry, platform) + ) + + return all(await asyncio.gather(*tasks)) diff --git a/homeassistant/components/ecobee/binary_sensor.py b/homeassistant/components/ecobee/binary_sensor.py new file mode 100644 index 0000000000000..f7a24886b8482 --- /dev/null +++ b/homeassistant/components/ecobee/binary_sensor.py @@ -0,0 +1,114 @@ +"""Support for Ecobee binary sensors.""" +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_OCCUPANCY, + BinarySensorDevice, +) + +from .const import _LOGGER, DOMAIN, ECOBEE_MODEL_TO_NAME, MANUFACTURER + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Old way of setting up ecobee binary sensors.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up ecobee binary (occupancy) sensors.""" + data = hass.data[DOMAIN] + 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(data, sensor["name"], index)) + + async_add_entities(dev, True) + + +class EcobeeBinarySensor(BinarySensorDevice): + """Representation of an Ecobee sensor.""" + + def __init__(self, data, sensor_name, sensor_index): + """Initialize the Ecobee sensor.""" + self.data = data + self._name = sensor_name + " Occupancy" + self.sensor_name = sensor_name + self.index = sensor_index + self._state = None + + @property + def name(self): + """Return the name of the Ecobee sensor.""" + return self._name.rstrip() + + @property + def unique_id(self): + """Return a unique identifier for this sensor.""" + for sensor in self.data.ecobee.get_remote_sensors(self.index): + if sensor["name"] == self.sensor_name: + if "code" in sensor: + return f"{sensor['code']}-{self.device_class}" + thermostat = self.data.ecobee.get_thermostat(self.index) + return f"{thermostat['identifier']}-{sensor['id']}-{self.device_class}" + + @property + def device_info(self): + """Return device information for this sensor.""" + identifier = None + model = None + for sensor in self.data.ecobee.get_remote_sensors(self.index): + if sensor["name"] != self.sensor_name: + continue + if "code" in sensor: + identifier = sensor["code"] + model = "ecobee Room Sensor" + else: + thermostat = self.data.ecobee.get_thermostat(self.index) + identifier = thermostat["identifier"] + try: + model = ( + f"{ECOBEE_MODEL_TO_NAME[thermostat['modelNumber']]} Thermostat" + ) + except KeyError: + _LOGGER.error( + "Model number for ecobee thermostat %s not recognized. " + "Please visit this link and provide the following information: " + "https://github.com/home-assistant/home-assistant/issues/27172 " + "Unrecognized model number: %s", + thermostat["name"], + thermostat["modelNumber"], + ) + break + + if identifier is not None and model is not None: + return { + "identifiers": {(DOMAIN, identifier)}, + "name": self.sensor_name, + "manufacturer": MANUFACTURER, + "model": model, + } + return None + + @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 DEVICE_CLASS_OCCUPANCY + + async def async_update(self): + """Get the latest state of the sensor.""" + await self.data.update() + for sensor in self.data.ecobee.get_remote_sensors(self.index): + if sensor["name"] != self.sensor_name: + continue + for item in sensor["capability"]: + if item["type"] != "occupancy": + continue + self._state = item["value"] + break diff --git a/homeassistant/components/ecobee/climate.py b/homeassistant/components/ecobee/climate.py new file mode 100644 index 0000000000000..5915e64334f06 --- /dev/null +++ b/homeassistant/components/ecobee/climate.py @@ -0,0 +1,717 @@ +"""Support for Ecobee Thermostats.""" +import collections +from typing import Optional + +import voluptuous as vol + +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_DRY, + CURRENT_HVAC_FAN, + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, + FAN_AUTO, + FAN_ON, + HVAC_MODE_AUTO, + HVAC_MODE_COOL, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + PRESET_AWAY, + PRESET_NONE, + SUPPORT_AUX_HEAT, + SUPPORT_FAN_MODE, + SUPPORT_PRESET_MODE, + SUPPORT_TARGET_TEMPERATURE, + SUPPORT_TARGET_TEMPERATURE_RANGE, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_TEMPERATURE, + STATE_ON, + TEMP_FAHRENHEIT, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.util.temperature import convert + +from .const import _LOGGER, DOMAIN, ECOBEE_MODEL_TO_NAME, MANUFACTURER +from .util import ecobee_date, ecobee_time + +ATTR_COOL_TEMP = "cool_temp" +ATTR_END_DATE = "end_date" +ATTR_END_TIME = "end_time" +ATTR_FAN_MIN_ON_TIME = "fan_min_on_time" +ATTR_FAN_MODE = "fan_mode" +ATTR_HEAT_TEMP = "heat_temp" +ATTR_RESUME_ALL = "resume_all" +ATTR_START_DATE = "start_date" +ATTR_START_TIME = "start_time" +ATTR_VACATION_NAME = "vacation_name" + +DEFAULT_RESUME_ALL = False +PRESET_TEMPERATURE = "temp" +PRESET_VACATION = "vacation" +PRESET_HOLD_NEXT_TRANSITION = "next_transition" +PRESET_HOLD_INDEFINITE = "indefinite" +AWAY_MODE = "awayMode" +PRESET_HOME = "home" +PRESET_SLEEP = "sleep" + +# Order matters, because for reverse mapping we don't want to map HEAT to AUX +ECOBEE_HVAC_TO_HASS = collections.OrderedDict( + [ + ("heat", HVAC_MODE_HEAT), + ("cool", HVAC_MODE_COOL), + ("auto", HVAC_MODE_AUTO), + ("off", HVAC_MODE_OFF), + ("auxHeatOnly", HVAC_MODE_HEAT), + ] +) + +ECOBEE_HVAC_ACTION_TO_HASS = { + # Map to None if we do not know how to represent. + "heatPump": CURRENT_HVAC_HEAT, + "heatPump2": CURRENT_HVAC_HEAT, + "heatPump3": CURRENT_HVAC_HEAT, + "compCool1": CURRENT_HVAC_COOL, + "compCool2": CURRENT_HVAC_COOL, + "auxHeat1": CURRENT_HVAC_HEAT, + "auxHeat2": CURRENT_HVAC_HEAT, + "auxHeat3": CURRENT_HVAC_HEAT, + "fan": CURRENT_HVAC_FAN, + "humidifier": None, + "dehumidifier": CURRENT_HVAC_DRY, + "ventilator": CURRENT_HVAC_FAN, + "economizer": CURRENT_HVAC_FAN, + "compHotWater": None, + "auxHotWater": None, +} + +PRESET_TO_ECOBEE_HOLD = { + PRESET_HOLD_NEXT_TRANSITION: "nextTransition", + PRESET_HOLD_INDEFINITE: "indefinite", +} + +SERVICE_CREATE_VACATION = "create_vacation" +SERVICE_DELETE_VACATION = "delete_vacation" +SERVICE_RESUME_PROGRAM = "resume_program" +SERVICE_SET_FAN_MIN_ON_TIME = "set_fan_min_on_time" + +DTGROUP_INCLUSIVE_MSG = ( + f"{ATTR_START_DATE}, {ATTR_START_TIME}, {ATTR_END_DATE}, " + f"and {ATTR_END_TIME} must be specified together" +) + +CREATE_VACATION_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_VACATION_NAME): vol.All(cv.string, vol.Length(max=12)), + vol.Required(ATTR_COOL_TEMP): vol.Coerce(float), + vol.Required(ATTR_HEAT_TEMP): vol.Coerce(float), + vol.Inclusive( + ATTR_START_DATE, "dtgroup", msg=DTGROUP_INCLUSIVE_MSG + ): ecobee_date, + vol.Inclusive( + ATTR_START_TIME, "dtgroup", msg=DTGROUP_INCLUSIVE_MSG + ): ecobee_time, + vol.Inclusive(ATTR_END_DATE, "dtgroup", msg=DTGROUP_INCLUSIVE_MSG): ecobee_date, + vol.Inclusive(ATTR_END_TIME, "dtgroup", msg=DTGROUP_INCLUSIVE_MSG): ecobee_time, + vol.Optional(ATTR_FAN_MODE, default="auto"): vol.Any("auto", "on"), + vol.Optional(ATTR_FAN_MIN_ON_TIME, default=0): vol.All( + int, vol.Range(min=0, max=60) + ), + } +) + +DELETE_VACATION_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_VACATION_NAME): vol.All(cv.string, vol.Length(max=12)), + } +) + +RESUME_PROGRAM_SCHEMA = vol.Schema( + { + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Optional(ATTR_RESUME_ALL, default=DEFAULT_RESUME_ALL): cv.boolean, + } +) + +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), + } +) + + +SUPPORT_FLAGS = ( + SUPPORT_TARGET_TEMPERATURE + | SUPPORT_PRESET_MODE + | SUPPORT_AUX_HEAT + | SUPPORT_TARGET_TEMPERATURE_RANGE + | SUPPORT_FAN_MODE +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Old way of setting up ecobee thermostat.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the ecobee thermostat.""" + + data = hass.data[DOMAIN] + + devices = [Thermostat(data, index) for index in range(len(data.ecobee.thermostats))] + + async_add_entities(devices, True) + + def create_vacation_service(service): + """Create a vacation on the target thermostat.""" + entity_id = service.data[ATTR_ENTITY_ID] + + for thermostat in devices: + if thermostat.entity_id == entity_id: + thermostat.create_vacation(service.data) + thermostat.schedule_update_ha_state(True) + break + + def delete_vacation_service(service): + """Delete a vacation on the target thermostat.""" + entity_id = service.data[ATTR_ENTITY_ID] + vacation_name = service.data[ATTR_VACATION_NAME] + + for thermostat in devices: + if thermostat.entity_id == entity_id: + thermostat.delete_vacation(vacation_name) + thermostat.schedule_update_ha_state(True) + break + + 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.async_register( + DOMAIN, + SERVICE_CREATE_VACATION, + create_vacation_service, + schema=CREATE_VACATION_SCHEMA, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_DELETE_VACATION, + delete_vacation_service, + schema=DELETE_VACATION_SCHEMA, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_SET_FAN_MIN_ON_TIME, + fan_min_on_time_set_service, + schema=SET_FAN_MIN_ON_TIME_SCHEMA, + ) + + hass.services.async_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): + """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.vacation = None + self._last_active_hvac_mode = HVAC_MODE_AUTO + + self._operation_list = [] + if ( + self.thermostat["settings"]["heatStages"] + or self.thermostat["settings"]["hasHeatPump"] + ): + self._operation_list.append(HVAC_MODE_HEAT) + if self.thermostat["settings"]["coolStages"]: + self._operation_list.append(HVAC_MODE_COOL) + if len(self._operation_list) == 2: + self._operation_list.insert(0, HVAC_MODE_AUTO) + self._operation_list.append(HVAC_MODE_OFF) + + self._preset_modes = { + comfort["climateRef"]: comfort["name"] + for comfort in self.thermostat["program"]["climates"] + } + self._fan_modes = [FAN_AUTO, FAN_ON] + self.update_without_throttle = False + + async def async_update(self): + """Get the latest state from the thermostat.""" + if self.update_without_throttle: + await self.data.update(no_throttle=True) + self.update_without_throttle = False + else: + await self.data.update() + self.thermostat = self.data.ecobee.get_thermostat(self.thermostat_index) + if self.hvac_mode is not HVAC_MODE_OFF: + self._last_active_hvac_mode = self.hvac_mode + + @property + def available(self): + """Return if device is available.""" + return self.thermostat["runtime"]["connected"] + + @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 unique_id(self): + """Return a unique identifier for this ecobee thermostat.""" + return self.thermostat["identifier"] + + @property + def device_info(self): + """Return device information for this ecobee thermostat.""" + try: + model = f"{ECOBEE_MODEL_TO_NAME[self.thermostat['modelNumber']]} Thermostat" + except KeyError: + _LOGGER.error( + "Model number for ecobee thermostat %s not recognized. " + "Please visit this link and provide the following information: " + "https://github.com/home-assistant/home-assistant/issues/27172 " + "Unrecognized model number: %s", + self.name, + self.thermostat["modelNumber"], + ) + return None + + return { + "identifiers": {(DOMAIN, self.thermostat["identifier"])}, + "name": self.name, + "manufacturer": MANUFACTURER, + "model": model, + } + + @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.hvac_mode == HVAC_MODE_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.hvac_mode == HVAC_MODE_AUTO: + return self.thermostat["runtime"]["desiredCool"] / 10.0 + return None + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + if self.hvac_mode == HVAC_MODE_AUTO: + return None + if self.hvac_mode == HVAC_MODE_HEAT: + return self.thermostat["runtime"]["desiredHeat"] / 10.0 + if self.hvac_mode == HVAC_MODE_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 HVAC_MODE_OFF + + @property + def fan_mode(self): + """Return the fan setting.""" + return self.thermostat["runtime"]["desiredFanMode"] + + @property + def fan_modes(self): + """Return the available fan modes.""" + return self._fan_modes + + @property + def preset_mode(self): + """Return current preset mode.""" + events = self.thermostat["events"] + for event in events: + if not event["running"]: + continue + + if event["type"] == "hold": + if event["holdClimateRef"] in self._preset_modes: + return self._preset_modes[event["holdClimateRef"]] + + # Any hold not based on a climate is a temp hold + return PRESET_TEMPERATURE + 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 PRESET_VACATION + + return self._preset_modes[self.thermostat["program"]["currentClimateRef"]] + + @property + def hvac_mode(self): + """Return current operation.""" + return ECOBEE_HVAC_TO_HASS[self.thermostat["settings"]["hvacMode"]] + + @property + def hvac_modes(self): + """Return the operation modes list.""" + return self._operation_list + + @property + def current_humidity(self) -> Optional[int]: + """Return the current humidity.""" + return self.thermostat["runtime"]["actualHumidity"] + + @property + def hvac_action(self): + """Return current HVAC action. + + Ecobee returns a CSV string with different equipment that is active. + We are prioritizing any heating/cooling equipment, otherwase look at + drying/fanning. Idle if nothing going on. + + We are unable to map all actions to HA equivalents. + """ + if self.thermostat["equipmentStatus"] == "": + return CURRENT_HVAC_IDLE + + actions = [ + ECOBEE_HVAC_ACTION_TO_HASS[status] + for status in self.thermostat["equipmentStatus"].split(",") + if ECOBEE_HVAC_ACTION_TO_HASS[status] is not None + ] + + for action in ( + CURRENT_HVAC_HEAT, + CURRENT_HVAC_COOL, + CURRENT_HVAC_DRY, + CURRENT_HVAC_FAN, + ): + if action in actions: + return action + + return CURRENT_HVAC_IDLE + + @property + def device_state_attributes(self): + """Return device specific state attributes.""" + status = self.thermostat["equipmentStatus"] + return { + "fan": self.fan, + "climate_mode": self._preset_modes[ + self.thermostat["program"]["currentClimateRef"] + ], + "equipment_running": status, + "fan_min_on_time": self.thermostat["settings"]["fanMinOnTime"], + } + + @property + def is_aux_heat(self): + """Return true if aux heater.""" + return "auxHeat" in self.thermostat["equipmentStatus"] + + def set_preset_mode(self, preset_mode): + """Activate a preset.""" + if preset_mode == self.preset_mode: + return + + self.update_without_throttle = True + + # If we are currently in vacation mode, cancel it. + if self.preset_mode == PRESET_VACATION: + self.data.ecobee.delete_vacation(self.thermostat_index, self.vacation) + + if preset_mode == PRESET_AWAY: + self.data.ecobee.set_climate_hold( + self.thermostat_index, "away", "indefinite" + ) + + elif preset_mode == PRESET_TEMPERATURE: + self.set_temp_hold(self.current_temperature) + + elif preset_mode in (PRESET_HOLD_NEXT_TRANSITION, PRESET_HOLD_INDEFINITE): + self.data.ecobee.set_climate_hold( + self.thermostat_index, + PRESET_TO_ECOBEE_HOLD[preset_mode], + self.hold_preference(), + ) + + elif preset_mode == PRESET_NONE: + self.data.ecobee.resume_program(self.thermostat_index) + + elif preset_mode in self.preset_modes: + climate_ref = None + + for comfort in self.thermostat["program"]["climates"]: + if comfort["name"] == preset_mode: + climate_ref = comfort["climateRef"] + break + + if climate_ref is not None: + self.data.ecobee.set_climate_hold( + self.thermostat_index, climate_ref, self.hold_preference() + ) + else: + _LOGGER.warning("Received unknown preset mode: %s", preset_mode) + + else: + self.data.ecobee.set_climate_hold( + self.thermostat_index, preset_mode, self.hold_preference() + ) + + @property + def preset_modes(self): + """Return available preset modes.""" + return list(self._preset_modes.values()) + + 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() != HVAC_MODE_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.hvac_mode == HVAC_MODE_HEAT or self.hvac_mode == HVAC_MODE_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.hvac_mode == HVAC_MODE_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_hvac_mode(self, hvac_mode): + """Set HVAC mode (auto, auxHeatOnly, cool, heat, off).""" + ecobee_value = next( + (k for k, v in ECOBEE_HVAC_TO_HASS.items() if v == hvac_mode), None + ) + if ecobee_value is None: + _LOGGER.error("Invalid mode for set_hvac_mode: %s", hvac_mode) + return + self.data.ecobee.set_hvac_mode(self.thermostat_index, ecobee_value) + 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" + + def create_vacation(self, service_data): + """Create a vacation with user-specified parameters.""" + vacation_name = service_data[ATTR_VACATION_NAME] + cool_temp = convert( + service_data[ATTR_COOL_TEMP], + self.hass.config.units.temperature_unit, + TEMP_FAHRENHEIT, + ) + heat_temp = convert( + service_data[ATTR_HEAT_TEMP], + self.hass.config.units.temperature_unit, + TEMP_FAHRENHEIT, + ) + start_date = service_data.get(ATTR_START_DATE) + start_time = service_data.get(ATTR_START_TIME) + end_date = service_data.get(ATTR_END_DATE) + end_time = service_data.get(ATTR_END_TIME) + fan_mode = service_data[ATTR_FAN_MODE] + fan_min_on_time = service_data[ATTR_FAN_MIN_ON_TIME] + + kwargs = { + key: value + for key, value in { + "start_date": start_date, + "start_time": start_time, + "end_date": end_date, + "end_time": end_time, + "fan_mode": fan_mode, + "fan_min_on_time": fan_min_on_time, + }.items() + if value is not None + } + + _LOGGER.debug( + "Creating a vacation on thermostat %s with name %s, cool temp %s, heat temp %s, " + "and the following other parameters: %s", + self.name, + vacation_name, + cool_temp, + heat_temp, + kwargs, + ) + self.data.ecobee.create_vacation( + self.thermostat_index, vacation_name, cool_temp, heat_temp, **kwargs + ) + + def delete_vacation(self, vacation_name): + """Delete a vacation with the specified name.""" + _LOGGER.debug( + "Deleting a vacation on thermostat %s with name %s", + self.name, + vacation_name, + ) + self.data.ecobee.delete_vacation(self.thermostat_index, vacation_name) + + def turn_on(self): + """Set the thermostat to the last active HVAC mode.""" + _LOGGER.debug( + "Turning on ecobee thermostat %s in %s mode", + self.name, + self._last_active_hvac_mode, + ) + self.set_hvac_mode(self._last_active_hvac_mode) diff --git a/homeassistant/components/ecobee/config_flow.py b/homeassistant/components/ecobee/config_flow.py new file mode 100644 index 0000000000000..bb406d81e3a3a --- /dev/null +++ b/homeassistant/components/ecobee/config_flow.py @@ -0,0 +1,123 @@ +"""Config flow to configure ecobee.""" +from pyecobee import ( + ECOBEE_API_KEY, + ECOBEE_CONFIG_FILENAME, + ECOBEE_REFRESH_TOKEN, + Ecobee, +) +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistantError +from homeassistant.util.json import load_json + +from .const import _LOGGER, CONF_REFRESH_TOKEN, DATA_ECOBEE_CONFIG, DOMAIN + + +class EcobeeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle an ecobee config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Initialize the ecobee flow.""" + self._ecobee = None + + async def async_step_user(self, user_input=None): + """Handle a flow initiated by the user.""" + if self._async_current_entries(): + # Config entry already exists, only one allowed. + return self.async_abort(reason="one_instance_only") + + errors = {} + stored_api_key = ( + self.hass.data[DATA_ECOBEE_CONFIG].get(CONF_API_KEY) + if DATA_ECOBEE_CONFIG in self.hass.data + else "" + ) + + if user_input is not None: + # Use the user-supplied API key to attempt to obtain a PIN from ecobee. + self._ecobee = Ecobee(config={ECOBEE_API_KEY: user_input[CONF_API_KEY]}) + + if await self.hass.async_add_executor_job(self._ecobee.request_pin): + # We have a PIN; move to the next step of the flow. + return await self.async_step_authorize() + errors["base"] = "pin_request_failed" + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + {vol.Required(CONF_API_KEY, default=stored_api_key): str} + ), + errors=errors, + ) + + async def async_step_authorize(self, user_input=None): + """Present the user with the PIN so that the app can be authorized on ecobee.com.""" + errors = {} + + if user_input is not None: + # Attempt to obtain tokens from ecobee and finish the flow. + if await self.hass.async_add_executor_job(self._ecobee.request_tokens): + # Refresh token obtained; create the config entry. + config = { + CONF_API_KEY: self._ecobee.api_key, + CONF_REFRESH_TOKEN: self._ecobee.refresh_token, + } + return self.async_create_entry(title=DOMAIN, data=config) + errors["base"] = "token_request_failed" + + return self.async_show_form( + step_id="authorize", + errors=errors, + description_placeholders={"pin": self._ecobee.pin}, + ) + + async def async_step_import(self, import_data): + """ + Import ecobee config from configuration.yaml. + + Triggered by async_setup only if a config entry doesn't already exist. + If ecobee.conf exists, we will attempt to validate the credentials + and create an entry if valid. Otherwise, we will delegate to the user + step so that the user can continue the config flow. + """ + try: + legacy_config = await self.hass.async_add_executor_job( + load_json, self.hass.config.path(ECOBEE_CONFIG_FILENAME) + ) + config = { + ECOBEE_API_KEY: legacy_config[ECOBEE_API_KEY], + ECOBEE_REFRESH_TOKEN: legacy_config[ECOBEE_REFRESH_TOKEN], + } + except (HomeAssistantError, KeyError): + _LOGGER.debug( + "No valid ecobee.conf configuration found for import, delegating to user step" + ) + return await self.async_step_user( + user_input={ + CONF_API_KEY: self.hass.data[DATA_ECOBEE_CONFIG].get(CONF_API_KEY) + } + ) + + ecobee = Ecobee(config=config) + if await self.hass.async_add_executor_job(ecobee.refresh_tokens): + # Credentials found and validated; create the entry. + _LOGGER.debug( + "Valid ecobee configuration found for import, creating config entry" + ) + return self.async_create_entry( + title=DOMAIN, + data={ + CONF_API_KEY: ecobee.api_key, + CONF_REFRESH_TOKEN: ecobee.refresh_token, + }, + ) + return await self.async_step_user( + user_input={ + CONF_API_KEY: self.hass.data[DATA_ECOBEE_CONFIG].get(CONF_API_KEY) + } + ) diff --git a/homeassistant/components/ecobee/const.py b/homeassistant/components/ecobee/const.py new file mode 100644 index 0000000000000..f380c9bbef34c --- /dev/null +++ b/homeassistant/components/ecobee/const.py @@ -0,0 +1,56 @@ +"""Constants for the ecobee integration.""" +import logging + +_LOGGER = logging.getLogger(__package__) + +DOMAIN = "ecobee" +DATA_ECOBEE_CONFIG = "ecobee_config" + +CONF_INDEX = "index" +CONF_REFRESH_TOKEN = "refresh_token" + +ECOBEE_MODEL_TO_NAME = { + "idtSmart": "ecobee Smart", + "idtEms": "ecobee Smart EMS", + "siSmart": "ecobee Si Smart", + "siEms": "ecobee Si EMS", + "athenaSmart": "ecobee3 Smart", + "athenaEms": "ecobee3 EMS", + "corSmart": "Carrier/Bryant Cor", + "nikeSmart": "ecobee3 lite Smart", + "nikeEms": "ecobee3 lite EMS", + "apolloSmart": "ecobee4 Smart", + "vulcanSmart": "ecobee4 Smart", +} + +ECOBEE_PLATFORMS = ["binary_sensor", "climate", "sensor", "weather"] + +MANUFACTURER = "ecobee" + +# Translates ecobee API weatherSymbol to Home Assistant usable names +# https://www.ecobee.com/home/developer/api/documentation/v1/objects/WeatherForecast.shtml +ECOBEE_WEATHER_SYMBOL_TO_HASS = { + 0: "sunny", + 1: "partlycloudy", + 2: "partlycloudy", + 3: "cloudy", + 4: "cloudy", + 5: "cloudy", + 6: "rainy", + 7: "snowy-rainy", + 8: "pouring", + 9: "hail", + 10: "snowy", + 11: "snowy", + 12: "snowy-rainy", + 13: "snowy-heavy", + 14: "hail", + 15: "lightning-rainy", + 16: "windy", + 17: "tornado", + 18: "fog", + 19: "hazy", + 20: "hazy", + 21: "hazy", + -2: None, +} diff --git a/homeassistant/components/ecobee/manifest.json b/homeassistant/components/ecobee/manifest.json new file mode 100644 index 0000000000000..32b589649266b --- /dev/null +++ b/homeassistant/components/ecobee/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "ecobee", + "name": "Ecobee", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/ecobee", + "dependencies": [], + "requirements": ["python-ecobee-api==0.1.4"], + "codeowners": ["@marthoc"] +} diff --git a/homeassistant/components/ecobee/notify.py b/homeassistant/components/ecobee/notify.py new file mode 100644 index 0000000000000..a8f53a027b370 --- /dev/null +++ b/homeassistant/components/ecobee/notify.py @@ -0,0 +1,31 @@ +"""Support for Ecobee Send Message service.""" +import voluptuous as vol + +from homeassistant.components.notify import PLATFORM_SCHEMA, BaseNotificationService +import homeassistant.helpers.config_validation as cv + +from .const import CONF_INDEX, DOMAIN + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + {vol.Optional(CONF_INDEX, default=0): cv.positive_int} +) + + +def get_service(hass, config, discovery_info=None): + """Get the Ecobee notification service.""" + data = hass.data[DOMAIN] + index = config.get(CONF_INDEX) + return EcobeeNotificationService(data, index) + + +class EcobeeNotificationService(BaseNotificationService): + """Implement the notification service for the Ecobee thermostat.""" + + def __init__(self, data, thermostat_index): + """Initialize the service.""" + self.data = data + self.thermostat_index = thermostat_index + + def send_message(self, message="", **kwargs): + """Send a message.""" + self.data.ecobee.send_message(self.thermostat_index, message) diff --git a/homeassistant/components/ecobee/sensor.py b/homeassistant/components/ecobee/sensor.py new file mode 100644 index 0000000000000..37201ec2121a9 --- /dev/null +++ b/homeassistant/components/ecobee/sensor.py @@ -0,0 +1,138 @@ +"""Support for Ecobee sensors.""" +from pyecobee.const import ECOBEE_STATE_CALIBRATING, ECOBEE_STATE_UNKNOWN + +from homeassistant.const import ( + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_TEMPERATURE, + TEMP_FAHRENHEIT, +) +from homeassistant.helpers.entity import Entity + +from .const import _LOGGER, DOMAIN, ECOBEE_MODEL_TO_NAME, MANUFACTURER + +SENSOR_TYPES = { + "temperature": ["Temperature", TEMP_FAHRENHEIT], + "humidity": ["Humidity", "%"], +} + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Old way of setting up ecobee sensors.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up ecobee (temperature and humidity) sensors.""" + data = hass.data[DOMAIN] + 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"] not in ("temperature", "humidity"): + continue + + dev.append(EcobeeSensor(data, sensor["name"], item["type"], index)) + + async_add_entities(dev, True) + + +class EcobeeSensor(Entity): + """Representation of an Ecobee sensor.""" + + def __init__(self, data, sensor_name, sensor_type, sensor_index): + """Initialize the sensor.""" + self.data = data + self._name = "{} {}".format(sensor_name, SENSOR_TYPES[sensor_type][0]) + self.sensor_name = sensor_name + self.type = sensor_type + self.index = sensor_index + self._state = None + self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + + @property + def name(self): + """Return the name of the Ecobee sensor.""" + return self._name + + @property + def unique_id(self): + """Return a unique identifier for this sensor.""" + for sensor in self.data.ecobee.get_remote_sensors(self.index): + if sensor["name"] == self.sensor_name: + if "code" in sensor: + return f"{sensor['code']}-{self.device_class}" + thermostat = self.data.ecobee.get_thermostat(self.index) + return f"{thermostat['identifier']}-{sensor['id']}-{self.device_class}" + + @property + def device_info(self): + """Return device information for this sensor.""" + identifier = None + model = None + for sensor in self.data.ecobee.get_remote_sensors(self.index): + if sensor["name"] != self.sensor_name: + continue + if "code" in sensor: + identifier = sensor["code"] + model = "ecobee Room Sensor" + else: + thermostat = self.data.ecobee.get_thermostat(self.index) + identifier = thermostat["identifier"] + try: + model = ( + f"{ECOBEE_MODEL_TO_NAME[thermostat['modelNumber']]} Thermostat" + ) + except KeyError: + _LOGGER.error( + "Model number for ecobee thermostat %s not recognized. " + "Please visit this link and provide the following information: " + "https://github.com/home-assistant/home-assistant/issues/27172 " + "Unrecognized model number: %s", + thermostat["name"], + thermostat["modelNumber"], + ) + break + + if identifier is not None and model is not None: + return { + "identifiers": {(DOMAIN, identifier)}, + "name": self.sensor_name, + "manufacturer": MANUFACTURER, + "model": model, + } + return None + + @property + def device_class(self): + """Return the device class of the sensor.""" + if self.type in (DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE): + return self.type + return None + + @property + def state(self): + """Return the state of the sensor.""" + if self._state in [ECOBEE_STATE_CALIBRATING, ECOBEE_STATE_UNKNOWN, "unknown"]: + return None + + if self.type == "temperature": + return float(self._state) / 10 + + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement this sensor expresses itself in.""" + return self._unit_of_measurement + + async def async_update(self): + """Get the latest state of the sensor.""" + await self.data.update() + for sensor in self.data.ecobee.get_remote_sensors(self.index): + if sensor["name"] != self.sensor_name: + continue + for item in sensor["capability"]: + if item["type"] != self.type: + continue + self._state = item["value"] + break diff --git a/homeassistant/components/ecobee/services.yaml b/homeassistant/components/ecobee/services.yaml new file mode 100644 index 0000000000000..2155d3cf7d24c --- /dev/null +++ b/homeassistant/components/ecobee/services.yaml @@ -0,0 +1,71 @@ +create_vacation: + description: >- + Create a vacation on the selected thermostat. Note: start/end date and time must all be specified + together for these parameters to have an effect. If start/end date and time are not specified, the + vacation will start immediately and last 14 days (unless deleted earlier). + fields: + entity_id: + description: ecobee thermostat on which to create the vacation (required). + example: "climate.kitchen" + vacation_name: + description: Name of the vacation to create; must be unique on the thermostat (required). + example: "Skiing" + cool_temp: + description: Cooling temperature during the vacation (required). + example: 23 + heat_temp: + description: Heating temperature during the vacation (required). + example: 25 + start_date: + description: >- + Date the vacation starts in the YYYY-MM-DD format (optional, immediately if not provided along with + start_time, end_date, and end_time). + example: "2019-03-15" + start_time: + description: Time the vacation starts, in the local time of the thermostat, in the 24-hour format "HH:MM:SS" + example: "20:00:00" + end_date: + description: >- + Date the vacation ends in the YYYY-MM-DD format (optional, 14 days from now if not provided along with + start_date, start_time, and end_time). + example: "2019-03-20" + end_time: + description: Time the vacation ends, in the local time of the thermostat, in the 24-hour format "HH:MM:SS" + example: "20:00:00" + fan_mode: + description: Fan mode of the thermostat during the vacation (auto or on) (optional, auto if not provided). + example: "on" + fan_min_on_time: + description: Minimum number of minutes to run the fan each hour (0 to 60) during the vacation (optional, 0 if not provided). + example: 30 + +delete_vacation: + description: >- + Delete a vacation on the selected thermostat. + fields: + entity_id: + description: ecobee thermostat on which to delete the vacation (required). + example: "climate.kitchen" + vacation_name: + description: Name of the vacation to delete (required). + example: "Skiing" + +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 + +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 diff --git a/homeassistant/components/ecobee/strings.json b/homeassistant/components/ecobee/strings.json new file mode 100644 index 0000000000000..9e7e9fed39659 --- /dev/null +++ b/homeassistant/components/ecobee/strings.json @@ -0,0 +1,23 @@ +{ + "config": { + "title": "ecobee", + "step": { + "user": { + "title": "ecobee API key", + "description": "Please enter the API key obtained from ecobee.com.", + "data": {"api_key": "API Key"} + }, + "authorize": { + "title": "Authorize app on ecobee.com", + "description": "Please authorize this app at https://www.ecobee.com/consumerportal/index.html with pin code:\n\n{pin}\n\nThen, press Submit." + } + }, + "error": { + "pin_request_failed": "Error requesting PIN from ecobee; please verify API key is correct.", + "token_request_failed": "Error requesting tokens from ecobee; please try again." + }, + "abort": { + "one_instance_only": "This integration currently supports only one ecobee instance." + } + } +} diff --git a/homeassistant/components/ecobee/util.py b/homeassistant/components/ecobee/util.py new file mode 100644 index 0000000000000..2f5d194fec04d --- /dev/null +++ b/homeassistant/components/ecobee/util.py @@ -0,0 +1,22 @@ +"""Validation utility functions for ecobee services.""" +from datetime import datetime + +import voluptuous as vol + + +def ecobee_date(date_string): + """Validate a date_string as valid for the ecobee API.""" + try: + datetime.strptime(date_string, "%Y-%m-%d") + except ValueError: + raise vol.Invalid("Date does not match ecobee date format YYYY-MM-DD") + return date_string + + +def ecobee_time(time_string): + """Validate a time_string as valid for the ecobee API.""" + try: + datetime.strptime(time_string, "%H:%M:%S") + except ValueError: + raise vol.Invalid("Time does not match ecobee 24-hour time format HH:MM:SS") + return time_string diff --git a/homeassistant/components/ecobee/weather.py b/homeassistant/components/ecobee/weather.py new file mode 100644 index 0000000000000..a571e854f7358 --- /dev/null +++ b/homeassistant/components/ecobee/weather.py @@ -0,0 +1,214 @@ +"""Support for displaying weather info from Ecobee API.""" +from datetime import datetime + +from pyecobee.const import ECOBEE_STATE_UNKNOWN + +from homeassistant.components.weather import ( + ATTR_FORECAST_CONDITION, + ATTR_FORECAST_TEMP, + ATTR_FORECAST_TEMP_LOW, + ATTR_FORECAST_TIME, + ATTR_FORECAST_WIND_BEARING, + ATTR_FORECAST_WIND_SPEED, + WeatherEntity, +) +from homeassistant.const import TEMP_FAHRENHEIT + +from .const import ( + _LOGGER, + DOMAIN, + ECOBEE_MODEL_TO_NAME, + ECOBEE_WEATHER_SYMBOL_TO_HASS, + MANUFACTURER, +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Old way of setting up the ecobee weather platform.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the ecobee weather platform.""" + data = hass.data[DOMAIN] + dev = list() + for index in range(len(data.ecobee.thermostats)): + thermostat = data.ecobee.get_thermostat(index) + if "weather" in thermostat: + dev.append(EcobeeWeather(data, thermostat["name"], index)) + + async_add_entities(dev, True) + + +class EcobeeWeather(WeatherEntity): + """Representation of Ecobee weather data.""" + + def __init__(self, data, name, index): + """Initialize the Ecobee weather platform.""" + self.data = data + self._name = name + self._index = index + self.weather = None + + def get_forecast(self, index, param): + """Retrieve forecast parameter.""" + try: + forecast = self.weather["forecasts"][index] + return forecast[param] + except (ValueError, IndexError, KeyError): + raise ValueError + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def unique_id(self): + """Return a unique identifier for the weather platform.""" + return self.data.ecobee.get_thermostat(self._index)["identifier"] + + @property + def device_info(self): + """Return device information for the ecobee weather platform.""" + thermostat = self.data.ecobee.get_thermostat(self._index) + try: + model = f"{ECOBEE_MODEL_TO_NAME[thermostat['modelNumber']]} Thermostat" + except KeyError: + _LOGGER.error( + "Model number for ecobee thermostat %s not recognized. " + "Please visit this link and provide the following information: " + "https://github.com/home-assistant/home-assistant/issues/27172 " + "Unrecognized model number: %s", + thermostat["name"], + thermostat["modelNumber"], + ) + return None + + return { + "identifiers": {(DOMAIN, thermostat["identifier"])}, + "name": self.name, + "manufacturer": MANUFACTURER, + "model": model, + } + + @property + def condition(self): + """Return the current condition.""" + try: + return ECOBEE_WEATHER_SYMBOL_TO_HASS[self.get_forecast(0, "weatherSymbol")] + except ValueError: + return None + + @property + def temperature(self): + """Return the temperature.""" + try: + return float(self.get_forecast(0, "temperature")) / 10 + except ValueError: + return None + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_FAHRENHEIT + + @property + def pressure(self): + """Return the pressure.""" + try: + return int(self.get_forecast(0, "pressure")) + except ValueError: + return None + + @property + def humidity(self): + """Return the humidity.""" + try: + return int(self.get_forecast(0, "relativeHumidity")) + except ValueError: + return None + + @property + def visibility(self): + """Return the visibility.""" + try: + return int(self.get_forecast(0, "visibility")) / 1000 + except ValueError: + return None + + @property + def wind_speed(self): + """Return the wind speed.""" + try: + return int(self.get_forecast(0, "windSpeed")) + except ValueError: + return None + + @property + def wind_bearing(self): + """Return the wind direction.""" + try: + return int(self.get_forecast(0, "windBearing")) + except ValueError: + return None + + @property + def attribution(self): + """Return the attribution.""" + if not self.weather: + return None + + station = self.weather.get("weatherStation", "UNKNOWN") + time = self.weather.get("timestamp", "UNKNOWN") + return f"Ecobee weather provided by {station} at {time} UTC" + + @property + def forecast(self): + """Return the forecast array.""" + if "forecasts" not in self.weather: + return None + + forecasts = list() + for day in range(1, 5): + forecast = _process_forecast(self.weather["forecasts"][day]) + if forecast is None: + continue + forecasts.append(forecast) + + if forecasts: + return forecasts + return None + + async def async_update(self): + """Get the latest weather data.""" + await self.data.update() + thermostat = self.data.ecobee.get_thermostat(self._index) + self.weather = thermostat.get("weather", None) + + +def _process_forecast(json): + """Process a single ecobee API forecast to return expected values.""" + forecast = dict() + try: + forecast[ATTR_FORECAST_TIME] = datetime.strptime( + json["dateTime"], "%Y-%m-%d %H:%M:%S" + ).isoformat() + forecast[ATTR_FORECAST_CONDITION] = ECOBEE_WEATHER_SYMBOL_TO_HASS[ + json["weatherSymbol"] + ] + if json["tempHigh"] != ECOBEE_STATE_UNKNOWN: + forecast[ATTR_FORECAST_TEMP] = float(json["tempHigh"]) / 10 + if json["tempLow"] != ECOBEE_STATE_UNKNOWN: + forecast[ATTR_FORECAST_TEMP_LOW] = float(json["tempLow"]) / 10 + if json["windBearing"] != ECOBEE_STATE_UNKNOWN: + forecast[ATTR_FORECAST_WIND_BEARING] = int(json["windBearing"]) + if json["windSpeed"] != ECOBEE_STATE_UNKNOWN: + forecast[ATTR_FORECAST_WIND_SPEED] = int(json["windSpeed"]) + + except (ValueError, IndexError, KeyError): + return None + + if forecast: + return forecast + return None diff --git a/homeassistant/components/econet/__init__.py b/homeassistant/components/econet/__init__.py new file mode 100644 index 0000000000000..48b7dad4c7c13 --- /dev/null +++ b/homeassistant/components/econet/__init__.py @@ -0,0 +1 @@ +"""The econet component.""" diff --git a/homeassistant/components/econet/const.py b/homeassistant/components/econet/const.py new file mode 100644 index 0000000000000..88b1b851aa6d2 --- /dev/null +++ b/homeassistant/components/econet/const.py @@ -0,0 +1,5 @@ +"""Constants for Econet integration.""" + +DOMAIN = "econet" +SERVICE_ADD_VACATION = "add_vacation" +SERVICE_DELETE_VACATION = "delete_vacation" diff --git a/homeassistant/components/econet/manifest.json b/homeassistant/components/econet/manifest.json new file mode 100644 index 0000000000000..d9ce5253e953d --- /dev/null +++ b/homeassistant/components/econet/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "econet", + "name": "Rheem EcoNET Water Products", + "documentation": "https://www.home-assistant.io/integrations/econet", + "requirements": ["pyeconet==0.0.11"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/econet/services.yaml b/homeassistant/components/econet/services.yaml new file mode 100644 index 0000000000000..9f489165c22e9 --- /dev/null +++ b/homeassistant/components/econet/services.yaml @@ -0,0 +1,19 @@ +add_vacation: + description: Add a vacation to your water heater. + fields: + entity_id: + description: Name(s) of entities to change. + example: 'water_heater.econet' + 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 + +delete_vacation: + description: Delete your existing vacation from your water heater. + fields: + entity_id: + description: Name(s) of entities to change. + example: 'water_heater.econet' \ No newline at end of file diff --git a/homeassistant/components/econet/water_heater.py b/homeassistant/components/econet/water_heater.py new file mode 100644 index 0000000000000..26ee7cb8bd422 --- /dev/null +++ b/homeassistant/components/econet/water_heater.py @@ -0,0 +1,240 @@ +"""Support for Rheem EcoNet water heaters.""" +import datetime +import logging + +from pyeconet.api import PyEcoNet +import voluptuous as vol + +from homeassistant.components.water_heater import ( + PLATFORM_SCHEMA, + STATE_ECO, + STATE_ELECTRIC, + STATE_GAS, + STATE_HEAT_PUMP, + STATE_HIGH_DEMAND, + STATE_OFF, + STATE_PERFORMANCE, + SUPPORT_OPERATION_MODE, + SUPPORT_TARGET_TEMPERATURE, + WaterHeaterDevice, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_TEMPERATURE, + CONF_PASSWORD, + CONF_USERNAME, + TEMP_FAHRENHEIT, +) +import homeassistant.helpers.config_validation as cv + +from .const import DOMAIN, SERVICE_ADD_VACATION, SERVICE_DELETE_VACATION + +_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 + +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" + +ECONET_STATE_TO_HA = { + "Energy Saver": STATE_ECO, + "gas": STATE_GAS, + "High Demand": STATE_HIGH_DEMAND, + "Off": STATE_OFF, + "Performance": STATE_PERFORMANCE, + "Heat Pump Only": STATE_HEAT_PUMP, + "Electric-Only": STATE_ELECTRIC, + "Electric": STATE_ELECTRIC, + "Heat Pump": STATE_HEAT_PUMP, +} + +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.""" + + 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(WaterHeaterDevice): + """Representation of an EcoNet water heater.""" + + def __init__(self, water_heater): + """Initialize the water heater.""" + self.water_heater = water_heater + self.supported_modes = self.water_heater.supported_modes + self.econet_state_to_ha = {} + self.ha_state_to_econet = {} + for mode in ECONET_STATE_TO_HA: + if mode in self.supported_modes: + self.econet_state_to_ha[mode] = ECONET_STATE_TO_HA.get(mode) + for key, value in self.econet_state_to_ha.items(): + self.ha_state_to_econet[value] = key + for mode in self.supported_modes: + if mode not in ECONET_STATE_TO_HA: + error = ( + "Invalid operation mode mapping. " + + mode + + " doesn't map. Please report this." + ) + _LOGGER.error(error) + + @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 = self.econet_state_to_ha.get(self.water_heater.mode) + return current_op + + @property + def operation_list(self): + """List of available operation modes.""" + op_list = [] + for mode in self.supported_modes: + ha_mode = self.econet_state_to_ha.get(mode) + if ha_mode is not None: + op_list.append(ha_mode) + 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 = self.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/ecovacs/__init__.py b/homeassistant/components/ecovacs/__init__.py new file mode 100644 index 0000000000000..964dd7a3f2ac4 --- /dev/null +++ b/homeassistant/components/ecovacs/__init__.py @@ -0,0 +1,91 @@ +"""Support for Ecovacs Deebot vacuums.""" +import logging +import random +import string + +from sucks import EcoVacsAPI, VacBot +import voluptuous as vol + +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, EVENT_HOMEASSISTANT_STOP +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "ecovacs" + +CONF_COUNTRY = "country" +CONF_CONTINENT = "continent" + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_COUNTRY): vol.All(vol.Lower, cv.string), + vol.Required(CONF_CONTINENT): vol.All(vol.Lower, cv.string), + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + +ECOVACS_DEVICES = "ecovacs_devices" + +# Generate a random device ID on each bootup +ECOVACS_API_DEVICEID = "".join( + random.choice(string.ascii_uppercase + string.digits) for _ in range(8) +) + + +def setup(hass, config): + """Set up the Ecovacs component.""" + _LOGGER.debug("Creating new Ecovacs component") + + hass.data[ECOVACS_DEVICES] = [] + + ecovacs_api = EcoVacsAPI( + ECOVACS_API_DEVICEID, + config[DOMAIN].get(CONF_USERNAME), + EcoVacsAPI.md5(config[DOMAIN].get(CONF_PASSWORD)), + config[DOMAIN].get(CONF_COUNTRY), + config[DOMAIN].get(CONF_CONTINENT), + ) + + devices = ecovacs_api.devices() + _LOGGER.debug("Ecobot devices: %s", devices) + + for device in devices: + _LOGGER.info( + "Discovered Ecovacs device on account: %s with nickname %s", + device["did"], + device["nick"], + ) + vacbot = VacBot( + ecovacs_api.uid, + ecovacs_api.REALM, + ecovacs_api.resource, + ecovacs_api.user_access_token, + device, + config[DOMAIN].get(CONF_CONTINENT).lower(), + monitor=True, + ) + hass.data[ECOVACS_DEVICES].append(vacbot) + + def stop(event: object) -> None: + """Shut down open connections to Ecovacs XMPP server.""" + for device in hass.data[ECOVACS_DEVICES]: + _LOGGER.info( + "Shutting down connection to Ecovacs device %s", device.vacuum["did"] + ) + device.disconnect() + + # Listen for HA stop to disconnect. + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop) + + if hass.data[ECOVACS_DEVICES]: + _LOGGER.debug("Starting vacuum components") + discovery.load_platform(hass, "vacuum", DOMAIN, {}, config) + + return True diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json new file mode 100644 index 0000000000000..637ee001ca3a1 --- /dev/null +++ b/homeassistant/components/ecovacs/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "ecovacs", + "name": "Ecovacs", + "documentation": "https://www.home-assistant.io/integrations/ecovacs", + "requirements": ["sucks==0.9.4"], + "dependencies": [], + "codeowners": ["@OverloadUT"] +} diff --git a/homeassistant/components/ecovacs/vacuum.py b/homeassistant/components/ecovacs/vacuum.py new file mode 100644 index 0000000000000..a74fdaa21baa2 --- /dev/null +++ b/homeassistant/components/ecovacs/vacuum.py @@ -0,0 +1,201 @@ +"""Support for Ecovacs Ecovacs Vaccums.""" +import logging + +import sucks + +from homeassistant.components.vacuum import ( + SUPPORT_BATTERY, + SUPPORT_CLEAN_SPOT, + SUPPORT_FAN_SPEED, + SUPPORT_LOCATE, + SUPPORT_RETURN_HOME, + SUPPORT_SEND_COMMAND, + SUPPORT_STATUS, + SUPPORT_STOP, + SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, + VacuumDevice, +) +from homeassistant.helpers.icon import icon_for_battery_level + +from . import ECOVACS_DEVICES + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_ECOVACS = ( + SUPPORT_BATTERY + | SUPPORT_RETURN_HOME + | SUPPORT_CLEAN_SPOT + | SUPPORT_STOP + | SUPPORT_TURN_OFF + | SUPPORT_TURN_ON + | SUPPORT_LOCATE + | SUPPORT_STATUS + | SUPPORT_SEND_COMMAND + | SUPPORT_FAN_SPEED +) + +ATTR_ERROR = "error" +ATTR_COMPONENT_PREFIX = "component_" + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Ecovacs vacuums.""" + vacuums = [] + for device in hass.data[ECOVACS_DEVICES]: + vacuums.append(EcovacsVacuum(device)) + _LOGGER.debug("Adding Ecovacs Vacuums to Home Assistant: %s", vacuums) + add_entities(vacuums, True) + + +class EcovacsVacuum(VacuumDevice): + """Ecovacs Vacuums such as Deebot.""" + + def __init__(self, device): + """Initialize the Ecovacs Vacuum.""" + self.device = device + self.device.connect_and_wait_until_ready() + if self.device.vacuum.get("nick", None) is not None: + self._name = "{}".format(self.device.vacuum["nick"]) + else: + # In case there is no nickname defined, use the device id + self._name = "{}".format(self.device.vacuum["did"]) + + self._fan_speed = None + self._error = None + _LOGGER.debug("Vacuum initialized: %s", self.name) + + async def async_added_to_hass(self) -> None: + """Set up the event listeners now that hass is ready.""" + self.device.statusEvents.subscribe(lambda _: self.schedule_update_ha_state()) + self.device.batteryEvents.subscribe(lambda _: self.schedule_update_ha_state()) + self.device.lifespanEvents.subscribe(lambda _: self.schedule_update_ha_state()) + self.device.errorEvents.subscribe(self.on_error) + + def on_error(self, error): + """Handle an error event from the robot. + + This will not change the entity's state. If the error caused the state + to change, that will come through as a separate on_status event + """ + if error == "no_error": + self._error = None + else: + self._error = error + + self.hass.bus.fire( + "ecovacs_error", {"entity_id": self.entity_id, "error": error} + ) + self.schedule_update_ha_state() + + @property + def should_poll(self) -> bool: + """Return True if entity has to be polled for state.""" + return False + + @property + def unique_id(self) -> str: + """Return an unique ID.""" + return self.device.vacuum.get("did", None) + + @property + def is_on(self): + """Return true if vacuum is currently cleaning.""" + return self.device.is_cleaning + + @property + def is_charging(self): + """Return true if vacuum is currently charging.""" + return self.device.is_charging + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def supported_features(self): + """Flag vacuum cleaner robot features that are supported.""" + return SUPPORT_ECOVACS + + @property + def status(self): + """Return the status of the vacuum cleaner.""" + return self.device.vacuum_status + + def return_to_base(self, **kwargs): + """Set the vacuum cleaner to return to the dock.""" + + self.device.run(sucks.Charge()) + + @property + def battery_icon(self): + """Return the battery icon for the vacuum cleaner.""" + return icon_for_battery_level( + battery_level=self.battery_level, charging=self.is_charging + ) + + @property + def battery_level(self): + """Return the battery level of the vacuum cleaner.""" + if self.device.battery_status is not None: + return self.device.battery_status * 100 + + return super().battery_level + + @property + def fan_speed(self): + """Return the fan speed of the vacuum cleaner.""" + return self.device.fan_speed + + @property + def fan_speed_list(self): + """Get the list of available fan speed steps of the vacuum cleaner.""" + + return [sucks.FAN_SPEED_NORMAL, sucks.FAN_SPEED_HIGH] + + def turn_on(self, **kwargs): + """Turn the vacuum on and start cleaning.""" + + self.device.run(sucks.Clean()) + + def turn_off(self, **kwargs): + """Turn the vacuum off stopping the cleaning and returning home.""" + self.return_to_base() + + def stop(self, **kwargs): + """Stop the vacuum cleaner.""" + + self.device.run(sucks.Stop()) + + def clean_spot(self, **kwargs): + """Perform a spot clean-up.""" + + self.device.run(sucks.Spot()) + + def locate(self, **kwargs): + """Locate the vacuum cleaner.""" + + self.device.run(sucks.PlaySound()) + + def set_fan_speed(self, fan_speed, **kwargs): + """Set fan speed.""" + if self.is_on: + + self.device.run(sucks.Clean(mode=self.device.clean_status, speed=fan_speed)) + + def send_command(self, command, params=None, **kwargs): + """Send a command to a vacuum cleaner.""" + self.device.run(sucks.VacBotCommand(command, params)) + + @property + def device_state_attributes(self): + """Return the device-specific state attributes of this vacuum.""" + data = {} + data[ATTR_ERROR] = self._error + + for key, val in self.device.components.items(): + attr_name = ATTR_COMPONENT_PREFIX + key + data[attr_name] = int(val * 100) + + return data diff --git a/homeassistant/components/eddystone_temperature/__init__.py b/homeassistant/components/eddystone_temperature/__init__.py new file mode 100644 index 0000000000000..2d6f92498bd79 --- /dev/null +++ b/homeassistant/components/eddystone_temperature/__init__.py @@ -0,0 +1 @@ +"""The eddystone_temperature component.""" diff --git a/homeassistant/components/eddystone_temperature/manifest.json b/homeassistant/components/eddystone_temperature/manifest.json new file mode 100644 index 0000000000000..7cc210c70536c --- /dev/null +++ b/homeassistant/components/eddystone_temperature/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "eddystone_temperature", + "name": "Eddystone", + "documentation": "https://www.home-assistant.io/integrations/eddystone_temperature", + "requirements": ["beacontools[scan]==1.2.3", "construct==2.9.45"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/eddystone_temperature/sensor.py b/homeassistant/components/eddystone_temperature/sensor.py new file mode 100644 index 0000000000000..22d3533d32fbb --- /dev/null +++ b/homeassistant/components/eddystone_temperature/sensor.py @@ -0,0 +1,190 @@ +""" +Read temperature information from Eddystone beacons. + +Your beacons must be configured to transmit UID (for identification) and TLM +(for temperature) frames. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.eddystone_temperature/ +""" +import logging + +# pylint: disable=import-error +from beacontools import BeaconScanner, EddystoneFilter, EddystoneTLMFrame +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_NAME, + EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP, + STATE_UNKNOWN, + TEMP_CELSIUS, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +CONF_BEACONS = "beacons" +CONF_BT_DEVICE_ID = "bt_device_id" +CONF_INSTANCE = "instance" +CONF_NAMESPACE = "namespace" + +BEACON_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAMESPACE): cv.string, + vol.Required(CONF_INSTANCE): cv.string, + vol.Optional(CONF_NAME): cv.string, + } +) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_BT_DEVICE_ID, default=0): cv.positive_int, + vol.Required(CONF_BEACONS): vol.Schema({cv.string: BEACON_SCHEMA}), + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Validate configuration, create devices and start monitoring thread.""" + bt_device_id = config.get("bt_device_id") + + beacons = config.get(CONF_BEACONS) + devices = [] + + for dev_name, properties in beacons.items(): + namespace = get_from_conf(properties, CONF_NAMESPACE, 20) + instance = get_from_conf(properties, CONF_INSTANCE, 12) + name = properties.get(CONF_NAME, dev_name) + + if instance is None or namespace is None: + _LOGGER.error("Skipping %s", dev_name) + continue + + devices.append(EddystoneTemp(name, namespace, instance)) + + if devices: + mon = Monitor(hass, devices, bt_device_id) + + def monitor_stop(_service_or_event): + """Stop the monitor thread.""" + _LOGGER.info("Stopping scanner for Eddystone beacons") + mon.stop() + + def monitor_start(_service_or_event): + """Start the monitor thread.""" + _LOGGER.info("Starting scanner for Eddystone beacons") + mon.start() + + add_entities(devices) + mon.start() + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, monitor_stop) + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, monitor_start) + else: + _LOGGER.warning("No devices were added") + + +def get_from_conf(config, config_key, length): + """Retrieve value from config and validate length.""" + string = config.get(config_key) + if len(string) != length: + _LOGGER.error( + "Error in config parameter %s: Must be exactly %d " + "bytes. Device will not be added", + config_key, + length / 2, + ) + return None + return string + + +class EddystoneTemp(Entity): + """Representation of a temperature sensor.""" + + def __init__(self, name, namespace, instance): + """Initialize a sensor.""" + self._name = name + self.namespace = namespace + self.instance = instance + self.bt_addr = None + self.temperature = STATE_UNKNOWN + + @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.temperature + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return TEMP_CELSIUS + + @property + def should_poll(self): + """Return the polling state.""" + return False + + +class Monitor: + """Continuously scan for BLE advertisements.""" + + def __init__(self, hass, devices, bt_device_id): + """Construct interface object.""" + self.hass = hass + + # List of beacons to monitor + self.devices = devices + # Number of the bt device (hciX) + self.bt_device_id = bt_device_id + + def callback(bt_addr, _, packet, additional_info): + """Handle new packets.""" + self.process_packet( + additional_info["namespace"], + additional_info["instance"], + packet.temperature, + ) + + device_filters = [EddystoneFilter(d.namespace, d.instance) for d in devices] + + self.scanner = BeaconScanner( + callback, bt_device_id, device_filters, EddystoneTLMFrame + ) + self.scanning = False + + def start(self): + """Continuously scan for BLE advertisements.""" + if not self.scanning: + self.scanner.start() + self.scanning = True + else: + _LOGGER.debug("start() called, but scanner is already running") + + def process_packet(self, namespace, instance, temperature): + """Assign temperature to device.""" + _LOGGER.debug( + "Received temperature for <%s,%s>: %d", namespace, instance, temperature + ) + + for dev in self.devices: + if dev.namespace == namespace and dev.instance == instance: + if dev.temperature != temperature: + dev.temperature = temperature + dev.schedule_update_ha_state() + + def stop(self): + """Signal runner to stop and join thread.""" + if self.scanning: + _LOGGER.debug("Stopping...") + self.scanner.stop() + _LOGGER.debug("Stopped") + self.scanning = False + else: + _LOGGER.debug("stop() called but scanner was not running") diff --git a/homeassistant/components/edimax/__init__.py b/homeassistant/components/edimax/__init__.py new file mode 100644 index 0000000000000..33614bf4f9597 --- /dev/null +++ b/homeassistant/components/edimax/__init__.py @@ -0,0 +1 @@ +"""The edimax component.""" diff --git a/homeassistant/components/edimax/manifest.json b/homeassistant/components/edimax/manifest.json new file mode 100644 index 0000000000000..20036311592dc --- /dev/null +++ b/homeassistant/components/edimax/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "edimax", + "name": "Edimax", + "documentation": "https://www.home-assistant.io/integrations/edimax", + "requirements": ["pyedimax==0.1"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/edimax/switch.py b/homeassistant/components/edimax/switch.py new file mode 100644 index 0000000000000..3d558f6c7708f --- /dev/null +++ b/homeassistant/components/edimax/switch.py @@ -0,0 +1,87 @@ +"""Support for Edimax switches.""" +import logging + +from pyedimax.smartplug import SmartPlug +import voluptuous as vol + +from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = "Edimax Smart Plug" +DEFAULT_PASSWORD = "1234" +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_USERNAME, default=DEFAULT_USERNAME): cv.string, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Find and return Edimax Smart Plugs.""" + host = config.get(CONF_HOST) + auth = (config.get(CONF_USERNAME), config.get(CONF_PASSWORD)) + name = config.get(CONF_NAME) + + add_entities([SmartPlugSwitch(SmartPlug(host, auth), name)]) + + +class SmartPlugSwitch(SwitchDevice): + """Representation an Edimax Smart Plug switch.""" + + def __init__(self, smartplug, name): + """Initialize the switch.""" + self.smartplug = smartplug + self._name = name + self._now_power = None + self._now_energy_day = None + self._state = False + + @property + def name(self): + """Return the name of the Smart Plug, if any.""" + return self._name + + @property + def current_power_w(self): + """Return the current power usage in W.""" + return self._now_power + + @property + def today_energy_kwh(self): + """Return the today total energy usage in kWh.""" + return self._now_energy_day + + @property + def is_on(self): + """Return true if switch is on.""" + return self._state + + def turn_on(self, **kwargs): + """Turn the switch on.""" + self.smartplug.state = "ON" + + def turn_off(self, **kwargs): + """Turn the switch off.""" + self.smartplug.state = "OFF" + + def update(self): + """Update edimax switch.""" + try: + self._now_power = float(self.smartplug.now_power) + except (TypeError, ValueError): + self._now_power = None + + try: + self._now_energy_day = float(self.smartplug.now_energy_day) + except (TypeError, ValueError): + self._now_energy_day = None + + self._state = self.smartplug.state == "ON" diff --git a/homeassistant/components/ee_brightbox/__init__.py b/homeassistant/components/ee_brightbox/__init__.py new file mode 100644 index 0000000000000..bec0886ae0749 --- /dev/null +++ b/homeassistant/components/ee_brightbox/__init__.py @@ -0,0 +1 @@ +"""The ee_brightbox component.""" diff --git a/homeassistant/components/ee_brightbox/device_tracker.py b/homeassistant/components/ee_brightbox/device_tracker.py new file mode 100644 index 0000000000000..845d557e029f1 --- /dev/null +++ b/homeassistant/components/ee_brightbox/device_tracker.py @@ -0,0 +1,102 @@ +"""Support for EE Brightbox router.""" +import logging + +from eebrightbox import EEBrightBox, EEBrightBoxException +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__) + +CONF_VERSION = "version" + +CONF_DEFAULT_IP = "192.168.1.1" +CONF_DEFAULT_USERNAME = "admin" +CONF_DEFAULT_VERSION = 2 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_VERSION, default=CONF_DEFAULT_VERSION): cv.positive_int, + vol.Required(CONF_HOST, default=CONF_DEFAULT_IP): cv.string, + vol.Required(CONF_USERNAME, default=CONF_DEFAULT_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + } +) + + +def get_scanner(hass, config): + """Return a router scanner instance.""" + scanner = EEBrightBoxScanner(config[DOMAIN]) + + return scanner if scanner.check_config() else None + + +class EEBrightBoxScanner(DeviceScanner): + """Scan EE Brightbox router.""" + + def __init__(self, config): + """Initialise the scanner.""" + self.config = config + self.devices = {} + + def check_config(self): + """Check if provided configuration and credentials are correct.""" + try: + with EEBrightBox(self.config) as ee_brightbox: + return bool(ee_brightbox.get_devices()) + except EEBrightBoxException: + _LOGGER.exception("Failed to connect to the router") + return False + + def scan_devices(self): + """Scan for devices.""" + with EEBrightBox(self.config) as ee_brightbox: + self.devices = {d["mac"]: d for d in ee_brightbox.get_devices()} + + macs = [d["mac"] for d in self.devices.values() if d["activity_ip"]] + + _LOGGER.debug("Scan devices %s", macs) + + return macs + + def get_device_name(self, device): + """Get the name of a device from hostname.""" + if device in self.devices: + return self.devices[device]["hostname"] or None + + return None + + def get_extra_attributes(self, device): + """ + Get the extra attributes of a device. + + Extra attributes include: + - ip + - mac + - port - ethX or wifiX + - last_active + """ + port_map = { + "wl1": "wifi5Ghz", + "wl0": "wifi2.4Ghz", + "eth0": "eth0", + "eth1": "eth1", + "eth2": "eth2", + "eth3": "eth3", + } + + if device in self.devices: + return { + "ip": self.devices[device]["ip"], + "mac": self.devices[device]["mac"], + "port": port_map[self.devices[device]["port"]], + "last_active": self.devices[device]["time_last_active"], + } + + return {} diff --git a/homeassistant/components/ee_brightbox/manifest.json b/homeassistant/components/ee_brightbox/manifest.json new file mode 100644 index 0000000000000..b0a313a939f62 --- /dev/null +++ b/homeassistant/components/ee_brightbox/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "ee_brightbox", + "name": "EE Bright Box", + "documentation": "https://www.home-assistant.io/integrations/ee_brightbox", + "requirements": ["eebrightbox==0.0.4"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/efergy/__init__.py b/homeassistant/components/efergy/__init__.py new file mode 100644 index 0000000000000..8ceeb1585a496 --- /dev/null +++ b/homeassistant/components/efergy/__init__.py @@ -0,0 +1 @@ +"""The efergy component.""" diff --git a/homeassistant/components/efergy/manifest.json b/homeassistant/components/efergy/manifest.json new file mode 100644 index 0000000000000..99b966c6c501a --- /dev/null +++ b/homeassistant/components/efergy/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "efergy", + "name": "Efergy", + "documentation": "https://www.home-assistant.io/integrations/efergy", + "requirements": [], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/efergy/sensor.py b/homeassistant/components/efergy/sensor.py new file mode 100644 index 0000000000000..3be962fea2f50 --- /dev/null +++ b/homeassistant/components/efergy/sensor.py @@ -0,0 +1,166 @@ +"""Support for Efergy sensors.""" +import logging + +import requests +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_CURRENCY, ENERGY_KILO_WATT_HOUR, POWER_WATT +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) +_RESOURCE = "https://engage.efergy.com/mobile_proxy/" + +CONF_APPTOKEN = "app_token" +CONF_UTC_OFFSET = "utc_offset" +CONF_MONITORED_VARIABLES = "monitored_variables" +CONF_SENSOR_TYPE = "type" + +CONF_PERIOD = "period" + +CONF_INSTANT = "instant_readings" +CONF_AMOUNT = "amount" +CONF_BUDGET = "budget" +CONF_COST = "cost" +CONF_CURRENT_VALUES = "current_values" + +DEFAULT_PERIOD = "year" +DEFAULT_UTC_OFFSET = "0" + +SENSOR_TYPES = { + CONF_INSTANT: ["Energy Usage", POWER_WATT], + CONF_AMOUNT: ["Energy Consumed", ENERGY_KILO_WATT_HOUR], + CONF_BUDGET: ["Energy Budget", None], + CONF_COST: ["Energy Cost", None], + CONF_CURRENT_VALUES: ["Per-Device Usage", POWER_WATT], +} + +TYPES_SCHEMA = vol.In(SENSOR_TYPES) + +SENSORS_SCHEMA = vol.Schema( + { + vol.Required(CONF_SENSOR_TYPE): TYPES_SCHEMA, + vol.Optional(CONF_CURRENCY, default=""): cv.string, + vol.Optional(CONF_PERIOD, default=DEFAULT_PERIOD): cv.string, + } +) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_APPTOKEN): cv.string, + vol.Optional(CONF_UTC_OFFSET, default=DEFAULT_UTC_OFFSET): cv.string, + vol.Required(CONF_MONITORED_VARIABLES): [SENSORS_SCHEMA], + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Efergy sensor.""" + app_token = config.get(CONF_APPTOKEN) + utc_offset = str(config.get(CONF_UTC_OFFSET)) + + dev = [] + for variable in config[CONF_MONITORED_VARIABLES]: + if variable[CONF_SENSOR_TYPE] == CONF_CURRENT_VALUES: + url_string = "{}getCurrentValuesSummary?token={}".format( + _RESOURCE, app_token + ) + response = requests.get(url_string, timeout=10) + for sensor in response.json(): + sid = sensor["sid"] + dev.append( + EfergySensor( + variable[CONF_SENSOR_TYPE], + app_token, + utc_offset, + variable[CONF_PERIOD], + variable[CONF_CURRENCY], + sid, + ) + ) + dev.append( + EfergySensor( + variable[CONF_SENSOR_TYPE], + app_token, + utc_offset, + variable[CONF_PERIOD], + variable[CONF_CURRENCY], + ) + ) + + add_entities(dev, True) + + +class EfergySensor(Entity): + """Implementation of an Efergy sensor.""" + + def __init__(self, sensor_type, app_token, utc_offset, period, currency, sid=None): + """Initialize the sensor.""" + self.sid = sid + if sid: + self._name = f"efergy_{sid}" + else: + self._name = SENSOR_TYPES[sensor_type][0] + self.type = sensor_type + self.app_token = app_token + self.utc_offset = utc_offset + self._state = None + self.period = period + self.currency = currency + if self.type == "cost": + self._unit_of_measurement = f"{self.currency}/{self.period}" + else: + 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 + + def update(self): + """Get the Efergy monitor data from the web service.""" + try: + if self.type == "instant_readings": + url_string = f"{_RESOURCE}getInstant?token={self.app_token}" + response = requests.get(url_string, timeout=10) + self._state = response.json()["reading"] + elif self.type == "amount": + url_string = "{}getEnergy?token={}&offset={}&period={}".format( + _RESOURCE, self.app_token, self.utc_offset, self.period + ) + response = requests.get(url_string, timeout=10) + self._state = response.json()["sum"] + elif self.type == "budget": + url_string = f"{_RESOURCE}getBudget?token={self.app_token}" + response = requests.get(url_string, timeout=10) + self._state = response.json()["status"] + elif self.type == "cost": + url_string = "{}getCost?token={}&offset={}&period={}".format( + _RESOURCE, self.app_token, self.utc_offset, self.period + ) + response = requests.get(url_string, timeout=10) + self._state = response.json()["sum"] + elif self.type == "current_values": + url_string = "{}getCurrentValuesSummary?token={}".format( + _RESOURCE, self.app_token + ) + response = requests.get(url_string, timeout=10) + for sensor in response.json(): + if self.sid == sensor["sid"]: + measurement = next(iter(sensor["data"][0].values())) + self._state = measurement + else: + self._state = None + except (requests.RequestException, ValueError, KeyError): + _LOGGER.warning("Could not update status for %s", self.name) diff --git a/homeassistant/components/egardia/__init__.py b/homeassistant/components/egardia/__init__.py new file mode 100644 index 0000000000000..8b67d23d3cc8d --- /dev/null +++ b/homeassistant/components/egardia/__init__.py @@ -0,0 +1,139 @@ +"""Interfaces with Egardia/Woonveilig alarm control panel.""" +import logging + +from pythonegardia import egardiadevice, egardiaserver +import requests +import voluptuous as vol + +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + EVENT_HOMEASSISTANT_STOP, +) +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +ATTR_DISCOVER_DEVICES = "egardia_sensor" + +CONF_REPORT_SERVER_CODES = "report_server_codes" +CONF_REPORT_SERVER_ENABLED = "report_server_enabled" +CONF_REPORT_SERVER_PORT = "report_server_port" +CONF_VERSION = "version" + +DEFAULT_NAME = "Egardia" +DEFAULT_PORT = 80 +DEFAULT_REPORT_SERVER_ENABLED = False +DEFAULT_REPORT_SERVER_PORT = 52010 +DEFAULT_VERSION = "GATE-01" +DOMAIN = "egardia" + +EGARDIA_DEVICE = "egardiadevice" +EGARDIA_NAME = "egardianame" +EGARDIA_REPORT_SERVER_CODES = "egardia_rs_codes" +EGARDIA_REPORT_SERVER_ENABLED = "egardia_rs_enabled" +EGARDIA_SERVER = "egardia_server" + +NOTIFICATION_ID = "egardia_notification" +NOTIFICATION_TITLE = "Egardia" + +REPORT_SERVER_CODES_IGNORE = "ignore" + +SERVER_CODE_SCHEMA = vol.Schema( + { + vol.Optional("arm"): vol.All(cv.ensure_list_csv, [cv.string]), + vol.Optional("disarm"): vol.All(cv.ensure_list_csv, [cv.string]), + vol.Optional("armhome"): vol.All(cv.ensure_list_csv, [cv.string]), + vol.Optional("triggered"): vol.All(cv.ensure_list_csv, [cv.string]), + vol.Optional("ignore"): vol.All(cv.ensure_list_csv, [cv.string]), + } +) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Optional(CONF_VERSION, default=DEFAULT_VERSION): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_REPORT_SERVER_CODES, default={}): SERVER_CODE_SCHEMA, + vol.Optional( + CONF_REPORT_SERVER_ENABLED, default=DEFAULT_REPORT_SERVER_ENABLED + ): cv.boolean, + vol.Optional( + CONF_REPORT_SERVER_PORT, default=DEFAULT_REPORT_SERVER_PORT + ): cv.port, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +def setup(hass, config): + """Set up the Egardia platform.""" + + conf = config[DOMAIN] + username = conf.get(CONF_USERNAME) + password = conf.get(CONF_PASSWORD) + host = conf.get(CONF_HOST) + port = conf.get(CONF_PORT) + version = conf.get(CONF_VERSION) + rs_enabled = conf.get(CONF_REPORT_SERVER_ENABLED) + rs_port = conf.get(CONF_REPORT_SERVER_PORT) + try: + device = hass.data[EGARDIA_DEVICE] = egardiadevice.EgardiaDevice( + host, port, username, password, "", version + ) + except requests.exceptions.RequestException: + _LOGGER.error( + "An error occurred accessing your Egardia device. " + "Please check configuration" + ) + return False + except egardiadevice.UnauthorizedError: + _LOGGER.error("Unable to authorize. Wrong password or username") + return False + # Set up the egardia server if enabled + if rs_enabled: + _LOGGER.debug("Setting up EgardiaServer") + try: + if EGARDIA_SERVER not in hass.data: + server = egardiaserver.EgardiaServer("", rs_port) + bound = server.bind() + if not bound: + raise OSError( + "Binding error occurred while starting EgardiaServer." + ) + hass.data[EGARDIA_SERVER] = server + server.start() + + def handle_stop_event(event): + """Handle Home Assistant stop event.""" + server.stop() + + # listen to Home Assistant stop event + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, handle_stop_event) + + except OSError: + _LOGGER.error("Binding error occurred while starting EgardiaServer") + return False + + discovery.load_platform( + hass, "alarm_control_panel", DOMAIN, discovered=conf, hass_config=config + ) + + # Get the sensors from the device and add those + sensors = device.getsensors() + discovery.load_platform( + hass, "binary_sensor", DOMAIN, {ATTR_DISCOVER_DEVICES: sensors}, config + ) + + return True diff --git a/homeassistant/components/egardia/alarm_control_panel.py b/homeassistant/components/egardia/alarm_control_panel.py new file mode 100644 index 0000000000000..7e5f88cff3e07 --- /dev/null +++ b/homeassistant/components/egardia/alarm_control_panel.py @@ -0,0 +1,166 @@ +"""Interfaces with Egardia/Woonveilig alarm control panel.""" +import logging + +import requests + +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 ( + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_DISARMED, + STATE_ALARM_TRIGGERED, +) + +from . import ( + CONF_REPORT_SERVER_CODES, + CONF_REPORT_SERVER_ENABLED, + CONF_REPORT_SERVER_PORT, + EGARDIA_DEVICE, + EGARDIA_SERVER, + REPORT_SERVER_CODES_IGNORE, +) + +_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 Alarm Control Panael 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_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 + + async 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 supported_features(self) -> int: + """Return the list of supported features.""" + return SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY + + @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/egardia/binary_sensor.py b/homeassistant/components/egardia/binary_sensor.py new file mode 100644 index 0000000000000..157e4f1fe8c45 --- /dev/null +++ b/homeassistant/components/egardia/binary_sensor.py @@ -0,0 +1,76 @@ +"""Interfaces with Egardia/Woonveilig alarm control panel.""" +import logging + +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.const import STATE_OFF, STATE_ON + +from . import ATTR_DISCOVER_DEVICES, EGARDIA_DEVICE + +_LOGGER = logging.getLogger(__name__) + +EGARDIA_TYPE_TO_DEVICE_CLASS = { + "IR Sensor": "motion", + "Door Contact": "opening", + "IR": "motion", +} + + +async 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] + + 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/egardia/manifest.json b/homeassistant/components/egardia/manifest.json new file mode 100644 index 0000000000000..b0dfc63d929b2 --- /dev/null +++ b/homeassistant/components/egardia/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "egardia", + "name": "Egardia", + "documentation": "https://www.home-assistant.io/integrations/egardia", + "requirements": ["pythonegardia==1.0.40"], + "dependencies": [], + "codeowners": ["@jeroenterheerdt"] +} diff --git a/homeassistant/components/eight_sleep/__init__.py b/homeassistant/components/eight_sleep/__init__.py new file mode 100644 index 0000000000000..a8a5a6e1fccd0 --- /dev/null +++ b/homeassistant/components/eight_sleep/__init__.py @@ -0,0 +1,236 @@ +"""Support for Eight smart mattress covers and mattresses.""" +from datetime import timedelta +import logging + +from pyeight.eight import EightSleep +import voluptuous as vol + +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_BINARY_SENSORS, + CONF_PASSWORD, + CONF_SENSORS, + CONF_USERNAME, + EVENT_HOMEASSISTANT_STOP, +) +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, +) +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__) + +CONF_PARTNER = "partner" + +DATA_EIGHT = "eight_sleep" +DEFAULT_PARTNER = False +DOMAIN = "eight_sleep" + +HEAT_ENTITY = "heat" +USER_ENTITY = "user" + +HEAT_SCAN_INTERVAL = timedelta(seconds=60) +USER_SCAN_INTERVAL = timedelta(seconds=300) + +SIGNAL_UPDATE_HEAT = "eight_heat_update" +SIGNAL_UPDATE_USER = "eight_user_update" + +NAME_MAP = { + "left_current_sleep": "Left Sleep Session", + "left_last_sleep": "Left Previous Sleep Session", + "left_bed_state": "Left Bed State", + "left_presence": "Left Bed Presence", + "left_bed_temp": "Left Bed Temperature", + "left_sleep_stage": "Left Sleep Stage", + "right_current_sleep": "Right Sleep Session", + "right_last_sleep": "Right Previous Sleep Session", + "right_bed_state": "Right Bed State", + "right_presence": "Right Bed Presence", + "right_bed_temp": "Right Bed Temperature", + "right_sleep_stage": "Right Sleep Stage", + "room_temp": "Room Temperature", +} + +SENSORS = ["current_sleep", "last_sleep", "bed_state", "bed_temp", "sleep_stage"] + +SERVICE_HEAT_SET = "heat_set" + +ATTR_TARGET_HEAT = "target" +ATTR_HEAT_DURATION = "duration" + +VALID_TARGET_HEAT = vol.All(vol.Coerce(int), vol.Clamp(min=0, max=100)) +VALID_DURATION = vol.All(vol.Coerce(int), vol.Clamp(min=0, max=28800)) + +SERVICE_EIGHT_SCHEMA = vol.Schema( + { + ATTR_ENTITY_ID: cv.entity_ids, + ATTR_TARGET_HEAT: VALID_TARGET_HEAT, + ATTR_HEAT_DURATION: VALID_DURATION, + } +) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_PARTNER, default=DEFAULT_PARTNER): cv.boolean, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, config): + """Set up the Eight Sleep component.""" + + conf = config.get(DOMAIN) + user = conf.get(CONF_USERNAME) + password = conf.get(CONF_PASSWORD) + partner = conf.get(CONF_PARTNER) + + if hass.config.time_zone is None: + _LOGGER.error("Timezone is not set in Home Assistant.") + return False + + timezone = hass.config.time_zone + + eight = EightSleep(user, password, timezone, partner, None, hass.loop) + + hass.data[DATA_EIGHT] = eight + + # Authenticate, build sensors + success = await eight.start() + if not success: + # Authentication failed, cannot continue + return False + + async def async_update_heat_data(now): + """Update heat data from eight in HEAT_SCAN_INTERVAL.""" + await eight.update_device_data() + async_dispatcher_send(hass, SIGNAL_UPDATE_HEAT) + + async_track_point_in_utc_time( + hass, async_update_heat_data, utcnow() + HEAT_SCAN_INTERVAL + ) + + async def async_update_user_data(now): + """Update user data from eight in USER_SCAN_INTERVAL.""" + await eight.update_user_data() + async_dispatcher_send(hass, SIGNAL_UPDATE_USER) + + async_track_point_in_utc_time( + hass, async_update_user_data, utcnow() + USER_SCAN_INTERVAL + ) + + await async_update_heat_data(None) + await async_update_user_data(None) + + # Load sub components + sensors = [] + binary_sensors = [] + if eight.users: + for user in eight.users: + obj = eight.users[user] + for sensor in SENSORS: + sensors.append(f"{obj.side}_{sensor}") + binary_sensors.append(f"{obj.side}_presence") + sensors.append("room_temp") + else: + # No users, cannot continue + return False + + hass.async_create_task( + discovery.async_load_platform( + hass, "sensor", DOMAIN, {CONF_SENSORS: sensors}, config + ) + ) + + hass.async_create_task( + discovery.async_load_platform( + hass, "binary_sensor", DOMAIN, {CONF_BINARY_SENSORS: binary_sensors}, config + ) + ) + + async def async_service_handler(service): + """Handle eight sleep service calls.""" + params = service.data.copy() + + sensor = params.pop(ATTR_ENTITY_ID, None) + target = params.pop(ATTR_TARGET_HEAT, None) + duration = params.pop(ATTR_HEAT_DURATION, 0) + + for sens in sensor: + side = sens.split("_")[1] + userid = eight.fetch_userid(side) + usrobj = eight.users[userid] + await usrobj.set_heating_level(target, duration) + + async_dispatcher_send(hass, SIGNAL_UPDATE_HEAT) + + # Register services + hass.services.async_register( + DOMAIN, SERVICE_HEAT_SET, async_service_handler, schema=SERVICE_EIGHT_SCHEMA + ) + + async def stop_eight(event): + """Handle stopping eight api session.""" + await eight.stop() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_eight) + + return True + + +class EightSleepUserEntity(Entity): + """The Eight Sleep device entity.""" + + def __init__(self, eight): + """Initialize the data object.""" + self._eight = eight + + async def async_added_to_hass(self): + """Register update dispatcher.""" + + @callback + def async_eight_user_update(): + """Update callback.""" + self.async_schedule_update_ha_state(True) + + async_dispatcher_connect(self.hass, SIGNAL_UPDATE_USER, async_eight_user_update) + + @property + def should_poll(self): + """Return True if entity has to be polled for state.""" + return False + + +class EightSleepHeatEntity(Entity): + """The Eight Sleep device entity.""" + + def __init__(self, eight): + """Initialize the data object.""" + self._eight = eight + + async def async_added_to_hass(self): + """Register update dispatcher.""" + + @callback + def async_eight_heat_update(): + """Update callback.""" + self.async_schedule_update_ha_state(True) + + async_dispatcher_connect(self.hass, SIGNAL_UPDATE_HEAT, async_eight_heat_update) + + @property + def should_poll(self): + """Return True if entity has to be polled for state.""" + return False diff --git a/homeassistant/components/eight_sleep/binary_sensor.py b/homeassistant/components/eight_sleep/binary_sensor.py new file mode 100644 index 0000000000000..7b801578ccd4b --- /dev/null +++ b/homeassistant/components/eight_sleep/binary_sensor.py @@ -0,0 +1,63 @@ +"""Support for Eight Sleep binary sensors.""" +import logging + +from homeassistant.components.binary_sensor import BinarySensorDevice + +from . import CONF_BINARY_SENSORS, DATA_EIGHT, NAME_MAP, EightSleepHeatEntity + +_LOGGER = logging.getLogger(__name__) + + +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 = f"{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/eight_sleep/manifest.json b/homeassistant/components/eight_sleep/manifest.json new file mode 100644 index 0000000000000..75998e71e5f74 --- /dev/null +++ b/homeassistant/components/eight_sleep/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "eight_sleep", + "name": "Eight Sleep", + "documentation": "https://www.home-assistant.io/integrations/eight_sleep", + "requirements": ["pyeight==0.1.2"], + "dependencies": [], + "codeowners": ["@mezz64"] +} diff --git a/homeassistant/components/eight_sleep/sensor.py b/homeassistant/components/eight_sleep/sensor.py new file mode 100644 index 0000000000000..d3d54fd58caa9 --- /dev/null +++ b/homeassistant/components/eight_sleep/sensor.py @@ -0,0 +1,301 @@ +"""Support for Eight Sleep sensors.""" +import logging + +from . import ( + CONF_SENSORS, + DATA_EIGHT, + NAME_MAP, + EightSleepHeatEntity, + EightSleepUserEntity, +) + +ATTR_ROOM_TEMP = "Room Temperature" +ATTR_AVG_ROOM_TEMP = "Average Room Temperature" +ATTR_BED_TEMP = "Bed Temperature" +ATTR_AVG_BED_TEMP = "Average Bed Temperature" +ATTR_RESP_RATE = "Respiratory Rate" +ATTR_AVG_RESP_RATE = "Average Respiratory Rate" +ATTR_HEART_RATE = "Heart Rate" +ATTR_AVG_HEART_RATE = "Average Heart Rate" +ATTR_SLEEP_DUR = "Time Slept" +ATTR_LIGHT_PERC = "Light Sleep %" +ATTR_DEEP_PERC = "Deep Sleep %" +ATTR_REM_PERC = "REM Sleep %" +ATTR_TNT = "Tosses & Turns" +ATTR_SLEEP_STAGE = "Sleep Stage" +ATTR_TARGET_HEAT = "Target Heating Level" +ATTR_ACTIVE_HEAT = "Heating Active" +ATTR_DURATION_HEAT = "Heating Time Remaining" +ATTR_PROCESSING = "Processing" +ATTR_SESSION_START = "Session Start" + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the eight sleep sensors.""" + if discovery_info is None: + return + + name = "Eight" + sensors = discovery_info[CONF_SENSORS] + eight = hass.data[DATA_EIGHT] + + if hass.config.units.is_metric: + units = "si" + else: + units = "us" + + all_sensors = [] + + for sensor in sensors: + if "bed_state" in sensor: + all_sensors.append(EightHeatSensor(name, eight, sensor)) + elif "room_temp" in sensor: + all_sensors.append(EightRoomSensor(name, eight, sensor, units)) + else: + all_sensors.append(EightUserSensor(name, eight, sensor, units)) + + async_add_entities(all_sensors, True) + + +class EightHeatSensor(EightSleepHeatEntity): + """Representation of an 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 = f"{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( + "Heat 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 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 "%" + + async def async_update(self): + """Retrieve latest state.""" + _LOGGER.debug("Updating Heat sensor: %s", self._sensor) + self._state = self._usrobj.heating_level + + @property + def device_state_attributes(self): + """Return device state attributes.""" + state_attr = {ATTR_TARGET_HEAT: self._usrobj.target_heating_level} + state_attr[ATTR_ACTIVE_HEAT] = self._usrobj.now_heating + state_attr[ATTR_DURATION_HEAT] = self._usrobj.heating_remaining + + return state_attr + + +class EightUserSensor(EightSleepUserEntity): + """Representation of an eight sleep user-based sensor.""" + + def __init__(self, name, eight, sensor, units): + """Initialize the sensor.""" + super().__init__(eight) + + self._sensor = sensor + self._sensor_root = self._sensor.split("_", 1)[1] + self._mapped_name = NAME_MAP.get(self._sensor, self._sensor) + self._name = f"{name} {self._mapped_name}" + self._state = None + self._attr = None + self._units = units + + self._side = self._sensor.split("_", 1)[0] + self._userid = self._eight.fetch_userid(self._side) + self._usrobj = self._eight.users[self._userid] + + _LOGGER.debug( + "User 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 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.""" + if "current_sleep" in self._sensor or "last_sleep" in self._sensor: + return "Score" + if "bed_temp" in self._sensor: + if self._units == "si": + return "°C" + return "°F" + return None + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + if "bed_temp" in self._sensor: + return "mdi:thermometer" + + async def async_update(self): + """Retrieve latest state.""" + _LOGGER.debug("Updating User sensor: %s", self._sensor) + if "current" in self._sensor: + self._state = self._usrobj.current_sleep_score + self._attr = self._usrobj.current_values + elif "last" in self._sensor: + self._state = self._usrobj.last_sleep_score + self._attr = self._usrobj.last_values + elif "bed_temp" in self._sensor: + temp = self._usrobj.current_values["bed_temp"] + try: + if self._units == "si": + self._state = round(temp, 2) + else: + self._state = round((temp * 1.8) + 32, 2) + except TypeError: + self._state = None + elif "sleep_stage" in self._sensor: + self._state = self._usrobj.current_values["stage"] + + @property + def device_state_attributes(self): + """Return device state attributes.""" + if self._attr is None: + # Skip attributes if sensor type doesn't support + return None + + state_attr = {ATTR_SESSION_START: self._attr["date"]} + state_attr[ATTR_TNT] = self._attr["tnt"] + state_attr[ATTR_PROCESSING] = self._attr["processing"] + + sleep_time = ( + sum(self._attr["breakdown"].values()) - self._attr["breakdown"]["awake"] + ) + state_attr[ATTR_SLEEP_DUR] = sleep_time + try: + state_attr[ATTR_LIGHT_PERC] = round( + (self._attr["breakdown"]["light"] / sleep_time) * 100, 2 + ) + except ZeroDivisionError: + state_attr[ATTR_LIGHT_PERC] = 0 + try: + state_attr[ATTR_DEEP_PERC] = round( + (self._attr["breakdown"]["deep"] / sleep_time) * 100, 2 + ) + except ZeroDivisionError: + state_attr[ATTR_DEEP_PERC] = 0 + + try: + state_attr[ATTR_REM_PERC] = round( + (self._attr["breakdown"]["rem"] / sleep_time) * 100, 2 + ) + except ZeroDivisionError: + state_attr[ATTR_REM_PERC] = 0 + + try: + if self._units == "si": + room_temp = round(self._attr["room_temp"], 2) + else: + room_temp = round((self._attr["room_temp"] * 1.8) + 32, 2) + except TypeError: + room_temp = None + + try: + if self._units == "si": + bed_temp = round(self._attr["bed_temp"], 2) + else: + bed_temp = round((self._attr["bed_temp"] * 1.8) + 32, 2) + except TypeError: + bed_temp = None + + if "current" in self._sensor_root: + state_attr[ATTR_RESP_RATE] = round(self._attr["resp_rate"], 2) + state_attr[ATTR_HEART_RATE] = round(self._attr["heart_rate"], 2) + state_attr[ATTR_SLEEP_STAGE] = self._attr["stage"] + state_attr[ATTR_ROOM_TEMP] = room_temp + state_attr[ATTR_BED_TEMP] = bed_temp + elif "last" in self._sensor_root: + state_attr[ATTR_AVG_RESP_RATE] = round(self._attr["resp_rate"], 2) + state_attr[ATTR_AVG_HEART_RATE] = round(self._attr["heart_rate"], 2) + state_attr[ATTR_AVG_ROOM_TEMP] = room_temp + state_attr[ATTR_AVG_BED_TEMP] = bed_temp + + return state_attr + + +class EightRoomSensor(EightSleepUserEntity): + """Representation of an eight sleep room sensor.""" + + def __init__(self, name, eight, sensor, units): + """Initialize the sensor.""" + super().__init__(eight) + + self._sensor = sensor + self._mapped_name = NAME_MAP.get(self._sensor, self._sensor) + self._name = f"{name} {self._mapped_name}" + self._state = None + self._attr = None + self._units = units + + @property + def name(self): + """Return the name of the sensor, if any.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + async def async_update(self): + """Retrieve latest state.""" + _LOGGER.debug("Updating Room sensor: %s", self._sensor) + temp = self._eight.room_temperature() + try: + if self._units == "si": + self._state = round(temp, 2) + else: + self._state = round((temp * 1.8) + 32, 2) + except TypeError: + self._state = None + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + if self._units == "si": + return "°C" + return "°F" + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return "mdi:thermometer" diff --git a/homeassistant/components/eight_sleep/services.yaml b/homeassistant/components/eight_sleep/services.yaml new file mode 100644 index 0000000000000..db7690730dd91 --- /dev/null +++ b/homeassistant/components/eight_sleep/services.yaml @@ -0,0 +1,6 @@ +heat_set: + description: Set heating level for eight sleep. + fields: + duration: {description: Duration to heat at the target level in seconds., example: 3600} + entity_id: {description: Entity id of the bed state to adjust., example: sensor.eight_left_bed_state} + target: {description: Target heating level from 0-100., example: 35} diff --git a/homeassistant/components/elgato/.translations/ca.json b/homeassistant/components/elgato/.translations/ca.json new file mode 100644 index 0000000000000..b717a5abadee2 --- /dev/null +++ b/homeassistant/components/elgato/.translations/ca.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Aquest dispositiu Elgato Key Light ja est\u00e0 configurat.", + "connection_error": "No s'ha pogut connectar amb el dispositiu Elgato Key Light." + }, + "error": { + "connection_error": "No s'ha pogut connectar amb el dispositiu Elgato Key Light." + }, + "flow_title": "Elgato Key Light: {serial_number}", + "step": { + "user": { + "data": { + "host": "Amfitri\u00f3 o adre\u00e7a IP", + "port": "N\u00famero de port" + }, + "description": "Configura l'Elgato Key Light per integrar-lo amb Home Assistant.", + "title": "Enlla\u00e7a Elgato Key Light" + }, + "zeroconf_confirm": { + "description": "Vols afegir l'Elgato Key Light amb n\u00famero de s\u00e8rie `{serial_number}` a Home Assistant?", + "title": "S'ha descobert un dispositiu Elgato Key Light" + } + }, + "title": "Elgato Key Light" + } +} \ No newline at end of file diff --git a/homeassistant/components/elgato/.translations/da.json b/homeassistant/components/elgato/.translations/da.json new file mode 100644 index 0000000000000..a10e4d9e89f41 --- /dev/null +++ b/homeassistant/components/elgato/.translations/da.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Denne Elgato Key Light-enhed er allerede konfigureret.", + "connection_error": "Kunne ikke oprette forbindelse til Elgato Key Light-enheden." + }, + "error": { + "connection_error": "Kunne ikke oprette forbindelse til Elgato Key Light-enheden." + }, + "flow_title": "Elgato Key Light: {serial_number}", + "step": { + "user": { + "data": { + "host": "V\u00e6rt eller IP-adresse", + "port": "Portnummer" + }, + "description": "Indstil din Elgato Key Light til at integrere med Home Assistant.", + "title": "Forbind din Elgato Key Light" + }, + "zeroconf_confirm": { + "description": "Vil du tilf\u00f8je Elgato Key Light med serienummer `{serial_number}` til Home Assistant?", + "title": "Fandt Elgato Key Light-enhed" + } + }, + "title": "Elgato Key Light" + } +} \ No newline at end of file diff --git a/homeassistant/components/elgato/.translations/de.json b/homeassistant/components/elgato/.translations/de.json new file mode 100644 index 0000000000000..dd6344916dea0 --- /dev/null +++ b/homeassistant/components/elgato/.translations/de.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Dieses Elgato Key Light-Ger\u00e4t ist bereits konfiguriert.", + "connection_error": "Verbindung zum Elgato Key Light-Ger\u00e4t fehlgeschlagen." + }, + "error": { + "connection_error": "Verbindung zum Elgato Key Light-Ger\u00e4t fehlgeschlagen." + }, + "flow_title": "Elgato Key Light: {serial_number}", + "step": { + "user": { + "data": { + "host": "Host oder IP-Adresse", + "port": "Port-Nummer" + }, + "description": "Richten Sie Ihr Elgato Key Light f\u00fcr die Integration mit Home Assistant ein.", + "title": "Verkn\u00fcpfen Sie Ihr Elgato Key Light" + }, + "zeroconf_confirm": { + "description": "M\u00f6chten Sie das Elgato Key Light mit der Seriennummer \"{serial_number} \" zu Home Assistant hinzuf\u00fcgen?", + "title": "Elgato Key Light Ger\u00e4t entdeckt" + } + }, + "title": "Elgato Key Light" + } +} \ No newline at end of file diff --git a/homeassistant/components/elgato/.translations/en.json b/homeassistant/components/elgato/.translations/en.json new file mode 100644 index 0000000000000..d52003d10e162 --- /dev/null +++ b/homeassistant/components/elgato/.translations/en.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "This Elgato Key Light device is already configured.", + "connection_error": "Failed to connect to Elgato Key Light device." + }, + "error": { + "connection_error": "Failed to connect to Elgato Key Light device." + }, + "flow_title": "Elgato Key Light: {serial_number}", + "step": { + "user": { + "data": { + "host": "Host or IP address", + "port": "Port number" + }, + "description": "Set up your Elgato Key Light to integrate with Home Assistant.", + "title": "Link your Elgato Key Light" + }, + "zeroconf_confirm": { + "description": "Do you want to add the Elgato Key Light with serial number `{serial_number}` to Home Assistant?", + "title": "Discovered Elgato Key Light device" + } + }, + "title": "Elgato Key Light" + } +} \ No newline at end of file diff --git a/homeassistant/components/elgato/.translations/es.json b/homeassistant/components/elgato/.translations/es.json new file mode 100644 index 0000000000000..2e689b5e064a0 --- /dev/null +++ b/homeassistant/components/elgato/.translations/es.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Este dispositivo Elgato Key Light ya est\u00e1 configurado.", + "connection_error": "No se pudo conectar al dispositivo Elgato Key Light." + }, + "error": { + "connection_error": "No se pudo conectar al dispositivo Elgato Key Light." + }, + "flow_title": "Elgato Key Light: {serial_number}", + "step": { + "user": { + "data": { + "host": "Host o direcci\u00f3n IP", + "port": "N\u00famero de puerto" + }, + "description": "Configura tu Elgato Key Light para integrarlo con Home Assistant.", + "title": "Conecte su Elgato Key Light" + }, + "zeroconf_confirm": { + "description": "\u00bfDesea agregar Elgato Key Light con el n\u00famero de serie `{serial_number}` a Home Assistant?", + "title": "Descubierto dispositivo Elgato Key Light" + } + }, + "title": "Elgato Key Light" + } +} \ No newline at end of file diff --git a/homeassistant/components/elgato/.translations/fr.json b/homeassistant/components/elgato/.translations/fr.json new file mode 100644 index 0000000000000..e8465a56728d4 --- /dev/null +++ b/homeassistant/components/elgato/.translations/fr.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Cet appareil Elgato Key Light est d\u00e9j\u00e0 configur\u00e9.", + "connection_error": "Impossible de se connecter au p\u00e9riph\u00e9rique Elgato Key Light." + }, + "error": { + "connection_error": "Impossible de se connecter au p\u00e9riph\u00e9rique Elgato Key Light." + }, + "flow_title": "Elgato Key Light: {serial_number}", + "step": { + "user": { + "data": { + "host": "H\u00f4te ou adresse IP", + "port": "Port" + }, + "description": "Configurez votre Elgato Key Light pour l'int\u00e9grer \u00e0 Home Assistant.", + "title": "Associez votre Elgato Key Light" + }, + "zeroconf_confirm": { + "description": "Voulez-vous ajouter l'Elgato Key Light avec le num\u00e9ro de s\u00e9rie `{serial_number}` \u00e0 Home Assistant?", + "title": "Appareil Elgato Key Light d\u00e9couvert" + } + }, + "title": "Elgato Key Light" + } +} \ No newline at end of file diff --git a/homeassistant/components/elgato/.translations/it.json b/homeassistant/components/elgato/.translations/it.json new file mode 100644 index 0000000000000..81e363aa01b08 --- /dev/null +++ b/homeassistant/components/elgato/.translations/it.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Questo dispositivo Elgato Key Light \u00e8 gi\u00e0 configurato.", + "connection_error": "Impossibile connettersi al dispositivo Elgato Key Light." + }, + "error": { + "connection_error": "Impossibile connettersi al dispositivo Elgato Key Light." + }, + "flow_title": "Elgato Key Light: {serial_number}", + "step": { + "user": { + "data": { + "host": "Host o indirizzo IP", + "port": "Numero porta" + }, + "description": "Configura Elgato Key Light per l'integrazione con Home Assistant.", + "title": "Collega il tuo Elgato Key Light" + }, + "zeroconf_confirm": { + "description": "Vuoi aggiungere il dispositivo Elgato Key Light con il numero di serie {serial_number} a Home Assistant?", + "title": "Dispositivo Elgato Key Light rilevato" + } + }, + "title": "Elgato Key Light" + } +} \ No newline at end of file diff --git a/homeassistant/components/elgato/.translations/ko.json b/homeassistant/components/elgato/.translations/ko.json new file mode 100644 index 0000000000000..9d7ab4ef2b0e7 --- /dev/null +++ b/homeassistant/components/elgato/.translations/ko.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Elgato Key Light \uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", + "connection_error": "Elgato Key Light \uae30\uae30\uc5d0 \uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4." + }, + "error": { + "connection_error": "Elgato Key Light \uae30\uae30\uc5d0 \uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4." + }, + "flow_title": "Elgato Key Light: {serial_number}", + "step": { + "user": { + "data": { + "host": "\ud638\uc2a4\ud2b8 \ub610\ub294 IP \uc8fc\uc18c", + "port": "\ud3ec\ud2b8 \ubc88\ud638" + }, + "description": "Home Assistant \uc5d0 Elgato Key Light \ud1b5\ud569 \uad6c\uc131\uc694\uc18c\ub97c \uc124\uc815\ud569\ub2c8\ub2e4.", + "title": "Elgato Key Light \uc5f0\uacb0" + }, + "zeroconf_confirm": { + "description": "Elgato Key Light \uc2dc\ub9ac\uc5bc \ubc88\ud638 `{serial_number}` \uc744(\ub97c) Home Assistant \uc5d0 \ucd94\uac00\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "title": "\ubc1c\uacac\ub41c Elgato Key Light \uae30\uae30" + } + }, + "title": "Elgato Key Light" + } +} \ No newline at end of file diff --git a/homeassistant/components/elgato/.translations/lb.json b/homeassistant/components/elgato/.translations/lb.json new file mode 100644 index 0000000000000..d53eea87c4cd2 --- /dev/null +++ b/homeassistant/components/elgato/.translations/lb.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "D\u00ebsen Elgato Key Light Apparat ass scho konfigur\u00e9iert.", + "connection_error": "Feeler beim verbannen mam Elgato key Light Apparat." + }, + "error": { + "connection_error": "Feeler beim verbannen mam Elgato key Light Apparat." + }, + "flow_title": "Elgato Key Light: {serial_number}", + "step": { + "user": { + "data": { + "host": "Numm oder IP Adresse", + "port": "Port Nummer" + }, + "description": "\u00c4ren Elgator Key Light als Integratioun mam Home Assistant ariichten.", + "title": "\u00c4ren Elgato Key Light verbannen" + }, + "zeroconf_confirm": { + "description": "W\u00ebllt dir den Elgato Key Light mat der Seriennummer `{serial_number}` am 'Home Assistant dob\u00e4isetzen?", + "title": "Entdeckten Elgato Key Light Apparat" + } + }, + "title": "Elgato Key Light" + } +} \ No newline at end of file diff --git a/homeassistant/components/elgato/.translations/nl.json b/homeassistant/components/elgato/.translations/nl.json new file mode 100644 index 0000000000000..ca05983eeb57d --- /dev/null +++ b/homeassistant/components/elgato/.translations/nl.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Dit Elgato Key Light apparaat is al geconfigureerd.", + "connection_error": "Kan geen verbinding maken met het Elgato Key Light apparaat." + }, + "error": { + "connection_error": "Kan geen verbinding maken met het Elgato Key Light apparaat." + }, + "flow_title": "Elgato Key Light: {serial_number}", + "step": { + "user": { + "data": { + "host": "Hostnaam of IP-adres", + "port": "Poortnummer" + }, + "description": "Stel uw Elgato Key Light in om te integreren met Home Assistant.", + "title": "Koppel uw Elgato Key Light" + }, + "zeroconf_confirm": { + "description": "Wilt u de Elgato Key Light met serienummer ` {serial_number} ` toevoegen aan Home Assistant?", + "title": "Elgato Key Light apparaat ontdekt" + } + }, + "title": "Elgato Key Light" + } +} \ No newline at end of file diff --git a/homeassistant/components/elgato/.translations/no.json b/homeassistant/components/elgato/.translations/no.json new file mode 100644 index 0000000000000..8642ae7502573 --- /dev/null +++ b/homeassistant/components/elgato/.translations/no.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Denne Elgato Key Light-enheten er allerede konfigurert.", + "connection_error": "Kunne ikke koble til Elgato Key Light-enheten." + }, + "error": { + "connection_error": "Kunne ikke koble til Elgato Key Light-enheten." + }, + "flow_title": "Elgato Key Light: {serial_number}", + "step": { + "user": { + "data": { + "host": "Vert eller IP-adresse", + "port": "Portnummer" + }, + "description": "Sett opp Elgato Key Light for \u00e5 integrere med Home Assistant.", + "title": "Linken ditt Elgato Key Light" + }, + "zeroconf_confirm": { + "description": "Vil du legge Elgato Key Light med serienummer ` {serial_number} til Home Assistant?", + "title": "Oppdaget Elgato Key Light-enheten" + } + }, + "title": "Elgato Key Light" + } +} \ No newline at end of file diff --git a/homeassistant/components/elgato/.translations/pl.json b/homeassistant/components/elgato/.translations/pl.json new file mode 100644 index 0000000000000..97e10b451f042 --- /dev/null +++ b/homeassistant/components/elgato/.translations/pl.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "To urz\u0105dzenie Elgato Key Light jest ju\u017c skonfigurowane.", + "connection_error": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia z urz\u0105dzeniem Elgato Key Light." + }, + "error": { + "connection_error": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia z urz\u0105dzeniem Elgato Key Light." + }, + "flow_title": "Elgato Key Light: {serial_number}", + "step": { + "user": { + "data": { + "host": "Nazwa hosta lub adres IP", + "port": "Port" + }, + "description": "Konfiguracja Elgato Key Light w celu integracji z Home Assistant'em.", + "title": "Po\u0142\u0105cz swoje Elgato Key Light" + }, + "zeroconf_confirm": { + "description": "Czy chcesz doda\u0107 urz\u0105dzenie Elgato Key Light o numerze seryjnym `{serial_number}` do Home Assistant'a?", + "title": "Wykryto urz\u0105dzenie Elgato Key Light" + } + }, + "title": "Elgato Key Light" + } +} \ No newline at end of file diff --git a/homeassistant/components/elgato/.translations/pt-BR.json b/homeassistant/components/elgato/.translations/pt-BR.json new file mode 100644 index 0000000000000..d809647c99f90 --- /dev/null +++ b/homeassistant/components/elgato/.translations/pt-BR.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "zeroconf_confirm": { + "description": "Deseja adicionar o Elgato Key Light n\u00famero de s\u00e9rie ` {serial_number} ` ao Home Assistant?", + "title": "Dispositivo Elgato Key Light descoberto" + } + }, + "title": "Elgato Key Light" + } +} \ No newline at end of file diff --git a/homeassistant/components/elgato/.translations/ru.json b/homeassistant/components/elgato/.translations/ru.json new file mode 100644 index 0000000000000..7f52b5adee590 --- /dev/null +++ b/homeassistant/components/elgato/.translations/ru.json @@ -0,0 +1,27 @@ +{ + "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.", + "connection_error": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443 Elgato Key Light." + }, + "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 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443 Elgato Key Light." + }, + "flow_title": "Elgato Key Light: {serial_number}", + "step": { + "user": { + "data": { + "host": "\u0414\u043e\u043c\u0435\u043d\u043d\u043e\u0435 \u0438\u043c\u044f \u0438\u043b\u0438 IP-\u0430\u0434\u0440\u0435\u0441", + "port": "\u041d\u043e\u043c\u0435\u0440 \u043f\u043e\u0440\u0442\u0430" + }, + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 Elgato Key Light \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 Home Assistant.", + "title": "Elgato Key Light" + }, + "zeroconf_confirm": { + "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c Elgato Key Light \u0441 \u0441\u0435\u0440\u0438\u0439\u043d\u044b\u043c \u043d\u043e\u043c\u0435\u0440\u043e\u043c `{serial_number}`?", + "title": "\u041d\u0430\u0439\u0434\u0435\u043d\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e Elgado Key Light" + } + }, + "title": "\u041e\u0441\u0432\u0435\u0442\u0438\u0442\u0435\u043b\u044c Elgado Key Light" + } +} \ No newline at end of file diff --git a/homeassistant/components/elgato/.translations/sl.json b/homeassistant/components/elgato/.translations/sl.json new file mode 100644 index 0000000000000..f05b0bcbd8ffc --- /dev/null +++ b/homeassistant/components/elgato/.translations/sl.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Ta naprava Elgato Key Light je \u017ee nastavljena.", + "connection_error": "Povezava z napravo Elgato Key Light ni uspela." + }, + "error": { + "connection_error": "Povezava z napravo Elgato Key Light ni uspela." + }, + "flow_title": "Elgato Key Light: {serial_number}", + "step": { + "user": { + "data": { + "host": "Gostitelj ali IP naslov", + "port": "\u0160tevilka vrat" + }, + "description": "Nastavite svojo Elgato Key Light tako, da se bo vklju\u010dila v Home Assistant.", + "title": "Pove\u017eite svojo Elgato Key Light" + }, + "zeroconf_confirm": { + "description": "Ali \u017eelite dodati Elgato Key Light s serijsko \u0161tevilko ' {serial_number} ' v Home Assistant-a?", + "title": "Odkrita naprava Elgato Key Light" + } + }, + "title": "Elgato Key Light" + } +} \ No newline at end of file diff --git a/homeassistant/components/elgato/.translations/zh-Hant.json b/homeassistant/components/elgato/.translations/zh-Hant.json new file mode 100644 index 0000000000000..b187abc5ccd73 --- /dev/null +++ b/homeassistant/components/elgato/.translations/zh-Hant.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Elgato Key \u7167\u660e\u8a2d\u5099\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210\u3002", + "connection_error": "Elgato Key \u7167\u660e\u8a2d\u5099\u9023\u7dda\u5931\u6557\u3002" + }, + "error": { + "connection_error": "Elgato Key \u7167\u660e\u8a2d\u5099\u9023\u7dda\u5931\u6557\u3002" + }, + "flow_title": "Elgato Key \u7167\u660e\uff1a{serial_number}", + "step": { + "user": { + "data": { + "host": "\u4e3b\u6a5f\u6216 IP \u4f4d\u5740", + "port": "\u901a\u8a0a\u57e0" + }, + "description": "\u8a2d\u5b9a Elgato Key \u7167\u660e\u4ee5\u6574\u5408\u81f3 Home Assistant\u3002", + "title": "\u9023\u7d50 Elgato Key \u7167\u660e\u3002" + }, + "zeroconf_confirm": { + "description": "\u662f\u5426\u8981\u5c07 Elgato Key \u7167\u660e\u5e8f\u865f `{serial_number}` \u65b0\u589e\u81f3 Home Assistant\uff1f", + "title": "\u767c\u73fe\u5230 Elgato Key \u7167\u660e\u8a2d\u5099" + } + }, + "title": "Elgato Key \u7167\u660e" + } +} \ No newline at end of file diff --git a/homeassistant/components/elgato/__init__.py b/homeassistant/components/elgato/__init__.py new file mode 100644 index 0000000000000..993748033b53a --- /dev/null +++ b/homeassistant/components/elgato/__init__.py @@ -0,0 +1,55 @@ +"""Support for Elgato Key Lights.""" +import logging + +from elgato import Elgato, ElgatoConnectionError + +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import ConfigType + +from .const import DATA_ELGATO_CLIENT, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Elgato Key Light components.""" + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Elgato Key Light from a config entry.""" + session = async_get_clientsession(hass) + elgato = Elgato(entry.data[CONF_HOST], port=entry.data[CONF_PORT], session=session,) + + # Ensure we can connect to it + try: + await elgato.info() + except ElgatoConnectionError as exception: + raise ConfigEntryNotReady from exception + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = {DATA_ELGATO_CLIENT: elgato} + + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, LIGHT_DOMAIN) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Elgato Key Light config entry.""" + # Unload entities for this entry/device. + await hass.config_entries.async_forward_entry_unload(entry, LIGHT_DOMAIN) + + # Cleanup + del hass.data[DOMAIN][entry.entry_id] + if not hass.data[DOMAIN]: + del hass.data[DOMAIN] + + return True diff --git a/homeassistant/components/elgato/config_flow.py b/homeassistant/components/elgato/config_flow.py new file mode 100644 index 0000000000000..a8a81734999e4 --- /dev/null +++ b/homeassistant/components/elgato/config_flow.py @@ -0,0 +1,136 @@ +"""Config flow to configure the Elgato Key Light integration.""" +import logging +from typing import Any, Dict, Optional + +from elgato import Elgato, ElgatoError, Info +import voluptuous as vol + +from homeassistant.config_entries import CONN_CLASS_LOCAL_POLL, ConfigFlow +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.helpers import ConfigType +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_SERIAL_NUMBER, DOMAIN # pylint: disable=unused-import + +_LOGGER = logging.getLogger(__name__) + + +class ElgatoFlowHandler(ConfigFlow, domain=DOMAIN): + """Handle a Elgato Key Light config flow.""" + + VERSION = 1 + CONNECTION_CLASS = CONN_CLASS_LOCAL_POLL + + async def async_step_user( + self, user_input: Optional[ConfigType] = None + ) -> Dict[str, Any]: + """Handle a flow initiated by the user.""" + if user_input is None: + return self._show_setup_form() + + try: + info = await self._get_elgato_info( + user_input[CONF_HOST], user_input[CONF_PORT] + ) + except ElgatoError: + return self._show_setup_form({"base": "connection_error"}) + + # Check if already configured + await self.async_set_unique_id(info.serial_number) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=info.serial_number, + data={ + CONF_HOST: user_input[CONF_HOST], + CONF_PORT: user_input[CONF_PORT], + CONF_SERIAL_NUMBER: info.serial_number, + }, + ) + + async def async_step_zeroconf( + self, user_input: Optional[ConfigType] = None + ) -> Dict[str, Any]: + """Handle zeroconf discovery.""" + if user_input is None: + return self.async_abort(reason="connection_error") + + # Hostname is format: my-ke.local. + host = user_input["hostname"].rstrip(".") + try: + info = await self._get_elgato_info(host, user_input[CONF_PORT]) + except ElgatoError: + return self.async_abort(reason="connection_error") + + # Check if already configured + await self.async_set_unique_id(info.serial_number) + self._abort_if_unique_id_configured() + + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + self.context.update( + { + CONF_HOST: host, + CONF_PORT: user_input[CONF_PORT], + CONF_SERIAL_NUMBER: info.serial_number, + "title_placeholders": {"serial_number": info.serial_number}, + } + ) + + # Prepare configuration flow + return self._show_confirm_dialog() + + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + async def async_step_zeroconf_confirm( + self, user_input: ConfigType = None + ) -> Dict[str, Any]: + """Handle a flow initiated by zeroconf.""" + if user_input is None: + return self._show_confirm_dialog() + + try: + info = await self._get_elgato_info( + self.context.get(CONF_HOST), self.context.get(CONF_PORT) + ) + except ElgatoError: + return self.async_abort(reason="connection_error") + + # Check if already configured + await self.async_set_unique_id(info.serial_number) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=self.context.get(CONF_SERIAL_NUMBER), + data={ + CONF_HOST: self.context.get(CONF_HOST), + CONF_PORT: self.context.get(CONF_PORT), + CONF_SERIAL_NUMBER: self.context.get(CONF_SERIAL_NUMBER), + }, + ) + + def _show_setup_form(self, errors: Optional[Dict] = None) -> Dict[str, Any]: + """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.Optional(CONF_PORT, default=9123): int, + } + ), + errors=errors or {}, + ) + + def _show_confirm_dialog(self) -> Dict[str, Any]: + """Show the confirm dialog to the user.""" + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + serial_number = self.context.get(CONF_SERIAL_NUMBER) + return self.async_show_form( + step_id="zeroconf_confirm", + description_placeholders={"serial_number": serial_number}, + ) + + async def _get_elgato_info(self, host: str, port: int) -> Info: + """Get device information from an Elgato Key Light device.""" + session = async_get_clientsession(self.hass) + elgato = Elgato(host, port=port, session=session,) + return await elgato.info() diff --git a/homeassistant/components/elgato/const.py b/homeassistant/components/elgato/const.py new file mode 100644 index 0000000000000..2b6caa37a8f9c --- /dev/null +++ b/homeassistant/components/elgato/const.py @@ -0,0 +1,17 @@ +"""Constants for the Elgato Key Light integration.""" + +# Integration domain +DOMAIN = "elgato" + +# Home Assistant data keys +DATA_ELGATO_CLIENT = "elgato_client" + +# Attributes +ATTR_IDENTIFIERS = "identifiers" +ATTR_MANUFACTURER = "manufacturer" +ATTR_MODEL = "model" +ATTR_ON = "on" +ATTR_SOFTWARE_VERSION = "sw_version" +ATTR_TEMPERATURE = "temperature" + +CONF_SERIAL_NUMBER = "serial_number" diff --git a/homeassistant/components/elgato/light.py b/homeassistant/components/elgato/light.py new file mode 100644 index 0000000000000..99bca1ba20e01 --- /dev/null +++ b/homeassistant/components/elgato/light.py @@ -0,0 +1,158 @@ +"""Support for LED lights.""" +from datetime import timedelta +import logging +from typing import Any, Callable, Dict, List, Optional + +from elgato import Elgato, ElgatoError, Info, State + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP, + SUPPORT_BRIGHTNESS, + SUPPORT_COLOR_TEMP, + Light, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_NAME +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import HomeAssistantType + +from .const import ( + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_ON, + ATTR_SOFTWARE_VERSION, + ATTR_TEMPERATURE, + DATA_ELGATO_CLIENT, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 1 +SCAN_INTERVAL = timedelta(seconds=10) + + +async def async_setup_entry( + hass: HomeAssistantType, + entry: ConfigEntry, + async_add_entities: Callable[[List[Entity], bool], None], +) -> None: + """Set up Elgato Key Light based on a config entry.""" + elgato: Elgato = hass.data[DOMAIN][entry.entry_id][DATA_ELGATO_CLIENT] + info = await elgato.info() + async_add_entities([ElgatoLight(entry.entry_id, elgato, info)], True) + + +class ElgatoLight(Light): + """Defines a Elgato Key Light.""" + + def __init__( + self, entry_id: str, elgato: Elgato, info: Info, + ): + """Initialize Elgato Key Light.""" + self._brightness: Optional[int] = None + self._info: Info = info + self._state: Optional[bool] = None + self._temperature: Optional[int] = None + self._available = True + self.elgato = elgato + + @property + def name(self) -> str: + """Return the name of the entity.""" + # Return the product name, if display name is not set + if not self._info.display_name: + return self._info.product_name + return self._info.display_name + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._available + + @property + def unique_id(self) -> str: + """Return the unique ID for this sensor.""" + return self._info.serial_number + + @property + def brightness(self) -> Optional[int]: + """Return the brightness of this light between 1..255.""" + return self._brightness + + @property + def color_temp(self): + """Return the CT color value in mireds.""" + return self._temperature + + @property + def min_mireds(self): + """Return the coldest color_temp that this light supports.""" + return 143 + + @property + def max_mireds(self): + """Return the warmest color_temp that this light supports.""" + return 344 + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP + + @property + def is_on(self) -> bool: + """Return the state of the light.""" + return bool(self._state) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the light.""" + await self.async_turn_on(on=False) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the light.""" + data = {} + + data[ATTR_ON] = True + if ATTR_ON in kwargs: + data[ATTR_ON] = kwargs[ATTR_ON] + + if ATTR_COLOR_TEMP in kwargs: + data[ATTR_TEMPERATURE] = kwargs[ATTR_COLOR_TEMP] + + if ATTR_BRIGHTNESS in kwargs: + data[ATTR_BRIGHTNESS] = round((kwargs[ATTR_BRIGHTNESS] / 255) * 100) + + try: + await self.elgato.light(**data) + except ElgatoError: + _LOGGER.error("An error occurred while updating the Elgato Key Light") + self._available = False + + async def async_update(self) -> None: + """Update Elgato entity.""" + try: + state: State = await self.elgato.state() + except ElgatoError: + if self._available: + _LOGGER.error("An error occurred while updating the Elgato Key Light") + self._available = False + return + + self._available = True + self._brightness = round((state.brightness * 255) / 100) + self._state = state.on + self._temperature = state.temperature + + @property + def device_info(self) -> Dict[str, Any]: + """Return device information about this Elgato Key Light.""" + return { + ATTR_IDENTIFIERS: {(DOMAIN, self._info.serial_number)}, + ATTR_NAME: self._info.product_name, + ATTR_MANUFACTURER: "Elgato", + ATTR_MODEL: self._info.product_name, + ATTR_SOFTWARE_VERSION: f"{self._info.firmware_version} ({self._info.firmware_build_number})", + } diff --git a/homeassistant/components/elgato/manifest.json b/homeassistant/components/elgato/manifest.json new file mode 100644 index 0000000000000..039b125e988a5 --- /dev/null +++ b/homeassistant/components/elgato/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "elgato", + "name": "Elgato Key Light", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/elgato", + "requirements": ["elgato==0.2.0"], + "dependencies": [], + "zeroconf": ["_elg._tcp.local."], + "codeowners": ["@frenck"], + "quality_scale": "platinum" +} diff --git a/homeassistant/components/elgato/strings.json b/homeassistant/components/elgato/strings.json new file mode 100644 index 0000000000000..03c46f02efcab --- /dev/null +++ b/homeassistant/components/elgato/strings.json @@ -0,0 +1,27 @@ +{ + "config": { + "title": "Elgato Key Light", + "flow_title": "Elgato Key Light: {serial_number}", + "step": { + "user": { + "title": "Link your Elgato Key Light", + "description": "Set up your Elgato Key Light to integrate with Home Assistant.", + "data": { + "host": "Host or IP address", + "port": "Port number" + } + }, + "zeroconf_confirm": { + "description": "Do you want to add the Elgato Key Light with serial number `{serial_number}` to Home Assistant?", + "title": "Discovered Elgato Key Light device" + } + }, + "error": { + "connection_error": "Failed to connect to Elgato Key Light device." + }, + "abort": { + "already_configured": "This Elgato Key Light device is already configured.", + "connection_error": "Failed to connect to Elgato Key Light device." + } + } +} diff --git a/homeassistant/components/eliqonline/__init__.py b/homeassistant/components/eliqonline/__init__.py new file mode 100644 index 0000000000000..4cb38436ee43d --- /dev/null +++ b/homeassistant/components/eliqonline/__init__.py @@ -0,0 +1 @@ +"""The eliqonline component.""" diff --git a/homeassistant/components/eliqonline/manifest.json b/homeassistant/components/eliqonline/manifest.json new file mode 100644 index 0000000000000..1cbaa5fa15603 --- /dev/null +++ b/homeassistant/components/eliqonline/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "eliqonline", + "name": "Eliqonline", + "documentation": "https://www.home-assistant.io/integrations/eliqonline", + "requirements": ["eliqonline==1.2.2"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/eliqonline/sensor.py b/homeassistant/components/eliqonline/sensor.py new file mode 100644 index 0000000000000..b3d56e42325e0 --- /dev/null +++ b/homeassistant/components/eliqonline/sensor.py @@ -0,0 +1,94 @@ +"""Monitors home energy use for the ELIQ Online service.""" +import asyncio +from datetime import timedelta +import logging + +import eliqonline +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_NAME, POWER_WATT +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__) + +CONF_CHANNEL_ID = "channel_id" + +DEFAULT_NAME = "ELIQ Online" + +ICON = "mdi:gauge" + +SCAN_INTERVAL = timedelta(seconds=60) + +UNIT_OF_MEASUREMENT = POWER_WATT + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_ACCESS_TOKEN): cv.string, + vol.Required(CONF_CHANNEL_ID): 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 the ELIQ Online sensor.""" + access_token = config.get(CONF_ACCESS_TOKEN) + name = config.get(CONF_NAME, DEFAULT_NAME) + channel_id = config.get(CONF_CHANNEL_ID) + session = async_get_clientsession(hass) + + api = eliqonline.API(session=session, access_token=access_token) + + try: + _LOGGER.debug("Probing for access to ELIQ Online API") + await api.get_data_now(channelid=channel_id) + except OSError as error: + _LOGGER.error("Could not access the ELIQ Online API: %s", error) + return False + + async_add_entities([EliqSensor(api, channel_id, name)], True) + + +class EliqSensor(Entity): + """Implementation of an ELIQ Online sensor.""" + + def __init__(self, api, channel_id, name): + """Initialize the sensor.""" + self._name = name + self._state = None + self._api = api + self._channel_id = channel_id + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def icon(self): + """Return icon.""" + return ICON + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return UNIT_OF_MEASUREMENT + + @property + def state(self): + """Return the state of the device.""" + return self._state + + async def async_update(self): + """Get the latest data.""" + try: + response = await self._api.get_data_now(channelid=self._channel_id) + self._state = int(response["power"]) + _LOGGER.debug("Updated power from server %d W", self._state) + except KeyError: + _LOGGER.warning("Invalid response from ELIQ Online API") + except (OSError, asyncio.TimeoutError) as error: + _LOGGER.warning("Could not connect to the ELIQ Online API: %s", error) diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py new file mode 100644 index 0000000000000..67b84c4f3bf7a --- /dev/null +++ b/homeassistant/components/elkm1/__init__.py @@ -0,0 +1,307 @@ +"""Support the ElkM1 Gold and ElkM1 EZ8 alarm/integration panels.""" +import logging +import re + +import elkm1_lib as elkm1 +from elkm1_lib.const import Max +import voluptuous as vol + +from homeassistant.const import ( + CONF_EXCLUDE, + CONF_HOST, + CONF_INCLUDE, + CONF_PASSWORD, + CONF_TEMPERATURE_UNIT, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv, discovery +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import ConfigType + +DOMAIN = "elkm1" + +CONF_AREA = "area" +CONF_COUNTER = "counter" +CONF_ENABLED = "enabled" +CONF_KEYPAD = "keypad" +CONF_OUTPUT = "output" +CONF_PLC = "plc" +CONF_SETTING = "setting" +CONF_TASK = "task" +CONF_THERMOSTAT = "thermostat" +CONF_ZONE = "zone" +CONF_PREFIX = "prefix" + +_LOGGER = logging.getLogger(__name__) + +SERVICE_ALARM_DISPLAY_MESSAGE = "alarm_display_message" +SERVICE_ALARM_ARM_VACATION = "alarm_arm_vacation" +SERVICE_ALARM_ARM_HOME_INSTANT = "alarm_arm_home_instant" +SERVICE_ALARM_ARM_NIGHT_INSTANT = "alarm_arm_night_instant" + +SUPPORTED_DOMAINS = [ + "alarm_control_panel", + "climate", + "light", + "scene", + "sensor", + "switch", +] + +SPEAK_SERVICE_SCHEMA = vol.Schema( + { + vol.Required("number"): vol.All(vol.Coerce(int), vol.Range(min=0, max=999)), + vol.Optional("prefix", default=""): cv.string, + } +) + + +def _host_validator(config): + """Validate that a host is properly configured.""" + if config[CONF_HOST].startswith("elks://"): + if CONF_USERNAME not in config or CONF_PASSWORD not in config: + raise vol.Invalid("Specify username and password for elks://") + elif not config[CONF_HOST].startswith("elk://") and not config[ + CONF_HOST + ].startswith("serial://"): + raise vol.Invalid("Invalid host URL") + return config + + +def _elk_range_validator(rng): + def _housecode_to_int(val): + match = re.search(r"^([a-p])(0[1-9]|1[0-6]|[1-9])$", val.lower()) + if match: + return (ord(match.group(1)) - ord("a")) * 16 + int(match.group(2)) + raise vol.Invalid("Invalid range") + + def _elk_value(val): + return int(val) if val.isdigit() else _housecode_to_int(val) + + vals = [s.strip() for s in str(rng).split("-")] + start = _elk_value(vals[0]) + end = start if len(vals) == 1 else _elk_value(vals[1]) + return (start, end) + + +def _has_all_unique_prefixes(value): + """Validate that each m1 configured has a unique prefix. + + Uniqueness is determined case-independently. + """ + prefixes = [device[CONF_PREFIX] for device in value] + schema = vol.Schema(vol.Unique()) + schema(prefixes) + return value + + +DEVICE_SCHEMA_SUBDOMAIN = vol.Schema( + { + vol.Optional(CONF_ENABLED, default=True): cv.boolean, + vol.Optional(CONF_INCLUDE, default=[]): [_elk_range_validator], + vol.Optional(CONF_EXCLUDE, default=[]): [_elk_range_validator], + } +) + +DEVICE_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PREFIX, default=""): vol.All(cv.string, vol.Lower), + vol.Optional(CONF_USERNAME, default=""): cv.string, + vol.Optional(CONF_PASSWORD, default=""): cv.string, + vol.Optional(CONF_TEMPERATURE_UNIT, default="F"): cv.temperature_unit, + vol.Optional(CONF_AREA, default={}): DEVICE_SCHEMA_SUBDOMAIN, + vol.Optional(CONF_COUNTER, default={}): DEVICE_SCHEMA_SUBDOMAIN, + vol.Optional(CONF_KEYPAD, default={}): DEVICE_SCHEMA_SUBDOMAIN, + vol.Optional(CONF_OUTPUT, default={}): DEVICE_SCHEMA_SUBDOMAIN, + vol.Optional(CONF_PLC, default={}): DEVICE_SCHEMA_SUBDOMAIN, + vol.Optional(CONF_SETTING, default={}): DEVICE_SCHEMA_SUBDOMAIN, + vol.Optional(CONF_TASK, default={}): DEVICE_SCHEMA_SUBDOMAIN, + vol.Optional(CONF_THERMOSTAT, default={}): DEVICE_SCHEMA_SUBDOMAIN, + vol.Optional(CONF_ZONE, default={}): DEVICE_SCHEMA_SUBDOMAIN, + }, + _host_validator, +) + +CONFIG_SCHEMA = vol.Schema( + {DOMAIN: vol.All(cv.ensure_list, [DEVICE_SCHEMA], _has_all_unique_prefixes)}, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool: + """Set up the Elk M1 platform.""" + devices = {} + elk_datas = {} + + configs = { + CONF_AREA: Max.AREAS.value, + CONF_COUNTER: Max.COUNTERS.value, + CONF_KEYPAD: Max.KEYPADS.value, + CONF_OUTPUT: Max.OUTPUTS.value, + CONF_PLC: Max.LIGHTS.value, + CONF_SETTING: Max.SETTINGS.value, + CONF_TASK: Max.TASKS.value, + CONF_THERMOSTAT: Max.THERMOSTATS.value, + CONF_ZONE: Max.ZONES.value, + } + + def _included(ranges, set_to, values): + for rng in ranges: + if not rng[0] <= rng[1] <= len(values): + raise vol.Invalid(f"Invalid range {rng}") + values[rng[0] - 1 : rng[1]] = [set_to] * (rng[1] - rng[0] + 1) + + for index, conf in enumerate(hass_config[DOMAIN]): + _LOGGER.debug("Setting up elkm1 #%d - %s", index, conf["host"]) + + config = {"temperature_unit": conf[CONF_TEMPERATURE_UNIT]} + config["panel"] = {"enabled": True, "included": [True]} + + for item, max_ in configs.items(): + config[item] = { + "enabled": conf[item][CONF_ENABLED], + "included": [not conf[item]["include"]] * max_, + } + try: + _included(conf[item]["include"], True, config[item]["included"]) + _included(conf[item]["exclude"], False, config[item]["included"]) + except (ValueError, vol.Invalid) as err: + _LOGGER.error("Config item: %s; %s", item, err) + return False + + prefix = conf[CONF_PREFIX] + elk = elkm1.Elk( + { + "url": conf[CONF_HOST], + "userid": conf[CONF_USERNAME], + "password": conf[CONF_PASSWORD], + } + ) + elk.connect() + + devices[prefix] = elk + elk_datas[prefix] = { + "elk": elk, + "prefix": prefix, + "config": config, + "keypads": {}, + } + + _create_elk_services(hass, devices) + + hass.data[DOMAIN] = elk_datas + for component in SUPPORTED_DOMAINS: + hass.async_create_task( + discovery.async_load_platform(hass, component, DOMAIN, {}, hass_config) + ) + + return True + + +def _create_elk_services(hass, elks): + def _speak_word_service(service): + prefix = service.data["prefix"] + elk = elks.get(prefix) + if elk is None: + _LOGGER.error("No elk m1 with prefix for speak_word: '%s'", prefix) + return + elk.panel.speak_word(service.data["number"]) + + def _speak_phrase_service(service): + prefix = service.data["prefix"] + elk = elks.get(prefix) + if elk is None: + _LOGGER.error("No elk m1 with prefix for speak_phrase: '%s'", prefix) + return + elk.panel.speak_phrase(service.data["number"]) + + hass.services.async_register( + DOMAIN, "speak_word", _speak_word_service, SPEAK_SERVICE_SCHEMA + ) + hass.services.async_register( + DOMAIN, "speak_phrase", _speak_phrase_service, SPEAK_SERVICE_SCHEMA + ) + + +def create_elk_entities(elk_data, elk_elements, element_type, class_, entities): + """Create the ElkM1 devices of a particular class.""" + if elk_data["config"][element_type]["enabled"]: + elk = elk_data["elk"] + _LOGGER.debug("Creating elk entities for %s", elk) + for element in elk_elements: + if elk_data["config"][element_type]["included"][element.index]: + entities.append(class_(element, elk, elk_data)) + return entities + + +class ElkEntity(Entity): + """Base class for all Elk entities.""" + + def __init__(self, element, elk, elk_data): + """Initialize the base of all Elk devices.""" + self._elk = elk + self._element = element + self._prefix = elk_data["prefix"] + self._temperature_unit = elk_data["config"]["temperature_unit"] + # unique_id starts with elkm1_ iff there is no prefix + # it starts with elkm1m_{prefix} iff there is a prefix + # this is to avoid a conflict between + # prefix=foo, name=bar (which would be elkm1_foo_bar) + # - and - + # prefix="", name="foo bar" (which would be elkm1_foo_bar also) + # we could have used elkm1__foo_bar for the latter, but that + # would have been a breaking change + if self._prefix != "": + uid_start = f"elkm1m_{self._prefix}" + else: + uid_start = "elkm1" + self._unique_id = "{uid_start}_{name}".format( + uid_start=uid_start, name=self._element.default_name("_") + ).lower() + + @property + def name(self): + """Name of the element.""" + return f"{self._prefix}{self._element.name}" + + @property + def unique_id(self): + """Return unique id of the element.""" + return self._unique_id + + @property + def should_poll(self) -> bool: + """Don't poll this device.""" + return False + + @property + def device_state_attributes(self): + """Return the default attributes of the element.""" + return {**self._element.as_dict(), **self.initial_attrs()} + + @property + def available(self): + """Is the entity available to be updated.""" + return self._elk.is_connected() + + def initial_attrs(self): + """Return the underlying element's attributes as a dict.""" + attrs = {} + attrs["index"] = self._element.index + 1 + return attrs + + def _element_changed(self, element, changeset): + pass + + @callback + def _element_callback(self, element, changeset): + """Handle callback from an Elk element that has changed.""" + self._element_changed(element, changeset) + self.async_schedule_update_ha_state(True) + + async def async_added_to_hass(self): + """Register callback for ElkM1 changes and update entity state.""" + self._element.add_callback(self._element_callback) + self._element_callback(self._element, {}) diff --git a/homeassistant/components/elkm1/alarm_control_panel.py b/homeassistant/components/elkm1/alarm_control_panel.py new file mode 100644 index 0000000000000..de1cb62234c78 --- /dev/null +++ b/homeassistant/components/elkm1/alarm_control_panel.py @@ -0,0 +1,233 @@ +"""Each ElkM1 area will be created as a separate alarm_control_panel.""" +from elkm1_lib.const import AlarmState, ArmedStatus, ArmLevel, ArmUpState +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, + ATTR_ENTITY_ID, + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMING, + STATE_ALARM_DISARMED, + STATE_ALARM_PENDING, + STATE_ALARM_TRIGGERED, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) + +from . import ( + DOMAIN, + SERVICE_ALARM_ARM_HOME_INSTANT, + SERVICE_ALARM_ARM_NIGHT_INSTANT, + SERVICE_ALARM_ARM_VACATION, + SERVICE_ALARM_DISPLAY_MESSAGE, + ElkEntity, + create_elk_entities, +) + +SIGNAL_ARM_ENTITY = "elkm1_arm" +SIGNAL_DISPLAY_MESSAGE = "elkm1_display_message" + +ELK_ALARM_SERVICE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID, default=[]): cv.entity_ids, + vol.Required(ATTR_CODE): vol.All(vol.Coerce(int), vol.Range(0, 999999)), + } +) + +DISPLAY_MESSAGE_SERVICE_SCHEMA = vol.Schema( + { + vol.Optional(ATTR_ENTITY_ID, default=[]): cv.entity_ids, + vol.Optional("clear", default=2): vol.All(vol.Coerce(int), vol.In([0, 1, 2])), + vol.Optional("beep", default=False): cv.boolean, + vol.Optional("timeout", default=0): vol.All( + vol.Coerce(int), vol.Range(min=0, max=65535) + ), + vol.Optional("line1", default=""): cv.string, + vol.Optional("line2", default=""): cv.string, + } +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the ElkM1 alarm platform.""" + if discovery_info is None: + return + + elk_datas = hass.data[DOMAIN] + entities = [] + for elk_data in elk_datas.values(): + elk = elk_data["elk"] + entities = create_elk_entities(elk_data, elk.areas, "area", ElkArea, entities) + async_add_entities(entities, True) + + def _dispatch(signal, entity_ids, *args): + for entity_id in entity_ids: + async_dispatcher_send(hass, f"{signal}_{entity_id}", *args) + + def _arm_service(service): + entity_ids = service.data.get(ATTR_ENTITY_ID, []) + arm_level = _arm_services().get(service.service) + args = (arm_level, service.data.get(ATTR_CODE)) + _dispatch(SIGNAL_ARM_ENTITY, entity_ids, *args) + + for service in _arm_services(): + hass.services.async_register( + DOMAIN, service, _arm_service, ELK_ALARM_SERVICE_SCHEMA + ) + + def _display_message_service(service): + entity_ids = service.data.get(ATTR_ENTITY_ID, []) + data = service.data + args = ( + data["clear"], + data["beep"], + data["timeout"], + data["line1"], + data["line2"], + ) + _dispatch(SIGNAL_DISPLAY_MESSAGE, entity_ids, *args) + + hass.services.async_register( + DOMAIN, + SERVICE_ALARM_DISPLAY_MESSAGE, + _display_message_service, + DISPLAY_MESSAGE_SERVICE_SCHEMA, + ) + + +def _arm_services(): + return { + SERVICE_ALARM_ARM_VACATION: ArmLevel.ARMED_VACATION.value, + SERVICE_ALARM_ARM_HOME_INSTANT: ArmLevel.ARMED_STAY_INSTANT.value, + SERVICE_ALARM_ARM_NIGHT_INSTANT: ArmLevel.ARMED_NIGHT_INSTANT.value, + } + + +class ElkArea(ElkEntity, AlarmControlPanel): + """Representation of an Area / Partition within the ElkM1 alarm panel.""" + + def __init__(self, element, elk, elk_data): + """Initialize Area as Alarm Control Panel.""" + super().__init__(element, elk, elk_data) + self._changed_by_entity_id = "" + self._state = None + + async def async_added_to_hass(self): + """Register callback for ElkM1 changes.""" + await super().async_added_to_hass() + for keypad in self._elk.keypads: + keypad.add_callback(self._watch_keypad) + async_dispatcher_connect( + self.hass, f"{SIGNAL_ARM_ENTITY}_{self.entity_id}", self._arm_service + ) + async_dispatcher_connect( + self.hass, + f"{SIGNAL_DISPLAY_MESSAGE}_{self.entity_id}", + self._display_message, + ) + + def _watch_keypad(self, keypad, changeset): + if keypad.area != self._element.index: + return + if changeset.get("last_user") is not None: + self._changed_by_entity_id = self.hass.data[DOMAIN][self._prefix][ + "keypads" + ].get(keypad.index, "") + self.async_schedule_update_ha_state(True) + + @property + def code_format(self): + """Return the alarm code format.""" + return FORMAT_NUMBER + + @property + def state(self): + """Return the state of the element.""" + 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): + """Attributes of the area.""" + attrs = self.initial_attrs() + elmt = self._element + attrs["is_exit"] = elmt.is_exit + attrs["timer1"] = elmt.timer1 + attrs["timer2"] = elmt.timer2 + if elmt.armed_status is not None: + attrs["armed_status"] = ArmedStatus(elmt.armed_status).name.lower() + if elmt.arm_up_state is not None: + attrs["arm_up_state"] = ArmUpState(elmt.arm_up_state).name.lower() + if elmt.alarm_state is not None: + attrs["alarm_state"] = AlarmState(elmt.alarm_state).name.lower() + attrs["changed_by_entity_id"] = self._changed_by_entity_id + return attrs + + def _element_changed(self, element, changeset): + elk_state_to_hass_state = { + ArmedStatus.DISARMED.value: STATE_ALARM_DISARMED, + ArmedStatus.ARMED_AWAY.value: STATE_ALARM_ARMED_AWAY, + ArmedStatus.ARMED_STAY.value: STATE_ALARM_ARMED_HOME, + ArmedStatus.ARMED_STAY_INSTANT.value: STATE_ALARM_ARMED_HOME, + ArmedStatus.ARMED_TO_NIGHT.value: STATE_ALARM_ARMED_NIGHT, + ArmedStatus.ARMED_TO_NIGHT_INSTANT.value: STATE_ALARM_ARMED_NIGHT, + ArmedStatus.ARMED_TO_VACATION.value: STATE_ALARM_ARMED_AWAY, + } + + if self._element.alarm_state is None: + self._state = None + elif self._area_is_in_alarm_state(): + self._state = STATE_ALARM_TRIGGERED + elif self._entry_exit_timer_is_running(): + self._state = ( + STATE_ALARM_ARMING if self._element.is_exit else STATE_ALARM_PENDING + ) + else: + self._state = elk_state_to_hass_state[self._element.armed_status] + + def _entry_exit_timer_is_running(self): + return self._element.timer1 > 0 or self._element.timer2 > 0 + + def _area_is_in_alarm_state(self): + return self._element.alarm_state >= AlarmState.FIRE_ALARM.value + + async def async_alarm_disarm(self, code=None): + """Send disarm command.""" + self._element.disarm(int(code)) + + async def async_alarm_arm_home(self, code=None): + """Send arm home command.""" + self._element.arm(ArmLevel.ARMED_STAY.value, int(code)) + + async def async_alarm_arm_away(self, code=None): + """Send arm away command.""" + self._element.arm(ArmLevel.ARMED_AWAY.value, int(code)) + + async def async_alarm_arm_night(self, code=None): + """Send arm night command.""" + self._element.arm(ArmLevel.ARMED_NIGHT.value, int(code)) + + async def _arm_service(self, arm_level, code): + self._element.arm(arm_level, code) + + async def _display_message(self, clear, beep, timeout, line1, line2): + """Display a message on all keypads for the area.""" + self._element.display_message(clear, beep, timeout, line1, line2) diff --git a/homeassistant/components/elkm1/climate.py b/homeassistant/components/elkm1/climate.py new file mode 100644 index 0000000000000..abc9dc0933c57 --- /dev/null +++ b/homeassistant/components/elkm1/climate.py @@ -0,0 +1,194 @@ +"""Support for control of Elk-M1 connected thermostats.""" +from elkm1_lib.const import ThermostatFan, ThermostatMode, ThermostatSetting + +from homeassistant.components.climate import ClimateDevice +from homeassistant.components.climate.const import ( + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + HVAC_MODE_AUTO, + HVAC_MODE_COOL, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + SUPPORT_AUX_HEAT, + SUPPORT_FAN_MODE, + SUPPORT_TARGET_TEMPERATURE_RANGE, +) +from homeassistant.const import PRECISION_WHOLE, STATE_ON + +from . import DOMAIN as ELK_DOMAIN, ElkEntity, create_elk_entities + +SUPPORT_HVAC = [ + HVAC_MODE_OFF, + HVAC_MODE_HEAT, + HVAC_MODE_COOL, + HVAC_MODE_AUTO, + HVAC_MODE_FAN_ONLY, +] + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Create the Elk-M1 thermostat platform.""" + if discovery_info is None: + return + + elk_datas = hass.data[ELK_DOMAIN] + entities = [] + for elk_data in elk_datas.values(): + elk = elk_data["elk"] + entities = create_elk_entities( + elk_data, elk.thermostats, "thermostat", ElkThermostat, entities + ) + async_add_entities(entities, True) + + +class ElkThermostat(ElkEntity, ClimateDevice): + """Representation of an Elk-M1 Thermostat.""" + + def __init__(self, element, elk, elk_data): + """Initialize climate entity.""" + super().__init__(element, elk, elk_data) + self._state = None + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FAN_MODE | SUPPORT_AUX_HEAT | SUPPORT_TARGET_TEMPERATURE_RANGE + + @property + def temperature_unit(self): + """Return the temperature unit.""" + return self._temperature_unit + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._element.current_temp + + @property + def target_temperature(self): + """Return the temperature we are trying to reach.""" + if (self._element.mode == ThermostatMode.HEAT.value) or ( + self._element.mode == ThermostatMode.EMERGENCY_HEAT.value + ): + return self._element.heat_setpoint + if self._element.mode == ThermostatMode.COOL.value: + return self._element.cool_setpoint + return None + + @property + def target_temperature_high(self): + """Return the high target temperature.""" + return self._element.cool_setpoint + + @property + def target_temperature_low(self): + """Return the low target temperature.""" + return self._element.heat_setpoint + + @property + def target_temperature_step(self): + """Return the supported step of target temperature.""" + return 1 + + @property + def current_humidity(self): + """Return the current humidity.""" + return self._element.humidity + + @property + def hvac_mode(self): + """Return current operation ie. heat, cool, idle.""" + return self._state + + @property + def hvac_modes(self): + """Return the list of available operation modes.""" + return SUPPORT_HVAC + + @property + def precision(self): + """Return the precision of the system.""" + return PRECISION_WHOLE + + @property + def is_aux_heat(self): + """Return if aux heater is on.""" + return self._element.mode == ThermostatMode.EMERGENCY_HEAT.value + + @property + def min_temp(self): + """Return the minimum temperature supported.""" + return 1 + + @property + def max_temp(self): + """Return the maximum temperature supported.""" + return 99 + + @property + def fan_mode(self): + """Return the fan setting.""" + if self._element.fan == ThermostatFan.AUTO.value: + return HVAC_MODE_AUTO + if self._element.fan == ThermostatFan.ON.value: + return STATE_ON + return None + + def _elk_set(self, mode, fan): + if mode is not None: + self._element.set(ThermostatSetting.MODE.value, mode) + if fan is not None: + self._element.set(ThermostatSetting.FAN.value, fan) + + async def async_set_hvac_mode(self, hvac_mode): + """Set thermostat operation mode.""" + settings = { + HVAC_MODE_OFF: (ThermostatMode.OFF.value, ThermostatFan.AUTO.value), + HVAC_MODE_HEAT: (ThermostatMode.HEAT.value, None), + HVAC_MODE_COOL: (ThermostatMode.COOL.value, None), + HVAC_MODE_AUTO: (ThermostatMode.AUTO.value, None), + HVAC_MODE_FAN_ONLY: (ThermostatMode.OFF.value, ThermostatFan.ON.value), + } + self._elk_set(settings[hvac_mode][0], settings[hvac_mode][1]) + + async def async_turn_aux_heat_on(self): + """Turn auxiliary heater on.""" + self._elk_set(ThermostatMode.EMERGENCY_HEAT.value, None) + + async def async_turn_aux_heat_off(self): + """Turn auxiliary heater off.""" + self._elk_set(ThermostatMode.HEAT.value, None) + + @property + def fan_modes(self): + """Return the list of available fan modes.""" + return [HVAC_MODE_AUTO, STATE_ON] + + async def async_set_fan_mode(self, fan_mode): + """Set new target fan mode.""" + if fan_mode == HVAC_MODE_AUTO: + self._elk_set(None, ThermostatFan.AUTO.value) + elif fan_mode == STATE_ON: + self._elk_set(None, ThermostatFan.ON.value) + + async def async_set_temperature(self, **kwargs): + """Set new target temperature.""" + low_temp = kwargs.get(ATTR_TARGET_TEMP_LOW) + high_temp = kwargs.get(ATTR_TARGET_TEMP_HIGH) + if low_temp is not None: + self._element.set(ThermostatSetting.HEAT_SETPOINT.value, round(low_temp)) + if high_temp is not None: + self._element.set(ThermostatSetting.COOL_SETPOINT.value, round(high_temp)) + + def _element_changed(self, element, changeset): + mode_to_state = { + ThermostatMode.OFF.value: HVAC_MODE_OFF, + ThermostatMode.COOL.value: HVAC_MODE_COOL, + ThermostatMode.HEAT.value: HVAC_MODE_HEAT, + ThermostatMode.EMERGENCY_HEAT.value: HVAC_MODE_HEAT, + ThermostatMode.AUTO.value: HVAC_MODE_AUTO, + } + self._state = mode_to_state.get(self._element.mode) + if self._state == HVAC_MODE_OFF and self._element.fan == ThermostatFan.ON.value: + self._state = HVAC_MODE_FAN_ONLY diff --git a/homeassistant/components/elkm1/light.py b/homeassistant/components/elkm1/light.py new file mode 100644 index 0000000000000..10a9ae1b931e1 --- /dev/null +++ b/homeassistant/components/elkm1/light.py @@ -0,0 +1,52 @@ +"""Support for control of ElkM1 lighting (X10, UPB, etc).""" +from homeassistant.components.light import ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light + +from . import DOMAIN as ELK_DOMAIN, ElkEntity, create_elk_entities + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the Elk light platform.""" + if discovery_info is None: + return + elk_datas = hass.data[ELK_DOMAIN] + entities = [] + for elk_data in elk_datas.values(): + elk = elk_data["elk"] + create_elk_entities(elk_data, elk.lights, "plc", ElkLight, entities) + async_add_entities(entities, True) + + +class ElkLight(ElkEntity, Light): + """Representation of an Elk lighting device.""" + + def __init__(self, element, elk, elk_data): + """Initialize the Elk light.""" + super().__init__(element, elk, elk_data) + self._brightness = self._element.status + + @property + def brightness(self): + """Get the brightness.""" + return self._brightness + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_BRIGHTNESS + + @property + def is_on(self) -> bool: + """Get the current brightness.""" + return self._brightness != 0 + + def _element_changed(self, element, changeset): + status = self._element.status if self._element.status != 1 else 100 + self._brightness = round(status * 2.55) + + async def async_turn_on(self, **kwargs): + """Turn on the light.""" + self._element.level(round(kwargs.get(ATTR_BRIGHTNESS, 255) / 2.55)) + + async def async_turn_off(self, **kwargs): + """Turn off the light.""" + self._element.level(0) diff --git a/homeassistant/components/elkm1/manifest.json b/homeassistant/components/elkm1/manifest.json new file mode 100644 index 0000000000000..c75da1ef03917 --- /dev/null +++ b/homeassistant/components/elkm1/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "elkm1", + "name": "Elk-M1 Control", + "documentation": "https://www.home-assistant.io/integrations/elkm1", + "requirements": ["elkm1-lib==0.7.15"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/elkm1/scene.py b/homeassistant/components/elkm1/scene.py new file mode 100644 index 0000000000000..dc5ea39d15478 --- /dev/null +++ b/homeassistant/components/elkm1/scene.py @@ -0,0 +1,24 @@ +"""Support for control of ElkM1 tasks ("macros").""" +from homeassistant.components.scene import Scene + +from . import DOMAIN as ELK_DOMAIN, ElkEntity, create_elk_entities + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Create the Elk-M1 scene platform.""" + if discovery_info is None: + return + elk_datas = hass.data[ELK_DOMAIN] + entities = [] + for elk_data in elk_datas.values(): + elk = elk_data["elk"] + entities = create_elk_entities(elk_data, elk.tasks, "task", ElkTask, entities) + async_add_entities(entities, True) + + +class ElkTask(ElkEntity, Scene): + """Elk-M1 task as scene.""" + + async def async_activate(self): + """Activate the task.""" + self._element.activate() diff --git a/homeassistant/components/elkm1/sensor.py b/homeassistant/components/elkm1/sensor.py new file mode 100644 index 0000000000000..3ed5356f4de13 --- /dev/null +++ b/homeassistant/components/elkm1/sensor.py @@ -0,0 +1,223 @@ +"""Support for control of ElkM1 sensors.""" +from elkm1_lib.const import ( + SettingFormat, + ZoneLogicalStatus, + ZonePhysicalStatus, + ZoneType, +) +from elkm1_lib.util import pretty_const, username + +from . import DOMAIN as ELK_DOMAIN, ElkEntity, create_elk_entities + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Create the Elk-M1 sensor platform.""" + if discovery_info is None: + return + + elk_datas = hass.data[ELK_DOMAIN] + entities = [] + for elk_data in elk_datas.values(): + elk = elk_data["elk"] + entities = create_elk_entities( + elk_data, elk.counters, "counter", ElkCounter, entities + ) + entities = create_elk_entities( + elk_data, elk.keypads, "keypad", ElkKeypad, entities + ) + entities = create_elk_entities( + elk_data, [elk.panel], "panel", ElkPanel, entities + ) + entities = create_elk_entities( + elk_data, elk.settings, "setting", ElkSetting, entities + ) + entities = create_elk_entities(elk_data, elk.zones, "zone", ElkZone, entities) + async_add_entities(entities, True) + + +def temperature_to_state(temperature, undefined_temperature): + """Convert temperature to a state.""" + return temperature if temperature > undefined_temperature else None + + +class ElkSensor(ElkEntity): + """Base representation of Elk-M1 sensor.""" + + def __init__(self, element, elk, elk_data): + """Initialize the base of all Elk sensors.""" + super().__init__(element, elk, elk_data) + self._state = None + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + +class ElkCounter(ElkSensor): + """Representation of an Elk-M1 Counter.""" + + @property + def icon(self): + """Icon to use in the frontend.""" + return "mdi:numeric" + + def _element_changed(self, element, changeset): + self._state = self._element.value + + +class ElkKeypad(ElkSensor): + """Representation of an Elk-M1 Keypad.""" + + @property + def temperature_unit(self): + """Return the temperature unit.""" + return self._temperature_unit + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._temperature_unit + + @property + def icon(self): + """Icon to use in the frontend.""" + return "mdi:thermometer-lines" + + @property + def device_state_attributes(self): + """Attributes of the sensor.""" + attrs = self.initial_attrs() + attrs["area"] = self._element.area + 1 + attrs["temperature"] = self._element.temperature + attrs["last_user_time"] = self._element.last_user_time.isoformat() + attrs["last_user"] = self._element.last_user + 1 + attrs["code"] = self._element.code + attrs["last_user_name"] = username(self._elk, self._element.last_user) + attrs["last_keypress"] = self._element.last_keypress + return attrs + + def _element_changed(self, element, changeset): + self._state = temperature_to_state(self._element.temperature, -40) + + async def async_added_to_hass(self): + """Register callback for ElkM1 changes and update entity state.""" + await super().async_added_to_hass() + elk_datas = self.hass.data[ELK_DOMAIN] + for elk_data in elk_datas.values(): + elk_data["keypads"][self._element.index] = self.entity_id + + +class ElkPanel(ElkSensor): + """Representation of an Elk-M1 Panel.""" + + @property + def icon(self): + """Icon to use in the frontend.""" + return "mdi:home" + + @property + def device_state_attributes(self): + """Attributes of the sensor.""" + attrs = self.initial_attrs() + attrs["system_trouble_status"] = self._element.system_trouble_status + return attrs + + def _element_changed(self, element, changeset): + if self._elk.is_connected(): + self._state = ( + "Paused" if self._element.remote_programming_status else "Connected" + ) + else: + self._state = "Disconnected" + + +class ElkSetting(ElkSensor): + """Representation of an Elk-M1 Setting.""" + + @property + def icon(self): + """Icon to use in the frontend.""" + return "mdi:numeric" + + def _element_changed(self, element, changeset): + self._state = self._element.value + + @property + def device_state_attributes(self): + """Attributes of the sensor.""" + attrs = self.initial_attrs() + attrs["value_format"] = SettingFormat(self._element.value_format).name.lower() + return attrs + + +class ElkZone(ElkSensor): + """Representation of an Elk-M1 Zone.""" + + @property + def icon(self): + """Icon to use in the frontend.""" + zone_icons = { + ZoneType.FIRE_ALARM.value: "fire", + ZoneType.FIRE_VERIFIED.value: "fire", + ZoneType.FIRE_SUPERVISORY.value: "fire", + ZoneType.KEYFOB.value: "key", + ZoneType.NON_ALARM.value: "alarm-off", + ZoneType.MEDICAL_ALARM.value: "medical-bag", + ZoneType.POLICE_ALARM.value: "alarm-light", + ZoneType.POLICE_NO_INDICATION.value: "alarm-light", + ZoneType.KEY_MOMENTARY_ARM_DISARM.value: "power", + ZoneType.KEY_MOMENTARY_ARM_AWAY.value: "power", + ZoneType.KEY_MOMENTARY_ARM_STAY.value: "power", + ZoneType.KEY_MOMENTARY_DISARM.value: "power", + ZoneType.KEY_ON_OFF.value: "toggle-switch", + ZoneType.MUTE_AUDIBLES.value: "volume-mute", + ZoneType.POWER_SUPERVISORY.value: "power-plug", + ZoneType.TEMPERATURE.value: "thermometer-lines", + ZoneType.ANALOG_ZONE.value: "speedometer", + ZoneType.PHONE_KEY.value: "phone-classic", + ZoneType.INTERCOM_KEY.value: "deskphone", + } + return "mdi:{}".format(zone_icons.get(self._element.definition, "alarm-bell")) + + @property + def device_state_attributes(self): + """Attributes of the sensor.""" + attrs = self.initial_attrs() + attrs["physical_status"] = ZonePhysicalStatus( + self._element.physical_status + ).name.lower() + attrs["logical_status"] = ZoneLogicalStatus( + self._element.logical_status + ).name.lower() + attrs["definition"] = ZoneType(self._element.definition).name.lower() + attrs["area"] = self._element.area + 1 + attrs["bypassed"] = self._element.bypassed + attrs["triggered_alarm"] = self._element.triggered_alarm + return attrs + + @property + def temperature_unit(self): + """Return the temperature unit.""" + if self._element.definition == ZoneType.TEMPERATURE.value: + return self._temperature_unit + return None + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + if self._element.definition == ZoneType.TEMPERATURE.value: + return self._temperature_unit + if self._element.definition == ZoneType.ANALOG_ZONE.value: + return "V" + return None + + def _element_changed(self, element, changeset): + if self._element.definition == ZoneType.TEMPERATURE.value: + self._state = temperature_to_state(self._element.temperature, -60) + elif self._element.definition == ZoneType.ANALOG_ZONE.value: + self._state = self._element.voltage + else: + self._state = pretty_const( + ZoneLogicalStatus(self._element.logical_status).name + ) diff --git a/homeassistant/components/elkm1/services.yaml b/homeassistant/components/elkm1/services.yaml new file mode 100644 index 0000000000000..fbcbf7edc6dde --- /dev/null +++ b/homeassistant/components/elkm1/services.yaml @@ -0,0 +1,65 @@ +alarm_arm_home_instant: + description: Arm the ElkM1 in home instant mode. + fields: + entity_id: + description: Name of alarm control panel to arm. + example: 'alarm_control_panel.main' + code: + description: An code to arm the alarm control panel. + example: 1234 + +alarm_arm_night_instant: + description: Arm the ElkM1 in night instant mode. + fields: + entity_id: + description: Name of alarm control panel to arm. + example: 'alarm_control_panel.main' + code: + description: An code to arm the alarm control panel. + example: 1234 + +alarm_arm_vacation: + description: Arm the ElkM1 in vacation mode. + fields: + entity_id: + description: Name of alarm control panel to arm. + example: 'alarm_control_panel.main' + code: + description: An code to arm the alarm control panel. + example: 1234 + +alarm_display_message: + description: Display a message on all of the ElkM1 keypads for an area. + fields: + entity_id: + description: Name of alarm control panel to display messages on. + example: 'alarm_control_panel.main' + clear: + description: 0=clear message, 1=clear message with * key, 2=Display until timeout; default 2 + example: 1 + beep: + description: 0=no beep, 1=beep; default 0 + example: 1 + timeout: + description: Time to display message, 0=forever, max 65535, default 0 + example: 4242 + line1: + description: Up to 16 characters of text (truncated if too long). Default blank. + example: The answer to life, + line2: + description: Up to 16 characters of text (truncated if too long). Default blank. + example: the universe, and everything. + +speak_phrase: + description: Speak a phrase. See list of phrases in ElkM1 ASCII Protocol documentation. + fields: + number: + description: Phrase number to speak. + example: 42 + +speak_word: + description: Speak a word. See list of words in ElkM1 ASCII Protocol documentation. + fields: + number: + description: Word number to speak. + example: 142 diff --git a/homeassistant/components/elkm1/switch.py b/homeassistant/components/elkm1/switch.py new file mode 100644 index 0000000000000..e6dd82dc0ac13 --- /dev/null +++ b/homeassistant/components/elkm1/switch.py @@ -0,0 +1,35 @@ +"""Support for control of ElkM1 outputs (relays).""" +from homeassistant.components.switch import SwitchDevice + +from . import DOMAIN as ELK_DOMAIN, ElkEntity, create_elk_entities + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Create the Elk-M1 switch platform.""" + if discovery_info is None: + return + elk_datas = hass.data[ELK_DOMAIN] + entities = [] + for elk_data in elk_datas.values(): + elk = elk_data["elk"] + entities = create_elk_entities( + elk_data, elk.outputs, "output", ElkOutput, entities + ) + async_add_entities(entities, True) + + +class ElkOutput(ElkEntity, SwitchDevice): + """Elk output as switch.""" + + @property + def is_on(self) -> bool: + """Get the current output status.""" + return self._element.output_on + + async def async_turn_on(self, **kwargs): + """Turn on the output.""" + self._element.turn_on(0) + + async def async_turn_off(self, **kwargs): + """Turn off the output.""" + self._element.turn_off() diff --git a/homeassistant/components/elv/__init__.py b/homeassistant/components/elv/__init__.py new file mode 100644 index 0000000000000..b776c7f54530c --- /dev/null +++ b/homeassistant/components/elv/__init__.py @@ -0,0 +1,37 @@ +"""The Elv integration.""" + +import logging + +import voluptuous as vol + +from homeassistant.const import CONF_DEVICE +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "elv" + +DEFAULT_DEVICE = "/dev/ttyUSB0" + +ELV_PLATFORMS = ["switch"] + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + {vol.Optional(CONF_DEVICE, default=DEFAULT_DEVICE): cv.string} + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +def setup(hass, config): + """Set up the PCA switch platform.""" + + for platform in ELV_PLATFORMS: + discovery.load_platform( + hass, platform, DOMAIN, {"device": config[DOMAIN][CONF_DEVICE]}, config + ) + + return True diff --git a/homeassistant/components/elv/manifest.json b/homeassistant/components/elv/manifest.json new file mode 100644 index 0000000000000..d5fb1eb251b5f --- /dev/null +++ b/homeassistant/components/elv/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "elv", + "name": "ELV PCA", + "documentation": "https://www.home-assistant.io/integrations/pca", + "dependencies": [], + "codeowners": ["@majuss"], + "requirements": ["pypca==0.0.7"] +} diff --git a/homeassistant/components/elv/switch.py b/homeassistant/components/elv/switch.py new file mode 100644 index 0000000000000..a77d21cf173a8 --- /dev/null +++ b/homeassistant/components/elv/switch.py @@ -0,0 +1,97 @@ +"""Support for PCA 301 smart switch.""" +import logging + +import pypca +from serial import SerialException + +from homeassistant.components.switch import ATTR_CURRENT_POWER_W, SwitchDevice +from homeassistant.const import EVENT_HOMEASSISTANT_STOP + +_LOGGER = logging.getLogger(__name__) + +ATTR_TOTAL_ENERGY_KWH = "total_energy_kwh" + +DEFAULT_NAME = "PCA 301" + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the PCA switch platform.""" + + if discovery_info is None: + return + + serial_device = discovery_info["device"] + + try: + pca = pypca.PCA(serial_device) + pca.open() + + entities = [SmartPlugSwitch(pca, device) for device in pca.get_devices()] + add_entities(entities, True) + + except SerialException as exc: + _LOGGER.warning("Unable to open serial port: %s", exc) + return + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, pca.close) + + pca.start_scan() + + +class SmartPlugSwitch(SwitchDevice): + """Representation of a PCA Smart Plug switch.""" + + def __init__(self, pca, device_id): + """Initialize the switch.""" + self._device_id = device_id + self._name = "PCA 301" + self._state = None + self._available = True + self._emeter_params = {} + self._pca = pca + + @property + def name(self): + """Return the name of the Smart Plug, if any.""" + return self._name + + @property + def available(self) -> bool: + """Return if switch is available.""" + return self._available + + @property + def is_on(self): + """Return true if switch is on.""" + return self._state + + def turn_on(self, **kwargs): + """Turn the switch on.""" + self._pca.turn_on(self._device_id) + + def turn_off(self, **kwargs): + """Turn the switch off.""" + self._pca.turn_off(self._device_id) + + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + return self._emeter_params + + def update(self): + """Update the PCA switch's state.""" + try: + self._emeter_params[ATTR_CURRENT_POWER_W] = "{:.1f}".format( + self._pca.get_current_power(self._device_id) + ) + self._emeter_params[ATTR_TOTAL_ENERGY_KWH] = "{:.2f}".format( + self._pca.get_total_consumption(self._device_id) + ) + + self._available = True + self._state = self._pca.get_state(self._device_id) + + except (OSError) as ex: + if self._available: + _LOGGER.warning("Could not read state for %s: %s", self.name, ex) + self._available = False diff --git a/homeassistant/components/emby/__init__.py b/homeassistant/components/emby/__init__.py new file mode 100644 index 0000000000000..053da956c6478 --- /dev/null +++ b/homeassistant/components/emby/__init__.py @@ -0,0 +1 @@ +"""The emby component.""" diff --git a/homeassistant/components/emby/manifest.json b/homeassistant/components/emby/manifest.json new file mode 100644 index 0000000000000..ec50b663c0198 --- /dev/null +++ b/homeassistant/components/emby/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "emby", + "name": "Emby", + "documentation": "https://www.home-assistant.io/integrations/emby", + "requirements": ["pyemby==1.6"], + "dependencies": [], + "codeowners": ["@mezz64"] +} diff --git a/homeassistant/components/emby/media_player.py b/homeassistant/components/emby/media_player.py new file mode 100644 index 0000000000000..57f781deceb74 --- /dev/null +++ b/homeassistant/components/emby/media_player.py @@ -0,0 +1,370 @@ +"""Support to interface with the Emby API.""" +import logging + +from pyemby import EmbyServer +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_MOVIE, + MEDIA_TYPE_MUSIC, + MEDIA_TYPE_TVSHOW, + SUPPORT_NEXT_TRACK, + SUPPORT_PAUSE, + SUPPORT_PLAY, + SUPPORT_PREVIOUS_TRACK, + SUPPORT_SEEK, + SUPPORT_STOP, +) +from homeassistant.const import ( + CONF_API_KEY, + CONF_HOST, + CONF_PORT, + CONF_SSL, + DEVICE_DEFAULT_NAME, + EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP, + STATE_IDLE, + STATE_OFF, + STATE_PAUSED, + STATE_PLAYING, +) +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv +import homeassistant.util.dt as dt_util + +_LOGGER = logging.getLogger(__name__) + +CONF_AUTO_HIDE = "auto_hide" + +MEDIA_TYPE_TRAILER = "trailer" +MEDIA_TYPE_GENERIC_VIDEO = "video" + +DEFAULT_HOST = "localhost" +DEFAULT_PORT = 8096 +DEFAULT_SSL_PORT = 8920 +DEFAULT_SSL = False +DEFAULT_AUTO_HIDE = False + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_EMBY = ( + SUPPORT_PAUSE + | SUPPORT_PREVIOUS_TRACK + | SUPPORT_NEXT_TRACK + | SUPPORT_STOP + | SUPPORT_SEEK + | SUPPORT_PLAY +) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_API_KEY): cv.string, + vol.Optional(CONF_AUTO_HIDE, default=DEFAULT_AUTO_HIDE): cv.boolean, + vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_PORT): cv.port, + vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, + } +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the Emby platform.""" + + host = config.get(CONF_HOST) + key = config.get(CONF_API_KEY) + port = config.get(CONF_PORT) + ssl = config.get(CONF_SSL) + auto_hide = config.get(CONF_AUTO_HIDE) + + if port is None: + port = DEFAULT_SSL_PORT if ssl else DEFAULT_PORT + + _LOGGER.debug("Setting up Emby server at: %s:%s", host, port) + + emby = EmbyServer(host, key, port, ssl, hass.loop) + + active_emby_devices = {} + inactive_emby_devices = {} + + @callback + def device_update_callback(data): + """Handle devices which are added to Emby.""" + new_devices = [] + active_devices = [] + for dev_id in emby.devices: + active_devices.append(dev_id) + if ( + dev_id not in active_emby_devices + and dev_id not in inactive_emby_devices + ): + new = EmbyDevice(emby, dev_id) + active_emby_devices[dev_id] = new + new_devices.append(new) + + elif dev_id in inactive_emby_devices: + if emby.devices[dev_id].state != "Off": + add = inactive_emby_devices.pop(dev_id) + active_emby_devices[dev_id] = add + _LOGGER.debug("Showing %s, item: %s", dev_id, add) + add.set_available(True) + add.set_hidden(False) + + if new_devices: + _LOGGER.debug("Adding new devices: %s", new_devices) + async_add_entities(new_devices, True) + + @callback + def device_removal_callback(data): + """Handle the removal of devices from Emby.""" + if data in active_emby_devices: + rem = active_emby_devices.pop(data) + inactive_emby_devices[data] = rem + _LOGGER.debug("Inactive %s, item: %s", data, rem) + rem.set_available(False) + if auto_hide: + rem.set_hidden(True) + + @callback + def start_emby(event): + """Start Emby connection.""" + emby.start() + + async def stop_emby(event): + """Stop Emby connection.""" + await emby.stop() + + emby.add_new_devices_callback(device_update_callback) + emby.add_stale_devices_callback(device_removal_callback) + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_emby) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_emby) + + +class EmbyDevice(MediaPlayerDevice): + """Representation of an Emby device.""" + + def __init__(self, emby, device_id): + """Initialize the Emby device.""" + _LOGGER.debug("New Emby Device initialized with ID: %s", device_id) + self.emby = emby + self.device_id = device_id + self.device = self.emby.devices[self.device_id] + + self._hidden = False + self._available = True + + self.media_status_last_position = None + self.media_status_received = None + + async def async_added_to_hass(self): + """Register callback.""" + self.emby.add_update_callback(self.async_update_callback, self.device_id) + + @callback + def async_update_callback(self, msg): + """Handle device updates.""" + # Check if we should update progress + if self.device.media_position: + if self.device.media_position != self.media_status_last_position: + self.media_status_last_position = self.device.media_position + self.media_status_received = dt_util.utcnow() + elif not self.device.is_nowplaying: + # No position, but we have an old value and are still playing + self.media_status_last_position = None + self.media_status_received = None + + self.async_schedule_update_ha_state() + + @property + def hidden(self): + """Return True if entity should be hidden from UI.""" + return self._hidden + + def set_hidden(self, value): + """Set hidden property.""" + self._hidden = value + + @property + def available(self): + """Return True if entity is available.""" + return self._available + + def set_available(self, value): + """Set available property.""" + self._available = value + + @property + def unique_id(self): + """Return the id of this emby client.""" + return self.device_id + + @property + def supports_remote_control(self): + """Return control ability.""" + return self.device.supports_remote_control + + @property + def name(self): + """Return the name of the device.""" + return ( + f"Emby - {self.device.client} - {self.device.name}" or DEVICE_DEFAULT_NAME + ) + + @property + def should_poll(self): + """Return True if entity has to be polled for state.""" + return False + + @property + def state(self): + """Return the state of the device.""" + state = self.device.state + if state == "Paused": + return STATE_PAUSED + if state == "Playing": + return STATE_PLAYING + if state == "Idle": + return STATE_IDLE + if state == "Off": + return STATE_OFF + + @property + def app_name(self): + """Return current user as app_name.""" + # Ideally the media_player object would have a user property. + return self.device.username + + @property + def media_content_id(self): + """Content ID of current playing media.""" + return self.device.media_id + + @property + def media_content_type(self): + """Content type of current playing media.""" + media_type = self.device.media_type + if media_type == "Episode": + return MEDIA_TYPE_TVSHOW + if media_type == "Movie": + return MEDIA_TYPE_MOVIE + if media_type == "Trailer": + return MEDIA_TYPE_TRAILER + if media_type == "Music": + return MEDIA_TYPE_MUSIC + if media_type == "Video": + return MEDIA_TYPE_GENERIC_VIDEO + if media_type == "Audio": + return MEDIA_TYPE_MUSIC + if media_type == "TvChannel": + return MEDIA_TYPE_CHANNEL + return None + + @property + def media_duration(self): + """Return the duration of current playing media in seconds.""" + return self.device.media_runtime + + @property + def media_position(self): + """Return the position of current playing media in seconds.""" + return self.media_status_last_position + + @property + def media_position_updated_at(self): + """ + When was the position of the current playing media valid. + + Returns value from homeassistant.util.dt.utcnow(). + """ + return self.media_status_received + + @property + def media_image_url(self): + """Return the image URL of current playing media.""" + return self.device.media_image_url + + @property + def media_title(self): + """Return the title of current playing media.""" + return self.device.media_title + + @property + def media_season(self): + """Season of current playing media (TV Show only).""" + return self.device.media_season + + @property + def media_series_title(self): + """Return the title of the series of current playing media (TV).""" + return self.device.media_series_title + + @property + def media_episode(self): + """Return the episode of current playing media (TV only).""" + return self.device.media_episode + + @property + def media_album_name(self): + """Return the album name of current playing media (Music only).""" + return self.device.media_album_name + + @property + def media_artist(self): + """Return the artist of current playing media (Music track only).""" + return self.device.media_artist + + @property + def media_album_artist(self): + """Return the album artist of current playing media (Music only).""" + return self.device.media_album_artist + + @property + def supported_features(self): + """Flag media player features that are supported.""" + if self.supports_remote_control: + return SUPPORT_EMBY + return None + + def async_media_play(self): + """Play media. + + This method must be run in the event loop and returns a coroutine. + """ + return self.device.media_play() + + def async_media_pause(self): + """Pause the media player. + + This method must be run in the event loop and returns a coroutine. + """ + return self.device.media_pause() + + def async_media_stop(self): + """Stop the media player. + + This method must be run in the event loop and returns a coroutine. + """ + return self.device.media_stop() + + def async_media_next_track(self): + """Send next track command. + + This method must be run in the event loop and returns a coroutine. + """ + return self.device.media_next() + + def async_media_previous_track(self): + """Send next track command. + + This method must be run in the event loop and returns a coroutine. + """ + return self.device.media_previous() + + def async_media_seek(self, position): + """Send seek command. + + This method must be run in the event loop and returns a coroutine. + """ + return self.device.media_seek(position) diff --git a/homeassistant/components/emoncms/__init__.py b/homeassistant/components/emoncms/__init__.py new file mode 100644 index 0000000000000..5e7adbcd6e772 --- /dev/null +++ b/homeassistant/components/emoncms/__init__.py @@ -0,0 +1 @@ +"""The emoncms component.""" diff --git a/homeassistant/components/emoncms/manifest.json b/homeassistant/components/emoncms/manifest.json new file mode 100644 index 0000000000000..83833d4f79bc5 --- /dev/null +++ b/homeassistant/components/emoncms/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "emoncms", + "name": "Emoncms", + "documentation": "https://www.home-assistant.io/integrations/emoncms", + "requirements": [], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/emoncms/sensor.py b/homeassistant/components/emoncms/sensor.py new file mode 100644 index 0000000000000..34063e4c253f4 --- /dev/null +++ b/homeassistant/components/emoncms/sensor.py @@ -0,0 +1,253 @@ +"""Support for monitoring emoncms feeds.""" +from datetime import timedelta +import logging + +import requests +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_API_KEY, + CONF_ID, + CONF_SCAN_INTERVAL, + CONF_UNIT_OF_MEASUREMENT, + CONF_URL, + CONF_VALUE_TEMPLATE, + POWER_WATT, + STATE_UNKNOWN, +) +from homeassistant.helpers import template +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +_LOGGER = logging.getLogger(__name__) + +ATTR_FEEDID = "FeedId" +ATTR_FEEDNAME = "FeedName" +ATTR_LASTUPDATETIME = "LastUpdated" +ATTR_LASTUPDATETIMESTR = "LastUpdatedStr" +ATTR_SIZE = "Size" +ATTR_TAG = "Tag" +ATTR_USERID = "UserId" + +CONF_EXCLUDE_FEEDID = "exclude_feed_id" +CONF_ONLY_INCLUDE_FEEDID = "include_only_feed_id" +CONF_SENSOR_NAMES = "sensor_names" + +DECIMALS = 2 +DEFAULT_UNIT = POWER_WATT + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=5) + +ONLY_INCL_EXCL_NONE = "only_include_exclude_or_none" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_URL): cv.string, + vol.Required(CONF_ID): cv.positive_int, + vol.Exclusive(CONF_ONLY_INCLUDE_FEEDID, ONLY_INCL_EXCL_NONE): vol.All( + cv.ensure_list, [cv.positive_int] + ), + vol.Exclusive(CONF_EXCLUDE_FEEDID, ONLY_INCL_EXCL_NONE): vol.All( + cv.ensure_list, [cv.positive_int] + ), + vol.Optional(CONF_SENSOR_NAMES): vol.All( + {cv.positive_int: vol.All(cv.string, vol.Length(min=1))} + ), + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_UNIT_OF_MEASUREMENT, default=DEFAULT_UNIT): cv.string, + } +) + + +def get_id(sensorid, feedtag, feedname, feedid, feeduserid): + """Return unique identifier for feed / sensor.""" + return "emoncms{}_{}_{}_{}_{}".format( + sensorid, feedtag, feedname, feedid, feeduserid + ) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Emoncms sensor.""" + apikey = config.get(CONF_API_KEY) + url = config.get(CONF_URL) + sensorid = config.get(CONF_ID) + value_template = config.get(CONF_VALUE_TEMPLATE) + unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) + exclude_feeds = config.get(CONF_EXCLUDE_FEEDID) + include_only_feeds = config.get(CONF_ONLY_INCLUDE_FEEDID) + sensor_names = config.get(CONF_SENSOR_NAMES) + interval = config.get(CONF_SCAN_INTERVAL) + + if value_template is not None: + value_template.hass = hass + + data = EmonCmsData(hass, url, apikey, interval) + + data.update() + + if data.data is None: + return False + + sensors = [] + + for elem in data.data: + + if exclude_feeds is not None: + if int(elem["id"]) in exclude_feeds: + continue + + if include_only_feeds is not None: + if int(elem["id"]) not in include_only_feeds: + continue + + name = None + if sensor_names is not None: + name = sensor_names.get(int(elem["id"]), None) + + sensors.append( + EmonCmsSensor( + hass, + data, + name, + value_template, + unit_of_measurement, + str(sensorid), + elem, + ) + ) + add_entities(sensors) + + +class EmonCmsSensor(Entity): + """Implementation of an Emoncms sensor.""" + + def __init__( + self, hass, data, name, value_template, unit_of_measurement, sensorid, elem + ): + """Initialize the sensor.""" + if name is None: + # Suppress ID in sensor name if it's 1, since most people won't + # have more than one EmonCMS source and it's redundant to show the + # ID if there's only one. + id_for_name = "" if str(sensorid) == "1" else sensorid + # Use the feed name assigned in EmonCMS or fall back to the feed ID + feed_name = elem.get("name") or "Feed {}".format(elem["id"]) + self._name = f"EmonCMS{id_for_name} {feed_name}" + else: + self._name = name + self._identifier = get_id( + sensorid, elem["tag"], elem["name"], elem["id"], elem["userid"] + ) + self._hass = hass + self._data = data + self._value_template = value_template + self._unit_of_measurement = unit_of_measurement + self._sensorid = sensorid + self._elem = elem + + if self._value_template is not None: + self._state = self._value_template.render_with_possible_json_value( + elem["value"], STATE_UNKNOWN + ) + else: + self._state = round(float(elem["value"]), DECIMALS) + + @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 device.""" + return self._state + + @property + def device_state_attributes(self): + """Return the attributes of the sensor.""" + return { + ATTR_FEEDID: self._elem["id"], + ATTR_TAG: self._elem["tag"], + ATTR_FEEDNAME: self._elem["name"], + ATTR_SIZE: self._elem["size"], + ATTR_USERID: self._elem["userid"], + ATTR_LASTUPDATETIME: self._elem["time"], + ATTR_LASTUPDATETIMESTR: template.timestamp_local(float(self._elem["time"])), + } + + def update(self): + """Get the latest data and updates the state.""" + self._data.update() + + if self._data.data is None: + return + + elem = next( + ( + elem + for elem in self._data.data + if get_id( + self._sensorid, + elem["tag"], + elem["name"], + elem["id"], + elem["userid"], + ) + == self._identifier + ), + None, + ) + + if elem is None: + return + + self._elem = elem + + if self._value_template is not None: + self._state = self._value_template.render_with_possible_json_value( + elem["value"], STATE_UNKNOWN + ) + else: + self._state = round(float(elem["value"]), DECIMALS) + + +class EmonCmsData: + """The class for handling the data retrieval.""" + + def __init__(self, hass, url, apikey, interval): + """Initialize the data object.""" + self._apikey = apikey + self._url = f"{url}/feed/list.json" + self._interval = interval + self._hass = hass + self.data = None + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data from Emoncms.""" + try: + parameters = {"apikey": self._apikey} + req = requests.get( + self._url, params=parameters, allow_redirects=True, timeout=5 + ) + except requests.exceptions.RequestException as exception: + _LOGGER.error(exception) + return + else: + if req.status_code == 200: + self.data = req.json() + else: + _LOGGER.error( + "Please verify if the specified config value " + "'%s' is correct! (HTTP Status_code = %d)", + CONF_URL, + req.status_code, + ) diff --git a/homeassistant/components/emoncms_history/__init__.py b/homeassistant/components/emoncms_history/__init__.py new file mode 100644 index 0000000000000..fd38da1cac17e --- /dev/null +++ b/homeassistant/components/emoncms_history/__init__.py @@ -0,0 +1,101 @@ +"""Support for sending data to Emoncms.""" +from datetime import timedelta +import logging + +import requests +import voluptuous as vol + +from homeassistant.const import ( + CONF_API_KEY, + CONF_SCAN_INTERVAL, + CONF_URL, + CONF_WHITELIST, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) +from homeassistant.helpers import state as state_helper +import homeassistant.helpers.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__) + +DOMAIN = "emoncms_history" +CONF_INPUTNODE = "inputnode" + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_URL): cv.string, + vol.Required(CONF_INPUTNODE): cv.positive_int, + vol.Required(CONF_WHITELIST): cv.entity_ids, + vol.Optional(CONF_SCAN_INTERVAL, default=30): cv.positive_int, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +def setup(hass, config): + """Set up the Emoncms history component.""" + conf = config[DOMAIN] + whitelist = conf.get(CONF_WHITELIST) + + def send_data(url, apikey, node, payload): + """Send payload data to Emoncms.""" + try: + fullurl = f"{url}/input/post.json" + data = {"apikey": apikey, "data": payload} + parameters = {"node": node} + req = requests.post( + fullurl, params=parameters, data=data, allow_redirects=True, timeout=5 + ) + + except requests.exceptions.RequestException: + _LOGGER.error("Error saving data '%s' to '%s'", payload, fullurl) + + else: + if req.status_code != 200: + _LOGGER.error( + "Error saving data %s to %s (http status code = %d)", + payload, + fullurl, + req.status_code, + ) + + def update_emoncms(time): + """Send whitelisted entities states regularly to Emoncms.""" + payload_dict = {} + + for entity_id in whitelist: + state = hass.states.get(entity_id) + + if state is None or state.state in (STATE_UNKNOWN, "", STATE_UNAVAILABLE): + continue + + try: + payload_dict[entity_id] = state_helper.state_as_number(state) + except ValueError: + continue + + if payload_dict: + payload = "{%s}" % ",".join( + f"{key}:{val}" for key, val in payload_dict.items() + ) + + send_data( + conf.get(CONF_URL), + conf.get(CONF_API_KEY), + str(conf.get(CONF_INPUTNODE)), + payload, + ) + + track_point_in_time( + hass, update_emoncms, time + timedelta(seconds=conf.get(CONF_SCAN_INTERVAL)) + ) + + update_emoncms(dt_util.utcnow()) + return True diff --git a/homeassistant/components/emoncms_history/manifest.json b/homeassistant/components/emoncms_history/manifest.json new file mode 100644 index 0000000000000..34270b6e2094a --- /dev/null +++ b/homeassistant/components/emoncms_history/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "emoncms_history", + "name": "Emoncms History", + "documentation": "https://www.home-assistant.io/integrations/emoncms_history", + "requirements": [], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/emulated_hue/__init__.py b/homeassistant/components/emulated_hue/__init__.py new file mode 100644 index 0000000000000..0a358c6e894eb --- /dev/null +++ b/homeassistant/components/emulated_hue/__init__.py @@ -0,0 +1,320 @@ +"""Support for local control of entities by emulating a Philips Hue bridge.""" +import logging + +from aiohttp import web +import voluptuous as vol + +from homeassistant import util +from homeassistant.components.http import real_ip +from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP +from homeassistant.exceptions import HomeAssistantError +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.deprecation import get_deprecated +from homeassistant.util.json import load_json, save_json + +from .hue_api import ( + HueAllGroupsStateView, + HueAllLightsStateView, + HueFullStateView, + HueGroupView, + HueOneLightChangeView, + HueOneLightStateView, + HueUnauthorizedUser, + HueUsernameView, +) +from .upnp import DescriptionXmlView, UPNPResponderThread + +DOMAIN = "emulated_hue" + +_LOGGER = logging.getLogger(__name__) + +NUMBERS_FILE = "emulated_hue_ids.json" + +CONF_ADVERTISE_IP = "advertise_ip" +CONF_ADVERTISE_PORT = "advertise_port" +CONF_ENTITIES = "entities" +CONF_ENTITY_HIDDEN = "hidden" +CONF_ENTITY_NAME = "name" +CONF_EXPOSE_BY_DEFAULT = "expose_by_default" +CONF_EXPOSED_DOMAINS = "exposed_domains" +CONF_HOST_IP = "host_ip" +CONF_LISTEN_PORT = "listen_port" +CONF_OFF_MAPS_TO_ON_DOMAINS = "off_maps_to_on_domains" +CONF_TYPE = "type" +CONF_UPNP_BIND_MULTICAST = "upnp_bind_multicast" + +TYPE_ALEXA = "alexa" +TYPE_GOOGLE = "google_home" + +DEFAULT_LISTEN_PORT = 8300 +DEFAULT_UPNP_BIND_MULTICAST = True +DEFAULT_OFF_MAPS_TO_ON_DOMAINS = ["script", "scene"] +DEFAULT_EXPOSE_BY_DEFAULT = True +DEFAULT_EXPOSED_DOMAINS = [ + "switch", + "light", + "group", + "input_boolean", + "media_player", + "fan", +] +DEFAULT_TYPE = TYPE_GOOGLE + +CONFIG_ENTITY_SCHEMA = vol.Schema( + { + vol.Optional(CONF_ENTITY_NAME): cv.string, + vol.Optional(CONF_ENTITY_HIDDEN): cv.boolean, + } +) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Optional(CONF_HOST_IP): cv.string, + vol.Optional(CONF_LISTEN_PORT, default=DEFAULT_LISTEN_PORT): cv.port, + vol.Optional(CONF_ADVERTISE_IP): cv.string, + vol.Optional(CONF_ADVERTISE_PORT): cv.port, + vol.Optional(CONF_UPNP_BIND_MULTICAST): cv.boolean, + vol.Optional(CONF_OFF_MAPS_TO_ON_DOMAINS): cv.ensure_list, + vol.Optional(CONF_EXPOSE_BY_DEFAULT): cv.boolean, + vol.Optional(CONF_EXPOSED_DOMAINS): cv.ensure_list, + vol.Optional(CONF_TYPE, default=DEFAULT_TYPE): vol.Any( + TYPE_ALEXA, TYPE_GOOGLE + ), + vol.Optional(CONF_ENTITIES): vol.Schema( + {cv.entity_id: CONFIG_ENTITY_SCHEMA} + ), + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + +ATTR_EMULATED_HUE = "emulated_hue" +ATTR_EMULATED_HUE_NAME = "emulated_hue_name" +ATTR_EMULATED_HUE_HIDDEN = "emulated_hue_hidden" + + +async def async_setup(hass, yaml_config): + """Activate the emulated_hue component.""" + config = Config(hass, yaml_config.get(DOMAIN, {})) + + app = web.Application() + app["hass"] = hass + + real_ip.setup_real_ip(app, False, []) + # We misunderstood the startup signal. You're not allowed to change + # anything during startup. Temp workaround. + # pylint: disable=protected-access + app._on_startup.freeze() + await app.startup() + + runner = None + site = None + + DescriptionXmlView(config).register(app, app.router) + HueUsernameView().register(app, app.router) + HueUnauthorizedUser().register(app, app.router) + HueAllLightsStateView(config).register(app, app.router) + HueOneLightStateView(config).register(app, app.router) + HueOneLightChangeView(config).register(app, app.router) + HueAllGroupsStateView(config).register(app, app.router) + HueGroupView(config).register(app, app.router) + HueFullStateView(config).register(app, app.router) + + upnp_listener = UPNPResponderThread( + config.host_ip_addr, + config.listen_port, + config.upnp_bind_multicast, + config.advertise_ip, + config.advertise_port, + ) + + async def stop_emulated_hue_bridge(event): + """Stop the emulated hue bridge.""" + upnp_listener.stop() + if site: + await site.stop() + if runner: + await runner.cleanup() + + async def start_emulated_hue_bridge(event): + """Start the emulated hue bridge.""" + upnp_listener.start() + nonlocal site + nonlocal runner + + runner = web.AppRunner(app) + await runner.setup() + + site = web.TCPSite(runner, config.host_ip_addr, config.listen_port) + + try: + await site.start() + except OSError as error: + _LOGGER.error( + "Failed to create HTTP server at port %d: %s", config.listen_port, error + ) + else: + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, stop_emulated_hue_bridge + ) + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_emulated_hue_bridge) + + return True + + +class Config: + """Hold configuration variables for the emulated hue bridge.""" + + def __init__(self, hass, conf): + """Initialize the instance.""" + self.hass = hass + self.type = conf.get(CONF_TYPE) + self.numbers = None + self.cached_states = {} + + if self.type == TYPE_ALEXA: + _LOGGER.warning( + "Emulated Hue running in legacy mode because type has been " + "specified. More info at https://goo.gl/M6tgz8" + ) + + # Get the IP address that will be passed to the Echo during discovery + self.host_ip_addr = conf.get(CONF_HOST_IP) + if self.host_ip_addr is None: + self.host_ip_addr = util.get_local_ip() + _LOGGER.info( + "Listen IP address not specified, auto-detected address is %s", + self.host_ip_addr, + ) + + # Get the port that the Hue bridge will listen on + self.listen_port = conf.get(CONF_LISTEN_PORT) + if not isinstance(self.listen_port, int): + self.listen_port = DEFAULT_LISTEN_PORT + _LOGGER.info( + "Listen port not specified, defaulting to %s", self.listen_port + ) + + # Get whether or not UPNP binds to multicast address (239.255.255.250) + # or to the unicast address (host_ip_addr) + self.upnp_bind_multicast = conf.get( + CONF_UPNP_BIND_MULTICAST, DEFAULT_UPNP_BIND_MULTICAST + ) + + # Get domains that cause both "on" and "off" commands to map to "on" + # This is primarily useful for things like scenes or scripts, which + # don't really have a concept of being off + self.off_maps_to_on_domains = conf.get(CONF_OFF_MAPS_TO_ON_DOMAINS) + if not isinstance(self.off_maps_to_on_domains, list): + self.off_maps_to_on_domains = DEFAULT_OFF_MAPS_TO_ON_DOMAINS + + # Get whether or not entities should be exposed by default, or if only + # explicitly marked ones will be exposed + self.expose_by_default = conf.get( + CONF_EXPOSE_BY_DEFAULT, DEFAULT_EXPOSE_BY_DEFAULT + ) + + # Get domains that are exposed by default when expose_by_default is + # True + self.exposed_domains = conf.get(CONF_EXPOSED_DOMAINS, DEFAULT_EXPOSED_DOMAINS) + + # Calculated effective advertised IP and port for network isolation + self.advertise_ip = conf.get(CONF_ADVERTISE_IP) or self.host_ip_addr + + self.advertise_port = conf.get(CONF_ADVERTISE_PORT) or self.listen_port + + self.entities = conf.get(CONF_ENTITIES, {}) + + def entity_id_to_number(self, entity_id): + """Get a unique number for the entity id.""" + if self.type == TYPE_ALEXA: + return entity_id + + if self.numbers is None: + self.numbers = _load_json(self.hass.config.path(NUMBERS_FILE)) + + # Google Home + for number, ent_id in self.numbers.items(): + if entity_id == ent_id: + return number + + number = "1" + if self.numbers: + number = str(max(int(k) for k in self.numbers) + 1) + self.numbers[number] = entity_id + save_json(self.hass.config.path(NUMBERS_FILE), self.numbers) + return number + + def number_to_entity_id(self, number): + """Convert unique number to entity id.""" + if self.type == TYPE_ALEXA: + return number + + if self.numbers is None: + self.numbers = _load_json(self.hass.config.path(NUMBERS_FILE)) + + # Google Home + assert isinstance(number, str) + return self.numbers.get(number) + + def get_entity_name(self, entity): + """Get the name of an entity.""" + if ( + entity.entity_id in self.entities + and CONF_ENTITY_NAME in self.entities[entity.entity_id] + ): + return self.entities[entity.entity_id][CONF_ENTITY_NAME] + + return entity.attributes.get(ATTR_EMULATED_HUE_NAME, entity.name) + + def is_entity_exposed(self, entity): + """Determine if an entity should be exposed on the emulated bridge. + + Async friendly. + """ + if entity.attributes.get("view") is not None: + # Ignore entities that are views + return False + + domain = entity.domain.lower() + explicit_expose = entity.attributes.get(ATTR_EMULATED_HUE, None) + explicit_hidden = entity.attributes.get(ATTR_EMULATED_HUE_HIDDEN, None) + + if ( + entity.entity_id in self.entities + and CONF_ENTITY_HIDDEN in self.entities[entity.entity_id] + ): + explicit_hidden = self.entities[entity.entity_id][CONF_ENTITY_HIDDEN] + + if explicit_expose is True or explicit_hidden is False: + expose = True + elif explicit_expose is False or explicit_hidden is True: + expose = False + else: + expose = None + get_deprecated( + entity.attributes, ATTR_EMULATED_HUE_HIDDEN, ATTR_EMULATED_HUE, None + ) + domain_exposed_by_default = ( + self.expose_by_default and domain in self.exposed_domains + ) + + # 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 expose is not False + + return is_default_exposed or expose + + +def _load_json(filename): + """Load JSON, handling invalid syntax.""" + try: + return load_json(filename) + except HomeAssistantError: + pass + return {} diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py new file mode 100644 index 0000000000000..b054d69e7a497 --- /dev/null +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -0,0 +1,730 @@ +"""Support for a Hue API to control Home Assistant.""" +import hashlib +import logging + +from homeassistant import core +from homeassistant.components import ( + climate, + cover, + fan, + light, + media_player, + scene, + script, +) +from homeassistant.components.climate.const import ( + SERVICE_SET_TEMPERATURE, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.components.cover import ( + ATTR_CURRENT_POSITION, + ATTR_POSITION, + SERVICE_SET_COVER_POSITION, + SUPPORT_SET_POSITION, +) +from homeassistant.components.fan import ( + ATTR_SPEED, + SPEED_HIGH, + SPEED_LOW, + SPEED_MEDIUM, + SPEED_OFF, + SUPPORT_SET_SPEED, +) +from homeassistant.components.http import HomeAssistantView +from homeassistant.components.http.const import KEY_REAL_IP +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP, + ATTR_HS_COLOR, + SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, + SUPPORT_COLOR_TEMP, +) +from homeassistant.components.media_player.const import ( + ATTR_MEDIA_VOLUME_LEVEL, + SUPPORT_VOLUME_SET, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + ATTR_TEMPERATURE, + HTTP_BAD_REQUEST, + HTTP_NOT_FOUND, + HTTP_UNAUTHORIZED, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + SERVICE_VOLUME_SET, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, +) +from homeassistant.util.network import is_local + +_LOGGER = logging.getLogger(__name__) + +STATE_BRIGHTNESS = "bri" +STATE_COLORMODE = "colormode" +STATE_HUE = "hue" +STATE_SATURATION = "sat" +STATE_COLOR_TEMP = "ct" + +# Hue API states, defined separately in case they change +HUE_API_STATE_ON = "on" +HUE_API_STATE_BRI = "bri" +HUE_API_STATE_COLORMODE = "colormode" +HUE_API_STATE_HUE = "hue" +HUE_API_STATE_SAT = "sat" +HUE_API_STATE_CT = "ct" +HUE_API_STATE_EFFECT = "effect" + +# Hue API min/max values - https://developers.meethue.com/develop/hue-api/lights-api/ +HUE_API_STATE_BRI_MIN = 1 # Brightness +HUE_API_STATE_BRI_MAX = 254 +HUE_API_STATE_HUE_MIN = 0 # Hue +HUE_API_STATE_HUE_MAX = 65535 +HUE_API_STATE_SAT_MIN = 0 # Saturation +HUE_API_STATE_SAT_MAX = 254 +HUE_API_STATE_CT_MIN = 153 # Color temp +HUE_API_STATE_CT_MAX = 500 + +HUE_API_USERNAME = "12345678901234567890" +UNAUTHORIZED_USER = [ + {"error": {"address": "/", "description": "unauthorized user", "type": "1"}} +] + + +class HueUnauthorizedUser(HomeAssistantView): + """Handle requests to find the emulated hue bridge.""" + + url = "/api" + name = "emulated_hue:api:unauthorized_user" + extra_urls = ["/api/"] + requires_auth = False + + async def get(self, request): + """Handle a GET request.""" + return self.json(UNAUTHORIZED_USER) + + +class HueUsernameView(HomeAssistantView): + """Handle requests to create a username for the emulated hue bridge.""" + + url = "/api" + name = "emulated_hue:api:create_username" + extra_urls = ["/api/"] + requires_auth = False + + async def post(self, request): + """Handle a POST request.""" + if not is_local(request[KEY_REAL_IP]): + return self.json_message("Only local IPs allowed", HTTP_UNAUTHORIZED) + + try: + data = await request.json() + except ValueError: + return self.json_message("Invalid JSON", HTTP_BAD_REQUEST) + + if "devicetype" not in data: + return self.json_message("devicetype not specified", HTTP_BAD_REQUEST) + + return self.json([{"success": {"username": HUE_API_USERNAME}}]) + + +class HueAllGroupsStateView(HomeAssistantView): + """Handle requests for getting info about entity groups.""" + + url = "/api/{username}/groups" + name = "emulated_hue:all_groups:state" + requires_auth = False + + def __init__(self, config): + """Initialize the instance of the view.""" + self.config = config + + @core.callback + def get(self, request, username): + """Process a request to make the Brilliant Lightpad work.""" + if not is_local(request[KEY_REAL_IP]): + return self.json_message("Only local IPs allowed", HTTP_UNAUTHORIZED) + + return self.json({}) + + +class HueGroupView(HomeAssistantView): + """Group handler to get Logitech Pop working.""" + + url = "/api/{username}/groups/0/action" + name = "emulated_hue:groups:state" + requires_auth = False + + def __init__(self, config): + """Initialize the instance of the view.""" + self.config = config + + @core.callback + def put(self, request, username): + """Process a request to make the Logitech Pop working.""" + if not is_local(request[KEY_REAL_IP]): + return self.json_message("Only local IPs allowed", HTTP_UNAUTHORIZED) + + return self.json( + [ + { + "error": { + "address": "/groups/0/action/scene", + "type": 7, + "description": "invalid value, dummy for parameter, scene", + } + } + ] + ) + + +class HueAllLightsStateView(HomeAssistantView): + """Handle requests for getting info about all entities.""" + + url = "/api/{username}/lights" + name = "emulated_hue:lights:state" + requires_auth = False + + def __init__(self, config): + """Initialize the instance of the view.""" + self.config = config + + @core.callback + def get(self, request, username): + """Process a request to get the list of available lights.""" + if not is_local(request[KEY_REAL_IP]): + return self.json_message("Only local IPs allowed", HTTP_UNAUTHORIZED) + + return self.json(create_list_of_entities(self.config, request)) + + +class HueFullStateView(HomeAssistantView): + """Return full state view of emulated hue.""" + + url = "/api/{username}" + name = "emulated_hue:username:state" + requires_auth = False + + def __init__(self, config): + """Initialize the instance of the view.""" + self.config = config + + @core.callback + def get(self, request, username): + """Process a request to get the list of available lights.""" + if not is_local(request[KEY_REAL_IP]): + return self.json_message("only local IPs allowed", HTTP_UNAUTHORIZED) + if username != HUE_API_USERNAME: + return self.json(UNAUTHORIZED_USER) + + json_response = { + "lights": create_list_of_entities(self.config, request), + "config": { + "mac": "00:00:00:00:00:00", + "swversion": "01003542", + "whitelist": {HUE_API_USERNAME: {"name": "HASS BRIDGE"}}, + "ipaddress": f"{self.config.advertise_ip}:{self.config.advertise_port}", + }, + } + + return self.json(json_response) + + +class HueOneLightStateView(HomeAssistantView): + """Handle requests for getting info about a single entity.""" + + url = "/api/{username}/lights/{entity_id}" + name = "emulated_hue:light:state" + requires_auth = False + + def __init__(self, config): + """Initialize the instance of the view.""" + self.config = config + + @core.callback + def get(self, request, username, entity_id): + """Process a request to get the state of an individual light.""" + if not is_local(request[KEY_REAL_IP]): + return self.json_message("Only local IPs allowed", HTTP_UNAUTHORIZED) + + hass = request.app["hass"] + hass_entity_id = self.config.number_to_entity_id(entity_id) + + if hass_entity_id is None: + _LOGGER.error( + "Unknown entity number: %s not found in emulated_hue_ids.json", + entity_id, + ) + return self.json_message("Entity not found", HTTP_NOT_FOUND) + + entity = hass.states.get(hass_entity_id) + + if entity is None: + _LOGGER.error("Entity not found: %s", hass_entity_id) + return self.json_message("Entity not found", HTTP_NOT_FOUND) + + if not self.config.is_entity_exposed(entity): + _LOGGER.error("Entity not exposed: %s", entity_id) + return self.json_message("Entity not exposed", HTTP_UNAUTHORIZED) + + json_response = entity_to_json(self.config, entity) + + return self.json(json_response) + + +class HueOneLightChangeView(HomeAssistantView): + """Handle requests for setting info about entities.""" + + url = "/api/{username}/lights/{entity_number}/state" + name = "emulated_hue:light:state" + requires_auth = False + + def __init__(self, config): + """Initialize the instance of the view.""" + self.config = config + + async def put(self, request, username, entity_number): + """Process a request to set the state of an individual light.""" + if not is_local(request[KEY_REAL_IP]): + return self.json_message("Only local IPs allowed", HTTP_UNAUTHORIZED) + + config = self.config + hass = request.app["hass"] + entity_id = config.number_to_entity_id(entity_number) + + if entity_id is None: + _LOGGER.error("Unknown entity number: %s", entity_number) + return self.json_message("Entity not found", HTTP_NOT_FOUND) + + entity = hass.states.get(entity_id) + + if entity is None: + _LOGGER.error("Entity not found: %s", entity_id) + return self.json_message("Entity not found", HTTP_NOT_FOUND) + + if not config.is_entity_exposed(entity): + _LOGGER.error("Entity not exposed: %s", entity_id) + return self.json_message("Entity not exposed", HTTP_UNAUTHORIZED) + + try: + request_json = await request.json() + except ValueError: + _LOGGER.error("Received invalid json") + return self.json_message("Invalid JSON", HTTP_BAD_REQUEST) + + # Get the entity's supported features + entity_features = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + + # Parse the request + parsed = { + STATE_ON: False, + STATE_BRIGHTNESS: None, + STATE_HUE: None, + STATE_SATURATION: None, + STATE_COLOR_TEMP: None, + } + + if HUE_API_STATE_ON in request_json: + if not isinstance(request_json[HUE_API_STATE_ON], bool): + _LOGGER.error("Unable to parse data: %s", request_json) + return self.json_message("Bad request", HTTP_BAD_REQUEST) + parsed[STATE_ON] = request_json[HUE_API_STATE_ON] + else: + parsed[STATE_ON] = entity.state != STATE_OFF + + for (key, attr) in ( + (HUE_API_STATE_BRI, STATE_BRIGHTNESS), + (HUE_API_STATE_HUE, STATE_HUE), + (HUE_API_STATE_SAT, STATE_SATURATION), + (HUE_API_STATE_CT, STATE_COLOR_TEMP), + ): + if key in request_json: + try: + parsed[attr] = int(request_json[key]) + except ValueError: + _LOGGER.error("Unable to parse data (2): %s", request_json) + return self.json_message("Bad request", HTTP_BAD_REQUEST) + + if HUE_API_STATE_BRI in request_json: + if entity.domain == light.DOMAIN: + parsed[STATE_ON] = parsed[STATE_BRIGHTNESS] > 0 + if not entity_features & SUPPORT_BRIGHTNESS: + parsed[STATE_BRIGHTNESS] = None + + elif entity.domain == scene.DOMAIN: + parsed[STATE_BRIGHTNESS] = None + parsed[STATE_ON] = True + + elif entity.domain in [ + script.DOMAIN, + media_player.DOMAIN, + fan.DOMAIN, + cover.DOMAIN, + climate.DOMAIN, + ]: + # Convert 0-255 to 0-100 + level = (parsed[STATE_BRIGHTNESS] / HUE_API_STATE_BRI_MAX) * 100 + parsed[STATE_BRIGHTNESS] = round(level) + parsed[STATE_ON] = True + + # Choose general HA domain + domain = core.DOMAIN + + # Entity needs separate call to turn on + turn_on_needed = False + + # Convert the resulting "on" status into the service we need to call + service = SERVICE_TURN_ON if parsed[STATE_ON] else SERVICE_TURN_OFF + + # Construct what we need to send to the service + data = {ATTR_ENTITY_ID: entity_id} + + # If the requested entity is a light, set the brightness, hue, + # saturation and color temp + if entity.domain == light.DOMAIN: + if parsed[STATE_ON]: + if entity_features & SUPPORT_BRIGHTNESS: + if parsed[STATE_BRIGHTNESS] is not None: + data[ATTR_BRIGHTNESS] = parsed[STATE_BRIGHTNESS] + + if entity_features & SUPPORT_COLOR: + if any((parsed[STATE_HUE], parsed[STATE_SATURATION])): + if parsed[STATE_HUE] is not None: + hue = parsed[STATE_HUE] + else: + hue = 0 + + if parsed[STATE_SATURATION] is not None: + sat = parsed[STATE_SATURATION] + else: + sat = 0 + + # Convert hs values to hass hs values + hue = int((hue / HUE_API_STATE_HUE_MAX) * 360) + sat = int((sat / HUE_API_STATE_SAT_MAX) * 100) + + data[ATTR_HS_COLOR] = (hue, sat) + + if entity_features & SUPPORT_COLOR_TEMP: + if parsed[STATE_COLOR_TEMP] is not None: + data[ATTR_COLOR_TEMP] = parsed[STATE_COLOR_TEMP] + + # If the requested entity is a script, add some variables + elif entity.domain == script.DOMAIN: + data["variables"] = { + "requested_state": STATE_ON if parsed[STATE_ON] else STATE_OFF + } + + if parsed[STATE_BRIGHTNESS] is not None: + data["variables"]["requested_level"] = parsed[STATE_BRIGHTNESS] + + # If the requested entity is a climate, set the temperature + elif entity.domain == climate.DOMAIN: + # We don't support turning climate devices on or off, + # only setting the temperature + service = None + + if entity_features & SUPPORT_TARGET_TEMPERATURE: + if parsed[STATE_BRIGHTNESS] is not None: + domain = entity.domain + service = SERVICE_SET_TEMPERATURE + data[ATTR_TEMPERATURE] = parsed[STATE_BRIGHTNESS] + + # If the requested entity is a media player, convert to volume + elif entity.domain == media_player.DOMAIN: + if entity_features & SUPPORT_VOLUME_SET: + if parsed[STATE_BRIGHTNESS] is not None: + turn_on_needed = True + domain = entity.domain + service = SERVICE_VOLUME_SET + # Convert 0-100 to 0.0-1.0 + data[ATTR_MEDIA_VOLUME_LEVEL] = parsed[STATE_BRIGHTNESS] / 100.0 + + # If the requested entity is a cover, convert to open_cover/close_cover + elif entity.domain == cover.DOMAIN: + domain = entity.domain + if service == SERVICE_TURN_ON: + service = SERVICE_OPEN_COVER + else: + service = SERVICE_CLOSE_COVER + + if entity_features & SUPPORT_SET_POSITION: + if parsed[STATE_BRIGHTNESS] is not None: + domain = entity.domain + service = SERVICE_SET_COVER_POSITION + data[ATTR_POSITION] = parsed[STATE_BRIGHTNESS] + + # If the requested entity is a fan, convert to speed + elif entity.domain == fan.DOMAIN: + if entity_features & SUPPORT_SET_SPEED: + if parsed[STATE_BRIGHTNESS] is not None: + domain = entity.domain + # Convert 0-100 to a fan speed + brightness = parsed[STATE_BRIGHTNESS] + if brightness == 0: + data[ATTR_SPEED] = SPEED_OFF + elif 0 < brightness <= 33.3: + data[ATTR_SPEED] = SPEED_LOW + elif 33.3 < brightness <= 66.6: + data[ATTR_SPEED] = SPEED_MEDIUM + elif 66.6 < brightness <= 100: + data[ATTR_SPEED] = SPEED_HIGH + + # Map the off command to on + if entity.domain in config.off_maps_to_on_domains: + service = SERVICE_TURN_ON + + # Caching is required because things like scripts and scenes won't + # report as "off" to Alexa if an "off" command is received, because + # they'll map to "on". Thus, instead of reporting its actual + # status, we report what Alexa will want to see, which is the same + # as the actual requested command. + config.cached_states[entity_id] = parsed + + # Separate call to turn on needed + if turn_on_needed: + hass.async_create_task( + hass.services.async_call( + core.DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + ) + + if service is not None: + hass.async_create_task( + hass.services.async_call(domain, service, data, blocking=True) + ) + + # Create success responses for all received keys + json_response = [ + create_hue_success_response(entity_id, HUE_API_STATE_ON, parsed[STATE_ON]) + ] + + for (key, val) in ( + (STATE_BRIGHTNESS, HUE_API_STATE_BRI), + (STATE_HUE, HUE_API_STATE_HUE), + (STATE_SATURATION, HUE_API_STATE_SAT), + (STATE_COLOR_TEMP, HUE_API_STATE_CT), + ): + if parsed[key] is not None: + json_response.append( + create_hue_success_response(entity_id, val, parsed[key]) + ) + + return self.json(json_response) + + +def get_entity_state(config, entity): + """Retrieve and convert state and brightness values for an entity.""" + cached_state = config.cached_states.get(entity.entity_id, None) + data = { + STATE_ON: False, + STATE_BRIGHTNESS: None, + STATE_HUE: None, + STATE_SATURATION: None, + STATE_COLOR_TEMP: None, + } + + if cached_state is None: + data[STATE_ON] = entity.state != STATE_OFF + + if data[STATE_ON]: + data[STATE_BRIGHTNESS] = entity.attributes.get(ATTR_BRIGHTNESS, 0) + hue_sat = entity.attributes.get(ATTR_HS_COLOR, None) + if hue_sat is not None: + hue = hue_sat[0] + sat = hue_sat[1] + # Convert hass hs values back to hue hs values + data[STATE_HUE] = int((hue / 360.0) * HUE_API_STATE_HUE_MAX) + data[STATE_SATURATION] = int((sat / 100.0) * HUE_API_STATE_SAT_MAX) + else: + data[STATE_HUE] = HUE_API_STATE_HUE_MIN + data[STATE_SATURATION] = HUE_API_STATE_SAT_MIN + data[STATE_COLOR_TEMP] = entity.attributes.get(ATTR_COLOR_TEMP, 0) + + else: + data[STATE_BRIGHTNESS] = 0 + data[STATE_HUE] = 0 + data[STATE_SATURATION] = 0 + data[STATE_COLOR_TEMP] = 0 + + # Get the entity's supported features + entity_features = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + + if entity.domain == light.DOMAIN: + if entity_features & SUPPORT_BRIGHTNESS: + pass + elif entity.domain == climate.DOMAIN: + temperature = entity.attributes.get(ATTR_TEMPERATURE, 0) + # Convert 0-100 to 0-255 + data[STATE_BRIGHTNESS] = round(temperature * 255 / 100) + elif entity.domain == media_player.DOMAIN: + level = entity.attributes.get( + ATTR_MEDIA_VOLUME_LEVEL, 1.0 if data[STATE_ON] else 0.0 + ) + # Convert 0.0-1.0 to 0-255 + data[STATE_BRIGHTNESS] = round(min(1.0, level) * 255) + elif entity.domain == fan.DOMAIN: + speed = entity.attributes.get(ATTR_SPEED, 0) + # Convert 0.0-1.0 to 0-255 + data[STATE_BRIGHTNESS] = 0 + if speed == SPEED_LOW: + data[STATE_BRIGHTNESS] = 85 + elif speed == SPEED_MEDIUM: + data[STATE_BRIGHTNESS] = 170 + elif speed == SPEED_HIGH: + data[STATE_BRIGHTNESS] = 255 + elif entity.domain == cover.DOMAIN: + level = entity.attributes.get(ATTR_CURRENT_POSITION, 0) + data[STATE_BRIGHTNESS] = round(level / 100 * 255) + else: + data = cached_state + # Make sure brightness is valid + if data[STATE_BRIGHTNESS] is None: + data[STATE_BRIGHTNESS] = 255 if data[STATE_ON] else 0 + + # Make sure hue/saturation are valid + if (data[STATE_HUE] is None) or (data[STATE_SATURATION] is None): + data[STATE_HUE] = 0 + data[STATE_SATURATION] = 0 + + # If the light is off, set the color to off + if data[STATE_BRIGHTNESS] == 0: + data[STATE_HUE] = 0 + data[STATE_SATURATION] = 0 + + # Clamp brightness, hue, saturation, and color temp to valid values + for (key, v_min, v_max) in ( + (STATE_BRIGHTNESS, HUE_API_STATE_BRI_MIN, HUE_API_STATE_BRI_MAX), + (STATE_HUE, HUE_API_STATE_HUE_MIN, HUE_API_STATE_HUE_MAX), + (STATE_SATURATION, HUE_API_STATE_SAT_MIN, HUE_API_STATE_SAT_MAX), + (STATE_COLOR_TEMP, HUE_API_STATE_CT_MIN, HUE_API_STATE_CT_MAX), + ): + if data[key] is not None: + data[key] = max(v_min, min(data[key], v_max)) + + return data + + +def entity_to_json(config, entity): + """Convert an entity to its Hue bridge JSON representation.""" + entity_features = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + unique_id = hashlib.md5(entity.entity_id.encode()).hexdigest() + unique_id = "00:{}:{}:{}:{}:{}:{}:{}-{}".format( + unique_id[0:2], + unique_id[2:4], + unique_id[4:6], + unique_id[6:8], + unique_id[8:10], + unique_id[10:12], + unique_id[12:14], + unique_id[14:16], + ) + + state = get_entity_state(config, entity) + + retval = { + "state": { + HUE_API_STATE_ON: state[STATE_ON], + "reachable": entity.state != STATE_UNAVAILABLE, + "mode": "homeautomation", + }, + "name": config.get_entity_name(entity), + "uniqueid": unique_id, + "manufacturername": "Home Assistant", + "swversion": "123", + } + + if ( + (entity_features & SUPPORT_BRIGHTNESS) + and (entity_features & SUPPORT_COLOR) + and (entity_features & SUPPORT_COLOR_TEMP) + ): + # Extended Color light (Zigbee Device ID: 0x0210) + # Same as Color light, but which supports additional setting of color temperature + retval["type"] = "Extended color light" + retval["modelid"] = "HASS231" + retval["state"].update( + { + HUE_API_STATE_BRI: state[STATE_BRIGHTNESS], + HUE_API_STATE_HUE: state[STATE_HUE], + HUE_API_STATE_SAT: state[STATE_SATURATION], + HUE_API_STATE_CT: state[STATE_COLOR_TEMP], + HUE_API_STATE_EFFECT: "none", + } + ) + if state[STATE_HUE] > 0 or state[STATE_SATURATION] > 0: + retval["state"][HUE_API_STATE_COLORMODE] = "hs" + else: + retval["state"][HUE_API_STATE_COLORMODE] = "ct" + elif (entity_features & SUPPORT_BRIGHTNESS) and (entity_features & SUPPORT_COLOR): + # Color light (Zigbee Device ID: 0x0200) + # Supports on/off, dimming and color control (hue/saturation, enhanced hue, color loop and XY) + retval["type"] = "Color light" + retval["modelid"] = "HASS213" + retval["state"].update( + { + HUE_API_STATE_BRI: state[STATE_BRIGHTNESS], + HUE_API_STATE_COLORMODE: "hs", + HUE_API_STATE_HUE: state[STATE_HUE], + HUE_API_STATE_SAT: state[STATE_SATURATION], + HUE_API_STATE_EFFECT: "none", + } + ) + elif (entity_features & SUPPORT_BRIGHTNESS) and ( + entity_features & SUPPORT_COLOR_TEMP + ): + # Color temperature light (Zigbee Device ID: 0x0220) + # Supports groups, scenes, on/off, dimming, and setting of a color temperature + retval["type"] = "Color temperature light" + retval["modelid"] = "HASS312" + retval["state"].update( + {HUE_API_STATE_COLORMODE: "ct", HUE_API_STATE_CT: state[STATE_COLOR_TEMP]} + ) + elif ( + entity_features + & ( + SUPPORT_BRIGHTNESS + | SUPPORT_SET_POSITION + | SUPPORT_SET_SPEED + | SUPPORT_VOLUME_SET + | SUPPORT_TARGET_TEMPERATURE + ) + ) or entity.domain == script.DOMAIN: + # Dimmable light (Zigbee Device ID: 0x0100) + # Supports groups, scenes, on/off and dimming + retval["type"] = "Dimmable light" + retval["modelid"] = "HASS123" + retval["state"].update({HUE_API_STATE_BRI: state[STATE_BRIGHTNESS]}) + else: + # On/off light (Zigbee Device ID: 0x0000) + # Supports groups, scenes and on/off control + retval["type"] = "On/off light" + retval["modelid"] = "HASS321" + + return retval + + +def create_hue_success_response(entity_id, attr, value): + """Create a success response for an attribute set on a light.""" + success_key = f"/lights/{entity_id}/state/{attr}" + return {"success": {success_key: value}} + + +def create_list_of_entities(config, request): + """Create a list of all entites.""" + hass = request.app["hass"] + json_response = {} + + for entity in hass.states.async_all(): + if config.is_entity_exposed(entity): + number = config.entity_id_to_number(entity.entity_id) + json_response[number] = entity_to_json(config, entity) + + return json_response diff --git a/homeassistant/components/emulated_hue/manifest.json b/homeassistant/components/emulated_hue/manifest.json new file mode 100644 index 0000000000000..fff855724779b --- /dev/null +++ b/homeassistant/components/emulated_hue/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "emulated_hue", + "name": "Emulated Hue", + "documentation": "https://www.home-assistant.io/integrations/emulated_hue", + "requirements": ["aiohttp_cors==0.7.0"], + "dependencies": [], + "codeowners": ["@NobleKangaroo"], + "quality_scale": "internal" +} diff --git a/homeassistant/components/emulated_hue/services.yaml b/homeassistant/components/emulated_hue/services.yaml new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/homeassistant/components/emulated_hue/upnp.py b/homeassistant/components/emulated_hue/upnp.py new file mode 100644 index 0000000000000..da9b4e23fe20e --- /dev/null +++ b/homeassistant/components/emulated_hue/upnp.py @@ -0,0 +1,164 @@ +"""Support UPNP discovery method that mimics Hue hubs.""" +import logging +import select +import socket +import threading + +from aiohttp import web + +from homeassistant import core +from homeassistant.components.http import HomeAssistantView + +_LOGGER = logging.getLogger(__name__) + + +class DescriptionXmlView(HomeAssistantView): + """Handles requests for the description.xml file.""" + + url = "/description.xml" + name = "description:xml" + requires_auth = False + + def __init__(self, config): + """Initialize the instance of the view.""" + self.config = config + + @core.callback + def get(self, request): + """Handle a GET request.""" + xml_template = """ + + +1 +0 + +http://{0}:{1}/ + +urn:schemas-upnp-org:device:Basic:1 +Home Assistant Bridge ({0}) +Royal Philips Electronics +http://www.philips.com +Philips hue Personal Wireless Lighting +Philips hue bridge 2015 +BSB002 +http://www.meethue.com +1234 +uuid:2f402f80-da50-11e1-9b23-001788255acc + + +""" + + resp_text = xml_template.format( + self.config.advertise_ip, self.config.advertise_port + ) + + return web.Response(text=resp_text, content_type="text/xml") + + +class UPNPResponderThread(threading.Thread): + """Handle responding to UPNP/SSDP discovery requests.""" + + _interrupted = False + + def __init__( + self, + host_ip_addr, + listen_port, + upnp_bind_multicast, + advertise_ip, + advertise_port, + ): + """Initialize the class.""" + threading.Thread.__init__(self) + + self.host_ip_addr = host_ip_addr + self.listen_port = listen_port + self.upnp_bind_multicast = upnp_bind_multicast + + # Note that the double newline at the end of + # this string is required per the SSDP spec + resp_template = """HTTP/1.1 200 OK +CACHE-CONTROL: max-age=60 +EXT: +LOCATION: http://{0}:{1}/description.xml +SERVER: FreeRTOS/6.0.5, UPnP/1.0, IpBridge/0.1 +hue-bridgeid: 1234 +ST: urn:schemas-upnp-org:device:basic:1 +USN: uuid:Socket-1_0-221438K0100073::urn:schemas-upnp-org:device:basic:1 + +""" + + self.upnp_response = ( + resp_template.format(advertise_ip, advertise_port) + .replace("\n", "\r\n") + .encode("utf-8") + ) + + def run(self): + """Run the server.""" + # Listen for UDP port 1900 packets sent to SSDP multicast address + ssdp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + ssdp_socket.setblocking(False) + + # Required for receiving multicast + ssdp_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + + ssdp_socket.setsockopt( + socket.SOL_IP, socket.IP_MULTICAST_IF, socket.inet_aton(self.host_ip_addr) + ) + + ssdp_socket.setsockopt( + socket.SOL_IP, + socket.IP_ADD_MEMBERSHIP, + socket.inet_aton("239.255.255.250") + socket.inet_aton(self.host_ip_addr), + ) + + if self.upnp_bind_multicast: + ssdp_socket.bind(("", 1900)) + else: + ssdp_socket.bind((self.host_ip_addr, 1900)) + + while True: + if self._interrupted: + clean_socket_close(ssdp_socket) + return + + try: + read, _, _ = select.select([ssdp_socket], [], [ssdp_socket], 2) + + if ssdp_socket in read: + data, addr = ssdp_socket.recvfrom(1024) + else: + # most likely the timeout, so check for interrupt + continue + except socket.error as ex: + if self._interrupted: + clean_socket_close(ssdp_socket) + return + + _LOGGER.error( + "UPNP Responder socket exception occurred: %s", ex.__str__ + ) + # without the following continue, a second exception occurs + # because the data object has not been initialized + continue + + if "M-SEARCH" in data.decode("utf-8", errors="ignore"): + # SSDP M-SEARCH method received, respond to it with our info + resp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + + resp_socket.sendto(self.upnp_response, addr) + resp_socket.close() + + def stop(self): + """Stop the server.""" + # Request for server + self._interrupted = True + self.join() + + +def clean_socket_close(sock): + """Close a socket connection and logs its closure.""" + _LOGGER.info("UPNP responder shutting down.") + + sock.close() diff --git a/homeassistant/components/emulated_roku/.translations/bg.json b/homeassistant/components/emulated_roku/.translations/bg.json new file mode 100644 index 0000000000000..ddb3c3a36a9f5 --- /dev/null +++ b/homeassistant/components/emulated_roku/.translations/bg.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "name_exists": "\u0418\u043c\u0435\u0442\u043e \u0432\u0435\u0447\u0435 \u0441\u044a\u0449\u0435\u0441\u0442\u0432\u0443\u0432\u0430" + }, + "step": { + "user": { + "data": { + "advertise_ip": "\u0420\u0430\u0437\u043f\u0440\u043e\u0441\u0442\u0440\u0430\u043d\u044f\u0432\u0430\u0439 IP \u0430\u0434\u0440\u0435\u0441", + "advertise_port": "\u0420\u0430\u0437\u043f\u0440\u043e\u0441\u0442\u0440\u0430\u043d\u044f\u0432\u0430\u0439 \u043f\u043e\u0440\u0442", + "host_ip": "\u0410\u0434\u0440\u0435\u0441", + "listen_port": "\u0421\u043b\u0443\u0448\u0430\u043d\u0435 \u043d\u0430 \u043f\u043e\u0440\u0442", + "name": "\u0418\u043c\u0435", + "upnp_bind_multicast": "\u0421\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u043d\u0430 \u043c\u0443\u043b\u0442\u0438\u043a\u0430\u0441\u0442 (True/False)" + }, + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0432\u0430\u043d\u0435 \u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f\u0442\u0430 \u043d\u0430 \u0441\u044a\u0440\u0432\u044a\u0440\u0430" + } + }, + "title": "EmulatedRoku" + } +} \ No newline at end of file diff --git a/homeassistant/components/emulated_roku/.translations/ca.json b/homeassistant/components/emulated_roku/.translations/ca.json new file mode 100644 index 0000000000000..bdd38b8538c5e --- /dev/null +++ b/homeassistant/components/emulated_roku/.translations/ca.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "name_exists": "El nom ja existeix" + }, + "step": { + "user": { + "data": { + "advertise_ip": "IP d'advert\u00e8ncies", + "advertise_port": "Port d'advert\u00e8ncies", + "host_ip": "IP de l'amfitri\u00f3", + "listen_port": "Port d'escolta", + "name": "Nom", + "upnp_bind_multicast": "Enlla\u00e7ar multicast (true/false)" + }, + "title": "Configuraci\u00f3 del servidor" + } + }, + "title": "EmulatedRoku" + } +} \ No newline at end of file diff --git a/homeassistant/components/emulated_roku/.translations/da.json b/homeassistant/components/emulated_roku/.translations/da.json new file mode 100644 index 0000000000000..0da64fac623a4 --- /dev/null +++ b/homeassistant/components/emulated_roku/.translations/da.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "name_exists": "Navnet findes allerede" + }, + "step": { + "user": { + "data": { + "advertise_ip": "Adviserings-IP", + "advertise_port": "Adviseringsport", + "host_ip": "V\u00e6rts-IP", + "listen_port": "Lytte-port", + "name": "Navn", + "upnp_bind_multicast": "Bind multicast (sand/falsk)" + }, + "title": "Angiv server-konfiguration" + } + }, + "title": "EmulatedRoku" + } +} \ No newline at end of file diff --git a/homeassistant/components/emulated_roku/.translations/de.json b/homeassistant/components/emulated_roku/.translations/de.json new file mode 100644 index 0000000000000..f9c8a21240a50 --- /dev/null +++ b/homeassistant/components/emulated_roku/.translations/de.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "name_exists": "Name existiert bereits" + }, + "step": { + "user": { + "data": { + "advertise_ip": "IP Adresse annoncieren", + "advertise_port": "Port annoncieren", + "host_ip": "Host-IP", + "listen_port": "Listen-Port", + "name": "Name", + "upnp_bind_multicast": "Multicast binden (True/False)" + }, + "title": "Serverkonfiguration definieren" + } + }, + "title": "EmulatedRoku" + } +} \ No newline at end of file diff --git a/homeassistant/components/emulated_roku/.translations/en.json b/homeassistant/components/emulated_roku/.translations/en.json new file mode 100644 index 0000000000000..376252966a37b --- /dev/null +++ b/homeassistant/components/emulated_roku/.translations/en.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "name_exists": "Name already exists" + }, + "step": { + "user": { + "data": { + "advertise_ip": "Advertise IP", + "advertise_port": "Advertise port", + "host_ip": "Host IP", + "listen_port": "Listen port", + "name": "Name", + "upnp_bind_multicast": "Bind multicast (True/False)" + }, + "title": "Define server configuration" + } + }, + "title": "EmulatedRoku" + } +} \ No newline at end of file diff --git a/homeassistant/components/emulated_roku/.translations/es-419.json b/homeassistant/components/emulated_roku/.translations/es-419.json new file mode 100644 index 0000000000000..51c18c764db4c --- /dev/null +++ b/homeassistant/components/emulated_roku/.translations/es-419.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "name_exists": "El nombre ya existe" + }, + "step": { + "user": { + "data": { + "host_ip": "IP del host", + "name": "Nombre" + }, + "title": "Definir la configuraci\u00f3n del servidor." + } + }, + "title": "EmulatedRoku" + } +} \ No newline at end of file diff --git a/homeassistant/components/emulated_roku/.translations/es.json b/homeassistant/components/emulated_roku/.translations/es.json new file mode 100644 index 0000000000000..f727c8bf522fa --- /dev/null +++ b/homeassistant/components/emulated_roku/.translations/es.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "name_exists": "El nombre ya existe" + }, + "step": { + "user": { + "data": { + "advertise_ip": "IP para anunciar", + "advertise_port": "Puerto para anunciar", + "host_ip": "IP del host", + "listen_port": "Puerto de escucha", + "name": "Nombre", + "upnp_bind_multicast": "Enlazar multicast (verdadero/falso)" + }, + "title": "Definir la configuraci\u00f3n del servidor" + } + }, + "title": "EmulatedRoku" + } +} \ No newline at end of file diff --git a/homeassistant/components/emulated_roku/.translations/et.json b/homeassistant/components/emulated_roku/.translations/et.json new file mode 100644 index 0000000000000..e284f6c37321d --- /dev/null +++ b/homeassistant/components/emulated_roku/.translations/et.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host_ip": "", + "name": "Nimi" + } + } + }, + "title": "" + } +} \ No newline at end of file diff --git a/homeassistant/components/emulated_roku/.translations/fr.json b/homeassistant/components/emulated_roku/.translations/fr.json new file mode 100644 index 0000000000000..629e006564be4 --- /dev/null +++ b/homeassistant/components/emulated_roku/.translations/fr.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "name_exists": "Ce nom est d\u00e9j\u00e0 utilis\u00e9" + }, + "step": { + "user": { + "data": { + "advertise_ip": "IP d'annonce", + "advertise_port": "Port d'annonce", + "host_ip": "IP h\u00f4te", + "listen_port": "Port d'\u00e9coute", + "name": "Nom", + "upnp_bind_multicast": "Lier la multidiffusion (True / False)" + }, + "title": "D\u00e9finir la configuration du serveur" + } + }, + "title": "EmulatedRoku" + } +} \ No newline at end of file diff --git a/homeassistant/components/emulated_roku/.translations/hu.json b/homeassistant/components/emulated_roku/.translations/hu.json new file mode 100644 index 0000000000000..9b6f77062537e --- /dev/null +++ b/homeassistant/components/emulated_roku/.translations/hu.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "name_exists": "A n\u00e9v m\u00e1r l\u00e9tezik" + }, + "step": { + "user": { + "data": { + "host_ip": "Hoszt IP", + "listen_port": "Port figyel\u00e9se", + "name": "N\u00e9v" + }, + "title": "A kiszolg\u00e1l\u00f3 szerver konfigur\u00e1l\u00e1sa" + } + }, + "title": "EmulatedRoku" + } +} \ No newline at end of file diff --git a/homeassistant/components/emulated_roku/.translations/it.json b/homeassistant/components/emulated_roku/.translations/it.json new file mode 100644 index 0000000000000..8f39309264a6c --- /dev/null +++ b/homeassistant/components/emulated_roku/.translations/it.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "name_exists": "Il nome \u00e8 gi\u00e0 esistente" + }, + "step": { + "user": { + "data": { + "advertise_ip": "Pubblicizza IP", + "advertise_port": "Pubblicizza porta", + "host_ip": "Indirizzo IP dell'host", + "listen_port": "Porta di ascolto", + "name": "Nome", + "upnp_bind_multicast": "Associa multicast (Vero / Falso)" + }, + "title": "Definisci la configurazione del server" + } + }, + "title": "EmulatedRoku" + } +} \ No newline at end of file diff --git a/homeassistant/components/emulated_roku/.translations/ko.json b/homeassistant/components/emulated_roku/.translations/ko.json new file mode 100644 index 0000000000000..ddee892039f4f --- /dev/null +++ b/homeassistant/components/emulated_roku/.translations/ko.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "name_exists": "\uc774\ub984\uc774 \uc774\ubbf8 \uc874\uc7ac\ud569\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "advertise_ip": "\uad11\uace0 IP", + "advertise_port": "\uad11\uace0 \ud3ec\ud2b8", + "host_ip": "\ud638\uc2a4\ud2b8 IP", + "listen_port": "\uc218\uc2e0 \ud3ec\ud2b8", + "name": "\uc774\ub984", + "upnp_bind_multicast": "\uba40\ud2f0 \uce90\uc2a4\ud2b8 \ud560\ub2f9 (\ucc38/\uac70\uc9d3)" + }, + "title": "\uc11c\ubc84 \uad6c\uc131 \uc815\uc758" + } + }, + "title": "EmulatedRoku" + } +} \ No newline at end of file diff --git a/homeassistant/components/emulated_roku/.translations/lb.json b/homeassistant/components/emulated_roku/.translations/lb.json new file mode 100644 index 0000000000000..11d1aa3ff7a7b --- /dev/null +++ b/homeassistant/components/emulated_roku/.translations/lb.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "name_exists": "Numm g\u00ebtt et schonn" + }, + "step": { + "user": { + "data": { + "advertise_ip": "IP annonc\u00e9ieren", + "advertise_port": "Port annonc\u00e9ieren", + "host_ip": "IP vum Apparat", + "listen_port": "Port lauschteren", + "name": "Numm", + "upnp_bind_multicast": "Multicast abannen (Richteg/Falsch)" + }, + "title": "Server Konfiguratioun d\u00e9fin\u00e9ieren" + } + }, + "title": "EmulatedRoku" + } +} \ No newline at end of file diff --git a/homeassistant/components/emulated_roku/.translations/nl.json b/homeassistant/components/emulated_roku/.translations/nl.json new file mode 100644 index 0000000000000..fe26cda31e264 --- /dev/null +++ b/homeassistant/components/emulated_roku/.translations/nl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "name_exists": "Naam bestaat al" + }, + "step": { + "user": { + "data": { + "advertise_ip": "Adverteer IP", + "advertise_port": "Adverterenpoort", + "host_ip": "Host IP", + "listen_port": "Luisterpoort", + "name": "Naam", + "upnp_bind_multicast": "Bind multicast (waar/niet waar)" + }, + "title": "Serverconfiguratie defini\u00ebren" + } + }, + "title": "EmulatedRoku" + } +} \ No newline at end of file diff --git a/homeassistant/components/emulated_roku/.translations/nn.json b/homeassistant/components/emulated_roku/.translations/nn.json new file mode 100644 index 0000000000000..fc349a0d9de9d --- /dev/null +++ b/homeassistant/components/emulated_roku/.translations/nn.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "EmulatedRoku" + } +} \ No newline at end of file diff --git a/homeassistant/components/emulated_roku/.translations/no.json b/homeassistant/components/emulated_roku/.translations/no.json new file mode 100644 index 0000000000000..b41da3ccde394 --- /dev/null +++ b/homeassistant/components/emulated_roku/.translations/no.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "name_exists": "Navnet eksisterer allerede" + }, + "step": { + "user": { + "data": { + "advertise_ip": "Annonser IP", + "advertise_port": "Annonser port", + "host_ip": "Vert IP", + "listen_port": "Lytte port", + "name": "Navn", + "upnp_bind_multicast": "Bind multicast (Sant/Usant)" + }, + "title": "Definer serverkonfigurasjon" + } + }, + "title": "EmulatedRoku" + } +} \ No newline at end of file diff --git a/homeassistant/components/emulated_roku/.translations/pl.json b/homeassistant/components/emulated_roku/.translations/pl.json new file mode 100644 index 0000000000000..0ed3cc3d14af6 --- /dev/null +++ b/homeassistant/components/emulated_roku/.translations/pl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "name_exists": "Nazwa ju\u017c istnieje" + }, + "step": { + "user": { + "data": { + "advertise_ip": "IP rozg\u0142aszania", + "advertise_port": "Port rozg\u0142aszania", + "host_ip": "IP hosta", + "listen_port": "Port nas\u0142uchu", + "name": "Nazwa", + "upnp_bind_multicast": "Powi\u0105\u017c multicast (prawda/fa\u0142sz)" + }, + "title": "Zdefiniuj konfiguracj\u0119 serwera" + } + }, + "title": "EmulatedRoku" + } +} \ No newline at end of file diff --git a/homeassistant/components/emulated_roku/.translations/pt-BR.json b/homeassistant/components/emulated_roku/.translations/pt-BR.json new file mode 100644 index 0000000000000..0f82f93b3836c --- /dev/null +++ b/homeassistant/components/emulated_roku/.translations/pt-BR.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "name_exists": "O nome j\u00e1 existe" + }, + "step": { + "user": { + "data": { + "advertise_ip": "Anunciar IP", + "advertise_port": "Anunciar porta", + "host_ip": "IP do host", + "listen_port": "Porta de escuta", + "name": "Nome", + "upnp_bind_multicast": "Vincular multicast (Verdadeiro/Falso)" + }, + "title": "Definir configura\u00e7\u00e3o do servidor" + } + }, + "title": "EmulatedRoku" + } +} \ No newline at end of file diff --git a/homeassistant/components/emulated_roku/.translations/pt.json b/homeassistant/components/emulated_roku/.translations/pt.json new file mode 100644 index 0000000000000..138e077d4a46d --- /dev/null +++ b/homeassistant/components/emulated_roku/.translations/pt.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "name_exists": "Nome j\u00e1 existe" + }, + "step": { + "user": { + "data": { + "advertise_ip": "Anuncie o IP", + "advertise_port": "Anuncie porto", + "host_ip": "IP do host", + "listen_port": "Porta \u00e0 escuta", + "name": "Nome", + "upnp_bind_multicast": "Liga\u00e7\u00e3o multicast (Verdadeiro/Falso)" + }, + "title": "Definir configura\u00e7\u00e3o do servidor" + } + }, + "title": "EmulatedRoku" + } +} \ No newline at end of file diff --git a/homeassistant/components/emulated_roku/.translations/ru.json b/homeassistant/components/emulated_roku/.translations/ru.json new file mode 100644 index 0000000000000..32bf473ac3854 --- /dev/null +++ b/homeassistant/components/emulated_roku/.translations/ru.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "name_exists": "\u042d\u0442\u043e \u043d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 \u0443\u0436\u0435 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442\u0441\u044f." + }, + "step": { + "user": { + "data": { + "advertise_ip": "\u041e\u0431\u044a\u044f\u0432\u043b\u044f\u0442\u044c IP", + "advertise_port": "\u041e\u0431\u044a\u044f\u0432\u043b\u044f\u0442\u044c \u043f\u043e\u0440\u0442", + "host_ip": "\u0425\u043e\u0441\u0442", + "listen_port": "\u041f\u043e\u0440\u0442", + "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435", + "upnp_bind_multicast": "\u041f\u0440\u0438\u0432\u044f\u0437\u0430\u0442\u044c multicast (True/False)" + }, + "title": "EmulatedRoku" + } + }, + "title": "EmulatedRoku" + } +} \ No newline at end of file diff --git a/homeassistant/components/emulated_roku/.translations/sl.json b/homeassistant/components/emulated_roku/.translations/sl.json new file mode 100644 index 0000000000000..768feb83747e8 --- /dev/null +++ b/homeassistant/components/emulated_roku/.translations/sl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "name_exists": "Ime \u017ee obstaja" + }, + "step": { + "user": { + "data": { + "advertise_ip": "Advertise IP", + "advertise_port": "Advertise port", + "host_ip": "IP gostitelja", + "listen_port": "Vrata naprave", + "name": "Ime", + "upnp_bind_multicast": "Vezava multicasta (True / False)" + }, + "title": "Dolo\u010dite konfiguracijo stre\u017enika" + } + }, + "title": "EmulatedRoku" + } +} \ No newline at end of file diff --git a/homeassistant/components/emulated_roku/.translations/sv.json b/homeassistant/components/emulated_roku/.translations/sv.json new file mode 100644 index 0000000000000..4ae7a356c4c0b --- /dev/null +++ b/homeassistant/components/emulated_roku/.translations/sv.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "name_exists": "Namnet finns redan" + }, + "step": { + "user": { + "data": { + "advertise_ip": "Annonsera med IP", + "advertise_port": "Annonsera p\u00e5 port", + "host_ip": "IP p\u00e5 v\u00e4rddatorn", + "listen_port": "Lyssna p\u00e5 port", + "name": "Namn", + "upnp_bind_multicast": "Bind multicast (True/False)" + }, + "title": "Definiera serverkonfiguration" + } + }, + "title": "EmulatedRoku" + } +} \ No newline at end of file diff --git a/homeassistant/components/emulated_roku/.translations/th.json b/homeassistant/components/emulated_roku/.translations/th.json new file mode 100644 index 0000000000000..c2570a457bce2 --- /dev/null +++ b/homeassistant/components/emulated_roku/.translations/th.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host_ip": "\u0e42\u0e2e\u0e2a\u0e15\u0e4c IP", + "name": "\u0e0a\u0e37\u0e48\u0e2d" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/emulated_roku/.translations/zh-Hans.json b/homeassistant/components/emulated_roku/.translations/zh-Hans.json new file mode 100644 index 0000000000000..88d8a822696f5 --- /dev/null +++ b/homeassistant/components/emulated_roku/.translations/zh-Hans.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "name_exists": "\u540d\u79f0\u5df2\u5b58\u5728" + }, + "step": { + "user": { + "data": { + "advertise_ip": "\u5e7f\u64ad IP", + "advertise_port": "\u5e7f\u64ad\u7aef\u53e3", + "host_ip": "\u4e3b\u673a IP", + "listen_port": "\u76d1\u542c\u7aef\u53e3", + "name": "\u59d3\u540d", + "upnp_bind_multicast": "\u7ed1\u5b9a\u591a\u64ad (True/False)" + }, + "title": "\u5b9a\u4e49\u670d\u52a1\u5668\u914d\u7f6e" + } + }, + "title": "EmulatedRoku" + } +} \ No newline at end of file diff --git a/homeassistant/components/emulated_roku/.translations/zh-Hant.json b/homeassistant/components/emulated_roku/.translations/zh-Hant.json new file mode 100644 index 0000000000000..40b4307ae02de --- /dev/null +++ b/homeassistant/components/emulated_roku/.translations/zh-Hant.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "name_exists": "\u8a72\u540d\u7a31\u5df2\u5b58\u5728" + }, + "step": { + "user": { + "data": { + "advertise_ip": "\u5ee3\u64ad\u901a\u8a0a\u57e0", + "advertise_port": "\u5ee3\u64ad\u901a\u8a0a\u57e0", + "host_ip": "\u4e3b\u6a5f IP", + "listen_port": "\u76e3\u807d\u901a\u8a0a\u57e0", + "name": "\u540d\u7a31", + "upnp_bind_multicast": "\u7d81\u5b9a\u7fa4\u64ad\uff08Multicast\uff09True/False" + }, + "title": "\u5b9a\u7fa9\u4f3a\u670d\u5668\u8a2d\u5b9a" + } + }, + "title": "EmulatedRoku" + } +} \ No newline at end of file diff --git a/homeassistant/components/emulated_roku/__init__.py b/homeassistant/components/emulated_roku/__init__.py new file mode 100644 index 0000000000000..4e5779296440a --- /dev/null +++ b/homeassistant/components/emulated_roku/__init__.py @@ -0,0 +1,98 @@ +"""Support for Roku API emulation.""" +import voluptuous as vol + +from homeassistant import config_entries, util +from homeassistant.const import CONF_NAME +import homeassistant.helpers.config_validation as cv + +from .binding import EmulatedRoku +from .config_flow import configured_servers +from .const import ( + CONF_ADVERTISE_IP, + CONF_ADVERTISE_PORT, + CONF_HOST_IP, + CONF_LISTEN_PORT, + CONF_SERVERS, + CONF_UPNP_BIND_MULTICAST, + DOMAIN, +) + +SERVER_CONFIG_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_LISTEN_PORT): cv.port, + vol.Optional(CONF_HOST_IP): cv.string, + vol.Optional(CONF_ADVERTISE_IP): cv.string, + vol.Optional(CONF_ADVERTISE_PORT): cv.port, + vol.Optional(CONF_UPNP_BIND_MULTICAST): cv.boolean, + } +) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_SERVERS): vol.All( + cv.ensure_list, [SERVER_CONFIG_SCHEMA] + ) + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, config): + """Set up the emulated roku component.""" + conf = config.get(DOMAIN) + + if conf is None: + return True + + existing_servers = configured_servers(hass) + + for entry in conf[CONF_SERVERS]: + if entry[CONF_NAME] not in existing_servers: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=entry + ) + ) + + return True + + +async def async_setup_entry(hass, config_entry): + """Set up an emulated roku server from a config entry.""" + config = config_entry.data + + if DOMAIN not in hass.data: + hass.data[DOMAIN] = {} + + name = config[CONF_NAME] + listen_port = config[CONF_LISTEN_PORT] + host_ip = config.get(CONF_HOST_IP) or util.get_local_ip() + advertise_ip = config.get(CONF_ADVERTISE_IP) + advertise_port = config.get(CONF_ADVERTISE_PORT) + upnp_bind_multicast = config.get(CONF_UPNP_BIND_MULTICAST) + + server = EmulatedRoku( + hass, + name, + host_ip, + listen_port, + advertise_ip, + advertise_port, + upnp_bind_multicast, + ) + + hass.data[DOMAIN][name] = server + + return await server.setup() + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + name = entry.data[CONF_NAME] + server = hass.data[DOMAIN].pop(name) + return await server.unload() diff --git a/homeassistant/components/emulated_roku/binding.py b/homeassistant/components/emulated_roku/binding.py new file mode 100644 index 0000000000000..a44effff55a0e --- /dev/null +++ b/homeassistant/components/emulated_roku/binding.py @@ -0,0 +1,181 @@ +"""Bridge between emulated_roku and Home Assistant.""" +import logging + +from emulated_roku import EmulatedRokuCommandHandler, EmulatedRokuServer + +from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import CoreState, EventOrigin + +LOGGER = logging.getLogger(__package__) + +EVENT_ROKU_COMMAND = "roku_command" + +ATTR_COMMAND_TYPE = "type" +ATTR_SOURCE_NAME = "source_name" +ATTR_KEY = "key" +ATTR_APP_ID = "app_id" + +ROKU_COMMAND_KEYDOWN = "keydown" +ROKU_COMMAND_KEYUP = "keyup" +ROKU_COMMAND_KEYPRESS = "keypress" +ROKU_COMMAND_LAUNCH = "launch" + + +class EmulatedRoku: + """Manages an emulated_roku server.""" + + def __init__( + self, + hass, + name, + host_ip, + listen_port, + advertise_ip, + advertise_port, + upnp_bind_multicast, + ): + """Initialize the properties.""" + self.hass = hass + + self.roku_usn = name + self.host_ip = host_ip + self.listen_port = listen_port + + self.advertise_port = advertise_port + self.advertise_ip = advertise_ip + + self.bind_multicast = upnp_bind_multicast + + self._api_server = None + + self._unsub_start_listener = None + self._unsub_stop_listener = None + + async def setup(self): + """Start the emulated_roku server.""" + + class EventCommandHandler(EmulatedRokuCommandHandler): + """emulated_roku command handler to turn commands into events.""" + + def __init__(self, hass): + self.hass = hass + + def on_keydown(self, roku_usn, key): + """Handle keydown event.""" + self.hass.bus.async_fire( + EVENT_ROKU_COMMAND, + { + ATTR_SOURCE_NAME: roku_usn, + ATTR_COMMAND_TYPE: ROKU_COMMAND_KEYDOWN, + ATTR_KEY: key, + }, + EventOrigin.local, + ) + + def on_keyup(self, roku_usn, key): + """Handle keyup event.""" + self.hass.bus.async_fire( + EVENT_ROKU_COMMAND, + { + ATTR_SOURCE_NAME: roku_usn, + ATTR_COMMAND_TYPE: ROKU_COMMAND_KEYUP, + ATTR_KEY: key, + }, + EventOrigin.local, + ) + + def on_keypress(self, roku_usn, key): + """Handle keypress event.""" + self.hass.bus.async_fire( + EVENT_ROKU_COMMAND, + { + ATTR_SOURCE_NAME: roku_usn, + ATTR_COMMAND_TYPE: ROKU_COMMAND_KEYPRESS, + ATTR_KEY: key, + }, + EventOrigin.local, + ) + + def launch(self, roku_usn, app_id): + """Handle launch event.""" + self.hass.bus.async_fire( + EVENT_ROKU_COMMAND, + { + ATTR_SOURCE_NAME: roku_usn, + ATTR_COMMAND_TYPE: ROKU_COMMAND_LAUNCH, + ATTR_APP_ID: app_id, + }, + EventOrigin.local, + ) + + LOGGER.debug( + "Intializing emulated_roku %s on %s:%s", + self.roku_usn, + self.host_ip, + self.listen_port, + ) + + handler = EventCommandHandler(self.hass) + + self._api_server = EmulatedRokuServer( + self.hass.loop, + handler, + self.roku_usn, + self.host_ip, + self.listen_port, + advertise_ip=self.advertise_ip, + advertise_port=self.advertise_port, + bind_multicast=self.bind_multicast, + ) + + async def emulated_roku_stop(event): + """Wrap the call to emulated_roku.close.""" + LOGGER.debug("Stopping emulated_roku %s", self.roku_usn) + self._unsub_stop_listener = None + await self._api_server.close() + + async def emulated_roku_start(event): + """Wrap the call to emulated_roku.start.""" + try: + LOGGER.debug("Starting emulated_roku %s", self.roku_usn) + self._unsub_start_listener = None + await self._api_server.start() + except OSError: + LOGGER.exception( + "Failed to start Emulated Roku %s on %s:%s", + self.roku_usn, + self.host_ip, + self.listen_port, + ) + # clean up inconsistent state on errors + await emulated_roku_stop(None) + else: + self._unsub_stop_listener = self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, emulated_roku_stop + ) + + # start immediately if already running + if self.hass.state == CoreState.running: + await emulated_roku_start(None) + else: + self._unsub_start_listener = self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, emulated_roku_start + ) + + return True + + async def unload(self): + """Unload the emulated_roku server.""" + LOGGER.debug("Unloading emulated_roku %s", self.roku_usn) + + if self._unsub_start_listener: + self._unsub_start_listener() + self._unsub_start_listener = None + + if self._unsub_stop_listener: + self._unsub_stop_listener() + self._unsub_stop_listener = None + + await self._api_server.close() + + return True diff --git a/homeassistant/components/emulated_roku/config_flow.py b/homeassistant/components/emulated_roku/config_flow.py new file mode 100644 index 0000000000000..0a6d54693ef9e --- /dev/null +++ b/homeassistant/components/emulated_roku/config_flow.py @@ -0,0 +1,62 @@ +"""Config flow to configure emulated_roku component.""" +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_NAME +from homeassistant.core import callback + +from .const import CONF_LISTEN_PORT, DEFAULT_NAME, DEFAULT_PORT, DOMAIN + + +@callback +def configured_servers(hass): + """Return a set of the configured servers.""" + return set( + entry.data[CONF_NAME] for entry in hass.config_entries.async_entries(DOMAIN) + ) + + +@config_entries.HANDLERS.register(DOMAIN) +class EmulatedRokuFlowHandler(config_entries.ConfigFlow): + """Handle an emulated_roku config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + errors = {} + + if user_input is not None: + name = user_input[CONF_NAME] + + if name in configured_servers(self.hass): + return self.async_abort(reason="name_exists") + + return self.async_create_entry(title=name, data=user_input) + + servers_num = len(configured_servers(self.hass)) + + if servers_num: + default_name = "{} {}".format(DEFAULT_NAME, servers_num + 1) + default_port = DEFAULT_PORT + servers_num + else: + default_name = DEFAULT_NAME + default_port = DEFAULT_PORT + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_NAME, default=default_name): str, + vol.Required(CONF_LISTEN_PORT, default=default_port): vol.Coerce( + int + ), + } + ), + errors=errors, + ) + + async def async_step_import(self, import_config): + """Handle a flow import.""" + return await self.async_step_user(import_config) diff --git a/homeassistant/components/emulated_roku/const.py b/homeassistant/components/emulated_roku/const.py new file mode 100644 index 0000000000000..6c7803671882f --- /dev/null +++ b/homeassistant/components/emulated_roku/const.py @@ -0,0 +1,12 @@ +"""Constants for the emulated_roku component.""" +DOMAIN = "emulated_roku" + +CONF_SERVERS = "servers" +CONF_LISTEN_PORT = "listen_port" +CONF_HOST_IP = "host_ip" +CONF_ADVERTISE_IP = "advertise_ip" +CONF_ADVERTISE_PORT = "advertise_port" +CONF_UPNP_BIND_MULTICAST = "upnp_bind_multicast" + +DEFAULT_NAME = "Home Assistant" +DEFAULT_PORT = 8060 diff --git a/homeassistant/components/emulated_roku/manifest.json b/homeassistant/components/emulated_roku/manifest.json new file mode 100644 index 0000000000000..05cf72019d842 --- /dev/null +++ b/homeassistant/components/emulated_roku/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "emulated_roku", + "name": "Emulated Roku", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/emulated_roku", + "requirements": ["emulated_roku==0.1.8"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/emulated_roku/strings.json b/homeassistant/components/emulated_roku/strings.json new file mode 100644 index 0000000000000..376252966a37b --- /dev/null +++ b/homeassistant/components/emulated_roku/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "name_exists": "Name already exists" + }, + "step": { + "user": { + "data": { + "advertise_ip": "Advertise IP", + "advertise_port": "Advertise port", + "host_ip": "Host IP", + "listen_port": "Listen port", + "name": "Name", + "upnp_bind_multicast": "Bind multicast (True/False)" + }, + "title": "Define server configuration" + } + }, + "title": "EmulatedRoku" + } +} \ No newline at end of file diff --git a/homeassistant/components/enigma2/__init__.py b/homeassistant/components/enigma2/__init__.py new file mode 100644 index 0000000000000..11cd4d9a80421 --- /dev/null +++ b/homeassistant/components/enigma2/__init__.py @@ -0,0 +1 @@ +"""Support for Enigma2 devices.""" diff --git a/homeassistant/components/enigma2/manifest.json b/homeassistant/components/enigma2/manifest.json new file mode 100644 index 0000000000000..a49f2aa0190cc --- /dev/null +++ b/homeassistant/components/enigma2/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "enigma2", + "name": "Enigma2 (OpenWebif)", + "documentation": "https://www.home-assistant.io/integrations/enigma2", + "requirements": ["openwebifpy==3.1.1"], + "dependencies": [], + "codeowners": ["@fbradyirl"] +} diff --git a/homeassistant/components/enigma2/media_player.py b/homeassistant/components/enigma2/media_player.py new file mode 100644 index 0000000000000..85dec4abd94f9 --- /dev/null +++ b/homeassistant/components/enigma2/media_player.py @@ -0,0 +1,269 @@ +"""Support for Enigma2 media players.""" +import logging + +from openwebif.api import CreateDevice +import voluptuous as vol + +from homeassistant.components.media_player import MediaPlayerDevice +from homeassistant.components.media_player.const import ( + MEDIA_TYPE_TVSHOW, + SUPPORT_NEXT_TRACK, + SUPPORT_PAUSE, + SUPPORT_PREVIOUS_TRACK, + SUPPORT_SELECT_SOURCE, + SUPPORT_STOP, + 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_SSL, + CONF_USERNAME, + STATE_OFF, + STATE_ON, + STATE_PLAYING, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.config_validation import PLATFORM_SCHEMA + +_LOGGER = logging.getLogger(__name__) + +ATTR_MEDIA_CURRENTLY_RECORDING = "media_currently_recording" +ATTR_MEDIA_DESCRIPTION = "media_description" +ATTR_MEDIA_END_TIME = "media_end_time" +ATTR_MEDIA_START_TIME = "media_start_time" + +CONF_USE_CHANNEL_ICON = "use_channel_icon" +CONF_DEEP_STANDBY = "deep_standby" +CONF_MAC_ADDRESS = "mac_address" +CONF_SOURCE_BOUQUET = "source_bouquet" + +DEFAULT_NAME = "Enigma2 Media Player" +DEFAULT_PORT = 80 +DEFAULT_SSL = False +DEFAULT_USE_CHANNEL_ICON = False +DEFAULT_USERNAME = "root" +DEFAULT_PASSWORD = "dreambox" +DEFAULT_DEEP_STANDBY = False +DEFAULT_MAC_ADDRESS = "" +DEFAULT_SOURCE_BOUQUET = "" + +SUPPORTED_ENIGMA2 = ( + SUPPORT_VOLUME_SET + | SUPPORT_VOLUME_MUTE + | SUPPORT_TURN_OFF + | SUPPORT_NEXT_TRACK + | SUPPORT_STOP + | SUPPORT_PREVIOUS_TRACK + | SUPPORT_VOLUME_STEP + | SUPPORT_TURN_ON + | SUPPORT_PAUSE + | 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, + vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, + vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, + vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, + vol.Optional( + CONF_USE_CHANNEL_ICON, default=DEFAULT_USE_CHANNEL_ICON + ): cv.boolean, + vol.Optional(CONF_DEEP_STANDBY, default=DEFAULT_DEEP_STANDBY): cv.boolean, + vol.Optional(CONF_MAC_ADDRESS, default=DEFAULT_MAC_ADDRESS): cv.string, + vol.Optional(CONF_SOURCE_BOUQUET, default=DEFAULT_SOURCE_BOUQUET): cv.string, + } +) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up of an enigma2 media player.""" + if discovery_info: + # Discovery gives us the streaming service port (8001) + # which is not useful as OpenWebif never runs on that port. + # So use the default port instead. + config[CONF_PORT] = DEFAULT_PORT + config[CONF_NAME] = discovery_info["hostname"] + config[CONF_HOST] = discovery_info["host"] + config[CONF_USERNAME] = DEFAULT_USERNAME + config[CONF_PASSWORD] = DEFAULT_PASSWORD + config[CONF_SSL] = DEFAULT_SSL + config[CONF_USE_CHANNEL_ICON] = DEFAULT_USE_CHANNEL_ICON + config[CONF_MAC_ADDRESS] = DEFAULT_MAC_ADDRESS + config[CONF_DEEP_STANDBY] = DEFAULT_DEEP_STANDBY + config[CONF_SOURCE_BOUQUET] = DEFAULT_SOURCE_BOUQUET + + device = CreateDevice( + host=config[CONF_HOST], + port=config.get(CONF_PORT), + username=config.get(CONF_USERNAME), + password=config.get(CONF_PASSWORD), + is_https=config.get(CONF_SSL), + prefer_picon=config.get(CONF_USE_CHANNEL_ICON), + mac_address=config.get(CONF_MAC_ADDRESS), + turn_off_to_deep=config.get(CONF_DEEP_STANDBY), + source_bouquet=config.get(CONF_SOURCE_BOUQUET), + ) + + add_devices([Enigma2Device(config[CONF_NAME], device)], True) + + +class Enigma2Device(MediaPlayerDevice): + """Representation of an Enigma2 box.""" + + def __init__(self, name, device): + """Initialize the Enigma2 device.""" + self._name = name + self.e2_box = device + + @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.e2_box.is_recording_playback: + return STATE_PLAYING + return STATE_OFF if self.e2_box.in_standby else STATE_ON + + @property + def supported_features(self): + """Flag of media commands that are supported.""" + return SUPPORTED_ENIGMA2 + + def turn_off(self): + """Turn off media player.""" + self.e2_box.turn_off() + + def turn_on(self): + """Turn the media player on.""" + self.e2_box.turn_on() + + @property + def media_title(self): + """Title of current playing media.""" + return self.e2_box.current_service_channel_name + + @property + def media_series_title(self): + """Return the title of current episode of TV show.""" + return self.e2_box.current_programme_name + + @property + def media_channel(self): + """Channel of current playing media.""" + return self.e2_box.current_service_channel_name + + @property + def media_content_id(self): + """Service Ref of current playing media.""" + return self.e2_box.current_service_ref + + @property + def media_content_type(self): + """Type of video currently playing.""" + return MEDIA_TYPE_TVSHOW + + @property + def is_volume_muted(self): + """Boolean if volume is currently muted.""" + return self.e2_box.muted + + @property + def media_image_url(self): + """Picon url for the channel.""" + return self.e2_box.picon_url + + def set_volume_level(self, volume): + """Set volume level, range 0..1.""" + self.e2_box.set_volume(int(volume * 100)) + + def volume_up(self): + """Volume up the media player.""" + self.e2_box.set_volume(int(self.e2_box.volume * 100) + 5) + + def volume_down(self): + """Volume down media player.""" + self.e2_box.set_volume(int(self.e2_box.volume * 100) - 5) + + @property + def volume_level(self): + """Volume level of the media player (0..1).""" + return self.e2_box.volume + + def media_stop(self): + """Send stop command.""" + self.e2_box.set_stop() + + def media_play(self): + """Play media.""" + self.e2_box.toggle_play_pause() + + def media_pause(self): + """Pause the media player.""" + self.e2_box.toggle_play_pause() + + def media_next_track(self): + """Send next track command.""" + self.e2_box.set_channel_up() + + def media_previous_track(self): + """Send next track command.""" + self.e2_box.set_channel_down() + + def mute_volume(self, mute): + """Mute or unmute.""" + self.e2_box.mute_volume() + + @property + def source(self): + """Return the current input source.""" + return self.e2_box.current_service_channel_name + + @property + def source_list(self): + """List of available input sources.""" + return self.e2_box.source_list + + def select_source(self, source): + """Select input source.""" + self.e2_box.select_source(self.e2_box.sources[source]) + + def update(self): + """Update state of the media_player.""" + self.e2_box.update() + + @property + def device_state_attributes(self): + """Return device specific state attributes. + + isRecording: Is the box currently recording. + currservice_fulldescription: Full program description. + currservice_begin: is in the format '21:00'. + currservice_end: is in the format '21:00'. + """ + attributes = {} + if not self.e2_box.in_standby: + attributes[ATTR_MEDIA_CURRENTLY_RECORDING] = self.e2_box.status_info[ + "isRecording" + ] + attributes[ATTR_MEDIA_DESCRIPTION] = self.e2_box.status_info[ + "currservice_fulldescription" + ] + attributes[ATTR_MEDIA_START_TIME] = self.e2_box.status_info[ + "currservice_begin" + ] + attributes[ATTR_MEDIA_END_TIME] = self.e2_box.status_info["currservice_end"] + + return attributes diff --git a/homeassistant/components/enocean/__init__.py b/homeassistant/components/enocean/__init__.py new file mode 100644 index 0000000000000..876c7a1f05baa --- /dev/null +++ b/homeassistant/components/enocean/__init__.py @@ -0,0 +1,91 @@ +"""Support for EnOcean devices.""" +import logging + +from enocean.communicators.serialcommunicator import SerialCommunicator +from enocean.protocol.packet import Packet, RadioPacket +from enocean.utils import combine_hex +import voluptuous as vol + +from homeassistant.const import CONF_DEVICE +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "enocean" +DATA_ENOCEAN = "enocean" + +CONFIG_SCHEMA = vol.Schema( + {DOMAIN: vol.Schema({vol.Required(CONF_DEVICE): cv.string})}, extra=vol.ALLOW_EXTRA +) + +SIGNAL_RECEIVE_MESSAGE = "enocean.receive_message" +SIGNAL_SEND_MESSAGE = "enocean.send_message" + + +def setup(hass, config): + """Set up the EnOcean component.""" + serial_dev = config[DOMAIN].get(CONF_DEVICE) + dongle = EnOceanDongle(hass, serial_dev) + hass.data[DATA_ENOCEAN] = dongle + + return True + + +class EnOceanDongle: + """Representation of an EnOcean dongle.""" + + def __init__(self, hass, ser): + """Initialize the EnOcean dongle.""" + + self.__communicator = SerialCommunicator(port=ser, callback=self.callback) + self.__communicator.start() + self.hass = hass + self.hass.helpers.dispatcher.dispatcher_connect( + SIGNAL_SEND_MESSAGE, self._send_message_callback + ) + + def _send_message_callback(self, command): + """Send a command through the EnOcean dongle.""" + self.__communicator.send(command) + + def callback(self, packet): + """Handle EnOcean device's callback. + + This is the callback function called by python-enocan whenever there + is an incoming packet. + """ + + if isinstance(packet, RadioPacket): + _LOGGER.debug("Received radio packet: %s", packet) + self.hass.helpers.dispatcher.dispatcher_send(SIGNAL_RECEIVE_MESSAGE, packet) + + +class EnOceanDevice(Entity): + """Parent class for all devices associated with the EnOcean component.""" + + def __init__(self, dev_id, dev_name="EnOcean device"): + """Initialize the device.""" + self.dev_id = dev_id + self.dev_name = dev_name + + async def async_added_to_hass(self): + """Register callbacks.""" + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_RECEIVE_MESSAGE, self._message_received_callback + ) + + def _message_received_callback(self, packet): + """Handle incoming packets.""" + + if packet.sender_int == combine_hex(self.dev_id): + self.value_changed(packet) + + def value_changed(self, packet): + """Update the internal state of the device when a packet arrives.""" + + def send_command(self, data, optional, packet_type): + """Send a command via the EnOcean dongle.""" + + packet = Packet(packet_type, data=data, optional=optional) + self.hass.helpers.dispatcher.dispatcher_send(SIGNAL_SEND_MESSAGE, packet) diff --git a/homeassistant/components/enocean/binary_sensor.py b/homeassistant/components/enocean/binary_sensor.py new file mode 100644 index 0000000000000..4ff1b4611292e --- /dev/null +++ b/homeassistant/components/enocean/binary_sensor.py @@ -0,0 +1,113 @@ +"""Support for EnOcean binary sensors.""" +import logging + +import voluptuous as vol + +from homeassistant.components import enocean +from homeassistant.components.binary_sensor import ( + DEVICE_CLASSES_SCHEMA, + PLATFORM_SCHEMA, + BinarySensorDevice, +) +from homeassistant.const import CONF_DEVICE_CLASS, CONF_ID, CONF_NAME +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = "EnOcean binary sensor" +DEPENDENCIES = ["enocean"] +EVENT_BUTTON_PRESSED = "button_pressed" + +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) + dev_name = config.get(CONF_NAME) + device_class = config.get(CONF_DEVICE_CLASS) + + add_entities([EnOceanBinarySensor(dev_id, dev_name, device_class)]) + + +class EnOceanBinarySensor(enocean.EnOceanDevice, BinarySensorDevice): + """Representation of EnOcean binary sensors such as wall switches. + + Supported EEPs (EnOcean Equipment Profiles): + - F6-02-01 (Light and Blind Control - Application Style 2) + - F6-02-02 (Light and Blind Control - Application Style 1) + """ + + def __init__(self, dev_id, dev_name, device_class): + """Initialize the EnOcean binary sensor.""" + super().__init__(dev_id, dev_name) + self._device_class = device_class + self.which = -1 + self.onoff = -1 + + @property + def name(self): + """Return the default name for the binary sensor.""" + return self.dev_name + + @property + def device_class(self): + """Return the class of this sensor.""" + return self._device_class + + def value_changed(self, packet): + """Fire an event with the data that have changed. + + This method is called when there is an incoming packet associated + with this platform. + + Example packet data: + - 2nd button pressed + ['0xf6', '0x10', '0x00', '0x2d', '0xcf', '0x45', '0x30'] + - button released + ['0xf6', '0x00', '0x00', '0x2d', '0xcf', '0x45', '0x20'] + """ + # Energy Bow + pushed = None + + if packet.data[6] == 0x30: + pushed = 1 + elif packet.data[6] == 0x20: + pushed = 0 + + self.schedule_update_ha_state() + + action = packet.data[1] + if action == 0x70: + self.which = 0 + self.onoff = 0 + elif action == 0x50: + self.which = 0 + self.onoff = 1 + elif action == 0x30: + self.which = 1 + self.onoff = 0 + elif action == 0x10: + self.which = 1 + self.onoff = 1 + elif action == 0x37: + self.which = 10 + self.onoff = 0 + elif action == 0x15: + self.which = 10 + self.onoff = 1 + self.hass.bus.fire( + EVENT_BUTTON_PRESSED, + { + "id": self.dev_id, + "pushed": pushed, + "which": self.which, + "onoff": self.onoff, + }, + ) diff --git a/homeassistant/components/enocean/light.py b/homeassistant/components/enocean/light.py new file mode 100644 index 0000000000000..a1d2b22cdb491 --- /dev/null +++ b/homeassistant/components/enocean/light.py @@ -0,0 +1,109 @@ +"""Support for EnOcean light sources.""" +import logging +import math + +import voluptuous as vol + +from homeassistant.components import enocean +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + PLATFORM_SCHEMA, + SUPPORT_BRIGHTNESS, + Light, +) +from homeassistant.const import CONF_ID, CONF_NAME +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +CONF_SENDER_ID = "sender_id" + +DEFAULT_NAME = "EnOcean Light" +SUPPORT_ENOCEAN = SUPPORT_BRIGHTNESS + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_ID, default=[]): vol.All(cv.ensure_list, [vol.Coerce(int)]), + vol.Required(CONF_SENDER_ID): vol.All(cv.ensure_list, [vol.Coerce(int)]), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the EnOcean light platform.""" + sender_id = config.get(CONF_SENDER_ID) + dev_name = config.get(CONF_NAME) + dev_id = config.get(CONF_ID) + + add_entities([EnOceanLight(sender_id, dev_id, dev_name)]) + + +class EnOceanLight(enocean.EnOceanDevice, Light): + """Representation of an EnOcean light source.""" + + def __init__(self, sender_id, dev_id, dev_name): + """Initialize the EnOcean light source.""" + super().__init__(dev_id, dev_name) + self._on_state = False + self._brightness = 50 + self._sender_id = sender_id + + @property + def name(self): + """Return the name of the device if any.""" + return self.dev_name + + @property + def brightness(self): + """Brightness of the light. + + This method is optional. Removing it indicates to Home Assistant + that brightness is not supported for this light. + """ + return self._brightness + + @property + def is_on(self): + """If light is on.""" + return self._on_state + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_ENOCEAN + + def turn_on(self, **kwargs): + """Turn the light source on or sets a specific dimmer value.""" + brightness = kwargs.get(ATTR_BRIGHTNESS) + if brightness is not None: + self._brightness = brightness + + bval = math.floor(self._brightness / 256.0 * 100.0) + if bval == 0: + bval = 1 + command = [0xA5, 0x02, bval, 0x01, 0x09] + command.extend(self._sender_id) + command.extend([0x00]) + self.send_command(command, [], 0x01) + self._on_state = True + + def turn_off(self, **kwargs): + """Turn the light source off.""" + command = [0xA5, 0x02, 0x00, 0x01, 0x09] + command.extend(self._sender_id) + command.extend([0x00]) + self.send_command(command, [], 0x01) + self._on_state = False + + def value_changed(self, packet): + """Update the internal state of this device. + + Dimmer devices like Eltako FUD61 send telegram in different RORGs. + We only care about the 4BS (0xA5). + """ + if packet.data[0] == 0xA5 and packet.data[1] == 0x02: + val = packet.data[2] + self._brightness = math.floor(val / 100.0 * 256.0) + self._on_state = bool(val != 0) + self.schedule_update_ha_state() diff --git a/homeassistant/components/enocean/manifest.json b/homeassistant/components/enocean/manifest.json new file mode 100644 index 0000000000000..a1d2c4a926014 --- /dev/null +++ b/homeassistant/components/enocean/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "enocean", + "name": "EnOcean", + "documentation": "https://www.home-assistant.io/integrations/enocean", + "requirements": ["enocean==0.50"], + "dependencies": [], + "codeowners": ["@bdurrer"] +} diff --git a/homeassistant/components/enocean/sensor.py b/homeassistant/components/enocean/sensor.py new file mode 100644 index 0000000000000..59ca10da791e9 --- /dev/null +++ b/homeassistant/components/enocean/sensor.py @@ -0,0 +1,258 @@ +"""Support for EnOcean sensors.""" +import logging + +import voluptuous as vol + +from homeassistant.components import enocean +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_DEVICE_CLASS, + CONF_ID, + CONF_NAME, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_TEMPERATURE, + POWER_WATT, + STATE_CLOSED, + STATE_OPEN, + TEMP_CELSIUS, +) +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +CONF_MAX_TEMP = "max_temp" +CONF_MIN_TEMP = "min_temp" +CONF_RANGE_FROM = "range_from" +CONF_RANGE_TO = "range_to" + +DEFAULT_NAME = "EnOcean sensor" + +SENSOR_TYPE_HUMIDITY = "humidity" +SENSOR_TYPE_POWER = "powersensor" +SENSOR_TYPE_TEMPERATURE = "temperature" +SENSOR_TYPE_WINDOWHANDLE = "windowhandle" + +SENSOR_TYPES = { + SENSOR_TYPE_HUMIDITY: { + "name": "Humidity", + "unit": "%", + "icon": "mdi:water-percent", + "class": DEVICE_CLASS_HUMIDITY, + }, + SENSOR_TYPE_POWER: { + "name": "Power", + "unit": POWER_WATT, + "icon": "mdi:power-plug", + "class": DEVICE_CLASS_POWER, + }, + SENSOR_TYPE_TEMPERATURE: { + "name": "Temperature", + "unit": TEMP_CELSIUS, + "icon": "mdi:thermometer", + "class": DEVICE_CLASS_TEMPERATURE, + }, + SENSOR_TYPE_WINDOWHANDLE: { + "name": "WindowHandle", + "unit": None, + "icon": "mdi:window", + "class": None, + }, +} + + +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, default=SENSOR_TYPE_POWER): cv.string, + vol.Optional(CONF_MAX_TEMP, default=40): vol.Coerce(int), + vol.Optional(CONF_MIN_TEMP, default=0): vol.Coerce(int), + vol.Optional(CONF_RANGE_FROM, default=255): cv.positive_int, + vol.Optional(CONF_RANGE_TO, default=0): cv.positive_int, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up an EnOcean sensor device.""" + dev_id = config.get(CONF_ID) + dev_name = config.get(CONF_NAME) + sensor_type = config.get(CONF_DEVICE_CLASS) + + if sensor_type == SENSOR_TYPE_TEMPERATURE: + temp_min = config.get(CONF_MIN_TEMP) + temp_max = config.get(CONF_MAX_TEMP) + range_from = config.get(CONF_RANGE_FROM) + range_to = config.get(CONF_RANGE_TO) + add_entities( + [ + EnOceanTemperatureSensor( + dev_id, dev_name, temp_min, temp_max, range_from, range_to + ) + ] + ) + + elif sensor_type == SENSOR_TYPE_HUMIDITY: + add_entities([EnOceanHumiditySensor(dev_id, dev_name)]) + + elif sensor_type == SENSOR_TYPE_POWER: + add_entities([EnOceanPowerSensor(dev_id, dev_name)]) + + elif sensor_type == SENSOR_TYPE_WINDOWHANDLE: + add_entities([EnOceanWindowHandle(dev_id, dev_name)]) + + +class EnOceanSensor(enocean.EnOceanDevice): + """Representation of an EnOcean sensor device such as a power meter.""" + + def __init__(self, dev_id, dev_name, sensor_type): + """Initialize the EnOcean sensor device.""" + super().__init__(dev_id, dev_name) + self._sensor_type = sensor_type + self._device_class = SENSOR_TYPES[self._sensor_type]["class"] + self._dev_name = "{} {}".format( + SENSOR_TYPES[self._sensor_type]["name"], dev_name + ) + self._unit_of_measurement = SENSOR_TYPES[self._sensor_type]["unit"] + self._icon = SENSOR_TYPES[self._sensor_type]["icon"] + self._state = None + + @property + def name(self): + """Return the name of the device.""" + return self._dev_name + + @property + def icon(self): + """Icon to use in the frontend.""" + return self._icon + + @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 device.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit_of_measurement + + def value_changed(self, packet): + """Update the internal state of the sensor.""" + + +class EnOceanPowerSensor(EnOceanSensor): + """Representation of an EnOcean power sensor. + + EEPs (EnOcean Equipment Profiles): + - A5-12-01 (Automated Meter Reading, Electricity) + """ + + def __init__(self, dev_id, dev_name): + """Initialize the EnOcean power sensor device.""" + super().__init__(dev_id, dev_name, SENSOR_TYPE_POWER) + + def value_changed(self, packet): + """Update the internal state of the sensor.""" + if packet.rorg != 0xA5: + return + packet.parse_eep(0x12, 0x01) + if packet.parsed["DT"]["raw_value"] == 1: + # this packet reports the current value + raw_val = packet.parsed["MR"]["raw_value"] + divisor = packet.parsed["DIV"]["raw_value"] + self._state = raw_val / (10 ** divisor) + self.schedule_update_ha_state() + + +class EnOceanTemperatureSensor(EnOceanSensor): + """Representation of an EnOcean temperature sensor device. + + EEPs (EnOcean Equipment Profiles): + - A5-02-01 to A5-02-1B All 8 Bit Temperature Sensors of A5-02 + - A5-10-01 to A5-10-14 (Room Operating Panels) + - A5-04-01 (Temp. and Humidity Sensor, Range 0°C to +40°C and 0% to 100%) + - A5-04-02 (Temp. and Humidity Sensor, Range -20°C to +60°C and 0% to 100%) + - A5-10-10 (Temp. and Humidity Sensor and Set Point) + - A5-10-12 (Temp. and Humidity Sensor, Set Point and Occupancy Control) + - 10 Bit Temp. Sensors are not supported (A5-02-20, A5-02-30) + + For the following EEPs the scales must be set to "0 to 250": + - A5-04-01 + - A5-04-02 + - A5-10-10 to A5-10-14 + """ + + def __init__(self, dev_id, dev_name, scale_min, scale_max, range_from, range_to): + """Initialize the EnOcean temperature sensor device.""" + super().__init__(dev_id, dev_name, SENSOR_TYPE_TEMPERATURE) + self._scale_min = scale_min + self._scale_max = scale_max + self.range_from = range_from + self.range_to = range_to + + def value_changed(self, packet): + """Update the internal state of the sensor.""" + if packet.data[0] != 0xA5: + return + temp_scale = self._scale_max - self._scale_min + temp_range = self.range_to - self.range_from + raw_val = packet.data[3] + temperature = temp_scale / temp_range * (raw_val - self.range_from) + temperature += self._scale_min + self._state = round(temperature, 1) + self.schedule_update_ha_state() + + +class EnOceanHumiditySensor(EnOceanSensor): + """Representation of an EnOcean humidity sensor device. + + EEPs (EnOcean Equipment Profiles): + - A5-04-01 (Temp. and Humidity Sensor, Range 0°C to +40°C and 0% to 100%) + - A5-04-02 (Temp. and Humidity Sensor, Range -20°C to +60°C and 0% to 100%) + - A5-10-10 to A5-10-14 (Room Operating Panels) + """ + + def __init__(self, dev_id, dev_name): + """Initialize the EnOcean humidity sensor device.""" + super().__init__(dev_id, dev_name, SENSOR_TYPE_HUMIDITY) + + def value_changed(self, packet): + """Update the internal state of the sensor.""" + if packet.rorg != 0xA5: + return + humidity = packet.data[2] * 100 / 250 + self._state = round(humidity, 1) + self.schedule_update_ha_state() + + +class EnOceanWindowHandle(EnOceanSensor): + """Representation of an EnOcean window handle device. + + EEPs (EnOcean Equipment Profiles): + - F6-10-00 (Mechanical handle / Hoppe AG) + """ + + def __init__(self, dev_id, dev_name): + """Initialize the EnOcean window handle sensor device.""" + super().__init__(dev_id, dev_name, SENSOR_TYPE_WINDOWHANDLE) + + def value_changed(self, packet): + """Update the internal state of the sensor.""" + + action = (packet.data[1] & 0x70) >> 4 + + if action == 0x07: + self._state = STATE_CLOSED + if action in (0x04, 0x06): + self._state = STATE_OPEN + if action == 0x05: + self._state = "tilt" + + self.schedule_update_ha_state() diff --git a/homeassistant/components/enocean/switch.py b/homeassistant/components/enocean/switch.py new file mode 100644 index 0000000000000..92642e329d93d --- /dev/null +++ b/homeassistant/components/enocean/switch.py @@ -0,0 +1,100 @@ +"""Support for EnOcean switches.""" +import logging + +import voluptuous as vol + +from homeassistant.components import enocean +from homeassistant.components.switch import PLATFORM_SCHEMA +from homeassistant.const import CONF_ID, CONF_NAME +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import ToggleEntity + +_LOGGER = logging.getLogger(__name__) + +CONF_CHANNEL = "channel" +DEFAULT_NAME = "EnOcean Switch" + +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_CHANNEL, default=0): cv.positive_int, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the EnOcean switch platform.""" + channel = config.get(CONF_CHANNEL) + dev_id = config.get(CONF_ID) + dev_name = config.get(CONF_NAME) + + add_entities([EnOceanSwitch(dev_id, dev_name, channel)]) + + +class EnOceanSwitch(enocean.EnOceanDevice, ToggleEntity): + """Representation of an EnOcean switch device.""" + + def __init__(self, dev_id, dev_name, channel): + """Initialize the EnOcean switch device.""" + super().__init__(dev_id, dev_name) + self._light = None + self._on_state = False + self._on_state2 = False + self.channel = channel + + @property + def is_on(self): + """Return whether the switch is on or off.""" + return self._on_state + + @property + def name(self): + """Return the device name.""" + return self.dev_name + + def turn_on(self, **kwargs): + """Turn on the switch.""" + optional = [0x03] + optional.extend(self.dev_id) + optional.extend([0xFF, 0x00]) + self.send_command( + data=[0xD2, 0x01, self.channel & 0xFF, 0x64, 0x00, 0x00, 0x00, 0x00, 0x00], + optional=optional, + packet_type=0x01, + ) + self._on_state = True + + def turn_off(self, **kwargs): + """Turn off the switch.""" + optional = [0x03] + optional.extend(self.dev_id) + optional.extend([0xFF, 0x00]) + self.send_command( + data=[0xD2, 0x01, self.channel & 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], + optional=optional, + packet_type=0x01, + ) + self._on_state = False + + def value_changed(self, packet): + """Update the internal state of the switch.""" + if packet.data[0] == 0xA5: + # power meter telegram, turn on if > 10 watts + packet.parse_eep(0x12, 0x01) + if packet.parsed["DT"]["raw_value"] == 1: + raw_val = packet.parsed["MR"]["raw_value"] + divisor = packet.parsed["DIV"]["raw_value"] + watts = raw_val / (10 ** divisor) + if watts > 1: + self._on_state = True + self.schedule_update_ha_state() + elif packet.data[0] == 0xD2: + # actuator status telegram + packet.parse_eep(0x01, 0x01) + if packet.parsed["CMD"]["raw_value"] == 4: + channel = packet.parsed["IO"]["raw_value"] + output = packet.parsed["OV"]["raw_value"] + if channel == self.channel: + self._on_state = output > 0 + self.schedule_update_ha_state() diff --git a/homeassistant/components/enphase_envoy/__init__.py b/homeassistant/components/enphase_envoy/__init__.py new file mode 100644 index 0000000000000..c4101fbcdf23f --- /dev/null +++ b/homeassistant/components/enphase_envoy/__init__.py @@ -0,0 +1 @@ +"""The enphase_envoy component.""" diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json new file mode 100644 index 0000000000000..68f584c053e9a --- /dev/null +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "enphase_envoy", + "name": "Enphase Envoy", + "documentation": "https://www.home-assistant.io/integrations/enphase_envoy", + "requirements": ["envoy_reader==0.11.0"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py new file mode 100644 index 0000000000000..a2b50f20eb635 --- /dev/null +++ b/homeassistant/components/enphase_envoy/sensor.py @@ -0,0 +1,168 @@ +"""Support for Enphase Envoy solar energy monitor.""" +import logging + +from envoy_reader.envoy_reader import EnvoyReader +import requests +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_IP_ADDRESS, + CONF_MONITORED_CONDITIONS, + CONF_NAME, + CONF_PASSWORD, + CONF_USERNAME, + ENERGY_WATT_HOUR, + POWER_WATT, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +SENSORS = { + "production": ("Envoy Current Energy Production", POWER_WATT), + "daily_production": ("Envoy Today's Energy Production", ENERGY_WATT_HOUR), + "seven_days_production": ( + "Envoy Last Seven Days Energy Production", + ENERGY_WATT_HOUR, + ), + "lifetime_production": ("Envoy Lifetime Energy Production", ENERGY_WATT_HOUR), + "consumption": ("Envoy Current Energy Consumption", POWER_WATT), + "daily_consumption": ("Envoy Today's Energy Consumption", ENERGY_WATT_HOUR), + "seven_days_consumption": ( + "Envoy Last Seven Days Energy Consumption", + ENERGY_WATT_HOUR, + ), + "lifetime_consumption": ("Envoy Lifetime Energy Consumption", ENERGY_WATT_HOUR), + "inverters": ("Envoy Inverter", POWER_WATT), +} + + +ICON = "mdi:flash" +CONST_DEFAULT_HOST = "envoy" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_IP_ADDRESS, default=CONST_DEFAULT_HOST): cv.string, + vol.Optional(CONF_USERNAME, default="envoy"): cv.string, + vol.Optional(CONF_PASSWORD, default=""): cv.string, + vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSORS)): vol.All( + cv.ensure_list, [vol.In(list(SENSORS))] + ), + vol.Optional(CONF_NAME, default=""): cv.string, + } +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the Enphase Envoy sensor.""" + ip_address = config[CONF_IP_ADDRESS] + monitored_conditions = config[CONF_MONITORED_CONDITIONS] + name = config[CONF_NAME] + username = config[CONF_USERNAME] + password = config[CONF_PASSWORD] + + envoy_reader = EnvoyReader(ip_address, username, password) + + entities = [] + # Iterate through the list of sensors + for condition in monitored_conditions: + if condition == "inverters": + try: + inverters = await envoy_reader.inverters_production() + except requests.exceptions.HTTPError: + _LOGGER.warning( + "Authentication for Inverter data failed during setup: %s", + ip_address, + ) + continue + + if isinstance(inverters, dict): + for inverter in inverters: + entities.append( + Envoy( + envoy_reader, + condition, + f"{name}{SENSORS[condition][0]} {inverter}", + SENSORS[condition][1], + ) + ) + + else: + entities.append( + Envoy( + envoy_reader, + condition, + f"{name}{SENSORS[condition][0]}", + SENSORS[condition][1], + ) + ) + async_add_entities(entities) + + +class Envoy(Entity): + """Implementation of the Enphase Envoy sensors.""" + + def __init__(self, envoy_reader, sensor_type, name, unit): + """Initialize the sensor.""" + self._envoy_reader = envoy_reader + self._type = sensor_type + self._name = name + self._unit_of_measurement = unit + self._state = None + self._last_reported = 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 this entity, if any.""" + return self._unit_of_measurement + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return ICON + + @property + def device_state_attributes(self): + """Return the state attributes.""" + if self._type == "inverters": + return {"last_reported": self._last_reported} + + return None + + async def async_update(self): + """Get the energy production data from the Enphase Envoy.""" + if self._type != "inverters": + _state = await getattr(self._envoy_reader, self._type)() + if isinstance(_state, int): + self._state = _state + else: + _LOGGER.error(_state) + self._state = None + + elif self._type == "inverters": + try: + inverters = await (self._envoy_reader.inverters_production()) + except requests.exceptions.HTTPError: + _LOGGER.warning( + "Authentication for Inverter data failed during update: %s", + self._envoy_reader.host, + ) + + if isinstance(inverters, dict): + serial_number = self._name.split(" ")[2] + self._state = inverters[serial_number][0] + self._last_reported = inverters[serial_number][1] + else: + self._state = None diff --git a/homeassistant/components/entur_public_transport/__init__.py b/homeassistant/components/entur_public_transport/__init__.py new file mode 100644 index 0000000000000..0bdce1909f4b7 --- /dev/null +++ b/homeassistant/components/entur_public_transport/__init__.py @@ -0,0 +1 @@ +"""Component for integrating entur public transport.""" diff --git a/homeassistant/components/entur_public_transport/manifest.json b/homeassistant/components/entur_public_transport/manifest.json new file mode 100644 index 0000000000000..0d5f3e24f8398 --- /dev/null +++ b/homeassistant/components/entur_public_transport/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "entur_public_transport", + "name": "Entur", + "documentation": "https://www.home-assistant.io/integrations/entur_public_transport", + "requirements": ["enturclient==0.2.1"], + "dependencies": [], + "codeowners": ["@hfurubotten"] +} diff --git a/homeassistant/components/entur_public_transport/sensor.py b/homeassistant/components/entur_public_transport/sensor.py new file mode 100644 index 0000000000000..2ecae21824eeb --- /dev/null +++ b/homeassistant/components/entur_public_transport/sensor.py @@ -0,0 +1,248 @@ +"""Real-time information about public transport departures in Norway.""" +from datetime import datetime, timedelta +import logging + +from enturclient import EnturPublicTransportData +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + ATTR_ATTRIBUTION, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_NAME, + CONF_SHOW_ON_MAP, +) +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 +import homeassistant.util.dt as dt_util + +_LOGGER = logging.getLogger(__name__) + +API_CLIENT_NAME = "homeassistant-homeassistant" + +ATTRIBUTION = "Data provided by entur.org under NLOD" + +CONF_STOP_IDS = "stop_ids" +CONF_EXPAND_PLATFORMS = "expand_platforms" +CONF_WHITELIST_LINES = "line_whitelist" +CONF_OMIT_NON_BOARDING = "omit_non_boarding" +CONF_NUMBER_OF_DEPARTURES = "number_of_departures" + +DEFAULT_NAME = "Entur" +DEFAULT_ICON_KEY = "bus" + +ICONS = { + "air": "mdi:airplane", + "bus": "mdi:bus", + "metro": "mdi:subway", + "rail": "mdi:train", + "tram": "mdi:tram", + "water": "mdi:ferry", +} + +SCAN_INTERVAL = timedelta(seconds=45) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_STOP_IDS): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_EXPAND_PLATFORMS, default=True): cv.boolean, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_SHOW_ON_MAP, default=False): cv.boolean, + vol.Optional(CONF_WHITELIST_LINES, default=[]): cv.ensure_list, + vol.Optional(CONF_OMIT_NON_BOARDING, default=True): cv.boolean, + vol.Optional(CONF_NUMBER_OF_DEPARTURES, default=2): vol.All( + cv.positive_int, vol.Range(min=2, max=10) + ), + } +) + + +ATTR_STOP_ID = "stop_id" + +ATTR_ROUTE = "route" +ATTR_ROUTE_ID = "route_id" +ATTR_EXPECTED_AT = "due_at" +ATTR_DELAY = "delay" +ATTR_REALTIME = "real_time" + +ATTR_NEXT_UP_IN = "next_due_in" +ATTR_NEXT_UP_ROUTE = "next_route" +ATTR_NEXT_UP_ROUTE_ID = "next_route_id" +ATTR_NEXT_UP_AT = "next_due_at" +ATTR_NEXT_UP_DELAY = "next_delay" +ATTR_NEXT_UP_REALTIME = "next_real_time" + +ATTR_TRANSPORT_MODE = "transport_mode" + + +def due_in_minutes(timestamp: datetime) -> int: + """Get the time in minutes from a timestamp.""" + if timestamp is None: + return None + diff = timestamp - dt_util.now() + return int(diff.total_seconds() / 60) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the Entur public transport sensor.""" + + expand = config.get(CONF_EXPAND_PLATFORMS) + line_whitelist = config.get(CONF_WHITELIST_LINES) + name = config.get(CONF_NAME) + show_on_map = config.get(CONF_SHOW_ON_MAP) + stop_ids = config.get(CONF_STOP_IDS) + omit_non_boarding = config.get(CONF_OMIT_NON_BOARDING) + number_of_departures = config.get(CONF_NUMBER_OF_DEPARTURES) + + stops = [s for s in stop_ids if "StopPlace" in s] + quays = [s for s in stop_ids if "Quay" in s] + + data = EnturPublicTransportData( + API_CLIENT_NAME, + stops=stops, + quays=quays, + line_whitelist=line_whitelist, + omit_non_boarding=omit_non_boarding, + number_of_departures=number_of_departures, + web_session=async_get_clientsession(hass), + ) + + if expand: + await data.expand_all_quays() + await data.update() + + proxy = EnturProxy(data) + + entities = [] + for place in data.all_stop_places_quays(): + try: + given_name = "{} {}".format(name, data.get_stop_info(place).name) + except KeyError: + given_name = f"{name} {place}" + + entities.append( + EnturPublicTransportSensor(proxy, given_name, place, show_on_map) + ) + + async_add_entities(entities, True) + + +class EnturProxy: + """Proxy for the Entur client. + + Ensure throttle to not hit rate limiting on the API. + """ + + def __init__(self, api): + """Initialize the proxy.""" + self._api = api + + @Throttle(timedelta(seconds=15)) + async def async_update(self) -> None: + """Update data in client.""" + await self._api.update() + + def get_stop_info(self, stop_id: str) -> dict: + """Get info about specific stop place.""" + return self._api.get_stop_info(stop_id) + + +class EnturPublicTransportSensor(Entity): + """Implementation of a Entur public transport sensor.""" + + def __init__(self, api: EnturProxy, name: str, stop: str, show_on_map: bool): + """Initialize the sensor.""" + self.api = api + self._stop = stop + self._show_on_map = show_on_map + self._name = name + self._state = None + self._icon = ICONS[DEFAULT_ICON_KEY] + self._attributes = {} + + @property + def name(self) -> str: + """Return the name of the sensor.""" + return self._name + + @property + def state(self) -> str: + """Return the state of the sensor.""" + return self._state + + @property + def device_state_attributes(self) -> dict: + """Return the state attributes.""" + self._attributes[ATTR_ATTRIBUTION] = ATTRIBUTION + self._attributes[ATTR_STOP_ID] = self._stop + return self._attributes + + @property + def unit_of_measurement(self) -> str: + """Return the unit this state is expressed in.""" + return "min" + + @property + def icon(self) -> str: + """Icon to use in the frontend.""" + return self._icon + + async def async_update(self) -> None: + """Get the latest data and update the states.""" + await self.api.async_update() + + self._attributes = {} + + data = self.api.get_stop_info(self._stop) + if data is None: + self._state = None + return + + if self._show_on_map and data.latitude and data.longitude: + self._attributes[CONF_LATITUDE] = data.latitude + self._attributes[CONF_LONGITUDE] = data.longitude + + calls = data.estimated_calls + if not calls: + self._state = None + return + + self._state = due_in_minutes(calls[0].expected_departure_time) + self._icon = ICONS.get(calls[0].transport_mode, ICONS[DEFAULT_ICON_KEY]) + + self._attributes[ATTR_ROUTE] = calls[0].front_display + self._attributes[ATTR_ROUTE_ID] = calls[0].line_id + self._attributes[ATTR_EXPECTED_AT] = calls[0].expected_departure_time.strftime( + "%H:%M" + ) + self._attributes[ATTR_REALTIME] = calls[0].is_realtime + self._attributes[ATTR_DELAY] = calls[0].delay_in_min + + number_of_calls = len(calls) + if number_of_calls < 2: + return + + self._attributes[ATTR_NEXT_UP_ROUTE] = calls[1].front_display + self._attributes[ATTR_NEXT_UP_ROUTE_ID] = calls[1].line_id + self._attributes[ATTR_NEXT_UP_AT] = calls[1].expected_departure_time.strftime( + "%H:%M" + ) + self._attributes[ATTR_NEXT_UP_IN] = "{} min".format( + due_in_minutes(calls[1].expected_departure_time) + ) + self._attributes[ATTR_NEXT_UP_REALTIME] = calls[1].is_realtime + self._attributes[ATTR_NEXT_UP_DELAY] = calls[1].delay_in_min + + if number_of_calls < 3: + return + + for i, call in enumerate(calls[2:]): + key_name = "departure_#" + str(i + 3) + self._attributes[key_name] = "{}{} {}".format( + "" if bool(call.is_realtime) else "ca. ", + call.expected_departure_time.strftime("%H:%M"), + call.front_display, + ) diff --git a/homeassistant/components/environment_canada/__init__.py b/homeassistant/components/environment_canada/__init__.py new file mode 100644 index 0000000000000..356e18fe23fd4 --- /dev/null +++ b/homeassistant/components/environment_canada/__init__.py @@ -0,0 +1 @@ +"""A component for Environment Canada weather.""" diff --git a/homeassistant/components/environment_canada/camera.py b/homeassistant/components/environment_canada/camera.py new file mode 100644 index 0000000000000..4ef3e17fc4683 --- /dev/null +++ b/homeassistant/components/environment_canada/camera.py @@ -0,0 +1,107 @@ +""" +Support for the Environment Canada radar imagery. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.environment_canada/ +""" +import datetime +import logging + +from env_canada import ECRadar +import voluptuous as vol + +from homeassistant.components.camera import PLATFORM_SCHEMA, Camera +from homeassistant.const import ( + ATTR_ATTRIBUTION, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_NAME, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.util import Throttle + +_LOGGER = logging.getLogger(__name__) + +ATTR_STATION = "station" +ATTR_LOCATION = "location" +ATTR_UPDATED = "updated" + +CONF_ATTRIBUTION = "Data provided by Environment Canada" +CONF_STATION = "station" +CONF_LOOP = "loop" +CONF_PRECIP_TYPE = "precip_type" + +MIN_TIME_BETWEEN_UPDATES = datetime.timedelta(minutes=10) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_LOOP, default=True): cv.boolean, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_STATION): cv.matches_regex(r"^C[A-Z]{4}$|^[A-Z]{3}$"), + vol.Inclusive(CONF_LATITUDE, "latlon"): cv.latitude, + vol.Inclusive(CONF_LONGITUDE, "latlon"): cv.longitude, + vol.Optional(CONF_PRECIP_TYPE): ["RAIN", "SNOW"], + } +) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Environment Canada camera.""" + + if config.get(CONF_STATION): + radar_object = ECRadar( + station_id=config[CONF_STATION], precip_type=config.get(CONF_PRECIP_TYPE) + ) + else: + lat = config.get(CONF_LATITUDE, hass.config.latitude) + lon = config.get(CONF_LONGITUDE, hass.config.longitude) + radar_object = ECRadar(coordinates=(lat, lon)) + + add_devices([ECCamera(radar_object, config.get(CONF_NAME))], True) + + +class ECCamera(Camera): + """Implementation of an Environment Canada radar camera.""" + + def __init__(self, radar_object, camera_name): + """Initialize the camera.""" + super().__init__() + + self.radar_object = radar_object + self.camera_name = camera_name + self.content_type = "image/gif" + self.image = None + self.timestamp = None + + def camera_image(self): + """Return bytes of camera image.""" + self.update() + return self.image + + @property + def name(self): + """Return the name of the camera.""" + if self.camera_name is not None: + return self.camera_name + return " ".join([self.radar_object.station_name, "Radar"]) + + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + attr = { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + ATTR_LOCATION: self.radar_object.station_name, + ATTR_STATION: self.radar_object.station_code, + ATTR_UPDATED: self.timestamp, + } + + return attr + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Update radar image.""" + if CONF_LOOP: + self.image = self.radar_object.get_loop() + else: + self.image = self.radar_object.get_latest_frame() + self.timestamp = self.radar_object.timestamp.isoformat() diff --git a/homeassistant/components/environment_canada/manifest.json b/homeassistant/components/environment_canada/manifest.json new file mode 100644 index 0000000000000..fa243f09bbb4e --- /dev/null +++ b/homeassistant/components/environment_canada/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "environment_canada", + "name": "Environment Canada", + "documentation": "https://www.home-assistant.io/integrations/environment_canada", + "requirements": ["env_canada==0.0.31"], + "dependencies": [], + "codeowners": ["@michaeldavie"] +} diff --git a/homeassistant/components/environment_canada/sensor.py b/homeassistant/components/environment_canada/sensor.py new file mode 100644 index 0000000000000..1568ba19d6b7b --- /dev/null +++ b/homeassistant/components/environment_canada/sensor.py @@ -0,0 +1,164 @@ +""" +Support for the Environment Canada weather service. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.environment_canada/ +""" +from datetime import datetime, timedelta +import logging +import re + +from env_canada import ECData +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_LOCATION, + CONF_LATITUDE, + CONF_LONGITUDE, + TEMP_CELSIUS, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(minutes=10) + +ATTR_UPDATED = "updated" +ATTR_STATION = "station" +ATTR_DETAIL = "alert detail" +ATTR_TIME = "alert time" + +CONF_ATTRIBUTION = "Data provided by Environment Canada" +CONF_STATION = "station" +CONF_LANGUAGE = "language" + + +def validate_station(station): + """Check that the station ID is well-formed.""" + if station is None: + return + if not re.fullmatch(r"[A-Z]{2}/s0000\d{3}", station): + raise vol.error.Invalid('Station ID must be of the form "XX/s0000###"') + return station + + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_LANGUAGE, default="english"): vol.In(["english", "french"]), + vol.Optional(CONF_STATION): validate_station, + vol.Inclusive(CONF_LATITUDE, "latlon"): cv.latitude, + vol.Inclusive(CONF_LONGITUDE, "latlon"): cv.longitude, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Environment Canada sensor.""" + + if config.get(CONF_STATION): + ec_data = ECData( + station_id=config[CONF_STATION], language=config.get(CONF_LANGUAGE) + ) + else: + lat = config.get(CONF_LATITUDE, hass.config.latitude) + lon = config.get(CONF_LONGITUDE, hass.config.longitude) + ec_data = ECData(coordinates=(lat, lon), language=config.get(CONF_LANGUAGE)) + + sensor_list = list(ec_data.conditions.keys()) + list(ec_data.alerts.keys()) + add_entities([ECSensor(sensor_type, ec_data) for sensor_type in sensor_list], True) + + +class ECSensor(Entity): + """Implementation of an Environment Canada sensor.""" + + def __init__(self, sensor_type, ec_data): + """Initialize the sensor.""" + self.sensor_type = sensor_type + self.ec_data = ec_data + + self._unique_id = None + self._name = None + self._state = None + self._attr = None + self._unit = None + + @property + def unique_id(self) -> str: + """Return the unique ID of the sensor.""" + return self._unique_id + + @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 of the device.""" + return self._attr + + @property + def unit_of_measurement(self): + """Return the units of measurement.""" + return self._unit + + def update(self): + """Update current conditions.""" + self.ec_data.update() + self.ec_data.conditions.update(self.ec_data.alerts) + + conditions = self.ec_data.conditions + metadata = self.ec_data.metadata + sensor_data = conditions.get(self.sensor_type) + + self._unique_id = "{}-{}".format(metadata["location"], self.sensor_type) + self._attr = {} + self._name = sensor_data.get("label") + value = sensor_data.get("value") + + if isinstance(value, list): + self._state = " | ".join([str(s.get("title")) for s in value])[:255] + self._attr.update( + { + ATTR_DETAIL: " | ".join([str(s.get("detail")) for s in value]), + ATTR_TIME: " | ".join([str(s.get("date")) for s in value]), + } + ) + elif self.sensor_type == "tendency": + self._state = str(value).capitalize() + elif value is not None and len(value) > 255: + self._state = value[:255] + _LOGGER.info("Value for %s truncated to 255 characters", self._unique_id) + else: + self._state = value + + if sensor_data.get("unit") == "C" or self.sensor_type in [ + "wind_chill", + "humidex", + ]: + self._unit = TEMP_CELSIUS + else: + self._unit = sensor_data.get("unit") + + timestamp = metadata.get("timestamp") + if timestamp: + updated_utc = datetime.strptime(timestamp, "%Y%m%d%H%M%S").isoformat() + else: + updated_utc = None + + self._attr.update( + { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + ATTR_UPDATED: updated_utc, + ATTR_LOCATION: metadata.get("location"), + ATTR_STATION: metadata.get("station"), + } + ) diff --git a/homeassistant/components/environment_canada/weather.py b/homeassistant/components/environment_canada/weather.py new file mode 100644 index 0000000000000..572543e39c4c1 --- /dev/null +++ b/homeassistant/components/environment_canada/weather.py @@ -0,0 +1,234 @@ +""" +Platform for retrieving meteorological data from Environment Canada. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/weather.environmentcanada/ +""" +import datetime +import logging +import re + +from env_canada import ECData +import voluptuous as vol + +from homeassistant.components.weather import ( + ATTR_FORECAST_CONDITION, + ATTR_FORECAST_TEMP, + ATTR_FORECAST_TEMP_LOW, + ATTR_FORECAST_TIME, + PLATFORM_SCHEMA, + WeatherEntity, +) +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, TEMP_CELSIUS +import homeassistant.helpers.config_validation as cv +import homeassistant.util.dt as dt + +_LOGGER = logging.getLogger(__name__) + +CONF_FORECAST = "forecast" +CONF_ATTRIBUTION = "Data provided by Environment Canada" +CONF_STATION = "station" + + +def validate_station(station): + """Check that the station ID is well-formed.""" + if station is None: + return + if not re.fullmatch(r"[A-Z]{2}/s0000\d{3}", station): + raise vol.error.Invalid('Station ID must be of the form "XX/s0000###"') + return station + + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_STATION): validate_station, + vol.Inclusive(CONF_LATITUDE, "latlon"): cv.latitude, + vol.Inclusive(CONF_LONGITUDE, "latlon"): cv.longitude, + vol.Optional(CONF_FORECAST, default="daily"): vol.In(["daily", "hourly"]), + } +) + +# Icon codes from http://dd.weatheroffice.ec.gc.ca/citypage_weather/ +# docs/current_conditions_icon_code_descriptions_e.csv +ICON_CONDITION_MAP = { + "sunny": [0, 1], + "clear-night": [30, 31], + "partlycloudy": [2, 3, 4, 5, 22, 32, 33, 34, 35], + "cloudy": [10], + "rainy": [6, 9, 11, 12, 28, 36], + "lightning-rainy": [19, 39, 46, 47], + "pouring": [13], + "snowy-rainy": [7, 14, 15, 27, 37], + "snowy": [8, 16, 17, 18, 25, 26, 38, 40], + "windy": [43], + "fog": [20, 21, 23, 24, 44], + "hail": [26, 27], +} + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Environment Canada weather.""" + if config.get(CONF_STATION): + ec_data = ECData(station_id=config[CONF_STATION]) + else: + lat = config.get(CONF_LATITUDE, hass.config.latitude) + lon = config.get(CONF_LONGITUDE, hass.config.longitude) + ec_data = ECData(coordinates=(lat, lon)) + + add_devices([ECWeather(ec_data, config)]) + + +class ECWeather(WeatherEntity): + """Representation of a weather condition.""" + + def __init__(self, ec_data, config): + """Initialize Environment Canada weather.""" + self.ec_data = ec_data + self.platform_name = config.get(CONF_NAME) + self.forecast_type = config[CONF_FORECAST] + + @property + def attribution(self): + """Return the attribution.""" + return CONF_ATTRIBUTION + + @property + def name(self): + """Return the name of the weather entity.""" + if self.platform_name: + return self.platform_name + return self.ec_data.metadata.get("location") + + @property + def temperature(self): + """Return the temperature.""" + if self.ec_data.conditions.get("temperature").get("value"): + return float(self.ec_data.conditions["temperature"]["value"]) + if self.ec_data.hourly_forecasts[0].get("temperature"): + return float(self.ec_data.hourly_forecasts[0]["temperature"]) + return None + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def humidity(self): + """Return the humidity.""" + if self.ec_data.conditions.get("humidity").get("value"): + return float(self.ec_data.conditions["humidity"]["value"]) + return None + + @property + def wind_speed(self): + """Return the wind speed.""" + if self.ec_data.conditions.get("wind_speed").get("value"): + return float(self.ec_data.conditions["wind_speed"]["value"]) + return None + + @property + def wind_bearing(self): + """Return the wind bearing.""" + if self.ec_data.conditions.get("wind_bearing").get("value"): + return float(self.ec_data.conditions["wind_bearing"]["value"]) + return None + + @property + def pressure(self): + """Return the pressure.""" + if self.ec_data.conditions.get("pressure").get("value"): + return 10 * float(self.ec_data.conditions["pressure"]["value"]) + return None + + @property + def visibility(self): + """Return the visibility.""" + if self.ec_data.conditions.get("visibility").get("value"): + return float(self.ec_data.conditions["visibility"]["value"]) + return None + + @property + def condition(self): + """Return the weather condition.""" + icon_code = None + + if self.ec_data.conditions.get("icon_code").get("value"): + icon_code = self.ec_data.conditions["icon_code"]["value"] + elif self.ec_data.hourly_forecasts[0].get("icon_code"): + icon_code = self.ec_data.hourly_forecasts[0]["icon_code"] + + if icon_code: + return icon_code_to_condition(int(icon_code)) + return "" + + @property + def forecast(self): + """Return the forecast array.""" + return get_forecast(self.ec_data, self.forecast_type) + + def update(self): + """Get the latest data from Environment Canada.""" + self.ec_data.update() + + +def get_forecast(ec_data, forecast_type): + """Build the forecast array.""" + forecast_array = [] + + if forecast_type == "daily": + half_days = ec_data.daily_forecasts + if half_days[0]["temperature_class"] == "high": + forecast_array.append( + { + ATTR_FORECAST_TIME: dt.now().isoformat(), + ATTR_FORECAST_TEMP: int(half_days[0]["temperature"]), + ATTR_FORECAST_TEMP_LOW: int(half_days[1]["temperature"]), + ATTR_FORECAST_CONDITION: icon_code_to_condition( + int(half_days[0]["icon_code"]) + ), + } + ) + half_days = half_days[2:] + else: + half_days = half_days[1:] + + for day, high, low in zip(range(1, 6), range(0, 9, 2), range(1, 10, 2)): + forecast_array.append( + { + ATTR_FORECAST_TIME: ( + dt.now() + datetime.timedelta(days=day) + ).isoformat(), + ATTR_FORECAST_TEMP: int(half_days[high]["temperature"]), + ATTR_FORECAST_TEMP_LOW: int(half_days[low]["temperature"]), + ATTR_FORECAST_CONDITION: icon_code_to_condition( + int(half_days[high]["icon_code"]) + ), + } + ) + + elif forecast_type == "hourly": + hours = ec_data.hourly_forecasts + for hour in range(0, 24): + forecast_array.append( + { + ATTR_FORECAST_TIME: dt.as_local( + datetime.datetime.strptime(hours[hour]["period"], "%Y%m%d%H%M") + ).isoformat(), + ATTR_FORECAST_TEMP: int(hours[hour]["temperature"]), + ATTR_FORECAST_CONDITION: icon_code_to_condition( + int(hours[hour]["icon_code"]) + ), + } + ) + + return forecast_array + + +def icon_code_to_condition(icon_code): + """Return the condition corresponding to an icon code.""" + for condition, codes in ICON_CONDITION_MAP.items(): + if icon_code in codes: + return condition + return None diff --git a/homeassistant/components/envirophat/__init__.py b/homeassistant/components/envirophat/__init__.py new file mode 100644 index 0000000000000..68d3a99441cb0 --- /dev/null +++ b/homeassistant/components/envirophat/__init__.py @@ -0,0 +1 @@ +"""The envirophat component.""" diff --git a/homeassistant/components/envirophat/manifest.json b/homeassistant/components/envirophat/manifest.json new file mode 100644 index 0000000000000..4cf443f4de662 --- /dev/null +++ b/homeassistant/components/envirophat/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "envirophat", + "name": "Enviro pHAT", + "documentation": "https://www.home-assistant.io/integrations/envirophat", + "requirements": ["envirophat==0.0.6", "smbus-cffi==0.5.1"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/envirophat/sensor.py b/homeassistant/components/envirophat/sensor.py new file mode 100644 index 0000000000000..ce1f154f911aa --- /dev/null +++ b/homeassistant/components/envirophat/sensor.py @@ -0,0 +1,201 @@ +"""Support for Enviro pHAT sensors.""" +from datetime import timedelta +import importlib +import logging + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_DISPLAY_OPTIONS, CONF_NAME, TEMP_CELSIUS +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = "envirophat" +CONF_USE_LEDS = "use_leds" + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) + +SENSOR_TYPES = { + "light": ["light", " ", "mdi:weather-sunny"], + "light_red": ["light_red", " ", "mdi:invert-colors"], + "light_green": ["light_green", " ", "mdi:invert-colors"], + "light_blue": ["light_blue", " ", "mdi:invert-colors"], + "accelerometer_x": ["accelerometer_x", "G", "mdi:earth"], + "accelerometer_y": ["accelerometer_y", "G", "mdi:earth"], + "accelerometer_z": ["accelerometer_z", "G", "mdi:earth"], + "magnetometer_x": ["magnetometer_x", " ", "mdi:magnet"], + "magnetometer_y": ["magnetometer_y", " ", "mdi:magnet"], + "magnetometer_z": ["magnetometer_z", " ", "mdi:magnet"], + "temperature": ["temperature", TEMP_CELSIUS, "mdi:thermometer"], + "pressure": ["pressure", "hPa", "mdi:gauge"], + "voltage_0": ["voltage_0", "V", "mdi:flash"], + "voltage_1": ["voltage_1", "V", "mdi:flash"], + "voltage_2": ["voltage_2", "V", "mdi:flash"], + "voltage_3": ["voltage_3", "V", "mdi:flash"], +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_DISPLAY_OPTIONS, default=list(SENSOR_TYPES)): [ + vol.In(SENSOR_TYPES) + ], + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_USE_LEDS, default=False): cv.boolean, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Sense HAT sensor platform.""" + try: + envirophat = importlib.import_module("envirophat") + except OSError: + _LOGGER.error("No Enviro pHAT was found.") + return False + + data = EnvirophatData(envirophat, config.get(CONF_USE_LEDS)) + + dev = [] + for variable in config[CONF_DISPLAY_OPTIONS]: + dev.append(EnvirophatSensor(data, variable)) + + add_entities(dev, True) + + +class EnvirophatSensor(Entity): + """Representation of an Enviro pHAT sensor.""" + + def __init__(self, data, sensor_types): + """Initialize the sensor.""" + self.data = data + self._name = SENSOR_TYPES[sensor_types][0] + self._unit_of_measurement = SENSOR_TYPES[sensor_types][1] + self.type = sensor_types + 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 icon(self): + """Icon to use in the frontend, if any.""" + return SENSOR_TYPES[self.type][2] + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self._unit_of_measurement + + def update(self): + """Get the latest data and updates the states.""" + self.data.update() + + if self.type == "light": + self._state = self.data.light + if self.type == "light_red": + self._state = self.data.light_red + if self.type == "light_green": + self._state = self.data.light_green + if self.type == "light_blue": + self._state = self.data.light_blue + if self.type == "accelerometer_x": + self._state = self.data.accelerometer_x + if self.type == "accelerometer_y": + self._state = self.data.accelerometer_y + if self.type == "accelerometer_z": + self._state = self.data.accelerometer_z + if self.type == "magnetometer_x": + self._state = self.data.magnetometer_x + if self.type == "magnetometer_y": + self._state = self.data.magnetometer_y + if self.type == "magnetometer_z": + self._state = self.data.magnetometer_z + if self.type == "temperature": + self._state = self.data.temperature + if self.type == "pressure": + self._state = self.data.pressure + if self.type == "voltage_0": + self._state = self.data.voltage_0 + if self.type == "voltage_1": + self._state = self.data.voltage_1 + if self.type == "voltage_2": + self._state = self.data.voltage_2 + if self.type == "voltage_3": + self._state = self.data.voltage_3 + + +class EnvirophatData: + """Get the latest data and update.""" + + def __init__(self, envirophat, use_leds): + """Initialize the data object.""" + self.envirophat = envirophat + self.use_leds = use_leds + # sensors readings + self.light = None + self.light_red = None + self.light_green = None + self.light_blue = None + self.accelerometer_x = None + self.accelerometer_y = None + self.accelerometer_z = None + self.magnetometer_x = None + self.magnetometer_y = None + self.magnetometer_z = None + self.temperature = None + self.pressure = None + self.voltage_0 = None + self.voltage_1 = None + self.voltage_2 = None + self.voltage_3 = None + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data from Enviro pHAT.""" + # Light sensor reading: 16-bit integer + self.light = self.envirophat.light.light() + if self.use_leds: + self.envirophat.leds.on() + # the three color values scaled against the overall light, 0-255 + self.light_red, self.light_green, self.light_blue = self.envirophat.light.rgb() + if self.use_leds: + self.envirophat.leds.off() + + # accelerometer readings in G + ( + self.accelerometer_x, + self.accelerometer_y, + self.accelerometer_z, + ) = self.envirophat.motion.accelerometer() + + # raw magnetometer reading + ( + self.magnetometer_x, + self.magnetometer_y, + self.magnetometer_z, + ) = self.envirophat.motion.magnetometer() + + # temperature resolution of BMP280 sensor: 0.01°C + self.temperature = round(self.envirophat.weather.temperature(), 2) + + # pressure resolution of BMP280 sensor: 0.16 Pa, rounding to 0.1 Pa + # with conversion to 100 Pa = 1 hPa + self.pressure = round(self.envirophat.weather.pressure() / 100.0, 3) + + # Voltage sensor, reading between 0-3.3V + ( + self.voltage_0, + self.voltage_1, + self.voltage_2, + self.voltage_3, + ) = self.envirophat.analog.read_all() diff --git a/homeassistant/components/envisalink/__init__.py b/homeassistant/components/envisalink/__init__.py new file mode 100644 index 0000000000000..14113537de6e9 --- /dev/null +++ b/homeassistant/components/envisalink/__init__.py @@ -0,0 +1,255 @@ +"""Support for Envisalink devices.""" +import asyncio +import logging + +from pyenvisalink import EnvisalinkAlarmPanel +import voluptuous as vol + +from homeassistant.const import CONF_HOST, CONF_TIMEOUT, 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_send +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "envisalink" + +DATA_EVL = "envisalink" + +CONF_CODE = "code" +CONF_EVL_KEEPALIVE = "keepalive_interval" +CONF_EVL_PORT = "port" +CONF_EVL_VERSION = "evl_version" +CONF_PANEL_TYPE = "panel_type" +CONF_PANIC = "panic_type" +CONF_PARTITIONNAME = "name" +CONF_PARTITIONS = "partitions" +CONF_PASS = "password" +CONF_USERNAME = "user_name" +CONF_ZONEDUMP_INTERVAL = "zonedump_interval" +CONF_ZONENAME = "name" +CONF_ZONES = "zones" +CONF_ZONETYPE = "type" + +DEFAULT_PORT = 4025 +DEFAULT_EVL_VERSION = 3 +DEFAULT_KEEPALIVE = 60 +DEFAULT_ZONEDUMP_INTERVAL = 30 +DEFAULT_ZONETYPE = "opening" +DEFAULT_PANIC = "Police" +DEFAULT_TIMEOUT = 10 + +SIGNAL_ZONE_UPDATE = "envisalink.zones_updated" +SIGNAL_PARTITION_UPDATE = "envisalink.partition_updated" +SIGNAL_KEYPAD_UPDATE = "envisalink.keypad_updated" + +ZONE_SCHEMA = vol.Schema( + { + vol.Required(CONF_ZONENAME): cv.string, + vol.Optional(CONF_ZONETYPE, default=DEFAULT_ZONETYPE): cv.string, + } +) + +PARTITION_SCHEMA = vol.Schema({vol.Required(CONF_PARTITIONNAME): cv.string}) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PANEL_TYPE): vol.All( + cv.string, vol.In(["HONEYWELL", "DSC"]) + ), + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASS): cv.string, + vol.Optional(CONF_CODE): cv.string, + vol.Optional(CONF_PANIC, default=DEFAULT_PANIC): cv.string, + vol.Optional(CONF_ZONES): {vol.Coerce(int): ZONE_SCHEMA}, + vol.Optional(CONF_PARTITIONS): {vol.Coerce(int): PARTITION_SCHEMA}, + vol.Optional(CONF_EVL_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_EVL_VERSION, default=DEFAULT_EVL_VERSION): vol.All( + vol.Coerce(int), vol.Range(min=3, max=4) + ), + vol.Optional(CONF_EVL_KEEPALIVE, default=DEFAULT_KEEPALIVE): vol.All( + vol.Coerce(int), vol.Range(min=15) + ), + vol.Optional( + CONF_ZONEDUMP_INTERVAL, default=DEFAULT_ZONEDUMP_INTERVAL + ): vol.Coerce(int), + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): vol.Coerce(int), + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + +SERVICE_CUSTOM_FUNCTION = "invoke_custom_function" +ATTR_CUSTOM_FUNCTION = "pgm" +ATTR_PARTITION = "partition" + +SERVICE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CUSTOM_FUNCTION): cv.string, + vol.Required(ATTR_PARTITION): cv.string, + } +) + + +async def async_setup(hass, config): + """Set up for Envisalink devices.""" + + conf = config.get(DOMAIN) + + host = conf.get(CONF_HOST) + port = conf.get(CONF_EVL_PORT) + code = conf.get(CONF_CODE) + panel_type = conf.get(CONF_PANEL_TYPE) + panic_type = conf.get(CONF_PANIC) + version = conf.get(CONF_EVL_VERSION) + user = conf.get(CONF_USERNAME) + password = conf.get(CONF_PASS) + keep_alive = conf.get(CONF_EVL_KEEPALIVE) + zone_dump = conf.get(CONF_ZONEDUMP_INTERVAL) + zones = conf.get(CONF_ZONES) + partitions = conf.get(CONF_PARTITIONS) + connection_timeout = conf.get(CONF_TIMEOUT) + sync_connect = asyncio.Future() + + controller = EnvisalinkAlarmPanel( + host, + port, + panel_type, + version, + user, + password, + zone_dump, + keep_alive, + hass.loop, + connection_timeout, + ) + hass.data[DATA_EVL] = controller + + @callback + def login_fail_callback(data): + """Handle when the evl rejects our login.""" + _LOGGER.error("The Envisalink rejected your credentials") + if not sync_connect.done(): + sync_connect.set_result(False) + + @callback + def connection_fail_callback(data): + """Network failure callback.""" + _LOGGER.error( + "Could not establish a connection with the Envisalink- retrying..." + ) + if not sync_connect.done(): + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_envisalink) + sync_connect.set_result(True) + + @callback + def connection_success_callback(data): + """Handle a successful connection.""" + _LOGGER.info("Established a connection with the Envisalink") + if not sync_connect.done(): + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_envisalink) + sync_connect.set_result(True) + + @callback + def zones_updated_callback(data): + """Handle zone timer updates.""" + _LOGGER.debug("Envisalink sent a zone update event. Updating zones...") + async_dispatcher_send(hass, SIGNAL_ZONE_UPDATE, data) + + @callback + def alarm_data_updated_callback(data): + """Handle non-alarm based info updates.""" + _LOGGER.debug("Envisalink sent new alarm info. Updating alarms...") + async_dispatcher_send(hass, SIGNAL_KEYPAD_UPDATE, data) + + @callback + def partition_updated_callback(data): + """Handle partition changes thrown by evl (including alarms).""" + _LOGGER.debug("The envisalink sent a partition update event") + async_dispatcher_send(hass, SIGNAL_PARTITION_UPDATE, data) + + @callback + def stop_envisalink(event): + """Shutdown envisalink connection and thread on exit.""" + _LOGGER.info("Shutting down Envisalink") + controller.stop() + + async def handle_custom_function(call): + """Handle custom/PGM service.""" + custom_function = call.data.get(ATTR_CUSTOM_FUNCTION) + partition = call.data.get(ATTR_PARTITION) + controller.command_output(code, partition, custom_function) + + controller.callback_zone_timer_dump = zones_updated_callback + controller.callback_zone_state_change = zones_updated_callback + controller.callback_partition_state_change = partition_updated_callback + controller.callback_keypad_update = alarm_data_updated_callback + controller.callback_login_failure = login_fail_callback + controller.callback_login_timeout = connection_fail_callback + controller.callback_login_success = connection_success_callback + + _LOGGER.info("Start envisalink.") + controller.start() + + result = await sync_connect + if not result: + return False + + # Load sub-components for Envisalink + if partitions: + hass.async_create_task( + async_load_platform( + hass, + "alarm_control_panel", + "envisalink", + {CONF_PARTITIONS: partitions, CONF_CODE: code, CONF_PANIC: panic_type}, + config, + ) + ) + hass.async_create_task( + async_load_platform( + hass, + "sensor", + "envisalink", + {CONF_PARTITIONS: partitions, CONF_CODE: code}, + config, + ) + ) + if zones: + hass.async_create_task( + async_load_platform( + hass, "binary_sensor", "envisalink", {CONF_ZONES: zones}, config + ) + ) + + hass.services.async_register( + DOMAIN, SERVICE_CUSTOM_FUNCTION, handle_custom_function, schema=SERVICE_SCHEMA + ) + + return True + + +class EnvisalinkDevice(Entity): + """Representation of an Envisalink device.""" + + def __init__(self, name, info, controller): + """Initialize the device.""" + self._controller = controller + self._info = info + self._name = name + + @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/envisalink/alarm_control_panel.py b/homeassistant/components/envisalink/alarm_control_panel.py new file mode 100644 index 0000000000000..7630169dcadaf --- /dev/null +++ b/homeassistant/components/envisalink/alarm_control_panel.py @@ -0,0 +1,211 @@ +"""Support for Envisalink-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, + SUPPORT_ALARM_TRIGGER, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_DISARMED, + STATE_ALARM_PENDING, + STATE_ALARM_TRIGGERED, + STATE_UNKNOWN, +) +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from . import ( + CONF_CODE, + CONF_PANIC, + CONF_PARTITIONNAME, + DATA_EVL, + DOMAIN, + PARTITION_SCHEMA, + SIGNAL_KEYPAD_UPDATE, + SIGNAL_PARTITION_UPDATE, + EnvisalinkDevice, +) + +_LOGGER = logging.getLogger(__name__) + +SERVICE_ALARM_KEYPRESS = "alarm_keypress" +ATTR_KEYPRESS = "keypress" +ALARM_KEYPRESS_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_KEYPRESS): cv.string, + } +) + + +async 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( + DOMAIN, + SERVICE_ALARM_KEYPRESS, + alarm_keypress_handler, + schema=ALARM_KEYPRESS_SCHEMA, + ) + + return True + + +class EnvisalinkAlarm(EnvisalinkDevice, 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) + + async 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 FORMAT_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_zero_entry_delay"]: + state = STATE_ALARM_ARMED_NIGHT + 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 + + @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 + | SUPPORT_ALARM_TRIGGER + ) + + async 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 + ) + + async 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 + ) + + async 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 + ) + + async 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) + + async def async_alarm_arm_night(self, code=None): + """Send arm night command.""" + self.hass.data[DATA_EVL].arm_night_partition( + str(code) if code else str(self._code), self._partition_number + ) + + @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/envisalink/binary_sensor.py b/homeassistant/components/envisalink/binary_sensor.py new file mode 100644 index 0000000000000..fbe9824d06733 --- /dev/null +++ b/homeassistant/components/envisalink/binary_sensor.py @@ -0,0 +1,97 @@ +"""Support for Envisalink zone states- represented as binary sensors.""" +import datetime +import logging + +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.const import ATTR_LAST_TRIP_TIME +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.util import dt as dt_util + +from . import ( + CONF_ZONENAME, + CONF_ZONETYPE, + DATA_EVL, + SIGNAL_ZONE_UPDATE, + ZONE_SCHEMA, + EnvisalinkDevice, +) + +_LOGGER = logging.getLogger(__name__) + + +async 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) + + async 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/envisalink/manifest.json b/homeassistant/components/envisalink/manifest.json new file mode 100644 index 0000000000000..1bc9d38e998b6 --- /dev/null +++ b/homeassistant/components/envisalink/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "envisalink", + "name": "Envisalink", + "documentation": "https://www.home-assistant.io/integrations/envisalink", + "requirements": ["pyenvisalink==4.0"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/envisalink/sensor.py b/homeassistant/components/envisalink/sensor.py new file mode 100644 index 0000000000000..05ad0783facda --- /dev/null +++ b/homeassistant/components/envisalink/sensor.py @@ -0,0 +1,77 @@ +"""Support for Envisalink sensors (shows panel info).""" +import logging + +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from . import ( + CONF_PARTITIONNAME, + DATA_EVL, + PARTITION_SCHEMA, + SIGNAL_KEYPAD_UPDATE, + SIGNAL_PARTITION_UPDATE, + EnvisalinkDevice, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Perform the setup for Envisalink sensor devices.""" + configured_partitions = discovery_info["partitions"] + + devices = [] + for part_num in configured_partitions: + device_config_data = PARTITION_SCHEMA(configured_partitions[part_num]) + device = EnvisalinkSensor( + hass, + device_config_data[CONF_PARTITIONNAME], + part_num, + hass.data[DATA_EVL].alarm_state["partition"][part_num], + hass.data[DATA_EVL], + ) + + devices.append(device) + + async_add_entities(devices) + + +class EnvisalinkSensor(EnvisalinkDevice, Entity): + """Representation of an Envisalink keypad.""" + + def __init__(self, hass, partition_name, partition_number, info, controller): + """Initialize the sensor.""" + self._icon = "mdi:alarm" + self._partition_number = partition_number + + _LOGGER.debug("Setting up sensor for partition: %s", partition_name) + super().__init__(partition_name + " Keypad", info, controller) + + async 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 + ) + + @property + def icon(self): + """Return the icon if any.""" + return self._icon + + @property + def state(self): + """Return the overall state.""" + return self._info["status"]["alpha"] + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._info["status"] + + @callback + def _update_callback(self, partition): + """Update the partition state in HA, if needed.""" + if partition is None or int(partition) == self._partition_number: + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/envisalink/services.yaml b/homeassistant/components/envisalink/services.yaml new file mode 100644 index 0000000000000..2a5f91791df46 --- /dev/null +++ b/homeassistant/components/envisalink/services.yaml @@ -0,0 +1,25 @@ +# Describes the format for available Envisalink services. + +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' + +invoke_custom_function: + description: > + Allows users with DSC panels to trigger a PGM output (1-4). + Note that you need to specify the alarm panel's "code" parameter for this to work. + fields: + partition: + description: > + The alarm panel partition to trigger the PGM output on. + Typically this is just "1". + example: "1" + pgm: + description: The PGM number to trigger on the alarm panel. This will be 1-4. + example: "2" diff --git a/homeassistant/components/ephember/__init__.py b/homeassistant/components/ephember/__init__.py new file mode 100644 index 0000000000000..97758383c1176 --- /dev/null +++ b/homeassistant/components/ephember/__init__.py @@ -0,0 +1 @@ +"""The ephember component.""" diff --git a/homeassistant/components/ephember/climate.py b/homeassistant/components/ephember/climate.py new file mode 100644 index 0000000000000..d743f3e82babd --- /dev/null +++ b/homeassistant/components/ephember/climate.py @@ -0,0 +1,208 @@ +"""Support for the EPH Controls Ember themostats.""" +from datetime import timedelta +import logging + +from pyephember.pyephember import ( + EphEmber, + ZoneMode, + zone_current_temperature, + zone_is_active, + zone_is_boost_active, + zone_is_hot_water, + zone_mode, + zone_name, + zone_target_temperature, +) +import voluptuous as vol + +from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateDevice +from homeassistant.components.climate.const import ( + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_OFF, + SUPPORT_AUX_HEAT, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.const import ( + ATTR_TEMPERATURE, + CONF_PASSWORD, + CONF_USERNAME, + TEMP_CELSIUS, +) +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +# Return cached results if last scan was less then this time ago +SCAN_INTERVAL = timedelta(seconds=120) + +OPERATION_LIST = [HVAC_MODE_HEAT_COOL, HVAC_MODE_HEAT, HVAC_MODE_OFF] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + {vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string} +) + +EPH_TO_HA_STATE = { + "AUTO": HVAC_MODE_HEAT_COOL, + "ON": HVAC_MODE_HEAT, + "OFF": HVAC_MODE_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.""" + 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 EphEmber thermostat.""" + + def __init__(self, ember, zone): + """Initialize the thermostat.""" + self._ember = ember + self._zone_name = zone_name(zone) + self._zone = zone + self._hot_water = zone_is_hot_water(zone) + + @property + def supported_features(self): + """Return the list of supported features.""" + if self._hot_water: + return SUPPORT_AUX_HEAT + + return SUPPORT_TARGET_TEMPERATURE | SUPPORT_AUX_HEAT + + @property + def name(self): + """Return the name of the thermostat, if any.""" + return self._zone_name + + @property + def temperature_unit(self): + """Return the unit of measurement which this thermostat uses.""" + return TEMP_CELSIUS + + @property + def current_temperature(self): + """Return the current temperature.""" + return zone_current_temperature(self._zone) + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return zone_target_temperature(self._zone) + + @property + def target_temperature_step(self): + """Return the supported step of target temperature.""" + if self._hot_water: + return None + + return 0.5 + + @property + def hvac_action(self): + """Return current HVAC action.""" + if zone_is_active(self._zone): + return CURRENT_HVAC_HEAT + + return CURRENT_HVAC_IDLE + + @property + def hvac_mode(self): + """Return current operation ie. heat, cool, idle.""" + mode = zone_mode(self._zone) + return self.map_mode_eph_hass(mode) + + @property + def hvac_modes(self): + """Return the supported operations.""" + return OPERATION_LIST + + def set_hvac_mode(self, hvac_mode): + """Set the operation mode.""" + mode = self.map_mode_hass_eph(hvac_mode) + if mode is not None: + self._ember.set_mode_by_name(self._zone_name, mode) + else: + _LOGGER.error("Invalid operation mode provided %s", hvac_mode) + + @property + def is_aux_heat(self): + """Return true if aux heater.""" + + return zone_is_boost_active(self._zone) + + def turn_aux_heat_on(self): + """Turn auxiliary heater on.""" + self._ember.activate_boost_by_name( + self._zone_name, zone_target_temperature(self._zone) + ) + + 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, temperature) + + @property + def min_temp(self): + """Return the minimum temperature.""" + # Hot water temp doesn't support being changed + if self._hot_water: + return zone_target_temperature(self._zone) + + return 5.0 + + @property + def max_temp(self): + """Return the maximum temperature.""" + if self._hot_water: + return zone_target_temperature(self._zone) + + return 35.0 + + 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.""" + 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, HVAC_MODE_HEAT_COOL) diff --git a/homeassistant/components/ephember/manifest.json b/homeassistant/components/ephember/manifest.json new file mode 100644 index 0000000000000..4df302ac2dd8e --- /dev/null +++ b/homeassistant/components/ephember/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "ephember", + "name": "EPH Controls", + "documentation": "https://www.home-assistant.io/integrations/ephember", + "requirements": ["pyephember==0.3.1"], + "dependencies": [], + "codeowners": ["@ttroy50"] +} diff --git a/homeassistant/components/epson/__init__.py b/homeassistant/components/epson/__init__.py new file mode 100644 index 0000000000000..eed342f77f927 --- /dev/null +++ b/homeassistant/components/epson/__init__.py @@ -0,0 +1 @@ +"""The epson component.""" diff --git a/homeassistant/components/epson/const.py b/homeassistant/components/epson/const.py new file mode 100644 index 0000000000000..23f3b081d0135 --- /dev/null +++ b/homeassistant/components/epson/const.py @@ -0,0 +1,10 @@ +"""Constants for the Epson projector component.""" +DOMAIN = "epson" +SERVICE_SELECT_CMODE = "select_cmode" + +ATTR_CMODE = "cmode" + +DATA_EPSON = "epson" +DEFAULT_NAME = "EPSON Projector" + +SUPPORT_CMODE = 33001 diff --git a/homeassistant/components/epson/manifest.json b/homeassistant/components/epson/manifest.json new file mode 100644 index 0000000000000..81d08d76dfb3e --- /dev/null +++ b/homeassistant/components/epson/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "epson", + "name": "Epson", + "documentation": "https://www.home-assistant.io/integrations/epson", + "requirements": ["epson-projector==0.1.3"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/epson/media_player.py b/homeassistant/components/epson/media_player.py new file mode 100644 index 0000000000000..b39722c39f344 --- /dev/null +++ b/homeassistant/components/epson/media_player.py @@ -0,0 +1,241 @@ +"""Support for Epson projector.""" +import logging + +import epson_projector as epson +from epson_projector.const import ( + BACK, + BUSY, + CMODE, + CMODE_LIST, + CMODE_LIST_SET, + DEFAULT_SOURCES, + EPSON_CODES, + FAST, + INV_SOURCES, + MUTE, + PAUSE, + PLAY, + POWER, + SOURCE, + SOURCE_LIST, + TURN_OFF, + TURN_ON, + VOL_DOWN, + VOL_UP, + VOLUME, +) +import voluptuous as vol + +from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerDevice +from homeassistant.components.media_player.const import ( + SUPPORT_NEXT_TRACK, + SUPPORT_PREVIOUS_TRACK, + SUPPORT_SELECT_SOURCE, + SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, + SUPPORT_VOLUME_MUTE, + SUPPORT_VOLUME_STEP, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_HOST, + CONF_NAME, + CONF_PORT, + CONF_SSL, + STATE_OFF, + STATE_ON, +) +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv + +from .const import ( + ATTR_CMODE, + DATA_EPSON, + DEFAULT_NAME, + DOMAIN, + SERVICE_SELECT_CMODE, + SUPPORT_CMODE, +) + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_EPSON = ( + SUPPORT_TURN_ON + | SUPPORT_TURN_OFF + | SUPPORT_SELECT_SOURCE + | SUPPORT_CMODE + | SUPPORT_VOLUME_MUTE + | SUPPORT_VOLUME_STEP + | SUPPORT_NEXT_TRACK + | SUPPORT_PREVIOUS_TRACK +) + +MEDIA_PLAYER_SCHEMA = vol.Schema({ATTR_ENTITY_ID: cv.comp_entity_ids}) + +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=80): cv.port, + vol.Optional(CONF_SSL, default=False): cv.boolean, + } +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the Epson media player platform.""" + if DATA_EPSON not in hass.data: + hass.data[DATA_EPSON] = [] + + name = config.get(CONF_NAME) + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) + ssl = config.get(CONF_SSL) + + epson_proj = EpsonProjector( + async_get_clientsession(hass, verify_ssl=False), name, host, port, ssl + ) + + hass.data[DATA_EPSON].append(epson_proj) + async_add_entities([epson_proj], update_before_add=True) + + async def async_service_handler(service): + """Handle for services.""" + entity_ids = service.data.get(ATTR_ENTITY_ID) + if entity_ids: + devices = [ + device + for device in hass.data[DATA_EPSON] + if device.entity_id in entity_ids + ] + else: + devices = hass.data[DATA_EPSON] + for device in devices: + if service.service == SERVICE_SELECT_CMODE: + cmode = service.data.get(ATTR_CMODE) + await device.select_cmode(cmode) + device.async_schedule_update_ha_state(True) + + epson_schema = MEDIA_PLAYER_SCHEMA.extend( + {vol.Required(ATTR_CMODE): vol.All(cv.string, vol.Any(*CMODE_LIST_SET))} + ) + hass.services.async_register( + DOMAIN, SERVICE_SELECT_CMODE, async_service_handler, schema=epson_schema + ) + + +class EpsonProjector(MediaPlayerDevice): + """Representation of Epson Projector Device.""" + + def __init__(self, websession, name, host, port, encryption): + """Initialize entity to control Epson projector.""" + self._name = name + self._projector = epson.Projector(host, websession=websession, port=port) + self._cmode = None + self._source_list = list(DEFAULT_SOURCES.values()) + self._source = None + self._volume = None + self._state = None + + async def async_update(self): + """Update state of device.""" + is_turned_on = await self._projector.get_property(POWER) + _LOGGER.debug("Project turn on/off status: %s", is_turned_on) + if is_turned_on and is_turned_on == EPSON_CODES[POWER]: + self._state = STATE_ON + cmode = await self._projector.get_property(CMODE) + self._cmode = CMODE_LIST.get(cmode, self._cmode) + source = await self._projector.get_property(SOURCE) + self._source = SOURCE_LIST.get(source, self._source) + volume = await self._projector.get_property(VOLUME) + if volume: + self._volume = volume + elif is_turned_on == BUSY: + self._state = STATE_ON + else: + self._state = STATE_OFF + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @property + def supported_features(self): + """Flag media player features that are supported.""" + return SUPPORT_EPSON + + async def async_turn_on(self): + """Turn on epson.""" + if self._state == STATE_OFF: + await self._projector.send_command(TURN_ON) + + async def async_turn_off(self): + """Turn off epson.""" + if self._state == STATE_ON: + await self._projector.send_command(TURN_OFF) + + @property + def source_list(self): + """List of available input sources.""" + return self._source_list + + @property + def source(self): + """Get current input sources.""" + return self._source + + @property + def volume_level(self): + """Return the volume level of the media player (0..1).""" + return self._volume + + async def select_cmode(self, cmode): + """Set color mode in Epson.""" + await self._projector.send_command(CMODE_LIST_SET[cmode]) + + async def async_select_source(self, source): + """Select input source.""" + selected_source = INV_SOURCES[source] + await self._projector.send_command(selected_source) + + async def async_mute_volume(self, mute): + """Mute (true) or unmute (false) sound.""" + await self._projector.send_command(MUTE) + + async def async_volume_up(self): + """Increase volume.""" + await self._projector.send_command(VOL_UP) + + async def async_volume_down(self): + """Decrease volume.""" + await self._projector.send_command(VOL_DOWN) + + async def async_media_play(self): + """Play media via Epson.""" + await self._projector.send_command(PLAY) + + async def async_media_pause(self): + """Pause media via Epson.""" + await self._projector.send_command(PAUSE) + + async def async_media_next_track(self): + """Skip to next.""" + await self._projector.send_command(FAST) + + async def async_media_previous_track(self): + """Skip to previous.""" + await self._projector.send_command(BACK) + + @property + def device_state_attributes(self): + """Return device specific state attributes.""" + attributes = {} + if self._cmode is not None: + attributes[ATTR_CMODE] = self._cmode + return attributes diff --git a/homeassistant/components/epson/services.yaml b/homeassistant/components/epson/services.yaml new file mode 100644 index 0000000000000..6e9724c95f72d --- /dev/null +++ b/homeassistant/components/epson/services.yaml @@ -0,0 +1,9 @@ +select_cmode: + description: Select Color mode of Epson projector + fields: + entity_id: + description: Name of projector + example: 'media_player.epson_projector' + cmode: + description: Name of Cmode + example: 'cinema' diff --git a/homeassistant/components/epsonworkforce/__init__.py b/homeassistant/components/epsonworkforce/__init__.py new file mode 100644 index 0000000000000..5efd217b1ddc6 --- /dev/null +++ b/homeassistant/components/epsonworkforce/__init__.py @@ -0,0 +1 @@ +"""The epsonworkforce component.""" diff --git a/homeassistant/components/epsonworkforce/manifest.json b/homeassistant/components/epsonworkforce/manifest.json new file mode 100644 index 0000000000000..37620b66e7c77 --- /dev/null +++ b/homeassistant/components/epsonworkforce/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "epsonworkforce", + "name": "Epson Workforce", + "documentation": "https://www.home-assistant.io/integrations/epsonworkforce", + "dependencies": [], + "codeowners": ["@ThaStealth"], + "requirements": ["epsonprinter==0.0.9"] +} diff --git a/homeassistant/components/epsonworkforce/sensor.py b/homeassistant/components/epsonworkforce/sensor.py new file mode 100644 index 0000000000000..3bb90eb064490 --- /dev/null +++ b/homeassistant/components/epsonworkforce/sensor.py @@ -0,0 +1,89 @@ +"""Support for Epson Workforce Printer.""" +from datetime import timedelta +import logging + +from epsonprinter_pkg.epsonprinterapi import EpsonPrinterAPI +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_HOST, CONF_MONITORED_CONDITIONS +from homeassistant.exceptions import PlatformNotReady +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) +MONITORED_CONDITIONS = { + "black": ["Ink level Black", "%", "mdi:water"], + "photoblack": ["Ink level Photoblack", "%", "mdi:water"], + "magenta": ["Ink level Magenta", "%", "mdi:water"], + "cyan": ["Ink level Cyan", "%", "mdi:water"], + "yellow": ["Ink level Yellow", "%", "mdi:water"], + "clean": ["Cleaning level", "%", "mdi:water"], +} +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_MONITORED_CONDITIONS): vol.All( + cv.ensure_list, [vol.In(MONITORED_CONDITIONS)] + ), + } +) +SCAN_INTERVAL = timedelta(minutes=60) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the cartridge sensor.""" + host = config.get(CONF_HOST) + + api = EpsonPrinterAPI(host) + if not api.available: + raise PlatformNotReady() + + sensors = [ + EpsonPrinterCartridge(api, condition) + for condition in config[CONF_MONITORED_CONDITIONS] + ] + + add_devices(sensors, True) + + +class EpsonPrinterCartridge(Entity): + """Representation of a cartridge sensor.""" + + def __init__(self, api, cartridgeidx): + """Initialize a cartridge sensor.""" + self._api = api + + self._id = cartridgeidx + self._name = MONITORED_CONDITIONS[self._id][0] + self._unit = MONITORED_CONDITIONS[self._id][1] + self._icon = MONITORED_CONDITIONS[self._id][2] + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return self._icon + + @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 device.""" + return self._api.getSensorValue(self._id) + + @property + def available(self): + """Could the device be accessed during the last update call.""" + return self._api.available + + def update(self): + """Get the latest data from the Epson printer.""" + self._api.update() diff --git a/homeassistant/components/eq3btsmart/__init__.py b/homeassistant/components/eq3btsmart/__init__.py new file mode 100644 index 0000000000000..f32eba6944f32 --- /dev/null +++ b/homeassistant/components/eq3btsmart/__init__.py @@ -0,0 +1 @@ +"""The eq3btsmart component.""" diff --git a/homeassistant/components/eq3btsmart/climate.py b/homeassistant/components/eq3btsmart/climate.py new file mode 100644 index 0000000000000..d0b60c74443da --- /dev/null +++ b/homeassistant/components/eq3btsmart/climate.py @@ -0,0 +1,199 @@ +"""Support for eQ-3 Bluetooth Smart thermostats.""" +import logging + +# pylint: disable=import-error +from bluepy.btle import BTLEException +import eq3bt as eq3 # pylint: disable=import-error +import voluptuous as vol + +from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateDevice +from homeassistant.components.climate.const import ( + HVAC_MODE_AUTO, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + PRESET_AWAY, + PRESET_BOOST, + PRESET_NONE, + SUPPORT_PRESET_MODE, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.const import ( + ATTR_TEMPERATURE, + CONF_DEVICES, + CONF_MAC, + PRECISION_HALVES, + TEMP_CELSIUS, +) +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +STATE_BOOST = "boost" + +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" + +EQ_TO_HA_HVAC = { + eq3.Mode.Open: HVAC_MODE_HEAT, + eq3.Mode.Closed: HVAC_MODE_OFF, + eq3.Mode.Auto: HVAC_MODE_AUTO, + eq3.Mode.Manual: HVAC_MODE_HEAT, + eq3.Mode.Boost: HVAC_MODE_AUTO, + eq3.Mode.Away: HVAC_MODE_HEAT, +} + +HA_TO_EQ_HVAC = { + HVAC_MODE_HEAT: eq3.Mode.Manual, + HVAC_MODE_OFF: eq3.Mode.Closed, + HVAC_MODE_AUTO: eq3.Mode.Auto, +} + +EQ_TO_HA_PRESET = {eq3.Mode.Boost: PRESET_BOOST, eq3.Mode.Away: PRESET_AWAY} + +HA_TO_EQ_PRESET = {PRESET_BOOST: eq3.Mode.Boost, PRESET_AWAY: eq3.Mode.Away} + + +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_PRESET_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, True) + + +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. + self._name = _name + self._thermostat = eq3.Thermostat(_mac) + + @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._thermostat.mode >= 0 + + @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._thermostat.target_temperature = temperature + + @property + def hvac_mode(self): + """Return the current operation mode.""" + if self._thermostat.mode < 0: + return HVAC_MODE_OFF + return EQ_TO_HA_HVAC[self._thermostat.mode] + + @property + def hvac_modes(self): + """Return the list of available operation modes.""" + return list(HA_TO_EQ_HVAC.keys()) + + def set_hvac_mode(self, hvac_mode): + """Set operation mode.""" + if self.preset_mode: + return + self._thermostat.mode = HA_TO_EQ_HVAC[hvac_mode] + + @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 + + @property + def preset_mode(self): + """Return the current preset mode, e.g., home, away, temp. + + Requires SUPPORT_PRESET_MODE. + """ + return EQ_TO_HA_PRESET.get(self._thermostat.mode) + + @property + def preset_modes(self): + """Return a list of available preset modes. + + Requires SUPPORT_PRESET_MODE. + """ + return list(HA_TO_EQ_PRESET.keys()) + + def set_preset_mode(self, preset_mode): + """Set new preset mode.""" + if preset_mode == PRESET_NONE: + self.set_hvac_mode(HVAC_MODE_HEAT) + self._thermostat.mode = HA_TO_EQ_PRESET[preset_mode] + + def update(self): + """Update the data from the thermostat.""" + + try: + self._thermostat.update() + except BTLEException as ex: + _LOGGER.warning("Updating the state failed: %s", ex) diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json new file mode 100644 index 0000000000000..a7d9ee11f6fa2 --- /dev/null +++ b/homeassistant/components/eq3btsmart/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "eq3btsmart", + "name": "EQ3 Bluetooth Smart Thermostats", + "documentation": "https://www.home-assistant.io/integrations/eq3btsmart", + "requirements": ["construct==2.9.45", "python-eq3bt==0.1.11"], + "dependencies": [], + "codeowners": ["@rytilahti"] +} diff --git a/homeassistant/components/esphome/.translations/bg.json b/homeassistant/components/esphome/.translations/bg.json new file mode 100644 index 0000000000000..44a183968739a --- /dev/null +++ b/homeassistant/components/esphome/.translations/bg.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "ESP \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d" + }, + "error": { + "connection_error": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u0441 ESP. \u041c\u043e\u043b\u044f, \u0443\u0432\u0435\u0440\u0435\u0442\u0435 \u0441\u0435, \u0447\u0435 \u0432\u0430\u0448\u0438\u044f\u0442 YAML \u0444\u0430\u0439\u043b \u0441\u044a\u0434\u044a\u0440\u0436\u0430 \u0440\u0435\u0434 \"api:\".", + "invalid_password": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u0430 \u043f\u0430\u0440\u043e\u043b\u0430!", + "resolve_error": "\u041d\u0435 \u043c\u043e\u0436\u0435 \u0434\u0430 \u0441\u0435 \u043e\u0442\u043a\u0440\u0438\u0435 \u0430\u0434\u0440\u0435\u0441\u044a\u0442 \u043d\u0430 ESP. \u0410\u043a\u043e \u0442\u0430\u0437\u0438 \u0433\u0440\u0435\u0448\u043a\u0430 \u043f\u0440\u043e\u0434\u044a\u043b\u0436\u0430\u0432\u0430, \u0437\u0430\u0434\u0430\u0439\u0442\u0435 \u0441\u0442\u0430\u0442\u0438\u0447\u0435\u043d IP \u0430\u0434\u0440\u0435\u0441: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" + }, + "flow_title": "ESPHome: {name}", + "step": { + "authenticate": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u0430" + }, + "description": "\u041c\u043e\u043b\u044f, \u0432\u044a\u0432\u0435\u0434\u0435\u0442\u0435 \u043f\u0430\u0440\u043e\u043b\u0430\u0442\u0430, \u043a\u043e\u044f\u0442\u043e \u0441\u0442\u0435 \u0437\u0430\u0434\u0430\u043b\u0438 \u0432 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f\u0442\u0430 \u0441\u0438 \u0437\u0430 {name} .", + "title": "\u041f\u0430\u0440\u043e\u043b\u0430" + }, + "discovery_confirm": { + "description": "\u0418\u0441\u043a\u0430\u0442\u0435 \u043b\u0438 \u0434\u0430 \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u0435 ESPHome \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e ` {name} ` \u043a\u044a\u043c Home Assistant?", + "title": "\u041e\u0442\u043a\u0440\u0438\u0442\u043e \u0435 ESPHome \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e" + }, + "user": { + "data": { + "host": "\u0410\u0434\u0440\u0435\u0441", + "port": "\u041f\u043e\u0440\u0442" + }, + "description": "\u041c\u043e\u043b\u044f, \u0432\u044a\u0432\u0435\u0434\u0435\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438\u0442\u0435 \u0437\u0430 \u0432\u0440\u044a\u0437\u043a\u0430 \u0441 [ESPHome](https://esphomelib.com/).", + "title": "ESPHome" + } + }, + "title": "ESPHome" + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/.translations/ca.json b/homeassistant/components/esphome/.translations/ca.json new file mode 100644 index 0000000000000..2e6f8dc62ad9b --- /dev/null +++ b/homeassistant/components/esphome/.translations/ca.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "ESP ja est\u00e0 configurat" + }, + "error": { + "connection_error": "No s'ha pogut connectar amb ESP. Verifica que l'arxiu YAML cont\u00e9 la l\u00ednia 'api:'.", + "invalid_password": "Contrasenya inv\u00e0lida!", + "resolve_error": "No s'ha pogut trobar l'adre\u00e7a de l'ESP. Si l'error persisteix, configura una adre\u00e7a IP est\u00e0tica: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" + }, + "flow_title": "ESPHome: {name}", + "step": { + "authenticate": { + "data": { + "password": "Contrasenya" + }, + "description": "Introdueix la contrasenya que has posat en la teva configuraci\u00f3 com a {name}.", + "title": "Introdueix la contrasenya" + }, + "discovery_confirm": { + "description": "Vols afegir el node `{name}` d'ESPHome a Home Assistant?", + "title": "Node d'ESPHome descobert" + }, + "user": { + "data": { + "host": "Amfitri\u00f3", + "port": "Port" + }, + "description": "Introdueix la informaci\u00f3 de connexi\u00f3 del teu node [ESPHome](https://esphomelib.com/).", + "title": "ESPHome" + } + }, + "title": "ESPHome" + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/.translations/cs.json b/homeassistant/components/esphome/.translations/cs.json new file mode 100644 index 0000000000000..081275d3defc9 --- /dev/null +++ b/homeassistant/components/esphome/.translations/cs.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "authenticate": { + "description": "Zadejte heslo, kter\u00e9 jste nastavili ve va\u0161\u00ed konfiguraci pro {name} ." + }, + "discovery_confirm": { + "description": "Chcete do domovsk\u00e9ho asistenta p\u0159idat uzel ESPHome `{name}`?", + "title": "Nalezen uzel ESPHome" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/.translations/da.json b/homeassistant/components/esphome/.translations/da.json new file mode 100644 index 0000000000000..db4b4362a5e0f --- /dev/null +++ b/homeassistant/components/esphome/.translations/da.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "ESP er allerede konfigureret" + }, + "error": { + "connection_error": "Kan ikke oprette forbindelse til ESP. S\u00f8rg for, at din YAML-fil indeholder en 'api:' linje.", + "invalid_password": "Ugyldig adgangskode!", + "resolve_error": "Kan ikke finde adressen p\u00e5 ESP. Hvis denne fejl forts\u00e6tter skal du angive en statisk IP-adresse: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" + }, + "flow_title": "ESPHome: {name}", + "step": { + "authenticate": { + "data": { + "password": "Adgangskode" + }, + "description": "Indtast venligst den adgangskode du har angivet i din konfiguration for {name}.", + "title": "Indtast adgangskode" + }, + "discovery_confirm": { + "description": "Vil du tilf\u00f8je ESPHome-knudepunkt `{name}` til Home Assistant?", + "title": "Fandt ESPHome-knudepunkt" + }, + "user": { + "data": { + "host": "V\u00e6rt", + "port": "Port" + }, + "description": "Angiv forbindelsesindstillinger for din [ESPHome](https://esphomelib.com/) node.", + "title": "ESPHome" + } + }, + "title": "ESPHome" + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/.translations/de.json b/homeassistant/components/esphome/.translations/de.json new file mode 100644 index 0000000000000..80111f34984cb --- /dev/null +++ b/homeassistant/components/esphome/.translations/de.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "ESP ist bereits konfiguriert" + }, + "error": { + "connection_error": "Keine Verbindung zum ESP m\u00f6glich. Achten Sie darauf, dass Ihre YAML-Datei eine Zeile 'api:' enth\u00e4lt.", + "invalid_password": "Ung\u00fcltiges Passwort!", + "resolve_error": "Adresse des ESP kann nicht aufgel\u00f6st werden. Wenn dieser Fehler weiterhin besteht, legen Sie eine statische IP-Adresse fest: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" + }, + "flow_title": "ESPHome: {name}", + "step": { + "authenticate": { + "data": { + "password": "Passwort" + }, + "description": "Bitte geben Sie das Passwort der ESPHome-Konfiguration f\u00fcr {name} ein:", + "title": "Passwort eingeben" + }, + "discovery_confirm": { + "description": "Willst du den ESPHome-Knoten `{name}` zu Home Assistant hinzuf\u00fcgen?", + "title": "Gefundener ESPHome-Knoten" + }, + "user": { + "data": { + "host": "Host", + "port": "Port" + }, + "description": "Bitte geben Sie die Verbindungseinstellungen Ihres [ESPHome](https://esphomelib.com/)-Knotens ein.", + "title": "ESPHome" + } + }, + "title": "ESPHome" + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/.translations/en.json b/homeassistant/components/esphome/.translations/en.json new file mode 100644 index 0000000000000..f5236d1735dcc --- /dev/null +++ b/homeassistant/components/esphome/.translations/en.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "ESP is already configured" + }, + "error": { + "connection_error": "Can't connect to ESP. Please make sure your YAML file contains an 'api:' line.", + "invalid_password": "Invalid password!", + "resolve_error": "Can't resolve address of the ESP. If this error persists, please set a static IP address: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" + }, + "flow_title": "ESPHome: {name}", + "step": { + "authenticate": { + "data": { + "password": "Password" + }, + "description": "Please enter the password you set in your configuration for {name}.", + "title": "Enter Password" + }, + "discovery_confirm": { + "description": "Do you want to add the ESPHome node `{name}` to Home Assistant?", + "title": "Discovered ESPHome node" + }, + "user": { + "data": { + "host": "Host", + "port": "Port" + }, + "description": "Please enter connection settings of your [ESPHome](https://esphomelib.com/) node.", + "title": "ESPHome" + } + }, + "title": "ESPHome" + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/.translations/es-419.json b/homeassistant/components/esphome/.translations/es-419.json new file mode 100644 index 0000000000000..a0a2d77d48c84 --- /dev/null +++ b/homeassistant/components/esphome/.translations/es-419.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "ESP ya est\u00e1 configurado" + }, + "error": { + "connection_error": "No se puede conectar a ESP. Aseg\u00farese de que su archivo YAML contenga una l\u00ednea 'api:'.", + "invalid_password": "\u00a1Contrase\u00f1a invalida!", + "resolve_error": "No se puede resolver la direcci\u00f3n de la ESP. Si este error persiste, configure una direcci\u00f3n IP est\u00e1tica: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" + }, + "flow_title": "ESPHome: {name}", + "step": { + "authenticate": { + "data": { + "password": "Contrase\u00f1a" + }, + "description": "Por favor ingrese la contrase\u00f1a que estableci\u00f3 en su configuraci\u00f3n para {name} .", + "title": "Escriba la contrase\u00f1a" + }, + "discovery_confirm": { + "description": "\u00bfDesea agregar el nodo ESPHome `{name}` a Home Assistant?", + "title": "Nodo ESPHome descubierto" + }, + "user": { + "data": { + "host": "Host", + "port": "Puerto" + }, + "description": "Por favor Ingrese la configuraci\u00f3n de conexi\u00f3n de su nodo [ESPHome] (https://esphomelib.com/).", + "title": "ESPHome" + } + }, + "title": "ESPHome" + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/.translations/es.json b/homeassistant/components/esphome/.translations/es.json new file mode 100644 index 0000000000000..be8033f316a2c --- /dev/null +++ b/homeassistant/components/esphome/.translations/es.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "ESP ya est\u00e1 configurado" + }, + "error": { + "connection_error": "No se puede conectar a ESP. Aseg\u00farate de que tu archivo YAML contenga una l\u00ednea 'api:'.", + "invalid_password": "\u00a1Contrase\u00f1a incorrecta!", + "resolve_error": "No se puede resolver la direcci\u00f3n de ESP. Si el error persiste, configura una direcci\u00f3n IP est\u00e1tica: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" + }, + "flow_title": "ESPHome: {name}", + "step": { + "authenticate": { + "data": { + "password": "Contrase\u00f1a" + }, + "description": "Escribe la contrase\u00f1a que hayas puesto en la configuraci\u00f3n para {name}.", + "title": "Escribe la contrase\u00f1a" + }, + "discovery_confirm": { + "description": "\u00bfQuieres a\u00f1adir el nodo `{name}` de ESPHome a Home Assistant?", + "title": "Nodo ESPHome descubierto" + }, + "user": { + "data": { + "host": "Host", + "port": "Puerto" + }, + "description": "Introduce la configuraci\u00f3n de la conexi\u00f3n de tu nodo [ESPHome](https://esphomelib.com/).", + "title": "ESPHome" + } + }, + "title": "ESPHome" + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/.translations/fr.json b/homeassistant/components/esphome/.translations/fr.json new file mode 100644 index 0000000000000..26fa4ec0bd46d --- /dev/null +++ b/homeassistant/components/esphome/.translations/fr.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "ESP est d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "connection_error": "Impossible de se connecter \u00e0 ESP. Assurez-vous que votre fichier YAML contient une ligne 'api:'.", + "invalid_password": "Mot de passe invalide !", + "resolve_error": "Impossible de r\u00e9soudre l'adresse de l'ESP. Si cette erreur persiste, veuillez d\u00e9finir une adresse IP statique: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" + }, + "flow_title": "ESPHome: {name}", + "step": { + "authenticate": { + "data": { + "password": "Mot de passe" + }, + "description": "Veuillez saisir le mot de passe que vous avez d\u00e9fini dans votre configuration pour {name}", + "title": "Entrer votre mot de passe" + }, + "discovery_confirm": { + "description": "Voulez-vous ajouter le n\u0153ud ESPHome ` {name} ` \u00e0 Home Assistant?", + "title": "N\u0153ud ESPHome d\u00e9couvert" + }, + "user": { + "data": { + "host": "H\u00f4te", + "port": "Port" + }, + "description": "Veuillez saisir les param\u00e8tres de connexion de votre n\u0153ud [ESPHome] (https://esphomelib.com/).", + "title": "ESPHome" + } + }, + "title": "ESPHome" + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/.translations/hu.json b/homeassistant/components/esphome/.translations/hu.json new file mode 100644 index 0000000000000..628983fec0370 --- /dev/null +++ b/homeassistant/components/esphome/.translations/hu.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "Az ESP-t m\u00e1r konfigur\u00e1ltad." + }, + "error": { + "connection_error": "Nem lehet csatlakozni az ESP-hez. K\u00e9rlek gy\u0151z\u0151dj meg r\u00f3la, hogy a YAML f\u00e1jl tartalmaz egy \"api:\" sort.", + "invalid_password": "\u00c9rv\u00e9nytelen jelsz\u00f3!", + "resolve_error": "Az ESP c\u00edme nem oldhat\u00f3 fel. Ha a hiba tov\u00e1bbra is fenn\u00e1ll, k\u00e9rlek, \u00e1ll\u00edts be egy statikus IP-c\u00edmet: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" + }, + "flow_title": "ESPHome: {name}", + "step": { + "authenticate": { + "data": { + "password": "Jelsz\u00f3" + }, + "description": "K\u00e9rlek, add meg a konfigur\u00e1ci\u00f3ban {name} n\u00e9vhez be\u00e1ll\u00edtott jelsz\u00f3t.", + "title": "Add meg a jelsz\u00f3t" + }, + "discovery_confirm": { + "description": "Szeretn\u00e9d hozz\u00e1adni a(z) `{name}` ESPHome csom\u00f3pontot a Home Assistant-hoz?", + "title": "Felfedezett ESPHome csom\u00f3pont" + }, + "user": { + "data": { + "host": "Hoszt", + "port": "Port" + }, + "description": "K\u00e9rlek, add meg az [ESPHome](https://esphomelib.com/) csom\u00f3pontod kapcsol\u00f3d\u00e1si be\u00e1ll\u00edt\u00e1sait.", + "title": "ESPHome" + } + }, + "title": "ESPHome" + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/.translations/id.json b/homeassistant/components/esphome/.translations/id.json new file mode 100644 index 0000000000000..837d18d27ad06 --- /dev/null +++ b/homeassistant/components/esphome/.translations/id.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "ESP sudah dikonfigurasi" + }, + "step": { + "authenticate": { + "data": { + "password": "Kata kunci" + }, + "description": "Silakan masukkan kata kunci yang Anda atur di konfigurasi Anda.", + "title": "Masukkan kata kunci" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/.translations/it.json b/homeassistant/components/esphome/.translations/it.json new file mode 100644 index 0000000000000..bb77e87f6a1c3 --- /dev/null +++ b/homeassistant/components/esphome/.translations/it.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "ESP \u00e8 gi\u00e0 configurato" + }, + "error": { + "connection_error": "Impossibile connettersi ad ESP. Assicurati che il tuo file YAML contenga una riga \"api:\".", + "invalid_password": "Password non valida!", + "resolve_error": "Impossibile risolvere l'indirizzo dell'ESP. Se questo errore persiste, impostare un indirizzo IP statico: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" + }, + "flow_title": "ESPHome: {name}", + "step": { + "authenticate": { + "data": { + "password": "Password" + }, + "description": "Inserisci la password per {name} che hai impostato nella tua configurazione.", + "title": "Inserisci la password" + }, + "discovery_confirm": { + "description": "Vuoi aggiungere il nodo ESPHome `{name}` a Home Assistant?", + "title": "Trovato nodo ESPHome" + }, + "user": { + "data": { + "host": "Host", + "port": "Porta" + }, + "description": "Inserisci le impostazioni di connessione del tuo nodo [ESPHome] (https://esphomelib.com/).", + "title": "ESPHome" + } + }, + "title": "ESPHome" + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/.translations/ko.json b/homeassistant/components/esphome/.translations/ko.json new file mode 100644 index 0000000000000..4d8068c801b35 --- /dev/null +++ b/homeassistant/components/esphome/.translations/ko.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "ESP \uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "error": { + "connection_error": "ESP \uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. YAML \ud30c\uc77c\uc5d0 'api:' \ub97c \uad6c\uc131\ud588\ub294\uc9c0 \ud655\uc778\ud574\uc8fc\uc138\uc694.", + "invalid_password": "\uc798\ubabb\ub41c \ube44\ubc00\ubc88\ud638", + "resolve_error": "ESP \uc758 \uc8fc\uc18c\ub97c \ud655\uc778\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. \uc774 \uc624\ub958\uac00 \uacc4\uc18d \ubc1c\uc0dd\ud558\uba74 \uace0\uc815 IP \uc8fc\uc18c\ub97c \uc124\uc815\ud574\uc8fc\uc138\uc694: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" + }, + "flow_title": "ESPHome: {name}", + "step": { + "authenticate": { + "data": { + "password": "\ube44\ubc00\ubc88\ud638" + }, + "description": "{name} \uc758 \uad6c\uc131\uc5d0 \uc124\uc815\ud55c \ube44\ubc00\ubc88\ud638\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694.", + "title": "\ube44\ubc00\ubc88\ud638 \uc785\ub825" + }, + "discovery_confirm": { + "description": "Home Assistant \uc5d0 ESPHome node `{name}` \uc744(\ub97c) \ucd94\uac00\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "title": "\ubc1c\uacac\ub41c ESPHome node" + }, + "user": { + "data": { + "host": "\ud638\uc2a4\ud2b8", + "port": "\ud3ec\ud2b8" + }, + "description": "[ESPHome](https://esphomelib.com/) \ub178\ub4dc\uc758 \uc5f0\uacb0 \uad6c\uc131\uc744 \uc785\ub825\ud574\uc8fc\uc138\uc694.", + "title": "ESPHome" + } + }, + "title": "ESPHome" + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/.translations/lb.json b/homeassistant/components/esphome/.translations/lb.json new file mode 100644 index 0000000000000..882b67823ba28 --- /dev/null +++ b/homeassistant/components/esphome/.translations/lb.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "ESP ass scho konfigur\u00e9iert" + }, + "error": { + "connection_error": "Keng Verbindung zum ESP. Iwwerpr\u00e9ift d'Pr\u00e4sens vun der Zeil api: am YAML Fichier.", + "invalid_password": "Ong\u00ebltegt Passwuert!", + "resolve_error": "Kann d'Adresse vum ESP net opl\u00e9isen. Falls d\u00ebse Problem weiderhi besteet dann defin\u00e9iert eng statesch IP Adresse:\nhttps://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" + }, + "flow_title": "ESPHome: {name}", + "step": { + "authenticate": { + "data": { + "password": "Passwuert" + }, + "description": "Gitt d'Passwuert vun \u00e4rer Konfiguratioun an fir {name}.", + "title": "Passwuert aginn" + }, + "discovery_confirm": { + "description": "W\u00ebllt dir den ESPHome Provider `{name}` am 'Home Assistant dob\u00e4isetzen?", + "title": "Entdeckten ESPHome Provider" + }, + "user": { + "data": { + "host": "Apparat", + "port": "Port" + }, + "description": "Gitt Verbindungs Informatioune vun \u00e4rem [ESPHome](https://esphomelib.com/) an.", + "title": "ESPHome" + } + }, + "title": "ESPHome" + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/.translations/nl.json b/homeassistant/components/esphome/.translations/nl.json new file mode 100644 index 0000000000000..a56130b226398 --- /dev/null +++ b/homeassistant/components/esphome/.translations/nl.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "ESP is al geconfigureerd" + }, + "error": { + "connection_error": "Kan geen verbinding maken met ESP. Zorg ervoor dat uw YAML-bestand een regel 'api:' bevat.", + "invalid_password": "Ongeldig wachtwoord!", + "resolve_error": "Kan het adres van de ESP niet vinden. Als deze fout aanhoudt, stel dan een statisch IP-adres in: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" + }, + "flow_title": "ESPHome: {name}", + "step": { + "authenticate": { + "data": { + "password": "Wachtwoord" + }, + "description": "Voer het wachtwoord in dat u in uw configuratie heeft ingesteld voor {name}.", + "title": "Voer wachtwoord in" + }, + "discovery_confirm": { + "description": "Wil je de ESPHome-node `{name}` toevoegen aan de Home Assistant?", + "title": "ESPHome node ontdekt" + }, + "user": { + "data": { + "host": "Host", + "port": "Poort" + }, + "description": "Voer de verbindingsinstellingen in van uw [ESPHome](https://esphomelib.com/) node.", + "title": "ESPHome" + } + }, + "title": "ESPHome" + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/.translations/nn.json b/homeassistant/components/esphome/.translations/nn.json new file mode 100644 index 0000000000000..5e40c8ec5e563 --- /dev/null +++ b/homeassistant/components/esphome/.translations/nn.json @@ -0,0 +1,14 @@ +{ + "config": { + "flow_title": "ESPHome: {name}", + "step": { + "discovery_confirm": { + "title": "Fann ESPhome node" + }, + "user": { + "title": "ESPHome" + } + }, + "title": "ESPHome" + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/.translations/no.json b/homeassistant/components/esphome/.translations/no.json new file mode 100644 index 0000000000000..f7dac2a9d568d --- /dev/null +++ b/homeassistant/components/esphome/.translations/no.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "ESP er allerede konfigurert" + }, + "error": { + "connection_error": "Kan ikke koble til ESP. Kontroller at YAML filen din inneholder en \"api:\" linje.", + "invalid_password": "Ugyldig passord!", + "resolve_error": "Kan ikke l\u00f8se adressen til ESP. Hvis denne feilen vedvarer, m\u00e5 du [angi en statisk IP-adresse](https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips)" + }, + "flow_title": "ESPHome: {name}", + "step": { + "authenticate": { + "data": { + "password": "Passord" + }, + "description": "Vennligst skriv inn passordet du har angitt i din konfigurasjon for {name}.", + "title": "Skriv Inn Passord" + }, + "discovery_confirm": { + "description": "\u00d8nsker du \u00e5 legge ESPHome noden `{name}` til Home Assistant?", + "title": "Oppdaget ESPHome node" + }, + "user": { + "data": { + "host": "Vert", + "port": "Port" + }, + "description": "Vennligst skriv inn tilkoblingsinnstillinger for din [ESPHome](https://esphomelib.com/) node.", + "title": "ESPHome" + } + }, + "title": "ESPHome" + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/.translations/pl.json b/homeassistant/components/esphome/.translations/pl.json new file mode 100644 index 0000000000000..9394b5af543cb --- /dev/null +++ b/homeassistant/components/esphome/.translations/pl.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "ESP jest ju\u017c skonfigurowane" + }, + "error": { + "connection_error": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z ESP. Upewnij si\u0119, \u017ce Tw\u00f3j plik YAML zawiera lini\u0119 'api:'.", + "invalid_password": "Nieprawid\u0142owe has\u0142o!", + "resolve_error": "Nie mo\u017cna rozpozna\u0107 adresu ESP. Je\u015bli ten b\u0142\u0105d b\u0119dzie si\u0119 powtarza\u0142, nale\u017cy ustawi\u0107 statyczny adres IP: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" + }, + "flow_title": "ESPHome: {name}", + "step": { + "authenticate": { + "data": { + "password": "Has\u0142o" + }, + "description": "Wprowad\u017a has\u0142o ustawione w konfiguracji dla {name}.", + "title": "Wprowad\u017a has\u0142o" + }, + "discovery_confirm": { + "description": "Czy chcesz doda\u0107 w\u0119ze\u0142 ESPHome `{name}` do Home Assistant?", + "title": "Znaleziono w\u0119ze\u0142 ESPHome" + }, + "user": { + "data": { + "host": "Host", + "port": "Port" + }, + "description": "Wprowad\u017a ustawienia po\u0142\u0105czenia [ESPHome](https://esphomelib.com/) w\u0119z\u0142a.", + "title": "ESPHome" + } + }, + "title": "ESPHome" + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/.translations/pt-BR.json b/homeassistant/components/esphome/.translations/pt-BR.json new file mode 100644 index 0000000000000..1c2baa9e028c4 --- /dev/null +++ b/homeassistant/components/esphome/.translations/pt-BR.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "O ESP j\u00e1 est\u00e1 configurado" + }, + "error": { + "connection_error": "N\u00e3o \u00e9 poss\u00edvel conectar-se ao ESP. Por favor, verifique se o seu arquivo YAML cont\u00e9m uma linha 'api:'.", + "invalid_password": "Senha inv\u00e1lida!", + "resolve_error": "N\u00e3o \u00e9 poss\u00edvel resolver o endere\u00e7o do ESP. Se este erro persistir, por favor, defina um endere\u00e7o IP est\u00e1tico: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" + }, + "flow_title": "ESPHome: {name}", + "step": { + "authenticate": { + "data": { + "password": "Senha" + }, + "description": "Por favor, digite a senha que voc\u00ea definiu em sua configura\u00e7\u00e3o.", + "title": "Digite a senha" + }, + "discovery_confirm": { + "description": "Voc\u00ea quer adicionar o n\u00f3 ESPHome ` {name} ` ao Home Assistant?", + "title": "N\u00f3 ESPHome descoberto" + }, + "user": { + "data": { + "host": "Host", + "port": "Porta" + }, + "description": "Por favor insira as configura\u00e7\u00f5es de conex\u00e3o de seu n\u00f3 de [ESPHome] (https://esphomelib.com/).", + "title": "ESPHome" + } + }, + "title": "ESPHome" + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/.translations/pt.json b/homeassistant/components/esphome/.translations/pt.json new file mode 100644 index 0000000000000..7e4a85f351486 --- /dev/null +++ b/homeassistant/components/esphome/.translations/pt.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "O ESP j\u00e1 est\u00e1 configurado" + }, + "error": { + "connection_error": "N\u00e3o \u00e9 poss\u00edvel conectar-se ao ESP. Por favor, verifique se o seu arquivo YAML cont\u00e9m uma linha 'api:'.", + "invalid_password": "Palavra-passe inv\u00e1lida", + "resolve_error": "N\u00e3o \u00e9 poss\u00edvel resolver o endere\u00e7o do ESP. Se este erro persistir, defina um endere\u00e7o IP est\u00e1tico: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" + }, + "step": { + "authenticate": { + "data": { + "password": "Palavra-passe" + }, + "description": "Por favor, insira a palavra-passe que colocou na configura\u00e7\u00e3o para {name}", + "title": "Palavra-passe" + }, + "user": { + "data": { + "host": "Servidor", + "port": "Porta" + }, + "description": "Por favor, insira as configura\u00e7\u00f5es de liga\u00e7\u00e3o ao seu n\u00f3 [ESPHome] (https://esphomelib.com/).", + "title": "ESPHome" + } + }, + "title": "ESPHome" + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/.translations/ru.json b/homeassistant/components/esphome/.translations/ru.json new file mode 100644 index 0000000000000..27d223012c092 --- /dev/null +++ b/homeassistant/components/esphome/.translations/ru.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430." + }, + "error": { + "connection_error": "\u041d\u0435 \u0443\u0434\u0430\u0435\u0442\u0441\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a ESP. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u0443\u0431\u0435\u0434\u0438\u0442\u0435\u0441\u044c, \u0447\u0442\u043e \u0412\u0430\u0448 YAML-\u0444\u0430\u0439\u043b \u0441\u043e\u0434\u0435\u0440\u0436\u0438\u0442 \u0441\u0442\u0440\u043e\u043a\u0443 'api:'.", + "invalid_password": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043f\u0430\u0440\u043e\u043b\u044c!", + "resolve_error": "\u041d\u0435 \u0443\u0434\u0430\u0435\u0442\u0441\u044f \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0438\u0442\u044c \u0430\u0434\u0440\u0435\u0441 ESP. \u0415\u0441\u043b\u0438 \u044d\u0442\u0430 \u043e\u0448\u0438\u0431\u043a\u0430 \u043f\u043e\u0432\u0442\u043e\u0440\u044f\u0435\u0442\u0441\u044f, \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 \u0441\u0442\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u0438\u0439 IP-\u0430\u0434\u0440\u0435\u0441: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips." + }, + "flow_title": "ESPHome: {name}", + "step": { + "authenticate": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u044c" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043f\u0430\u0440\u043e\u043b\u044c, \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u0435\u043d\u043d\u044b\u0439 \u0432 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 {name}.", + "title": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043f\u0430\u0440\u043e\u043b\u044c" + }, + "discovery_confirm": { + "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c ESPHome `{name}`?", + "title": "ESPHome" + }, + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "port": "\u041f\u043e\u0440\u0442" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f \u043a \u0412\u0430\u0448\u0435\u043c\u0443 [ESPHome](https://esphomelib.com/).", + "title": "ESPHome" + } + }, + "title": "ESPHome" + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/.translations/sl.json b/homeassistant/components/esphome/.translations/sl.json new file mode 100644 index 0000000000000..5f4e9d3e4c43a --- /dev/null +++ b/homeassistant/components/esphome/.translations/sl.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "ESP je \u017ee konfiguriran" + }, + "error": { + "connection_error": "Ne morem se povezati z ESP. Poskrbite, da va\u0161a datoteka YAML vsebuje vrstico \"api:\".", + "invalid_password": "Neveljavno geslo!", + "resolve_error": "Ne moremo razre\u0161iti naslova ESP. \u010ce se napaka ponovi, prosimo nastavite stati\u010dni IP naslov: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" + }, + "flow_title": "ESPHome: {name}", + "step": { + "authenticate": { + "data": { + "password": "Geslo" + }, + "description": "Vnesite geslo, ki ste ga nastavili v konfiguraciji za {name}.", + "title": "Vnesite geslo" + }, + "discovery_confirm": { + "description": "\u017delite dodati ESPHome vozli\u0161\u010de ` {name} ` v Home Assistant?", + "title": "Odkrita ESPHome vozli\u0161\u010da" + }, + "user": { + "data": { + "host": "Gostitelj", + "port": "Vrata" + }, + "description": "Prosimo, vnesite nastavitve povezave va\u0161ega vozli\u0161\u010da [ESPHome] (https://esphomelib.com/).", + "title": "ESPHome" + } + }, + "title": "ESPHome" + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/.translations/sv.json b/homeassistant/components/esphome/.translations/sv.json new file mode 100644 index 0000000000000..37788522e4f69 --- /dev/null +++ b/homeassistant/components/esphome/.translations/sv.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "ESP \u00e4r redan konfigurerad" + }, + "error": { + "connection_error": "Kan inte ansluta till ESP. Se till att din YAML-fil inneh\u00e5ller en 'api:' line.", + "invalid_password": "Ogiltigt l\u00f6senord!", + "resolve_error": "Det g\u00e5r inte att hitta IP-adressen f\u00f6r ESP med DNS-namnet. Om det h\u00e4r felet kvarst\u00e5r anger du en statisk IP-adress: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" + }, + "flow_title": "ESPHome: {name}", + "step": { + "authenticate": { + "data": { + "password": "L\u00f6senord" + }, + "description": "Ange det l\u00f6senord du angett i din konfiguration f\u00f6r {name}.", + "title": "Ange l\u00f6senord" + }, + "discovery_confirm": { + "description": "Vill du l\u00e4gga till ESPHome noden ` {name} ` till Home Assistant?", + "title": "Uppt\u00e4ckt ESPHome-nod" + }, + "user": { + "data": { + "host": "V\u00e4rddatorn", + "port": "Port" + }, + "description": "Ange anslutningsinst\u00e4llningarna f\u00f6r noden [ESPHome](https://esphomelib.com/).", + "title": "ESPHome" + } + }, + "title": "ESPHome" + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/.translations/th.json b/homeassistant/components/esphome/.translations/th.json new file mode 100644 index 0000000000000..ceab9b6e11b78 --- /dev/null +++ b/homeassistant/components/esphome/.translations/th.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "invalid_password": "\u0e23\u0e2b\u0e31\u0e2a\u0e1c\u0e48\u0e32\u0e19\u0e44\u0e21\u0e48\u0e16\u0e39\u0e01\u0e15\u0e49\u0e2d\u0e07!" + }, + "step": { + "authenticate": { + "data": { + "password": "\u0e23\u0e2b\u0e31\u0e2a\u0e1c\u0e48\u0e32\u0e19" + }, + "title": "\u0e43\u0e2a\u0e48\u0e23\u0e2b\u0e31\u0e2a\u0e1c\u0e48\u0e32\u0e19" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/.translations/uk.json b/homeassistant/components/esphome/.translations/uk.json new file mode 100644 index 0000000000000..79c9e70bcc84d --- /dev/null +++ b/homeassistant/components/esphome/.translations/uk.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured": "ESP \u0432\u0436\u0435 \u043d\u0430\u043b\u0430\u0448\u0442\u043e\u0432\u0430\u043d\u043e" + }, + "error": { + "connection_error": "\u041d\u0435 \u0432\u0434\u0430\u0454\u0442\u044c\u0441\u044f \u043f\u0456\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u0438\u0441\u044f \u0434\u043e ESP. \u041f\u0435\u0440\u0435\u043a\u043e\u043d\u0430\u0439\u0442\u0435\u0441\u044f, \u0449\u043e \u0444\u0430\u0439\u043b YAML \u043c\u0456\u0441\u0442\u0438\u0442\u044c \u0440\u044f\u0434\u043e\u043a \"api:\".", + "invalid_password": "\u041d\u0435\u0432\u0456\u0440\u043d\u0438\u0439 \u043f\u0430\u0440\u043e\u043b\u044c!", + "resolve_error": "\u041d\u0435\u043c\u043e\u0436\u043b\u0438\u0432\u043e \u043e\u0442\u0440\u0438\u043c\u0430\u0442\u0438 \u0430\u0434\u0440\u0435\u0441\u0443 ESP. \u042f\u043a\u0449\u043e \u0446\u044f \u043f\u043e\u043c\u0438\u043b\u043a\u0430 \u043d\u0435 \u0437\u043d\u0438\u043a\u0430\u0454, \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0456\u0442\u044c \u0441\u0442\u0430\u0442\u0438\u0447\u043d\u0443 IP-\u0430\u0434\u0440\u0435\u0441\u0443: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" + }, + "step": { + "authenticate": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u044c" + }, + "description": "\u0412\u0432\u0435\u0434\u0456\u0442\u044c \u043f\u0430\u0440\u043e\u043b\u044c, \u044f\u043a\u0438\u0439 \u0432\u0438 \u0432\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u043b\u0438 \u0443 \u0441\u0432\u043e\u0457\u0439 \u043a\u043e\u043d\u0444\u0456\u0433\u0443\u0440\u0430\u0446\u0456\u0457.", + "title": "\u0412\u0432\u0435\u0434\u0456\u0442\u044c \u043f\u0430\u0440\u043e\u043b\u044c" + }, + "discovery_confirm": { + "description": "\u0414\u043e\u0434\u0430\u0442\u0438 ESPHome \u0432\u0443\u0437\u043e\u043b {name} \u0443 Home Assistant?", + "title": "\u0412\u0438\u044f\u0432\u043b\u0435\u043d\u043e \u0432\u0443\u0437\u043e\u043b ESPHome" + }, + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "port": "\u041f\u043e\u0440\u0442" + }, + "description": "\u0412\u0432\u0435\u0434\u0456\u0442\u044c \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0438 \u043f\u0456\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u043d\u044f \u0432\u0430\u0448\u043e\u0433\u043e \u0432\u0443\u0437\u043b\u0430 [ESPHome] (https://esphomelib.com/)." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/.translations/zh-Hans.json b/homeassistant/components/esphome/.translations/zh-Hans.json new file mode 100644 index 0000000000000..46790868aba61 --- /dev/null +++ b/homeassistant/components/esphome/.translations/zh-Hans.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "ESP \u5df2\u914d\u7f6e\u5b8c\u6210" + }, + "error": { + "connection_error": "\u65e0\u6cd5\u8fde\u63a5\u5230 ESP\u3002\u8bf7\u786e\u8ba4\u60a8\u7684 YAML \u6587\u4ef6\u4e2d\u5305\u542b 'api:' \u884c\u3002", + "invalid_password": "\u65e0\u6548\u7684\u5bc6\u7801\uff01", + "resolve_error": "\u65e0\u6cd5\u89e3\u6790 ESP \u7684\u5730\u5740\u3002\u5982\u679c\u6b64\u9519\u8bef\u6301\u7eed\u5b58\u5728\uff0c\u8bf7\u8bbe\u7f6e\u9759\u6001IP\u5730\u5740\uff1ahttps://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" + }, + "step": { + "authenticate": { + "data": { + "password": "\u5bc6\u7801" + }, + "description": "\u8bf7\u8f93\u5165\u60a8\u5728\u914d\u7f6e\u4e2d\u4e3a\u201c{name}\u201d\u8bbe\u7f6e\u7684\u5bc6\u7801\u3002", + "title": "\u8f93\u5165\u5bc6\u7801" + }, + "discovery_confirm": { + "description": "\u662f\u5426\u8981\u5c06 ESPHome \u8282\u70b9 `{name}` \u6dfb\u52a0\u5230 Home Assistant\uff1f", + "title": "\u53d1\u73b0\u4e86 ESPHome \u8282\u70b9" + }, + "user": { + "data": { + "host": "\u4e3b\u673a", + "port": "\u7aef\u53e3" + }, + "description": "\u8bf7\u8f93\u5165\u60a8\u7684 [ESPHome](https://esphomelib.com/) \u8282\u70b9\u7684\u8fde\u63a5\u8bbe\u7f6e\u3002", + "title": "ESPHome" + } + }, + "title": "ESPHome" + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/.translations/zh-Hant.json b/homeassistant/components/esphome/.translations/zh-Hant.json new file mode 100644 index 0000000000000..0386fd8c46857 --- /dev/null +++ b/homeassistant/components/esphome/.translations/zh-Hant.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "ESP \u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "connection_error": "\u7121\u6cd5\u9023\u7dda\u81f3 ESP\uff0c\u8acb\u78ba\u5b9a\u60a8\u7684 YAML \u6a94\u6848\u5305\u542b\u300capi:\u300d\u8a2d\u5b9a\u5217\u3002", + "invalid_password": "\u5bc6\u78bc\u7121\u6548\uff01", + "resolve_error": "\u7121\u6cd5\u89e3\u6790 ESP \u4f4d\u5740\uff0c\u5047\u5982\u6b64\u932f\u8aa4\u6301\u7e8c\u767c\u751f\uff0c\u8acb\u53c3\u8003\u8aaa\u660e\u8a2d\u5b9a\u70ba\u975c\u614b\u56fa\u5b9a IP \uff1a https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" + }, + "flow_title": "ESPHome\uff1a{name}", + "step": { + "authenticate": { + "data": { + "password": "\u5bc6\u78bc" + }, + "description": "\u8acb\u8f38\u5165 {name} \u8a2d\u5b9a\u5167\u6240\u8a2d\u5b9a\u4e4b\u5bc6\u78bc\u3002", + "title": "\u8f38\u5165\u5bc6\u78bc" + }, + "discovery_confirm": { + "description": "\u662f\u5426\u8981\u5c07 ESPHome \u7bc0\u9ede `{name}` \u65b0\u589e\u81f3 Home Assistant\uff1f", + "title": "\u767c\u73fe\u5230 ESPHome \u7bc0\u9ede" + }, + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef", + "port": "\u901a\u8a0a\u57e0" + }, + "description": "\u8acb\u8f38\u5165 [ESPHome](https://esphomelib.com/) \u7bc0\u9ede\u9023\u7dda\u8cc7\u8a0a\u3002", + "title": "ESPHome" + } + }, + "title": "ESPHome" + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py new file mode 100644 index 0000000000000..cabba95ea7e48 --- /dev/null +++ b/homeassistant/components/esphome/__init__.py @@ -0,0 +1,594 @@ +"""Support for esphome devices.""" +import asyncio +import logging +import math +from typing import Any, Callable, Dict, List, Optional + +from aioesphomeapi import ( + APIClient, + APIConnectionError, + DeviceInfo, + EntityInfo, + EntityState, + HomeassistantServiceCall, + UserService, + UserServiceArgType, +) +import voluptuous as vol + +from homeassistant import const +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + EVENT_HOMEASSISTANT_STOP, +) +from homeassistant.core import Event, State, callback +from homeassistant.exceptions import TemplateError +from homeassistant.helpers import template +import homeassistant.helpers.config_validation as cv +import homeassistant.helpers.device_registry as dr +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_track_state_change +from homeassistant.helpers.json import JSONEncoder +from homeassistant.helpers.storage import Store +from homeassistant.helpers.template import Template +from homeassistant.helpers.typing import ConfigType, HomeAssistantType + +# Import config flow so that it's added to the registry +from .config_flow import EsphomeFlowHandler # noqa: F401 +from .entry_data import ( + DATA_KEY, + DISPATCHER_ON_DEVICE_UPDATE, + DISPATCHER_ON_LIST, + DISPATCHER_ON_STATE, + DISPATCHER_REMOVE_ENTITY, + DISPATCHER_UPDATE_ENTITY, + RuntimeEntryData, +) + +DOMAIN = "esphome" +_LOGGER = logging.getLogger(__name__) + +STORAGE_KEY = "esphome.{}" +STORAGE_VERSION = 1 + +# No config schema - only configuration entry +CONFIG_SCHEMA = vol.Schema({}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: + """Stub to allow setting up this component. + + Configuration through YAML is not supported at this time. + """ + return True + + +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: + """Set up the esphome component.""" + hass.data.setdefault(DATA_KEY, {}) + + host = entry.data[CONF_HOST] + port = entry.data[CONF_PORT] + password = entry.data[CONF_PASSWORD] + + cli = APIClient( + hass.loop, + host, + port, + password, + client_info=f"Home Assistant {const.__version__}", + ) + + # Store client in per-config-entry hass.data + store = Store( + hass, STORAGE_VERSION, STORAGE_KEY.format(entry.entry_id), encoder=JSONEncoder + ) + entry_data = hass.data[DATA_KEY][entry.entry_id] = RuntimeEntryData( + client=cli, entry_id=entry.entry_id, store=store + ) + + async def on_stop(event: Event) -> None: + """Cleanup the socket client on HA stop.""" + await _cleanup_instance(hass, entry) + + # Use async_listen instead of async_listen_once so that we don't deregister + # the callback twice when shutting down Home Assistant. + # "Unable to remove unknown listener .onetime_listener>" + entry_data.cleanup_callbacks.append( + hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, on_stop) + ) + + @callback + def async_on_state(state: EntityState) -> None: + """Send dispatcher updates when a new state is received.""" + entry_data.async_update_state(hass, state) + + @callback + def async_on_service_call(service: HomeassistantServiceCall) -> None: + """Call service when user automation in ESPHome config is triggered.""" + domain, service_name = service.service.split(".", 1) + service_data = service.data + + if service.data_template: + try: + data_template = { + key: Template(value) for key, value in service.data_template.items() + } + template.attach(hass, data_template) + service_data.update( + template.render_complex(data_template, service.variables) + ) + except TemplateError as ex: + _LOGGER.error("Error rendering data template: %s", ex) + return + + if service.is_event: + # ESPHome uses servicecall packet for both events and service calls + # Ensure the user can only send events of form 'esphome.xyz' + if domain != "esphome": + _LOGGER.error("Can only generate events under esphome domain!") + return + hass.bus.async_fire(service.service, service_data) + else: + hass.async_create_task( + hass.services.async_call( + domain, service_name, service_data, blocking=True + ) + ) + + async def send_home_assistant_state( + entity_id: str, _, new_state: Optional[State] + ) -> None: + """Forward Home Assistant states to ESPHome.""" + if new_state is None: + return + await cli.send_home_assistant_state(entity_id, new_state.state) + + @callback + def async_on_state_subscription(entity_id: str) -> None: + """Subscribe and forward states for requested entities.""" + unsub = async_track_state_change(hass, entity_id, send_home_assistant_state) + entry_data.disconnect_callbacks.append(unsub) + # Send initial state + hass.async_create_task( + send_home_assistant_state(entity_id, None, hass.states.get(entity_id)) + ) + + async def on_login() -> None: + """Subscribe to states and list entities on successful API login.""" + try: + entry_data.device_info = await cli.device_info() + entry_data.available = True + await _async_setup_device_registry(hass, entry, entry_data.device_info) + entry_data.async_update_device_state(hass) + + entity_infos, services = await cli.list_entities_services() + await entry_data.async_update_static_infos(hass, entry, entity_infos) + await _setup_services(hass, entry_data, services) + await cli.subscribe_states(async_on_state) + await cli.subscribe_service_calls(async_on_service_call) + await cli.subscribe_home_assistant_states(async_on_state_subscription) + + hass.async_create_task(entry_data.async_save_to_store()) + except APIConnectionError as err: + _LOGGER.warning("Error getting initial data: %s", err) + # Re-connection logic will trigger after this + await cli.disconnect() + + try_connect = await _setup_auto_reconnect_logic(hass, cli, entry, host, on_login) + + async def complete_setup() -> None: + """Complete the config entry setup.""" + infos, services = await entry_data.async_load_from_store() + await entry_data.async_update_static_infos(hass, entry, infos) + await _setup_services(hass, entry_data, services) + + # Create connection attempt outside of HA's tracked task in order + # not to delay startup. + hass.loop.create_task(try_connect(is_disconnect=False)) + + hass.async_create_task(complete_setup()) + return True + + +async def _setup_auto_reconnect_logic( + hass: HomeAssistantType, cli: APIClient, entry: ConfigEntry, host: str, on_login +): + """Set up the re-connect logic for the API client.""" + + async def try_connect(tries: int = 0, is_disconnect: bool = True) -> None: + """Try connecting to the API client. Will retry if not successful.""" + if entry.entry_id not in hass.data[DOMAIN]: + # When removing/disconnecting manually + return + + data: RuntimeEntryData = hass.data[DOMAIN][entry.entry_id] + for disconnect_cb in data.disconnect_callbacks: + disconnect_cb() + data.disconnect_callbacks = [] + data.available = False + data.async_update_device_state(hass) + + if is_disconnect: + # This can happen often depending on WiFi signal strength. + # So therefore all these connection warnings are logged + # as infos. The "unavailable" logic will still trigger so the + # user knows if the device is not connected. + _LOGGER.info("Disconnected from ESPHome API for %s", host) + + if tries != 0: + # If not first re-try, wait and print message + # Cap wait time at 1 minute. This is because while working on the + # device (e.g. soldering stuff), users don't want to have to wait + # a long time for their device to show up in HA again (this was + # mentioned a lot in early feedback) + # + # In the future another API will be set up so that the ESP can + # notify HA of connectivity directly, but for new we'll use a + # really short reconnect interval. + tries = min(tries, 10) # prevent OverflowError + wait_time = int(round(min(1.8 ** tries, 60.0))) + _LOGGER.info("Trying to reconnect in %s seconds", wait_time) + await asyncio.sleep(wait_time) + + try: + await cli.connect(on_stop=try_connect, login=True) + except APIConnectionError as error: + _LOGGER.info("Can't connect to ESPHome API for %s: %s", host, error) + # Schedule re-connect in event loop in order not to delay HA + # startup. First connect is scheduled in tracked tasks. + data.reconnect_task = hass.loop.create_task( + try_connect(tries + 1, is_disconnect=False) + ) + else: + _LOGGER.info("Successfully connected to %s", host) + hass.async_create_task(on_login()) + + return try_connect + + +async def _async_setup_device_registry( + hass: HomeAssistantType, entry: ConfigEntry, device_info: DeviceInfo +): + """Set up device registry feature for a particular config entry.""" + sw_version = device_info.esphome_version + if device_info.compilation_time: + sw_version += f" ({device_info.compilation_time})" + device_registry = await dr.async_get_registry(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)}, + name=device_info.name, + manufacturer="espressif", + model=device_info.model, + sw_version=sw_version, + ) + + +async def _register_service( + hass: HomeAssistantType, entry_data: RuntimeEntryData, service: UserService +): + service_name = f"{entry_data.device_info.name}_{service.name}" + schema = {} + for arg in service.args: + schema[vol.Required(arg.name)] = { + UserServiceArgType.BOOL: cv.boolean, + UserServiceArgType.INT: vol.Coerce(int), + UserServiceArgType.FLOAT: vol.Coerce(float), + UserServiceArgType.STRING: cv.string, + UserServiceArgType.BOOL_ARRAY: [cv.boolean], + UserServiceArgType.INT_ARRAY: [vol.Coerce(int)], + UserServiceArgType.FLOAT_ARRAY: [vol.Coerce(float)], + UserServiceArgType.STRING_ARRAY: [cv.string], + }[arg.type_] + + async def execute_service(call): + await entry_data.client.execute_service(service, call.data) + + hass.services.async_register( + DOMAIN, service_name, execute_service, vol.Schema(schema) + ) + + +async def _setup_services( + hass: HomeAssistantType, entry_data: RuntimeEntryData, services: List[UserService] +): + old_services = entry_data.services.copy() + to_unregister = [] + to_register = [] + for service in services: + if service.key in old_services: + # Already exists + matching = old_services.pop(service.key) + if matching != service: + # Need to re-register + to_unregister.append(matching) + to_register.append(service) + else: + # New service + to_register.append(service) + + for service in old_services.values(): + to_unregister.append(service) + + entry_data.services = {serv.key: serv for serv in services} + + for service in to_unregister: + service_name = f"{entry_data.device_info.name}_{service.name}" + hass.services.async_remove(DOMAIN, service_name) + + for service in to_register: + await _register_service(hass, entry_data, service) + + +async def _cleanup_instance( + hass: HomeAssistantType, entry: ConfigEntry +) -> RuntimeEntryData: + """Cleanup the esphome client if it exists.""" + data: RuntimeEntryData = hass.data[DATA_KEY].pop(entry.entry_id) + if data.reconnect_task is not None: + data.reconnect_task.cancel() + for disconnect_cb in data.disconnect_callbacks: + disconnect_cb() + for cleanup_callback in data.cleanup_callbacks: + cleanup_callback() + await data.client.disconnect() + return data + + +async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: + """Unload an esphome config entry.""" + entry_data = await _cleanup_instance(hass, entry) + tasks = [] + for platform in entry_data.loaded_platforms: + tasks.append(hass.config_entries.async_forward_entry_unload(entry, platform)) + if tasks: + await asyncio.wait(tasks) + return True + + +async def platform_async_setup_entry( + hass: HomeAssistantType, + entry: ConfigEntry, + async_add_entities, + *, + component_key: str, + info_type, + entity_type, + state_type, +) -> None: + """Set up an esphome platform. + + This method is in charge of receiving, distributing and storing + info and state updates. + """ + entry_data: RuntimeEntryData = hass.data[DOMAIN][entry.entry_id] + entry_data.info[component_key] = {} + entry_data.old_info[component_key] = {} + entry_data.state[component_key] = {} + + @callback + def async_list_entities(infos: List[EntityInfo]): + """Update entities of this platform when entities are listed.""" + old_infos = entry_data.info[component_key] + new_infos = {} + add_entities = [] + for info in infos: + if not isinstance(info, info_type): + # Filter out infos that don't belong to this platform. + continue + + if info.key in old_infos: + # Update existing entity + old_infos.pop(info.key) + else: + # Create new entity + entity = entity_type(entry.entry_id, component_key, info.key) + add_entities.append(entity) + new_infos[info.key] = info + + # Remove old entities + for info in old_infos.values(): + entry_data.async_remove_entity(hass, component_key, info.key) + + # First copy the now-old info into the backup object + entry_data.old_info[component_key] = entry_data.info[component_key] + # Then update the actual info + entry_data.info[component_key] = new_infos + + # Add entities to Home Assistant + async_add_entities(add_entities) + + signal = DISPATCHER_ON_LIST.format(entry_id=entry.entry_id) + entry_data.cleanup_callbacks.append( + async_dispatcher_connect(hass, signal, async_list_entities) + ) + + @callback + def async_entity_state(state: EntityState): + """Notify the appropriate entity of an updated state.""" + if not isinstance(state, state_type): + return + entry_data.state[component_key][state.key] = state + entry_data.async_update_entity(hass, component_key, state.key) + + signal = DISPATCHER_ON_STATE.format(entry_id=entry.entry_id) + entry_data.cleanup_callbacks.append( + async_dispatcher_connect(hass, signal, async_entity_state) + ) + + +def esphome_state_property(func): + """Wrap a state property of an esphome entity. + + This checks if the state object in the entity is set, and + prevents writing NAN values to the Home Assistant state machine. + """ + + @property + def _wrapper(self): + # pylint: disable=protected-access + if self._state is None: + return None + val = func(self) + if isinstance(val, float) and math.isnan(val): + # Home Assistant doesn't use NAN values in state machine + # (not JSON serializable) + return None + return val + + return _wrapper + + +class EsphomeEnumMapper: + """Helper class to convert between hass and esphome enum values.""" + + def __init__(self, func: Callable[[], Dict[int, str]]): + """Construct a EsphomeEnumMapper.""" + self._func = func + + def from_esphome(self, value: int) -> str: + """Convert from an esphome int representation to a hass string.""" + return self._func()[value] + + def from_hass(self, value: str) -> int: + """Convert from a hass string to a esphome int representation.""" + inverse = {v: k for k, v in self._func().items()} + return inverse[value] + + +def esphome_map_enum(func: Callable[[], Dict[int, str]]): + """Map esphome int enum values to hass string constants. + + This class has to be used as a decorator. This ensures the aioesphomeapi + import is only happening at runtime. + """ + return EsphomeEnumMapper(func) + + +class EsphomeEntity(Entity): + """Define a generic esphome entity.""" + + def __init__(self, entry_id: str, component_key: str, key: int): + """Initialize.""" + self._entry_id = entry_id + self._component_key = component_key + self._key = key + self._remove_callbacks: List[Callable[[], None]] = [] + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + kwargs = { + "entry_id": self._entry_id, + "component_key": self._component_key, + "key": self._key, + } + self._remove_callbacks.append( + async_dispatcher_connect( + self.hass, + DISPATCHER_UPDATE_ENTITY.format(**kwargs), + self._on_state_update, + ) + ) + + self._remove_callbacks.append( + async_dispatcher_connect( + self.hass, DISPATCHER_REMOVE_ENTITY.format(**kwargs), self.async_remove + ) + ) + + self._remove_callbacks.append( + async_dispatcher_connect( + self.hass, + DISPATCHER_ON_DEVICE_UPDATE.format(**kwargs), + self._on_device_update, + ) + ) + + async def _on_state_update(self) -> None: + """Update the entity state when state or static info changed.""" + self.async_schedule_update_ha_state() + + async def _on_device_update(self) -> None: + """Update the entity state when device info has changed.""" + if self._entry_data.available: + # Don't update the HA state yet when the device comes online. + # Only update the HA state when the full state arrives + # through the next entity state packet. + return + self.async_schedule_update_ha_state() + + async def async_will_remove_from_hass(self) -> None: + """Unregister callbacks.""" + for remove_callback in self._remove_callbacks: + remove_callback() + self._remove_callbacks = [] + + @property + def _entry_data(self) -> RuntimeEntryData: + return self.hass.data[DATA_KEY][self._entry_id] + + @property + def _static_info(self) -> EntityInfo: + # Check if value is in info database. Use a single lookup. + info = self._entry_data.info[self._component_key].get(self._key) + if info is not None: + return info + # This entity is in the removal project and has been removed from .info + # already, look in old_info + return self._entry_data.old_info[self._component_key].get(self._key) + + @property + def _device_info(self) -> DeviceInfo: + return self._entry_data.device_info + + @property + def _client(self) -> APIClient: + return self._entry_data.client + + @property + def _state(self) -> Optional[EntityState]: + try: + return self._entry_data.state[self._component_key][self._key] + except KeyError: + return None + + @property + def available(self) -> bool: + """Return if the entity is available.""" + device = self._device_info + + if device.has_deep_sleep: + # During deep sleep the ESP will not be connectable (by design) + # For these cases, show it as available + return True + + return self._entry_data.available + + @property + def unique_id(self) -> Optional[str]: + """Return a unique id identifying the entity.""" + if not self._static_info.unique_id: + return None + return self._static_info.unique_id + + @property + def device_info(self) -> Dict[str, Any]: + """Return device registry information for this entity.""" + return { + "connections": {(dr.CONNECTION_NETWORK_MAC, self._device_info.mac_address)} + } + + @property + def name(self) -> str: + """Return the name of the entity.""" + return self._static_info.name + + @property + def should_poll(self) -> bool: + """Disable polling.""" + return False diff --git a/homeassistant/components/esphome/binary_sensor.py b/homeassistant/components/esphome/binary_sensor.py new file mode 100644 index 0000000000000..fe41bb2f7bb6f --- /dev/null +++ b/homeassistant/components/esphome/binary_sensor.py @@ -0,0 +1,58 @@ +"""Support for ESPHome binary sensors.""" +from typing import Optional + +from aioesphomeapi import BinarySensorInfo, BinarySensorState + +from homeassistant.components.binary_sensor import BinarySensorDevice + +from . import EsphomeEntity, platform_async_setup_entry + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up ESPHome binary sensors based on a config entry.""" + await platform_async_setup_entry( + hass, + entry, + async_add_entities, + component_key="binary_sensor", + info_type=BinarySensorInfo, + entity_type=EsphomeBinarySensor, + state_type=BinarySensorState, + ) + + +class EsphomeBinarySensor(EsphomeEntity, BinarySensorDevice): + """A binary sensor implementation for ESPHome.""" + + @property + def _static_info(self) -> BinarySensorInfo: + return super()._static_info + + @property + def _state(self) -> Optional[BinarySensorState]: + return super()._state + + @property + def is_on(self) -> Optional[bool]: + """Return true if the binary sensor is on.""" + if self._static_info.is_status_binary_sensor: + # Status binary sensors indicated connected state. + # So in their case what's usually _availability_ is now state + return self._entry_data.available + if self._state is None: + return None + if self._state.missing_state: + return None + return self._state.state + + @property + def device_class(self) -> str: + """Return the class of this device, from component DEVICE_CLASSES.""" + return self._static_info.device_class + + @property + def available(self) -> bool: + """Return True if entity is available.""" + if self._static_info.is_status_binary_sensor: + return True + return super().available diff --git a/homeassistant/components/esphome/camera.py b/homeassistant/components/esphome/camera.py new file mode 100644 index 0000000000000..c3615c4726d40 --- /dev/null +++ b/homeassistant/components/esphome/camera.py @@ -0,0 +1,82 @@ +"""Support for ESPHome cameras.""" +import asyncio +import logging +from typing import Optional + +from aioesphomeapi import CameraInfo, CameraState + +from homeassistant.components import camera +from homeassistant.components.camera import Camera +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.typing import HomeAssistantType + +from . import EsphomeEntity, platform_async_setup_entry + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: + """Set up esphome cameras based on a config entry.""" + await platform_async_setup_entry( + hass, + entry, + async_add_entities, + component_key="camera", + info_type=CameraInfo, + entity_type=EsphomeCamera, + state_type=CameraState, + ) + + +class EsphomeCamera(Camera, EsphomeEntity): + """A camera implementation for ESPHome.""" + + def __init__(self, entry_id: str, component_key: str, key: int): + """Initialize.""" + Camera.__init__(self) + EsphomeEntity.__init__(self, entry_id, component_key, key) + self._image_cond = asyncio.Condition() + + @property + def _static_info(self) -> CameraInfo: + return super()._static_info + + @property + def _state(self) -> Optional[CameraState]: + return super()._state + + async def _on_state_update(self) -> None: + """Notify listeners of new image when update arrives.""" + await super()._on_state_update() + async with self._image_cond: + self._image_cond.notify_all() + + async def async_camera_image(self) -> Optional[bytes]: + """Return single camera image bytes.""" + if not self.available: + return None + await self._client.request_single_image() + async with self._image_cond: + await self._image_cond.wait() + if not self.available: + return None + return self._state.image[:] + + async def _async_camera_stream_image(self) -> Optional[bytes]: + """Return a single camera image in a stream.""" + if not self.available: + return None + await self._client.request_image_stream() + async with self._image_cond: + await self._image_cond.wait() + if not self.available: + return None + return self._state.image[:] + + async def handle_async_mjpeg_stream(self, request): + """Serve an HTTP MJPEG stream from the camera.""" + return await camera.async_get_still_stream( + request, self._async_camera_stream_image, camera.DEFAULT_CONTENT_TYPE, 0.0 + ) diff --git a/homeassistant/components/esphome/climate.py b/homeassistant/components/esphome/climate.py new file mode 100644 index 0000000000000..960366a8332e7 --- /dev/null +++ b/homeassistant/components/esphome/climate.py @@ -0,0 +1,304 @@ +"""Support for ESPHome climate devices.""" +import logging +from typing import List, Optional + +from aioesphomeapi import ( + ClimateAction, + ClimateFanMode, + ClimateInfo, + ClimateMode, + ClimateState, + ClimateSwingMode, +) + +from homeassistant.components.climate import ClimateDevice +from homeassistant.components.climate.const import ( + ATTR_HVAC_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + CURRENT_HVAC_COOL, + CURRENT_HVAC_DRY, + CURRENT_HVAC_FAN, + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, + CURRENT_HVAC_OFF, + FAN_AUTO, + FAN_DIFFUSE, + FAN_FOCUS, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + FAN_MIDDLE, + FAN_OFF, + FAN_ON, + HVAC_MODE_COOL, + HVAC_MODE_DRY, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_OFF, + PRESET_AWAY, + PRESET_HOME, + SUPPORT_FAN_MODE, + SUPPORT_PRESET_MODE, + SUPPORT_SWING_MODE, + SUPPORT_TARGET_TEMPERATURE, + SUPPORT_TARGET_TEMPERATURE_RANGE, + SWING_BOTH, + SWING_HORIZONTAL, + SWING_OFF, + SWING_VERTICAL, +) +from homeassistant.const import ( + ATTR_TEMPERATURE, + PRECISION_HALVES, + PRECISION_TENTHS, + PRECISION_WHOLE, + TEMP_CELSIUS, +) + +from . import ( + EsphomeEntity, + esphome_map_enum, + esphome_state_property, + platform_async_setup_entry, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up ESPHome climate devices based on a config entry.""" + await platform_async_setup_entry( + hass, + entry, + async_add_entities, + component_key="climate", + info_type=ClimateInfo, + entity_type=EsphomeClimateDevice, + state_type=ClimateState, + ) + + +@esphome_map_enum +def _climate_modes(): + return { + ClimateMode.OFF: HVAC_MODE_OFF, + ClimateMode.AUTO: HVAC_MODE_HEAT_COOL, + ClimateMode.COOL: HVAC_MODE_COOL, + ClimateMode.HEAT: HVAC_MODE_HEAT, + ClimateMode.FAN_ONLY: HVAC_MODE_FAN_ONLY, + ClimateMode.DRY: HVAC_MODE_DRY, + } + + +@esphome_map_enum +def _climate_actions(): + return { + ClimateAction.OFF: CURRENT_HVAC_OFF, + ClimateAction.COOLING: CURRENT_HVAC_COOL, + ClimateAction.HEATING: CURRENT_HVAC_HEAT, + ClimateAction.IDLE: CURRENT_HVAC_IDLE, + ClimateAction.DRYING: CURRENT_HVAC_DRY, + ClimateAction.FAN: CURRENT_HVAC_FAN, + } + + +@esphome_map_enum +def _fan_modes(): + return { + ClimateFanMode.ON: FAN_ON, + ClimateFanMode.OFF: FAN_OFF, + ClimateFanMode.AUTO: FAN_AUTO, + ClimateFanMode.LOW: FAN_LOW, + ClimateFanMode.MEDIUM: FAN_MEDIUM, + ClimateFanMode.HIGH: FAN_HIGH, + ClimateFanMode.MIDDLE: FAN_MIDDLE, + ClimateFanMode.FOCUS: FAN_FOCUS, + ClimateFanMode.DIFFUSE: FAN_DIFFUSE, + } + + +@esphome_map_enum +def _swing_modes(): + return { + ClimateSwingMode.OFF: SWING_OFF, + ClimateSwingMode.BOTH: SWING_BOTH, + ClimateSwingMode.VERTICAL: SWING_VERTICAL, + ClimateSwingMode.HORIZONTAL: SWING_HORIZONTAL, + } + + +class EsphomeClimateDevice(EsphomeEntity, ClimateDevice): + """A climate implementation for ESPHome.""" + + @property + def _static_info(self) -> ClimateInfo: + return super()._static_info + + @property + def _state(self) -> Optional[ClimateState]: + return super()._state + + @property + def precision(self) -> float: + """Return the precision of the climate device.""" + precicions = [PRECISION_WHOLE, PRECISION_HALVES, PRECISION_TENTHS] + for prec in precicions: + if self._static_info.visual_temperature_step >= prec: + return prec + # Fall back to highest precision, tenths + return PRECISION_TENTHS + + @property + def temperature_unit(self) -> str: + """Return the unit of measurement used by the platform.""" + return TEMP_CELSIUS + + @property + def hvac_modes(self) -> List[str]: + """Return the list of available operation modes.""" + return [ + _climate_modes.from_esphome(mode) + for mode in self._static_info.supported_modes + ] + + @property + def fan_modes(self): + """Return the list of available fan modes.""" + return [ + _fan_modes.from_esphome(mode) + for mode in self._static_info.supported_fan_modes + ] + + @property + def preset_modes(self): + """Return preset modes.""" + return [PRESET_AWAY, PRESET_HOME] if self._static_info.supports_away else [] + + @property + def swing_modes(self): + """Return the list of available swing modes.""" + return [ + _swing_modes.from_esphome(mode) + for mode in self._static_info.supported_swing_modes + ] + + @property + def target_temperature_step(self) -> float: + """Return the supported step of target temperature.""" + # Round to one digit because of floating point math + return round(self._static_info.visual_temperature_step, 1) + + @property + def min_temp(self) -> float: + """Return the minimum temperature.""" + return self._static_info.visual_min_temperature + + @property + def max_temp(self) -> float: + """Return the maximum temperature.""" + return self._static_info.visual_max_temperature + + @property + def supported_features(self) -> int: + """Return the list of supported features.""" + features = 0 + if self._static_info.supports_two_point_target_temperature: + features |= SUPPORT_TARGET_TEMPERATURE_RANGE + else: + features |= SUPPORT_TARGET_TEMPERATURE + if self._static_info.supports_away: + features |= SUPPORT_PRESET_MODE + if self._static_info.supported_fan_modes: + features |= SUPPORT_FAN_MODE + if self._static_info.supported_swing_modes: + features |= SUPPORT_SWING_MODE + return features + + # https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property + # pylint: disable=invalid-overridden-method + + @esphome_state_property + def hvac_mode(self) -> Optional[str]: + """Return current operation ie. heat, cool, idle.""" + return _climate_modes.from_esphome(self._state.mode) + + @esphome_state_property + def hvac_action(self) -> Optional[str]: + """Return current action.""" + # HA has no support feature field for hvac_action + if not self._static_info.supports_action: + return None + return _climate_actions.from_esphome(self._state.action) + + @esphome_state_property + def fan_mode(self): + """Return current fan setting.""" + return _fan_modes.from_esphome(self._state.fan_mode) + + @esphome_state_property + def preset_mode(self): + """Return current preset mode.""" + return PRESET_AWAY if self._state.away else PRESET_HOME + + @esphome_state_property + def swing_mode(self): + """Return current swing mode.""" + return _swing_modes.from_esphome(self._state.swing_mode) + + @esphome_state_property + def current_temperature(self) -> Optional[float]: + """Return the current temperature.""" + return self._state.current_temperature + + @esphome_state_property + def target_temperature(self) -> Optional[float]: + """Return the temperature we try to reach.""" + return self._state.target_temperature + + @esphome_state_property + def target_temperature_low(self) -> Optional[float]: + """Return the lowbound target temperature we try to reach.""" + return self._state.target_temperature_low + + @esphome_state_property + def target_temperature_high(self) -> Optional[float]: + """Return the highbound target temperature we try to reach.""" + return self._state.target_temperature_high + + async def async_set_temperature(self, **kwargs) -> None: + """Set new target temperature (and operation mode if set).""" + data = {"key": self._static_info.key} + if ATTR_HVAC_MODE in kwargs: + data["mode"] = _climate_modes.from_hass(kwargs[ATTR_HVAC_MODE]) + if ATTR_TEMPERATURE in kwargs: + data["target_temperature"] = kwargs[ATTR_TEMPERATURE] + if ATTR_TARGET_TEMP_LOW in kwargs: + data["target_temperature_low"] = kwargs[ATTR_TARGET_TEMP_LOW] + if ATTR_TARGET_TEMP_HIGH in kwargs: + data["target_temperature_high"] = kwargs[ATTR_TARGET_TEMP_HIGH] + await self._client.climate_command(**data) + + async def async_set_hvac_mode(self, hvac_mode: str) -> None: + """Set new target operation mode.""" + await self._client.climate_command( + key=self._static_info.key, mode=_climate_modes.from_hass(hvac_mode) + ) + + async def async_set_preset_mode(self, preset_mode): + """Set preset mode.""" + away = preset_mode == PRESET_AWAY + await self._client.climate_command(key=self._static_info.key, away=away) + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set new fan mode.""" + await self._client.climate_command( + key=self._static_info.key, fan_mode=_fan_modes.from_hass(fan_mode) + ) + + async def async_set_swing_mode(self, swing_mode: str) -> None: + """Set new swing mode.""" + await self._client.climate_command( + key=self._static_info.key, swing_mode=_swing_modes.from_hass(swing_mode) + ) diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py new file mode 100644 index 0000000000000..53289799b439e --- /dev/null +++ b/homeassistant/components/esphome/config_flow.py @@ -0,0 +1,175 @@ +"""Config flow to configure esphome component.""" +from collections import OrderedDict +from typing import Optional + +from aioesphomeapi import APIClient, APIConnectionError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.helpers import ConfigType + +from .entry_data import DATA_KEY, RuntimeEntryData + + +@config_entries.HANDLERS.register("esphome") +class EsphomeFlowHandler(config_entries.ConfigFlow): + """Handle a esphome config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + + def __init__(self): + """Initialize flow.""" + self._host: Optional[str] = None + self._port: Optional[int] = None + self._password: Optional[str] = None + + async def async_step_user( + self, user_input: Optional[ConfigType] = None, error: Optional[str] = None + ): + """Handle a flow initialized by the user.""" + if user_input is not None: + return await self._async_authenticate_or_add(user_input) + + fields = OrderedDict() + fields[vol.Required("host", default=self._host or vol.UNDEFINED)] = str + fields[vol.Optional("port", default=self._port or 6053)] = int + + errors = {} + if error is not None: + errors["base"] = error + + return self.async_show_form( + step_id="user", data_schema=vol.Schema(fields), errors=errors + ) + + @property + def _name(self): + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + return self.context.get("name") + + @_name.setter + def _name(self, value): + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + self.context["name"] = value + self.context["title_placeholders"] = {"name": self._name} + + def _set_user_input(self, user_input): + if user_input is None: + return + self._host = user_input["host"] + self._port = user_input["port"] + + async def _async_authenticate_or_add(self, user_input): + self._set_user_input(user_input) + error, device_info = await self.fetch_device_info() + if error is not None: + return await self.async_step_user(error=error) + self._name = device_info.name + + # Only show authentication step if device uses password + if device_info.uses_password: + return await self.async_step_authenticate() + + return self._async_get_entry() + + async def async_step_discovery_confirm(self, user_input=None): + """Handle user-confirmation of discovered node.""" + if user_input is not None: + return await self._async_authenticate_or_add(None) + return self.async_show_form( + step_id="discovery_confirm", description_placeholders={"name": self._name} + ) + + async def async_step_zeroconf(self, user_input: ConfigType): + """Handle zeroconf discovery.""" + # Hostname is format: livingroom.local. + local_name = user_input["hostname"][:-1] + node_name = local_name[: -len(".local")] + address = user_input["properties"].get("address", local_name) + + # Check if already configured + for entry in self._async_current_entries(): + already_configured = False + if entry.data["host"] == address: + # Is this address already configured? + already_configured = True + elif entry.entry_id in self.hass.data.get(DATA_KEY, {}): + # Does a config entry with this name already exist? + data: RuntimeEntryData = self.hass.data[DATA_KEY][entry.entry_id] + # Node names are unique in the network + if data.device_info is not None: + already_configured = data.device_info.name == node_name + + if already_configured: + return self.async_abort(reason="already_configured") + + self._host = address + self._port = user_input["port"] + self._name = node_name + + # Check if flow for this device already in progress + for flow in self._async_in_progress(): + if flow["context"].get("name") == node_name: + return self.async_abort(reason="already_configured") + + return await self.async_step_discovery_confirm() + + def _async_get_entry(self): + return self.async_create_entry( + title=self._name, + data={ + "host": self._host, + "port": self._port, + # The API uses protobuf, so empty string denotes absence + "password": self._password or "", + }, + ) + + async def async_step_authenticate(self, user_input=None, error=None): + """Handle getting password for authentication.""" + if user_input is not None: + self._password = user_input["password"] + error = await self.try_login() + if error: + return await self.async_step_authenticate(error=error) + return self._async_get_entry() + + errors = {} + if error is not None: + errors["base"] = error + + return self.async_show_form( + step_id="authenticate", + data_schema=vol.Schema({vol.Required("password"): str}), + description_placeholders={"name": self._name}, + errors=errors, + ) + + async def fetch_device_info(self): + """Fetch device info from API and return any errors.""" + cli = APIClient(self.hass.loop, self._host, self._port, "") + + try: + await cli.connect() + device_info = await cli.device_info() + except APIConnectionError as err: + if "resolving" in str(err): + return "resolve_error", None + return "connection_error", None + finally: + await cli.disconnect(force=True) + + return None, device_info + + async def try_login(self): + """Try logging in to device and return any errors.""" + cli = APIClient(self.hass.loop, self._host, self._port, self._password) + + try: + await cli.connect(login=True) + except APIConnectionError: + await cli.disconnect(force=True) + return "invalid_password" + + return None diff --git a/homeassistant/components/esphome/cover.py b/homeassistant/components/esphome/cover.py new file mode 100644 index 0000000000000..53014991de80e --- /dev/null +++ b/homeassistant/components/esphome/cover.py @@ -0,0 +1,136 @@ +"""Support for ESPHome covers.""" +import logging +from typing import Optional + +from aioesphomeapi import CoverInfo, CoverOperation, CoverState + +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.config_entries import ConfigEntry +from homeassistant.helpers.typing import HomeAssistantType + +from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: + """Set up ESPHome covers based on a config entry.""" + await platform_async_setup_entry( + hass, + entry, + async_add_entities, + component_key="cover", + info_type=CoverInfo, + entity_type=EsphomeCover, + state_type=CoverState, + ) + + +class EsphomeCover(EsphomeEntity, CoverDevice): + """A cover implementation for ESPHome.""" + + @property + def _static_info(self) -> CoverInfo: + return super()._static_info + + @property + def supported_features(self) -> int: + """Flag supported features.""" + flags = SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_STOP + if self._static_info.supports_position: + flags |= SUPPORT_SET_POSITION + if self._static_info.supports_tilt: + flags |= SUPPORT_OPEN_TILT | SUPPORT_CLOSE_TILT | SUPPORT_SET_TILT_POSITION + return flags + + @property + def device_class(self) -> str: + """Return the class of this device, from component DEVICE_CLASSES.""" + return self._static_info.device_class + + @property + def assumed_state(self) -> bool: + """Return true if we do optimistic updates.""" + return self._static_info.assumed_state + + @property + def _state(self) -> Optional[CoverState]: + return super()._state + + # https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property + # pylint: disable=invalid-overridden-method + + @esphome_state_property + def is_closed(self) -> Optional[bool]: + """Return if the cover is closed or not.""" + # Check closed state with api version due to a protocol change + return self._state.is_closed(self._client.api_version) + + @esphome_state_property + def is_opening(self) -> bool: + """Return if the cover is opening or not.""" + return self._state.current_operation == CoverOperation.IS_OPENING + + @esphome_state_property + def is_closing(self) -> bool: + """Return if the cover is closing or not.""" + return self._state.current_operation == CoverOperation.IS_CLOSING + + @esphome_state_property + def current_cover_position(self) -> Optional[int]: + """Return current position of cover. 0 is closed, 100 is open.""" + if not self._static_info.supports_position: + return None + return round(self._state.position * 100.0) + + @esphome_state_property + def current_cover_tilt_position(self) -> Optional[float]: + """Return current position of cover tilt. 0 is closed, 100 is open.""" + if not self._static_info.supports_tilt: + return None + return self._state.tilt * 100.0 + + async def async_open_cover(self, **kwargs) -> None: + """Open the cover.""" + await self._client.cover_command(key=self._static_info.key, position=1.0) + + async def async_close_cover(self, **kwargs) -> None: + """Close cover.""" + await self._client.cover_command(key=self._static_info.key, position=0.0) + + async def async_stop_cover(self, **kwargs) -> None: + """Stop the cover.""" + await self._client.cover_command(key=self._static_info.key, stop=True) + + async def async_set_cover_position(self, **kwargs) -> None: + """Move the cover to a specific position.""" + await self._client.cover_command( + key=self._static_info.key, position=kwargs[ATTR_POSITION] / 100 + ) + + async def async_open_cover_tilt(self, **kwargs) -> None: + """Open the cover tilt.""" + await self._client.cover_command(key=self._static_info.key, tilt=1.0) + + async def async_close_cover_tilt(self, **kwargs) -> None: + """Close the cover tilt.""" + await self._client.cover_command(key=self._static_info.key, tilt=0.0) + + async def async_set_cover_tilt_position(self, **kwargs) -> None: + """Move the cover tilt to a specific position.""" + await self._client.cover_command( + key=self._static_info.key, tilt=kwargs[ATTR_TILT_POSITION] / 100 + ) diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py new file mode 100644 index 0000000000000..48f1aea2c2ddd --- /dev/null +++ b/homeassistant/components/esphome/entry_data.py @@ -0,0 +1,167 @@ +"""Runtime entry data for ESPHome stored in hass.data.""" +import asyncio +from typing import Any, Callable, Dict, List, Optional, Set, Tuple + +from aioesphomeapi import ( + COMPONENT_TYPE_TO_INFO, + BinarySensorInfo, + CameraInfo, + ClimateInfo, + CoverInfo, + DeviceInfo, + EntityInfo, + EntityState, + FanInfo, + LightInfo, + SensorInfo, + SwitchInfo, + TextSensorInfo, + UserService, +) +import attr + +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.storage import Store +from homeassistant.helpers.typing import HomeAssistantType + +DATA_KEY = "esphome" +DISPATCHER_UPDATE_ENTITY = "esphome_{entry_id}_update_{component_key}_{key}" +DISPATCHER_REMOVE_ENTITY = "esphome_{entry_id}_remove_{component_key}_{key}" +DISPATCHER_ON_LIST = "esphome_{entry_id}_on_list" +DISPATCHER_ON_DEVICE_UPDATE = "esphome_{entry_id}_on_device_update" +DISPATCHER_ON_STATE = "esphome_{entry_id}_on_state" + +# Mapping from ESPHome info type to HA platform +INFO_TYPE_TO_PLATFORM = { + BinarySensorInfo: "binary_sensor", + CameraInfo: "camera", + ClimateInfo: "climate", + CoverInfo: "cover", + FanInfo: "fan", + LightInfo: "light", + SensorInfo: "sensor", + SwitchInfo: "switch", + TextSensorInfo: "sensor", +} + + +@attr.s +class RuntimeEntryData: + """Store runtime data for esphome config entries.""" + + entry_id = attr.ib(type=str) + client = attr.ib(type="APIClient") + store = attr.ib(type=Store) + reconnect_task = attr.ib(type=Optional[asyncio.Task], default=None) + state = attr.ib(type=Dict[str, Dict[str, Any]], factory=dict) + info = attr.ib(type=Dict[str, Dict[str, Any]], factory=dict) + + # A second list of EntityInfo objects + # This is necessary for when an entity is being removed. HA requires + # some static info to be accessible during removal (unique_id, maybe others) + # If an entity can't find anything in the info array, it will look for info here. + old_info = attr.ib(type=Dict[str, Dict[str, Any]], factory=dict) + + services = attr.ib(type=Dict[int, "UserService"], factory=dict) + available = attr.ib(type=bool, default=False) + device_info = attr.ib(type=DeviceInfo, default=None) + cleanup_callbacks = attr.ib(type=List[Callable[[], None]], factory=list) + disconnect_callbacks = attr.ib(type=List[Callable[[], None]], factory=list) + loaded_platforms = attr.ib(type=Set[str], factory=set) + platform_load_lock = attr.ib(type=asyncio.Lock, factory=asyncio.Lock) + + def async_update_entity( + self, hass: HomeAssistantType, component_key: str, key: int + ) -> None: + """Schedule the update of an entity.""" + signal = DISPATCHER_UPDATE_ENTITY.format( + entry_id=self.entry_id, component_key=component_key, key=key + ) + async_dispatcher_send(hass, signal) + + def async_remove_entity( + self, hass: HomeAssistantType, component_key: str, key: int + ) -> None: + """Schedule the removal of an entity.""" + signal = DISPATCHER_REMOVE_ENTITY.format( + entry_id=self.entry_id, component_key=component_key, key=key + ) + async_dispatcher_send(hass, signal) + + async def _ensure_platforms_loaded( + self, hass: HomeAssistantType, entry: ConfigEntry, platforms: Set[str] + ): + async with self.platform_load_lock: + needed = platforms - self.loaded_platforms + tasks = [] + for platform in needed: + tasks.append( + hass.config_entries.async_forward_entry_setup(entry, platform) + ) + if tasks: + await asyncio.wait(tasks) + self.loaded_platforms |= needed + + async def async_update_static_infos( + self, hass: HomeAssistantType, entry: ConfigEntry, infos: List[EntityInfo] + ) -> None: + """Distribute an update of static infos to all platforms.""" + # First, load all platforms + needed_platforms = set() + for info in infos: + for info_type, platform in INFO_TYPE_TO_PLATFORM.items(): + if isinstance(info, info_type): + needed_platforms.add(platform) + break + await self._ensure_platforms_loaded(hass, entry, needed_platforms) + + # Then send dispatcher event + signal = DISPATCHER_ON_LIST.format(entry_id=self.entry_id) + async_dispatcher_send(hass, signal, infos) + + def async_update_state(self, hass: HomeAssistantType, state: EntityState) -> None: + """Distribute an update of state information to all platforms.""" + signal = DISPATCHER_ON_STATE.format(entry_id=self.entry_id) + async_dispatcher_send(hass, signal, state) + + def async_update_device_state(self, hass: HomeAssistantType) -> None: + """Distribute an update of a core device state like availability.""" + signal = DISPATCHER_ON_DEVICE_UPDATE.format(entry_id=self.entry_id) + async_dispatcher_send(hass, signal) + + async def async_load_from_store(self) -> Tuple[List[EntityInfo], List[UserService]]: + """Load the retained data from store and return de-serialized data.""" + restored = await self.store.async_load() + if restored is None: + return [], [] + + self.device_info = _attr_obj_from_dict( + DeviceInfo, **restored.pop("device_info") + ) + infos = [] + for comp_type, restored_infos in restored.items(): + if comp_type not in COMPONENT_TYPE_TO_INFO: + continue + for info in restored_infos: + cls = COMPONENT_TYPE_TO_INFO[comp_type] + infos.append(_attr_obj_from_dict(cls, **info)) + services = [] + for service in restored.get("services", []): + services.append(UserService.from_dict(service)) + return infos, services + + async def async_save_to_store(self) -> None: + """Generate dynamic data to store and save it to the filesystem.""" + store_data = {"device_info": attr.asdict(self.device_info), "services": []} + + for comp_type, infos in self.info.items(): + store_data[comp_type] = [attr.asdict(info) for info in infos.values()] + for service in self.services.values(): + store_data["services"].append(service.to_dict()) + + await self.store.async_save(store_data) + + +def _attr_obj_from_dict(cls, **kwargs): + return cls(**{key: kwargs[key] for key in attr.fields_dict(cls) if key in kwargs}) diff --git a/homeassistant/components/esphome/fan.py b/homeassistant/components/esphome/fan.py new file mode 100644 index 0000000000000..8b9b4b4922cf8 --- /dev/null +++ b/homeassistant/components/esphome/fan.py @@ -0,0 +1,131 @@ +"""Support for ESPHome fans.""" +import logging +from typing import List, Optional + +from aioesphomeapi import FanInfo, FanSpeed, FanState + +from homeassistant.components.fan import ( + SPEED_HIGH, + SPEED_LOW, + SPEED_MEDIUM, + SPEED_OFF, + SUPPORT_OSCILLATE, + SUPPORT_SET_SPEED, + FanEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.typing import HomeAssistantType + +from . import ( + EsphomeEntity, + esphome_map_enum, + esphome_state_property, + platform_async_setup_entry, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: + """Set up ESPHome fans based on a config entry.""" + await platform_async_setup_entry( + hass, + entry, + async_add_entities, + component_key="fan", + info_type=FanInfo, + entity_type=EsphomeFan, + state_type=FanState, + ) + + +@esphome_map_enum +def _fan_speeds(): + return { + FanSpeed.LOW: SPEED_LOW, + FanSpeed.MEDIUM: SPEED_MEDIUM, + FanSpeed.HIGH: SPEED_HIGH, + } + + +class EsphomeFan(EsphomeEntity, FanEntity): + """A fan implementation for ESPHome.""" + + @property + def _static_info(self) -> FanInfo: + return super()._static_info + + @property + def _state(self) -> Optional[FanState]: + return super()._state + + async def async_set_speed(self, speed: str) -> None: + """Set the speed of the fan.""" + if speed == SPEED_OFF: + await self.async_turn_off() + return + + await self._client.fan_command( + self._static_info.key, speed=_fan_speeds.from_hass(speed) + ) + + async def async_turn_on(self, speed: Optional[str] = None, **kwargs) -> None: + """Turn on the fan.""" + if speed == SPEED_OFF: + await self.async_turn_off() + return + data = {"key": self._static_info.key, "state": True} + if speed is not None: + data["speed"] = _fan_speeds.from_hass(speed) + await self._client.fan_command(**data) + + async def async_turn_off(self, **kwargs) -> None: + """Turn off the fan.""" + await self._client.fan_command(key=self._static_info.key, state=False) + + async def async_oscillate(self, oscillating: bool) -> None: + """Oscillate the fan.""" + await self._client.fan_command( + key=self._static_info.key, oscillating=oscillating + ) + + # https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property + # pylint: disable=invalid-overridden-method + + @esphome_state_property + def is_on(self) -> Optional[bool]: + """Return true if the entity is on.""" + return self._state.state + + @esphome_state_property + def speed(self) -> Optional[str]: + """Return the current speed.""" + if not self._static_info.supports_speed: + return None + return _fan_speeds.from_esphome(self._state.speed) + + @esphome_state_property + def oscillating(self) -> None: + """Return the oscillation state.""" + if not self._static_info.supports_oscillation: + return None + return self._state.oscillating + + @property + def speed_list(self) -> Optional[List[str]]: + """Get the list of available speeds.""" + if not self._static_info.supports_speed: + return None + return [SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] + + @property + def supported_features(self) -> int: + """Flag supported features.""" + flags = 0 + if self._static_info.supports_oscillation: + flags |= SUPPORT_OSCILLATE + if self._static_info.supports_speed: + flags |= SUPPORT_SET_SPEED + return flags diff --git a/homeassistant/components/esphome/light.py b/homeassistant/components/esphome/light.py new file mode 100644 index 0000000000000..9a2a0ccd0bca0 --- /dev/null +++ b/homeassistant/components/esphome/light.py @@ -0,0 +1,159 @@ +"""Support for ESPHome lights.""" +import logging +from typing import List, Optional, Tuple + +from aioesphomeapi import LightInfo, LightState + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP, + ATTR_EFFECT, + ATTR_FLASH, + ATTR_HS_COLOR, + ATTR_TRANSITION, + ATTR_WHITE_VALUE, + FLASH_LONG, + FLASH_SHORT, + SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, + SUPPORT_COLOR_TEMP, + SUPPORT_EFFECT, + SUPPORT_FLASH, + SUPPORT_TRANSITION, + SUPPORT_WHITE_VALUE, + Light, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.typing import HomeAssistantType +import homeassistant.util.color as color_util + +from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry + +_LOGGER = logging.getLogger(__name__) + + +FLASH_LENGTHS = {FLASH_SHORT: 2, FLASH_LONG: 10} + + +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: + """Set up ESPHome lights based on a config entry.""" + await platform_async_setup_entry( + hass, + entry, + async_add_entities, + component_key="light", + info_type=LightInfo, + entity_type=EsphomeLight, + state_type=LightState, + ) + + +class EsphomeLight(EsphomeEntity, Light): + """A switch implementation for ESPHome.""" + + @property + def _static_info(self) -> LightInfo: + return super()._static_info + + @property + def _state(self) -> Optional[LightState]: + return super()._state + + # https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property + # pylint: disable=invalid-overridden-method + + @esphome_state_property + def is_on(self) -> Optional[bool]: + """Return true if the switch is on.""" + return self._state.state + + async def async_turn_on(self, **kwargs) -> None: + """Turn the entity on.""" + data = {"key": self._static_info.key, "state": True} + if ATTR_HS_COLOR in kwargs: + hue, sat = kwargs[ATTR_HS_COLOR] + red, green, blue = color_util.color_hsv_to_RGB(hue, sat, 100) + data["rgb"] = (red / 255, green / 255, blue / 255) + if ATTR_FLASH in kwargs: + data["flash_length"] = FLASH_LENGTHS[kwargs[ATTR_FLASH]] + if ATTR_TRANSITION in kwargs: + data["transition_length"] = kwargs[ATTR_TRANSITION] + if ATTR_BRIGHTNESS in kwargs: + data["brightness"] = kwargs[ATTR_BRIGHTNESS] / 255 + if ATTR_COLOR_TEMP in kwargs: + data["color_temperature"] = kwargs[ATTR_COLOR_TEMP] + if ATTR_EFFECT in kwargs: + data["effect"] = kwargs[ATTR_EFFECT] + if ATTR_WHITE_VALUE in kwargs: + data["white"] = kwargs[ATTR_WHITE_VALUE] / 255 + await self._client.light_command(**data) + + async def async_turn_off(self, **kwargs) -> None: + """Turn the entity off.""" + data = {"key": self._static_info.key, "state": False} + if ATTR_FLASH in kwargs: + data["flash_length"] = FLASH_LENGTHS[kwargs[ATTR_FLASH]] + if ATTR_TRANSITION in kwargs: + data["transition_length"] = kwargs[ATTR_TRANSITION] + await self._client.light_command(**data) + + @esphome_state_property + def brightness(self) -> Optional[int]: + """Return the brightness of this light between 0..255.""" + return round(self._state.brightness * 255) + + @esphome_state_property + def hs_color(self) -> Optional[Tuple[float, float]]: + """Return the hue and saturation color value [float, float].""" + return color_util.color_RGB_to_hs( + self._state.red * 255, self._state.green * 255, self._state.blue * 255 + ) + + @esphome_state_property + def color_temp(self) -> Optional[float]: + """Return the CT color value in mireds.""" + return self._state.color_temperature + + @esphome_state_property + def white_value(self) -> Optional[int]: + """Return the white value of this light between 0..255.""" + return round(self._state.white * 255) + + @esphome_state_property + def effect(self) -> Optional[str]: + """Return the current effect.""" + return self._state.effect + + @property + def supported_features(self) -> int: + """Flag supported features.""" + flags = SUPPORT_FLASH + if self._static_info.supports_brightness: + flags |= SUPPORT_BRIGHTNESS + flags |= SUPPORT_TRANSITION + if self._static_info.supports_rgb: + flags |= SUPPORT_COLOR + if self._static_info.supports_white_value: + flags |= SUPPORT_WHITE_VALUE + if self._static_info.supports_color_temperature: + flags |= SUPPORT_COLOR_TEMP + if self._static_info.effects: + flags |= SUPPORT_EFFECT + return flags + + @property + def effect_list(self) -> List[str]: + """Return the list of supported effects.""" + return self._static_info.effects + + @property + def min_mireds(self) -> float: + """Return the coldest color_temp that this light supports.""" + return self._static_info.min_mireds + + @property + def max_mireds(self) -> float: + """Return the warmest color_temp that this light supports.""" + return self._static_info.max_mireds diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json new file mode 100644 index 0000000000000..c3d87bf836d41 --- /dev/null +++ b/homeassistant/components/esphome/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "esphome", + "name": "ESPHome", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/esphome", + "requirements": ["aioesphomeapi==2.6.1"], + "dependencies": [], + "zeroconf": ["_esphomelib._tcp.local."], + "codeowners": ["@OttoWinter"] +} diff --git a/homeassistant/components/esphome/sensor.py b/homeassistant/components/esphome/sensor.py new file mode 100644 index 0000000000000..e50991af6c137 --- /dev/null +++ b/homeassistant/components/esphome/sensor.py @@ -0,0 +1,103 @@ +"""Support for esphome sensors.""" +import logging +import math +from typing import Optional + +from aioesphomeapi import SensorInfo, SensorState, TextSensorInfo, TextSensorState + +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.typing import HomeAssistantType + +from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: + """Set up esphome sensors based on a config entry.""" + await platform_async_setup_entry( + hass, + entry, + async_add_entities, + component_key="sensor", + info_type=SensorInfo, + entity_type=EsphomeSensor, + state_type=SensorState, + ) + await platform_async_setup_entry( + hass, + entry, + async_add_entities, + component_key="text_sensor", + info_type=TextSensorInfo, + entity_type=EsphomeTextSensor, + state_type=TextSensorState, + ) + + +# https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property +# pylint: disable=invalid-overridden-method + + +class EsphomeSensor(EsphomeEntity): + """A sensor implementation for esphome.""" + + @property + def _static_info(self) -> SensorInfo: + return super()._static_info + + @property + def _state(self) -> Optional[SensorState]: + return super()._state + + @property + def icon(self) -> str: + """Return the icon.""" + return self._static_info.icon + + @property + def force_update(self) -> bool: + """Return if this sensor should force a state update.""" + return self._static_info.force_update + + @esphome_state_property + def state(self) -> Optional[str]: + """Return the state of the entity.""" + if math.isnan(self._state.state): + return None + if self._state.missing_state: + return None + return "{:.{prec}f}".format( + self._state.state, prec=self._static_info.accuracy_decimals + ) + + @property + def unit_of_measurement(self) -> str: + """Return the unit the value is expressed in.""" + return self._static_info.unit_of_measurement + + +class EsphomeTextSensor(EsphomeEntity): + """A text sensor implementation for ESPHome.""" + + @property + def _static_info(self) -> "TextSensorInfo": + return super()._static_info + + @property + def _state(self) -> Optional["TextSensorState"]: + return super()._state + + @property + def icon(self) -> str: + """Return the icon.""" + return self._static_info.icon + + @esphome_state_property + def state(self) -> Optional[str]: + """Return the state of the entity.""" + if self._state.missing_state: + return None + return self._state.state diff --git a/homeassistant/components/esphome/services.yaml b/homeassistant/components/esphome/services.yaml new file mode 100644 index 0000000000000..f4c31420f9a89 --- /dev/null +++ b/homeassistant/components/esphome/services.yaml @@ -0,0 +1 @@ +# Empty file, ESPHome services are dynamically created (user-defined services) diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json new file mode 100644 index 0000000000000..3b662441e1396 --- /dev/null +++ b/homeassistant/components/esphome/strings.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "ESP is already configured" + }, + "error": { + "resolve_error": "Can't resolve address of the ESP. If this error persists, please set a static IP address: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips", + "connection_error": "Can't connect to ESP. Please make sure your YAML file contains an 'api:' line.", + "invalid_password": "Invalid password!" + }, + "step": { + "user": { + "data": { + "host": "Host", + "port": "Port" + }, + "description": "Please enter connection settings of your [ESPHome](https://esphomelib.com/) node.", + "title": "ESPHome" + }, + "authenticate": { + "data": { + "password": "Password" + }, + "description": "Please enter the password you set in your configuration for {name}.", + "title": "Enter Password" + }, + "discovery_confirm": { + "description": "Do you want to add the ESPHome node `{name}` to Home Assistant?", + "title": "Discovered ESPHome node" + } + }, + "title": "ESPHome", + "flow_title": "ESPHome: {name}" + } +} diff --git a/homeassistant/components/esphome/switch.py b/homeassistant/components/esphome/switch.py new file mode 100644 index 0000000000000..b52d630e1b471 --- /dev/null +++ b/homeassistant/components/esphome/switch.py @@ -0,0 +1,65 @@ +"""Support for ESPHome switches.""" +import logging +from typing import Optional + +from aioesphomeapi import SwitchInfo, SwitchState + +from homeassistant.components.switch import SwitchDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.typing import HomeAssistantType + +from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: + """Set up ESPHome switches based on a config entry.""" + await platform_async_setup_entry( + hass, + entry, + async_add_entities, + component_key="switch", + info_type=SwitchInfo, + entity_type=EsphomeSwitch, + state_type=SwitchState, + ) + + +class EsphomeSwitch(EsphomeEntity, SwitchDevice): + """A switch implementation for ESPHome.""" + + @property + def _static_info(self) -> SwitchInfo: + return super()._static_info + + @property + def _state(self) -> Optional[SwitchState]: + return super()._state + + @property + def icon(self) -> str: + """Return the icon.""" + return self._static_info.icon + + @property + def assumed_state(self) -> bool: + """Return true if we do optimistic updates.""" + return self._static_info.assumed_state + + # https://github.com/PyCQA/pylint/issues/3150 for @esphome_state_property + # pylint: disable=invalid-overridden-method + @esphome_state_property + def is_on(self) -> Optional[bool]: + """Return true if the switch is on.""" + return self._state.state + + async def async_turn_on(self, **kwargs) -> None: + """Turn the entity on.""" + await self._client.switch_command(self._static_info.key, True) + + async def async_turn_off(self, **kwargs) -> None: + """Turn the entity off.""" + await self._client.switch_command(self._static_info.key, False) diff --git a/homeassistant/components/essent/__init__.py b/homeassistant/components/essent/__init__.py new file mode 100644 index 0000000000000..42e867c6d2144 --- /dev/null +++ b/homeassistant/components/essent/__init__.py @@ -0,0 +1 @@ +"""The Essent component.""" diff --git a/homeassistant/components/essent/manifest.json b/homeassistant/components/essent/manifest.json new file mode 100644 index 0000000000000..914c8f1556fa1 --- /dev/null +++ b/homeassistant/components/essent/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "essent", + "name": "Essent", + "documentation": "https://www.home-assistant.io/integrations/essent", + "requirements": ["PyEssent==0.13"], + "dependencies": [], + "codeowners": ["@TheLastProject"] +} diff --git a/homeassistant/components/essent/sensor.py b/homeassistant/components/essent/sensor.py new file mode 100644 index 0000000000000..b106d9d2ae660 --- /dev/null +++ b/homeassistant/components/essent/sensor.py @@ -0,0 +1,124 @@ +"""Support for Essent API.""" +from datetime import timedelta + +from pyessent import PyEssent +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, ENERGY_KILO_WATT_HOUR +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +SCAN_INTERVAL = timedelta(hours=1) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + {vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string} +) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Essent platform.""" + username = config[CONF_USERNAME] + password = config[CONF_PASSWORD] + + essent = EssentBase(username, password) + meters = [] + for meter in essent.retrieve_meters(): + data = essent.retrieve_meter_data(meter) + for tariff in data["values"]["LVR"].keys(): + meters.append( + EssentMeter( + essent, + meter, + data["type"], + tariff, + data["values"]["LVR"][tariff]["unit"], + ) + ) + + if not meters: + hass.components.persistent_notification.create( + "Couldn't find any meter readings. " + "Please ensure Verbruiks Manager is enabled in Mijn Essent " + "and at least one reading has been logged to Meterstanden.", + title="Essent", + notification_id="essent_notification", + ) + return + + add_devices(meters, True) + + +class EssentBase: + """Essent Base.""" + + def __init__(self, username, password): + """Initialize the Essent API.""" + self._username = username + self._password = password + self._meter_data = {} + + self.update() + + def retrieve_meters(self): + """Retrieve the list of meters.""" + return self._meter_data.keys() + + def retrieve_meter_data(self, meter): + """Retrieve the data for this meter.""" + return self._meter_data[meter] + + @Throttle(timedelta(minutes=30)) + def update(self): + """Retrieve the latest meter data from Essent.""" + essent = PyEssent(self._username, self._password) + eans = essent.get_EANs() + for possible_meter in eans: + meter_data = essent.read_meter(possible_meter, only_last_meter_reading=True) + if meter_data: + self._meter_data[possible_meter] = meter_data + + +class EssentMeter(Entity): + """Representation of Essent measurements.""" + + def __init__(self, essent_base, meter, meter_type, tariff, unit): + """Initialize the sensor.""" + self._state = None + self._essent_base = essent_base + self._meter = meter + self._type = meter_type + self._tariff = tariff + self._unit = unit + + @property + def name(self): + """Return the name of the sensor.""" + return f"Essent {self._type} ({self._tariff})" + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + if self._unit.lower() == "kwh": + return ENERGY_KILO_WATT_HOUR + + return self._unit + + def update(self): + """Fetch the energy usage.""" + # Ensure our data isn't too old + self._essent_base.update() + + # Retrieve our meter + data = self._essent_base.retrieve_meter_data(self._meter) + + # Set our value + self._state = next( + iter(data["values"]["LVR"][self._tariff]["records"].values()) + ) diff --git a/homeassistant/components/etherscan/__init__.py b/homeassistant/components/etherscan/__init__.py new file mode 100644 index 0000000000000..0e983bd6bead8 --- /dev/null +++ b/homeassistant/components/etherscan/__init__.py @@ -0,0 +1 @@ +"""The etherscan component.""" diff --git a/homeassistant/components/etherscan/manifest.json b/homeassistant/components/etherscan/manifest.json new file mode 100644 index 0000000000000..106ec6f1f967b --- /dev/null +++ b/homeassistant/components/etherscan/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "etherscan", + "name": "Etherscan", + "documentation": "https://www.home-assistant.io/integrations/etherscan", + "requirements": ["python-etherscan-api==0.0.3"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/etherscan/sensor.py b/homeassistant/components/etherscan/sensor.py new file mode 100644 index 0000000000000..1c14ce578c16f --- /dev/null +++ b/homeassistant/components/etherscan/sensor.py @@ -0,0 +1,85 @@ +"""Support for Etherscan sensors.""" +from datetime import timedelta + +from pyetherscan import get_balance +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ATTR_ATTRIBUTION, CONF_ADDRESS, CONF_NAME, CONF_TOKEN +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +ATTRIBUTION = "Data provided by etherscan.io" + +CONF_TOKEN_ADDRESS = "token_address" + +SCAN_INTERVAL = timedelta(minutes=5) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_ADDRESS): cv.string, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_TOKEN): cv.string, + vol.Optional(CONF_TOKEN_ADDRESS): cv.string, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Etherscan.io sensors.""" + address = config.get(CONF_ADDRESS) + name = config.get(CONF_NAME) + token = config.get(CONF_TOKEN) + token_address = config.get(CONF_TOKEN_ADDRESS) + + if token: + token = token.upper() + if not name: + name = "%s Balance" % token + if not name: + name = "ETH Balance" + + add_entities([EtherscanSensor(name, address, token, token_address)], True) + + +class EtherscanSensor(Entity): + """Representation of an Etherscan.io sensor.""" + + def __init__(self, name, address, token, token_address): + """Initialize the sensor.""" + self._name = name + self._address = address + self._token_address = token_address + self._token = token + self._state = None + self._unit_of_measurement = self._token or "ETH" + + @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 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.""" + + if self._token_address: + self._state = get_balance(self._address, self._token_address) + elif self._token: + self._state = get_balance(self._address, self._token) + else: + self._state = get_balance(self._address) diff --git a/homeassistant/components/eufy/__init__.py b/homeassistant/components/eufy/__init__.py new file mode 100644 index 0000000000000..eca637ec37183 --- /dev/null +++ b/homeassistant/components/eufy/__init__.py @@ -0,0 +1,82 @@ +"""Support for Eufy devices.""" +import logging + +import lakeside +import voluptuous as vol + +from homeassistant.const import ( + CONF_ACCESS_TOKEN, + CONF_ADDRESS, + CONF_DEVICES, + CONF_NAME, + CONF_PASSWORD, + CONF_TYPE, + CONF_USERNAME, +) +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "eufy" + +DEVICE_SCHEMA = vol.Schema( + { + vol.Required(CONF_ADDRESS): cv.string, + vol.Required(CONF_ACCESS_TOKEN): cv.string, + vol.Required(CONF_TYPE): cv.string, + vol.Optional(CONF_NAME): cv.string, + } +) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Optional(CONF_DEVICES, default=[]): vol.All( + cv.ensure_list, [DEVICE_SCHEMA] + ), + vol.Inclusive(CONF_USERNAME, "authentication"): cv.string, + vol.Inclusive(CONF_PASSWORD, "authentication"): cv.string, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + +EUFY_DISPATCH = { + "T1011": "light", + "T1012": "light", + "T1013": "light", + "T1201": "switch", + "T1202": "switch", + "T1203": "switch", + "T1211": "switch", +} + + +def setup(hass, config): + """Set up Eufy devices.""" + + if CONF_USERNAME in config[DOMAIN] and CONF_PASSWORD in config[DOMAIN]: + data = lakeside.get_devices( + config[DOMAIN][CONF_USERNAME], config[DOMAIN][CONF_PASSWORD] + ) + for device in data: + kind = device["type"] + if kind not in EUFY_DISPATCH: + continue + discovery.load_platform(hass, EUFY_DISPATCH[kind], DOMAIN, device, config) + + for device_info in config[DOMAIN][CONF_DEVICES]: + kind = device_info["type"] + if kind not in EUFY_DISPATCH: + continue + device = {} + device["address"] = device_info["address"] + device["code"] = device_info["access_token"] + device["type"] = device_info["type"] + device["name"] = device_info["name"] + discovery.load_platform(hass, EUFY_DISPATCH[kind], DOMAIN, device, config) + + return True diff --git a/homeassistant/components/eufy/light.py b/homeassistant/components/eufy/light.py new file mode 100644 index 0000000000000..570f690307f22 --- /dev/null +++ b/homeassistant/components/eufy/light.py @@ -0,0 +1,169 @@ +"""Support for Eufy lights.""" +import logging + +import lakeside + +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 homeassistant.util.color import ( + color_temperature_kelvin_to_mired as kelvin_to_mired, + color_temperature_mired_to_kelvin as mired_to_kelvin, +) + +_LOGGER = logging.getLogger(__name__) + +EUFY_MAX_KELVIN = 6500 +EUFY_MIN_KELVIN = 2700 + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up Eufy bulbs.""" + if discovery_info is None: + return + add_entities([EufyLight(discovery_info)], True) + + +class EufyLight(Light): + """Representation of a Eufy light.""" + + def __init__(self, device): + """Initialize the light.""" + + self._temp = None + self._brightness = None + self._hs = None + self._state = None + self._name = device["name"] + self._address = device["address"] + self._code = device["code"] + self._type = device["type"] + self._bulb = lakeside.bulb(self._address, self._code, self._type) + self._colormode = False + if self._type == "T1011": + self._features = SUPPORT_BRIGHTNESS + elif self._type == "T1012": + self._features = SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP + elif self._type == "T1013": + self._features = SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_COLOR + self._bulb.connect() + + def update(self): + """Synchronise state from the bulb.""" + self._bulb.update() + if self._bulb.power: + self._brightness = self._bulb.brightness + self._temp = self._bulb.temperature + if self._bulb.colors: + self._colormode = True + self._hs = color_util.color_RGB_to_hs(*self._bulb.colors) + else: + self._colormode = False + self._state = self._bulb.power + + @property + def unique_id(self): + """Return the ID of this light.""" + return self._address + + @property + def name(self): + """Return the name of the device if any.""" + return self._name + + @property + def is_on(self): + """Return true if device is on.""" + return self._state + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + return int(self._brightness * 255 / 100) + + @property + def min_mireds(self): + """Return minimum supported color temperature.""" + return kelvin_to_mired(EUFY_MAX_KELVIN) + + @property + def max_mireds(self): + """Return maximu supported color temperature.""" + return kelvin_to_mired(EUFY_MIN_KELVIN) + + @property + def color_temp(self): + """Return the color temperature of this light.""" + temp_in_k = int( + EUFY_MIN_KELVIN + (self._temp * (EUFY_MAX_KELVIN - EUFY_MIN_KELVIN) / 100) + ) + return kelvin_to_mired(temp_in_k) + + @property + def hs_color(self): + """Return the color of this light.""" + if not self._colormode: + return None + return self._hs + + @property + def supported_features(self): + """Flag supported features.""" + return self._features + + def turn_on(self, **kwargs): + """Turn the specified light on.""" + brightness = kwargs.get(ATTR_BRIGHTNESS) + colortemp = kwargs.get(ATTR_COLOR_TEMP) + # pylint: disable=invalid-name + hs = kwargs.get(ATTR_HS_COLOR) + + if brightness is not None: + brightness = int(brightness * 100 / 255) + else: + if self._brightness is None: + self._brightness = 100 + brightness = self._brightness + + if colortemp is not None: + self._colormode = False + temp_in_k = mired_to_kelvin(colortemp) + relative_temp = temp_in_k - EUFY_MIN_KELVIN + temp = int(relative_temp * 100 / (EUFY_MAX_KELVIN - EUFY_MIN_KELVIN)) + else: + temp = None + + if hs is not None: + rgb = color_util.color_hsv_to_RGB(hs[0], hs[1], brightness / 255 * 100) + self._colormode = True + elif self._colormode: + rgb = color_util.color_hsv_to_RGB( + self._hs[0], self._hs[1], brightness / 255 * 100 + ) + else: + rgb = None + + try: + self._bulb.set_state( + power=True, brightness=brightness, temperature=temp, colors=rgb + ) + except BrokenPipeError: + self._bulb.connect() + self._bulb.set_state( + power=True, brightness=brightness, temperature=temp, colors=rgb + ) + + def turn_off(self, **kwargs): + """Turn the specified light off.""" + try: + self._bulb.set_state(power=False) + except BrokenPipeError: + self._bulb.connect() + self._bulb.set_state(power=False) diff --git a/homeassistant/components/eufy/manifest.json b/homeassistant/components/eufy/manifest.json new file mode 100644 index 0000000000000..dc9176db7b067 --- /dev/null +++ b/homeassistant/components/eufy/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "eufy", + "name": "eufy", + "documentation": "https://www.home-assistant.io/integrations/eufy", + "requirements": ["lakeside==0.12"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/eufy/switch.py b/homeassistant/components/eufy/switch.py new file mode 100644 index 0000000000000..cbc09f4101c9c --- /dev/null +++ b/homeassistant/components/eufy/switch.py @@ -0,0 +1,66 @@ +"""Support for Eufy switches.""" +import logging + +import lakeside + +from homeassistant.components.switch import SwitchDevice + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up Eufy switches.""" + if discovery_info is None: + return + add_entities([EufySwitch(discovery_info)], True) + + +class EufySwitch(SwitchDevice): + """Representation of a Eufy switch.""" + + def __init__(self, device): + """Initialize the light.""" + + self._state = None + self._name = device["name"] + self._address = device["address"] + self._code = device["code"] + self._type = device["type"] + self._switch = lakeside.switch(self._address, self._code, self._type) + self._switch.connect() + + def update(self): + """Synchronise state from the switch.""" + self._switch.update() + self._state = self._switch.power + + @property + def unique_id(self): + """Return the ID of this light.""" + return self._address + + @property + def name(self): + """Return the name of the device if any.""" + return self._name + + @property + def is_on(self): + """Return true if device is on.""" + return self._state + + def turn_on(self, **kwargs): + """Turn the specified switch on.""" + try: + self._switch.set_state(True) + except BrokenPipeError: + self._switch.connect() + self._switch.set_state(power=True) + + def turn_off(self, **kwargs): + """Turn the specified switch off.""" + try: + self._switch.set_state(False) + except BrokenPipeError: + self._switch.connect() + self._switch.set_state(False) diff --git a/homeassistant/components/everlights/__init__.py b/homeassistant/components/everlights/__init__.py new file mode 100644 index 0000000000000..0b45bdd430b53 --- /dev/null +++ b/homeassistant/components/everlights/__init__.py @@ -0,0 +1 @@ +"""The everlights component.""" diff --git a/homeassistant/components/everlights/light.py b/homeassistant/components/everlights/light.py new file mode 100644 index 0000000000000..f7fa9deffa07f --- /dev/null +++ b/homeassistant/components/everlights/light.py @@ -0,0 +1,170 @@ +"""Support for EverLights lights.""" +from datetime import timedelta +import logging +from typing import Tuple + +import pyeverlights +import voluptuous as vol + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_EFFECT, + ATTR_HS_COLOR, + PLATFORM_SCHEMA, + SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, + SUPPORT_EFFECT, + Light, +) +from homeassistant.const import CONF_HOSTS +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv +import homeassistant.util.color as color_util + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_EVERLIGHTS = SUPPORT_EFFECT | SUPPORT_BRIGHTNESS | SUPPORT_COLOR + +SCAN_INTERVAL = timedelta(minutes=1) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + {vol.Required(CONF_HOSTS): vol.All(cv.ensure_list, [cv.string])} +) + +NAME_FORMAT = "EverLights {} Zone {}" + + +def color_rgb_to_int(red: int, green: int, blue: int) -> int: + """Return a RGB color as an integer.""" + return red * 256 * 256 + green * 256 + blue + + +def color_int_to_rgb(value: int) -> Tuple[int, int, int]: + """Return an RGB tuple from an integer.""" + return (value >> 16, (value >> 8) & 0xFF, value & 0xFF) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the EverLights lights from configuration.yaml.""" + lights = [] + + for ipaddr in config[CONF_HOSTS]: + api = pyeverlights.EverLights(ipaddr, async_get_clientsession(hass)) + + try: + status = await api.get_status() + + effects = await api.get_all_patterns() + + except pyeverlights.ConnectionError: + raise PlatformNotReady + + else: + lights.append(EverLightsLight(api, pyeverlights.ZONE_1, status, effects)) + lights.append(EverLightsLight(api, pyeverlights.ZONE_2, status, effects)) + + async_add_entities(lights) + + +class EverLightsLight(Light): + """Representation of a Flux light.""" + + def __init__(self, api, channel, status, effects): + """Initialize the light.""" + self._api = api + self._channel = channel + self._status = status + self._effects = effects + self._mac = status["mac"] + self._error_reported = False + self._hs_color = [255, 255] + self._brightness = 255 + self._effect = None + self._available = True + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return f"{self._mac}-{self._channel}" + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._available + + @property + def name(self): + """Return the name of the device.""" + return NAME_FORMAT.format(self._mac, self._channel) + + @property + def is_on(self): + """Return true if device is on.""" + return self._status[f"ch{self._channel}Active"] == 1 + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + return self._brightness + + @property + def hs_color(self): + """Return the color property.""" + return self._hs_color + + @property + def effect(self): + """Return the effect property.""" + return self._effect + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_EVERLIGHTS + + @property + def effect_list(self): + """Return the list of supported effects.""" + return self._effects + + async def async_turn_on(self, **kwargs): + """Turn the light on.""" + hs_color = kwargs.get(ATTR_HS_COLOR, self._hs_color) + brightness = kwargs.get(ATTR_BRIGHTNESS, self._brightness) + effect = kwargs.get(ATTR_EFFECT) + + if effect is not None: + colors = await self._api.set_pattern_by_id(self._channel, effect) + + rgb = color_int_to_rgb(colors[0]) + hsv = color_util.color_RGB_to_hsv(*rgb) + hs_color = hsv[:2] + brightness = hsv[2] / 100 * 255 + + else: + rgb = color_util.color_hsv_to_RGB(*hs_color, brightness / 255 * 100) + colors = [color_rgb_to_int(*rgb)] + + await self._api.set_pattern(self._channel, colors) + + self._hs_color = hs_color + self._brightness = brightness + self._effect = effect + + async def async_turn_off(self, **kwargs): + """Turn the light off.""" + await self._api.clear_pattern(self._channel) + + async def async_update(self): + """Synchronize state with control box.""" + try: + self._status = await self._api.get_status() + except pyeverlights.ConnectionError: + if self._available: + _LOGGER.warning("EverLights control box connection lost.") + self._available = False + else: + if not self._available: + _LOGGER.warning("EverLights control box connection restored.") + self._available = True diff --git a/homeassistant/components/everlights/manifest.json b/homeassistant/components/everlights/manifest.json new file mode 100644 index 0000000000000..7ee6378af01b7 --- /dev/null +++ b/homeassistant/components/everlights/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "everlights", + "name": "EverLights", + "documentation": "https://www.home-assistant.io/integrations/everlights", + "requirements": ["pyeverlights==0.1.0"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py new file mode 100644 index 0000000000000..3d903e86e3064 --- /dev/null +++ b/homeassistant/components/evohome/__init__.py @@ -0,0 +1,517 @@ +"""Support for (EMEA/EU-based) Honeywell TCC climate systems. + +Such systems include evohome (multi-zone), and Round Thermostat (single zone). +""" +from datetime import datetime, timedelta +import logging +import re +from typing import Any, Dict, Optional, Tuple + +import aiohttp.client_exceptions +import evohomeasync +import evohomeasync2 +import voluptuous as vol + +from homeassistant.const import ( + CONF_PASSWORD, + CONF_SCAN_INTERVAL, + CONF_USERNAME, + HTTP_SERVICE_UNAVAILABLE, + HTTP_TOO_MANY_REQUESTS, + TEMP_CELSIUS, +) +from homeassistant.core import callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.discovery import async_load_platform +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import ConfigType, HomeAssistantType +import homeassistant.util.dt as dt_util + +from .const import DOMAIN, EVO_FOLLOW, GWS, STORAGE_KEY, STORAGE_VERSION, TCS + +_LOGGER = logging.getLogger(__name__) + +ACCESS_TOKEN = "access_token" +ACCESS_TOKEN_EXPIRES = "access_token_expires" +REFRESH_TOKEN = "refresh_token" +USER_DATA = "user_data" + +CONF_LOCATION_IDX = "location_idx" + +SCAN_INTERVAL_DEFAULT = timedelta(seconds=300) +SCAN_INTERVAL_MINIMUM = timedelta(seconds=60) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_LOCATION_IDX, default=0): cv.positive_int, + vol.Optional( + CONF_SCAN_INTERVAL, default=SCAN_INTERVAL_DEFAULT + ): vol.All(cv.time_period, vol.Range(min=SCAN_INTERVAL_MINIMUM)), + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +def _local_dt_to_aware(dt_naive: datetime) -> datetime: + dt_aware = dt_util.now() + (dt_naive - datetime.now()) + if dt_aware.microsecond >= 500000: + dt_aware += timedelta(seconds=1) + return dt_aware.replace(microsecond=0) + + +def _dt_to_local_naive(dt_aware: datetime) -> datetime: + dt_naive = datetime.now() + (dt_aware - dt_util.now()) + if dt_naive.microsecond >= 500000: + dt_naive += timedelta(seconds=1) + return dt_naive.replace(microsecond=0) + + +def convert_until(status_dict, until_key) -> str: + """Convert datetime string from "%Y-%m-%dT%H:%M:%SZ" to local/aware/isoformat.""" + if until_key in status_dict: # only present for certain modes + dt_utc_naive = dt_util.parse_datetime(status_dict[until_key]) + status_dict[until_key] = dt_util.as_local(dt_utc_naive).isoformat() + + +def convert_dict(dictionary: Dict[str, Any]) -> Dict[str, Any]: + """Recursively convert a dict's keys to snake_case.""" + + def convert_key(key: str) -> str: + """Convert a string to snake_case.""" + string = re.sub(r"[\-\.\s]", "_", str(key)) + return (string[0]).lower() + re.sub( + r"[A-Z]", lambda matched: "_" + matched.group(0).lower(), string[1:] + ) + + return { + (convert_key(k) if isinstance(k, str) else k): ( + convert_dict(v) if isinstance(v, dict) else v + ) + for k, v in dictionary.items() + } + + +def _handle_exception(err) -> bool: + """Return False if the exception can't be ignored.""" + try: + raise err + + except evohomeasync2.AuthenticationError: + _LOGGER.error( + "Failed to authenticate with the vendor's server. " + "Check your network and the vendor's service status page. " + "Also check that your username and password are correct. " + "Message is: %s", + err, + ) + return False + + except aiohttp.ClientConnectionError: + # this appears to be common with Honeywell's servers + _LOGGER.warning( + "Unable to connect with the vendor's server. " + "Check your network and the vendor's service status page. " + "Message is: %s", + err, + ) + return False + + except aiohttp.ClientResponseError: + if err.status == HTTP_SERVICE_UNAVAILABLE: + _LOGGER.warning( + "The vendor says their server is currently unavailable. " + "Check the vendor's service status page." + ) + return False + + if err.status == HTTP_TOO_MANY_REQUESTS: + _LOGGER.warning( + "The vendor's API rate limit has been exceeded. " + "If this message persists, consider increasing the %s.", + CONF_SCAN_INTERVAL, + ) + return False + + raise # we don't expect/handle any other Exceptions + + +async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: + """Create a (EMEA/EU-based) Honeywell evohome system.""" + + async def load_auth_tokens(store) -> Tuple[Dict, Optional[Dict]]: + app_storage = await store.async_load() + tokens = dict(app_storage if app_storage else {}) + + if tokens.pop(CONF_USERNAME, None) != config[DOMAIN][CONF_USERNAME]: + # any tokens wont be valid, and store might be be corrupt + await store.async_save({}) + return ({}, None) + + # evohomeasync2 requires naive/local datetimes as strings + if tokens.get(ACCESS_TOKEN_EXPIRES) is not None: + tokens[ACCESS_TOKEN_EXPIRES] = _dt_to_local_naive( + dt_util.parse_datetime(tokens[ACCESS_TOKEN_EXPIRES]) + ) + + user_data = tokens.pop(USER_DATA, None) + return (tokens, user_data) + + store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + tokens, user_data = await load_auth_tokens(store) + + client_v2 = evohomeasync2.EvohomeClient( + config[DOMAIN][CONF_USERNAME], + config[DOMAIN][CONF_PASSWORD], + **tokens, + session=async_get_clientsession(hass), + ) + + try: + await client_v2.login() + except (aiohttp.ClientError, evohomeasync2.AuthenticationError) as err: + _handle_exception(err) + return False + finally: + config[DOMAIN][CONF_PASSWORD] = "REDACTED" + + loc_idx = config[DOMAIN][CONF_LOCATION_IDX] + try: + loc_config = client_v2.installation_info[loc_idx][GWS][0][TCS][0] + except IndexError: + _LOGGER.error( + "Config error: '%s' = %s, but the valid range is 0-%s. " + "Unable to continue. Fix any configuration errors and restart HA.", + CONF_LOCATION_IDX, + loc_idx, + len(client_v2.installation_info) - 1, + ) + return False + + _LOGGER.debug("Config = %s", loc_config) + + client_v1 = evohomeasync.EvohomeClient( + client_v2.username, + client_v2.password, + user_data=user_data, + session=async_get_clientsession(hass), + ) + + hass.data[DOMAIN] = {} + hass.data[DOMAIN]["broker"] = broker = EvoBroker( + hass, client_v2, client_v1, store, config[DOMAIN] + ) + + await broker.save_auth_tokens() + await broker.update() # get initial state + + hass.async_create_task(async_load_platform(hass, "climate", DOMAIN, {}, config)) + if broker.tcs.hotwater: + hass.async_create_task( + async_load_platform(hass, "water_heater", DOMAIN, {}, config) + ) + + hass.helpers.event.async_track_time_interval( + broker.update, config[DOMAIN][CONF_SCAN_INTERVAL] + ) + + return True + + +class EvoBroker: + """Container for evohome client and data.""" + + def __init__(self, hass, client, client_v1, store, params) -> None: + """Initialize the evohome client and its data structure.""" + self.hass = hass + self.client = client + self.client_v1 = client_v1 + self._store = store + self.params = params + + loc_idx = params[CONF_LOCATION_IDX] + self.config = client.installation_info[loc_idx][GWS][0][TCS][0] + self.tcs = client.locations[loc_idx]._gateways[0]._control_systems[0] + self.temps = None + + async def save_auth_tokens(self) -> None: + """Save access tokens and session IDs to the store for later use.""" + # evohomeasync2 uses naive/local datetimes + access_token_expires = _local_dt_to_aware(self.client.access_token_expires) + + app_storage = {CONF_USERNAME: self.client.username} + app_storage[REFRESH_TOKEN] = self.client.refresh_token + app_storage[ACCESS_TOKEN] = self.client.access_token + app_storage[ACCESS_TOKEN_EXPIRES] = access_token_expires.isoformat() + + if self.client_v1 and self.client_v1.user_data: + app_storage[USER_DATA] = { + "userInfo": {"userID": self.client_v1.user_data["userInfo"]["userID"]}, + "sessionId": self.client_v1.user_data["sessionId"], + } + else: + app_storage[USER_DATA] = None + + await self._store.async_save(app_storage) + + async def _update_v1(self, *args, **kwargs) -> None: + """Get the latest high-precision temperatures of the default Location.""" + + def get_session_id(client_v1) -> Optional[str]: + user_data = client_v1.user_data if client_v1 else None + return user_data.get("sessionId") if user_data else None + + session_id = get_session_id(self.client_v1) + + try: + temps = list(await self.client_v1.temperatures(force_refresh=True)) + + except aiohttp.ClientError as err: + _LOGGER.warning( + "Unable to obtain the latest high-precision temperatures. " + "Check your network and the vendor's service status page. " + "Proceeding with low-precision temperatures. " + "Message is: %s", + err, + ) + self.temps = None # these are now stale, will fall back to v2 temps + + else: + if ( + str(self.client_v1.location_id) + != self.client.locations[self.params[CONF_LOCATION_IDX]].locationId + ): + _LOGGER.warning( + "The v2 API's configured location doesn't match " + "the v1 API's default location (there is more than one location), " + "so the high-precision feature will be disabled" + ) + self.client_v1 = self.temps = None + else: + self.temps = {str(i["id"]): i["temp"] for i in temps} + + _LOGGER.debug("Temperatures = %s", self.temps) + + if session_id != get_session_id(self.client_v1): + await self.save_auth_tokens() + + async def _update_v2(self, *args, **kwargs) -> None: + """Get the latest modes, temperatures, setpoints of a Location.""" + access_token = self.client.access_token + + loc_idx = self.params[CONF_LOCATION_IDX] + try: + status = await self.client.locations[loc_idx].status() + except (aiohttp.ClientError, evohomeasync2.AuthenticationError) as err: + _handle_exception(err) + else: + self.hass.helpers.dispatcher.async_dispatcher_send(DOMAIN) + + _LOGGER.debug("Status = %s", status[GWS][0][TCS][0]) + + if access_token != self.client.access_token: + await self.save_auth_tokens() + + async def update(self, *args, **kwargs) -> None: + """Get the latest state data of an entire evohome Location. + + This includes state data for a Controller and all its child devices, such as the + operating mode of the Controller and the current temp of its children (e.g. + Zones, DHW controller). + """ + await self._update_v2() + + if self.client_v1: + await self._update_v1() + + # inform the evohome devices that state data has been updated + self.hass.helpers.dispatcher.async_dispatcher_send(DOMAIN) + + +class EvoDevice(Entity): + """Base for any evohome device. + + This includes the Controller, (up to 12) Heating Zones and (optionally) a + DHW controller. + """ + + def __init__(self, evo_broker, evo_device) -> None: + """Initialize the evohome entity.""" + self._evo_device = evo_device + self._evo_broker = evo_broker + self._evo_tcs = evo_broker.tcs + + self._unique_id = self._name = self._icon = self._precision = None + self._supported_features = None + self._device_state_attrs = {} + + @callback + def _refresh(self) -> None: + self.async_schedule_update_ha_state(force_refresh=True) + + @property + def should_poll(self) -> bool: + """Evohome entities should not be polled.""" + return False + + @property + def unique_id(self) -> Optional[str]: + """Return a unique ID.""" + return self._unique_id + + @property + def name(self) -> str: + """Return the name of the Evohome entity.""" + return self._name + + @property + def device_state_attributes(self) -> Dict[str, Any]: + """Return the Evohome-specific state attributes.""" + status = self._device_state_attrs + if "systemModeStatus" in status: + convert_until(status["systemModeStatus"], "timeUntil") + if "setpointStatus" in status: + convert_until(status["setpointStatus"], "until") + if "stateStatus" in status: + convert_until(status["stateStatus"], "until") + + return {"status": convert_dict(status)} + + @property + def icon(self) -> str: + """Return the icon to use in the frontend UI.""" + return self._icon + + @property + def supported_features(self) -> int: + """Get the flag of supported features of the device.""" + return self._supported_features + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + self.hass.helpers.dispatcher.async_dispatcher_connect(DOMAIN, self._refresh) + + @property + def precision(self) -> float: + """Return the temperature precision to use in the frontend UI.""" + return self._precision + + @property + def temperature_unit(self) -> str: + """Return the temperature unit to use in the frontend UI.""" + return TEMP_CELSIUS + + async def _call_client_api(self, api_function, refresh=True) -> Any: + try: + result = await api_function + except (aiohttp.ClientError, evohomeasync2.AuthenticationError) as err: + if not _handle_exception(err): + return + + if refresh is True: + self.hass.helpers.event.async_call_later(1, self._evo_broker.update()) + + return result + + +class EvoChild(EvoDevice): + """Base for any evohome child. + + This includes (up to 12) Heating Zones and (optionally) a DHW controller. + """ + + def __init__(self, evo_broker, evo_device) -> None: + """Initialize a evohome Controller (hub).""" + super().__init__(evo_broker, evo_device) + self._schedule = {} + self._setpoints = {} + + @property + def current_temperature(self) -> Optional[float]: + """Return the current temperature of a Zone.""" + if not self._evo_device.temperatureStatus["isAvailable"]: + return None + + if self._evo_broker.temps: + return self._evo_broker.temps[self._evo_device.zoneId] + + return self._evo_device.temperatureStatus["temperature"] + + @property + def setpoints(self) -> Dict[str, Any]: + """Return the current/next setpoints from the schedule. + + Only Zones & DHW controllers (but not the TCS) can have schedules. + """ + if not self._schedule["DailySchedules"]: + return {} # no schedule {'DailySchedules': []}, so no scheduled setpoints + + day_time = dt_util.now() + day_of_week = int(day_time.strftime("%w")) # 0 is Sunday + time_of_day = day_time.strftime("%H:%M:%S") + + try: + # Iterate today's switchpoints until past the current time of day... + day = self._schedule["DailySchedules"][day_of_week] + sp_idx = -1 # last switchpoint of the day before + for i, tmp in enumerate(day["Switchpoints"]): + if time_of_day > tmp["TimeOfDay"]: + sp_idx = i # current setpoint + else: + break + + # Did the current SP start yesterday? Does the next start SP tomorrow? + this_sp_day = -1 if sp_idx == -1 else 0 + next_sp_day = 1 if sp_idx + 1 == len(day["Switchpoints"]) else 0 + + for key, offset, idx in [ + ("this", this_sp_day, sp_idx), + ("next", next_sp_day, (sp_idx + 1) * (1 - next_sp_day)), + ]: + sp_date = (day_time + timedelta(days=offset)).strftime("%Y-%m-%d") + day = self._schedule["DailySchedules"][(day_of_week + offset) % 7] + switchpoint = day["Switchpoints"][idx] + + dt_local_aware = _local_dt_to_aware( + dt_util.parse_datetime(f"{sp_date}T{switchpoint['TimeOfDay']}") + ) + + self._setpoints[f"{key}_sp_from"] = dt_local_aware.isoformat() + try: + self._setpoints[f"{key}_sp_temp"] = switchpoint["heatSetpoint"] + except KeyError: + self._setpoints[f"{key}_sp_state"] = switchpoint["DhwState"] + + except IndexError: + self._setpoints = {} + _LOGGER.warning( + "Failed to get setpoints - please report as an issue", exc_info=True + ) + + return self._setpoints + + async def _update_schedule(self) -> None: + """Get the latest schedule.""" + if "DailySchedules" in self._schedule and not self._schedule["DailySchedules"]: + if not self._evo_device.setpointStatus["setpointMode"] == EVO_FOLLOW: + return # avoid unnecessary I/O - there's nothing to update + + self._schedule = await self._call_client_api( + self._evo_device.schedule(), refresh=False + ) + + _LOGGER.debug("Schedule['%s'] = %s", self.name, self._schedule) + + async def async_update(self) -> None: + """Get the latest state data.""" + next_sp_from = self._setpoints.get("next_sp_from", "2000-01-01T00:00:00+00:00") + if dt_util.now() >= dt_util.parse_datetime(next_sp_from): + await self._update_schedule() # no schedule, or it's out-of-date + + self._device_state_attrs = {"setpoints": self.setpoints} diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py new file mode 100644 index 0000000000000..82a7001539df7 --- /dev/null +++ b/homeassistant/components/evohome/climate.py @@ -0,0 +1,405 @@ +"""Support for Climate devices of (EMEA/EU-based) Honeywell TCC systems.""" +import logging +from typing import List, Optional + +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_AWAY, + PRESET_ECO, + PRESET_HOME, + PRESET_NONE, + SUPPORT_PRESET_MODE, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.const import PRECISION_TENTHS +from homeassistant.helpers.typing import ConfigType, HomeAssistantType +from homeassistant.util.dt import parse_datetime + +from . import CONF_LOCATION_IDX, EvoChild, EvoDevice +from .const import ( + DOMAIN, + EVO_AUTO, + EVO_AUTOECO, + EVO_AWAY, + EVO_CUSTOM, + EVO_DAYOFF, + EVO_FOLLOW, + EVO_HEATOFF, + EVO_PERMOVER, + EVO_RESET, + EVO_TEMPOVER, +) + +_LOGGER = logging.getLogger(__name__) + +PRESET_RESET = "Reset" # reset all child zones to EVO_FOLLOW +PRESET_CUSTOM = "Custom" + +HA_HVAC_TO_TCS = {HVAC_MODE_OFF: EVO_HEATOFF, HVAC_MODE_HEAT: EVO_AUTO} + +TCS_PRESET_TO_HA = { + EVO_AWAY: PRESET_AWAY, + EVO_CUSTOM: PRESET_CUSTOM, + EVO_AUTOECO: PRESET_ECO, + EVO_DAYOFF: PRESET_HOME, + EVO_RESET: PRESET_RESET, +} # EVO_AUTO: None, + +HA_PRESET_TO_TCS = {v: k for k, v in TCS_PRESET_TO_HA.items()} + +EVO_PRESET_TO_HA = { + EVO_FOLLOW: PRESET_NONE, + EVO_TEMPOVER: "temporary", + EVO_PERMOVER: "permanent", +} +HA_PRESET_TO_EVO = {v: k for k, v in EVO_PRESET_TO_HA.items()} + +STATE_ATTRS_TCS = ["systemId", "activeFaults", "systemModeStatus"] +STATE_ATTRS_ZONES = ["zoneId", "activeFaults", "setpointStatus", "temperatureStatus"] + + +async def async_setup_platform( + hass: HomeAssistantType, config: ConfigType, async_add_entities, discovery_info=None +) -> None: + """Create the evohome Controller, and its Zones, if any.""" + if discovery_info is None: + return + + broker = hass.data[DOMAIN]["broker"] + + _LOGGER.debug( + "Found the Location/Controller (%s), id=%s, name=%s (location_idx=%s)", + broker.tcs.modelType, + broker.tcs.systemId, + broker.tcs.location.name, + broker.params[CONF_LOCATION_IDX], + ) + + # special case of RoundModulation/RoundWireless (is a single zone system) + if broker.config["zones"][0]["zoneType"] == "Thermostat": + zone = list(broker.tcs.zones.values())[0] + _LOGGER.debug( + "Found the Thermostat (%s), id=%s, name=%s", + zone.modelType, + zone.zoneId, + zone.name, + ) + + async_add_entities([EvoThermostat(broker, zone)], update_before_add=True) + return + + controller = EvoController(broker, broker.tcs) + + zones = [] + for zone in broker.tcs.zones.values(): + _LOGGER.debug( + "Found a %s (%s), id=%s, name=%s", + zone.zoneType, + zone.modelType, + zone.zoneId, + zone.name, + ) + zones.append(EvoZone(broker, zone)) + + async_add_entities([controller] + zones, update_before_add=True) + + +class EvoClimateDevice(EvoDevice, ClimateDevice): + """Base for a Honeywell evohome Climate device.""" + + def __init__(self, evo_broker, evo_device) -> None: + """Initialize a Climate device.""" + super().__init__(evo_broker, evo_device) + + self._preset_modes = None + + async def _set_tcs_mode(self, op_mode: str) -> None: + """Set a Controller to any of its native EVO_* operating modes.""" + await self._call_client_api( + self._evo_tcs._set_status(op_mode) # pylint: disable=protected-access + ) + + @property + def hvac_modes(self) -> List[str]: + """Return a list of available hvac operation modes.""" + return list(HA_HVAC_TO_TCS) + + @property + def preset_modes(self) -> Optional[List[str]]: + """Return a list of available preset modes.""" + return self._preset_modes + + +class EvoZone(EvoChild, EvoClimateDevice): + """Base for a Honeywell evohome Zone.""" + + def __init__(self, evo_broker, evo_device) -> None: + """Initialize a Zone.""" + super().__init__(evo_broker, evo_device) + + self._unique_id = evo_device.zoneId + self._name = evo_device.name + self._icon = "mdi:radiator" + + self._supported_features = SUPPORT_PRESET_MODE | SUPPORT_TARGET_TEMPERATURE + self._preset_modes = list(HA_PRESET_TO_EVO) + if evo_broker.client_v1: + self._precision = PRECISION_TENTHS + else: + self._precision = self._evo_device.setpointCapabilities["valueResolution"] + + @property + def hvac_mode(self) -> str: + """Return the current operating mode of a Zone.""" + if self._evo_tcs.systemModeStatus["mode"] in [EVO_AWAY, EVO_HEATOFF]: + return HVAC_MODE_AUTO + is_off = self.target_temperature <= self.min_temp + return HVAC_MODE_OFF if is_off else HVAC_MODE_HEAT + + @property + def hvac_action(self) -> Optional[str]: + """Return the current running hvac operation if supported.""" + if self._evo_tcs.systemModeStatus["mode"] == EVO_HEATOFF: + return CURRENT_HVAC_OFF + if self.target_temperature <= self.min_temp: + return CURRENT_HVAC_OFF + if not self._evo_device.temperatureStatus["isAvailable"]: + return None + if self.target_temperature <= self.current_temperature: + return CURRENT_HVAC_IDLE + return CURRENT_HVAC_HEAT + + @property + def target_temperature(self) -> float: + """Return the target temperature of a Zone.""" + return self._evo_device.setpointStatus["targetHeatTemperature"] + + @property + def preset_mode(self) -> Optional[str]: + """Return the current preset mode, e.g., home, away, temp.""" + if self._evo_tcs.systemModeStatus["mode"] in [EVO_AWAY, EVO_HEATOFF]: + return TCS_PRESET_TO_HA.get(self._evo_tcs.systemModeStatus["mode"]) + return EVO_PRESET_TO_HA.get( + self._evo_device.setpointStatus["setpointMode"], "follow" + ) + + @property + def min_temp(self) -> float: + """Return the minimum target temperature of a Zone. + + The default is 5, but is user-configurable within 5-35 (in Celsius). + """ + return self._evo_device.setpointCapabilities["minHeatSetpoint"] + + @property + def max_temp(self) -> float: + """Return the maximum target temperature of a Zone. + + The default is 35, but is user-configurable within 5-35 (in Celsius). + """ + return self._evo_device.setpointCapabilities["maxHeatSetpoint"] + + async def async_set_temperature(self, **kwargs) -> None: + """Set a new target temperature.""" + temperature = kwargs["temperature"] + + if self._evo_device.setpointStatus["setpointMode"] == EVO_FOLLOW: + await self._update_schedule() + until = parse_datetime(str(self.setpoints.get("next_sp_from"))) + elif self._evo_device.setpointStatus["setpointMode"] == EVO_TEMPOVER: + until = parse_datetime(self._evo_device.setpointStatus["until"]) + else: # EVO_PERMOVER + until = None + + await self._call_client_api( + self._evo_device.set_temperature(temperature, until) + ) + + async def async_set_hvac_mode(self, hvac_mode: str) -> None: + """Set a Zone to one of its native EVO_* operating modes. + + Zones inherit their _effective_ operating mode from their Controller. + + Usually, Zones are in 'FollowSchedule' mode, where their setpoints are a + function of their own schedule and the Controller's operating mode, e.g. + 'AutoWithEco' mode means their setpoint is (by default) 3C less than scheduled. + + However, Zones can _override_ these setpoints, either indefinitely, + 'PermanentOverride' mode, or for a set period of time, 'TemporaryOverride' mode + (after which they will revert back to 'FollowSchedule' mode). + + Finally, some of the Controller's operating modes are _forced_ upon the Zones, + regardless of any override mode, e.g. 'HeatingOff', Zones to (by default) 5C, + and 'Away', Zones to (by default) 12C. + """ + if hvac_mode == HVAC_MODE_OFF: + await self._call_client_api( + self._evo_device.set_temperature(self.min_temp, until=None) + ) + else: # HVAC_MODE_HEAT + await self._call_client_api(self._evo_device.cancel_temp_override()) + + async def async_set_preset_mode(self, preset_mode: Optional[str]) -> None: + """Set the preset mode; if None, then revert to following the schedule.""" + evo_preset_mode = HA_PRESET_TO_EVO.get(preset_mode, EVO_FOLLOW) + + if evo_preset_mode == EVO_FOLLOW: + await self._call_client_api(self._evo_device.cancel_temp_override()) + return + + temperature = self._evo_device.setpointStatus["targetHeatTemperature"] + + if evo_preset_mode == EVO_TEMPOVER: + await self._update_schedule() + until = parse_datetime(str(self.setpoints.get("next_sp_from"))) + else: # EVO_PERMOVER + until = None + + await self._call_client_api( + self._evo_device.set_temperature(temperature, until) + ) + + async def async_update(self) -> None: + """Get the latest state data for a Zone.""" + await super().async_update() + + for attr in STATE_ATTRS_ZONES: + self._device_state_attrs[attr] = getattr(self._evo_device, attr) + + +class EvoController(EvoClimateDevice): + """Base for a Honeywell evohome Controller (hub). + + The Controller (aka TCS, temperature control system) is the parent of all + the child (CH/DHW) devices. It is also a Climate device. + """ + + def __init__(self, evo_broker, evo_device) -> None: + """Initialize a evohome Controller (hub).""" + super().__init__(evo_broker, evo_device) + + self._unique_id = evo_device.systemId + self._name = evo_device.location.name + self._icon = "mdi:thermostat" + + self._precision = PRECISION_TENTHS + self._supported_features = SUPPORT_PRESET_MODE + self._preset_modes = list(HA_PRESET_TO_TCS) + + @property + def hvac_mode(self) -> str: + """Return the current operating mode of a Controller.""" + tcs_mode = self._evo_tcs.systemModeStatus["mode"] + return HVAC_MODE_OFF if tcs_mode == EVO_HEATOFF else HVAC_MODE_HEAT + + @property + def current_temperature(self) -> Optional[float]: + """Return the average current temperature of the heating Zones. + + Controllers do not have a current temp, but one is expected by HA. + """ + temps = [ + z.temperatureStatus["temperature"] + for z in self._evo_tcs.zones.values() + if z.temperatureStatus["isAvailable"] + ] + return round(sum(temps) / len(temps), 1) if temps else None + + @property + def preset_mode(self) -> Optional[str]: + """Return the current preset mode, e.g., home, away, temp.""" + return TCS_PRESET_TO_HA.get(self._evo_tcs.systemModeStatus["mode"]) + + @property + def min_temp(self) -> float: + """Return None as Controllers don't have a target temperature.""" + return None + + @property + def max_temp(self) -> float: + """Return None as Controllers don't have a target temperature.""" + return None + + async def async_set_temperature(self, **kwargs) -> None: + """Raise exception as Controllers don't have a target temperature.""" + raise NotImplementedError("Evohome Controllers don't have target temperatures.") + + async def async_set_hvac_mode(self, hvac_mode: str) -> None: + """Set an operating mode for a Controller.""" + await self._set_tcs_mode(HA_HVAC_TO_TCS.get(hvac_mode)) + + async def async_set_preset_mode(self, preset_mode: Optional[str]) -> None: + """Set the preset mode; if None, then revert to 'Auto' mode.""" + await self._set_tcs_mode(HA_PRESET_TO_TCS.get(preset_mode, EVO_AUTO)) + + async def async_update(self) -> None: + """Get the latest state data for a Controller.""" + self._device_state_attrs = {} + + attrs = self._device_state_attrs + for attr in STATE_ATTRS_TCS: + if attr == "activeFaults": + attrs["activeSystemFaults"] = getattr(self._evo_tcs, attr) + else: + attrs[attr] = getattr(self._evo_tcs, attr) + + +class EvoThermostat(EvoZone): + """Base for a Honeywell Round Thermostat. + + These are implemented as a combined Controller/Zone. + """ + + def __init__(self, evo_broker, evo_device) -> None: + """Initialize the Thermostat.""" + super().__init__(evo_broker, evo_device) + + self._name = evo_broker.tcs.location.name + self._preset_modes = [PRESET_AWAY, PRESET_ECO] + + @property + def hvac_mode(self) -> str: + """Return the current operating mode.""" + if self._evo_tcs.systemModeStatus["mode"] == EVO_HEATOFF: + return HVAC_MODE_OFF + + return super().hvac_mode + + @property + def preset_mode(self) -> Optional[str]: + """Return the current preset mode, e.g., home, away, temp.""" + if ( + self._evo_tcs.systemModeStatus["mode"] == EVO_AUTOECO + and self._evo_device.setpointStatus["setpointMode"] == EVO_FOLLOW + ): + return PRESET_ECO + + return super().preset_mode + + async def async_set_hvac_mode(self, hvac_mode: str) -> None: + """Set an operating mode.""" + await self._set_tcs_mode(HA_HVAC_TO_TCS.get(hvac_mode)) + + async def async_set_preset_mode(self, preset_mode: Optional[str]) -> None: + """Set the preset mode; if None, then revert to following the schedule.""" + if preset_mode in list(HA_PRESET_TO_TCS): + await self._set_tcs_mode(HA_PRESET_TO_TCS.get(preset_mode)) + else: + await super().async_set_hvac_mode(preset_mode) + + async def async_update(self) -> None: + """Get the latest state data for the Thermostat.""" + await super().async_update() + + attrs = self._device_state_attrs + for attr in STATE_ATTRS_TCS: + if attr == "activeFaults": # self._evo_device also has "activeFaults" + attrs["activeSystemFaults"] = getattr(self._evo_tcs, attr) + else: + attrs[attr] = getattr(self._evo_tcs, attr) diff --git a/homeassistant/components/evohome/const.py b/homeassistant/components/evohome/const.py new file mode 100644 index 0000000000000..444671cf82aa8 --- /dev/null +++ b/homeassistant/components/evohome/const.py @@ -0,0 +1,23 @@ +"""Support for (EMEA/EU-based) Honeywell TCC climate systems.""" +DOMAIN = "evohome" + +STORAGE_VERSION = 1 +STORAGE_KEY = DOMAIN + +# The Parent's (i.e. TCS, Controller's) operating mode is one of: +EVO_RESET = "AutoWithReset" +EVO_AUTO = "Auto" +EVO_AUTOECO = "AutoWithEco" +EVO_AWAY = "Away" +EVO_DAYOFF = "DayOff" +EVO_CUSTOM = "Custom" +EVO_HEATOFF = "HeatingOff" + +# The Childs' operating mode is one of: +EVO_FOLLOW = "FollowSchedule" # the operating mode is 'inherited' from the TCS +EVO_TEMPOVER = "TemporaryOverride" +EVO_PERMOVER = "PermanentOverride" + +# These are used only to help prevent E501 (line too long) violations +GWS = "gateways" +TCS = "temperatureControlSystems" diff --git a/homeassistant/components/evohome/manifest.json b/homeassistant/components/evohome/manifest.json new file mode 100644 index 0000000000000..579f14757f8cd --- /dev/null +++ b/homeassistant/components/evohome/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "evohome", + "name": "Honeywell evohome / Total Connect Comfort", + "documentation": "https://www.home-assistant.io/integrations/evohome", + "requirements": ["evohome-async==0.3.4b1"], + "dependencies": [], + "codeowners": ["@zxdavb"] +} diff --git a/homeassistant/components/evohome/water_heater.py b/homeassistant/components/evohome/water_heater.py new file mode 100644 index 0000000000000..e29dbb49af262 --- /dev/null +++ b/homeassistant/components/evohome/water_heater.py @@ -0,0 +1,114 @@ +"""Support for WaterHeater devices of (EMEA/EU) Honeywell TCC systems.""" +import logging +from typing import List + +from homeassistant.components.water_heater import ( + SUPPORT_AWAY_MODE, + SUPPORT_OPERATION_MODE, + WaterHeaterDevice, +) +from homeassistant.const import PRECISION_TENTHS, PRECISION_WHOLE, STATE_OFF, STATE_ON +from homeassistant.helpers.typing import ConfigType, HomeAssistantType +from homeassistant.util.dt import parse_datetime + +from . import EvoChild +from .const import DOMAIN, EVO_FOLLOW, EVO_PERMOVER + +_LOGGER = logging.getLogger(__name__) + +STATE_AUTO = "auto" + +HA_STATE_TO_EVO = {STATE_AUTO: "", STATE_ON: "On", STATE_OFF: "Off"} +EVO_STATE_TO_HA = {v: k for k, v in HA_STATE_TO_EVO.items() if k != ""} + +STATE_ATTRS_DHW = ["dhwId", "activeFaults", "stateStatus", "temperatureStatus"] + + +async def async_setup_platform( + hass: HomeAssistantType, config: ConfigType, async_add_entities, discovery_info=None +) -> None: + """Create a DHW controller.""" + if discovery_info is None: + return + + broker = hass.data[DOMAIN]["broker"] + + _LOGGER.debug( + "Found the DHW Controller (%s), id: %s", + broker.tcs.hotwater.zone_type, + broker.tcs.hotwater.zoneId, + ) + + evo_dhw = EvoDHW(broker, broker.tcs.hotwater) + + async_add_entities([evo_dhw], update_before_add=True) + + +class EvoDHW(EvoChild, WaterHeaterDevice): + """Base for a Honeywell evohome DHW controller (aka boiler).""" + + def __init__(self, evo_broker, evo_device) -> None: + """Initialize a evohome DHW controller.""" + super().__init__(evo_broker, evo_device) + + self._unique_id = evo_device.dhwId + self._name = "DHW controller" + self._icon = "mdi:thermometer-lines" + + self._precision = PRECISION_TENTHS if evo_broker.client_v1 else PRECISION_WHOLE + self._supported_features = SUPPORT_AWAY_MODE | SUPPORT_OPERATION_MODE + + @property + def state(self): + """Return the current state.""" + return EVO_STATE_TO_HA[self._evo_device.stateStatus["state"]] + + @property + def current_operation(self) -> str: + """Return the current operating mode (Auto, On, or Off).""" + if self._evo_device.stateStatus["mode"] == EVO_FOLLOW: + return STATE_AUTO + return EVO_STATE_TO_HA[self._evo_device.stateStatus["state"]] + + @property + def operation_list(self) -> List[str]: + """Return the list of available operations.""" + return list(HA_STATE_TO_EVO) + + @property + def is_away_mode_on(self): + """Return True if away mode is on.""" + is_off = EVO_STATE_TO_HA[self._evo_device.stateStatus["state"]] == STATE_OFF + is_permanent = self._evo_device.stateStatus["mode"] == EVO_PERMOVER + return is_off and is_permanent + + async def async_set_operation_mode(self, operation_mode: str) -> None: + """Set new operation mode for a DHW controller. + + Except for Auto, the mode is only until the next SetPoint. + """ + if operation_mode == STATE_AUTO: + await self._call_client_api(self._evo_device.set_dhw_auto()) + else: + await self._update_schedule() + until = parse_datetime(str(self.setpoints.get("next_sp_from"))) + + if operation_mode == STATE_ON: + await self._call_client_api(self._evo_device.set_dhw_on(until)) + else: # STATE_OFF + await self._call_client_api(self._evo_device.set_dhw_off(until)) + + async def async_turn_away_mode_on(self): + """Turn away mode on.""" + await self._call_client_api(self._evo_device.set_dhw_off()) + + async def async_turn_away_mode_off(self): + """Turn away mode off.""" + await self._call_client_api(self._evo_device.set_dhw_auto()) + + async def async_update(self) -> None: + """Get the latest state data for a DHW controller.""" + await super().async_update() + + for attr in STATE_ATTRS_DHW: + self._device_state_attrs[attr] = getattr(self._evo_device, attr) diff --git a/homeassistant/components/facebook/__init__.py b/homeassistant/components/facebook/__init__.py new file mode 100644 index 0000000000000..1619b8a91f104 --- /dev/null +++ b/homeassistant/components/facebook/__init__.py @@ -0,0 +1 @@ +"""The facebook component.""" diff --git a/homeassistant/components/facebook/manifest.json b/homeassistant/components/facebook/manifest.json new file mode 100644 index 0000000000000..dfdda34d39f00 --- /dev/null +++ b/homeassistant/components/facebook/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "facebook", + "name": "Facebook Messenger", + "documentation": "https://www.home-assistant.io/integrations/facebook", + "requirements": [], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/facebook/notify.py b/homeassistant/components/facebook/notify.py new file mode 100644 index 0000000000000..b75f26280331b --- /dev/null +++ b/homeassistant/components/facebook/notify.py @@ -0,0 +1,120 @@ +"""Facebook 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 ( + ATTR_DATA, + ATTR_TARGET, + PLATFORM_SCHEMA, + BaseNotificationService, +) +from homeassistant.const import CONTENT_TYPE_JSON +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +CONF_PAGE_ACCESS_TOKEN = "page_access_token" +BASE_URL = "https://graph.facebook.com/v2.6/me/messages" +CREATE_BROADCAST_URL = "https://graph.facebook.com/v2.11/me/message_creatives" +SEND_BROADCAST_URL = "https://graph.facebook.com/v2.11/me/broadcast_messages" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + {vol.Required(CONF_PAGE_ACCESS_TOKEN): cv.string} +) + + +def get_service(hass, config, discovery_info=None): + """Get the Facebook notification service.""" + return FacebookNotificationService(config[CONF_PAGE_ACCESS_TOKEN]) + + +class FacebookNotificationService(BaseNotificationService): + """Implementation of a notification service for the Facebook service.""" + + def __init__(self, access_token): + """Initialize the service.""" + self.page_access_token = access_token + + def send_message(self, message="", **kwargs): + """Send some message.""" + payload = {"access_token": self.page_access_token} + targets = kwargs.get(ATTR_TARGET) + data = kwargs.get(ATTR_DATA) + + body_message = {"text": message} + + if data is not None: + body_message.update(data) + # Only one of text or attachment can be specified + if "attachment" in body_message: + body_message.pop("text") + + if not targets: + _LOGGER.error("At least 1 target is required") + return + + # broadcast message + if targets[0].lower() == "broadcast": + broadcast_create_body = {"messages": [body_message]} + _LOGGER.debug("Broadcast body %s : ", broadcast_create_body) + + resp = requests.post( + CREATE_BROADCAST_URL, + data=json.dumps(broadcast_create_body), + params=payload, + headers={CONTENT_TYPE: CONTENT_TYPE_JSON}, + timeout=10, + ) + _LOGGER.debug("FB Messager broadcast id %s : ", resp.json()) + + # at this point we get broadcast id + broadcast_body = { + "message_creative_id": resp.json().get("message_creative_id"), + "notification_type": "REGULAR", + } + + resp = requests.post( + SEND_BROADCAST_URL, + data=json.dumps(broadcast_body), + params=payload, + headers={CONTENT_TYPE: CONTENT_TYPE_JSON}, + timeout=10, + ) + if resp.status_code != 200: + log_error(resp) + + # non-broadcast message + else: + for target in targets: + # If the target starts with a "+", it's a phone number, + # otherwise it's a user id. + if target.startswith("+"): + recipient = {"phone_number": target} + else: + recipient = {"id": target} + + body = {"recipient": recipient, "message": body_message} + resp = requests.post( + BASE_URL, + data=json.dumps(body), + params=payload, + headers={CONTENT_TYPE: CONTENT_TYPE_JSON}, + timeout=10, + ) + if resp.status_code != 200: + log_error(resp) + + +def log_error(response): + """Log error message.""" + obj = response.json() + error_message = obj["error"]["message"] + error_code = obj["error"]["code"] + + _LOGGER.error( + "Error %s : %s (Code %s)", response.status_code, error_message, error_code + ) diff --git a/homeassistant/components/facebox/__init__.py b/homeassistant/components/facebox/__init__.py new file mode 100644 index 0000000000000..9e5b6afb10b08 --- /dev/null +++ b/homeassistant/components/facebox/__init__.py @@ -0,0 +1 @@ +"""The facebox component.""" diff --git a/homeassistant/components/facebox/const.py b/homeassistant/components/facebox/const.py new file mode 100644 index 0000000000000..991ec925a9817 --- /dev/null +++ b/homeassistant/components/facebox/const.py @@ -0,0 +1,4 @@ +"""Constants for the Facebox component.""" + +DOMAIN = "facebox" +SERVICE_TEACH_FACE = "teach_face" diff --git a/homeassistant/components/facebox/image_processing.py b/homeassistant/components/facebox/image_processing.py new file mode 100644 index 0000000000000..ee6e4d8a6fa50 --- /dev/null +++ b/homeassistant/components/facebox/image_processing.py @@ -0,0 +1,272 @@ +"""Component for facial detection and identification via facebox.""" +import base64 +import logging + +import requests +import voluptuous as vol + +from homeassistant.components.image_processing import ( + ATTR_CONFIDENCE, + CONF_ENTITY_ID, + CONF_NAME, + CONF_SOURCE, + PLATFORM_SCHEMA, + ImageProcessingFaceEntity, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_NAME, + CONF_IP_ADDRESS, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + HTTP_BAD_REQUEST, + HTTP_OK, + HTTP_UNAUTHORIZED, +) +from homeassistant.core import split_entity_id +import homeassistant.helpers.config_validation as cv + +from .const import DOMAIN, SERVICE_TEACH_FACE + +_LOGGER = logging.getLogger(__name__) + +ATTR_BOUNDING_BOX = "bounding_box" +ATTR_CLASSIFIER = "classifier" +ATTR_IMAGE_ID = "image_id" +ATTR_ID = "id" +ATTR_MATCHED = "matched" +FACEBOX_NAME = "name" +CLASSIFIER = "facebox" +DATA_FACEBOX = "facebox_classifiers" +FILE_PATH = "file_path" + + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_IP_ADDRESS): cv.string, + vol.Required(CONF_PORT): cv.port, + vol.Optional(CONF_USERNAME): cv.string, + vol.Optional(CONF_PASSWORD): cv.string, + } +) + +SERVICE_TEACH_SCHEMA = vol.Schema( + { + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_NAME): cv.string, + vol.Required(FILE_PATH): cv.string, + } +) + + +def check_box_health(url, username, password): + """Check the health of the classifier and return its id if healthy.""" + kwargs = {} + if username: + kwargs["auth"] = requests.auth.HTTPBasicAuth(username, password) + try: + response = requests.get(url, **kwargs) + if response.status_code == HTTP_UNAUTHORIZED: + _LOGGER.error("AuthenticationError on %s", CLASSIFIER) + return None + if response.status_code == HTTP_OK: + return response.json()["hostname"] + except requests.exceptions.ConnectionError: + _LOGGER.error("ConnectionError: Is %s running?", CLASSIFIER) + return None + + +def encode_image(image): + """base64 encode an image stream.""" + base64_img = base64.b64encode(image).decode("ascii") + return base64_img + + +def get_matched_faces(faces): + """Return the name and rounded confidence of matched faces.""" + return { + face["name"]: round(face["confidence"], 2) for face in faces if face["matched"] + } + + +def parse_faces(api_faces): + """Parse the API face data into the format required.""" + known_faces = [] + for entry in api_faces: + face = {} + if entry["matched"]: # This data is only in matched faces. + face[FACEBOX_NAME] = entry["name"] + face[ATTR_IMAGE_ID] = entry["id"] + else: # Lets be explicit. + face[FACEBOX_NAME] = None + face[ATTR_IMAGE_ID] = None + face[ATTR_CONFIDENCE] = round(100.0 * entry["confidence"], 2) + face[ATTR_MATCHED] = entry["matched"] + face[ATTR_BOUNDING_BOX] = entry["rect"] + known_faces.append(face) + return known_faces + + +def post_image(url, image, username, password): + """Post an image to the classifier.""" + kwargs = {} + if username: + kwargs["auth"] = requests.auth.HTTPBasicAuth(username, password) + try: + response = requests.post(url, json={"base64": encode_image(image)}, **kwargs) + if response.status_code == HTTP_UNAUTHORIZED: + _LOGGER.error("AuthenticationError on %s", CLASSIFIER) + return None + return response + except requests.exceptions.ConnectionError: + _LOGGER.error("ConnectionError: Is %s running?", CLASSIFIER) + return None + + +def teach_file(url, name, file_path, username, password): + """Teach the classifier a name associated with a file.""" + kwargs = {} + if username: + kwargs["auth"] = requests.auth.HTTPBasicAuth(username, password) + try: + with open(file_path, "rb") as open_file: + response = requests.post( + url, + data={FACEBOX_NAME: name, ATTR_ID: file_path}, + files={"file": open_file}, + **kwargs, + ) + if response.status_code == HTTP_UNAUTHORIZED: + _LOGGER.error("AuthenticationError on %s", CLASSIFIER) + elif response.status_code == HTTP_BAD_REQUEST: + _LOGGER.error( + "%s teaching of file %s failed with message:%s", + CLASSIFIER, + file_path, + response.text, + ) + except requests.exceptions.ConnectionError: + _LOGGER.error("ConnectionError: Is %s running?", CLASSIFIER) + + +def valid_file_path(file_path): + """Check that a file_path points to a valid file.""" + try: + cv.isfile(file_path) + return True + except vol.Invalid: + _LOGGER.error("%s error: Invalid file path: %s", CLASSIFIER, file_path) + return False + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the classifier.""" + if DATA_FACEBOX not in hass.data: + hass.data[DATA_FACEBOX] = [] + + ip_address = config[CONF_IP_ADDRESS] + port = config[CONF_PORT] + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + url_health = f"http://{ip_address}:{port}/healthz" + hostname = check_box_health(url_health, username, password) + if hostname is None: + return + + entities = [] + for camera in config[CONF_SOURCE]: + facebox = FaceClassifyEntity( + ip_address, + port, + username, + password, + hostname, + camera[CONF_ENTITY_ID], + camera.get(CONF_NAME), + ) + entities.append(facebox) + hass.data[DATA_FACEBOX].append(facebox) + add_entities(entities) + + def service_handle(service): + """Handle for services.""" + entity_ids = service.data.get("entity_id") + + classifiers = hass.data[DATA_FACEBOX] + if entity_ids: + classifiers = [c for c in classifiers if c.entity_id in entity_ids] + + for classifier in classifiers: + name = service.data.get(ATTR_NAME) + file_path = service.data.get(FILE_PATH) + classifier.teach(name, file_path) + + hass.services.register( + DOMAIN, SERVICE_TEACH_FACE, service_handle, schema=SERVICE_TEACH_SCHEMA + ) + + +class FaceClassifyEntity(ImageProcessingFaceEntity): + """Perform a face classification.""" + + def __init__( + self, ip_address, port, username, password, hostname, camera_entity, name=None + ): + """Init with the API key and model id.""" + super().__init__() + self._url_check = f"http://{ip_address}:{port}/{CLASSIFIER}/check" + self._url_teach = f"http://{ip_address}:{port}/{CLASSIFIER}/teach" + self._username = username + self._password = password + self._hostname = hostname + self._camera = camera_entity + if name: + self._name = name + else: + camera_name = split_entity_id(camera_entity)[1] + self._name = f"{CLASSIFIER} {camera_name}" + self._matched = {} + + def process_image(self, image): + """Process an image.""" + response = post_image(self._url_check, image, self._username, self._password) + if response: + response_json = response.json() + if response_json["success"]: + total_faces = response_json["facesCount"] + faces = parse_faces(response_json["faces"]) + self._matched = get_matched_faces(faces) + self.process_faces(faces, total_faces) + + else: + self.total_faces = None + self.faces = [] + self._matched = {} + + def teach(self, name, file_path): + """Teach classifier a face name.""" + if not self.hass.config.is_allowed_path(file_path) or not valid_file_path( + file_path + ): + return + teach_file(self._url_teach, name, file_path, self._username, self._password) + + @property + def camera_entity(self): + """Return camera entity id from process pictures.""" + return self._camera + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def device_state_attributes(self): + """Return the classifier attributes.""" + return { + "matched_faces": self._matched, + "total_matched_faces": len(self._matched), + "hostname": self._hostname, + } diff --git a/homeassistant/components/facebox/manifest.json b/homeassistant/components/facebox/manifest.json new file mode 100644 index 0000000000000..2c911eb04ef45 --- /dev/null +++ b/homeassistant/components/facebox/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "facebox", + "name": "Facebox", + "documentation": "https://www.home-assistant.io/integrations/facebox", + "requirements": [], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/facebox/services.yaml b/homeassistant/components/facebox/services.yaml new file mode 100644 index 0000000000000..c6b686efb8517 --- /dev/null +++ b/homeassistant/components/facebox/services.yaml @@ -0,0 +1,12 @@ +teach_face: + description: Teach facebox a face using a file. + fields: + entity_id: + description: The facebox entity to teach. + example: 'image_processing.facebox' + name: + description: The name of the face to teach. + example: 'my_name' + file_path: + description: The path to the image file. + example: '/images/my_image.jpg' diff --git a/homeassistant/components/fail2ban/__init__.py b/homeassistant/components/fail2ban/__init__.py new file mode 100644 index 0000000000000..cb2716e581d42 --- /dev/null +++ b/homeassistant/components/fail2ban/__init__.py @@ -0,0 +1 @@ +"""The fail2ban component.""" diff --git a/homeassistant/components/fail2ban/manifest.json b/homeassistant/components/fail2ban/manifest.json new file mode 100644 index 0000000000000..01afbb12b6f5e --- /dev/null +++ b/homeassistant/components/fail2ban/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "fail2ban", + "name": "Fail2Ban", + "documentation": "https://www.home-assistant.io/integrations/fail2ban", + "requirements": [], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/fail2ban/sensor.py b/homeassistant/components/fail2ban/sensor.py new file mode 100644 index 0000000000000..692b48d9db5d8 --- /dev/null +++ b/homeassistant/components/fail2ban/sensor.py @@ -0,0 +1,127 @@ +""" +Support for displaying IPs banned by fail2ban. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.fail2ban/ + +""" +from datetime import timedelta +import logging +import os +import re + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_FILE_PATH, CONF_NAME +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +CONF_JAILS = "jails" + +DEFAULT_NAME = "fail2ban" +DEFAULT_LOG = "/var/log/fail2ban.log" + +STATE_CURRENT_BANS = "current_bans" +STATE_ALL_BANS = "total_bans" +SCAN_INTERVAL = timedelta(seconds=120) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_JAILS): vol.All(cv.ensure_list, vol.Length(min=1)), + vol.Optional(CONF_FILE_PATH): cv.isfile, + 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 fail2ban sensor.""" + name = config.get(CONF_NAME) + jails = config.get(CONF_JAILS) + log_file = config.get(CONF_FILE_PATH, DEFAULT_LOG) + + device_list = [] + log_parser = BanLogParser(log_file) + for jail in jails: + device_list.append(BanSensor(name, jail, log_parser)) + + async_add_entities(device_list, True) + + +class BanSensor(Entity): + """Implementation of a fail2ban sensor.""" + + def __init__(self, name, jail, log_parser): + """Initialize the sensor.""" + self._name = f"{name} {jail}" + self.jail = jail + self.ban_dict = {STATE_CURRENT_BANS: [], STATE_ALL_BANS: []} + self.last_ban = None + self.log_parser = log_parser + self.log_parser.ip_regex[self.jail] = re.compile( + r"\[{}\]\s*(Ban|Unban) (.*)".format(re.escape(self.jail)) + ) + _LOGGER.debug("Setting up jail %s", self.jail) + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state_attributes(self): + """Return the state attributes of the fail2ban sensor.""" + return self.ban_dict + + @property + def state(self): + """Return the most recently banned IP Address.""" + return self.last_ban + + def update(self): + """Update the list of banned ips.""" + self.log_parser.read_log(self.jail) + + if self.log_parser.data: + for entry in self.log_parser.data: + _LOGGER.debug(entry) + current_ip = entry[1] + if entry[0] == "Ban": + if current_ip not in self.ban_dict[STATE_CURRENT_BANS]: + self.ban_dict[STATE_CURRENT_BANS].append(current_ip) + if current_ip not in self.ban_dict[STATE_ALL_BANS]: + self.ban_dict[STATE_ALL_BANS].append(current_ip) + if len(self.ban_dict[STATE_ALL_BANS]) > 10: + self.ban_dict[STATE_ALL_BANS].pop(0) + + elif entry[0] == "Unban": + if current_ip in self.ban_dict[STATE_CURRENT_BANS]: + self.ban_dict[STATE_CURRENT_BANS].remove(current_ip) + + if self.ban_dict[STATE_CURRENT_BANS]: + self.last_ban = self.ban_dict[STATE_CURRENT_BANS][-1] + else: + self.last_ban = "None" + + +class BanLogParser: + """Class to parse fail2ban logs.""" + + def __init__(self, log_file): + """Initialize the parser.""" + self.log_file = log_file + self.data = list() + self.ip_regex = dict() + + def read_log(self, jail): + """Read the fail2ban log and find entries for jail.""" + self.data = list() + try: + with open(self.log_file, "r", encoding="utf-8") as file_data: + self.data = self.ip_regex[jail].findall(file_data.read()) + + except (IndexError, FileNotFoundError, IsADirectoryError, UnboundLocalError): + _LOGGER.warning("File not present: %s", os.path.basename(self.log_file)) diff --git a/homeassistant/components/familyhub/__init__.py b/homeassistant/components/familyhub/__init__.py new file mode 100644 index 0000000000000..1ac09b44a9dd6 --- /dev/null +++ b/homeassistant/components/familyhub/__init__.py @@ -0,0 +1 @@ +"""The familyhub component.""" diff --git a/homeassistant/components/familyhub/camera.py b/homeassistant/components/familyhub/camera.py new file mode 100644 index 0000000000000..2e4e7085927fa --- /dev/null +++ b/homeassistant/components/familyhub/camera.py @@ -0,0 +1,52 @@ +"""Family Hub camera for Samsung Refrigerators.""" +import logging + +from pyfamilyhublocal import FamilyHubCam +import voluptuous as vol + +from homeassistant.components.camera import PLATFORM_SCHEMA, Camera +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__) + +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.""" + + 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/familyhub/manifest.json b/homeassistant/components/familyhub/manifest.json new file mode 100644 index 0000000000000..f0181ba79ed76 --- /dev/null +++ b/homeassistant/components/familyhub/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "familyhub", + "name": "Samsung Family Hub", + "documentation": "https://www.home-assistant.io/integrations/familyhub", + "requirements": ["python-family-hub-local==0.0.2"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/fan/.translations/bg.json b/homeassistant/components/fan/.translations/bg.json new file mode 100644 index 0000000000000..f678c87096823 --- /dev/null +++ b/homeassistant/components/fan/.translations/bg.json @@ -0,0 +1,16 @@ +{ + "device_automation": { + "action_type": { + "turn_off": "\u0418\u0437\u043a\u043b\u044e\u0447\u0438 {entity_name}", + "turn_on": "\u0412\u043a\u043b\u044e\u0447\u0438 {entity_name}" + }, + "condition_type": { + "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" + }, + "trigger_type": { + "turned_off": "{entity_name} \u0431\u044a\u0434\u0435 \u0438\u0437\u043a\u043b\u044e\u0447\u0435\u043d", + "turned_on": "{entity_name} \u0431\u044a\u0434\u0435 \u0432\u043a\u043b\u044e\u0447\u0435\u043d" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fan/.translations/ca.json b/homeassistant/components/fan/.translations/ca.json new file mode 100644 index 0000000000000..e2f3ce2b0a40c --- /dev/null +++ b/homeassistant/components/fan/.translations/ca.json @@ -0,0 +1,16 @@ +{ + "device_automation": { + "action_type": { + "turn_off": "Apaga {entity_name}", + "turn_on": "Enc\u00e9n {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} est\u00e0 apagat", + "is_on": "{entity_name} est\u00e0 enc\u00e8s" + }, + "trigger_type": { + "turned_off": "{entity_name} s'ha apagat", + "turned_on": "{entity_name} s'ha enc\u00e8s" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fan/.translations/da.json b/homeassistant/components/fan/.translations/da.json new file mode 100644 index 0000000000000..0c9556bfedb84 --- /dev/null +++ b/homeassistant/components/fan/.translations/da.json @@ -0,0 +1,16 @@ +{ + "device_automation": { + "action_type": { + "turn_off": "Sluk {entity_name}", + "turn_on": "T\u00e6nd for {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} er slukket", + "is_on": "{entity_name} er t\u00e6ndt" + }, + "trigger_type": { + "turned_off": "{entity_name} blev slukket", + "turned_on": "{entity_name} blev t\u00e6ndt" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fan/.translations/de.json b/homeassistant/components/fan/.translations/de.json new file mode 100644 index 0000000000000..9c3559b7cfc96 --- /dev/null +++ b/homeassistant/components/fan/.translations/de.json @@ -0,0 +1,16 @@ +{ + "device_automation": { + "action_type": { + "turn_off": "Schalte {entity_name} aus.", + "turn_on": "Schalte {entity_name} ein." + }, + "condition_type": { + "is_off": "{entity_name} ist ausgeschaltet", + "is_on": "{entity_name} ist eingeschaltet" + }, + "trigger_type": { + "turned_off": "{entity_name} ausgeschaltet", + "turned_on": "{entity_name} eingeschaltet" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fan/.translations/en.json b/homeassistant/components/fan/.translations/en.json new file mode 100644 index 0000000000000..c27d983ca2e90 --- /dev/null +++ b/homeassistant/components/fan/.translations/en.json @@ -0,0 +1,16 @@ +{ + "device_automation": { + "action_type": { + "turn_off": "Turn off {entity_name}", + "turn_on": "Turn on {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} is off", + "is_on": "{entity_name} is on" + }, + "trigger_type": { + "turned_off": "{entity_name} turned off", + "turned_on": "{entity_name} turned on" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fan/.translations/es.json b/homeassistant/components/fan/.translations/es.json new file mode 100644 index 0000000000000..4ceefe9c72128 --- /dev/null +++ b/homeassistant/components/fan/.translations/es.json @@ -0,0 +1,16 @@ +{ + "device_automation": { + "action_type": { + "turn_off": "Desactivar {entity_name}", + "turn_on": "Activar {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} est\u00e1 desactivado", + "is_on": "{entity_name} est\u00e1 activado" + }, + "trigger_type": { + "turned_off": "{entity_name} desactivado", + "turned_on": "{entity_name} activado" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fan/.translations/fr.json b/homeassistant/components/fan/.translations/fr.json new file mode 100644 index 0000000000000..e6944dab781b5 --- /dev/null +++ b/homeassistant/components/fan/.translations/fr.json @@ -0,0 +1,16 @@ +{ + "device_automation": { + "action_type": { + "turn_off": "\u00c9teindre {entity_name}", + "turn_on": "Allumer {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} est d\u00e9sactiv\u00e9", + "is_on": "{entity_name} est activ\u00e9" + }, + "trigger_type": { + "turned_off": "{entity_name} est \u00e9teint", + "turned_on": "{entity_name} allum\u00e9" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fan/.translations/hu.json b/homeassistant/components/fan/.translations/hu.json new file mode 100644 index 0000000000000..b559f29c58163 --- /dev/null +++ b/homeassistant/components/fan/.translations/hu.json @@ -0,0 +1,16 @@ +{ + "device_automation": { + "action_type": { + "turn_off": "{entity_name} kikapcsol\u00e1sa", + "turn_on": "{entity_name} bekapcsol\u00e1sa" + }, + "condition_type": { + "is_off": "{entity_name} ki van kapcsolva", + "is_on": "{entity_name} be van kapcsolva" + }, + "trigger_type": { + "turned_off": "{entity_name} ki lett kapcsolva", + "turned_on": "{entity_name} be lett kapcsolva" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fan/.translations/it.json b/homeassistant/components/fan/.translations/it.json new file mode 100644 index 0000000000000..4fab847f1cb65 --- /dev/null +++ b/homeassistant/components/fan/.translations/it.json @@ -0,0 +1,16 @@ +{ + "device_automation": { + "action_type": { + "turn_off": "Spegnere {entity_name}", + "turn_on": "Accendere {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} \u00e8 spento", + "is_on": "{entity_name} \u00e8 acceso" + }, + "trigger_type": { + "turned_off": "{entity_name} disattivato", + "turned_on": "{entity_name} attivato" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fan/.translations/ko.json b/homeassistant/components/fan/.translations/ko.json new file mode 100644 index 0000000000000..dec2a711e578e --- /dev/null +++ b/homeassistant/components/fan/.translations/ko.json @@ -0,0 +1,16 @@ +{ + "device_automation": { + "action_type": { + "turn_off": "{entity_name} \ub044\uae30", + "turn_on": "{entity_name} \ucf1c\uae30" + }, + "condition_type": { + "is_off": "{entity_name} \uc774(\uac00) \uaebc\uc838 \uc788\uc73c\uba74", + "is_on": "{entity_name} \uc774(\uac00) \ucf1c\uc838 \uc788\uc73c\uba74" + }, + "trigger_type": { + "turned_off": "{entity_name} \uc774(\uac00) \uaebc\uc9c8 \ub54c", + "turned_on": "{entity_name} \uc774(\uac00) \ucf1c\uc9c8 \ub54c" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fan/.translations/lb.json b/homeassistant/components/fan/.translations/lb.json new file mode 100644 index 0000000000000..f5170949badf4 --- /dev/null +++ b/homeassistant/components/fan/.translations/lb.json @@ -0,0 +1,16 @@ +{ + "device_automation": { + "action_type": { + "turn_off": "{entity_name} ausschalten", + "turn_on": "{entity_name} uschalten" + }, + "condition_type": { + "is_off": "{entity_name} ass aus", + "is_on": "{entity_name} ass un" + }, + "trigger_type": { + "turned_off": "{entity_name} gouf ausgeschalt", + "turned_on": "{entity_name} gouf ugeschalt" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fan/.translations/nl.json b/homeassistant/components/fan/.translations/nl.json new file mode 100644 index 0000000000000..4837b301ea7f6 --- /dev/null +++ b/homeassistant/components/fan/.translations/nl.json @@ -0,0 +1,16 @@ +{ + "device_automation": { + "action_type": { + "turn_off": "Schakel {entity_name} uit", + "turn_on": "Schakel {entity_name} in" + }, + "condition_type": { + "is_off": "{entity_name} is uitgeschakeld", + "is_on": "{entity_name} is ingeschakeld" + }, + "trigger_type": { + "turned_off": "{entity_name} uitgeschakeld", + "turned_on": "{entity_name} ingeschakeld" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fan/.translations/no.json b/homeassistant/components/fan/.translations/no.json new file mode 100644 index 0000000000000..aa6320f0a657b --- /dev/null +++ b/homeassistant/components/fan/.translations/no.json @@ -0,0 +1,16 @@ +{ + "device_automation": { + "action_type": { + "turn_off": "Sl\u00e5 av {entity_name}", + "turn_on": "Sl\u00e5 p\u00e5 {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} er av", + "is_on": "{entity_name} er p\u00e5" + }, + "trigger_type": { + "turned_off": "{entity_name} sl\u00e5tt av", + "turned_on": "{entity_name} sl\u00e5tt p\u00e5" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fan/.translations/pl.json b/homeassistant/components/fan/.translations/pl.json new file mode 100644 index 0000000000000..709a63c238996 --- /dev/null +++ b/homeassistant/components/fan/.translations/pl.json @@ -0,0 +1,16 @@ +{ + "device_automation": { + "action_type": { + "turn_off": "wy\u0142\u0105cz {entity_name}", + "turn_on": "w\u0142\u0105cz {entity_name}" + }, + "condition_type": { + "is_off": "wentylator (entity_name} jest wy\u0142\u0105czony", + "is_on": "wentylator (entity_name} jest w\u0142\u0105czony" + }, + "trigger_type": { + "turned_off": "nast\u0105pi wy\u0142\u0105czenie {entity_name}", + "turned_on": "nast\u0105pi w\u0142\u0105czenie {entity_name}" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fan/.translations/pt-BR.json b/homeassistant/components/fan/.translations/pt-BR.json new file mode 100644 index 0000000000000..6b95464bdbcdb --- /dev/null +++ b/homeassistant/components/fan/.translations/pt-BR.json @@ -0,0 +1,8 @@ +{ + "device_automation": { + "condition_type": { + "is_off": "{entity_name} est\u00e1 desligado", + "is_on": "{entity_name} est\u00e1 ligado" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fan/.translations/pt.json b/homeassistant/components/fan/.translations/pt.json new file mode 100644 index 0000000000000..ab78bc776bdd1 --- /dev/null +++ b/homeassistant/components/fan/.translations/pt.json @@ -0,0 +1,16 @@ +{ + "device_automation": { + "action_type": { + "turn_off": "Desligar {entity_name}", + "turn_on": "Ligar {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} est\u00e1 desligada", + "is_on": "{entity_name} est\u00e1 ligada" + }, + "trigger_type": { + "turned_off": "{entity_name} desligou-se", + "turned_on": "{entity_name} ligou-se" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fan/.translations/ru.json b/homeassistant/components/fan/.translations/ru.json new file mode 100644 index 0000000000000..157c78975cb42 --- /dev/null +++ b/homeassistant/components/fan/.translations/ru.json @@ -0,0 +1,16 @@ +{ + "device_automation": { + "action_type": { + "turn_off": "\u0412\u044b\u043a\u043b\u044e\u0447\u0438\u0442\u044c {entity_name}", + "turn_on": "\u0412\u043a\u043b\u044e\u0447\u0438\u0442\u044c {entity_name}" + }, + "condition_type": { + "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" + }, + "trigger_type": { + "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" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fan/.translations/sl.json b/homeassistant/components/fan/.translations/sl.json new file mode 100644 index 0000000000000..a2bca3352ab28 --- /dev/null +++ b/homeassistant/components/fan/.translations/sl.json @@ -0,0 +1,16 @@ +{ + "device_automation": { + "action_type": { + "turn_off": "Izklopite {entity_name}", + "turn_on": "Vklopite {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} je izklopljen", + "is_on": "{entity_name} je vklopljen" + }, + "trigger_type": { + "turned_off": "{entity_name} izklopljen", + "turned_on": "{entity_name} vklopljen" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fan/.translations/zh-Hant.json b/homeassistant/components/fan/.translations/zh-Hant.json new file mode 100644 index 0000000000000..78c0d991125e4 --- /dev/null +++ b/homeassistant/components/fan/.translations/zh-Hant.json @@ -0,0 +1,16 @@ +{ + "device_automation": { + "action_type": { + "turn_off": "\u95dc\u9589 {entity_name}", + "turn_on": "\u958b\u555f {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} \u95dc\u9589", + "is_on": "{entity_name} \u958b\u555f" + }, + "trigger_type": { + "turned_off": "{entity_name} \u5df2\u95dc\u9589", + "turned_on": "{entity_name} \u5df2\u958b\u555f" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py new file mode 100644 index 0000000000000..fe6843ed6b917 --- /dev/null +++ b/homeassistant/components/fan/__init__.py @@ -0,0 +1,199 @@ +"""Provides functionality to interact with fans.""" +from datetime import timedelta +import functools as ft +import logging +from typing import Optional + +import voluptuous as vol + +from homeassistant.const import SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON +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 ToggleEntity +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.loader import bind_hass + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "fan" +SCAN_INTERVAL = timedelta(seconds=30) + +ENTITY_ID_FORMAT = DOMAIN + ".{}" + +# Bitfield of features supported by the fan entity +SUPPORT_SET_SPEED = 1 +SUPPORT_OSCILLATE = 2 +SUPPORT_DIRECTION = 4 + +SERVICE_SET_SPEED = "set_speed" +SERVICE_OSCILLATE = "oscillate" +SERVICE_SET_DIRECTION = "set_direction" + +SPEED_OFF = "off" +SPEED_LOW = "low" +SPEED_MEDIUM = "medium" +SPEED_HIGH = "high" + +DIRECTION_FORWARD = "forward" +DIRECTION_REVERSE = "reverse" + +ATTR_SPEED = "speed" +ATTR_SPEED_LIST = "speed_list" +ATTR_OSCILLATING = "oscillating" +ATTR_DIRECTION = "direction" + +PROP_TO_ATTR = { + "speed": ATTR_SPEED, + "speed_list": ATTR_SPEED_LIST, + "oscillating": ATTR_OSCILLATING, + "current_direction": ATTR_DIRECTION, +} + + +@bind_hass +def is_on(hass, entity_id: str) -> bool: + """Return if the fans are on based on the statemachine.""" + state = hass.states.get(entity_id) + return state.attributes[ATTR_SPEED] not in [SPEED_OFF, None] + + +async def async_setup(hass, config: dict): + """Expose fan control via statemachine and services.""" + component = hass.data[DOMAIN] = EntityComponent( + _LOGGER, DOMAIN, hass, SCAN_INTERVAL + ) + + await component.async_setup(config) + + component.async_register_entity_service( + SERVICE_TURN_ON, {vol.Optional(ATTR_SPEED): cv.string}, "async_turn_on" + ) + component.async_register_entity_service(SERVICE_TURN_OFF, {}, "async_turn_off") + component.async_register_entity_service(SERVICE_TOGGLE, {}, "async_toggle") + component.async_register_entity_service( + SERVICE_SET_SPEED, + {vol.Required(ATTR_SPEED): cv.string}, + "async_set_speed", + [SUPPORT_SET_SPEED], + ) + component.async_register_entity_service( + SERVICE_OSCILLATE, + {vol.Required(ATTR_OSCILLATING): cv.boolean}, + "async_oscillate", + [SUPPORT_OSCILLATE], + ) + component.async_register_entity_service( + SERVICE_SET_DIRECTION, + {vol.Optional(ATTR_DIRECTION): cv.string}, + "async_set_direction", + [SUPPORT_DIRECTION], + ) + + 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 FanEntity(ToggleEntity): + """Representation of a fan.""" + + def set_speed(self, speed: str) -> None: + """Set the speed of the fan.""" + raise NotImplementedError() + + def async_set_speed(self, speed: str): + """Set the speed of the fan. + + This method must be run in the event loop and returns a coroutine. + """ + if speed is SPEED_OFF: + return self.async_turn_off() + return self.hass.async_add_job(self.set_speed, speed) + + def set_direction(self, direction: str) -> None: + """Set the direction of the fan.""" + raise NotImplementedError() + + def async_set_direction(self, direction: str): + """Set the direction of the fan. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_job(self.set_direction, direction) + + # pylint: disable=arguments-differ + def turn_on(self, speed: Optional[str] = None, **kwargs) -> None: + """Turn on the fan.""" + raise NotImplementedError() + + # pylint: disable=arguments-differ + def async_turn_on(self, speed: Optional[str] = None, **kwargs): + """Turn on the fan. + + This method must be run in the event loop and returns a coroutine. + """ + if speed is SPEED_OFF: + return self.async_turn_off() + return self.hass.async_add_job(ft.partial(self.turn_on, speed, **kwargs)) + + def oscillate(self, oscillating: bool) -> None: + """Oscillate the fan.""" + pass + + def async_oscillate(self, oscillating: bool): + """Oscillate the fan. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_job(self.oscillate, oscillating) + + @property + def is_on(self): + """Return true if the entity is on.""" + return self.speed not in [SPEED_OFF, None] + + @property + def speed(self) -> Optional[str]: + """Return the current speed.""" + return None + + @property + def speed_list(self) -> list: + """Get the list of available speeds.""" + return [] + + @property + def current_direction(self) -> Optional[str]: + """Return the current direction of the fan.""" + return None + + @property + def state_attributes(self) -> dict: + """Return optional state attributes.""" + data = {} + + for prop, attr in PROP_TO_ATTR.items(): + if not hasattr(self, prop): + continue + + value = getattr(self, prop) + if value is not None: + data[attr] = value + + return data + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return 0 diff --git a/homeassistant/components/fan/device_action.py b/homeassistant/components/fan/device_action.py new file mode 100644 index 0000000000000..a5d35d741b640 --- /dev/null +++ b/homeassistant/components/fan/device_action.py @@ -0,0 +1,76 @@ +"""Provides device automations for Fan.""" +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, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import entity_registry +import homeassistant.helpers.config_validation as cv + +from . import DOMAIN + +ACTION_TYPES = {"turn_on", "turn_off"} + +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), + } +) + + +async def async_get_actions(hass: HomeAssistant, device_id: str) -> List[dict]: + """List device actions for Fan 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 + + actions.append( + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "turn_on", + } + ) + actions.append( + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "turn_off", + } + ) + + 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] == "turn_on": + service = SERVICE_TURN_ON + elif config[CONF_TYPE] == "turn_off": + service = SERVICE_TURN_OFF + + await hass.services.async_call( + DOMAIN, service, service_data, blocking=True, context=context + ) diff --git a/homeassistant/components/fan/device_condition.py b/homeassistant/components/fan/device_condition.py new file mode 100644 index 0000000000000..c69f28c10e9f2 --- /dev/null +++ b/homeassistant/components/fan/device_condition.py @@ -0,0 +1,82 @@ +"""Provide the device automations for Fan.""" +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_OFF, + STATE_ON, +) +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_on", "is_off"} + +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 Fan 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 + + conditions.append( + { + CONF_CONDITION: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "is_on", + } + ) + conditions.append( + { + CONF_CONDITION: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "is_off", + } + ) + + 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_on": + state = STATE_ON + else: + state = STATE_OFF + + 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/fan/device_trigger.py b/homeassistant/components/fan/device_trigger.py new file mode 100644 index 0000000000000..3bfeb5ee36b8b --- /dev/null +++ b/homeassistant/components/fan/device_trigger.py @@ -0,0 +1,91 @@ +"""Provides device automations for Fan.""" +from typing import List + +import voluptuous as vol + +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_OFF, + STATE_ON, +) +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 = {"turned_on", "turned_off"} + +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 Fan 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 + + # 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: "turned_on", + } + ) + triggers.append( + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "turned_off", + } + ) + + 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] == "turned_on": + from_state = STATE_OFF + to_state = STATE_ON + else: + from_state = STATE_ON + to_state = STATE_OFF + + 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/fan/manifest.json b/homeassistant/components/fan/manifest.json new file mode 100644 index 0000000000000..02ed368feacf6 --- /dev/null +++ b/homeassistant/components/fan/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "fan", + "name": "Fan", + "documentation": "https://www.home-assistant.io/integrations/fan", + "requirements": [], + "dependencies": ["group"], + "codeowners": [], + "quality_scale": "internal" +} diff --git a/homeassistant/components/fan/reproduce_state.py b/homeassistant/components/fan/reproduce_state.py new file mode 100644 index 0000000000000..2692ac7ee5cb3 --- /dev/null +++ b/homeassistant/components/fan/reproduce_state.py @@ -0,0 +1,100 @@ +"""Reproduce an Fan state.""" +import asyncio +import logging +from types import MappingProxyType +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 ( + ATTR_DIRECTION, + ATTR_OSCILLATING, + ATTR_SPEED, + DOMAIN, + SERVICE_OSCILLATE, + SERVICE_SET_DIRECTION, + SERVICE_SET_SPEED, +) + +_LOGGER = logging.getLogger(__name__) + +VALID_STATES = {STATE_ON, STATE_OFF} +ATTRIBUTES = { # attribute: service + ATTR_DIRECTION: SERVICE_SET_DIRECTION, + ATTR_OSCILLATING: SERVICE_OSCILLATE, + ATTR_SPEED: SERVICE_SET_SPEED, +} + + +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 all( + check_attr_equal(cur_state.attributes, state.attributes, attr) + for attr in ATTRIBUTES + ): + return + + service_data = {ATTR_ENTITY_ID: state.entity_id} + service_calls = {} # service: service_data + + if state.state == STATE_ON: + # The fan should be on + if cur_state.state != STATE_ON: + # Turn on the fan at first + service_calls[SERVICE_TURN_ON] = service_data + + for attr, service in ATTRIBUTES.items(): + # Call services to adjust the attributes + if attr in state.attributes and not check_attr_equal( + state.attributes, cur_state.attributes, attr + ): + data = service_data.copy() + data[attr] = state.attributes[attr] + service_calls[service] = data + + elif state.state == STATE_OFF: + service_calls[SERVICE_TURN_OFF] = service_data + + for service, data in service_calls.items(): + await hass.services.async_call( + DOMAIN, service, data, context=context, blocking=True + ) + + +async def async_reproduce_states( + hass: HomeAssistantType, states: Iterable[State], context: Optional[Context] = None +) -> None: + """Reproduce Fan states.""" + await asyncio.gather( + *(_async_reproduce_state(hass, state, context) for state in states) + ) + + +def check_attr_equal( + attr1: MappingProxyType, attr2: MappingProxyType, attr_str: str +) -> bool: + """Return true if the given attributes are equal.""" + return attr1.get(attr_str) == attr2.get(attr_str) diff --git a/homeassistant/components/fan/services.yaml b/homeassistant/components/fan/services.yaml new file mode 100644 index 0000000000000..ee478950095b2 --- /dev/null +++ b/homeassistant/components/fan/services.yaml @@ -0,0 +1,55 @@ +# Describes the format for available fan services + +set_speed: + description: Sets fan speed. + fields: + entity_id: + description: Name(s) of the entities to set + example: 'fan.living_room' + speed: + description: Speed setting + example: 'low' + +turn_on: + description: Turns fan on. + fields: + entity_id: + description: Names(s) of the entities to turn on + example: 'fan.living_room' + speed: + description: Speed setting + example: 'high' + +turn_off: + description: Turns fan off. + fields: + entity_id: + description: Names(s) of the entities to turn off + example: 'fan.living_room' + +oscillate: + description: Oscillates the fan. + fields: + entity_id: + description: Name(s) of the entities to oscillate + example: 'fan.desk_fan' + oscillating: + description: Flag to turn on/off oscillation + example: True + +toggle: + description: Toggle the fan on/off. + fields: + entity_id: + description: Name(s) of the entities to toggle + example: 'fan.living_room' + +set_direction: + description: Set the fan rotation. + fields: + entity_id: + description: Name(s) of the entities to toggle + example: 'fan.living_room' + direction: + description: The direction to rotate. Either 'forward' or 'reverse' + example: 'forward' diff --git a/homeassistant/components/fan/strings.json b/homeassistant/components/fan/strings.json new file mode 100644 index 0000000000000..98c3012c1232a --- /dev/null +++ b/homeassistant/components/fan/strings.json @@ -0,0 +1,16 @@ +{ + "device_automation": { + "condition_type": { + "is_on": "{entity_name} is on", + "is_off": "{entity_name} is off" + }, + "trigger_type": { + "turned_on": "{entity_name} turned on", + "turned_off": "{entity_name} turned off" + }, + "action_type": { + "turn_on": "Turn on {entity_name}", + "turn_off": "Turn off {entity_name}" + } + } +} diff --git a/homeassistant/components/fastdotcom/__init__.py b/homeassistant/components/fastdotcom/__init__.py new file mode 100644 index 0000000000000..e0a4782493e4e --- /dev/null +++ b/homeassistant/components/fastdotcom/__init__.py @@ -0,0 +1,70 @@ +"""Support for testing internet speed via Fast.com.""" +from datetime import timedelta +import logging + +from fastdotcom import fast_com +import voluptuous as vol + +from homeassistant.const import CONF_SCAN_INTERVAL +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.discovery import async_load_platform +from homeassistant.helpers.dispatcher import dispatcher_send +from homeassistant.helpers.event import async_track_time_interval + +DOMAIN = "fastdotcom" +DATA_UPDATED = f"{DOMAIN}_data_updated" + +_LOGGER = logging.getLogger(__name__) + +CONF_MANUAL = "manual" + +DEFAULT_INTERVAL = timedelta(hours=1) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_INTERVAL): vol.All( + cv.time_period, cv.positive_timedelta + ), + vol.Optional(CONF_MANUAL, default=False): cv.boolean, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, config): + """Set up the Fast.com component.""" + conf = config[DOMAIN] + data = hass.data[DOMAIN] = SpeedtestData(hass) + + if not conf[CONF_MANUAL]: + async_track_time_interval(hass, data.update, conf[CONF_SCAN_INTERVAL]) + + def update(call=None): + """Service call to manually update the data.""" + data.update() + + hass.services.async_register(DOMAIN, "speedtest", update) + + hass.async_create_task(async_load_platform(hass, "sensor", DOMAIN, {}, config)) + + return True + + +class SpeedtestData: + """Get the latest data from fast.com.""" + + def __init__(self, hass): + """Initialize the data object.""" + self.data = None + self._hass = hass + + def update(self, now=None): + """Get the latest data from fast.com.""" + + _LOGGER.debug("Executing fast.com speedtest") + self.data = {"download": fast_com()} + dispatcher_send(self._hass, DATA_UPDATED) diff --git a/homeassistant/components/fastdotcom/manifest.json b/homeassistant/components/fastdotcom/manifest.json new file mode 100644 index 0000000000000..d6fe4a07c59b3 --- /dev/null +++ b/homeassistant/components/fastdotcom/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "fastdotcom", + "name": "Fast.com", + "documentation": "https://www.home-assistant.io/integrations/fastdotcom", + "requirements": ["fastdotcom==0.0.3"], + "dependencies": [], + "codeowners": ["@rohankapoorcom"] +} diff --git a/homeassistant/components/fastdotcom/sensor.py b/homeassistant/components/fastdotcom/sensor.py new file mode 100644 index 0000000000000..6d9445ce15937 --- /dev/null +++ b/homeassistant/components/fastdotcom/sensor.py @@ -0,0 +1,77 @@ +"""Support for Fast.com internet speed testing sensor.""" +import logging + +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.restore_state import RestoreEntity + +from . import DATA_UPDATED, DOMAIN as FASTDOTCOM_DOMAIN + +_LOGGER = logging.getLogger(__name__) + +ICON = "mdi:speedometer" + +UNIT_OF_MEASUREMENT = "Mbit/s" + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the Fast.com sensor.""" + async_add_entities([SpeedtestSensor(hass.data[FASTDOTCOM_DOMAIN])]) + + +class SpeedtestSensor(RestoreEntity): + """Implementation of a FAst.com sensor.""" + + def __init__(self, speedtest_data): + """Initialize the sensor.""" + self._name = "Fast.com Download" + self.speedtest_client = speedtest_data + 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 device.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return UNIT_OF_MEASUREMENT + + @property + def icon(self): + """Return icon.""" + return ICON + + @property + def should_poll(self): + """Return the polling requirement for this sensor.""" + return False + + async def async_added_to_hass(self): + """Handle entity which will be added.""" + await super().async_added_to_hass() + state = await self.async_get_last_state() + if not state: + return + self._state = state.state + + async_dispatcher_connect( + self.hass, DATA_UPDATED, self._schedule_immediate_update + ) + + def update(self): + """Get the latest data and update the states.""" + data = self.speedtest_client.data + if data is None: + return + self._state = data["download"] + + @callback + def _schedule_immediate_update(self): + self.async_schedule_update_ha_state(True) diff --git a/homeassistant/components/fastdotcom/services.yaml b/homeassistant/components/fastdotcom/services.yaml new file mode 100644 index 0000000000000..fe6cb1ac12dba --- /dev/null +++ b/homeassistant/components/fastdotcom/services.yaml @@ -0,0 +1,2 @@ +speedtest: + description: Immediately take a speedest with Fast.com \ No newline at end of file diff --git a/homeassistant/components/feedreader/__init__.py b/homeassistant/components/feedreader/__init__.py new file mode 100644 index 0000000000000..2643607c3a87e --- /dev/null +++ b/homeassistant/components/feedreader/__init__.py @@ -0,0 +1,222 @@ +"""Support for RSS/Atom feeds.""" +from datetime import datetime, timedelta +from logging import getLogger +from os.path import exists +import pickle +from threading import Lock + +import feedparser +import voluptuous as vol + +from homeassistant.const import CONF_SCAN_INTERVAL, EVENT_HOMEASSISTANT_START +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import track_time_interval + +_LOGGER = getLogger(__name__) + +CONF_URLS = "urls" +CONF_MAX_ENTRIES = "max_entries" + +DEFAULT_MAX_ENTRIES = 20 +DEFAULT_SCAN_INTERVAL = timedelta(hours=1) + +DOMAIN = "feedreader" + +EVENT_FEEDREADER = "feedreader" + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: { + vol.Required(CONF_URLS): vol.All(cv.ensure_list, [cv.url]), + vol.Optional( + CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL + ): cv.time_period, + vol.Optional( + CONF_MAX_ENTRIES, default=DEFAULT_MAX_ENTRIES + ): cv.positive_int, + } + }, + extra=vol.ALLOW_EXTRA, +) + + +def setup(hass, config): + """Set up the Feedreader component.""" + urls = config.get(DOMAIN)[CONF_URLS] + scan_interval = config.get(DOMAIN).get(CONF_SCAN_INTERVAL) + max_entries = config.get(DOMAIN).get(CONF_MAX_ENTRIES) + data_file = hass.config.path(f"{DOMAIN}.pickle") + storage = StoredData(data_file) + feeds = [ + FeedManager(url, scan_interval, max_entries, hass, storage) for url in urls + ] + return len(feeds) > 0 + + +class FeedManager: + """Abstraction over Feedparser module.""" + + def __init__(self, url, scan_interval, max_entries, hass, storage): + """Initialize the FeedManager object, poll as per scan interval.""" + self._url = url + self._scan_interval = scan_interval + self._max_entries = max_entries + self._feed = None + self._hass = hass + self._firstrun = True + self._storage = storage + self._last_entry_timestamp = None + self._last_update_successful = False + self._has_published_parsed = False + self._event_type = EVENT_FEEDREADER + self._feed_id = url + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, lambda _: self._update()) + self._init_regular_updates(hass) + + def _log_no_entries(self): + """Send no entries log at debug level.""" + _LOGGER.debug("No new entries to be published in feed %s", self._url) + + def _init_regular_updates(self, hass): + """Schedule regular updates at the top of the clock.""" + track_time_interval(hass, lambda now: self._update(), self._scan_interval) + + @property + def last_update_successful(self): + """Return True if the last feed update was successful.""" + return self._last_update_successful + + def _update(self): + """Update the feed and publish new entries to the event bus.""" + _LOGGER.info("Fetching new data from feed %s", self._url) + self._feed = feedparser.parse( + self._url, + etag=None if not self._feed else self._feed.get("etag"), + modified=None if not self._feed else self._feed.get("modified"), + ) + if not self._feed: + _LOGGER.error("Error fetching feed data from %s", self._url) + self._last_update_successful = False + else: + # The 'bozo' flag really only indicates that there was an issue + # during the initial parsing of the XML, but it doesn't indicate + # whether this is an unrecoverable error. In this case the + # feedparser lib is trying a less strict parsing approach. + # If an error is detected here, log error message but continue + # processing the feed entries if present. + if self._feed.bozo != 0: + _LOGGER.error( + "Error parsing feed %s: %s", self._url, self._feed.bozo_exception + ) + # Using etag and modified, if there's no new data available, + # the entries list will be empty + if self._feed.entries: + _LOGGER.debug( + "%s entri(es) available in feed %s", + len(self._feed.entries), + self._url, + ) + self._filter_entries() + self._publish_new_entries() + if self._has_published_parsed: + self._storage.put_timestamp( + self._feed_id, self._last_entry_timestamp + ) + else: + self._log_no_entries() + self._last_update_successful = True + _LOGGER.info("Fetch from feed %s completed", self._url) + + def _filter_entries(self): + """Filter the entries provided and return the ones to keep.""" + if len(self._feed.entries) > self._max_entries: + _LOGGER.debug( + "Processing only the first %s entries in feed %s", + self._max_entries, + self._url, + ) + self._feed.entries = self._feed.entries[0 : self._max_entries] + + def _update_and_fire_entry(self, entry): + """Update last_entry_timestamp and fire entry.""" + # Check if the entry has a published date. + if "published_parsed" in entry.keys() and entry.published_parsed: + # We are lucky, `published_parsed` data available, let's make use of + # it to publish only new available entries since the last run + self._has_published_parsed = True + self._last_entry_timestamp = max( + entry.published_parsed, self._last_entry_timestamp + ) + else: + self._has_published_parsed = False + _LOGGER.debug("No published_parsed info available for entry %s", entry) + entry.update({"feed_url": self._url}) + self._hass.bus.fire(self._event_type, entry) + + def _publish_new_entries(self): + """Publish new entries to the event bus.""" + new_entries = False + self._last_entry_timestamp = self._storage.get_timestamp(self._feed_id) + if self._last_entry_timestamp: + self._firstrun = False + else: + # Set last entry timestamp as epoch time if not available + self._last_entry_timestamp = datetime.utcfromtimestamp(0).timetuple() + for entry in self._feed.entries: + if self._firstrun or ( + "published_parsed" in entry.keys() + and entry.published_parsed > self._last_entry_timestamp + ): + self._update_and_fire_entry(entry) + new_entries = True + else: + _LOGGER.debug("Entry %s already processed", entry) + if not new_entries: + self._log_no_entries() + self._firstrun = False + + +class StoredData: + """Abstraction over pickle data storage.""" + + def __init__(self, data_file): + """Initialize pickle data storage.""" + self._data_file = data_file + self._lock = Lock() + self._cache_outdated = True + self._data = {} + self._fetch_data() + + def _fetch_data(self): + """Fetch data stored into pickle file.""" + if self._cache_outdated and exists(self._data_file): + try: + _LOGGER.debug("Fetching data from file %s", self._data_file) + with self._lock, open(self._data_file, "rb") as myfile: + self._data = pickle.load(myfile) or {} + self._cache_outdated = False + except: # noqa: E722 pylint: disable=bare-except + _LOGGER.error( + "Error loading data from pickled file %s", self._data_file + ) + + def get_timestamp(self, feed_id): + """Return stored timestamp for given feed id (usually the url).""" + self._fetch_data() + return self._data.get(feed_id) + + def put_timestamp(self, feed_id, timestamp): + """Update timestamp for given feed id (usually the url).""" + self._fetch_data() + with self._lock, open(self._data_file, "wb") as myfile: + self._data.update({feed_id: timestamp}) + _LOGGER.debug( + "Overwriting feed %s timestamp in storage file %s", + feed_id, + self._data_file, + ) + try: + pickle.dump(self._data, myfile) + except: # noqa: E722 pylint: disable=bare-except + _LOGGER.error("Error saving pickled data to %s", self._data_file) + self._cache_outdated = True diff --git a/homeassistant/components/feedreader/manifest.json b/homeassistant/components/feedreader/manifest.json new file mode 100644 index 0000000000000..16c32bdd089e0 --- /dev/null +++ b/homeassistant/components/feedreader/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "feedreader", + "name": "Feedreader", + "documentation": "https://www.home-assistant.io/integrations/feedreader", + "requirements": ["feedparser-homeassistant==5.2.2.dev1"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/ffmpeg/__init__.py b/homeassistant/components/ffmpeg/__init__.py new file mode 100644 index 0000000000000..bc402b46fb21a --- /dev/null +++ b/homeassistant/components/ffmpeg/__init__.py @@ -0,0 +1,207 @@ +"""Support for FFmpeg.""" +import logging +import re + +from haffmpeg.tools import FFVersion +import voluptuous as vol + +from homeassistant.const import ( + ATTR_ENTITY_ID, + EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP, +) +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.entity import Entity + +DOMAIN = "ffmpeg" + +_LOGGER = logging.getLogger(__name__) + +SERVICE_START = "start" +SERVICE_STOP = "stop" +SERVICE_RESTART = "restart" + +SIGNAL_FFMPEG_START = "ffmpeg.start" +SIGNAL_FFMPEG_STOP = "ffmpeg.stop" +SIGNAL_FFMPEG_RESTART = "ffmpeg.restart" + +DATA_FFMPEG = "ffmpeg" + +CONF_INITIAL_STATE = "initial_state" +CONF_INPUT = "input" +CONF_FFMPEG_BIN = "ffmpeg_bin" +CONF_EXTRA_ARGUMENTS = "extra_arguments" +CONF_OUTPUT = "output" + +DEFAULT_BINARY = "ffmpeg" + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + {vol.Optional(CONF_FFMPEG_BIN, default=DEFAULT_BINARY): cv.string} + ) + }, + extra=vol.ALLOW_EXTRA, +) + +SERVICE_FFMPEG_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.entity_ids}) + + +async def async_setup(hass, config): + """Set up the FFmpeg component.""" + conf = config.get(DOMAIN, {}) + + manager = FFmpegManager(hass, conf.get(CONF_FFMPEG_BIN, DEFAULT_BINARY)) + + await manager.async_get_version() + + # Register service + async def async_service_handle(service): + """Handle service ffmpeg process.""" + entity_ids = service.data.get(ATTR_ENTITY_ID) + + if service.service == SERVICE_START: + async_dispatcher_send(hass, SIGNAL_FFMPEG_START, entity_ids) + elif service.service == SERVICE_STOP: + async_dispatcher_send(hass, SIGNAL_FFMPEG_STOP, entity_ids) + else: + async_dispatcher_send(hass, SIGNAL_FFMPEG_RESTART, entity_ids) + + hass.services.async_register( + DOMAIN, SERVICE_START, async_service_handle, schema=SERVICE_FFMPEG_SCHEMA + ) + + hass.services.async_register( + DOMAIN, SERVICE_STOP, async_service_handle, schema=SERVICE_FFMPEG_SCHEMA + ) + + hass.services.async_register( + DOMAIN, SERVICE_RESTART, async_service_handle, schema=SERVICE_FFMPEG_SCHEMA + ) + + hass.data[DATA_FFMPEG] = manager + return True + + +class FFmpegManager: + """Helper for ha-ffmpeg.""" + + def __init__(self, hass, ffmpeg_bin): + """Initialize helper.""" + self.hass = hass + self._cache = {} + self._bin = ffmpeg_bin + self._version = None + self._major_version = None + + @property + def binary(self): + """Return ffmpeg binary from config.""" + return self._bin + + async def async_get_version(self): + """Return ffmpeg version.""" + + ffversion = FFVersion(self._bin, self.hass.loop) + self._version = await ffversion.get_version() + + self._major_version = None + if self._version is not None: + result = re.search(r"(\d+)\.", self._version) + if result is not None: + self._major_version = int(result.group(1)) + + return self._version, self._major_version + + @property + def ffmpeg_stream_content_type(self): + """Return HTTP content type for ffmpeg stream.""" + if self._major_version is not None and self._major_version > 3: + return "multipart/x-mixed-replace;boundary=ffmpeg" + + return "multipart/x-mixed-replace;boundary=ffserver" + + +class FFmpegBase(Entity): + """Interface object for FFmpeg.""" + + def __init__(self, initial_state=True): + """Initialize ffmpeg base object.""" + self.ffmpeg = None + self.initial_state = initial_state + + async def async_added_to_hass(self): + """Register dispatcher & events. + + This method is a coroutine. + """ + async_dispatcher_connect( + self.hass, SIGNAL_FFMPEG_START, self._async_start_ffmpeg + ) + async_dispatcher_connect(self.hass, SIGNAL_FFMPEG_STOP, self._async_stop_ffmpeg) + async_dispatcher_connect( + self.hass, SIGNAL_FFMPEG_RESTART, self._async_restart_ffmpeg + ) + + # register start/stop + self._async_register_events() + + @property + def available(self): + """Return True if entity is available.""" + return self.ffmpeg.is_running + + @property + def should_poll(self): + """Return True if entity has to be polled for state.""" + return False + + async def _async_start_ffmpeg(self, entity_ids): + """Start a FFmpeg process. + + This method is a coroutine. + """ + raise NotImplementedError() + + async def _async_stop_ffmpeg(self, entity_ids): + """Stop a FFmpeg process. + + This method is a coroutine. + """ + if entity_ids is None or self.entity_id in entity_ids: + await self.ffmpeg.close() + + async def _async_restart_ffmpeg(self, entity_ids): + """Stop a FFmpeg process. + + This method is a coroutine. + """ + if entity_ids is None or self.entity_id in entity_ids: + await self._async_stop_ffmpeg(None) + await self._async_start_ffmpeg(None) + + @callback + def _async_register_events(self): + """Register a FFmpeg process/device.""" + + async def async_shutdown_handle(event): + """Stop FFmpeg process.""" + await self._async_stop_ffmpeg(None) + + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_shutdown_handle) + + # start on startup + if not self.initial_state: + return + + async def async_start_handle(event): + """Start FFmpeg process.""" + await self._async_start_ffmpeg(None) + self.async_schedule_update_ha_state() + + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, async_start_handle) diff --git a/homeassistant/components/ffmpeg/camera.py b/homeassistant/components/ffmpeg/camera.py new file mode 100644 index 0000000000000..db3eb5621ff7b --- /dev/null +++ b/homeassistant/components/ffmpeg/camera.py @@ -0,0 +1,88 @@ +"""Support for Cameras with FFmpeg as decoder.""" +import asyncio +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, SUPPORT_STREAM, Camera +from homeassistant.const import CONF_NAME +from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream +import homeassistant.helpers.config_validation as cv + +from . import CONF_EXTRA_ARGUMENTS, CONF_INPUT, DATA_FFMPEG + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = "FFmpeg" +DEFAULT_ARGUMENTS = "-pred 1" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_INPUT): cv.string, + vol.Optional(CONF_EXTRA_ARGUMENTS, default=DEFAULT_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.""" + 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) + + @property + def supported_features(self): + """Return supported features.""" + return SUPPORT_STREAM + + async def stream_source(self): + """Return the stream source.""" + return self._input.split(" ")[-1] + + async def async_camera_image(self): + """Return a still image response from the camera.""" + + 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 + ) + ) + return image + + async def handle_async_mjpeg_stream(self, request): + """Generate an HTTP MJPEG stream from the camera.""" + + stream = CameraMjpeg(self._manager.binary, loop=self.hass.loop) + await stream.open_camera(self._input, extra_cmd=self._extra_arguments) + + try: + stream_reader = await stream.get_reader() + return await async_aiohttp_proxy_stream( + self.hass, + request, + stream_reader, + self._manager.ffmpeg_stream_content_type, + ) + finally: + await stream.close() + + @property + def name(self): + """Return the name of this camera.""" + return self._name diff --git a/homeassistant/components/ffmpeg/manifest.json b/homeassistant/components/ffmpeg/manifest.json new file mode 100644 index 0000000000000..bacfa498fe1cf --- /dev/null +++ b/homeassistant/components/ffmpeg/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "ffmpeg", + "name": "FFmpeg", + "documentation": "https://www.home-assistant.io/integrations/ffmpeg", + "requirements": ["ha-ffmpeg==2.0"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/ffmpeg/services.yaml b/homeassistant/components/ffmpeg/services.yaml new file mode 100644 index 0000000000000..05c9c4fb18060 --- /dev/null +++ b/homeassistant/components/ffmpeg/services.yaml @@ -0,0 +1,15 @@ +restart: + description: Send a restart command to a ffmpeg based sensor. + fields: + entity_id: {description: Name(s) of entities that will restart. Platform dependent., + example: binary_sensor.ffmpeg_noise} +start: + description: Send a start command to a ffmpeg based sensor. + fields: + entity_id: {description: Name(s) of entities that will start. Platform dependent., + example: binary_sensor.ffmpeg_noise} +stop: + description: Send a stop command to a ffmpeg based sensor. + fields: + entity_id: {description: Name(s) of entities that will stop. Platform dependent., + example: binary_sensor.ffmpeg_noise} diff --git a/homeassistant/components/ffmpeg_motion/__init__.py b/homeassistant/components/ffmpeg_motion/__init__.py new file mode 100644 index 0000000000000..b13c0efeacf8c --- /dev/null +++ b/homeassistant/components/ffmpeg_motion/__init__.py @@ -0,0 +1 @@ +"""The ffmpeg_motion component.""" diff --git a/homeassistant/components/ffmpeg_motion/binary_sensor.py b/homeassistant/components/ffmpeg_motion/binary_sensor.py new file mode 100644 index 0000000000000..294fcc2518fa2 --- /dev/null +++ b/homeassistant/components/ffmpeg_motion/binary_sensor.py @@ -0,0 +1,122 @@ +"""Provides a binary sensor which is a collection of ffmpeg tools.""" +import logging + +import haffmpeg.sensor as ffmpeg_sensor +import voluptuous as vol + +from homeassistant.components.binary_sensor import PLATFORM_SCHEMA, BinarySensorDevice +from homeassistant.components.ffmpeg import ( + CONF_EXTRA_ARGUMENTS, + CONF_INITIAL_STATE, + CONF_INPUT, + DATA_FFMPEG, + FFmpegBase, +) +from homeassistant.const import CONF_NAME +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv + +_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) + ), + } +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the FFmpeg binary motion sensor.""" + manager = hass.data[DATA_FFMPEG] + 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.""" + + super().__init__(config) + self.ffmpeg = ffmpeg_sensor.SensorMotion( + manager.binary, hass.loop, self._async_callback + ) + + async 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 + await 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/ffmpeg_motion/manifest.json b/homeassistant/components/ffmpeg_motion/manifest.json new file mode 100644 index 0000000000000..c1ae41e0f2b81 --- /dev/null +++ b/homeassistant/components/ffmpeg_motion/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "ffmpeg_motion", + "name": "FFmpeg Motion", + "documentation": "https://www.home-assistant.io/integrations/ffmpeg_motion", + "requirements": [], + "dependencies": ["ffmpeg"], + "codeowners": [] +} diff --git a/homeassistant/components/ffmpeg_noise/__init__.py b/homeassistant/components/ffmpeg_noise/__init__.py new file mode 100644 index 0000000000000..ab233df98ceed --- /dev/null +++ b/homeassistant/components/ffmpeg_noise/__init__.py @@ -0,0 +1 @@ +"""The ffmpeg_noise component.""" diff --git a/homeassistant/components/ffmpeg_noise/binary_sensor.py b/homeassistant/components/ffmpeg_noise/binary_sensor.py new file mode 100644 index 0000000000000..6ada2bb274896 --- /dev/null +++ b/homeassistant/components/ffmpeg_noise/binary_sensor.py @@ -0,0 +1,87 @@ +"""Provides a binary sensor which is a collection of ffmpeg tools.""" +import logging + +import haffmpeg.sensor as ffmpeg_sensor +import voluptuous as vol + +from homeassistant.components.binary_sensor import PLATFORM_SCHEMA +from homeassistant.components.ffmpeg import ( + CONF_EXTRA_ARGUMENTS, + CONF_INITIAL_STATE, + CONF_INPUT, + CONF_OUTPUT, + DATA_FFMPEG, +) +from homeassistant.components.ffmpeg_motion.binary_sensor import FFmpegBinarySensor +from homeassistant.const import CONF_NAME +import homeassistant.helpers.config_validation as cv + +_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) + ), + } +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the FFmpeg noise binary sensor.""" + manager = hass.data[DATA_FFMPEG] + 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.""" + + super().__init__(config) + self.ffmpeg = ffmpeg_sensor.SensorNoise( + manager.binary, hass.loop, self._async_callback + ) + + async 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), + ) + + await 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/ffmpeg_noise/manifest.json b/homeassistant/components/ffmpeg_noise/manifest.json new file mode 100644 index 0000000000000..ca7043c51a584 --- /dev/null +++ b/homeassistant/components/ffmpeg_noise/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "ffmpeg_noise", + "name": "FFmpeg Noise", + "documentation": "https://www.home-assistant.io/integrations/ffmpeg_noise", + "requirements": [], + "dependencies": ["ffmpeg"], + "codeowners": [] +} diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py new file mode 100644 index 0000000000000..32d8f328ef84f --- /dev/null +++ b/homeassistant/components/fibaro/__init__.py @@ -0,0 +1,498 @@ +"""Support for the Fibaro devices.""" +from collections import defaultdict +import logging +from typing import Optional + +from fiblary3.client.v4.client import Client as FibaroClient, StateHandler +import voluptuous as vol + +from homeassistant.const import ( + ATTR_ARMED, + ATTR_BATTERY_LEVEL, + CONF_DEVICE_CLASS, + CONF_EXCLUDE, + CONF_ICON, + CONF_PASSWORD, + CONF_URL, + CONF_USERNAME, + CONF_WHITE_VALUE, + EVENT_HOMEASSISTANT_STOP, +) +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import convert, slugify + +_LOGGER = logging.getLogger(__name__) + +ATTR_CURRENT_ENERGY_KWH = "current_energy_kwh" +ATTR_CURRENT_POWER_W = "current_power_w" + +CONF_COLOR = "color" +CONF_DEVICE_CONFIG = "device_config" +CONF_DIMMING = "dimming" +CONF_GATEWAYS = "gateways" +CONF_PLUGINS = "plugins" +CONF_RESET_COLOR = "reset_color" +DOMAIN = "fibaro" +FIBARO_CONTROLLERS = "fibaro_controllers" +FIBARO_DEVICES = "fibaro_devices" +FIBARO_COMPONENTS = [ + "binary_sensor", + "climate", + "cover", + "light", + "scene", + "sensor", + "switch", +] + +FIBARO_TYPEMAP = { + "com.fibaro.multilevelSensor": "sensor", + "com.fibaro.binarySwitch": "switch", + "com.fibaro.multilevelSwitch": "switch", + "com.fibaro.FGD212": "light", + "com.fibaro.FGR": "cover", + "com.fibaro.doorSensor": "binary_sensor", + "com.fibaro.doorWindowSensor": "binary_sensor", + "com.fibaro.FGMS001": "binary_sensor", + "com.fibaro.heatDetector": "binary_sensor", + "com.fibaro.lifeDangerSensor": "binary_sensor", + "com.fibaro.smokeSensor": "binary_sensor", + "com.fibaro.remoteSwitch": "switch", + "com.fibaro.sensor": "sensor", + "com.fibaro.colorController": "light", + "com.fibaro.securitySensor": "binary_sensor", + "com.fibaro.hvac": "climate", + "com.fibaro.setpoint": "climate", + "com.fibaro.FGT001": "climate", + "com.fibaro.thermostatDanfoss": "climate", +} + +DEVICE_CONFIG_SCHEMA_ENTRY = vol.Schema( + { + vol.Optional(CONF_DIMMING): cv.boolean, + vol.Optional(CONF_COLOR): cv.boolean, + vol.Optional(CONF_WHITE_VALUE): cv.boolean, + vol.Optional(CONF_RESET_COLOR): cv.boolean, + vol.Optional(CONF_DEVICE_CLASS): cv.string, + vol.Optional(CONF_ICON): cv.string, + } +) + +FIBARO_ID_LIST_SCHEMA = vol.Schema([cv.string]) + +GATEWAY_CONFIG = vol.Schema( + { + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_URL): cv.url, + vol.Optional(CONF_PLUGINS, default=False): cv.boolean, + vol.Optional(CONF_EXCLUDE, default=[]): FIBARO_ID_LIST_SCHEMA, + vol.Optional(CONF_DEVICE_CONFIG, default={}): vol.Schema( + {cv.string: DEVICE_CONFIG_SCHEMA_ENTRY} + ), + }, + extra=vol.ALLOW_EXTRA, +) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + {vol.Required(CONF_GATEWAYS): vol.All(cv.ensure_list, [GATEWAY_CONFIG])} + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +class FibaroController: + """Initiate Fibaro Controller Class.""" + + def __init__(self, config): + """Initialize the Fibaro controller.""" + + self._client = FibaroClient( + config[CONF_URL], config[CONF_USERNAME], config[CONF_PASSWORD] + ) + self._scene_map = None + # Whether to import devices from plugins + self._import_plugins = config[CONF_PLUGINS] + self._device_config = config[CONF_DEVICE_CONFIG] + self._room_map = None # Mapping roomId to room object + self._device_map = None # Mapping deviceId to device object + self.fibaro_devices = None # List of devices by type + self._callbacks = {} # Update value callbacks by deviceId + self._state_handler = None # Fiblary's StateHandler object + self._excluded_devices = config[CONF_EXCLUDE] + self.hub_serial = None # Unique serial number of the hub + + def connect(self): + """Start the communication with the Fibaro controller.""" + try: + login = self._client.login.get() + info = self._client.info.get() + self.hub_serial = slugify(info.serialNumber) + except AssertionError: + _LOGGER.error("Can't connect to Fibaro HC. Please check URL.") + return False + if login is None or login.status is False: + _LOGGER.error( + "Invalid login for Fibaro HC. Please check username and password" + ) + return False + + self._room_map = {room.id: room for room in self._client.rooms.list()} + self._read_devices() + self._read_scenes() + return True + + def enable_state_handler(self): + """Start StateHandler thread for monitoring updates.""" + self._state_handler = StateHandler(self._client, self._on_state_change) + + def disable_state_handler(self): + """Stop StateHandler thread used for monitoring updates.""" + self._state_handler.stop() + self._state_handler = None + + def _on_state_change(self, state): + """Handle change report received from the HomeCenter.""" + callback_set = set() + for change in state.get("changes", []): + try: + dev_id = change.pop("id") + if dev_id not in self._device_map.keys(): + continue + device = self._device_map[dev_id] + for property_name, value in change.items(): + if property_name == "log": + if value and value != "transfer OK": + _LOGGER.debug("LOG %s: %s", device.friendly_name, value) + continue + if property_name == "logTemp": + continue + if property_name in device.properties: + device.properties[property_name] = value + _LOGGER.debug( + "<- %s.%s = %s", device.ha_id, property_name, str(value) + ) + else: + _LOGGER.warning("%s.%s not found", device.ha_id, property_name) + if dev_id in self._callbacks: + callback_set.add(dev_id) + except (ValueError, KeyError): + pass + for item in callback_set: + self._callbacks[item]() + + def register(self, device_id, callback): + """Register device with a callback for updates.""" + self._callbacks[device_id] = callback + + def get_children(self, device_id): + """Get a list of child devices.""" + return [ + device + for device in self._device_map.values() + if device.parentId == device_id + ] + + def get_siblings(self, device_id): + """Get the siblings of a device.""" + return self.get_children(self._device_map[device_id].parentId) + + @staticmethod + def _map_device_to_type(device): + """Map device to HA device type.""" + # Use our lookup table to identify device type + device_type = None + if "type" in device: + device_type = FIBARO_TYPEMAP.get(device.type) + if device_type is None and "baseType" in device: + device_type = FIBARO_TYPEMAP.get(device.baseType) + + # We can also identify device type by its capabilities + if device_type is None: + if "setBrightness" in device.actions: + device_type = "light" + elif "turnOn" in device.actions: + device_type = "switch" + elif "open" in device.actions: + device_type = "cover" + elif "value" in device.properties: + if device.properties.value in ("true", "false"): + device_type = "binary_sensor" + else: + device_type = "sensor" + + # Switches that control lights should show up as lights + if ( + device_type == "switch" + and device.properties.get("isLight", "false") == "true" + ): + device_type = "light" + return device_type + + def _read_scenes(self): + scenes = self._client.scenes.list() + self._scene_map = {} + for device in scenes: + if not device.visible: + continue + device.fibaro_controller = self + if device.roomID == 0: + room_name = "Unknown" + else: + room_name = self._room_map[device.roomID].name + device.room_name = room_name + device.friendly_name = f"{room_name} {device.name}" + device.ha_id = "scene_{}_{}_{}".format( + slugify(room_name), slugify(device.name), device.id + ) + device.unique_id_str = f"{self.hub_serial}.scene.{device.id}" + self._scene_map[device.id] = device + self.fibaro_devices["scene"].append(device) + + def _read_devices(self): + """Read and process the device list.""" + devices = self._client.devices.list() + self._device_map = {} + self.fibaro_devices = defaultdict(list) + last_climate_parent = None + for device in devices: + try: + device.fibaro_controller = self + if device.roomID == 0: + room_name = "Unknown" + else: + room_name = self._room_map[device.roomID].name + device.room_name = room_name + device.friendly_name = f"{room_name} {device.name}" + device.ha_id = "{}_{}_{}".format( + slugify(room_name), slugify(device.name), device.id + ) + if ( + device.enabled + and ( + "isPlugin" not in device + or (not device.isPlugin or self._import_plugins) + ) + and device.ha_id not in self._excluded_devices + ): + device.mapped_type = self._map_device_to_type(device) + device.device_config = self._device_config.get(device.ha_id, {}) + else: + device.mapped_type = None + dtype = device.mapped_type + if dtype: + device.unique_id_str = f"{self.hub_serial}.{device.id}" + self._device_map[device.id] = device + if dtype != "climate": + self.fibaro_devices[dtype].append(device) + else: + # if a sibling of this has been added, skip this one + # otherwise add the first visible device in the group + # which is a hack, but solves a problem with FGT having + # hidden compatibility devices before the real device + if last_climate_parent != device.parentId and device.visible: + self.fibaro_devices[dtype].append(device) + last_climate_parent = device.parentId + _LOGGER.debug( + "%s (%s, %s) -> %s %s", + device.ha_id, + device.type, + device.baseType, + dtype, + str(device), + ) + except (KeyError, ValueError): + pass + + +def setup(hass, base_config): + """Set up the Fibaro Component.""" + gateways = base_config[DOMAIN][CONF_GATEWAYS] + hass.data[FIBARO_CONTROLLERS] = {} + + def stop_fibaro(event): + """Stop Fibaro Thread.""" + _LOGGER.info("Shutting down Fibaro connection") + for controller in hass.data[FIBARO_CONTROLLERS].values(): + controller.disable_state_handler() + + hass.data[FIBARO_DEVICES] = {} + for component in FIBARO_COMPONENTS: + hass.data[FIBARO_DEVICES][component] = [] + + for gateway in gateways: + controller = FibaroController(gateway) + if controller.connect(): + hass.data[FIBARO_CONTROLLERS][controller.hub_serial] = controller + for component in FIBARO_COMPONENTS: + hass.data[FIBARO_DEVICES][component].extend( + controller.fibaro_devices[component] + ) + + if hass.data[FIBARO_CONTROLLERS]: + for component in FIBARO_COMPONENTS: + discovery.load_platform(hass, component, DOMAIN, {}, base_config) + for controller in hass.data[FIBARO_CONTROLLERS].values(): + controller.enable_state_handler() + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_fibaro) + return True + + return False + + +class FibaroDevice(Entity): + """Representation of a Fibaro device entity.""" + + def __init__(self, fibaro_device): + """Initialize the device.""" + self.fibaro_device = fibaro_device + self.controller = fibaro_device.fibaro_controller + self._name = fibaro_device.friendly_name + self.ha_id = fibaro_device.ha_id + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + self.controller.register(self.fibaro_device.id, self._update_callback) + + def _update_callback(self): + """Update the state.""" + self.schedule_update_ha_state(True) + + @property + def level(self): + """Get the level of Fibaro device.""" + if "value" in self.fibaro_device.properties: + return self.fibaro_device.properties.value + return None + + @property + def level2(self): + """Get the tilt level of Fibaro device.""" + if "value2" in self.fibaro_device.properties: + return self.fibaro_device.properties.value2 + return None + + def dont_know_message(self, action): + """Make a warning in case we don't know how to perform an action.""" + _LOGGER.warning( + "Not sure how to setValue: %s (available actions: %s)", + str(self.ha_id), + str(self.fibaro_device.actions), + ) + + def set_level(self, level): + """Set the level of Fibaro device.""" + self.action("setValue", level) + if "value" in self.fibaro_device.properties: + self.fibaro_device.properties.value = level + if "brightness" in self.fibaro_device.properties: + self.fibaro_device.properties.brightness = level + + def set_level2(self, level): + """Set the level2 of Fibaro device.""" + self.action("setValue2", level) + if "value2" in self.fibaro_device.properties: + self.fibaro_device.properties.value2 = level + + def call_turn_on(self): + """Turn on the Fibaro device.""" + self.action("turnOn") + + def call_turn_off(self): + """Turn off the Fibaro device.""" + self.action("turnOff") + + def call_set_color(self, red, green, blue, white): + """Set the color of Fibaro device.""" + red = int(max(0, min(255, red))) + green = int(max(0, min(255, green))) + blue = int(max(0, min(255, blue))) + white = int(max(0, min(255, white))) + color_str = f"{red},{green},{blue},{white}" + self.fibaro_device.properties.color = color_str + self.action("setColor", str(red), str(green), str(blue), str(white)) + + def action(self, cmd, *args): + """Perform an action on the Fibaro HC.""" + if cmd in self.fibaro_device.actions: + getattr(self.fibaro_device, cmd)(*args) + _LOGGER.debug("-> %s.%s%s called", str(self.ha_id), str(cmd), str(args)) + else: + self.dont_know_message(cmd) + + @property + def hidden(self) -> bool: + """Return True if the entity should be hidden from UIs.""" + return self.fibaro_device.visible is False + + @property + def current_power_w(self): + """Return the current power usage in W.""" + if "power" in self.fibaro_device.properties: + power = self.fibaro_device.properties.power + if power: + return convert(power, float, 0.0) + else: + return None + + @property + def current_binary_state(self): + """Return the current binary state.""" + if self.fibaro_device.properties.value == "false": + return False + if ( + self.fibaro_device.properties.value == "true" + or int(self.fibaro_device.properties.value) > 0 + ): + return True + return False + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return self.fibaro_device.unique_id_str + + @property + def name(self) -> Optional[str]: + """Return the name of the device.""" + return self._name + + @property + def should_poll(self): + """Get polling requirement from fibaro device.""" + return False + + def update(self): + """Call to update state.""" + pass + + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + attr = {} + + try: + if "battery" in self.fibaro_device.interfaces: + attr[ATTR_BATTERY_LEVEL] = int( + self.fibaro_device.properties.batteryLevel + ) + if "fibaroAlarmArm" in self.fibaro_device.interfaces: + attr[ATTR_ARMED] = bool(self.fibaro_device.properties.armed) + if "power" in self.fibaro_device.interfaces: + attr[ATTR_CURRENT_POWER_W] = convert( + self.fibaro_device.properties.power, float, 0.0 + ) + if "energy" in self.fibaro_device.interfaces: + attr[ATTR_CURRENT_ENERGY_KWH] = convert( + self.fibaro_device.properties.energy, float, 0.0 + ) + except (ValueError, KeyError): + pass + + attr["fibaro_id"] = self.fibaro_device.id + return attr diff --git a/homeassistant/components/fibaro/binary_sensor.py b/homeassistant/components/fibaro/binary_sensor.py new file mode 100644 index 0000000000000..af2c2a9401a18 --- /dev/null +++ b/homeassistant/components/fibaro/binary_sensor.py @@ -0,0 +1,77 @@ +"""Support for Fibaro binary sensors.""" +import logging + +from homeassistant.components.binary_sensor import ENTITY_ID_FORMAT, BinarySensorDevice +from homeassistant.const import CONF_DEVICE_CLASS, CONF_ICON + +from . import FIBARO_DEVICES, FibaroDevice + +_LOGGER = logging.getLogger(__name__) + +SENSOR_TYPES = { + "com.fibaro.floodSensor": ["Flood", "mdi:water", "flood"], + "com.fibaro.motionSensor": ["Motion", "mdi:run", "motion"], + "com.fibaro.doorSensor": ["Door", "mdi:window-open", "door"], + "com.fibaro.windowSensor": ["Window", "mdi:window-open", "window"], + "com.fibaro.smokeSensor": ["Smoke", "mdi:smoking", "smoke"], + "com.fibaro.FGMS001": ["Motion", "mdi:run", "motion"], + "com.fibaro.heatDetector": ["Heat", "mdi:fire", "heat"], +} + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Perform the setup for Fibaro controller devices.""" + if discovery_info is None: + return + + add_entities( + [ + FibaroBinarySensor(device) + for device in hass.data[FIBARO_DEVICES]["binary_sensor"] + ], + True, + ) + + +class FibaroBinarySensor(FibaroDevice, BinarySensorDevice): + """Representation of a Fibaro Binary Sensor.""" + + def __init__(self, fibaro_device): + """Initialize the binary_sensor.""" + self._state = None + super().__init__(fibaro_device) + self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) + stype = None + devconf = fibaro_device.device_config + if fibaro_device.type in SENSOR_TYPES: + stype = fibaro_device.type + elif fibaro_device.baseType in SENSOR_TYPES: + stype = fibaro_device.baseType + if stype: + self._device_class = SENSOR_TYPES[stype][2] + self._icon = SENSOR_TYPES[stype][1] + else: + self._device_class = None + self._icon = None + # device_config overrides: + self._device_class = devconf.get(CONF_DEVICE_CLASS, self._device_class) + self._icon = devconf.get(CONF_ICON, self._icon) + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return self._icon + + @property + def device_class(self): + """Return the device class of the sensor.""" + return self._device_class + + @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.current_binary_state diff --git a/homeassistant/components/fibaro/climate.py b/homeassistant/components/fibaro/climate.py new file mode 100644 index 0000000000000..71be289e27b3c --- /dev/null +++ b/homeassistant/components/fibaro/climate.py @@ -0,0 +1,327 @@ +"""Support for Fibaro thermostats.""" +import logging + +from homeassistant.components.climate import ClimateDevice +from homeassistant.components.climate.const import ( + HVAC_MODE_AUTO, + HVAC_MODE_COOL, + HVAC_MODE_DRY, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + PRESET_AWAY, + PRESET_BOOST, + SUPPORT_FAN_MODE, + SUPPORT_PRESET_MODE, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT + +from . import FIBARO_DEVICES, FibaroDevice + +PRESET_RESUME = "resume" +PRESET_MOIST = "moist" +PRESET_FURNACE = "furnace" +PRESET_CHANGEOVER = "changeover" +PRESET_ECO_HEAT = "eco_heat" +PRESET_ECO_COOL = "eco_cool" +PRESET_FORCE_OPEN = "force_open" + +_LOGGER = logging.getLogger(__name__) + +# SDS13781-10 Z-Wave Application Command Class Specification 2019-01-04 +# Table 128, Thermostat Fan Mode Set version 4::Fan Mode encoding +FANMODES = { + 0: "off", + 1: "low", + 2: "auto_high", + 3: "medium", + 4: "auto_medium", + 5: "high", + 6: "circulation", + 7: "humidity_circulation", + 8: "left_right", + 9: "up_down", + 10: "quiet", + 128: "auto", +} + +HA_FANMODES = {v: k for k, v in FANMODES.items()} + +# SDS13781-10 Z-Wave Application Command Class Specification 2019-01-04 +# Table 130, Thermostat Mode Set version 3::Mode encoding. +# 4 AUXILIARY +OPMODES_PRESET = { + 5: PRESET_RESUME, + 7: PRESET_FURNACE, + 9: PRESET_MOIST, + 10: PRESET_CHANGEOVER, + 11: PRESET_ECO_HEAT, + 12: PRESET_ECO_COOL, + 13: PRESET_AWAY, + 15: PRESET_BOOST, + 31: PRESET_FORCE_OPEN, +} + +HA_OPMODES_PRESET = {v: k for k, v in OPMODES_PRESET.items()} + +OPMODES_HVAC = { + 0: HVAC_MODE_OFF, + 1: HVAC_MODE_HEAT, + 2: HVAC_MODE_COOL, + 3: HVAC_MODE_AUTO, + 4: HVAC_MODE_HEAT, + 5: HVAC_MODE_AUTO, + 6: HVAC_MODE_FAN_ONLY, + 7: HVAC_MODE_HEAT, + 8: HVAC_MODE_DRY, + 9: HVAC_MODE_DRY, + 10: HVAC_MODE_AUTO, + 11: HVAC_MODE_HEAT, + 12: HVAC_MODE_COOL, + 13: HVAC_MODE_AUTO, + 15: HVAC_MODE_AUTO, + 31: HVAC_MODE_HEAT, +} + +HA_OPMODES_HVAC = { + HVAC_MODE_OFF: 0, + HVAC_MODE_HEAT: 1, + HVAC_MODE_COOL: 2, + HVAC_MODE_AUTO: 3, + HVAC_MODE_FAN_ONLY: 6, +} + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Perform the setup for Fibaro controller devices.""" + if discovery_info is None: + return + + add_entities( + [FibaroThermostat(device) for device in hass.data[FIBARO_DEVICES]["climate"]], + True, + ) + + +class FibaroThermostat(FibaroDevice, ClimateDevice): + """Representation of a Fibaro Thermostat.""" + + def __init__(self, fibaro_device): + """Initialize the Fibaro device.""" + super().__init__(fibaro_device) + self._temp_sensor_device = None + self._target_temp_device = None + self._op_mode_device = None + self._fan_mode_device = None + self._support_flags = 0 + self.entity_id = f"climate.{self.ha_id}" + self._hvac_support = [] + self._preset_support = [] + self._fan_support = [] + + siblings = fibaro_device.fibaro_controller.get_siblings(fibaro_device.id) + tempunit = "C" + for device in siblings: + if device.type == "com.fibaro.temperatureSensor": + self._temp_sensor_device = FibaroDevice(device) + tempunit = device.properties.unit + if ( + "setTargetLevel" in device.actions + or "setThermostatSetpoint" in device.actions + ): + self._target_temp_device = FibaroDevice(device) + self._support_flags |= SUPPORT_TARGET_TEMPERATURE + tempunit = device.properties.unit + if "setMode" in device.actions or "setOperatingMode" in device.actions: + self._op_mode_device = FibaroDevice(device) + self._support_flags |= SUPPORT_PRESET_MODE + if "setFanMode" in device.actions: + self._fan_mode_device = FibaroDevice(device) + self._support_flags |= SUPPORT_FAN_MODE + + if tempunit == "F": + self._unit_of_temp = TEMP_FAHRENHEIT + else: + self._unit_of_temp = TEMP_CELSIUS + + if self._fan_mode_device: + fan_modes = self._fan_mode_device.fibaro_device.properties.supportedModes.split( + "," + ) + for mode in fan_modes: + mode = int(mode) + if mode not in FANMODES: + _LOGGER.warning("%d unknown fan mode", mode) + continue + self._fan_support.append(FANMODES[int(mode)]) + + if self._op_mode_device: + prop = self._op_mode_device.fibaro_device.properties + if "supportedOperatingModes" in prop: + op_modes = prop.supportedOperatingModes.split(",") + elif "supportedModes" in prop: + op_modes = prop.supportedModes.split(",") + for mode in op_modes: + mode = int(mode) + if mode in OPMODES_HVAC: + mode_ha = OPMODES_HVAC[mode] + if mode_ha not in self._hvac_support: + self._hvac_support.append(mode_ha) + if mode in OPMODES_PRESET: + self._preset_support.append(OPMODES_PRESET[mode]) + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + _LOGGER.debug( + "Climate %s\n" + "- _temp_sensor_device %s\n" + "- _target_temp_device %s\n" + "- _op_mode_device %s\n" + "- _fan_mode_device %s", + self.ha_id, + self._temp_sensor_device.ha_id if self._temp_sensor_device else "None", + self._target_temp_device.ha_id if self._target_temp_device else "None", + self._op_mode_device.ha_id if self._op_mode_device else "None", + self._fan_mode_device.ha_id if self._fan_mode_device else "None", + ) + await super().async_added_to_hass() + + # Register update callback for child devices + siblings = self.fibaro_device.fibaro_controller.get_siblings( + self.fibaro_device.id + ) + for device in siblings: + if device != self.fibaro_device: + self.controller.register(device.id, self._update_callback) + + @property + def supported_features(self): + """Return the list of supported features.""" + return self._support_flags + + @property + def fan_modes(self): + """Return the list of available fan modes.""" + if not self._fan_mode_device: + return None + return self._fan_support + + @property + def fan_mode(self): + """Return the fan setting.""" + if not self._fan_mode_device: + return None + mode = int(self._fan_mode_device.fibaro_device.properties.mode) + return FANMODES[mode] + + def set_fan_mode(self, fan_mode): + """Set new target fan mode.""" + if not self._fan_mode_device: + return + self._fan_mode_device.action("setFanMode", HA_FANMODES[fan_mode]) + + @property + def fibaro_op_mode(self): + """Return the operating mode of the device.""" + if not self._op_mode_device: + return 6 # Fan only + + if "operatingMode" in self._op_mode_device.fibaro_device.properties: + return int(self._op_mode_device.fibaro_device.properties.operatingMode) + + return int(self._op_mode_device.fibaro_device.properties.mode) + + @property + def hvac_mode(self): + """Return current operation ie. heat, cool, idle.""" + return OPMODES_HVAC[self.fibaro_op_mode] + + @property + def hvac_modes(self): + """Return the list of available operation modes.""" + if not self._op_mode_device: + return [HVAC_MODE_FAN_ONLY] + return self._hvac_support + + def set_hvac_mode(self, hvac_mode): + """Set new target operation mode.""" + if not self._op_mode_device: + return + if self.preset_mode: + return + + if "setOperatingMode" in self._op_mode_device.fibaro_device.actions: + self._op_mode_device.action("setOperatingMode", HA_OPMODES_HVAC[hvac_mode]) + elif "setMode" in self._op_mode_device.fibaro_device.actions: + self._op_mode_device.action("setMode", HA_OPMODES_HVAC[hvac_mode]) + + @property + def preset_mode(self): + """Return the current preset mode, e.g., home, away, temp. + + Requires SUPPORT_PRESET_MODE. + """ + if not self._op_mode_device: + return None + + if "operatingMode" in self._op_mode_device.fibaro_device.properties: + mode = int(self._op_mode_device.fibaro_device.properties.operatingMode) + else: + mode = int(self._op_mode_device.fibaro_device.properties.mode) + + if mode not in OPMODES_PRESET: + return None + return OPMODES_PRESET[mode] + + @property + def preset_modes(self): + """Return a list of available preset modes. + + Requires SUPPORT_PRESET_MODE. + """ + if not self._op_mode_device: + return None + return self._preset_support + + def set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + if self._op_mode_device is None: + return + if "setOperatingMode" in self._op_mode_device.fibaro_device.actions: + self._op_mode_device.action( + "setOperatingMode", HA_OPMODES_PRESET[preset_mode] + ) + elif "setMode" in self._op_mode_device.fibaro_device.actions: + self._op_mode_device.action("setMode", HA_OPMODES_PRESET[preset_mode]) + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return self._unit_of_temp + + @property + def current_temperature(self): + """Return the current temperature.""" + if self._temp_sensor_device: + device = self._temp_sensor_device.fibaro_device + return float(device.properties.value) + return None + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + if self._target_temp_device: + device = self._target_temp_device.fibaro_device + return float(device.properties.targetLevel) + return None + + def set_temperature(self, **kwargs): + """Set new target temperatures.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + target = self._target_temp_device + if temperature is not None: + if "setThermostatSetpoint" in target.fibaro_device.actions: + target.action("setThermostatSetpoint", self.fibaro_op_mode, temperature) + else: + target.action("setTargetLevel", temperature) diff --git a/homeassistant/components/fibaro/cover.py b/homeassistant/components/fibaro/cover.py new file mode 100644 index 0000000000000..fe9c0990fa888 --- /dev/null +++ b/homeassistant/components/fibaro/cover.py @@ -0,0 +1,89 @@ +"""Support for Fibaro cover - curtains, rollershutters etc.""" +import logging + +from homeassistant.components.cover import ( + ATTR_POSITION, + ATTR_TILT_POSITION, + ENTITY_ID_FORMAT, + CoverDevice, +) + +from . import FIBARO_DEVICES, FibaroDevice + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Fibaro covers.""" + if discovery_info is None: + return + + add_entities( + [FibaroCover(device) for device in hass.data[FIBARO_DEVICES]["cover"]], True + ) + + +class FibaroCover(FibaroDevice, CoverDevice): + """Representation a Fibaro Cover.""" + + def __init__(self, fibaro_device): + """Initialize the Vera device.""" + super().__init__(fibaro_device) + self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) + + @staticmethod + def bound(position): + """Normalize the position.""" + if position is None: + return None + position = int(position) + if position <= 5: + return 0 + if position >= 95: + return 100 + return position + + @property + def current_cover_position(self): + """Return current position of cover. 0 is closed, 100 is open.""" + return self.bound(self.level) + + @property + def current_cover_tilt_position(self): + """Return the current tilt position for venetian blinds.""" + return self.bound(self.level2) + + def set_cover_position(self, **kwargs): + """Move the cover to a specific position.""" + self.set_level(kwargs.get(ATTR_POSITION)) + + def set_cover_tilt_position(self, **kwargs): + """Move the cover to a specific position.""" + self.set_level2(kwargs.get(ATTR_TILT_POSITION)) + + @property + def is_closed(self): + """Return if the cover is closed.""" + if self.current_cover_position is None: + return None + return self.current_cover_position == 0 + + def open_cover(self, **kwargs): + """Open the cover.""" + self.action("open") + + def close_cover(self, **kwargs): + """Close the cover.""" + self.action("close") + + def open_cover_tilt(self, **kwargs): + """Open the cover tilt.""" + self.set_level2(100) + + def close_cover_tilt(self, **kwargs): + """Close the cover.""" + self.set_level2(0) + + def stop_cover(self, **kwargs): + """Stop the cover.""" + self.action("stop") diff --git a/homeassistant/components/fibaro/light.py b/homeassistant/components/fibaro/light.py new file mode 100644 index 0000000000000..ba77942a44819 --- /dev/null +++ b/homeassistant/components/fibaro/light.py @@ -0,0 +1,209 @@ +"""Support for Fibaro lights.""" +import asyncio +from functools import partial +import logging + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_HS_COLOR, + ATTR_WHITE_VALUE, + ENTITY_ID_FORMAT, + SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, + SUPPORT_WHITE_VALUE, + Light, +) +from homeassistant.const import CONF_WHITE_VALUE +import homeassistant.util.color as color_util + +from . import CONF_COLOR, CONF_DIMMING, CONF_RESET_COLOR, FIBARO_DEVICES, FibaroDevice + +_LOGGER = logging.getLogger(__name__) + + +def scaleto255(value): + """Scale the input value from 0-100 to 0-255.""" + # Fibaro has a funny way of storing brightness either 0-100 or 0-99 + # depending on device type (e.g. dimmer vs led) + if value > 98: + value = 100 + return max(0, min(255, ((value * 255.0) / 100.0))) + + +def scaleto100(value): + """Scale the input value from 0-255 to 0-100.""" + # Make sure a low but non-zero value is not rounded down to zero + if 0 < value < 3: + return 1 + return max(0, min(100, ((value * 100.0) / 255.0))) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Perform the setup for Fibaro controller devices.""" + if discovery_info is None: + return + + async_add_entities( + [FibaroLight(device) for device in hass.data[FIBARO_DEVICES]["light"]], True + ) + + +class FibaroLight(FibaroDevice, Light): + """Representation of a Fibaro Light, including dimmable.""" + + def __init__(self, fibaro_device): + """Initialize the light.""" + self._brightness = None + self._color = (0, 0) + self._last_brightness = 0 + self._supported_flags = 0 + self._update_lock = asyncio.Lock() + self._white = 0 + + devconf = fibaro_device.device_config + self._reset_color = devconf.get(CONF_RESET_COLOR, False) + supports_color = ( + "color" in fibaro_device.properties and "setColor" in fibaro_device.actions + ) + supports_dimming = "levelChange" in fibaro_device.interfaces + supports_white_v = "setW" in fibaro_device.actions + + # Configuration can overrride default capability detection + if devconf.get(CONF_DIMMING, supports_dimming): + self._supported_flags |= SUPPORT_BRIGHTNESS + if devconf.get(CONF_COLOR, supports_color): + self._supported_flags |= SUPPORT_COLOR + if devconf.get(CONF_WHITE_VALUE, supports_white_v): + self._supported_flags |= SUPPORT_WHITE_VALUE + + super().__init__(fibaro_device) + self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) + + @property + def brightness(self): + """Return the brightness of the light.""" + return scaleto255(self._brightness) + + @property + def hs_color(self): + """Return the color of the light.""" + return self._color + + @property + def white_value(self): + """Return the white value of this light between 0..255.""" + return self._white + + @property + def supported_features(self): + """Flag supported features.""" + return self._supported_flags + + async def async_turn_on(self, **kwargs): + """Turn the light on.""" + async with self._update_lock: + await self.hass.async_add_executor_job(partial(self._turn_on, **kwargs)) + + def _turn_on(self, **kwargs): + """Really turn the light on.""" + if self._supported_flags & SUPPORT_BRIGHTNESS: + target_brightness = kwargs.get(ATTR_BRIGHTNESS) + + # No brightness specified, so we either restore it to + # last brightness or switch it on at maximum level + if target_brightness is None: + if self._brightness == 0: + if self._last_brightness: + self._brightness = self._last_brightness + else: + self._brightness = 100 + else: + # We set it to the target brightness and turn it on + self._brightness = scaleto100(target_brightness) + + if self._supported_flags & SUPPORT_COLOR: + if ( + self._reset_color + and kwargs.get(ATTR_WHITE_VALUE) is None + and kwargs.get(ATTR_HS_COLOR) is None + and kwargs.get(ATTR_BRIGHTNESS) is None + ): + self._color = (100, 0) + + # Update based on parameters + self._white = kwargs.get(ATTR_WHITE_VALUE, self._white) + self._color = kwargs.get(ATTR_HS_COLOR, self._color) + rgb = color_util.color_hs_to_RGB(*self._color) + self.call_set_color( + round(rgb[0] * self._brightness / 100.0), + round(rgb[1] * self._brightness / 100.0), + round(rgb[2] * self._brightness / 100.0), + round(self._white * self._brightness / 100.0), + ) + + if self.state == "off": + self.set_level(int(self._brightness)) + return + + if self._reset_color: + bri255 = scaleto255(self._brightness) + self.call_set_color(bri255, bri255, bri255, bri255) + + if self._supported_flags & SUPPORT_BRIGHTNESS: + self.set_level(int(self._brightness)) + return + + # The simplest case is left for last. No dimming, just switch on + self.call_turn_on() + + async def async_turn_off(self, **kwargs): + """Turn the light off.""" + async with self._update_lock: + await self.hass.async_add_executor_job(partial(self._turn_off, **kwargs)) + + def _turn_off(self, **kwargs): + """Really turn the light off.""" + # Let's save the last brightness level before we switch it off + if ( + (self._supported_flags & SUPPORT_BRIGHTNESS) + and self._brightness + and self._brightness > 0 + ): + self._last_brightness = self._brightness + self._brightness = 0 + self.call_turn_off() + + @property + def is_on(self): + """Return true if device is on.""" + return self.current_binary_state + + async def async_update(self): + """Update the state.""" + async with self._update_lock: + await self.hass.async_add_executor_job(self._update) + + def _update(self): + """Really update the state.""" + # Brightness handling + if self._supported_flags & SUPPORT_BRIGHTNESS: + self._brightness = float(self.fibaro_device.properties.value) + # Fibaro might report 0-99 or 0-100 for brightness, + # based on device type, so we round up here + if self._brightness > 99: + self._brightness = 100 + # Color handling + if ( + self._supported_flags & SUPPORT_COLOR + and "color" in self.fibaro_device.properties + and "," in self.fibaro_device.properties.color + ): + # Fibaro communicates the color as an 'R, G, B, W' string + rgbw_s = self.fibaro_device.properties.color + if rgbw_s == "0,0,0,0" and "lastColorSet" in self.fibaro_device.properties: + rgbw_s = self.fibaro_device.properties.lastColorSet + rgbw_list = [int(i) for i in rgbw_s.split(",")][:4] + if rgbw_list[0] or rgbw_list[1] or rgbw_list[2]: + self._color = color_util.color_RGB_to_hs(*rgbw_list[:3]) + if (self._supported_flags & SUPPORT_WHITE_VALUE) and self.brightness != 0: + self._white = min(255, max(0, rgbw_list[3] * 100.0 / self._brightness)) diff --git a/homeassistant/components/fibaro/manifest.json b/homeassistant/components/fibaro/manifest.json new file mode 100644 index 0000000000000..b4288afee7113 --- /dev/null +++ b/homeassistant/components/fibaro/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "fibaro", + "name": "Fibaro", + "documentation": "https://www.home-assistant.io/integrations/fibaro", + "requirements": ["fiblary3==0.1.7"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/fibaro/scene.py b/homeassistant/components/fibaro/scene.py new file mode 100644 index 0000000000000..06d11bc1f5cfd --- /dev/null +++ b/homeassistant/components/fibaro/scene.py @@ -0,0 +1,26 @@ +"""Support for Fibaro scenes.""" +import logging + +from homeassistant.components.scene import Scene + +from . import FIBARO_DEVICES, FibaroDevice + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Perform the setup for Fibaro scenes.""" + if discovery_info is None: + return + + async_add_entities( + [FibaroScene(scene) for scene in hass.data[FIBARO_DEVICES]["scene"]], True + ) + + +class FibaroScene(FibaroDevice, Scene): + """Representation of a Fibaro scene entity.""" + + def activate(self): + """Activate the scene.""" + self.fibaro_device.start() diff --git a/homeassistant/components/fibaro/sensor.py b/homeassistant/components/fibaro/sensor.py new file mode 100644 index 0000000000000..1e0bae212f8f2 --- /dev/null +++ b/homeassistant/components/fibaro/sensor.py @@ -0,0 +1,97 @@ +"""Support for Fibaro sensors.""" +import logging + +from homeassistant.components.sensor import ENTITY_ID_FORMAT +from homeassistant.const import ( + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_TEMPERATURE, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) +from homeassistant.helpers.entity import Entity + +from . import FIBARO_DEVICES, FibaroDevice + +SENSOR_TYPES = { + "com.fibaro.temperatureSensor": [ + "Temperature", + None, + None, + DEVICE_CLASS_TEMPERATURE, + ], + "com.fibaro.smokeSensor": ["Smoke", "ppm", "mdi:fire", None], + "CO2": ["CO2", "ppm", "mdi:cloud", None], + "com.fibaro.humiditySensor": ["Humidity", "%", None, DEVICE_CLASS_HUMIDITY], + "com.fibaro.lightSensor": ["Light", "lx", None, DEVICE_CLASS_ILLUMINANCE], +} + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Fibaro controller devices.""" + if discovery_info is None: + return + + add_entities( + [FibaroSensor(device) for device in hass.data[FIBARO_DEVICES]["sensor"]], True + ) + + +class FibaroSensor(FibaroDevice, Entity): + """Representation of a Fibaro Sensor.""" + + def __init__(self, fibaro_device): + """Initialize the sensor.""" + self.current_value = None + self.last_changed_time = None + super().__init__(fibaro_device) + self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) + if fibaro_device.type in SENSOR_TYPES: + self._unit = SENSOR_TYPES[fibaro_device.type][1] + self._icon = SENSOR_TYPES[fibaro_device.type][2] + self._device_class = SENSOR_TYPES[fibaro_device.type][3] + else: + self._unit = None + self._icon = None + self._device_class = None + try: + if not self._unit: + if self.fibaro_device.properties.unit == "lux": + self._unit = "lx" + elif self.fibaro_device.properties.unit == "C": + self._unit = TEMP_CELSIUS + elif self.fibaro_device.properties.unit == "F": + self._unit = TEMP_FAHRENHEIT + else: + self._unit = self.fibaro_device.properties.unit + except (KeyError, ValueError): + pass + + @property + def state(self): + """Return the state of the sensor.""" + return self.current_value + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._unit + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return self._icon + + @property + def device_class(self): + """Return the device class of the sensor.""" + return self._device_class + + def update(self): + """Update the state.""" + try: + self.current_value = float(self.fibaro_device.properties.value) + except (KeyError, ValueError): + pass diff --git a/homeassistant/components/fibaro/switch.py b/homeassistant/components/fibaro/switch.py new file mode 100644 index 0000000000000..4bb8c34d57953 --- /dev/null +++ b/homeassistant/components/fibaro/switch.py @@ -0,0 +1,62 @@ +"""Support for Fibaro switches.""" +import logging + +from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchDevice +from homeassistant.util import convert + +from . import FIBARO_DEVICES, FibaroDevice + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Fibaro switches.""" + if discovery_info is None: + return + + add_entities( + [FibaroSwitch(device) for device in hass.data[FIBARO_DEVICES]["switch"]], True + ) + + +class FibaroSwitch(FibaroDevice, SwitchDevice): + """Representation of a Fibaro Switch.""" + + def __init__(self, fibaro_device): + """Initialize the Fibaro device.""" + self._state = False + super().__init__(fibaro_device) + self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) + + def turn_on(self, **kwargs): + """Turn device on.""" + self.call_turn_on() + self._state = True + + def turn_off(self, **kwargs): + """Turn device off.""" + self.call_turn_off() + self._state = False + + @property + def current_power_w(self): + """Return the current power usage in W.""" + if "power" in self.fibaro_device.interfaces: + return convert(self.fibaro_device.properties.power, float, 0.0) + return None + + @property + def today_energy_kwh(self): + """Return the today total energy usage in kWh.""" + if "energy" in self.fibaro_device.interfaces: + return convert(self.fibaro_device.properties.energy, float, 0.0) + return None + + @property + def is_on(self): + """Return true if device is on.""" + return self._state + + def update(self): + """Update device state.""" + self._state = self.current_binary_state diff --git a/homeassistant/components/fido/__init__.py b/homeassistant/components/fido/__init__.py new file mode 100644 index 0000000000000..d950d39ef7075 --- /dev/null +++ b/homeassistant/components/fido/__init__.py @@ -0,0 +1 @@ +"""The fido component.""" diff --git a/homeassistant/components/fido/manifest.json b/homeassistant/components/fido/manifest.json new file mode 100644 index 0000000000000..690fc3ed777e4 --- /dev/null +++ b/homeassistant/components/fido/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "fido", + "name": "Fido", + "documentation": "https://www.home-assistant.io/integrations/fido", + "requirements": ["pyfido==2.1.1"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/fido/sensor.py b/homeassistant/components/fido/sensor.py new file mode 100644 index 0000000000000..086ae87a52999 --- /dev/null +++ b/homeassistant/components/fido/sensor.py @@ -0,0 +1,167 @@ +""" +Support for Fido. + +Get data from 'Usage Summary' page: +https://www.fido.ca/pages/#/my-account/wireless + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.fido/ +""" +from datetime import timedelta +import logging + +from pyfido import FidoClient +from pyfido.client import PyFidoError +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_MONITORED_VARIABLES, + 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__) + +KILOBITS = "Kb" +PRICE = "CAD" +MESSAGES = "messages" +MINUTES = "minutes" + +DEFAULT_NAME = "Fido" + +REQUESTS_TIMEOUT = 15 +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) + +SENSOR_TYPES = { + "fido_dollar": ["Fido dollar", PRICE, "mdi:square-inc-cash"], + "balance": ["Balance", PRICE, "mdi:square-inc-cash"], + "data_used": ["Data used", KILOBITS, "mdi:download"], + "data_limit": ["Data limit", KILOBITS, "mdi:download"], + "data_remaining": ["Data remaining", KILOBITS, "mdi:download"], + "text_used": ["Text used", MESSAGES, "mdi:message-text"], + "text_limit": ["Text limit", MESSAGES, "mdi:message-text"], + "text_remaining": ["Text remaining", MESSAGES, "mdi:message-text"], + "mms_used": ["MMS used", MESSAGES, "mdi:message-image"], + "mms_limit": ["MMS limit", MESSAGES, "mdi:message-image"], + "mms_remaining": ["MMS remaining", MESSAGES, "mdi:message-image"], + "text_int_used": ["International text used", MESSAGES, "mdi:message-alert"], + "text_int_limit": ["International text limit", MESSAGES, "mdi:message-alert"], + "text_int_remaining": ["International remaining", MESSAGES, "mdi:message-alert"], + "talk_used": ["Talk used", MINUTES, "mdi:cellphone"], + "talk_limit": ["Talk limit", MINUTES, "mdi:cellphone"], + "talk_remaining": ["Talk remaining", MINUTES, "mdi:cellphone"], + "other_talk_used": ["Other Talk used", MINUTES, "mdi:cellphone"], + "other_talk_limit": ["Other Talk limit", MINUTES, "mdi:cellphone"], + "other_talk_remaining": ["Other Talk remaining", MINUTES, "mdi:cellphone"], +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_MONITORED_VARIABLES): vol.All( + cv.ensure_list, [vol.In(SENSOR_TYPES)] + ), + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): 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 Fido sensor.""" + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + + httpsession = hass.helpers.aiohttp_client.async_get_clientsession() + fido_data = FidoData(username, password, httpsession) + ret = await fido_data.async_update() + if ret is False: + return + + name = config.get(CONF_NAME) + + sensors = [] + for number in fido_data.client.get_phone_numbers(): + for variable in config[CONF_MONITORED_VARIABLES]: + sensors.append(FidoSensor(fido_data, variable, name, number)) + + async_add_entities(sensors, True) + + +class FidoSensor(Entity): + """Implementation of a Fido sensor.""" + + def __init__(self, fido_data, sensor_type, name, number): + """Initialize the sensor.""" + self.client_name = name + self._number = number + 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.fido_data = fido_data + self._state = None + + @property + def name(self): + """Return the name of the sensor.""" + return f"{self.client_name} {self._number} {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 of the sensor.""" + return {"number": self._number} + + async def async_update(self): + """Get the latest data from Fido and update the state.""" + await self.fido_data.async_update() + if self.type == "balance": + if self.fido_data.data.get(self.type) is not None: + self._state = round(self.fido_data.data[self.type], 2) + else: + if self.fido_data.data.get(self._number, {}).get(self.type) is not None: + self._state = self.fido_data.data[self._number][self.type] + self._state = round(self._state, 2) + + +class FidoData: + """Get data from Fido.""" + + def __init__(self, username, password, httpsession): + """Initialize the data object.""" + + self.client = FidoClient(username, password, REQUESTS_TIMEOUT, httpsession) + self.data = {} + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + async def async_update(self): + """Get the latest data from Fido.""" + + try: + await self.client.fetch_data() + except PyFidoError as exp: + _LOGGER.error("Error on receive last Fido data: %s", exp) + return False + # Update data + self.data = self.client.get_data() + return True diff --git a/homeassistant/components/file/__init__.py b/homeassistant/components/file/__init__.py new file mode 100644 index 0000000000000..ed31fa957dd00 --- /dev/null +++ b/homeassistant/components/file/__init__.py @@ -0,0 +1 @@ +"""The file component.""" diff --git a/homeassistant/components/file/manifest.json b/homeassistant/components/file/manifest.json new file mode 100644 index 0000000000000..b0340eb271ee0 --- /dev/null +++ b/homeassistant/components/file/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "file", + "name": "File", + "documentation": "https://www.home-assistant.io/integrations/file", + "requirements": [], + "dependencies": [], + "codeowners": ["@fabaff"] +} diff --git a/homeassistant/components/file/notify.py b/homeassistant/components/file/notify.py new file mode 100644 index 0000000000000..4cd83e64a83b5 --- /dev/null +++ b/homeassistant/components/file/notify.py @@ -0,0 +1,60 @@ +"""Support for file notification.""" +import logging +import os + +import voluptuous as vol + +from homeassistant.components.notify import ( + ATTR_TITLE, + ATTR_TITLE_DEFAULT, + PLATFORM_SCHEMA, + BaseNotificationService, +) +from homeassistant.const import CONF_FILENAME +import homeassistant.helpers.config_validation as cv +import homeassistant.util.dt as dt_util + +CONF_TIMESTAMP = "timestamp" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_FILENAME): cv.string, + vol.Optional(CONF_TIMESTAMP, default=False): cv.boolean, + } +) + +_LOGGER = logging.getLogger(__name__) + + +def get_service(hass, config, discovery_info=None): + """Get the file notification service.""" + filename = config[CONF_FILENAME] + timestamp = config[CONF_TIMESTAMP] + + return FileNotificationService(hass, filename, timestamp) + + +class FileNotificationService(BaseNotificationService): + """Implement the notification service for the File service.""" + + def __init__(self, hass, filename, add_timestamp): + """Initialize the service.""" + self.filepath = os.path.join(hass.config.config_dir, filename) + self.add_timestamp = add_timestamp + + def send_message(self, message="", **kwargs): + """Send a message to a file.""" + with open(self.filepath, "a") as file: + if os.stat(self.filepath).st_size == 0: + title = "{} notifications (Log started: {})\n{}\n".format( + kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT), + dt_util.utcnow().isoformat(), + "-" * 80, + ) + file.write(title) + + if self.add_timestamp: + text = "{} {}\n".format(dt_util.utcnow().isoformat(), message) + else: + text = f"{message}\n" + file.write(text) diff --git a/homeassistant/components/file/sensor.py b/homeassistant/components/file/sensor.py new file mode 100644 index 0000000000000..96ae885ca77fc --- /dev/null +++ b/homeassistant/components/file/sensor.py @@ -0,0 +1,96 @@ +"""Support for sensor value(s) stored in local files.""" +import logging +import os + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_NAME, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +CONF_FILE_PATH = "file_path" + +DEFAULT_NAME = "File" + +ICON = "mdi:file" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_FILE_PATH): cv.isfile, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + } +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the file sensor.""" + file_path = config.get(CONF_FILE_PATH) + name = config.get(CONF_NAME) + unit = config.get(CONF_UNIT_OF_MEASUREMENT) + value_template = config.get(CONF_VALUE_TEMPLATE) + + if value_template is not None: + value_template.hass = hass + + if hass.config.is_allowed_path(file_path): + async_add_entities([FileSensor(name, file_path, unit, value_template)], True) + else: + _LOGGER.error("'%s' is not a whitelisted directory", file_path) + + +class FileSensor(Entity): + """Implementation of a file sensor.""" + + def __init__(self, name, file_path, unit_of_measurement, value_template): + """Initialize the file sensor.""" + self._name = name + self._file_path = file_path + self._unit_of_measurement = unit_of_measurement + self._val_tpl = value_template + self._state = None + + @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 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 + + def update(self): + """Get the latest entry from a file and updates the state.""" + try: + with open(self._file_path, "r", encoding="utf-8") as file_data: + for line in file_data: + data = line + data = data.strip() + except (IndexError, FileNotFoundError, IsADirectoryError, UnboundLocalError): + _LOGGER.warning( + "File or data not present at the moment: %s", + os.path.basename(self._file_path), + ) + return + + if self._val_tpl is not None: + self._state = self._val_tpl.async_render_with_possible_json_value( + data, None + ) + else: + self._state = data diff --git a/homeassistant/components/filesize/__init__.py b/homeassistant/components/filesize/__init__.py new file mode 100644 index 0000000000000..c532274997ce1 --- /dev/null +++ b/homeassistant/components/filesize/__init__.py @@ -0,0 +1 @@ +"""The filesize component.""" diff --git a/homeassistant/components/filesize/manifest.json b/homeassistant/components/filesize/manifest.json new file mode 100644 index 0000000000000..4687e0745477b --- /dev/null +++ b/homeassistant/components/filesize/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "filesize", + "name": "File Size", + "documentation": "https://www.home-assistant.io/integrations/filesize", + "requirements": [], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/filesize/sensor.py b/homeassistant/components/filesize/sensor.py new file mode 100644 index 0000000000000..8c6cd30b1186f --- /dev/null +++ b/homeassistant/components/filesize/sensor.py @@ -0,0 +1,84 @@ +"""Sensor for monitoring the size of a file.""" +import datetime +import logging +import os + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + + +CONF_FILE_PATHS = "file_paths" +ICON = "mdi:file" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + {vol.Required(CONF_FILE_PATHS): vol.All(cv.ensure_list, [cv.isfile])} +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the file size sensor.""" + sensors = [] + for path in config.get(CONF_FILE_PATHS): + if not hass.config.is_allowed_path(path): + _LOGGER.error("Filepath %s is not valid or allowed", path) + continue + sensors.append(Filesize(path)) + + if sensors: + add_entities(sensors, True) + + +class Filesize(Entity): + """Encapsulates file size information.""" + + def __init__(self, path): + """Initialize the data object.""" + self._path = path # Need to check its a valid path + self._size = None + self._last_updated = None + self._name = path.split("/")[-1] + self._unit_of_measurement = "MB" + + def update(self): + """Update the sensor.""" + statinfo = os.stat(self._path) + self._size = statinfo.st_size + last_updated = datetime.datetime.fromtimestamp(statinfo.st_mtime) + self._last_updated = last_updated.isoformat() + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the size of the file in MB.""" + decimals = 2 + state_mb = round(self._size / 1e6, decimals) + return state_mb + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return ICON + + @property + def device_state_attributes(self): + """Return other details about the sensor state.""" + attr = { + "path": self._path, + "last_updated": self._last_updated, + "bytes": self._size, + } + return attr + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._unit_of_measurement diff --git a/homeassistant/components/filter/__init__.py b/homeassistant/components/filter/__init__.py new file mode 100644 index 0000000000000..ebdcec75abf2f --- /dev/null +++ b/homeassistant/components/filter/__init__.py @@ -0,0 +1 @@ +"""The filter component.""" diff --git a/homeassistant/components/filter/manifest.json b/homeassistant/components/filter/manifest.json new file mode 100644 index 0000000000000..d1933507f4d68 --- /dev/null +++ b/homeassistant/components/filter/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "filter", + "name": "Filter", + "documentation": "https://www.home-assistant.io/integrations/filter", + "requirements": [], + "dependencies": ["history"], + "codeowners": ["@dgomes"], + "quality_scale": "internal" +} diff --git a/homeassistant/components/filter/sensor.py b/homeassistant/components/filter/sensor.py new file mode 100644 index 0000000000000..baa4f90af3f8d --- /dev/null +++ b/homeassistant/components/filter/sensor.py @@ -0,0 +1,602 @@ +"""Allows the creation of a sensor that filters state property.""" +from collections import Counter, deque +from copy import copy +from datetime import timedelta +from functools import partial +import logging +from numbers import Number +import statistics +from typing import Optional + +import voluptuous as vol + +from homeassistant.components import history +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_ICON, + ATTR_UNIT_OF_MEASUREMENT, + CONF_ENTITY_ID, + CONF_NAME, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_track_state_change +from homeassistant.util.decorator import Registry +import homeassistant.util.dt as dt_util + +_LOGGER = logging.getLogger(__name__) + +FILTER_NAME_RANGE = "range" +FILTER_NAME_LOWPASS = "lowpass" +FILTER_NAME_OUTLIER = "outlier" +FILTER_NAME_THROTTLE = "throttle" +FILTER_NAME_TIME_THROTTLE = "time_throttle" +FILTER_NAME_TIME_SMA = "time_simple_moving_average" +FILTERS = Registry() + +CONF_FILTERS = "filters" +CONF_FILTER_NAME = "filter" +CONF_FILTER_WINDOW_SIZE = "window_size" +CONF_FILTER_PRECISION = "precision" +CONF_FILTER_RADIUS = "radius" +CONF_FILTER_TIME_CONSTANT = "time_constant" +CONF_FILTER_LOWER_BOUND = "lower_bound" +CONF_FILTER_UPPER_BOUND = "upper_bound" +CONF_TIME_SMA_TYPE = "type" + +TIME_SMA_LAST = "last" + +WINDOW_SIZE_UNIT_NUMBER_EVENTS = 1 +WINDOW_SIZE_UNIT_TIME = 2 + +DEFAULT_WINDOW_SIZE = 1 +DEFAULT_PRECISION = 2 +DEFAULT_FILTER_RADIUS = 2.0 +DEFAULT_FILTER_TIME_CONSTANT = 10 + +NAME_TEMPLATE = "{} filter" +ICON = "mdi:chart-line-variant" + +FILTER_SCHEMA = vol.Schema( + {vol.Optional(CONF_FILTER_PRECISION, default=DEFAULT_PRECISION): vol.Coerce(int)} +) + +FILTER_OUTLIER_SCHEMA = FILTER_SCHEMA.extend( + { + vol.Required(CONF_FILTER_NAME): FILTER_NAME_OUTLIER, + vol.Optional(CONF_FILTER_WINDOW_SIZE, default=DEFAULT_WINDOW_SIZE): vol.Coerce( + int + ), + vol.Optional(CONF_FILTER_RADIUS, default=DEFAULT_FILTER_RADIUS): vol.Coerce( + float + ), + } +) + +FILTER_LOWPASS_SCHEMA = FILTER_SCHEMA.extend( + { + vol.Required(CONF_FILTER_NAME): FILTER_NAME_LOWPASS, + vol.Optional(CONF_FILTER_WINDOW_SIZE, default=DEFAULT_WINDOW_SIZE): vol.Coerce( + int + ), + vol.Optional( + CONF_FILTER_TIME_CONSTANT, default=DEFAULT_FILTER_TIME_CONSTANT + ): vol.Coerce(int), + } +) + +FILTER_RANGE_SCHEMA = FILTER_SCHEMA.extend( + { + vol.Required(CONF_FILTER_NAME): FILTER_NAME_RANGE, + vol.Optional(CONF_FILTER_LOWER_BOUND): vol.Coerce(float), + vol.Optional(CONF_FILTER_UPPER_BOUND): vol.Coerce(float), + } +) + +FILTER_TIME_SMA_SCHEMA = FILTER_SCHEMA.extend( + { + vol.Required(CONF_FILTER_NAME): FILTER_NAME_TIME_SMA, + vol.Optional(CONF_TIME_SMA_TYPE, default=TIME_SMA_LAST): vol.In( + [TIME_SMA_LAST] + ), + vol.Required(CONF_FILTER_WINDOW_SIZE): vol.All( + cv.time_period, cv.positive_timedelta + ), + } +) + +FILTER_THROTTLE_SCHEMA = FILTER_SCHEMA.extend( + { + vol.Required(CONF_FILTER_NAME): FILTER_NAME_THROTTLE, + vol.Optional(CONF_FILTER_WINDOW_SIZE, default=DEFAULT_WINDOW_SIZE): vol.Coerce( + int + ), + } +) + +FILTER_TIME_THROTTLE_SCHEMA = FILTER_SCHEMA.extend( + { + vol.Required(CONF_FILTER_NAME): FILTER_NAME_TIME_THROTTLE, + vol.Required(CONF_FILTER_WINDOW_SIZE): vol.All( + cv.time_period, cv.positive_timedelta + ), + } +) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Optional(CONF_NAME): cv.string, + vol.Required(CONF_FILTERS): vol.All( + cv.ensure_list, + [ + vol.Any( + FILTER_OUTLIER_SCHEMA, + FILTER_LOWPASS_SCHEMA, + FILTER_TIME_SMA_SCHEMA, + FILTER_THROTTLE_SCHEMA, + FILTER_TIME_THROTTLE_SCHEMA, + FILTER_RANGE_SCHEMA, + ) + ], + ), + } +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the template sensors.""" + name = config.get(CONF_NAME) + entity_id = config.get(CONF_ENTITY_ID) + + filters = [ + FILTERS[_filter.pop(CONF_FILTER_NAME)](entity=entity_id, **_filter) + for _filter in config[CONF_FILTERS] + ] + + async_add_entities([SensorFilter(name, entity_id, filters)]) + + +class SensorFilter(Entity): + """Representation of a Filter Sensor.""" + + def __init__(self, name, entity_id, filters): + """Initialize the sensor.""" + self._name = name + self._entity = entity_id + self._unit_of_measurement = None + self._state = None + self._filters = filters + self._icon = None + + async def async_added_to_hass(self): + """Register callbacks.""" + + @callback + def filter_sensor_state_listener(entity, old_state, new_state, update_ha=True): + """Handle device state changes.""" + if new_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE]: + return + + temp_state = new_state + + try: + for filt in self._filters: + filtered_state = filt.filter_state(copy(temp_state)) + _LOGGER.debug( + "%s(%s=%s) -> %s", + filt.name, + self._entity, + temp_state.state, + "skip" if filt.skip_processing else filtered_state.state, + ) + if filt.skip_processing: + return + temp_state = filtered_state + except ValueError: + _LOGGER.error("Could not convert state: %s to number", self._state) + return + + self._state = temp_state.state + + if self._icon is None: + self._icon = new_state.attributes.get(ATTR_ICON, ICON) + + if self._unit_of_measurement is None: + self._unit_of_measurement = new_state.attributes.get( + ATTR_UNIT_OF_MEASUREMENT + ) + + if update_ha: + self.async_schedule_update_ha_state() + + if "recorder" in self.hass.config.components: + history_list = [] + largest_window_items = 0 + largest_window_time = timedelta(0) + + # Determine the largest window_size by type + for filt in self._filters: + if ( + filt.window_unit == WINDOW_SIZE_UNIT_NUMBER_EVENTS + and largest_window_items < filt.window_size + ): + largest_window_items = filt.window_size + elif ( + filt.window_unit == WINDOW_SIZE_UNIT_TIME + and largest_window_time < filt.window_size + ): + largest_window_time = filt.window_size + + # Retrieve the largest window_size of each type + if largest_window_items > 0: + filter_history = await self.hass.async_add_job( + partial( + history.get_last_state_changes, + self.hass, + largest_window_items, + entity_id=self._entity, + ) + ) + if self._entity in filter_history: + history_list.extend(filter_history[self._entity]) + if largest_window_time > timedelta(seconds=0): + start = dt_util.utcnow() - largest_window_time + filter_history = await self.hass.async_add_job( + partial( + history.state_changes_during_period, + self.hass, + start, + entity_id=self._entity, + ) + ) + if self._entity in filter_history: + history_list.extend( + [ + state + for state in filter_history[self._entity] + if state not in history_list + ] + ) + + # Sort the window states + history_list = sorted(history_list, key=lambda s: s.last_updated) + _LOGGER.debug( + "Loading from history: %s", + [(s.state, s.last_updated) for s in history_list], + ) + + # Replay history through the filter chain + prev_state = None + for state in history_list: + filter_sensor_state_listener(self._entity, prev_state, state, False) + prev_state = state + + async_track_state_change(self.hass, self._entity, filter_sensor_state_listener) + + @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 to use in the frontend, if any.""" + return self._icon + + @property + def unit_of_measurement(self): + """Return the unit_of_measurement of the device.""" + return self._unit_of_measurement + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + state_attr = {ATTR_ENTITY_ID: self._entity} + return state_attr + + +class FilterState: + """State abstraction for filter usage.""" + + def __init__(self, state): + """Initialize with HA State object.""" + self.timestamp = state.last_updated + try: + self.state = float(state.state) + except ValueError: + self.state = state.state + + def set_precision(self, precision): + """Set precision of Number based states.""" + if isinstance(self.state, Number): + value = round(float(self.state), precision) + self.state = int(value) if precision == 0 else value + + def __str__(self): + """Return state as the string representation of FilterState.""" + return str(self.state) + + def __repr__(self): + """Return timestamp and state as the representation of FilterState.""" + return f"{self.timestamp} : {self.state}" + + +class Filter: + """Filter skeleton.""" + + def __init__( + self, + name, + window_size: int = 1, + precision: Optional[int] = None, + entity: Optional[str] = None, + ): + """Initialize common attributes. + + :param window_size: size of the sliding window that holds previous values + :param precision: round filtered value to precision value + :param entity: used for debugging only + """ + if isinstance(window_size, int): + self.states = deque(maxlen=window_size) + self.window_unit = WINDOW_SIZE_UNIT_NUMBER_EVENTS + else: + self.states = deque(maxlen=0) + self.window_unit = WINDOW_SIZE_UNIT_TIME + self.precision = precision + self._name = name + self._entity = entity + self._skip_processing = False + self._window_size = window_size + self._store_raw = False + + @property + def window_size(self): + """Return window size.""" + return self._window_size + + @property + def name(self): + """Return filter name.""" + return self._name + + @property + def skip_processing(self): + """Return wether the current filter_state should be skipped.""" + return self._skip_processing + + def _filter_state(self, new_state): + """Implement filter.""" + raise NotImplementedError() + + def filter_state(self, new_state): + """Implement a common interface for filters.""" + filtered = self._filter_state(FilterState(new_state)) + filtered.set_precision(self.precision) + if self._store_raw: + self.states.append(copy(FilterState(new_state))) + else: + self.states.append(copy(filtered)) + new_state.state = filtered.state + return new_state + + +@FILTERS.register(FILTER_NAME_RANGE) +class RangeFilter(Filter): + """Range filter. + + Determines if new state is in the range of upper_bound and lower_bound. + If not inside, lower or upper bound is returned instead. + """ + + def __init__( + self, + entity, + precision: Optional[int] = DEFAULT_PRECISION, + lower_bound: Optional[float] = None, + upper_bound: Optional[float] = None, + ): + """Initialize Filter. + + :param upper_bound: band upper bound + :param lower_bound: band lower bound + """ + super().__init__(FILTER_NAME_RANGE, precision=precision, entity=entity) + self._lower_bound = lower_bound + self._upper_bound = upper_bound + self._stats_internal = Counter() + + def _filter_state(self, new_state): + """Implement the range filter.""" + if self._upper_bound is not None and new_state.state > self._upper_bound: + + self._stats_internal["erasures_up"] += 1 + + _LOGGER.debug( + "Upper outlier nr. %s in %s: %s", + self._stats_internal["erasures_up"], + self._entity, + new_state, + ) + new_state.state = self._upper_bound + + elif self._lower_bound is not None and new_state.state < self._lower_bound: + + self._stats_internal["erasures_low"] += 1 + + _LOGGER.debug( + "Lower outlier nr. %s in %s: %s", + self._stats_internal["erasures_low"], + self._entity, + new_state, + ) + new_state.state = self._lower_bound + + return new_state + + +@FILTERS.register(FILTER_NAME_OUTLIER) +class OutlierFilter(Filter): + """BASIC outlier filter. + + Determines if new state is in a band around the median. + """ + + def __init__(self, window_size, precision, entity, radius: float): + """Initialize Filter. + + :param radius: band radius + """ + super().__init__(FILTER_NAME_OUTLIER, window_size, precision, entity) + self._radius = radius + self._stats_internal = Counter() + self._store_raw = True + + def _filter_state(self, new_state): + """Implement the outlier filter.""" + median = statistics.median([s.state for s in self.states]) if self.states else 0 + if ( + len(self.states) == self.states.maxlen + and abs(new_state.state - median) > self._radius + ): + + self._stats_internal["erasures"] += 1 + + _LOGGER.debug( + "Outlier nr. %s in %s: %s", + self._stats_internal["erasures"], + self._entity, + new_state, + ) + new_state.state = median + return new_state + + +@FILTERS.register(FILTER_NAME_LOWPASS) +class LowPassFilter(Filter): + """BASIC Low Pass Filter.""" + + def __init__(self, window_size, precision, entity, time_constant: int): + """Initialize Filter.""" + super().__init__(FILTER_NAME_LOWPASS, window_size, precision, entity) + self._time_constant = time_constant + + def _filter_state(self, new_state): + """Implement the low pass filter.""" + if not self.states: + return new_state + + new_weight = 1.0 / self._time_constant + prev_weight = 1.0 - new_weight + new_state.state = ( + prev_weight * self.states[-1].state + new_weight * new_state.state + ) + + return new_state + + +@FILTERS.register(FILTER_NAME_TIME_SMA) +class TimeSMAFilter(Filter): + """Simple Moving Average (SMA) Filter. + + The window_size is determined by time, and SMA is time weighted. + """ + + def __init__( + self, window_size, precision, entity, type + ): # pylint: disable=redefined-builtin + """Initialize Filter. + + :param type: type of algorithm used to connect discrete values + """ + super().__init__(FILTER_NAME_TIME_SMA, window_size, precision, entity) + self._time_window = window_size + self.last_leak = None + self.queue = deque() + + def _leak(self, left_boundary): + """Remove timeouted elements.""" + while self.queue: + if self.queue[0].timestamp + self._time_window <= left_boundary: + self.last_leak = self.queue.popleft() + else: + return + + def _filter_state(self, new_state): + """Implement the Simple Moving Average filter.""" + self._leak(new_state.timestamp) + self.queue.append(copy(new_state)) + + moving_sum = 0 + start = new_state.timestamp - self._time_window + prev_state = self.last_leak or self.queue[0] + for state in self.queue: + moving_sum += (state.timestamp - start).total_seconds() * prev_state.state + start = state.timestamp + prev_state = state + + new_state.state = moving_sum / self._time_window.total_seconds() + + return new_state + + +@FILTERS.register(FILTER_NAME_THROTTLE) +class ThrottleFilter(Filter): + """Throttle Filter. + + One sample per window. + """ + + def __init__(self, window_size, precision, entity): + """Initialize Filter.""" + super().__init__(FILTER_NAME_THROTTLE, window_size, precision, entity) + + def _filter_state(self, new_state): + """Implement the throttle filter.""" + if not self.states or len(self.states) == self.states.maxlen: + self.states.clear() + self._skip_processing = False + else: + self._skip_processing = True + + return new_state + + +@FILTERS.register(FILTER_NAME_TIME_THROTTLE) +class TimeThrottleFilter(Filter): + """Time Throttle Filter. + + One sample per time period. + """ + + def __init__(self, window_size, precision, entity): + """Initialize Filter.""" + super().__init__(FILTER_NAME_TIME_THROTTLE, window_size, precision, entity) + self._time_window = window_size + self._last_emitted_at = None + + def _filter_state(self, new_state): + """Implement the filter.""" + window_start = new_state.timestamp - self._time_window + if not self._last_emitted_at or self._last_emitted_at <= window_start: + self._last_emitted_at = new_state.timestamp + self._skip_processing = False + else: + self._skip_processing = True + + return new_state diff --git a/homeassistant/components/fints/__init__.py b/homeassistant/components/fints/__init__.py new file mode 100644 index 0000000000000..0113fa752346e --- /dev/null +++ b/homeassistant/components/fints/__init__.py @@ -0,0 +1 @@ +"""The fints component.""" diff --git a/homeassistant/components/fints/manifest.json b/homeassistant/components/fints/manifest.json new file mode 100644 index 0000000000000..8644124fde24e --- /dev/null +++ b/homeassistant/components/fints/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "fints", + "name": "FinTS", + "documentation": "https://www.home-assistant.io/integrations/fints", + "requirements": ["fints==1.0.1"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/fints/sensor.py b/homeassistant/components/fints/sensor.py new file mode 100644 index 0000000000000..d81f353c222f8 --- /dev/null +++ b/homeassistant/components/fints/sensor.py @@ -0,0 +1,291 @@ +"""Read the balance of your bank accounts via FinTS.""" + +from collections import namedtuple +from datetime import timedelta +import logging + +from fints.client import FinTS3PinTanClient +from fints.dialog import FinTSDialogError +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_NAME, CONF_PIN, CONF_URL, CONF_USERNAME +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(hours=4) + +ICON = "mdi:currency-eur" + +BankCredentials = namedtuple("BankCredentials", "blz login pin url") + +CONF_BIN = "bank_identification_number" +CONF_ACCOUNTS = "accounts" +CONF_HOLDINGS = "holdings" +CONF_ACCOUNT = "account" + +ATTR_ACCOUNT = CONF_ACCOUNT +ATTR_BANK = "bank" +ATTR_ACCOUNT_TYPE = "account_type" + +SCHEMA_ACCOUNTS = vol.Schema( + { + vol.Required(CONF_ACCOUNT): cv.string, + vol.Optional(CONF_NAME, default=None): vol.Any(None, cv.string), + } +) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_BIN): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PIN): cv.string, + vol.Required(CONF_URL): cv.string, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_ACCOUNTS, default=[]): cv.ensure_list(SCHEMA_ACCOUNTS), + vol.Optional(CONF_HOLDINGS, default=[]): cv.ensure_list(SCHEMA_ACCOUNTS), + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the sensors. + + Login to the bank and get a list of existing accounts. Create a + sensor for each account. + """ + credentials = BankCredentials( + config[CONF_BIN], config[CONF_USERNAME], config[CONF_PIN], config[CONF_URL] + ) + fints_name = config.get(CONF_NAME, config[CONF_BIN]) + + account_config = { + acc[CONF_ACCOUNT]: acc[CONF_NAME] for acc in config[CONF_ACCOUNTS] + } + + holdings_config = { + acc[CONF_ACCOUNT]: acc[CONF_NAME] for acc in config[CONF_HOLDINGS] + } + + client = FinTsClient(credentials, fints_name) + balance_accounts, holdings_accounts = client.detect_accounts() + accounts = [] + + for account in balance_accounts: + if config[CONF_ACCOUNTS] and account.iban not in account_config: + _LOGGER.info("skipping account %s for bank %s", account.iban, fints_name) + continue + + account_name = account_config.get(account.iban) + if not account_name: + account_name = f"{fints_name} - {account.iban}" + accounts.append(FinTsAccount(client, account, account_name)) + _LOGGER.debug("Creating account %s for bank %s", account.iban, fints_name) + + for account in holdings_accounts: + if config[CONF_HOLDINGS] and account.accountnumber not in holdings_config: + _LOGGER.info( + "skipping holdings %s for bank %s", account.accountnumber, fints_name + ) + continue + + account_name = holdings_config.get(account.accountnumber) + if not account_name: + account_name = f"{fints_name} - {account.accountnumber}" + accounts.append(FinTsHoldingsAccount(client, account, account_name)) + _LOGGER.debug( + "Creating holdings %s for bank %s", account.accountnumber, fints_name + ) + + add_entities(accounts, True) + + +class FinTsClient: + """Wrapper around the FinTS3PinTanClient. + + Use this class as Context Manager to get the FinTS3Client object. + """ + + def __init__(self, credentials: BankCredentials, name: str): + """Initialize a FinTsClient.""" + self._credentials = credentials + self.name = name + + @property + def client(self): + """Get the client object. + + As the fints library is stateless, there is not benefit in caching + the client objects. If that ever changes, consider caching the client + object and also think about potential concurrency problems. + """ + + return FinTS3PinTanClient( + self._credentials.blz, + self._credentials.login, + self._credentials.pin, + self._credentials.url, + ) + + def detect_accounts(self): + """Identify the accounts of the bank.""" + + balance_accounts = [] + holdings_accounts = [] + for account in self.client.get_sepa_accounts(): + try: + self.client.get_balance(account) + balance_accounts.append(account) + except IndexError: + # account is not a balance account. + pass + except FinTSDialogError: + # account is not a balance account. + pass + try: + self.client.get_holdings(account) + holdings_accounts.append(account) + except FinTSDialogError: + # account is not a holdings account. + pass + + return balance_accounts, holdings_accounts + + +class FinTsAccount(Entity): + """Sensor for a FinTS balance account. + + A balance account contains an amount of money (=balance). The amount may + also be negative. + """ + + def __init__(self, client: FinTsClient, account, name: str) -> None: + """Initialize a FinTs balance account.""" + self._client = client + self._account = account + self._name = name + self._balance: float = None + self._currency: str = None + + @property + def should_poll(self) -> bool: + """Return True. + + Data needs to be polled from the bank servers. + """ + return True + + def update(self) -> None: + """Get the current balance and currency for the account.""" + bank = self._client.client + balance = bank.get_balance(self._account) + self._balance = balance.amount.amount + self._currency = balance.amount.currency + _LOGGER.debug("updated balance of account %s", self.name) + + @property + def name(self) -> str: + """Friendly name of the sensor.""" + return self._name + + @property + def state(self) -> float: + """Return the balance of the account as state.""" + return self._balance + + @property + def unit_of_measurement(self) -> str: + """Use the currency as unit of measurement.""" + return self._currency + + @property + def device_state_attributes(self) -> dict: + """Additional attributes of the sensor.""" + attributes = {ATTR_ACCOUNT: self._account.iban, ATTR_ACCOUNT_TYPE: "balance"} + if self._client.name: + attributes[ATTR_BANK] = self._client.name + return attributes + + @property + def icon(self) -> str: + """Set the icon for the sensor.""" + return ICON + + +class FinTsHoldingsAccount(Entity): + """Sensor for a FinTS holdings account. + + A holdings account does not contain money but rather some financial + instruments, e.g. stocks. + """ + + def __init__(self, client: FinTsClient, account, name: str) -> None: + """Initialize a FinTs holdings account.""" + self._client = client + self._name = name + self._account = account + self._holdings = [] + self._total: float = None + + @property + def should_poll(self) -> bool: + """Return True. + + Data needs to be polled from the bank servers. + """ + return True + + def update(self) -> None: + """Get the current holdings for the account.""" + bank = self._client.client + self._holdings = bank.get_holdings(self._account) + self._total = sum(h.total_value for h in self._holdings) + + @property + def state(self) -> float: + """Return total market value as state.""" + return self._total + + @property + def icon(self) -> str: + """Set the icon for the sensor.""" + return ICON + + @property + def device_state_attributes(self) -> dict: + """Additional attributes of the sensor. + + Lists each holding of the account with the current value. + """ + attributes = { + ATTR_ACCOUNT: self._account.accountnumber, + ATTR_ACCOUNT_TYPE: "holdings", + } + if self._client.name: + attributes[ATTR_BANK] = self._client.name + for holding in self._holdings: + total_name = f"{holding.name} total" + attributes[total_name] = holding.total_value + pieces_name = f"{holding.name} pieces" + attributes[pieces_name] = holding.pieces + price_name = f"{holding.name} price" + attributes[price_name] = holding.market_value + + return attributes + + @property + def name(self) -> str: + """Friendly name of the sensor.""" + return self._name + + @property + def unit_of_measurement(self) -> str: + """Get the unit of measurement. + + Hardcoded to EUR, as the library does not provide the currency for the + holdings. And as FinTS is only used in Germany, most accounts will be + in EUR anyways. + """ + return "EUR" diff --git a/homeassistant/components/fitbit/__init__.py b/homeassistant/components/fitbit/__init__.py new file mode 100644 index 0000000000000..04946f6386f38 --- /dev/null +++ b/homeassistant/components/fitbit/__init__.py @@ -0,0 +1 @@ +"""The fitbit component.""" diff --git a/homeassistant/components/fitbit/manifest.json b/homeassistant/components/fitbit/manifest.json new file mode 100644 index 0000000000000..88620785e141b --- /dev/null +++ b/homeassistant/components/fitbit/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "fitbit", + "name": "Fitbit", + "documentation": "https://www.home-assistant.io/integrations/fitbit", + "requirements": ["fitbit==0.3.1"], + "dependencies": ["configurator", "http"], + "codeowners": ["@robbiet480"] +} diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py new file mode 100644 index 0000000000000..5ddb63ef899a3 --- /dev/null +++ b/homeassistant/components/fitbit/sensor.py @@ -0,0 +1,520 @@ +"""Support for the Fitbit API.""" +import datetime +import logging +import os +import time + +from fitbit import Fitbit +from fitbit.api import FitbitOauth2Client +from oauthlib.oauth2.rfc6749.errors import MismatchingStateError, MissingTokenError +import voluptuous as vol + +from homeassistant.components.http import HomeAssistantView +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ATTR_ATTRIBUTION, CONF_UNIT_SYSTEM +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.icon import icon_for_battery_level +from homeassistant.util.json import load_json, save_json + +_CONFIGURING = {} +_LOGGER = logging.getLogger(__name__) + +ATTR_ACCESS_TOKEN = "access_token" +ATTR_REFRESH_TOKEN = "refresh_token" +ATTR_CLIENT_ID = "client_id" +ATTR_CLIENT_SECRET = "client_secret" +ATTR_LAST_SAVED_AT = "last_saved_at" + +CONF_MONITORED_RESOURCES = "monitored_resources" +CONF_CLOCK_FORMAT = "clock_format" +ATTRIBUTION = "Data provided by Fitbit.com" + +FITBIT_AUTH_CALLBACK_PATH = "/api/fitbit/callback" +FITBIT_AUTH_START = "/api/fitbit" +FITBIT_CONFIG_FILE = "fitbit.conf" +FITBIT_DEFAULT_RESOURCES = ["activities/steps"] + +SCAN_INTERVAL = datetime.timedelta(minutes=30) + +DEFAULT_CONFIG = {"client_id": "CLIENT_ID_HERE", "client_secret": "CLIENT_SECRET_HERE"} + +FITBIT_RESOURCES_LIST = { + "activities/activityCalories": ["Activity Calories", "cal", "fire"], + "activities/calories": ["Calories", "cal", "fire"], + "activities/caloriesBMR": ["Calories BMR", "cal", "fire"], + "activities/distance": ["Distance", "", "map-marker"], + "activities/elevation": ["Elevation", "", "walk"], + "activities/floors": ["Floors", "floors", "walk"], + "activities/heart": ["Resting Heart Rate", "bpm", "heart-pulse"], + "activities/minutesFairlyActive": ["Minutes Fairly Active", "minutes", "walk"], + "activities/minutesLightlyActive": ["Minutes Lightly Active", "minutes", "walk"], + "activities/minutesSedentary": [ + "Minutes Sedentary", + "minutes", + "seat-recline-normal", + ], + "activities/minutesVeryActive": ["Minutes Very Active", "minutes", "run"], + "activities/steps": ["Steps", "steps", "walk"], + "activities/tracker/activityCalories": ["Tracker Activity Calories", "cal", "fire"], + "activities/tracker/calories": ["Tracker Calories", "cal", "fire"], + "activities/tracker/distance": ["Tracker Distance", "", "map-marker"], + "activities/tracker/elevation": ["Tracker Elevation", "", "walk"], + "activities/tracker/floors": ["Tracker Floors", "floors", "walk"], + "activities/tracker/minutesFairlyActive": [ + "Tracker Minutes Fairly Active", + "minutes", + "walk", + ], + "activities/tracker/minutesLightlyActive": [ + "Tracker Minutes Lightly Active", + "minutes", + "walk", + ], + "activities/tracker/minutesSedentary": [ + "Tracker Minutes Sedentary", + "minutes", + "seat-recline-normal", + ], + "activities/tracker/minutesVeryActive": [ + "Tracker Minutes Very Active", + "minutes", + "run", + ], + "activities/tracker/steps": ["Tracker Steps", "steps", "walk"], + "body/bmi": ["BMI", "BMI", "human"], + "body/fat": ["Body Fat", "%", "human"], + "body/weight": ["Weight", "", "human"], + "devices/battery": ["Battery", None, None], + "sleep/awakeningsCount": ["Awakenings Count", "times awaken", "sleep"], + "sleep/efficiency": ["Sleep Efficiency", "%", "sleep"], + "sleep/minutesAfterWakeup": ["Minutes After Wakeup", "minutes", "sleep"], + "sleep/minutesAsleep": ["Sleep Minutes Asleep", "minutes", "sleep"], + "sleep/minutesAwake": ["Sleep Minutes Awake", "minutes", "sleep"], + "sleep/minutesToFallAsleep": ["Sleep Minutes to Fall Asleep", "minutes", "sleep"], + "sleep/startTime": ["Sleep Start Time", None, "clock"], + "sleep/timeInBed": ["Sleep Time in Bed", "minutes", "hotel"], +} + +FITBIT_MEASUREMENTS = { + "en_US": { + "duration": "ms", + "distance": "mi", + "elevation": "ft", + "height": "in", + "weight": "lbs", + "body": "in", + "liquids": "fl. oz.", + "blood glucose": "mg/dL", + "battery": "", + }, + "en_GB": { + "duration": "milliseconds", + "distance": "kilometers", + "elevation": "meters", + "height": "centimeters", + "weight": "stone", + "body": "centimeters", + "liquids": "milliliters", + "blood glucose": "mmol/L", + "battery": "", + }, + "metric": { + "duration": "milliseconds", + "distance": "kilometers", + "elevation": "meters", + "height": "centimeters", + "weight": "kilograms", + "body": "centimeters", + "liquids": "milliliters", + "blood glucose": "mmol/L", + "battery": "", + }, +} + +BATTERY_LEVELS = {"High": 100, "Medium": 50, "Low": 20, "Empty": 0} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional( + CONF_MONITORED_RESOURCES, default=FITBIT_DEFAULT_RESOURCES + ): vol.All(cv.ensure_list, [vol.In(FITBIT_RESOURCES_LIST)]), + vol.Optional(CONF_CLOCK_FORMAT, default="24H"): vol.In(["12H", "24H"]), + vol.Optional(CONF_UNIT_SYSTEM, default="default"): vol.In( + ["en_GB", "en_US", "metric", "default"] + ), + } +) + + +def request_app_setup(hass, config, add_entities, config_path, discovery_info=None): + """Assist user with configuring the Fitbit dev application.""" + configurator = hass.components.configurator + + def fitbit_configuration_callback(callback_data): + """Handle configuration updates.""" + config_path = hass.config.path(FITBIT_CONFIG_FILE) + if os.path.isfile(config_path): + config_file = load_json(config_path) + if config_file == DEFAULT_CONFIG: + error_msg = ( + "You didn't correctly modify fitbit.conf", + " please try again", + ) + configurator.notify_errors(_CONFIGURING["fitbit"], error_msg) + else: + setup_platform(hass, config, add_entities, discovery_info) + else: + setup_platform(hass, config, add_entities, discovery_info) + + start_url = f"{hass.config.api.base_url}{FITBIT_AUTH_CALLBACK_PATH}" + + description = """Please create a Fitbit developer app at + https://dev.fitbit.com/apps/new. + For the OAuth 2.0 Application Type choose Personal. + Set the Callback URL to {}. + They will provide you a Client ID and secret. + These need to be saved into the file located at: {}. + Then come back here and hit the below button. + """.format( + start_url, config_path + ) + + submit = "I have saved my Client ID and Client Secret into fitbit.conf." + + _CONFIGURING["fitbit"] = configurator.request_config( + "Fitbit", + fitbit_configuration_callback, + description=description, + submit_caption=submit, + description_image="/static/images/config_fitbit_app.png", + ) + + +def request_oauth_completion(hass): + """Request user complete Fitbit OAuth2 flow.""" + configurator = hass.components.configurator + if "fitbit" in _CONFIGURING: + configurator.notify_errors( + _CONFIGURING["fitbit"], "Failed to register, please try again." + ) + + return + + def fitbit_configuration_callback(callback_data): + """Handle configuration updates.""" + + start_url = f"{hass.config.api.base_url}{FITBIT_AUTH_START}" + + description = f"Please authorize Fitbit by visiting {start_url}" + + _CONFIGURING["fitbit"] = configurator.request_config( + "Fitbit", + fitbit_configuration_callback, + description=description, + submit_caption="I have authorized Fitbit.", + ) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Fitbit sensor.""" + config_path = hass.config.path(FITBIT_CONFIG_FILE) + if os.path.isfile(config_path): + config_file = load_json(config_path) + if config_file == DEFAULT_CONFIG: + request_app_setup( + hass, config, add_entities, config_path, discovery_info=None + ) + return False + else: + save_json(config_path, DEFAULT_CONFIG) + request_app_setup(hass, config, add_entities, config_path, discovery_info=None) + return False + + if "fitbit" in _CONFIGURING: + hass.components.configurator.request_done(_CONFIGURING.pop("fitbit")) + + access_token = config_file.get(ATTR_ACCESS_TOKEN) + refresh_token = config_file.get(ATTR_REFRESH_TOKEN) + expires_at = config_file.get(ATTR_LAST_SAVED_AT) + if None not in (access_token, refresh_token): + authd_client = Fitbit( + config_file.get(ATTR_CLIENT_ID), + config_file.get(ATTR_CLIENT_SECRET), + access_token=access_token, + refresh_token=refresh_token, + expires_at=expires_at, + refresh_cb=lambda x: None, + ) + + if int(time.time()) - expires_at > 3600: + authd_client.client.refresh_token() + + unit_system = config.get(CONF_UNIT_SYSTEM) + if unit_system == "default": + authd_client.system = authd_client.user_profile_get()["user"]["locale"] + if authd_client.system != "en_GB": + if hass.config.units.is_metric: + authd_client.system = "metric" + else: + authd_client.system = "en_US" + else: + authd_client.system = unit_system + + dev = [] + registered_devs = authd_client.get_devices() + clock_format = config.get(CONF_CLOCK_FORMAT) + for resource in config.get(CONF_MONITORED_RESOURCES): + + # monitor battery for all linked FitBit devices + if resource == "devices/battery": + for dev_extra in registered_devs: + dev.append( + FitbitSensor( + authd_client, + config_path, + resource, + hass.config.units.is_metric, + clock_format, + dev_extra, + ) + ) + else: + dev.append( + FitbitSensor( + authd_client, + config_path, + resource, + hass.config.units.is_metric, + clock_format, + ) + ) + add_entities(dev, True) + + else: + oauth = FitbitOauth2Client( + config_file.get(ATTR_CLIENT_ID), config_file.get(ATTR_CLIENT_SECRET) + ) + + redirect_uri = "{}{}".format( + hass.config.api.base_url, FITBIT_AUTH_CALLBACK_PATH + ) + + fitbit_auth_start_url, _ = oauth.authorize_token_url( + redirect_uri=redirect_uri, + scope=[ + "activity", + "heartrate", + "nutrition", + "profile", + "settings", + "sleep", + "weight", + ], + ) + + hass.http.register_redirect(FITBIT_AUTH_START, fitbit_auth_start_url) + hass.http.register_view(FitbitAuthCallbackView(config, add_entities, oauth)) + + request_oauth_completion(hass) + + +class FitbitAuthCallbackView(HomeAssistantView): + """Handle OAuth finish callback requests.""" + + requires_auth = False + url = FITBIT_AUTH_CALLBACK_PATH + name = "api:fitbit:callback" + + def __init__(self, config, add_entities, oauth): + """Initialize the OAuth callback view.""" + self.config = config + self.add_entities = add_entities + self.oauth = oauth + + @callback + def get(self, request): + """Finish OAuth callback request.""" + hass = request.app["hass"] + data = request.query + + response_message = """Fitbit has been successfully authorized! + You can close this window now!""" + + result = None + if data.get("code") is not None: + redirect_uri = "{}{}".format( + hass.config.api.base_url, FITBIT_AUTH_CALLBACK_PATH + ) + + try: + result = self.oauth.fetch_access_token(data.get("code"), redirect_uri) + except MissingTokenError as error: + _LOGGER.error("Missing token: %s", error) + response_message = """Something went wrong when + attempting authenticating with Fitbit. The error + encountered was {}. Please try again!""".format( + error + ) + except MismatchingStateError as error: + _LOGGER.error("Mismatched state, CSRF error: %s", error) + response_message = """Something went wrong when + attempting authenticating with Fitbit. The error + encountered was {}. Please try again!""".format( + error + ) + else: + _LOGGER.error("Unknown error when authing") + response_message = """Something went wrong when + attempting authenticating with Fitbit. + An unknown error occurred. Please try again! + """ + + if result is None: + _LOGGER.error("Unknown error when authing") + response_message = """Something went wrong when + attempting authenticating with Fitbit. + An unknown error occurred. Please try again! + """ + + html_response = """Fitbit Auth +

{}

""".format( + response_message + ) + + if result: + config_contents = { + ATTR_ACCESS_TOKEN: result.get("access_token"), + ATTR_REFRESH_TOKEN: result.get("refresh_token"), + ATTR_CLIENT_ID: self.oauth.client_id, + ATTR_CLIENT_SECRET: self.oauth.client_secret, + ATTR_LAST_SAVED_AT: int(time.time()), + } + save_json(hass.config.path(FITBIT_CONFIG_FILE), config_contents) + + hass.async_add_job(setup_platform, hass, self.config, self.add_entities) + + return html_response + + +class FitbitSensor(Entity): + """Implementation of a Fitbit sensor.""" + + def __init__( + self, client, config_path, resource_type, is_metric, clock_format, extra=None + ): + """Initialize the Fitbit sensor.""" + self.client = client + self.config_path = config_path + self.resource_type = resource_type + self.is_metric = is_metric + self.clock_format = clock_format + self.extra = extra + self._name = FITBIT_RESOURCES_LIST[self.resource_type][0] + if self.extra: + self._name = "{0} Battery".format(self.extra.get("deviceVersion")) + unit_type = FITBIT_RESOURCES_LIST[self.resource_type][1] + if unit_type == "": + split_resource = self.resource_type.split("/") + try: + measurement_system = FITBIT_MEASUREMENTS[self.client.system] + except KeyError: + if self.is_metric: + measurement_system = FITBIT_MEASUREMENTS["metric"] + else: + measurement_system = FITBIT_MEASUREMENTS["en_US"] + unit_type = measurement_system[split_resource[-1]] + self._unit_of_measurement = unit_type + self._state = 0 + + @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 icon(self): + """Icon to use in the frontend, if any.""" + if self.resource_type == "devices/battery" and self.extra: + battery_level = BATTERY_LEVELS[self.extra.get("battery")] + return icon_for_battery_level(battery_level=battery_level, charging=None) + return "mdi:{}".format(FITBIT_RESOURCES_LIST[self.resource_type][2]) + + @property + def device_state_attributes(self): + """Return the state attributes.""" + attrs = {} + + attrs[ATTR_ATTRIBUTION] = ATTRIBUTION + + if self.extra: + attrs["model"] = self.extra.get("deviceVersion") + attrs["type"] = self.extra.get("type").lower() + + return attrs + + def update(self): + """Get the latest data from the Fitbit API and update the states.""" + if self.resource_type == "devices/battery" and self.extra: + self._state = self.extra.get("battery") + else: + container = self.resource_type.replace("/", "-") + response = self.client.time_series(self.resource_type, period="7d") + raw_state = response[container][-1].get("value") + if self.resource_type == "activities/distance": + self._state = format(float(raw_state), ".2f") + elif self.resource_type == "activities/tracker/distance": + self._state = format(float(raw_state), ".2f") + elif self.resource_type == "body/bmi": + self._state = format(float(raw_state), ".1f") + elif self.resource_type == "body/fat": + self._state = format(float(raw_state), ".1f") + elif self.resource_type == "body/weight": + self._state = format(float(raw_state), ".1f") + elif self.resource_type == "sleep/startTime": + if raw_state == "": + self._state = "-" + elif self.clock_format == "12H": + hours, minutes = raw_state.split(":") + hours, minutes = int(hours), int(minutes) + setting = "AM" + if hours > 12: + setting = "PM" + hours -= 12 + elif hours == 0: + hours = 12 + self._state = f"{hours}:{minutes:02d} {setting}" + else: + self._state = raw_state + else: + if self.is_metric: + self._state = raw_state + else: + try: + self._state = "{0:,}".format(int(raw_state)) + except TypeError: + self._state = raw_state + + if self.resource_type == "activities/heart": + self._state = response[container][-1].get("value").get("restingHeartRate") + + token = self.client.client.session.token + config_contents = { + ATTR_ACCESS_TOKEN: token.get("access_token"), + ATTR_REFRESH_TOKEN: token.get("refresh_token"), + ATTR_CLIENT_ID: self.client.client.client_id, + ATTR_CLIENT_SECRET: self.client.client.client_secret, + ATTR_LAST_SAVED_AT: int(time.time()), + } + save_json(self.config_path, config_contents) diff --git a/homeassistant/components/fixer/__init__.py b/homeassistant/components/fixer/__init__.py new file mode 100644 index 0000000000000..a5023b5db7050 --- /dev/null +++ b/homeassistant/components/fixer/__init__.py @@ -0,0 +1 @@ +"""The fixer component.""" diff --git a/homeassistant/components/fixer/manifest.json b/homeassistant/components/fixer/manifest.json new file mode 100644 index 0000000000000..4bb0b7ba1b74e --- /dev/null +++ b/homeassistant/components/fixer/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "fixer", + "name": "Fixer", + "documentation": "https://www.home-assistant.io/integrations/fixer", + "requirements": ["fixerio==1.0.0a0"], + "dependencies": [], + "codeowners": ["@fabaff"] +} diff --git a/homeassistant/components/fixer/sensor.py b/homeassistant/components/fixer/sensor.py new file mode 100644 index 0000000000000..e3dfd432a416a --- /dev/null +++ b/homeassistant/components/fixer/sensor.py @@ -0,0 +1,114 @@ +"""Currency exchange rate support that comes from fixer.io.""" +from datetime import timedelta +import logging + +from fixerio import Fixerio +from fixerio.exceptions import FixerioException +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ATTR_ATTRIBUTION, CONF_API_KEY, CONF_NAME +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +ATTR_EXCHANGE_RATE = "Exchange rate" +ATTR_TARGET = "Target currency" +ATTRIBUTION = "Data provided by the European Central Bank (ECB)" + +CONF_TARGET = "target" + +DEFAULT_BASE = "USD" +DEFAULT_NAME = "Exchange rate" + +ICON = "mdi:currency-usd" + +SCAN_INTERVAL = timedelta(days=1) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_TARGET): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Fixer.io sensor.""" + + api_key = config.get(CONF_API_KEY) + name = config.get(CONF_NAME) + target = config.get(CONF_TARGET) + + try: + Fixerio(symbols=[target], access_key=api_key).latest() + except FixerioException: + _LOGGER.error("One of the given currencies is not supported") + return + + data = ExchangeData(target, api_key) + add_entities([ExchangeRateSensor(data, name, target)], True) + + +class ExchangeRateSensor(Entity): + """Representation of a Exchange sensor.""" + + def __init__(self, data, name, target): + """Initialize the sensor.""" + self.data = data + self._target = target + self._name = name + self._state = 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._target + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def device_state_attributes(self): + """Return the state attributes.""" + if self.data.rate is not None: + return { + ATTR_ATTRIBUTION: ATTRIBUTION, + ATTR_EXCHANGE_RATE: self.data.rate["rates"][self._target], + ATTR_TARGET: self._target, + } + + @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.""" + self.data.update() + self._state = round(self.data.rate["rates"][self._target], 3) + + +class ExchangeData: + """Get the latest data and update the states.""" + + def __init__(self, target_currency, api_key): + """Initialize the data object.""" + + self.api_key = api_key + self.rate = None + self.target_currency = target_currency + self.exchange = Fixerio(symbols=[self.target_currency], access_key=self.api_key) + + def update(self): + """Get the latest data from Fixer.io.""" + self.rate = self.exchange.latest() diff --git a/homeassistant/components/fleetgo/__init__.py b/homeassistant/components/fleetgo/__init__.py new file mode 100644 index 0000000000000..659f30ac443c4 --- /dev/null +++ b/homeassistant/components/fleetgo/__init__.py @@ -0,0 +1 @@ +"""The FleetGO component.""" diff --git a/homeassistant/components/fleetgo/device_tracker.py b/homeassistant/components/fleetgo/device_tracker.py new file mode 100644 index 0000000000000..5a922ed4b9243 --- /dev/null +++ b/homeassistant/components/fleetgo/device_tracker.py @@ -0,0 +1,88 @@ +"""Support for FleetGO Platform.""" +import logging + +import requests +from ritassist import API +import voluptuous as vol + +from homeassistant.components.device_tracker import PLATFORM_SCHEMA +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import track_utc_time_change + +_LOGGER = logging.getLogger(__name__) + +CONF_CLIENT_ID = "client_id" +CONF_CLIENT_SECRET = "client_secret" +CONF_INCLUDE = "include" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_CLIENT_ID): cv.string, + vol.Required(CONF_CLIENT_SECRET): cv.string, + vol.Optional(CONF_INCLUDE, default=[]): vol.All(cv.ensure_list, [cv.string]), + } +) + + +def setup_scanner(hass, config: dict, see, discovery_info=None): + """Set up the DeviceScanner and check if login is valid.""" + scanner = FleetGoDeviceScanner(config, see) + if not scanner.login(hass): + _LOGGER.error("FleetGO authentication failed") + return False + return True + + +class FleetGoDeviceScanner: + """Define a scanner for the FleetGO platform.""" + + def __init__(self, config, see): + """Initialize FleetGoDeviceScanner.""" + + self._include = config.get(CONF_INCLUDE) + self._see = see + + self._api = API( + config.get(CONF_CLIENT_ID), + config.get(CONF_CLIENT_SECRET), + config.get(CONF_USERNAME), + config.get(CONF_PASSWORD), + ) + + def setup(self, hass): + """Set up a timer and start gathering devices.""" + self._refresh() + track_utc_time_change( + hass, lambda now: self._refresh(), second=range(0, 60, 30) + ) + + def login(self, hass): + """Perform a login on the FleetGO API.""" + if self._api.login(): + self.setup(hass) + return True + return False + + def _refresh(self) -> None: + """Refresh device information from the platform.""" + try: + devices = self._api.get_devices() + + for device in devices: + if not self._include or device.license_plate in self._include: + + if device.active or device.current_address is None: + device.get_map_details() + + self._see( + dev_id=device.plate_as_id, + gps=(device.latitude, device.longitude), + attributes=device.state_attributes, + icon="mdi:car", + ) + + except requests.exceptions.ConnectionError: + _LOGGER.error("ConnectionError: Could not connect to FleetGO") diff --git a/homeassistant/components/fleetgo/manifest.json b/homeassistant/components/fleetgo/manifest.json new file mode 100644 index 0000000000000..142d6ba00ed02 --- /dev/null +++ b/homeassistant/components/fleetgo/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "fleetgo", + "name": "FleetGO", + "documentation": "https://www.home-assistant.io/integrations/fleetgo", + "requirements": ["ritassist==0.9.2"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/flexit/__init__.py b/homeassistant/components/flexit/__init__.py new file mode 100644 index 0000000000000..4ace1a3894594 --- /dev/null +++ b/homeassistant/components/flexit/__init__.py @@ -0,0 +1 @@ +"""The flexit component.""" diff --git a/homeassistant/components/flexit/climate.py b/homeassistant/components/flexit/climate.py new file mode 100644 index 0000000000000..34ddd9a8ffa6e --- /dev/null +++ b/homeassistant/components/flexit/climate.py @@ -0,0 +1,179 @@ +""" +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 +from typing import List + +from pyflexit.pyflexit import pyflexit +import voluptuous as vol + +from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateDevice +from homeassistant.components.climate.const import ( + HVAC_MODE_COOL, + SUPPORT_FAN_MODE, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.components.modbus import ( + CONF_HUB, + DEFAULT_HUB, + DOMAIN as MODBUS_DOMAIN, +) +from homeassistant.const import ( + ATTR_TEMPERATURE, + CONF_NAME, + CONF_SLAVE, + DEVICE_DEFAULT_NAME, + TEMP_CELSIUS, +) +import homeassistant.helpers.config_validation as cv + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_HUB, default=DEFAULT_HUB): cv.string, + 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) + hub = hass.data[MODBUS_DOMAIN][config.get(CONF_HUB)] + add_entities([Flexit(hub, modbus_slave, name)], True) + + +class Flexit(ClimateDevice): + """Representation of a Flexit AC unit.""" + + def __init__(self, hub, modbus_slave, name): + """Initialize the unit.""" + self._hub = hub + 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_modes = ["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(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_modes[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 hvac_mode(self): + """Return current operation ie. heat, cool, idle.""" + return self._current_operation + + @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_COOL] + + @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 + + 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_modes.index(fan_mode)) diff --git a/homeassistant/components/flexit/manifest.json b/homeassistant/components/flexit/manifest.json new file mode 100644 index 0000000000000..6c98925ababdf --- /dev/null +++ b/homeassistant/components/flexit/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "flexit", + "name": "Flexit", + "documentation": "https://www.home-assistant.io/integrations/flexit", + "requirements": ["pyflexit==0.3"], + "dependencies": ["modbus"], + "codeowners": [] +} diff --git a/homeassistant/components/flic/__init__.py b/homeassistant/components/flic/__init__.py new file mode 100644 index 0000000000000..b15b06217c1b4 --- /dev/null +++ b/homeassistant/components/flic/__init__.py @@ -0,0 +1 @@ +"""The flic component.""" diff --git a/homeassistant/components/flic/binary_sensor.py b/homeassistant/components/flic/binary_sensor.py new file mode 100644 index 0000000000000..4f2f229977fc0 --- /dev/null +++ b/homeassistant/components/flic/binary_sensor.py @@ -0,0 +1,249 @@ +"""Support to use flic buttons as a binary sensor.""" +import logging +import threading + +from pyflic import ( + ButtonConnectionChannel, + ClickType, + ConnectionStatus, + FlicClient, + ScanWizard, + ScanWizardResult, +) +import voluptuous as vol + +from homeassistant.components.binary_sensor import PLATFORM_SCHEMA, BinarySensorDevice +from homeassistant.const import ( + CONF_DISCOVERY, + CONF_HOST, + CONF_PORT, + CONF_TIMEOUT, + EVENT_HOMEASSISTANT_STOP, +) +import homeassistant.helpers.config_validation as cv + +_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.""" + + # 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 = 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.""" + scan_wizard = ScanWizard() + + def scan_completed_callback(scan_wizard, result, address, name): + """Restart scan wizard to constantly check for new buttons.""" + if result == ScanWizardResult.WizardSuccess: + _LOGGER.info("Found new button %s", address) + elif result != 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.""" + + 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 = { + ClickType.ButtonClick: CLICK_TYPE_SINGLE, + ClickType.ButtonSingleClick: CLICK_TYPE_SINGLE, + ClickType.ButtonDoubleClick: CLICK_TYPE_DOUBLE, + 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.""" + channel = 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.""" + if was_queued and self._queued_event_check(click_type, time_diff): + return + + self._is_down = click_type == 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.""" + if connection_status == ConnectionStatus.Disconnected: + _LOGGER.warning( + "Button (%s) disconnected. Reason: %s", self.address, disconnect_reason + ) diff --git a/homeassistant/components/flic/manifest.json b/homeassistant/components/flic/manifest.json new file mode 100644 index 0000000000000..24170b34acf6c --- /dev/null +++ b/homeassistant/components/flic/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "flic", + "name": "Flic", + "documentation": "https://www.home-assistant.io/integrations/flic", + "requirements": ["pyflic-homeassistant==0.4.dev0"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/flock/__init__.py b/homeassistant/components/flock/__init__.py new file mode 100644 index 0000000000000..1b58d21cff885 --- /dev/null +++ b/homeassistant/components/flock/__init__.py @@ -0,0 +1 @@ +"""The flock component.""" diff --git a/homeassistant/components/flock/manifest.json b/homeassistant/components/flock/manifest.json new file mode 100644 index 0000000000000..6bb3eaf9e690f --- /dev/null +++ b/homeassistant/components/flock/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "flock", + "name": "Flock", + "documentation": "https://www.home-assistant.io/integrations/flock", + "requirements": [], + "dependencies": [], + "codeowners": ["@fabaff"] +} diff --git a/homeassistant/components/flock/notify.py b/homeassistant/components/flock/notify.py new file mode 100644 index 0000000000000..a71601ea2c478 --- /dev/null +++ b/homeassistant/components/flock/notify.py @@ -0,0 +1,54 @@ +"""Flock platform for notify component.""" +import asyncio +import logging + +import async_timeout +import voluptuous as vol + +from homeassistant.components.notify import PLATFORM_SCHEMA, BaseNotificationService +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) +_RESOURCE = "https://api.flock.com/hooks/sendMessage/" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({vol.Required(CONF_ACCESS_TOKEN): cv.string}) + + +async def get_service(hass, config, discovery_info=None): + """Get the Flock notification service.""" + access_token = config.get(CONF_ACCESS_TOKEN) + url = f"{_RESOURCE}{access_token}" + session = async_get_clientsession(hass) + + return FlockNotificationService(url, session) + + +class FlockNotificationService(BaseNotificationService): + """Implement the notification service for Flock.""" + + def __init__(self, url, session): + """Initialize the Flock notification service.""" + self._url = url + self._session = session + + async def async_send_message(self, message, **kwargs): + """Send the message to the user.""" + payload = {"text": message} + + _LOGGER.debug("Attempting to call Flock at %s", self._url) + + try: + with async_timeout.timeout(10): + response = await self._session.post(self._url, json=payload) + result = await response.json() + + if response.status != 200 or "error" in result: + _LOGGER.error( + "Flock service returned HTTP status %d, response %s", + response.status, + result, + ) + except asyncio.TimeoutError: + _LOGGER.error("Timeout accessing Flock at %s", self._url) diff --git a/homeassistant/components/flume/__init__.py b/homeassistant/components/flume/__init__.py new file mode 100644 index 0000000000000..ab626e1f15604 --- /dev/null +++ b/homeassistant/components/flume/__init__.py @@ -0,0 +1 @@ +"""The Flume component.""" diff --git a/homeassistant/components/flume/manifest.json b/homeassistant/components/flume/manifest.json new file mode 100644 index 0000000000000..d03c6330f20da --- /dev/null +++ b/homeassistant/components/flume/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "flume", + "name": "flume", + "documentation": "https://www.home-assistant.io/integrations/flume/", + "requirements": ["pyflume==0.2.4"], + "dependencies": [], + "codeowners": ["@ChrisMandich"] +} diff --git a/homeassistant/components/flume/sensor.py b/homeassistant/components/flume/sensor.py new file mode 100644 index 0000000000000..e96ce0d96ef20 --- /dev/null +++ b/homeassistant/components/flume/sensor.py @@ -0,0 +1,94 @@ +"""Sensor for displaying the number of result from Flume.""" +from datetime import timedelta +import logging + +from pyflume import FlumeData, FlumeDeviceList +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 + +LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = "Flume Sensor" + +CONF_CLIENT_ID = "client_id" +CONF_CLIENT_SECRET = "client_secret" +FLUME_TYPE_SENSOR = 2 + +SCAN_INTERVAL = timedelta(minutes=1) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_CLIENT_ID): cv.string, + vol.Required(CONF_CLIENT_SECRET): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Flume sensor.""" + username = config[CONF_USERNAME] + password = config[CONF_PASSWORD] + client_id = config[CONF_CLIENT_ID] + client_secret = config[CONF_CLIENT_SECRET] + flume_token_file = hass.config.path("FLUME_TOKEN_FILE") + time_zone = str(hass.config.time_zone) + name = config[CONF_NAME] + flume_entity_list = [] + + flume_devices = FlumeDeviceList( + username, password, client_id, client_secret, flume_token_file + ) + + for device in flume_devices.device_list: + if device["type"] == FLUME_TYPE_SENSOR: + flume = FlumeData( + username, + password, + client_id, + client_secret, + device["id"], + time_zone, + SCAN_INTERVAL, + flume_token_file, + ) + flume_entity_list.append(FlumeSensor(flume, f"{name} {device['id']}")) + + if flume_entity_list: + add_entities(flume_entity_list, True) + + +class FlumeSensor(Entity): + """Representation of the Flume sensor.""" + + def __init__(self, flume, name): + """Initialize the Flume sensor.""" + self.flume = flume + 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 the value is expressed in.""" + return "gal" + + def update(self): + """Get the latest data and updates the states.""" + self.flume.update() + self._state = self.flume.value diff --git a/homeassistant/components/flunearyou/__init__.py b/homeassistant/components/flunearyou/__init__.py new file mode 100644 index 0000000000000..5657e646be509 --- /dev/null +++ b/homeassistant/components/flunearyou/__init__.py @@ -0,0 +1 @@ +"""The flunearyou component.""" diff --git a/homeassistant/components/flunearyou/manifest.json b/homeassistant/components/flunearyou/manifest.json new file mode 100644 index 0000000000000..e7394356c64d1 --- /dev/null +++ b/homeassistant/components/flunearyou/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "flunearyou", + "name": "Flu Near You", + "documentation": "https://www.home-assistant.io/integrations/flunearyou", + "requirements": ["pyflunearyou==1.0.3"], + "dependencies": [], + "codeowners": ["@bachya"] +} diff --git a/homeassistant/components/flunearyou/sensor.py b/homeassistant/components/flunearyou/sensor.py new file mode 100644 index 0000000000000..e06eb3a8ef49d --- /dev/null +++ b/homeassistant/components/flunearyou/sensor.py @@ -0,0 +1,233 @@ +"""Support for user- and CDC-based flu info sensors from Flu Near You.""" +from datetime import timedelta +import logging + +from pyflunearyou import Client +from pyflunearyou.errors import FluNearYouError +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_STATE, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_MONITORED_CONDITIONS, +) +from homeassistant.helpers import aiohttp_client +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +_LOGGER = logging.getLogger(__name__) + +ATTR_CITY = "city" +ATTR_REPORTED_DATE = "reported_date" +ATTR_REPORTED_LATITUDE = "reported_latitude" +ATTR_REPORTED_LONGITUDE = "reported_longitude" +ATTR_STATE_REPORTS_LAST_WEEK = "state_reports_last_week" +ATTR_STATE_REPORTS_THIS_WEEK = "state_reports_this_week" +ATTR_ZIP_CODE = "zip_code" + +DEFAULT_ATTRIBUTION = "Data provided by Flu Near You" + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10) +SCAN_INTERVAL = timedelta(minutes=30) + +CATEGORY_CDC_REPORT = "cdc_report" +CATEGORY_USER_REPORT = "user_report" + +TYPE_CDC_LEVEL = "level" +TYPE_CDC_LEVEL2 = "level2" +TYPE_USER_CHICK = "chick" +TYPE_USER_DENGUE = "dengue" +TYPE_USER_FLU = "flu" +TYPE_USER_LEPTO = "lepto" +TYPE_USER_NO_SYMPTOMS = "none" +TYPE_USER_SYMPTOMS = "symptoms" +TYPE_USER_TOTAL = "total" + +EXTENDED_TYPE_MAPPING = { + TYPE_USER_FLU: "ili", + TYPE_USER_NO_SYMPTOMS: "no_symptoms", + TYPE_USER_TOTAL: "total_surveys", +} + +SENSORS = { + CATEGORY_CDC_REPORT: [ + (TYPE_CDC_LEVEL, "CDC Level", "mdi:biohazard", None), + (TYPE_CDC_LEVEL2, "CDC Level 2", "mdi:biohazard", None), + ], + CATEGORY_USER_REPORT: [ + (TYPE_USER_CHICK, "Avian Flu Symptoms", "mdi:alert", "reports"), + (TYPE_USER_DENGUE, "Dengue Fever Symptoms", "mdi:alert", "reports"), + (TYPE_USER_FLU, "Flu Symptoms", "mdi:alert", "reports"), + (TYPE_USER_LEPTO, "Leptospirosis Symptoms", "mdi:alert", "reports"), + (TYPE_USER_NO_SYMPTOMS, "No Symptoms", "mdi:alert", "reports"), + (TYPE_USER_SYMPTOMS, "Flu-like Symptoms", "mdi:alert", "reports"), + (TYPE_USER_TOTAL, "Total Symptoms", "mdi:alert", "reports"), + ], +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_LATITUDE): cv.latitude, + vol.Optional(CONF_LONGITUDE): cv.longitude, + vol.Required(CONF_MONITORED_CONDITIONS, default=list(SENSORS)): vol.All( + cv.ensure_list, [vol.In(SENSORS)] + ), + } +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Configure the platform and add the sensors.""" + websession = aiohttp_client.async_get_clientsession(hass) + + latitude = config.get(CONF_LATITUDE, hass.config.latitude) + longitude = config.get(CONF_LONGITUDE, hass.config.longitude) + + fny = FluNearYouData( + Client(websession), latitude, longitude, config[CONF_MONITORED_CONDITIONS] + ) + await fny.async_update() + + sensors = [ + FluNearYouSensor(fny, kind, name, category, icon, unit) + for category in config[CONF_MONITORED_CONDITIONS] + for kind, name, icon, unit in SENSORS[category] + ] + + async_add_entities(sensors, True) + + +class FluNearYouSensor(Entity): + """Define a base Flu Near You sensor.""" + + def __init__(self, fny, kind, name, category, icon, unit): + """Initialize the sensor.""" + self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} + self._category = category + self._icon = icon + self._kind = kind + self._name = name + self._state = None + self._unit = unit + self.fny = fny + + @property + def available(self): + """Return True if entity is available.""" + return bool(self.fny.data[self._category]) + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + return self._attrs + + @property + def icon(self): + """Return the icon.""" + return self._icon + + @property + def name(self): + """Return the name.""" + return self._name + + @property + def state(self): + """Return the state.""" + return self._state + + @property + def unique_id(self): + """Return a unique, Home Assistant friendly identifier for this entity.""" + return f"{self.fny.latitude},{self.fny.longitude}_{self._kind}" + + @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.fny.async_update() + + cdc_data = self.fny.data.get(CATEGORY_CDC_REPORT) + user_data = self.fny.data.get(CATEGORY_USER_REPORT) + + if self._category == CATEGORY_CDC_REPORT and cdc_data: + self._attrs.update( + { + ATTR_REPORTED_DATE: cdc_data["week_date"], + ATTR_STATE: cdc_data["name"], + } + ) + self._state = cdc_data[self._kind] + elif self._category == CATEGORY_USER_REPORT and user_data: + self._attrs.update( + { + ATTR_CITY: user_data["local"]["city"].split("(")[0], + ATTR_REPORTED_LATITUDE: user_data["local"]["latitude"], + ATTR_REPORTED_LONGITUDE: user_data["local"]["longitude"], + ATTR_STATE: user_data["state"]["name"], + ATTR_ZIP_CODE: user_data["local"]["zip"], + } + ) + + if self._kind in user_data["state"]["data"]: + states_key = self._kind + elif self._kind in EXTENDED_TYPE_MAPPING: + states_key = EXTENDED_TYPE_MAPPING[self._kind] + + self._attrs[ATTR_STATE_REPORTS_THIS_WEEK] = user_data["state"]["data"][ + states_key + ] + self._attrs[ATTR_STATE_REPORTS_LAST_WEEK] = user_data["state"][ + "last_week_data" + ][states_key] + + if self._kind == TYPE_USER_TOTAL: + self._state = sum( + v + for k, v in user_data["local"].items() + if k + in ( + TYPE_USER_CHICK, + TYPE_USER_DENGUE, + TYPE_USER_FLU, + TYPE_USER_LEPTO, + TYPE_USER_SYMPTOMS, + ) + ) + else: + self._state = user_data["local"][self._kind] + + +class FluNearYouData: + """Define a data object to retrieve info from Flu Near You.""" + + def __init__(self, client, latitude, longitude, sensor_types): + """Initialize.""" + self._client = client + self._sensor_types = sensor_types + self.data = {} + self.latitude = latitude + self.longitude = longitude + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + async def async_update(self): + """Update Flu Near You data.""" + for key, method in [ + (CATEGORY_CDC_REPORT, self._client.cdc_reports.status_by_coordinates), + (CATEGORY_USER_REPORT, self._client.user_reports.status_by_coordinates), + ]: + if key in self._sensor_types: + try: + self.data[key] = await method(self.latitude, self.longitude) + except FluNearYouError as err: + _LOGGER.error('There was an error with "%s" data: %s', key, err) + self.data[key] = {} + + _LOGGER.debug("New data stored: %s", self.data) diff --git a/homeassistant/components/flux/__init__.py b/homeassistant/components/flux/__init__.py new file mode 100644 index 0000000000000..c9eeda06fa81a --- /dev/null +++ b/homeassistant/components/flux/__init__.py @@ -0,0 +1 @@ +"""The flux component.""" diff --git a/homeassistant/components/flux/manifest.json b/homeassistant/components/flux/manifest.json new file mode 100644 index 0000000000000..5195ed06bb300 --- /dev/null +++ b/homeassistant/components/flux/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "flux", + "name": "Flux", + "documentation": "https://www.home-assistant.io/integrations/flux", + "requirements": [], + "dependencies": [], + "after_dependencies": ["light"], + "codeowners": [], + "quality_scale": "internal" +} diff --git a/homeassistant/components/flux/services.yaml b/homeassistant/components/flux/services.yaml new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/homeassistant/components/flux/switch.py b/homeassistant/components/flux/switch.py new file mode 100644 index 0000000000000..f22b633591123 --- /dev/null +++ b/homeassistant/components/flux/switch.py @@ -0,0 +1,366 @@ +""" +Flux for Home-Assistant. + +The idea was taken from https://github.com/KpaBap/hue-flux/ + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/switch.flux/ +""" +import datetime +import logging + +import voluptuous as vol + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP, + ATTR_RGB_COLOR, + ATTR_TRANSITION, + ATTR_WHITE_VALUE, + ATTR_XY_COLOR, + DOMAIN as LIGHT_DOMAIN, + VALID_TRANSITION, + is_on, +) +from homeassistant.components.switch import DOMAIN, SwitchDevice +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_LIGHTS, + CONF_MODE, + CONF_NAME, + CONF_PLATFORM, + SERVICE_TURN_ON, + STATE_ON, + SUN_EVENT_SUNRISE, + SUN_EVENT_SUNSET, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.sun import get_astral_event_date +from homeassistant.util import slugify +from homeassistant.util.color import ( + color_RGB_to_xy_brightness, + color_temperature_kelvin_to_mired, + color_temperature_to_rgb, +) +from homeassistant.util.dt import as_local, utcnow as dt_utcnow + +_LOGGER = logging.getLogger(__name__) + +CONF_START_TIME = "start_time" +CONF_STOP_TIME = "stop_time" +CONF_START_CT = "start_colortemp" +CONF_SUNSET_CT = "sunset_colortemp" +CONF_STOP_CT = "stop_colortemp" +CONF_BRIGHTNESS = "brightness" +CONF_DISABLE_BRIGHTNESS_ADJUST = "disable_brightness_adjust" +CONF_INTERVAL = "interval" + +MODE_XY = "xy" +MODE_MIRED = "mired" +MODE_RGB = "rgb" +DEFAULT_MODE = MODE_XY + +PLATFORM_SCHEMA = vol.Schema( + { + vol.Required(CONF_PLATFORM): "flux", + vol.Required(CONF_LIGHTS): cv.entity_ids, + vol.Optional(CONF_NAME, default="Flux"): cv.string, + vol.Optional(CONF_START_TIME): cv.time, + vol.Optional(CONF_STOP_TIME): cv.time, + vol.Optional(CONF_START_CT, default=4000): vol.All( + vol.Coerce(int), vol.Range(min=1000, max=40000) + ), + vol.Optional(CONF_SUNSET_CT, default=3000): vol.All( + vol.Coerce(int), vol.Range(min=1000, max=40000) + ), + vol.Optional(CONF_STOP_CT, default=1900): vol.All( + vol.Coerce(int), vol.Range(min=1000, max=40000) + ), + vol.Optional(CONF_BRIGHTNESS): vol.All( + vol.Coerce(int), vol.Range(min=0, max=255) + ), + vol.Optional(CONF_DISABLE_BRIGHTNESS_ADJUST): cv.boolean, + vol.Optional(CONF_MODE, default=DEFAULT_MODE): vol.Any( + MODE_XY, MODE_MIRED, MODE_RGB + ), + vol.Optional(CONF_INTERVAL, default=30): cv.positive_int, + vol.Optional(ATTR_TRANSITION, default=30): VALID_TRANSITION, + } +) + + +async def async_set_lights_xy(hass, lights, x_val, y_val, brightness, transition): + """Set color of array of lights.""" + for light in lights: + if is_on(hass, light): + service_data = {ATTR_ENTITY_ID: light} + if x_val is not None and y_val is not None: + service_data[ATTR_XY_COLOR] = [x_val, y_val] + if brightness is not None: + service_data[ATTR_BRIGHTNESS] = brightness + service_data[ATTR_WHITE_VALUE] = brightness + if transition is not None: + service_data[ATTR_TRANSITION] = transition + await hass.services.async_call(LIGHT_DOMAIN, SERVICE_TURN_ON, service_data) + + +async def async_set_lights_temp(hass, lights, mired, brightness, transition): + """Set color of array of lights.""" + for light in lights: + if is_on(hass, light): + service_data = {ATTR_ENTITY_ID: light} + if mired is not None: + service_data[ATTR_COLOR_TEMP] = int(mired) + if brightness is not None: + service_data[ATTR_BRIGHTNESS] = brightness + if transition is not None: + service_data[ATTR_TRANSITION] = transition + await hass.services.async_call(LIGHT_DOMAIN, SERVICE_TURN_ON, service_data) + + +async def async_set_lights_rgb(hass, lights, rgb, transition): + """Set color of array of lights.""" + for light in lights: + if is_on(hass, light): + service_data = {ATTR_ENTITY_ID: light} + if rgb is not None: + service_data[ATTR_RGB_COLOR] = rgb + if transition is not None: + service_data[ATTR_TRANSITION] = transition + await hass.services.async_call(LIGHT_DOMAIN, SERVICE_TURN_ON, service_data) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the Flux switches.""" + name = config.get(CONF_NAME) + lights = config.get(CONF_LIGHTS) + start_time = config.get(CONF_START_TIME) + stop_time = config.get(CONF_STOP_TIME) + start_colortemp = config.get(CONF_START_CT) + sunset_colortemp = config.get(CONF_SUNSET_CT) + stop_colortemp = config.get(CONF_STOP_CT) + brightness = config.get(CONF_BRIGHTNESS) + disable_brightness_adjust = config.get(CONF_DISABLE_BRIGHTNESS_ADJUST) + mode = config.get(CONF_MODE) + interval = config.get(CONF_INTERVAL) + transition = config.get(ATTR_TRANSITION) + flux = FluxSwitch( + name, + hass, + lights, + start_time, + stop_time, + start_colortemp, + sunset_colortemp, + stop_colortemp, + brightness, + disable_brightness_adjust, + mode, + interval, + transition, + ) + async_add_entities([flux]) + + async def async_update(call=None): + """Update lights.""" + await flux.async_flux_update() + + service_name = slugify("{} {}".format(name, "update")) + hass.services.async_register(DOMAIN, service_name, async_update) + + +class FluxSwitch(SwitchDevice, RestoreEntity): + """Representation of a Flux switch.""" + + def __init__( + self, + name, + hass, + lights, + start_time, + stop_time, + start_colortemp, + sunset_colortemp, + stop_colortemp, + brightness, + disable_brightness_adjust, + mode, + interval, + transition, + ): + """Initialize the Flux switch.""" + self._name = name + self.hass = hass + self._lights = lights + self._start_time = start_time + self._stop_time = stop_time + self._start_colortemp = start_colortemp + self._sunset_colortemp = sunset_colortemp + self._stop_colortemp = stop_colortemp + self._brightness = brightness + self._disable_brightness_adjust = disable_brightness_adjust + self._mode = mode + self._interval = interval + self._transition = transition + self.unsub_tracker = None + + @property + def name(self): + """Return the name of the device if any.""" + return self._name + + @property + def is_on(self): + """Return true if switch is on.""" + return self.unsub_tracker is not None + + async def async_added_to_hass(self): + """Call when entity about to be added to hass.""" + last_state = await self.async_get_last_state() + if last_state and last_state.state == STATE_ON: + await self.async_turn_on() + + async def async_turn_on(self, **kwargs): + """Turn on flux.""" + if self.is_on: + return + + self.unsub_tracker = async_track_time_interval( + self.hass, + self.async_flux_update, + datetime.timedelta(seconds=self._interval), + ) + + # Make initial update + await self.async_flux_update() + + self.async_schedule_update_ha_state() + + async def async_turn_off(self, **kwargs): + """Turn off flux.""" + if self.is_on: + self.unsub_tracker() + self.unsub_tracker = None + + self.async_schedule_update_ha_state() + + async def async_flux_update(self, utcnow=None): + """Update all the lights using flux.""" + if utcnow is None: + utcnow = dt_utcnow() + + now = as_local(utcnow) + + sunset = get_astral_event_date(self.hass, SUN_EVENT_SUNSET, now.date()) + start_time = self.find_start_time(now) + stop_time = self.find_stop_time(now) + + if stop_time <= start_time: + # stop_time does not happen in the same day as start_time + if start_time < now: + # stop time is tomorrow + stop_time += datetime.timedelta(days=1) + elif now < start_time: + # stop_time was yesterday since the new start_time is not reached + stop_time -= datetime.timedelta(days=1) + + if start_time < now < sunset: + # Daytime + time_state = "day" + temp_range = abs(self._start_colortemp - self._sunset_colortemp) + day_length = int(sunset.timestamp() - start_time.timestamp()) + seconds_from_start = int(now.timestamp() - start_time.timestamp()) + percentage_complete = seconds_from_start / day_length + temp_offset = temp_range * percentage_complete + if self._start_colortemp > self._sunset_colortemp: + temp = self._start_colortemp - temp_offset + else: + temp = self._start_colortemp + temp_offset + else: + # Night time + time_state = "night" + + if now < stop_time: + if stop_time < start_time and stop_time.day == sunset.day: + # we need to use yesterday's sunset time + sunset_time = sunset - datetime.timedelta(days=1) + else: + sunset_time = sunset + + night_length = int(stop_time.timestamp() - sunset_time.timestamp()) + seconds_from_sunset = int(now.timestamp() - sunset_time.timestamp()) + percentage_complete = seconds_from_sunset / night_length + else: + percentage_complete = 1 + + temp_range = abs(self._sunset_colortemp - self._stop_colortemp) + temp_offset = temp_range * percentage_complete + if self._sunset_colortemp > self._stop_colortemp: + temp = self._sunset_colortemp - temp_offset + else: + temp = self._sunset_colortemp + temp_offset + rgb = color_temperature_to_rgb(temp) + x_val, y_val, b_val = color_RGB_to_xy_brightness(*rgb) + brightness = self._brightness if self._brightness else b_val + if self._disable_brightness_adjust: + brightness = None + if self._mode == MODE_XY: + await async_set_lights_xy( + self.hass, self._lights, x_val, y_val, brightness, self._transition + ) + _LOGGER.debug( + "Lights updated to x:%s y:%s brightness:%s, %s%% " + "of %s cycle complete at %s", + x_val, + y_val, + brightness, + round(percentage_complete * 100), + time_state, + now, + ) + elif self._mode == MODE_RGB: + await async_set_lights_rgb(self.hass, self._lights, rgb, self._transition) + _LOGGER.debug( + "Lights updated to rgb:%s, %s%% of %s cycle complete at %s", + rgb, + round(percentage_complete * 100), + time_state, + now, + ) + else: + # Convert to mired and clamp to allowed values + mired = color_temperature_kelvin_to_mired(temp) + await async_set_lights_temp( + self.hass, self._lights, mired, brightness, self._transition + ) + _LOGGER.debug( + "Lights updated to mired:%s brightness:%s, %s%% " + "of %s cycle complete at %s", + mired, + brightness, + round(percentage_complete * 100), + time_state, + now, + ) + + def find_start_time(self, now): + """Return sunrise or start_time if given.""" + if self._start_time: + sunrise = now.replace( + hour=self._start_time.hour, minute=self._start_time.minute, second=0 + ) + else: + sunrise = get_astral_event_date(self.hass, SUN_EVENT_SUNRISE, now.date()) + return sunrise + + def find_stop_time(self, now): + """Return dusk or stop_time if given.""" + if self._stop_time: + dusk = now.replace( + hour=self._stop_time.hour, minute=self._stop_time.minute, second=0 + ) + else: + dusk = get_astral_event_date(self.hass, "dusk", now.date()) + return dusk diff --git a/homeassistant/components/flux_led/__init__.py b/homeassistant/components/flux_led/__init__.py new file mode 100644 index 0000000000000..572d6e3c9833c --- /dev/null +++ b/homeassistant/components/flux_led/__init__.py @@ -0,0 +1 @@ +"""The flux_led component.""" diff --git a/homeassistant/components/flux_led/light.py b/homeassistant/components/flux_led/light.py new file mode 100644 index 0000000000000..16db60abbc018 --- /dev/null +++ b/homeassistant/components/flux_led/light.py @@ -0,0 +1,375 @@ +"""Support for Flux lights.""" +import logging +import random +import socket + +from flux_led import BulbScanner, WifiLedBulb +import voluptuous as vol + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP, + ATTR_EFFECT, + ATTR_HS_COLOR, + ATTR_WHITE_VALUE, + EFFECT_COLORLOOP, + EFFECT_RANDOM, + PLATFORM_SCHEMA, + SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, + SUPPORT_COLOR_TEMP, + SUPPORT_EFFECT, + SUPPORT_WHITE_VALUE, + Light, +) +from homeassistant.const import ATTR_MODE, CONF_DEVICES, CONF_NAME, CONF_PROTOCOL +import homeassistant.helpers.config_validation as cv +import homeassistant.util.color as color_util + +_LOGGER = logging.getLogger(__name__) + +CONF_AUTOMATIC_ADD = "automatic_add" +CONF_CUSTOM_EFFECT = "custom_effect" +CONF_COLORS = "colors" +CONF_SPEED_PCT = "speed_pct" +CONF_TRANSITION = "transition" + +DOMAIN = "flux_led" + +SUPPORT_FLUX_LED = SUPPORT_BRIGHTNESS | SUPPORT_EFFECT | SUPPORT_COLOR + +MODE_RGB = "rgb" +MODE_RGBW = "rgbw" + +# This mode enables white value to be controlled by brightness. +# RGB value is ignored when this mode is specified. +MODE_WHITE = "w" + +# Constant color temp values for 2 flux_led special modes +# Warm-white and Cool-white modes +COLOR_TEMP_WARM_VS_COLD_WHITE_CUT_OFF = 285 + +# List of supported effects which aren't already declared in LIGHT +EFFECT_RED_FADE = "red_fade" +EFFECT_GREEN_FADE = "green_fade" +EFFECT_BLUE_FADE = "blue_fade" +EFFECT_YELLOW_FADE = "yellow_fade" +EFFECT_CYAN_FADE = "cyan_fade" +EFFECT_PURPLE_FADE = "purple_fade" +EFFECT_WHITE_FADE = "white_fade" +EFFECT_RED_GREEN_CROSS_FADE = "rg_cross_fade" +EFFECT_RED_BLUE_CROSS_FADE = "rb_cross_fade" +EFFECT_GREEN_BLUE_CROSS_FADE = "gb_cross_fade" +EFFECT_COLORSTROBE = "colorstrobe" +EFFECT_RED_STROBE = "red_strobe" +EFFECT_GREEN_STROBE = "green_strobe" +EFFECT_BLUE_STROBE = "blue_strobe" +EFFECT_YELLOW_STROBE = "yellow_strobe" +EFFECT_CYAN_STROBE = "cyan_strobe" +EFFECT_PURPLE_STROBE = "purple_strobe" +EFFECT_WHITE_STROBE = "white_strobe" +EFFECT_COLORJUMP = "colorjump" +EFFECT_CUSTOM = "custom" + +EFFECT_MAP = { + EFFECT_COLORLOOP: 0x25, + EFFECT_RED_FADE: 0x26, + EFFECT_GREEN_FADE: 0x27, + EFFECT_BLUE_FADE: 0x28, + EFFECT_YELLOW_FADE: 0x29, + EFFECT_CYAN_FADE: 0x2A, + EFFECT_PURPLE_FADE: 0x2B, + EFFECT_WHITE_FADE: 0x2C, + EFFECT_RED_GREEN_CROSS_FADE: 0x2D, + EFFECT_RED_BLUE_CROSS_FADE: 0x2E, + EFFECT_GREEN_BLUE_CROSS_FADE: 0x2F, + EFFECT_COLORSTROBE: 0x30, + EFFECT_RED_STROBE: 0x31, + EFFECT_GREEN_STROBE: 0x32, + EFFECT_BLUE_STROBE: 0x33, + EFFECT_YELLOW_STROBE: 0x34, + EFFECT_CYAN_STROBE: 0x35, + EFFECT_PURPLE_STROBE: 0x36, + EFFECT_WHITE_STROBE: 0x37, + EFFECT_COLORJUMP: 0x38, +} +EFFECT_CUSTOM_CODE = 0x60 + +TRANSITION_GRADUAL = "gradual" +TRANSITION_JUMP = "jump" +TRANSITION_STROBE = "strobe" + +FLUX_EFFECT_LIST = sorted(list(EFFECT_MAP)) + [EFFECT_RANDOM] + +CUSTOM_EFFECT_SCHEMA = vol.Schema( + { + vol.Required(CONF_COLORS): vol.All( + cv.ensure_list, + vol.Length(min=1, max=16), + [ + vol.All( + vol.ExactSequence((cv.byte, cv.byte, cv.byte)), vol.Coerce(tuple) + ) + ], + ), + vol.Optional(CONF_SPEED_PCT, default=50): vol.All( + vol.Range(min=0, max=100), vol.Coerce(int) + ), + vol.Optional(CONF_TRANSITION, default=TRANSITION_GRADUAL): vol.All( + cv.string, vol.In([TRANSITION_GRADUAL, TRANSITION_JUMP, TRANSITION_STROBE]) + ), + } +) + +DEVICE_SCHEMA = vol.Schema( + { + vol.Optional(CONF_NAME): cv.string, + vol.Optional(ATTR_MODE, default=MODE_RGBW): vol.All( + cv.string, vol.In([MODE_RGBW, MODE_RGB, MODE_WHITE]) + ), + vol.Optional(CONF_PROTOCOL): vol.All(cv.string, vol.In(["ledenet"])), + vol.Optional(CONF_CUSTOM_EFFECT): CUSTOM_EFFECT_SCHEMA, + } +) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_DEVICES, default={}): {cv.string: DEVICE_SCHEMA}, + vol.Optional(CONF_AUTOMATIC_ADD, default=False): cv.boolean, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Flux lights.""" + lights = [] + light_ips = [] + + for ipaddr, device_config in config.get(CONF_DEVICES, {}).items(): + device = {} + device["name"] = device_config[CONF_NAME] + device["ipaddr"] = ipaddr + device[CONF_PROTOCOL] = device_config.get(CONF_PROTOCOL) + device[ATTR_MODE] = device_config[ATTR_MODE] + device[CONF_CUSTOM_EFFECT] = device_config.get(CONF_CUSTOM_EFFECT) + light = FluxLight(device) + lights.append(light) + light_ips.append(ipaddr) + + if not config.get(CONF_AUTOMATIC_ADD, False): + add_entities(lights, True) + return + + # Find the bulbs on the LAN + scanner = BulbScanner() + scanner.scan(timeout=10) + for device in scanner.getBulbInfo(): + ipaddr = device["ipaddr"] + if ipaddr in light_ips: + continue + device["name"] = "{} {}".format(device["id"], ipaddr) + device[ATTR_MODE] = None + device[CONF_PROTOCOL] = None + device[CONF_CUSTOM_EFFECT] = None + light = FluxLight(device) + lights.append(light) + + add_entities(lights, True) + + +class FluxLight(Light): + """Representation of a Flux light.""" + + def __init__(self, device): + """Initialize the light.""" + self._name = device["name"] + self._ipaddr = device["ipaddr"] + self._protocol = device[CONF_PROTOCOL] + self._mode = device[ATTR_MODE] + self._custom_effect = device[CONF_CUSTOM_EFFECT] + self._bulb = None + self._error_reported = False + + def _connect(self): + """Connect to Flux light.""" + + self._bulb = WifiLedBulb(self._ipaddr, timeout=5) + if self._protocol: + self._bulb.setProtocol(self._protocol) + + # After bulb object is created the status is updated. We can + # now set the correct mode if it was not explicitly defined. + if not self._mode: + if self._bulb.rgbwcapable: + self._mode = MODE_RGBW + else: + self._mode = MODE_RGB + + def _disconnect(self): + """Disconnect from Flux light.""" + self._bulb = None + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._bulb is not None + + @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._bulb.isOn() + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + if self._mode == MODE_WHITE: + return self.white_value + + return self._bulb.brightness + + @property + def hs_color(self): + """Return the color property.""" + return color_util.color_RGB_to_hs(*self._bulb.getRgb()) + + @property + def supported_features(self): + """Flag supported features.""" + if self._mode == MODE_RGBW: + return SUPPORT_FLUX_LED | SUPPORT_WHITE_VALUE | SUPPORT_COLOR_TEMP + + if self._mode == MODE_WHITE: + return SUPPORT_BRIGHTNESS + + return SUPPORT_FLUX_LED + + @property + def white_value(self): + """Return the white value of this light between 0..255.""" + return self._bulb.getRgbw()[3] + + @property + def effect_list(self): + """Return the list of supported effects.""" + if self._custom_effect: + return FLUX_EFFECT_LIST + [EFFECT_CUSTOM] + + return FLUX_EFFECT_LIST + + @property + def effect(self): + """Return the current effect.""" + current_mode = self._bulb.raw_state[3] + + if current_mode == EFFECT_CUSTOM_CODE: + return EFFECT_CUSTOM + + for effect, code in EFFECT_MAP.items(): + if current_mode == code: + return effect + + return None + + def turn_on(self, **kwargs): + """Turn the specified or all lights on.""" + if not self.is_on: + self._bulb.turnOn() + + hs_color = kwargs.get(ATTR_HS_COLOR) + + if hs_color: + rgb = color_util.color_hs_to_RGB(*hs_color) + else: + rgb = None + + brightness = kwargs.get(ATTR_BRIGHTNESS) + effect = kwargs.get(ATTR_EFFECT) + white = kwargs.get(ATTR_WHITE_VALUE) + color_temp = kwargs.get(ATTR_COLOR_TEMP) + + # handle special modes + if color_temp is not None: + if brightness is None: + brightness = self.brightness + if color_temp > COLOR_TEMP_WARM_VS_COLD_WHITE_CUT_OFF: + self._bulb.setRgbw(w=brightness) + else: + self._bulb.setRgbw(w2=brightness) + return + + # Show warning if effect set with rgb, brightness, or white level + if effect and (brightness or white or rgb): + _LOGGER.warning( + "RGB, brightness and white level are ignored when" + " an effect is specified for a flux bulb" + ) + + # Random color effect + if effect == EFFECT_RANDOM: + self._bulb.setRgb( + random.randint(0, 255), random.randint(0, 255), random.randint(0, 255) + ) + return + + if effect == EFFECT_CUSTOM: + if self._custom_effect: + self._bulb.setCustomPattern( + self._custom_effect[CONF_COLORS], + self._custom_effect[CONF_SPEED_PCT], + self._custom_effect[CONF_TRANSITION], + ) + return + + # Effect selection + if effect in EFFECT_MAP: + self._bulb.setPresetPattern(EFFECT_MAP[effect], 50) + return + + # Preserve current brightness on color/white level change + if brightness is None: + brightness = self.brightness + + # Preserve color on brightness/white level change + if rgb is None: + rgb = self._bulb.getRgb() + + if white is None and self._mode == MODE_RGBW: + white = self.white_value + + # handle W only mode (use brightness instead of white value) + if self._mode == MODE_WHITE: + self._bulb.setRgbw(0, 0, 0, w=brightness) + + # handle RGBW mode + elif self._mode == MODE_RGBW: + self._bulb.setRgbw(*tuple(rgb), w=white, brightness=brightness) + + # handle RGB mode + else: + self._bulb.setRgb(*tuple(rgb), brightness=brightness) + + def turn_off(self, **kwargs): + """Turn the specified or all lights off.""" + self._bulb.turnOff() + + def update(self): + """Synchronize state with bulb.""" + if not self.available: + try: + self._connect() + self._error_reported = False + except socket.error: + self._disconnect() + if not self._error_reported: + _LOGGER.warning( + "Failed to connect to bulb %s, %s", self._ipaddr, self._name + ) + self._error_reported = True + return + + self._bulb.update_state(retry=2) diff --git a/homeassistant/components/flux_led/manifest.json b/homeassistant/components/flux_led/manifest.json new file mode 100644 index 0000000000000..2069913917908 --- /dev/null +++ b/homeassistant/components/flux_led/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "flux_led", + "name": "Flux LED/MagicLight", + "documentation": "https://www.home-assistant.io/integrations/flux_led", + "requirements": ["flux_led==0.22"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/folder/__init__.py b/homeassistant/components/folder/__init__.py new file mode 100644 index 0000000000000..0b95217fd2ad0 --- /dev/null +++ b/homeassistant/components/folder/__init__.py @@ -0,0 +1 @@ +"""The folder component.""" diff --git a/homeassistant/components/folder/manifest.json b/homeassistant/components/folder/manifest.json new file mode 100644 index 0000000000000..d4026e7689daf --- /dev/null +++ b/homeassistant/components/folder/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "folder", + "name": "Folder", + "documentation": "https://www.home-assistant.io/integrations/folder", + "requirements": [], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/folder/sensor.py b/homeassistant/components/folder/sensor.py new file mode 100644 index 0000000000000..a706ab2a0b5cf --- /dev/null +++ b/homeassistant/components/folder/sensor.py @@ -0,0 +1,108 @@ +"""Sensor for monitoring the contents of a folder.""" +from datetime import timedelta +import glob +import logging +import os + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +CONF_FOLDER_PATHS = "folder" +CONF_FILTER = "filter" +DEFAULT_FILTER = "*" + +SCAN_INTERVAL = timedelta(minutes=1) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_FOLDER_PATHS): cv.isdir, + vol.Optional(CONF_FILTER, default=DEFAULT_FILTER): cv.string, + } +) + + +def get_files_list(folder_path, filter_term): + """Return the list of files, applying filter.""" + query = folder_path + filter_term + files_list = glob.glob(query) + return files_list + + +def get_size(files_list): + """Return the sum of the size in bytes of files in the list.""" + size_list = [os.stat(f).st_size for f in files_list if os.path.isfile(f)] + return sum(size_list) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the folder sensor.""" + path = config.get(CONF_FOLDER_PATHS) + + if not hass.config.is_allowed_path(path): + _LOGGER.error("folder %s is not valid or allowed", path) + else: + folder = Folder(path, config.get(CONF_FILTER)) + add_entities([folder], True) + + +class Folder(Entity): + """Representation of a folder.""" + + ICON = "mdi:folder" + + def __init__(self, folder_path, filter_term): + """Initialize the data object.""" + folder_path = os.path.join(folder_path, "") # If no trailing / add it + self._folder_path = folder_path # Need to check its a valid path + self._filter_term = filter_term + self._number_of_files = None + self._size = None + self._name = os.path.split(os.path.split(folder_path)[0])[1] + self._unit_of_measurement = "MB" + self._file_list = None + + def update(self): + """Update the sensor.""" + files_list = get_files_list(self._folder_path, self._filter_term) + self._file_list = files_list + self._number_of_files = len(files_list) + self._size = get_size(files_list) + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + decimals = 2 + size_mb = round(self._size / 1e6, decimals) + return size_mb + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return self.ICON + + @property + def device_state_attributes(self): + """Return other details about the sensor state.""" + attr = { + "path": self._folder_path, + "filter": self._filter_term, + "number_of_files": self._number_of_files, + "bytes": self._size, + "file_list": self._file_list, + } + return attr + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._unit_of_measurement diff --git a/homeassistant/components/folder_watcher/__init__.py b/homeassistant/components/folder_watcher/__init__.py new file mode 100644 index 0000000000000..d99e4928cc5d5 --- /dev/null +++ b/homeassistant/components/folder_watcher/__init__.py @@ -0,0 +1,117 @@ +"""Component for monitoring activity on a folder.""" +import logging +import os + +import voluptuous as vol +from watchdog.events import PatternMatchingEventHandler +from watchdog.observers import Observer + +from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +CONF_FOLDER = "folder" +CONF_PATTERNS = "patterns" +DEFAULT_PATTERN = "*" +DOMAIN = "folder_watcher" + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.All( + cv.ensure_list, + [ + vol.Schema( + { + vol.Required(CONF_FOLDER): cv.isdir, + vol.Optional(CONF_PATTERNS, default=[DEFAULT_PATTERN]): vol.All( + cv.ensure_list, [cv.string] + ), + } + ) + ], + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +def setup(hass, config): + """Set up the folder watcher.""" + conf = config[DOMAIN] + for watcher in conf: + path = watcher[CONF_FOLDER] + patterns = watcher[CONF_PATTERNS] + if not hass.config.is_allowed_path(path): + _LOGGER.error("folder %s is not valid or allowed", path) + return False + Watcher(path, patterns, hass) + + return True + + +def create_event_handler(patterns, hass): + """Return the Watchdog EventHandler object.""" + + class EventHandler(PatternMatchingEventHandler): + """Class for handling Watcher events.""" + + def __init__(self, patterns, hass): + """Initialise the EventHandler.""" + super().__init__(patterns) + self.hass = hass + + def process(self, event): + """On Watcher event, fire HA event.""" + _LOGGER.debug("process(%s)", event) + if not event.is_directory: + folder, file_name = os.path.split(event.src_path) + self.hass.bus.fire( + DOMAIN, + { + "event_type": event.event_type, + "path": event.src_path, + "file": file_name, + "folder": folder, + }, + ) + + def on_modified(self, event): + """File modified.""" + self.process(event) + + def on_moved(self, event): + """File moved.""" + self.process(event) + + def on_created(self, event): + """File created.""" + self.process(event) + + def on_deleted(self, event): + """File deleted.""" + self.process(event) + + return EventHandler(patterns, hass) + + +class Watcher: + """Class for starting Watchdog.""" + + def __init__(self, path, patterns, hass): + """Initialise the watchdog observer.""" + self._observer = Observer() + self._observer.schedule( + create_event_handler(patterns, hass), path, recursive=True + ) + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, self.startup) + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, self.shutdown) + + def startup(self, event): + """Start the watcher.""" + self._observer.start() + + def shutdown(self, event): + """Shutdown the watcher.""" + self._observer.stop() + self._observer.join() diff --git a/homeassistant/components/folder_watcher/manifest.json b/homeassistant/components/folder_watcher/manifest.json new file mode 100644 index 0000000000000..47edc4dccc0a5 --- /dev/null +++ b/homeassistant/components/folder_watcher/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "folder_watcher", + "name": "Folder Watcher", + "documentation": "https://www.home-assistant.io/integrations/folder_watcher", + "requirements": ["watchdog==0.8.3"], + "dependencies": [], + "codeowners": [], + "quality_scale": "internal" +} diff --git a/homeassistant/components/foobot/__init__.py b/homeassistant/components/foobot/__init__.py new file mode 100644 index 0000000000000..92edde9a5e1cd --- /dev/null +++ b/homeassistant/components/foobot/__init__.py @@ -0,0 +1 @@ +"""The foobot component.""" diff --git a/homeassistant/components/foobot/manifest.json b/homeassistant/components/foobot/manifest.json new file mode 100644 index 0000000000000..c30985225f45a --- /dev/null +++ b/homeassistant/components/foobot/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "foobot", + "name": "Foobot", + "documentation": "https://www.home-assistant.io/integrations/foobot", + "requirements": ["foobot_async==0.3.1"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/foobot/sensor.py b/homeassistant/components/foobot/sensor.py new file mode 100644 index 0000000000000..efb74e2cc9a1a --- /dev/null +++ b/homeassistant/components/foobot/sensor.py @@ -0,0 +1,157 @@ +"""Support for the Foobot indoor air quality monitor.""" +import asyncio +from datetime import timedelta +import logging + +import aiohttp +from foobot_async import FoobotClient +import voluptuous as vol + +from homeassistant.const import ( + ATTR_TEMPERATURE, + ATTR_TIME, + CONF_TOKEN, + CONF_USERNAME, + 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.config_validation import PLATFORM_SCHEMA +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +_LOGGER = logging.getLogger(__name__) + +ATTR_HUMIDITY = "humidity" +ATTR_PM2_5 = "PM2.5" +ATTR_CARBON_DIOXIDE = "CO2" +ATTR_VOLATILE_ORGANIC_COMPOUNDS = "VOC" +ATTR_FOOBOT_INDEX = "index" + +SENSOR_TYPES = { + "time": [ATTR_TIME, "s"], + "pm": [ATTR_PM2_5, "µg/m3", "mdi:cloud"], + "tmp": [ATTR_TEMPERATURE, TEMP_CELSIUS, "mdi:thermometer"], + "hum": [ATTR_HUMIDITY, "%", "mdi:water-percent"], + "co2": [ATTR_CARBON_DIOXIDE, "ppm", "mdi:periodic-table-co2"], + "voc": [ATTR_VOLATILE_ORGANIC_COMPOUNDS, "ppb", "mdi:cloud"], + "allpollu": [ATTR_FOOBOT_INDEX, "%", "mdi:percent"], +} + +SCAN_INTERVAL = timedelta(minutes=10) +PARALLEL_UPDATES = 1 + +TIMEOUT = 10 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + {vol.Required(CONF_TOKEN): cv.string, vol.Required(CONF_USERNAME): cv.string} +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the devices associated with the account.""" + token = config.get(CONF_TOKEN) + username = config.get(CONF_USERNAME) + + client = FoobotClient( + token, username, async_get_clientsession(hass), timeout=TIMEOUT + ) + dev = [] + try: + devices = await client.get_devices() + _LOGGER.debug("The following devices were found: %s", devices) + for device in devices: + foobot_data = FoobotData(client, device["uuid"]) + for sensor_type in SENSOR_TYPES: + if sensor_type == "time": + continue + foobot_sensor = FoobotSensor(foobot_data, device, sensor_type) + dev.append(foobot_sensor) + except ( + aiohttp.client_exceptions.ClientConnectorError, + asyncio.TimeoutError, + FoobotClient.TooManyRequests, + FoobotClient.InternalError, + ): + _LOGGER.exception("Failed to connect to foobot servers.") + raise PlatformNotReady + except FoobotClient.ClientError: + _LOGGER.error("Failed to fetch data from foobot servers.") + return + async_add_entities(dev, True) + + +class FoobotSensor(Entity): + """Implementation of a Foobot sensor.""" + + def __init__(self, data, device, sensor_type): + """Initialize the sensor.""" + self._uuid = device["uuid"] + self.foobot_data = data + self._name = "Foobot {} {}".format(device["name"], SENSOR_TYPES[sensor_type][0]) + self.type = sensor_type + self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def icon(self): + """Icon to use in the frontend.""" + return SENSOR_TYPES[self.type][2] + + @property + def state(self): + """Return the state of the device.""" + try: + data = self.foobot_data.data[self.type] + except (KeyError, TypeError): + data = None + return data + + @property + def unique_id(self): + """Return the unique id of this entity.""" + return 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.foobot_data.async_update() + + +class FoobotData(Entity): + """Get data from Foobot API.""" + + def __init__(self, client, uuid): + """Initialize the data object.""" + self._client = client + self._uuid = uuid + self.data = {} + + @Throttle(SCAN_INTERVAL) + async def async_update(self): + """Get the data from Foobot API.""" + interval = SCAN_INTERVAL.total_seconds() + try: + response = await self._client.get_last_data( + self._uuid, interval, interval + 1 + ) + except ( + aiohttp.client_exceptions.ClientConnectorError, + asyncio.TimeoutError, + self._client.TooManyRequests, + self._client.InternalError, + ): + _LOGGER.debug("Couldn't fetch data") + return False + _LOGGER.debug("The data response is: %s", response) + self.data = {k: round(v, 1) for k, v in response[0].items()} + return True diff --git a/homeassistant/components/fortigate/__init__.py b/homeassistant/components/fortigate/__init__.py new file mode 100644 index 0000000000000..6de55ae3d6570 --- /dev/null +++ b/homeassistant/components/fortigate/__init__.py @@ -0,0 +1,76 @@ +"""Fortigate integration.""" +import logging + +from pyFGT.fortigate import FGTConnectionError, FortiGate +import voluptuous as vol + +from homeassistant.const import ( + CONF_API_KEY, + CONF_DEVICES, + CONF_HOST, + CONF_USERNAME, + EVENT_HOMEASSISTANT_STOP, +) +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.discovery import async_load_platform + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "fortigate" + +DATA_FGT = DOMAIN + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_API_KEY): cv.string, + vol.Optional(CONF_DEVICES, default=[]): vol.All( + cv.ensure_list, [cv.string] + ), + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, config): + """Start the Fortigate component.""" + conf = config[DOMAIN] + + host = conf[CONF_HOST] + user = conf[CONF_USERNAME] + api_key = conf[CONF_API_KEY] + devices = conf[CONF_DEVICES] + + is_success = await async_setup_fortigate(hass, config, host, user, api_key, devices) + + return is_success + + +async def async_setup_fortigate(hass, config, host, user, api_key, devices): + """Start up the Fortigate component platforms.""" + fgt = FortiGate(host, user, apikey=api_key, disable_request_warnings=True) + + try: + fgt.login() + except FGTConnectionError: + _LOGGER.error("Failed to connect to Fortigate") + return False + + hass.data[DATA_FGT] = {"fgt": fgt, "devices": devices} + + hass.async_create_task( + async_load_platform(hass, "device_tracker", DOMAIN, {}, config) + ) + + async def close_fgt(event): + """Close Fortigate connection on HA Stop.""" + fgt.logout() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, close_fgt) + + return True diff --git a/homeassistant/components/fortigate/device_tracker.py b/homeassistant/components/fortigate/device_tracker.py new file mode 100644 index 0000000000000..b51dc6843aaf1 --- /dev/null +++ b/homeassistant/components/fortigate/device_tracker.py @@ -0,0 +1,89 @@ +"""Device tracker for Fortigate firewalls.""" +from collections import namedtuple +import logging + +from homeassistant.components.device_tracker import DeviceScanner + +from . import DATA_FGT + +_LOGGER = logging.getLogger(__name__) + +DETECTED_DEVICES = "/monitor/user/detected-device" + + +async def async_get_scanner(hass, config): + """Validate the configuration and return a Fortigate scanner.""" + scanner = FortigateDeviceScanner(hass.data[DATA_FGT]) + await scanner.async_connect() + return scanner if scanner.success_init else None + + +Device = namedtuple("Device", ["hostname", "mac"]) + + +def _build_device(device_dict): + """Return a Device from data.""" + return Device(device_dict["hostname"], device_dict["mac"]) + + +class FortigateDeviceScanner(DeviceScanner): + """Query the Fortigate firewall.""" + + def __init__(self, hass_data): + """Initialize the scanner.""" + self.last_results = {} + self.success_init = False + self.connection = hass_data["fgt"] + self.devices = hass_data["devices"] + + def get_results(self): + """Get the results from the Fortigate.""" + results = self.connection.get(DETECTED_DEVICES, "vdom=root")[1]["results"] + + ret = [] + for result in results: + if "hostname" not in result: + continue + + ret.append(result) + + return ret + + async def async_connect(self): + """Initialize connection to the router.""" + # Test if the firewall is accessible + data = self.get_results() + self.success_init = data is not None + + async def async_scan_devices(self): + """Scan for new devices and return a list with found device MACs.""" + await self.async_update_info() + return [device.mac for device in self.last_results] + + async def get_device_name(self, device): + """Return the name of the given device or None if we don't know.""" + name = next( + (result.hostname for result in self.last_results if result.mac == device), + None, + ) + return name + + async def async_update_info(self): + """Ensure the information from the Fortigate firewall is up to date.""" + _LOGGER.debug("Checking devices") + + hosts = self.get_results() + + all_results = [_build_device(device) for device in hosts if device["is_online"]] + + # If the 'devices' configuration field is filled + if self.devices is not None: + last_results = [ + device for device in all_results if device.hostname in self.devices + ] + _LOGGER.debug(last_results) + # If the 'devices' configuration field is not filled + else: + last_results = all_results + + self.last_results = last_results diff --git a/homeassistant/components/fortigate/manifest.json b/homeassistant/components/fortigate/manifest.json new file mode 100644 index 0000000000000..1fdd28e256d72 --- /dev/null +++ b/homeassistant/components/fortigate/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "fortigate", + "name": "FortiGate", + "documentation": "https://www.home-assistant.io/integrations/fortigate", + "dependencies": [], + "codeowners": ["@kifeo"], + "requirements": ["pyfgt==0.5.1"] +} diff --git a/homeassistant/components/fortios/__init__.py b/homeassistant/components/fortios/__init__.py new file mode 100644 index 0000000000000..873d6c00c6559 --- /dev/null +++ b/homeassistant/components/fortios/__init__.py @@ -0,0 +1 @@ +"""Fortinet FortiOS components.""" diff --git a/homeassistant/components/fortios/device_tracker.py b/homeassistant/components/fortios/device_tracker.py new file mode 100644 index 0000000000000..2b2d14f60e04f --- /dev/null +++ b/homeassistant/components/fortios/device_tracker.py @@ -0,0 +1,100 @@ +""" +Support to use FortiOS device like FortiGate as device tracker. + +This component is part of the device_tracker platform. +""" +import logging + +from fortiosapi import FortiOSAPI +import voluptuous as vol + +from homeassistant.components.device_tracker import ( + DOMAIN, + PLATFORM_SCHEMA, + DeviceScanner, +) +from homeassistant.const import CONF_HOST, CONF_TOKEN, CONF_VERIFY_SSL +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) +DEFAULT_VERIFY_SSL = False + + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_TOKEN): cv.string, + vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, + } +) + + +def get_scanner(hass, config): + """Validate the configuration and return a FortiOSDeviceScanner.""" + host = config[DOMAIN][CONF_HOST] + verify_ssl = config[DOMAIN][CONF_VERIFY_SSL] + token = config[DOMAIN][CONF_TOKEN] + + fgt = FortiOSAPI() + + try: + fgt.tokenlogin(host, token, verify_ssl) + except ConnectionError as ex: + _LOGGER.error("ConnectionError to FortiOS API: %s", ex) + return None + except Exception as ex: # pylint: disable=broad-except + _LOGGER.error("Failed to login to FortiOS API: %s", ex) + return None + + return FortiOSDeviceScanner(fgt) + + +class FortiOSDeviceScanner(DeviceScanner): + """This class queries a FortiOS unit for connected devices.""" + + def __init__(self, fgt) -> None: + """Initialize the scanner.""" + self._clients = {} + self._clients_json = {} + self._fgt = fgt + + def update(self): + """Update clients from the device.""" + clients_json = self._fgt.monitor("user/device/select", "") + self._clients_json = clients_json + + self._clients = [] + + if clients_json: + for client in clients_json["results"]: + if client["last_seen"] < 180: + self._clients.append(client["mac"].upper()) + + def scan_devices(self): + """Scan for new devices and return a list with found device IDs.""" + self.update() + return self._clients + + def get_device_name(self, device): + """Return the name of the given device or None if we don't know.""" + _LOGGER.debug("Getting name of device %s", device) + + device = device.lower() + + data = self._clients_json + + if data == 0: + _LOGGER.error("No json results to get device names") + return None + + for client in data["results"]: + if client["mac"] == device: + try: + name = client["host"]["name"] + _LOGGER.debug("Getting device name=%s", name) + return name + except KeyError as kex: + _LOGGER.error("Name not found in client data: %s", kex) + return None + + return None diff --git a/homeassistant/components/fortios/manifest.json b/homeassistant/components/fortios/manifest.json new file mode 100644 index 0000000000000..dd0fdae96196e --- /dev/null +++ b/homeassistant/components/fortios/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "fortios", + "name": "Home Assistant Device Tracker to support FortiOS", + "documentation": "https://www.home-assistant.io/integrations/fortios/", + "requirements": ["fortiosapi==0.10.8"], + "dependencies": [], + "codeowners": ["@kimfrellsen"] +} diff --git a/homeassistant/components/foscam/__init__.py b/homeassistant/components/foscam/__init__.py new file mode 100644 index 0000000000000..5c63f7b2a154e --- /dev/null +++ b/homeassistant/components/foscam/__init__.py @@ -0,0 +1 @@ +"""The foscam component.""" diff --git a/homeassistant/components/foscam/camera.py b/homeassistant/components/foscam/camera.py new file mode 100644 index 0000000000000..f4ec655689475 --- /dev/null +++ b/homeassistant/components/foscam/camera.py @@ -0,0 +1,255 @@ +"""This component provides basic support for Foscam IP cameras.""" +import asyncio +import logging + +from libpyfoscam import FoscamCamera +import voluptuous as vol + +from homeassistant.components.camera import PLATFORM_SCHEMA, SUPPORT_STREAM, Camera +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, +) +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.service import async_extract_entity_ids + +from .const import ( + DATA as FOSCAM_DATA, + DOMAIN as FOSCAM_DOMAIN, + ENTITIES as FOSCAM_ENTITIES, +) + +_LOGGER = logging.getLogger(__name__) + +CONF_IP = "ip" +CONF_RTSP_PORT = "rtsp_port" + +DEFAULT_NAME = "Foscam Camera" +DEFAULT_PORT = 88 + +SERVICE_PTZ = "ptz" +ATTR_MOVEMENT = "movement" +ATTR_TRAVELTIME = "travel_time" + +DEFAULT_TRAVELTIME = 0.125 + +DIR_UP = "up" +DIR_DOWN = "down" +DIR_LEFT = "left" +DIR_RIGHT = "right" + +DIR_TOPLEFT = "top_left" +DIR_TOPRIGHT = "top_right" +DIR_BOTTOMLEFT = "bottom_left" +DIR_BOTTOMRIGHT = "bottom_right" + +MOVEMENT_ATTRS = { + DIR_UP: "ptz_move_up", + DIR_DOWN: "ptz_move_down", + DIR_LEFT: "ptz_move_left", + DIR_RIGHT: "ptz_move_right", + DIR_TOPLEFT: "ptz_move_top_left", + DIR_TOPRIGHT: "ptz_move_top_right", + DIR_BOTTOMLEFT: "ptz_move_bottom_left", + DIR_BOTTOMRIGHT: "ptz_move_bottom_right", +} + +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, + vol.Optional(CONF_RTSP_PORT): cv.port, + } +) + +SERVICE_PTZ_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_MOVEMENT): vol.In( + [ + DIR_UP, + DIR_DOWN, + DIR_LEFT, + DIR_RIGHT, + DIR_TOPLEFT, + DIR_TOPRIGHT, + DIR_BOTTOMLEFT, + DIR_BOTTOMRIGHT, + ] + ), + vol.Optional(ATTR_TRAVELTIME, default=DEFAULT_TRAVELTIME): cv.small_float, + } +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up a Foscam IP Camera.""" + + async def async_handle_ptz(service): + """Handle PTZ service call.""" + movement = service.data[ATTR_MOVEMENT] + travel_time = service.data[ATTR_TRAVELTIME] + entity_ids = await async_extract_entity_ids(hass, service) + + if not entity_ids: + return + + _LOGGER.debug("Moving '%s' camera(s): %s", movement, entity_ids) + + all_cameras = hass.data[FOSCAM_DATA][FOSCAM_ENTITIES] + target_cameras = [ + camera for camera in all_cameras if camera.entity_id in entity_ids + ] + + for camera in target_cameras: + await camera.async_perform_ptz(movement, travel_time) + + hass.services.async_register( + FOSCAM_DOMAIN, SERVICE_PTZ, async_handle_ptz, schema=SERVICE_PTZ_SCHEMA + ) + + camera = FoscamCamera( + config[CONF_IP], + config[CONF_PORT], + config[CONF_USERNAME], + config[CONF_PASSWORD], + verbose=False, + ) + + rtsp_port = config.get(CONF_RTSP_PORT) + if not rtsp_port: + ret, response = await hass.async_add_executor_job(camera.get_port_info) + + if ret == 0: + rtsp_port = response.get("rtspPort") or response.get("mediaPort") + + ret, response = await hass.async_add_executor_job(camera.get_motion_detect_config) + + motion_status = False + if ret != 0 and response == 1: + motion_status = True + + async_add_entities( + [ + HassFoscamCamera( + camera, + config[CONF_NAME], + config[CONF_USERNAME], + config[CONF_PASSWORD], + rtsp_port, + motion_status, + ) + ] + ) + + +class HassFoscamCamera(Camera): + """An implementation of a Foscam IP camera.""" + + def __init__(self, camera, name, username, password, rtsp_port, motion_status): + """Initialize a Foscam camera.""" + super().__init__() + + self._foscam_session = camera + self._name = name + self._username = username + self._password = password + self._rtsp_port = rtsp_port + self._motion_status = motion_status + + async def async_added_to_hass(self): + """Handle entity addition to hass.""" + entities = self.hass.data.setdefault(FOSCAM_DATA, {}).setdefault( + FOSCAM_ENTITIES, [] + ) + entities.append(self) + + 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 != 0: + return None + + return response + + @property + def supported_features(self): + """Return supported features.""" + if self._rtsp_port: + return SUPPORT_STREAM + return 0 + + async def stream_source(self): + """Return the stream source.""" + if self._rtsp_port: + return "rtsp://{}:{}@{}:{}/videoMain".format( + self._username, + self._password, + self._foscam_session.host, + self._rtsp_port, + ) + return None + + @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() + + if ret != 0: + return + + self._motion_status = True + except TypeError: + _LOGGER.debug("Communication problem") + + def disable_motion_detection(self): + """Disable motion detection.""" + try: + ret = self._foscam_session.disable_motion_detection() + + if ret != 0: + return + + self._motion_status = False + except TypeError: + _LOGGER.debug("Communication problem") + + async def async_perform_ptz(self, movement, travel_time): + """Perform a PTZ action on the camera.""" + _LOGGER.debug("PTZ action '%s' on %s", movement, self._name) + + movement_function = getattr(self._foscam_session, MOVEMENT_ATTRS[movement]) + + ret, _ = await self.hass.async_add_executor_job(movement_function) + + if ret != 0: + _LOGGER.error("Error moving %s '%s': %s", movement, self._name, ret) + return + + await asyncio.sleep(travel_time) + + ret, _ = await self.hass.async_add_executor_job( + self._foscam_session.ptz_stop_run + ) + + if ret != 0: + _LOGGER.error("Error stopping movement on '%s': %s", self._name, ret) + return + + @property + def name(self): + """Return the name of this camera.""" + return self._name diff --git a/homeassistant/components/foscam/const.py b/homeassistant/components/foscam/const.py new file mode 100644 index 0000000000000..63b4b74a76330 --- /dev/null +++ b/homeassistant/components/foscam/const.py @@ -0,0 +1,5 @@ +"""Constants for Foscam component.""" + +DOMAIN = "foscam" +DATA = "foscam" +ENTITIES = "entities" diff --git a/homeassistant/components/foscam/manifest.json b/homeassistant/components/foscam/manifest.json new file mode 100644 index 0000000000000..63d44fc04e935 --- /dev/null +++ b/homeassistant/components/foscam/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "foscam", + "name": "Foscam", + "documentation": "https://www.home-assistant.io/integrations/foscam", + "requirements": ["libpyfoscam==1.0"], + "dependencies": [], + "codeowners": ["@skgsergio"] +} diff --git a/homeassistant/components/foscam/services.yaml b/homeassistant/components/foscam/services.yaml new file mode 100644 index 0000000000000..64e68dd5bc42b --- /dev/null +++ b/homeassistant/components/foscam/services.yaml @@ -0,0 +1,12 @@ +ptz: + description: Pan/Tilt service for Foscam camera. + fields: + entity_id: + description: Name(s) of entities to move. + example: 'camera.living_room_camera' + movement: + description: "Direction of the movement. Allowed values: up, down, left, right, top_left, top_right, bottom_left, bottom_right." + example: 'up' + travel_time: + description: "(Optional) Travel time in seconds. Allowed values: float from 0 to 1. Default: 0.125" + example: 0.125 diff --git a/homeassistant/components/foursquare/__init__.py b/homeassistant/components/foursquare/__init__.py new file mode 100644 index 0000000000000..af15c4e5fa845 --- /dev/null +++ b/homeassistant/components/foursquare/__init__.py @@ -0,0 +1,110 @@ +"""Support for the Foursquare (Swarm) API.""" +import logging + +import requests +import voluptuous as vol + +from homeassistant.components.http import HomeAssistantView +from homeassistant.const import CONF_ACCESS_TOKEN, HTTP_BAD_REQUEST +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +CONF_PUSH_SECRET = "push_secret" + +DOMAIN = "foursquare" + +EVENT_CHECKIN = "foursquare.checkin" +EVENT_PUSH = "foursquare.push" + +SERVICE_CHECKIN = "checkin" + +CHECKIN_SERVICE_SCHEMA = vol.Schema( + { + vol.Optional("alt"): cv.string, + vol.Optional("altAcc"): cv.string, + vol.Optional("broadcast"): cv.string, + vol.Optional("eventId"): cv.string, + vol.Optional("ll"): cv.string, + vol.Optional("llAcc"): cv.string, + vol.Optional("mentions"): cv.string, + vol.Optional("shout"): cv.string, + vol.Required("venueId"): cv.string, + } +) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_ACCESS_TOKEN): cv.string, + vol.Required(CONF_PUSH_SECRET): cv.string, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +def setup(hass, config): + """Set up the Foursquare component.""" + config = config[DOMAIN] + + def checkin_user(call): + """Check a user in on Swarm.""" + url = ( + "https://api.foursquare.com/v2/checkins/add" + "?oauth_token={}" + "&v=20160802" + "&m=swarm" + ).format(config[CONF_ACCESS_TOKEN]) + response = requests.post(url, data=call.data, timeout=10) + + if response.status_code not in (200, 201): + _LOGGER.exception( + "Error checking in user. Response %d: %s:", + response.status_code, + response.reason, + ) + + hass.bus.fire(EVENT_CHECKIN, response.text) + + # Register our service with Home Assistant. + hass.services.register( + DOMAIN, "checkin", checkin_user, schema=CHECKIN_SERVICE_SCHEMA + ) + + hass.http.register_view(FoursquarePushReceiver(config[CONF_PUSH_SECRET])) + + return True + + +class FoursquarePushReceiver(HomeAssistantView): + """Handle pushes from the Foursquare API.""" + + requires_auth = False + url = "/api/foursquare" + name = "foursquare" + + def __init__(self, push_secret): + """Initialize the OAuth callback view.""" + self.push_secret = push_secret + + async def post(self, request): + """Accept the POST from Foursquare.""" + try: + data = await request.json() + except ValueError: + return self.json_message("Invalid JSON", HTTP_BAD_REQUEST) + + secret = data.pop("secret", None) + + _LOGGER.debug("Received Foursquare push: %s", data) + + if self.push_secret != secret: + _LOGGER.error( + "Received Foursquare push with invalid push secret: %s", secret + ) + return self.json_message("Incorrect secret", HTTP_BAD_REQUEST) + + request.app["hass"].bus.async_fire(EVENT_PUSH, data) diff --git a/homeassistant/components/foursquare/manifest.json b/homeassistant/components/foursquare/manifest.json new file mode 100644 index 0000000000000..450759a592286 --- /dev/null +++ b/homeassistant/components/foursquare/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "foursquare", + "name": "Foursquare", + "documentation": "https://www.home-assistant.io/integrations/foursquare", + "requirements": [], + "dependencies": ["http"], + "codeowners": ["@robbiet480"] +} diff --git a/homeassistant/components/foursquare/services.yaml b/homeassistant/components/foursquare/services.yaml new file mode 100644 index 0000000000000..3d15a9583f64f --- /dev/null +++ b/homeassistant/components/foursquare/services.yaml @@ -0,0 +1,29 @@ +checkin: + description: Check a user into a Foursquare venue. + fields: + alt: {description: 'Altitude of the user''s location, in meters. [Optional]', + example: 0} + altAcc: {description: 'Vertical accuracy of the user''s location, in meters.', + example: 1} + broadcast: {description: 'Who to broadcast this check-in to. Accepts a comma-delimited + list of values: private (off the grid) or public (share with friends), facebook + share on facebook, twitter share on twitter, followers share with followers + (celebrity mode users only), If no valid value is found, the default is public. + [Optional]', example: 'public,twitter'} + eventId: {description: 'The event the user is checking in to. [Optional]', example: UHR8THISVNT} + ll: {description: 'Latitude and longitude of the user''s location. Only specify + this field if you have a GPS or other device reported location for the user + at the time of check-in. [Optional]', example: '33.7,44.2'} + llAcc: {description: 'Accuracy of the user''s latitude and longitude, in meters. + [Optional]', example: 1} + mentions: {description: 'Mentions in your check-in. This parameter is a semicolon-delimited + list of mentions. A single mention is of the form "start,end,userid", where + start is the index of the first character in the shout representing the mention, + end is the index of the first character in the shout after the mention, and + userid is the userid of the user being mentioned. If userid is prefixed with + "fbu-", this indicates a Facebook userid that is being mention. Character + indices in shouts are 0-based. [Optional]', example: '5,10,HZXXY3Y;15,20,GZYYZ3Z;25,30,fbu-GZXY13Y'} + shout: {description: 'A message about your check-in. The maximum length of this + field is 140 characters. [Optional]', example: There are crayons! Crayons!} + venueId: {description: 'The Foursquare venue where the user is checking in. [Required]', + example: IHR8THISVNU} diff --git a/homeassistant/components/free_mobile/__init__.py b/homeassistant/components/free_mobile/__init__.py new file mode 100644 index 0000000000000..002a1475fde0f --- /dev/null +++ b/homeassistant/components/free_mobile/__init__.py @@ -0,0 +1 @@ +"""The free_mobile component.""" diff --git a/homeassistant/components/free_mobile/manifest.json b/homeassistant/components/free_mobile/manifest.json new file mode 100644 index 0000000000000..2bba216242f79 --- /dev/null +++ b/homeassistant/components/free_mobile/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "free_mobile", + "name": "Free Mobile", + "documentation": "https://www.home-assistant.io/integrations/free_mobile", + "requirements": ["freesms==0.1.2"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/free_mobile/notify.py b/homeassistant/components/free_mobile/notify.py new file mode 100644 index 0000000000000..8b5273f39d114 --- /dev/null +++ b/homeassistant/components/free_mobile/notify.py @@ -0,0 +1,41 @@ +"""Support for Free Mobile SMS platform.""" +import logging + +from freesms import FreeClient +import voluptuous as vol + +from homeassistant.components.notify import PLATFORM_SCHEMA, BaseNotificationService +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_USERNAME +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + {vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_ACCESS_TOKEN): cv.string} +) + + +def get_service(hass, config, discovery_info=None): + """Get the Free Mobile SMS notification service.""" + return FreeSMSNotificationService(config[CONF_USERNAME], config[CONF_ACCESS_TOKEN]) + + +class FreeSMSNotificationService(BaseNotificationService): + """Implement a notification service for the Free Mobile SMS service.""" + + def __init__(self, username, access_token): + """Initialize the service.""" + self.free_client = FreeClient(username, access_token) + + def send_message(self, message="", **kwargs): + """Send a message to the Free Mobile user cell.""" + resp = self.free_client.send_sms(message) + + if resp.status_code == 400: + _LOGGER.error("At least one parameter is missing") + elif resp.status_code == 402: + _LOGGER.error("Too much SMS send in a few time") + elif resp.status_code == 403: + _LOGGER.error("Wrong Username/Password") + elif resp.status_code == 500: + _LOGGER.error("Server error, try later") diff --git a/homeassistant/components/freebox/__init__.py b/homeassistant/components/freebox/__init__.py new file mode 100644 index 0000000000000..58426334dea6f --- /dev/null +++ b/homeassistant/components/freebox/__init__.py @@ -0,0 +1,90 @@ +"""Support for Freebox devices (Freebox v6 and Freebox mini 4K).""" +import logging +import socket + +from aiofreepybox import Freepybox +from aiofreepybox.exceptions import HttpRequestError +import voluptuous as vol + +from homeassistant.components.discovery import SERVICE_FREEBOX +from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP +from homeassistant.helpers import config_validation as cv, discovery +from homeassistant.helpers.discovery import async_load_platform + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "freebox" +DATA_FREEBOX = DOMAIN + +FREEBOX_CONFIG_FILE = "freebox.conf" + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + {vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PORT): cv.port} + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, config): + """Set up the Freebox component.""" + conf = config.get(DOMAIN) + + async def discovery_dispatch(service, discovery_info): + if conf is None: + host = discovery_info.get("properties", {}).get("api_domain") + port = discovery_info.get("properties", {}).get("https_port") + _LOGGER.info("Discovered Freebox server: %s:%s", host, port) + await async_setup_freebox(hass, config, host, port) + + discovery.async_listen(hass, SERVICE_FREEBOX, discovery_dispatch) + + if conf is not None: + host = conf.get(CONF_HOST) + port = conf.get(CONF_PORT) + await async_setup_freebox(hass, config, host, port) + + return True + + +async def async_setup_freebox(hass, config, host, port): + """Start up the Freebox component platforms.""" + + app_desc = { + "app_id": "hass", + "app_name": "Home Assistant", + "app_version": "0.65", + "device_name": socket.gethostname(), + } + + token_file = hass.config.path(FREEBOX_CONFIG_FILE) + api_version = "v6" + + fbx = Freepybox(app_desc=app_desc, token_file=token_file, api_version=api_version) + + try: + await fbx.open(host, port) + except HttpRequestError: + _LOGGER.exception("Failed to connect to Freebox") + else: + hass.data[DATA_FREEBOX] = fbx + + async def async_freebox_reboot(call): + """Handle reboot service call.""" + await fbx.system.reboot() + + hass.services.async_register(DOMAIN, "reboot", async_freebox_reboot) + + hass.async_create_task(async_load_platform(hass, "sensor", DOMAIN, {}, config)) + hass.async_create_task( + async_load_platform(hass, "device_tracker", DOMAIN, {}, config) + ) + hass.async_create_task(async_load_platform(hass, "switch", DOMAIN, {}, config)) + + async def close_fbx(event): + """Close Freebox connection on HA Stop.""" + await fbx.close() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, close_fbx) diff --git a/homeassistant/components/freebox/device_tracker.py b/homeassistant/components/freebox/device_tracker.py new file mode 100644 index 0000000000000..63cf869990daf --- /dev/null +++ b/homeassistant/components/freebox/device_tracker.py @@ -0,0 +1,65 @@ +"""Support for Freebox devices (Freebox v6 and Freebox mini 4K).""" +from collections import namedtuple +import logging + +from homeassistant.components.device_tracker import DeviceScanner + +from . import DATA_FREEBOX + +_LOGGER = logging.getLogger(__name__) + + +async def async_get_scanner(hass, config): + """Validate the configuration and return a Freebox scanner.""" + scanner = FreeboxDeviceScanner(hass.data[DATA_FREEBOX]) + await scanner.async_connect() + return scanner if scanner.success_init else None + + +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(DeviceScanner): + """Queries the Freebox device.""" + + def __init__(self, fbx): + """Initialize the scanner.""" + self.last_results = {} + self.success_init = False + self.connection = fbx + + async def async_connect(self): + """Initialize connection to the router.""" + # Test the router is accessible. + data = await self.connection.lan.get_hosts_list() + 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 [device.id for device in self.last_results] + + async def get_device_name(self, device): + """Return the name of the given device or None if we don't know.""" + name = next( + (result.name for result in self.last_results if result.id == device), None + ) + return name + + async def async_update_info(self): + """Ensure the information from the Freebox router is up to date.""" + _LOGGER.debug("Checking Devices") + + hosts = await self.connection.lan.get_hosts_list() + + last_results = [_build_device(device) for device in hosts if device["active"]] + + self.last_results = last_results diff --git a/homeassistant/components/freebox/manifest.json b/homeassistant/components/freebox/manifest.json new file mode 100644 index 0000000000000..5a29a619a3345 --- /dev/null +++ b/homeassistant/components/freebox/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "freebox", + "name": "Freebox", + "documentation": "https://www.home-assistant.io/integrations/freebox", + "requirements": ["aiofreepybox==0.0.8"], + "dependencies": [], + "codeowners": ["@snoof85"] +} diff --git a/homeassistant/components/freebox/sensor.py b/homeassistant/components/freebox/sensor.py new file mode 100644 index 0000000000000..61ec670d217ef --- /dev/null +++ b/homeassistant/components/freebox/sensor.py @@ -0,0 +1,80 @@ +"""Support for Freebox devices (Freebox v6 and Freebox mini 4K).""" +import logging + +from homeassistant.helpers.entity import Entity + +from . import DATA_FREEBOX + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the sensors.""" + fbx = hass.data[DATA_FREEBOX] + async_add_entities([FbxRXSensor(fbx), FbxTXSensor(fbx)], True) + + +class FbxSensor(Entity): + """Representation of a freebox sensor.""" + + _name = "generic" + _unit = None + _icon = None + + def __init__(self, fbx): + """Initialize the sensor.""" + self._fbx = fbx + self._state = None + self._datas = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def unit_of_measurement(self): + """Return the unit of the sensor.""" + return self._unit + + @property + def icon(self): + """Return the icon of the sensor.""" + return self._icon + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + async def async_update(self): + """Fetch status from freebox.""" + self._datas = await self._fbx.connection.get_status() + + +class FbxRXSensor(FbxSensor): + """Update the Freebox RxSensor.""" + + _name = "Freebox download speed" + _unit = "KB/s" + _icon = "mdi:download-network" + + async def async_update(self): + """Get the value from fetched datas.""" + await super().async_update() + if self._datas is not None: + self._state = round(self._datas["rate_down"] / 1000, 2) + + +class FbxTXSensor(FbxSensor): + """Update the Freebox TxSensor.""" + + _name = "Freebox upload speed" + _unit = "KB/s" + _icon = "mdi:upload-network" + + async def async_update(self): + """Get the value from fetched datas.""" + await super().async_update() + if self._datas is not None: + self._state = round(self._datas["rate_up"] / 1000, 2) diff --git a/homeassistant/components/freebox/services.yaml b/homeassistant/components/freebox/services.yaml new file mode 100644 index 0000000000000..be7afa60562bf --- /dev/null +++ b/homeassistant/components/freebox/services.yaml @@ -0,0 +1,5 @@ +# Freebox service entries description. + +reboot: + # Description of the service + description: Reboots the Freebox. diff --git a/homeassistant/components/freebox/switch.py b/homeassistant/components/freebox/switch.py new file mode 100644 index 0000000000000..b6655c9634f63 --- /dev/null +++ b/homeassistant/components/freebox/switch.py @@ -0,0 +1,62 @@ +"""Support for Freebox Delta, Revolution and Mini 4K.""" +import logging + +from homeassistant.components.switch import SwitchDevice + +from . import DATA_FREEBOX + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the switch.""" + fbx = hass.data[DATA_FREEBOX] + async_add_entities([FbxWifiSwitch(fbx)], True) + + +class FbxWifiSwitch(SwitchDevice): + """Representation of a freebox wifi switch.""" + + def __init__(self, fbx): + """Initilize the Wifi switch.""" + self._name = "Freebox WiFi" + self._state = None + self._fbx = fbx + + @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 _async_set_state(self, enabled): + """Turn the switch on or off.""" + from aiofreepybox.exceptions import InsufficientPermissionsError + + wifi_config = {"enabled": enabled} + try: + await self._fbx.wifi.set_global_config(wifi_config) + except InsufficientPermissionsError: + _LOGGER.warning( + "Home Assistant does not have permissions to" + " modify the Freebox settings. Please refer" + " to documentation." + ) + + async def async_turn_on(self, **kwargs): + """Turn the switch on.""" + await self._async_set_state(True) + + async def async_turn_off(self, **kwargs): + """Turn the switch off.""" + await self._async_set_state(False) + + async def async_update(self): + """Get the state and update it.""" + datas = await self._fbx.wifi.get_global_config() + active = datas["enabled"] + self._state = bool(active) diff --git a/homeassistant/components/freedns/__init__.py b/homeassistant/components/freedns/__init__.py new file mode 100644 index 0000000000000..7aa34c8780e88 --- /dev/null +++ b/homeassistant/components/freedns/__init__.py @@ -0,0 +1,99 @@ +"""Integrate with FreeDNS Dynamic DNS service at freedns.afraid.org.""" +import asyncio +from datetime import timedelta +import logging + +import aiohttp +import async_timeout +import voluptuous as vol + +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_SCAN_INTERVAL, CONF_URL +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "freedns" + +DEFAULT_INTERVAL = timedelta(minutes=10) + +TIMEOUT = 10 +UPDATE_URL = "https://freedns.afraid.org/dynamic/update.php" + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Exclusive(CONF_URL, DOMAIN): cv.string, + vol.Exclusive(CONF_ACCESS_TOKEN, DOMAIN): cv.string, + vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_INTERVAL): vol.All( + cv.time_period, cv.positive_timedelta + ), + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, config): + """Initialize the FreeDNS component.""" + conf = config[DOMAIN] + url = conf.get(CONF_URL) + auth_token = conf.get(CONF_ACCESS_TOKEN) + update_interval = conf[CONF_SCAN_INTERVAL] + + session = hass.helpers.aiohttp_client.async_get_clientsession() + + result = await _update_freedns(hass, session, url, auth_token) + + if result is False: + return False + + async def update_domain_callback(now): + """Update the FreeDNS entry.""" + await _update_freedns(hass, session, url, auth_token) + + hass.helpers.event.async_track_time_interval( + update_domain_callback, update_interval + ) + + return True + + +async def _update_freedns(hass, session, url, auth_token): + """Update FreeDNS.""" + params = None + + if url is None: + url = UPDATE_URL + + if auth_token is not None: + params = {} + params[auth_token] = "" + + try: + with async_timeout.timeout(TIMEOUT): + resp = await session.get(url, params=params) + body = await resp.text() + + if "has not changed" in body: + # IP has not changed. + _LOGGER.debug("FreeDNS update skipped: IP has not changed") + return True + + if "ERROR" not in body: + _LOGGER.debug("Updating FreeDNS was successful: %s", body) + return True + + if "Invalid update URL" in body: + _LOGGER.error("FreeDNS update token is invalid") + else: + _LOGGER.warning("Updating FreeDNS failed: %s", body) + + except aiohttp.ClientError: + _LOGGER.warning("Can't connect to FreeDNS API") + + except asyncio.TimeoutError: + _LOGGER.warning("Timeout from FreeDNS API at %s", url) + + return False diff --git a/homeassistant/components/freedns/manifest.json b/homeassistant/components/freedns/manifest.json new file mode 100644 index 0000000000000..ff4f9ec92029e --- /dev/null +++ b/homeassistant/components/freedns/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "freedns", + "name": "FreeDNS", + "documentation": "https://www.home-assistant.io/integrations/freedns", + "requirements": [], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/fritz/__init__.py b/homeassistant/components/fritz/__init__.py new file mode 100644 index 0000000000000..7069a29f163b2 --- /dev/null +++ b/homeassistant/components/fritz/__init__.py @@ -0,0 +1 @@ +"""The fritz component.""" diff --git a/homeassistant/components/fritz/device_tracker.py b/homeassistant/components/fritz/device_tracker.py new file mode 100644 index 0000000000000..f27e409a28d2b --- /dev/null +++ b/homeassistant/components/fritz/device_tracker.py @@ -0,0 +1,97 @@ +"""Support for FRITZ!Box routers.""" +import logging + +from fritzconnection import FritzHosts # pylint: disable=import-error +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__) + +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 + + # Establish a connection to the FRITZ!Box. + try: + self.fritz_box = 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 get_extra_attributes(self, device): + """Return the attributes (ip, mac) of the given device or None if is not known.""" + ip_device = self.fritz_box.get_specific_host_entry(device).get("NewIPAddress") + + if not ip_device: + return {} + return {"ip": ip_device, "mac": device} + + def _update_info(self): + """Retrieve latest information from the FRITZ!Box.""" + if not self.success_init: + return False + + _LOGGER.debug("Scanning") + self.last_results = self.fritz_box.get_hosts_info() + return True diff --git a/homeassistant/components/fritz/manifest.json b/homeassistant/components/fritz/manifest.json new file mode 100644 index 0000000000000..80709db04376c --- /dev/null +++ b/homeassistant/components/fritz/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "fritz", + "name": "AVM Fritzbox", + "documentation": "https://www.home-assistant.io/integrations/fritz", + "requirements": ["fritzconnection==0.8.4"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/fritzbox/__init__.py b/homeassistant/components/fritzbox/__init__.py new file mode 100644 index 0000000000000..40aa3a881d12d --- /dev/null +++ b/homeassistant/components/fritzbox/__init__.py @@ -0,0 +1,90 @@ +"""Support for AVM Fritz!Box smarthome devices.""" +import logging + +from pyfritzhome import Fritzhome, LoginError +import voluptuous as vol + +from homeassistant.const import ( + CONF_DEVICES, + CONF_HOST, + CONF_PASSWORD, + CONF_USERNAME, + EVENT_HOMEASSISTANT_STOP, +) +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +SUPPORTED_DOMAINS = ["binary_sensor", "climate", "switch", "sensor"] + +DOMAIN = "fritzbox" + +ATTR_STATE_BATTERY_LOW = "battery_low" +ATTR_STATE_DEVICE_LOCKED = "device_locked" +ATTR_STATE_HOLIDAY_MODE = "holiday_mode" +ATTR_STATE_LOCKED = "locked" +ATTR_STATE_SUMMER_MODE = "summer_mode" +ATTR_STATE_WINDOW_OPEN = "window_open" + + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_DEVICES): vol.All( + cv.ensure_list, + [ + vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, + } + ) + ], + ) + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +def setup(hass, config): + """Set up the fritzbox component.""" + + fritz_list = [] + + configured_devices = config[DOMAIN].get(CONF_DEVICES) + for device in configured_devices: + host = device.get(CONF_HOST) + username = device.get(CONF_USERNAME) + password = device.get(CONF_PASSWORD) + fritzbox = Fritzhome(host=host, user=username, password=password) + try: + fritzbox.login() + _LOGGER.info("Connected to device %s", device) + except LoginError: + _LOGGER.warning("Login to Fritz!Box %s as %s failed", host, username) + continue + + fritz_list.append(fritzbox) + + if not fritz_list: + _LOGGER.info("No fritzboxes configured") + return False + + hass.data[DOMAIN] = fritz_list + + def logout_fritzboxes(event): + """Close all connections to the fritzboxes.""" + for fritz in fritz_list: + fritz.logout() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, logout_fritzboxes) + + for domain in SUPPORTED_DOMAINS: + discovery.load_platform(hass, domain, DOMAIN, {}, config) + + return True diff --git a/homeassistant/components/fritzbox/binary_sensor.py b/homeassistant/components/fritzbox/binary_sensor.py new file mode 100644 index 0000000000000..3d8d676d1d061 --- /dev/null +++ b/homeassistant/components/fritzbox/binary_sensor.py @@ -0,0 +1,58 @@ +"""Support for Fritzbox binary sensors.""" +import logging + +import requests + +from homeassistant.components.binary_sensor import BinarySensorDevice + +from . import DOMAIN as FRITZBOX_DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Fritzbox binary sensor 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_alarm: + devices.append(FritzboxBinarySensor(device, fritz)) + + add_entities(devices, True) + + +class FritzboxBinarySensor(BinarySensorDevice): + """Representation of a binary Fritzbox device.""" + + def __init__(self, device, fritz): + """Initialize the Fritzbox binary sensor.""" + self._device = device + self._fritz = fritz + + @property + def name(self): + """Return the name of the entity.""" + return self._device.name + + @property + def device_class(self): + """Return the class of this sensor.""" + return "window" + + @property + def is_on(self): + """Return true if sensor is on.""" + if not self._device.present: + return False + return self._device.alert_state + + def update(self): + """Get latest data from the Fritzbox.""" + try: + self._device.update() + except requests.exceptions.HTTPError as ex: + _LOGGER.warning("Connection error: %s", ex) + self._fritz.login() diff --git a/homeassistant/components/fritzbox/climate.py b/homeassistant/components/fritzbox/climate.py new file mode 100644 index 0000000000000..115f7f8e644fd --- /dev/null +++ b/homeassistant/components/fritzbox/climate.py @@ -0,0 +1,209 @@ +"""Support for AVM Fritz!Box smarthome thermostate devices.""" +import logging + +import requests + +from homeassistant.components.climate import ClimateDevice +from homeassistant.components.climate.const import ( + ATTR_HVAC_MODE, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + PRESET_COMFORT, + PRESET_ECO, + SUPPORT_PRESET_MODE, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.const import ( + ATTR_BATTERY_LEVEL, + ATTR_TEMPERATURE, + PRECISION_HALVES, + TEMP_CELSIUS, +) + +from . import ( + ATTR_STATE_BATTERY_LOW, + ATTR_STATE_DEVICE_LOCKED, + ATTR_STATE_HOLIDAY_MODE, + ATTR_STATE_LOCKED, + ATTR_STATE_SUMMER_MODE, + ATTR_STATE_WINDOW_OPEN, + DOMAIN as FRITZBOX_DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE + +OPERATION_LIST = [HVAC_MODE_HEAT, HVAC_MODE_OFF] + +MIN_TEMPERATURE = 8 +MAX_TEMPERATURE = 28 + +PRESET_MANUAL = "manual" + +# 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 == ON_API_TEMPERATURE: + return ON_REPORT_SET_TEMPERATURE + if self._target_temperature == OFF_API_TEMPERATURE: + return OFF_REPORT_SET_TEMPERATURE + return self._target_temperature + + def set_temperature(self, **kwargs): + """Set new target temperature.""" + if ATTR_HVAC_MODE in kwargs: + hvac_mode = kwargs.get(ATTR_HVAC_MODE) + self.set_hvac_mode(hvac_mode) + elif ATTR_TEMPERATURE in kwargs: + temperature = kwargs.get(ATTR_TEMPERATURE) + self._device.set_target_temperature(temperature) + + @property + def hvac_mode(self): + """Return the current operation mode.""" + if ( + self._target_temperature == OFF_REPORT_SET_TEMPERATURE + or self._target_temperature == OFF_API_TEMPERATURE + ): + return HVAC_MODE_OFF + + return HVAC_MODE_HEAT + + @property + def hvac_modes(self): + """Return the list of available operation modes.""" + return OPERATION_LIST + + def set_hvac_mode(self, hvac_mode): + """Set new operation mode.""" + if hvac_mode == HVAC_MODE_OFF: + self.set_temperature(temperature=OFF_REPORT_SET_TEMPERATURE) + else: + self.set_temperature(temperature=self._comfort_temperature) + + @property + def preset_mode(self): + """Return current preset mode.""" + if self._target_temperature == self._comfort_temperature: + return PRESET_COMFORT + if self._target_temperature == self._eco_temperature: + return PRESET_ECO + + @property + def preset_modes(self): + """Return supported preset modes.""" + return [PRESET_ECO, PRESET_COMFORT] + + def set_preset_mode(self, preset_mode): + """Set preset mode.""" + if preset_mode == PRESET_COMFORT: + self.set_temperature(temperature=self._comfort_temperature) + elif preset_mode == PRESET_ECO: + self.set_temperature(temperature=self._eco_temperature) + + @property + def min_temp(self): + """Return the minimum temperature.""" + return MIN_TEMPERATURE + + @property + def max_temp(self): + """Return the maximum temperature.""" + return MAX_TEMPERATURE + + @property + def device_state_attributes(self): + """Return the device specific state attributes.""" + attrs = { + ATTR_STATE_BATTERY_LOW: self._device.battery_low, + ATTR_STATE_DEVICE_LOCKED: self._device.device_lock, + ATTR_STATE_LOCKED: self._device.lock, + } + + # the following attributes are available since fritzos 7 + if self._device.battery_level is not None: + attrs[ATTR_BATTERY_LEVEL] = self._device.battery_level + if self._device.holiday_active is not None: + attrs[ATTR_STATE_HOLIDAY_MODE] = self._device.holiday_active + if self._device.summer_active is not None: + attrs[ATTR_STATE_SUMMER_MODE] = self._device.summer_active + if ATTR_STATE_WINDOW_OPEN is not None: + attrs[ATTR_STATE_WINDOW_OPEN] = self._device.window_open + + 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/fritzbox/manifest.json b/homeassistant/components/fritzbox/manifest.json new file mode 100644 index 0000000000000..494e70e8bccb2 --- /dev/null +++ b/homeassistant/components/fritzbox/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "fritzbox", + "name": "AVM FRITZ!Box", + "documentation": "https://www.home-assistant.io/integrations/fritzbox", + "requirements": ["pyfritzhome==0.4.0"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py new file mode 100644 index 0000000000000..4454ea35bbe81 --- /dev/null +++ b/homeassistant/components/fritzbox/sensor.py @@ -0,0 +1,71 @@ +"""Support for AVM Fritz!Box smarthome temperature sensor only devices.""" +import logging + +import requests + +from homeassistant.const import TEMP_CELSIUS +from homeassistant.helpers.entity import Entity + +from . import ATTR_STATE_DEVICE_LOCKED, ATTR_STATE_LOCKED, DOMAIN as FRITZBOX_DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Fritzbox smarthome sensor platform.""" + _LOGGER.debug("Initializing fritzbox temperature sensors") + 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_temperature_sensor + and not device.has_switch + and not device.has_thermostat + ): + devices.append(FritzBoxTempSensor(device, fritz)) + + add_entities(devices) + + +class FritzBoxTempSensor(Entity): + """The entity class for Fritzbox temperature sensors.""" + + def __init__(self, device, fritz): + """Initialize the switch.""" + self._device = device + self._fritz = fritz + + @property + def name(self): + """Return the name of the device.""" + return self._device.name + + @property + def state(self): + """Return the state of the sensor.""" + return self._device.temperature + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS + + def update(self): + """Get latest data and states from the device.""" + try: + self._device.update() + except requests.exceptions.HTTPError as ex: + _LOGGER.warning("Fritzhome connection error: %s", ex) + self._fritz.login() + + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + attrs = { + ATTR_STATE_DEVICE_LOCKED: self._device.device_lock, + ATTR_STATE_LOCKED: self._device.lock, + } + return attrs diff --git a/homeassistant/components/fritzbox/switch.py b/homeassistant/components/fritzbox/switch.py new file mode 100644 index 0000000000000..c51c952ab06ab --- /dev/null +++ b/homeassistant/components/fritzbox/switch.py @@ -0,0 +1,97 @@ +"""Support for AVM Fritz!Box smarthome switch devices.""" +import logging + +import requests + +from homeassistant.components.switch import SwitchDevice +from homeassistant.const import ATTR_TEMPERATURE, ENERGY_KILO_WATT_HOUR, TEMP_CELSIUS + +from . import ATTR_STATE_DEVICE_LOCKED, ATTR_STATE_LOCKED, DOMAIN as FRITZBOX_DOMAIN + +_LOGGER = logging.getLogger(__name__) + +ATTR_TOTAL_CONSUMPTION = "total_consumption" +ATTR_TOTAL_CONSUMPTION_UNIT = "total_consumption_unit" +ATTR_TOTAL_CONSUMPTION_UNIT_VALUE = ENERGY_KILO_WATT_HOUR + +ATTR_TEMPERATURE_UNIT = "temperature_unit" + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Fritzbox smarthome switch platform.""" + devices = [] + fritz_list = hass.data[FRITZBOX_DOMAIN] + + for fritz in fritz_list: + device_list = fritz.get_devices() + for device in device_list: + if device.has_switch: + devices.append(FritzboxSwitch(device, fritz)) + + add_entities(devices) + + +class FritzboxSwitch(SwitchDevice): + """The switch class for Fritzbox switches.""" + + def __init__(self, device, fritz): + """Initialize the switch.""" + self._device = device + self._fritz = fritz + + @property + def available(self): + """Return if switch is available.""" + return self._device.present + + @property + def name(self): + """Return the name of the device.""" + return self._device.name + + @property + def is_on(self): + """Return true if the switch is on.""" + return self._device.switch_state + + def turn_on(self, **kwargs): + """Turn the switch on.""" + self._device.set_switch_state_on() + + def turn_off(self, **kwargs): + """Turn the switch off.""" + self._device.set_switch_state_off() + + def update(self): + """Get latest data and states from the device.""" + try: + self._device.update() + except requests.exceptions.HTTPError as ex: + _LOGGER.warning("Fritzhome connection error: %s", ex) + self._fritz.login() + + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + attrs = {} + attrs[ATTR_STATE_DEVICE_LOCKED] = self._device.device_lock + attrs[ATTR_STATE_LOCKED] = self._device.lock + + if self._device.has_powermeter: + attrs[ATTR_TOTAL_CONSUMPTION] = "{:.3f}".format( + (self._device.energy or 0.0) / 1000 + ) + attrs[ATTR_TOTAL_CONSUMPTION_UNIT] = ATTR_TOTAL_CONSUMPTION_UNIT_VALUE + if self._device.has_temperature_sensor: + attrs[ATTR_TEMPERATURE] = str( + self.hass.config.units.temperature( + self._device.temperature, TEMP_CELSIUS + ) + ) + attrs[ATTR_TEMPERATURE_UNIT] = self.hass.config.units.temperature_unit + return attrs + + @property + def current_power_w(self): + """Return the current power usage in W.""" + return self._device.power / 1000 diff --git a/homeassistant/components/fritzbox_callmonitor/__init__.py b/homeassistant/components/fritzbox_callmonitor/__init__.py new file mode 100644 index 0000000000000..f9a520216067d --- /dev/null +++ b/homeassistant/components/fritzbox_callmonitor/__init__.py @@ -0,0 +1 @@ +"""The fritzbox_callmonitor component.""" diff --git a/homeassistant/components/fritzbox_callmonitor/manifest.json b/homeassistant/components/fritzbox_callmonitor/manifest.json new file mode 100644 index 0000000000000..f05bcec846a57 --- /dev/null +++ b/homeassistant/components/fritzbox_callmonitor/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "fritzbox_callmonitor", + "name": "AVM FRITZ!Box Call Monitor", + "documentation": "https://www.home-assistant.io/integrations/fritzbox_callmonitor", + "requirements": ["fritzconnection==0.8.4"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/fritzbox_callmonitor/sensor.py b/homeassistant/components/fritzbox_callmonitor/sensor.py new file mode 100644 index 0000000000000..600420db8591c --- /dev/null +++ b/homeassistant/components/fritzbox_callmonitor/sensor.py @@ -0,0 +1,298 @@ +"""Sensor to monitor incoming/outgoing phone calls on a Fritz!Box router.""" +import datetime +import logging +import re +import socket +import threading +import time + +import fritzconnection as fc # pylint: disable=import-error +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + EVENT_HOMEASSISTANT_STOP, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +_LOGGER = logging.getLogger(__name__) + +CONF_PHONEBOOK = "phonebook" +CONF_PREFIXES = "prefixes" + +DEFAULT_HOST = "169.254.1.1" # IP valid for all Fritz!Box routers +DEFAULT_NAME = "Phone" +DEFAULT_PORT = 1012 + +INTERVAL_RECONNECT = 60 + +VALUE_CALL = "dialing" +VALUE_CONNECT = "talking" +VALUE_DEFAULT = "idle" +VALUE_DISCONNECT = "idle" +VALUE_RING = "ringing" + +# Return cached results if phonebook was downloaded less then this time ago. +MIN_TIME_PHONEBOOK_UPDATE = datetime.timedelta(hours=6) +SCAN_INTERVAL = datetime.timedelta(hours=3) + +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, + vol.Optional(CONF_PASSWORD, default="admin"): cv.string, + vol.Optional(CONF_USERNAME, default=""): cv.string, + vol.Optional(CONF_PHONEBOOK, default=0): cv.positive_int, + vol.Optional(CONF_PREFIXES, default=[]): vol.All(cv.ensure_list, [cv.string]), + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up Fritz!Box call monitor sensor platform.""" + name = config.get(CONF_NAME) + host = config.get(CONF_HOST) + # Try to resolve a hostname; if it is already an IP, it will be returned as-is + try: + host = socket.gethostbyname(host) + except socket.error: + _LOGGER.error("Could not resolve hostname %s", host) + return + port = config.get(CONF_PORT) + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + phonebook_id = config.get(CONF_PHONEBOOK) + prefixes = config.get(CONF_PREFIXES) + + try: + phonebook = FritzBoxPhonebook( + host=host, + port=port, + username=username, + password=password, + phonebook_id=phonebook_id, + prefixes=prefixes, + ) + except: # noqa: E722 pylint: disable=bare-except + phonebook = None + _LOGGER.warning("Phonebook with ID %s not found on Fritz!Box", phonebook_id) + + sensor = FritzBoxCallSensor(name=name, phonebook=phonebook) + + add_entities([sensor]) + + monitor = FritzBoxCallMonitor(host=host, port=port, sensor=sensor) + monitor.connect() + + def _stop_listener(_event): + monitor.stopped.set() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _stop_listener) + + return monitor.sock is not None + + +class FritzBoxCallSensor(Entity): + """Implementation of a Fritz!Box call monitor.""" + + def __init__(self, name, phonebook): + """Initialize the sensor.""" + self._state = VALUE_DEFAULT + self._attributes = {} + self._name = name + self.phonebook = phonebook + + def set_state(self, state): + """Set the state.""" + self._state = state + + def set_attributes(self, attributes): + """Set the state attributes.""" + self._attributes = attributes + + @property + def should_poll(self): + """Only poll to update phonebook, if defined.""" + return self.phonebook is not None + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._attributes + + def number_to_name(self, number): + """Return a name for a given phone number.""" + if self.phonebook is None: + return "unknown" + return self.phonebook.get_name(number) + + def update(self): + """Update the phonebook if it is defined.""" + if self.phonebook is not None: + self.phonebook.update_phonebook() + + +class FritzBoxCallMonitor: + """Event listener to monitor calls on the Fritz!Box.""" + + def __init__(self, host, port, sensor): + """Initialize Fritz!Box monitor instance.""" + self.host = host + self.port = port + self.sock = None + self._sensor = sensor + self.stopped = threading.Event() + + def connect(self): + """Connect to the Fritz!Box.""" + _LOGGER.debug("Setting up socket...") + self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.sock.settimeout(10) + self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) + try: + self.sock.connect((self.host, self.port)) + threading.Thread(target=self._listen).start() + except socket.error as err: + self.sock = None + _LOGGER.error( + "Cannot connect to %s on port %s: %s", self.host, self.port, err + ) + + def _listen(self): + """Listen to incoming or outgoing calls.""" + _LOGGER.debug("Connection established, waiting for response...") + while not self.stopped.isSet(): + try: + response = self.sock.recv(2048) + except socket.timeout: + # if no response after 10 seconds, just recv again + continue + response = str(response, "utf-8") + _LOGGER.debug("Received %s", response) + + if not response: + # if the response is empty, the connection has been lost. + # try to reconnect + _LOGGER.warning("Connection lost, reconnecting...") + self.sock = None + while self.sock is None: + self.connect() + time.sleep(INTERVAL_RECONNECT) + else: + line = response.split("\n", 1)[0] + self._parse(line) + time.sleep(1) + + def _parse(self, line): + """Parse the call information and set the sensor states.""" + line = line.split(";") + df_in = "%d.%m.%y %H:%M:%S" + df_out = "%Y-%m-%dT%H:%M:%S" + isotime = datetime.datetime.strptime(line[0], df_in).strftime(df_out) + if line[1] == "RING": + self._sensor.set_state(VALUE_RING) + att = { + "type": "incoming", + "from": line[3], + "to": line[4], + "device": line[5], + "initiated": isotime, + } + att["from_name"] = self._sensor.number_to_name(att["from"]) + self._sensor.set_attributes(att) + elif line[1] == "CALL": + self._sensor.set_state(VALUE_CALL) + att = { + "type": "outgoing", + "from": line[4], + "to": line[5], + "device": line[6], + "initiated": isotime, + } + att["to_name"] = self._sensor.number_to_name(att["to"]) + self._sensor.set_attributes(att) + elif line[1] == "CONNECT": + self._sensor.set_state(VALUE_CONNECT) + att = {"with": line[4], "device": line[3], "accepted": isotime} + att["with_name"] = self._sensor.number_to_name(att["with"]) + self._sensor.set_attributes(att) + elif line[1] == "DISCONNECT": + self._sensor.set_state(VALUE_DISCONNECT) + att = {"duration": line[3], "closed": isotime} + self._sensor.set_attributes(att) + self._sensor.schedule_update_ha_state() + + +class FritzBoxPhonebook: + """This connects to a FritzBox router and downloads its phone book.""" + + def __init__(self, host, port, username, password, phonebook_id=0, prefixes=None): + """Initialize the class.""" + self.host = host + self.username = username + self.password = password + self.port = port + self.phonebook_id = phonebook_id + self.phonebook_dict = None + self.number_dict = None + self.prefixes = prefixes or [] + + # Establish a connection to the FRITZ!Box. + self.fph = fc.FritzPhonebook( + address=self.host, user=self.username, password=self.password + ) + + if self.phonebook_id not in self.fph.list_phonebooks: + raise ValueError("Phonebook with this ID not found.") + + self.update_phonebook() + + @Throttle(MIN_TIME_PHONEBOOK_UPDATE) + def update_phonebook(self): + """Update the phone book dictionary.""" + self.phonebook_dict = self.fph.get_all_names(self.phonebook_id) + self.number_dict = { + re.sub(r"[^\d\+]", "", nr): name + for name, nrs in self.phonebook_dict.items() + for nr in nrs + } + _LOGGER.info("Fritz!Box phone book successfully updated") + + def get_name(self, number): + """Return a name for a given phone number.""" + number = re.sub(r"[^\d\+]", "", str(number)) + if self.number_dict is None: + return "unknown" + try: + return self.number_dict[number] + except KeyError: + pass + if self.prefixes: + for prefix in self.prefixes: + try: + return self.number_dict[prefix + number] + except KeyError: + pass + try: + return self.number_dict[prefix + number.lstrip("0")] + except KeyError: + pass + return "unknown" diff --git a/homeassistant/components/fritzbox_netmonitor/__init__.py b/homeassistant/components/fritzbox_netmonitor/__init__.py new file mode 100644 index 0000000000000..8bea1da4a44bc --- /dev/null +++ b/homeassistant/components/fritzbox_netmonitor/__init__.py @@ -0,0 +1 @@ +"""The fritzbox_netmonitor component.""" diff --git a/homeassistant/components/fritzbox_netmonitor/manifest.json b/homeassistant/components/fritzbox_netmonitor/manifest.json new file mode 100644 index 0000000000000..4dbb978842c46 --- /dev/null +++ b/homeassistant/components/fritzbox_netmonitor/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "fritzbox_netmonitor", + "name": "AVM FRITZ!Box Net Monitor", + "documentation": "https://www.home-assistant.io/integrations/fritzbox_netmonitor", + "requirements": ["fritzconnection==0.8.4"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/fritzbox_netmonitor/sensor.py b/homeassistant/components/fritzbox_netmonitor/sensor.py new file mode 100644 index 0000000000000..0a82c5e29c32d --- /dev/null +++ b/homeassistant/components/fritzbox_netmonitor/sensor.py @@ -0,0 +1,138 @@ +"""Support for monitoring an AVM Fritz!Box router.""" +from datetime import timedelta +import logging + +from fritzconnection import FritzStatus # pylint: disable=import-error +from fritzconnection.fritzconnection import ( # pylint: disable=import-error + FritzConnectionException, +) +from requests.exceptions import RequestException +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_HOST, CONF_NAME, STATE_UNAVAILABLE +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +_LOGGER = logging.getLogger(__name__) + +CONF_DEFAULT_NAME = "fritz_netmonitor" +CONF_DEFAULT_IP = "169.254.1.1" # This IP is valid for all FRITZ!Box routers. + +ATTR_BYTES_RECEIVED = "bytes_received" +ATTR_BYTES_SENT = "bytes_sent" +ATTR_TRANSMISSION_RATE_UP = "transmission_rate_up" +ATTR_TRANSMISSION_RATE_DOWN = "transmission_rate_down" +ATTR_EXTERNAL_IP = "external_ip" +ATTR_IS_CONNECTED = "is_connected" +ATTR_IS_LINKED = "is_linked" +ATTR_MAX_BYTE_RATE_DOWN = "max_byte_rate_down" +ATTR_MAX_BYTE_RATE_UP = "max_byte_rate_up" +ATTR_UPTIME = "uptime" +ATTR_WAN_ACCESS_TYPE = "wan_access_type" + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=5) + +STATE_ONLINE = "online" +STATE_OFFLINE = "offline" + +ICON = "mdi:web" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_NAME, default=CONF_DEFAULT_NAME): cv.string, + vol.Optional(CONF_HOST, default=CONF_DEFAULT_IP): cv.string, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the FRITZ!Box monitor sensors.""" + name = config.get(CONF_NAME) + host = config.get(CONF_HOST) + + try: + fstatus = FritzStatus(address=host) + except (ValueError, TypeError, FritzConnectionException): + fstatus = None + + if fstatus is None: + _LOGGER.error("Failed to establish connection to FRITZ!Box: %s", host) + return 1 + _LOGGER.info("Successfully connected to FRITZ!Box") + + add_entities([FritzboxMonitorSensor(name, fstatus)], True) + + +class FritzboxMonitorSensor(Entity): + """Implementation of a fritzbox monitor sensor.""" + + def __init__(self, name, fstatus): + """Initialize the sensor.""" + self._name = name + self._fstatus = fstatus + self._state = STATE_UNAVAILABLE + self._is_linked = self._is_connected = self._wan_access_type = None + self._external_ip = self._uptime = None + self._bytes_sent = self._bytes_received = None + self._transmission_rate_up = None + self._transmission_rate_down = None + self._max_byte_rate_up = self._max_byte_rate_down = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name.rstrip() + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return ICON + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @property + def state_attributes(self): + """Return the device state attributes.""" + # Don't return attributes if FritzBox is unreachable + if self._state == STATE_UNAVAILABLE: + return {} + attr = { + ATTR_IS_LINKED: self._is_linked, + ATTR_IS_CONNECTED: self._is_connected, + ATTR_WAN_ACCESS_TYPE: self._wan_access_type, + ATTR_EXTERNAL_IP: self._external_ip, + ATTR_UPTIME: self._uptime, + ATTR_BYTES_SENT: self._bytes_sent, + ATTR_BYTES_RECEIVED: self._bytes_received, + ATTR_TRANSMISSION_RATE_UP: self._transmission_rate_up, + ATTR_TRANSMISSION_RATE_DOWN: self._transmission_rate_down, + ATTR_MAX_BYTE_RATE_UP: self._max_byte_rate_up, + ATTR_MAX_BYTE_RATE_DOWN: self._max_byte_rate_down, + } + return attr + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Retrieve information from the FritzBox.""" + try: + self._is_linked = self._fstatus.is_linked + self._is_connected = self._fstatus.is_connected + self._wan_access_type = self._fstatus.wan_access_type + self._external_ip = self._fstatus.external_ip + self._uptime = self._fstatus.uptime + self._bytes_sent = self._fstatus.bytes_sent + self._bytes_received = self._fstatus.bytes_received + transmission_rate = self._fstatus.transmission_rate + self._transmission_rate_up = transmission_rate[0] + self._transmission_rate_down = transmission_rate[1] + self._max_byte_rate_up = self._fstatus.max_byte_rate[0] + self._max_byte_rate_down = self._fstatus.max_byte_rate[1] + self._state = STATE_ONLINE if self._is_connected else STATE_OFFLINE + except RequestException as err: + self._state = STATE_UNAVAILABLE + _LOGGER.warning("Could not reach FRITZ!Box: %s", err) diff --git a/homeassistant/components/fritzdect/__init__.py b/homeassistant/components/fritzdect/__init__.py new file mode 100644 index 0000000000000..d64990bc3f0c6 --- /dev/null +++ b/homeassistant/components/fritzdect/__init__.py @@ -0,0 +1 @@ +"""The fritzdect component.""" diff --git a/homeassistant/components/fritzdect/manifest.json b/homeassistant/components/fritzdect/manifest.json new file mode 100644 index 0000000000000..9fc9129360876 --- /dev/null +++ b/homeassistant/components/fritzdect/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "fritzdect", + "name": "AVM FRITZ!DECT", + "documentation": "https://www.home-assistant.io/integrations/fritzdect", + "requirements": ["fritzhome==1.0.4"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/fritzdect/switch.py b/homeassistant/components/fritzdect/switch.py new file mode 100644 index 0000000000000..f67c84ae5525c --- /dev/null +++ b/homeassistant/components/fritzdect/switch.py @@ -0,0 +1,224 @@ +"""Support for FRITZ!DECT Switches.""" +import logging + +from fritzhome.fritz import FritzBox +from requests.exceptions import HTTPError, RequestException +import voluptuous as vol + +from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice +from homeassistant.const import ( + ATTR_TEMPERATURE, + CONF_HOST, + CONF_PASSWORD, + CONF_USERNAME, + ENERGY_KILO_WATT_HOUR, + POWER_WATT, + TEMP_CELSIUS, +) +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +# Standard Fritz Box IP +DEFAULT_HOST = "fritz.box" + +ATTR_CURRENT_CONSUMPTION = "current_consumption" +ATTR_CURRENT_CONSUMPTION_UNIT = "current_consumption_unit" +ATTR_CURRENT_CONSUMPTION_UNIT_VALUE = POWER_WATT + +ATTR_TOTAL_CONSUMPTION = "total_consumption" +ATTR_TOTAL_CONSUMPTION_UNIT = "total_consumption_unit" +ATTR_TOTAL_CONSUMPTION_UNIT_VALUE = ENERGY_KILO_WATT_HOUR + +ATTR_TEMPERATURE_UNIT = "temperature_unit" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_HOST, default=DEFAULT_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): + """Add all switches connected to Fritz Box.""" + + host = config.get(CONF_HOST) + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + + # Log into Fritz Box + fritz = FritzBox(host, username, password) + try: + fritz.login() + except Exception: # pylint: disable=broad-except + _LOGGER.error("Login to Fritz!Box failed") + return + + # Add all actors to hass + for actor in fritz.get_actors(): + # Only add devices that support switching + if actor.has_switch: + data = FritzDectSwitchData(fritz, actor.actor_id) + data.is_online = True + add_entities([FritzDectSwitch(hass, data, actor.name)], True) + + +class FritzDectSwitch(SwitchDevice): + """Representation of a FRITZ!DECT switch.""" + + def __init__(self, hass, data, name): + """Initialize the switch.""" + self.units = hass.config.units + self.data = data + self._name = name + + @property + def name(self): + """Return the name of the FRITZ!DECT switch, if any.""" + return self._name + + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + attrs = {} + + if ( + self.data.has_powermeter + and self.data.current_consumption is not None + and self.data.total_consumption is not None + ): + attrs[ATTR_CURRENT_CONSUMPTION] = "{:.1f}".format( + self.data.current_consumption + ) + attrs[ATTR_CURRENT_CONSUMPTION_UNIT] = "{}".format( + ATTR_CURRENT_CONSUMPTION_UNIT_VALUE + ) + attrs[ATTR_TOTAL_CONSUMPTION] = f"{self.data.total_consumption:.3f}" + attrs[ATTR_TOTAL_CONSUMPTION_UNIT] = "{}".format( + ATTR_TOTAL_CONSUMPTION_UNIT_VALUE + ) + + if self.data.has_temperature and self.data.temperature is not None: + attrs[ATTR_TEMPERATURE] = "{}".format( + self.units.temperature(self.data.temperature, TEMP_CELSIUS) + ) + attrs[ATTR_TEMPERATURE_UNIT] = f"{self.units.temperature_unit}" + return attrs + + @property + def current_power_w(self): + """Return the current power usage in Watt.""" + try: + return float(self.data.current_consumption) + except ValueError: + return None + + @property + def is_on(self): + """Return true if switch is on.""" + return self.data.state + + def turn_on(self, **kwargs): + """Turn the switch on.""" + if not self.data.is_online: + _LOGGER.error("turn_on: Not online skipping request") + return + + try: + actor = self.data.fritz.get_actor_by_ain(self.data.ain) + actor.switch_on() + except (RequestException, HTTPError): + _LOGGER.error("Fritz!Box query failed, triggering relogin") + self.data.is_online = False + + def turn_off(self, **kwargs): + """Turn the switch off.""" + if not self.data.is_online: + _LOGGER.error("turn_off: Not online skipping request") + return + + try: + actor = self.data.fritz.get_actor_by_ain(self.data.ain) + actor.switch_off() + except (RequestException, HTTPError): + _LOGGER.error("Fritz!Box query failed, triggering relogin") + self.data.is_online = False + + def update(self): + """Get the latest data from the fritz box and updates the states.""" + if not self.data.is_online: + _LOGGER.error("update: Not online, logging back in") + + try: + self.data.fritz.login() + except Exception: # pylint: disable=broad-except + _LOGGER.error("Login to Fritz!Box failed") + return + + self.data.is_online = True + + try: + self.data.update() + except Exception: # pylint: disable=broad-except + _LOGGER.error("Fritz!Box query failed, triggering relogin") + self.data.is_online = False + + +class FritzDectSwitchData: + """Get the latest data from the fritz box.""" + + def __init__(self, fritz, ain): + """Initialize the data object.""" + self.fritz = fritz + self.ain = ain + self.state = None + self.temperature = None + self.current_consumption = None + self.total_consumption = None + self.has_switch = False + self.has_temperature = False + self.has_powermeter = False + self.is_online = False + + def update(self): + """Get the latest data from the fritz box.""" + if not self.is_online: + _LOGGER.error("Not online skipping request") + return + + try: + actor = self.fritz.get_actor_by_ain(self.ain) + except (RequestException, HTTPError): + _LOGGER.error("Request to actor registry failed") + self.state = None + self.temperature = None + self.current_consumption = None + self.total_consumption = None + raise Exception("Request to actor registry failed") + + if actor is None: + _LOGGER.error("Actor could not be found") + self.state = None + self.temperature = None + self.current_consumption = None + self.total_consumption = None + raise Exception("Actor could not be found") + + try: + self.state = actor.get_state() + self.current_consumption = (actor.get_power() or 0.0) / 1000 + self.total_consumption = (actor.get_energy() or 0.0) / 1000 + except (RequestException, HTTPError): + _LOGGER.error("Request to actor failed") + self.state = None + self.temperature = None + self.current_consumption = None + self.total_consumption = None + raise Exception("Request to actor failed") + + self.temperature = actor.temperature + self.has_switch = actor.has_switch + self.has_temperature = actor.has_temperature + self.has_powermeter = actor.has_powermeter diff --git a/homeassistant/components/fronius/__init__.py b/homeassistant/components/fronius/__init__.py new file mode 100644 index 0000000000000..2b4d968fecaa9 --- /dev/null +++ b/homeassistant/components/fronius/__init__.py @@ -0,0 +1 @@ +"""The Fronius component.""" diff --git a/homeassistant/components/fronius/manifest.json b/homeassistant/components/fronius/manifest.json new file mode 100644 index 0000000000000..c7e919c95e563 --- /dev/null +++ b/homeassistant/components/fronius/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "fronius", + "name": "Fronius", + "documentation": "https://www.home-assistant.io/integrations/fronius", + "requirements": ["pyfronius==0.4.6"], + "dependencies": [], + "codeowners": ["@nielstron"] +} diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py new file mode 100644 index 0000000000000..27e2531c9f911 --- /dev/null +++ b/homeassistant/components/fronius/sensor.py @@ -0,0 +1,292 @@ +"""Support for Fronius devices.""" +import copy +from datetime import timedelta +import logging + +from pyfronius import Fronius +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_DEVICE, + CONF_MONITORED_CONDITIONS, + CONF_RESOURCE, + CONF_SCAN_INTERVAL, + CONF_SENSOR_TYPE, +) +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_track_time_interval + +_LOGGER = logging.getLogger(__name__) + +CONF_SCOPE = "scope" + +TYPE_INVERTER = "inverter" +TYPE_STORAGE = "storage" +TYPE_METER = "meter" +TYPE_POWER_FLOW = "power_flow" +SCOPE_DEVICE = "device" +SCOPE_SYSTEM = "system" + +DEFAULT_SCOPE = SCOPE_DEVICE +DEFAULT_DEVICE = 0 +DEFAULT_INVERTER = 1 +DEFAULT_SCAN_INTERVAL = timedelta(seconds=60) + +SENSOR_TYPES = [TYPE_INVERTER, TYPE_STORAGE, TYPE_METER, TYPE_POWER_FLOW] +SCOPE_TYPES = [SCOPE_DEVICE, SCOPE_SYSTEM] + + +def _device_id_validator(config): + """Ensure that inverters have default id 1 and other devices 0.""" + config = copy.deepcopy(config) + for cond in config[CONF_MONITORED_CONDITIONS]: + if CONF_DEVICE not in cond: + if cond[CONF_SENSOR_TYPE] == TYPE_INVERTER: + cond[CONF_DEVICE] = DEFAULT_INVERTER + else: + cond[CONF_DEVICE] = DEFAULT_DEVICE + return config + + +PLATFORM_SCHEMA = vol.Schema( + vol.All( + PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_RESOURCE): cv.url, + vol.Required(CONF_MONITORED_CONDITIONS): vol.All( + cv.ensure_list, + [ + { + vol.Required(CONF_SENSOR_TYPE): vol.In(SENSOR_TYPES), + vol.Optional(CONF_SCOPE, default=DEFAULT_SCOPE): vol.In( + SCOPE_TYPES + ), + vol.Optional(CONF_DEVICE): vol.All( + vol.Coerce(int), vol.Range(min=0) + ), + } + ], + ), + } + ), + _device_id_validator, + ) +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up of Fronius platform.""" + session = async_get_clientsession(hass) + fronius = Fronius(session, config[CONF_RESOURCE]) + + scan_interval = config.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + adapters = [] + # Creates all adapters for monitored conditions + for condition in config[CONF_MONITORED_CONDITIONS]: + + device = condition[CONF_DEVICE] + sensor_type = condition[CONF_SENSOR_TYPE] + scope = condition[CONF_SCOPE] + name = "Fronius {} {} {}".format( + condition[CONF_SENSOR_TYPE].replace("_", " ").capitalize(), + device if scope == SCOPE_DEVICE else SCOPE_SYSTEM, + config[CONF_RESOURCE], + ) + if sensor_type == TYPE_INVERTER: + if scope == SCOPE_SYSTEM: + adapter_cls = FroniusInverterSystem + else: + adapter_cls = FroniusInverterDevice + elif sensor_type == TYPE_METER: + if scope == SCOPE_SYSTEM: + adapter_cls = FroniusMeterSystem + else: + adapter_cls = FroniusMeterDevice + elif sensor_type == TYPE_POWER_FLOW: + adapter_cls = FroniusPowerFlow + else: + adapter_cls = FroniusStorage + + adapters.append(adapter_cls(fronius, name, device, async_add_entities)) + + # Creates a lamdba that fetches an update when called + def adapter_data_fetcher(data_adapter): + async def fetch_data(*_): + await data_adapter.async_update() + + return fetch_data + + # Set up the fetching in a fixed interval for each adapter + for adapter in adapters: + fetch = adapter_data_fetcher(adapter) + # fetch data once at set-up + await fetch() + async_track_time_interval(hass, fetch, scan_interval) + + +class FroniusAdapter: + """The Fronius sensor fetching component.""" + + def __init__(self, bridge, name, device, add_entities): + """Initialize the sensor.""" + self.bridge = bridge + self._name = name + self._device = device + self._fetched = {} + + self.sensors = set() + self._registered_sensors = set() + self._add_entities = add_entities + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def data(self): + """Return the state attributes.""" + return self._fetched + + async def async_update(self): + """Retrieve and update latest state.""" + values = {} + try: + values = await self._update() + except ConnectionError: + _LOGGER.error("Failed to update: connection error") + except ValueError: + _LOGGER.error( + "Failed to update: invalid response returned." + "Maybe the configured device is not supported" + ) + + if not values: + return + attributes = self._fetched + # Copy data of current fronius device + for key, entry in values.items(): + # If the data is directly a sensor + if "value" in entry: + attributes[key] = entry + self._fetched = attributes + + # Add discovered value fields as sensors + # because some fields are only sent temporarily + new_sensors = [] + for key in attributes: + if key not in self.sensors: + self.sensors.add(key) + _LOGGER.info("Discovered %s, adding as sensor", key) + new_sensors.append(FroniusTemplateSensor(self, key)) + self._add_entities(new_sensors, True) + + # Schedule an update for all included sensors + for sensor in self._registered_sensors: + sensor.async_schedule_update_ha_state(True) + + async def _update(self): + """Return values of interest.""" + pass + + async def register(self, sensor): + """Register child sensor for update subscriptions.""" + self._registered_sensors.add(sensor) + + +class FroniusInverterSystem(FroniusAdapter): + """Adapter for the fronius inverter with system scope.""" + + async def _update(self): + """Get the values for the current state.""" + return await self.bridge.current_system_inverter_data() + + +class FroniusInverterDevice(FroniusAdapter): + """Adapter for the fronius inverter with device scope.""" + + async def _update(self): + """Get the values for the current state.""" + return await self.bridge.current_inverter_data(self._device) + + +class FroniusStorage(FroniusAdapter): + """Adapter for the fronius battery storage.""" + + async def _update(self): + """Get the values for the current state.""" + return await self.bridge.current_storage_data(self._device) + + +class FroniusMeterSystem(FroniusAdapter): + """Adapter for the fronius meter with system scope.""" + + async def _update(self): + """Get the values for the current state.""" + return await self.bridge.current_system_meter_data() + + +class FroniusMeterDevice(FroniusAdapter): + """Adapter for the fronius meter with device scope.""" + + async def _update(self): + """Get the values for the current state.""" + return await self.bridge.current_meter_data(self._device) + + +class FroniusPowerFlow(FroniusAdapter): + """Adapter for the fronius power flow.""" + + async def _update(self): + """Get the values for the current state.""" + return await self.bridge.current_power_flow() + + +class FroniusTemplateSensor(Entity): + """Sensor for the single values (e.g. pv power, ac power).""" + + def __init__(self, parent: FroniusAdapter, name): + """Initialize a singular value sensor.""" + self._name = name + self.parent = parent + self._state = None + self._unit = None + + @property + def name(self): + """Return the name of the sensor.""" + return "{} {}".format( + self._name.replace("_", " ").capitalize(), self.parent.name + ) + + @property + def state(self): + """Return the current state.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit + + @property + def should_poll(self): + """Device should not be polled, returns False.""" + return False + + async def async_update(self): + """Update the internal state.""" + state = self.parent.data.get(self._name) + self._state = state.get("value") + self._unit = state.get("unit") + + async def async_added_to_hass(self): + """Register at parent component for updates.""" + await self.parent.register(self) + + def __hash__(self): + """Hash sensor by hashing its name.""" + return hash(self.name) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 8ff722e41b2f2..efb1c34653b3b 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -1,77 +1,541 @@ -""" -homeassistant.components.frontend -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Provides a frontend for Home Assistant. -""" -import re -import os +"""Handle the frontend for Home Assistant.""" +import json import logging +import mimetypes +import os +import pathlib +from typing import Any, Dict, Optional, Set, Tuple + +from aiohttp import hdrs, web, web_urldispatcher +import jinja2 +import voluptuous as vol +from yarl import URL + +from homeassistant.components import websocket_api +from homeassistant.components.http.view import HomeAssistantView +from homeassistant.config import find_config_file, load_yaml_config_file +from homeassistant.const import CONF_NAME, EVENT_THEMES_UPDATED +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.translation import async_get_translations +from homeassistant.loader import bind_hass + +from .storage import async_setup_frontend_storage -from . import version -import homeassistant.util as util -from homeassistant.const import URL_ROOT, HTTP_OK +# mypy: allow-untyped-defs, no-check-untyped-defs -DOMAIN = 'frontend' -DEPENDENCIES = ['api'] +# Fix mimetypes for borked Windows machines +# https://github.com/home-assistant/home-assistant-polymer/issues/3336 +mimetypes.add_type("text/css", ".css") +mimetypes.add_type("application/javascript", ".js") -INDEX_PATH = os.path.join(os.path.dirname(__file__), 'index.html.template') + +DOMAIN = "frontend" +CONF_THEMES = "themes" +CONF_EXTRA_HTML_URL = "extra_html_url" +CONF_EXTRA_HTML_URL_ES5 = "extra_html_url_es5" +CONF_EXTRA_MODULE_URL = "extra_module_url" +CONF_EXTRA_JS_URL_ES5 = "extra_js_url_es5" +CONF_FRONTEND_REPO = "development_repo" +CONF_JS_VERSION = "javascript_version" +EVENT_PANELS_UPDATED = "panels_updated" + +DEFAULT_THEME_COLOR = "#03A9F4" + +MANIFEST_JSON = { + "background_color": "#FFFFFF", + "description": "Home automation platform that puts local control and privacy first.", + "dir": "ltr", + "display": "standalone", + "icons": [ + { + "src": "/static/icons/favicon-{size}x{size}.png".format(size=size), + "sizes": "{size}x{size}".format(size=size), + "type": "image/png", + "purpose": "maskable any", + } + for size in (192, 384, 512, 1024) + ], + "lang": "en-US", + "name": "Home Assistant", + "short_name": "Assistant", + "start_url": "/?homescreen=1", + "theme_color": DEFAULT_THEME_COLOR, +} + +DATA_PANELS = "frontend_panels" +DATA_JS_VERSION = "frontend_js_version" +DATA_EXTRA_HTML_URL = "frontend_extra_html_url" +DATA_EXTRA_HTML_URL_ES5 = "frontend_extra_html_url_es5" +DATA_EXTRA_MODULE_URL = "frontend_extra_module_url" +DATA_EXTRA_JS_URL_ES5 = "frontend_extra_js_url_es5" +DATA_THEMES = "frontend_themes" +DATA_DEFAULT_THEME = "frontend_default_theme" +DEFAULT_THEME = "default" + +PRIMARY_COLOR = "primary-color" _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Optional(CONF_FRONTEND_REPO): cv.isdir, + vol.Optional(CONF_THEMES): vol.Schema( + {cv.string: {cv.string: cv.string}} + ), + vol.Optional(CONF_EXTRA_HTML_URL): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_EXTRA_MODULE_URL): vol.All( + cv.ensure_list, [cv.string] + ), + vol.Optional(CONF_EXTRA_JS_URL_ES5): vol.All( + cv.ensure_list, [cv.string] + ), + # We no longer use these options. + vol.Optional(CONF_EXTRA_HTML_URL_ES5): cv.match_all, + vol.Optional(CONF_JS_VERSION): cv.match_all, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + +SERVICE_SET_THEME = "set_theme" +SERVICE_RELOAD_THEMES = "reload_themes" +SERVICE_SET_THEME_SCHEMA = vol.Schema({vol.Required(CONF_NAME): cv.string}) +WS_TYPE_GET_PANELS = "get_panels" +SCHEMA_GET_PANELS = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( + {vol.Required("type"): WS_TYPE_GET_PANELS} +) +WS_TYPE_GET_THEMES = "frontend/get_themes" +SCHEMA_GET_THEMES = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( + {vol.Required("type"): WS_TYPE_GET_THEMES} +) +WS_TYPE_GET_TRANSLATIONS = "frontend/get_translations" +SCHEMA_GET_TRANSLATIONS = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( + {vol.Required("type"): WS_TYPE_GET_TRANSLATIONS, vol.Required("language"): str} +) + + +class Panel: + """Abstract class for panels.""" + + # Name of the webcomponent + component_name: Optional[str] = None + + # Icon to show in the sidebar + sidebar_icon: Optional[str] = None + + # Title to show in the sidebar + sidebar_title: Optional[str] = None + + # Url to show the panel in the frontend + frontend_url_path: Optional[str] = None + + # Config to pass to the webcomponent + config: Optional[Dict[str, Any]] = None + + # If the panel should only be visible to admins + require_admin = False + + def __init__( + self, + component_name, + sidebar_title, + sidebar_icon, + frontend_url_path, + config, + require_admin, + ): + """Initialize a built-in panel.""" + self.component_name = component_name + self.sidebar_title = sidebar_title + self.sidebar_icon = sidebar_icon + self.frontend_url_path = frontend_url_path or component_name + self.config = config + self.require_admin = require_admin + + @callback + def to_response(self): + """Panel as dictionary.""" + return { + "component_name": self.component_name, + "icon": self.sidebar_icon, + "title": self.sidebar_title, + "config": self.config, + "url_path": self.frontend_url_path, + "require_admin": self.require_admin, + } + + +@bind_hass +@callback +def async_register_built_in_panel( + hass, + component_name, + sidebar_title=None, + sidebar_icon=None, + frontend_url_path=None, + config=None, + require_admin=False, +): + """Register a built-in panel.""" + panel = Panel( + component_name, + sidebar_title, + sidebar_icon, + frontend_url_path, + config, + require_admin, + ) + + panels = hass.data.setdefault(DATA_PANELS, {}) + + if panel.frontend_url_path in panels: + _LOGGER.warning("Overwriting integration %s", panel.frontend_url_path) + + panels[panel.frontend_url_path] = panel + + hass.bus.async_fire(EVENT_PANELS_UPDATED) + + +@bind_hass +@callback +def async_remove_panel(hass, frontend_url_path): + """Remove a built-in panel.""" + panel = hass.data.get(DATA_PANELS, {}).pop(frontend_url_path, None) + + if panel is None: + _LOGGER.warning("Removing unknown panel %s", frontend_url_path) + + hass.bus.async_fire(EVENT_PANELS_UPDATED) + + +@bind_hass +@callback +def add_extra_html_url(hass, url, es5=False): + """Register extra html url to load.""" + key = DATA_EXTRA_HTML_URL_ES5 if es5 else DATA_EXTRA_HTML_URL + url_set = hass.data.get(key) + if url_set is None: + url_set = hass.data[key] = set() + url_set.add(url) + + +def add_extra_js_url(hass, url, es5=False): + """Register extra js or module url to load.""" + key = DATA_EXTRA_JS_URL_ES5 if es5 else DATA_EXTRA_MODULE_URL + url_set = hass.data.get(key) + if url_set is None: + url_set = hass.data[key] = set() + url_set.add(url) + + +def add_manifest_json_key(key, val): + """Add a keyval to the manifest.json.""" + MANIFEST_JSON[key] = val + + +def _frontend_root(dev_repo_path): + """Return root path to the frontend files.""" + if dev_repo_path is not None: + return pathlib.Path(dev_repo_path) / "hass_frontend" + # Keep import here so that we can import frontend without installing reqs + # pylint: disable=import-outside-toplevel + import hass_frontend + + return hass_frontend.where() + + +async def async_setup(hass, config): + """Set up the serving of the frontend.""" + await async_setup_frontend_storage(hass) + hass.components.websocket_api.async_register_command( + WS_TYPE_GET_PANELS, websocket_get_panels, SCHEMA_GET_PANELS + ) + hass.components.websocket_api.async_register_command( + WS_TYPE_GET_THEMES, websocket_get_themes, SCHEMA_GET_THEMES + ) + hass.components.websocket_api.async_register_command( + WS_TYPE_GET_TRANSLATIONS, websocket_get_translations, SCHEMA_GET_TRANSLATIONS + ) + hass.http.register_view(ManifestJSONView) + + conf = config.get(DOMAIN, {}) + + repo_path = conf.get(CONF_FRONTEND_REPO) + is_dev = repo_path is not None + root_path = _frontend_root(repo_path) -def setup(hass, config): - """ Setup serving the frontend. """ - if 'http' not in hass.config.components: - _LOGGER.error('Dependency http is not loaded') - return False + for path, should_cache in ( + ("service_worker.js", False), + ("robots.txt", False), + ("onboarding.html", True), + ("static", True), + ("frontend_latest", True), + ("frontend_es5", True), + ): + hass.http.register_static_path(f"/{path}", str(root_path / path), should_cache) - hass.http.register_path('GET', URL_ROOT, _handle_get_root, False) + hass.http.register_static_path( + "/auth/authorize", str(root_path / "authorize.html"), False + ) - # Static files - hass.http.register_path( - 'GET', re.compile(r'/static/(?P[a-zA-Z\._\-0-9/]+)'), - _handle_get_static, False) - hass.http.register_path( - 'HEAD', re.compile(r'/static/(?P[a-zA-Z\._\-0-9/]+)'), - _handle_get_static, False) + local = hass.config.path("www") + if os.path.isdir(local): + hass.http.register_static_path("/local", local, not is_dev) + + hass.http.app.router.register_resource(IndexView(repo_path, hass)) + + for panel in ("kiosk", "states", "profile"): + async_register_built_in_panel(hass, panel) + + # To smooth transition to new urls, add redirects to new urls of dev tools + # Added June 27, 2019. Can be removed in 2021. + for panel in ("event", "info", "service", "state", "template", "mqtt"): + hass.http.register_redirect(f"/dev-{panel}", f"/developer-tools/{panel}") + + async_register_built_in_panel( + hass, + "developer-tools", + require_admin=True, + sidebar_title="developer_tools", + sidebar_icon="hass:hammer", + ) + + if DATA_EXTRA_HTML_URL not in hass.data: + hass.data[DATA_EXTRA_HTML_URL] = set() + + for url in conf.get(CONF_EXTRA_HTML_URL, []): + add_extra_html_url(hass, url, False) + + if DATA_EXTRA_MODULE_URL not in hass.data: + hass.data[DATA_EXTRA_MODULE_URL] = set() + + for url in conf.get(CONF_EXTRA_MODULE_URL, []): + add_extra_js_url(hass, url) + + if DATA_EXTRA_JS_URL_ES5 not in hass.data: + hass.data[DATA_EXTRA_JS_URL_ES5] = set() + + for url in conf.get(CONF_EXTRA_JS_URL_ES5, []): + add_extra_js_url(hass, url, True) + + _async_setup_themes(hass, conf.get(CONF_THEMES)) return True -def _handle_get_root(handler, path_match, data): - """ Renders the debug interface. """ +@callback +def _async_setup_themes(hass, themes): + """Set up themes data and services.""" + hass.data[DATA_DEFAULT_THEME] = DEFAULT_THEME + if themes is None: + hass.data[DATA_THEMES] = {} + return + + hass.data[DATA_THEMES] = themes + + @callback + def update_theme_and_fire_event(): + """Update theme_color in manifest.""" + name = hass.data[DATA_DEFAULT_THEME] + themes = hass.data[DATA_THEMES] + if name != DEFAULT_THEME and PRIMARY_COLOR in themes[name]: + MANIFEST_JSON["theme_color"] = themes[name][PRIMARY_COLOR] + else: + MANIFEST_JSON["theme_color"] = DEFAULT_THEME_COLOR + hass.bus.async_fire( + EVENT_THEMES_UPDATED, {"themes": themes, "default_theme": name} + ) + + @callback + def set_theme(call): + """Set backend-preferred theme.""" + data = call.data + name = data[CONF_NAME] + if name == DEFAULT_THEME or name in hass.data[DATA_THEMES]: + _LOGGER.info("Theme %s set as default", name) + hass.data[DATA_DEFAULT_THEME] = name + update_theme_and_fire_event() + else: + _LOGGER.warning("Theme %s is not defined.", name) + + @callback + def reload_themes(_): + """Reload themes.""" + path = find_config_file(hass.config.config_dir) + new_themes = load_yaml_config_file(path)[DOMAIN].get(CONF_THEMES, {}) + hass.data[DATA_THEMES] = new_themes + if hass.data[DATA_DEFAULT_THEME] not in new_themes: + hass.data[DATA_DEFAULT_THEME] = DEFAULT_THEME + update_theme_and_fire_event() + + hass.services.async_register( + DOMAIN, SERVICE_SET_THEME, set_theme, schema=SERVICE_SET_THEME_SCHEMA + ) + hass.services.async_register(DOMAIN, SERVICE_RELOAD_THEMES, reload_themes) + + +class IndexView(web_urldispatcher.AbstractResource): + """Serve the frontend.""" + + def __init__(self, repo_path, hass): + """Initialize the frontend view.""" + super().__init__(name="frontend:index") + self.repo_path = repo_path + self.hass = hass + self._template_cache = None + + @property + def canonical(self) -> str: + """Return resource's canonical path.""" + return "/" + + @property + def _route(self): + """Return the index route.""" + return web_urldispatcher.ResourceRoute("GET", self.get, self) + + def url_for(self, **kwargs: str) -> URL: + """Construct url for resource with additional params.""" + return URL("/") + + async def resolve( + self, request: web.Request + ) -> Tuple[Optional[web_urldispatcher.UrlMappingMatchInfo], Set[str]]: + """Resolve resource. + + Return (UrlMappingMatchInfo, allowed_methods) pair. + """ + if ( + request.path != "/" + and request.url.parts[1] not in self.hass.data[DATA_PANELS] + ): + return None, set() + + if request.method != hdrs.METH_GET: + return None, {"GET"} + + return web_urldispatcher.UrlMappingMatchInfo({}, self._route), {"GET"} + + def add_prefix(self, prefix: str) -> None: + """Add a prefix to processed URLs. + + Required for subapplications support. + """ + + def get_info(self): + """Return a dict with additional info useful for introspection.""" + return {"panels": list(self.hass.data[DATA_PANELS])} + + def freeze(self) -> None: + """Freeze the resource.""" + pass + + def raw_match(self, path: str) -> bool: + """Perform a raw match against path.""" + pass + + def get_template(self): + """Get template.""" + tpl = self._template_cache + if tpl is None: + with open(str(_frontend_root(self.repo_path) / "index.html")) as file: + tpl = jinja2.Template(file.read()) + + # Cache template if not running from repository + if self.repo_path is None: + self._template_cache = tpl + + return tpl + + async def get(self, request: web.Request) -> web.Response: + """Serve the index page for panel pages.""" + hass = request.app["hass"] + + if not hass.components.onboarding.async_is_onboarded(): + return web.Response(status=302, headers={"location": "/onboarding.html"}) + + template = self._template_cache + + if template is None: + template = await hass.async_add_executor_job(self.get_template) + + return web.Response( + text=template.render( + theme_color=MANIFEST_JSON["theme_color"], + extra_urls=hass.data[DATA_EXTRA_HTML_URL], + extra_modules=hass.data[DATA_EXTRA_MODULE_URL], + extra_js_es5=hass.data[DATA_EXTRA_JS_URL_ES5], + ), + content_type="text/html", + ) + + def __len__(self) -> int: + """Return length of resource.""" + return 1 + + def __iter__(self): + """Iterate over routes.""" + return iter([self._route]) + + +class ManifestJSONView(HomeAssistantView): + """View to return a manifest.json.""" + + requires_auth = False + url = "/manifest.json" + name = "manifestjson" - handler.send_response(HTTP_OK) - handler.send_header('Content-type', 'text/html; charset=utf-8') - handler.end_headers() + @callback + def get(self, request): # pylint: disable=no-self-use + """Return the manifest.json.""" + msg = json.dumps(MANIFEST_JSON, sort_keys=True) + return web.Response(text=msg, content_type="application/manifest+json") - if handler.server.development: - app_url = "polymer/home-assistant.html" - else: - app_url = "frontend-{}.html".format(version.VERSION) - # auto login if no password was set, else check api_password param - auth = ('no_password_set' if handler.server.no_password_set - else data.get('api_password', '')) +@callback +def websocket_get_panels(hass, connection, msg): + """Handle get panels command. - with open(INDEX_PATH) as template_file: - template_html = template_file.read() + Async friendly. + """ + user_is_admin = connection.user.is_admin + panels = { + panel_key: panel.to_response() + for panel_key, panel in connection.hass.data[DATA_PANELS].items() + if user_is_admin or not panel.require_admin + } - template_html = template_html.replace('{{ app_url }}', app_url) - template_html = template_html.replace('{{ auth }}', auth) + connection.send_message(websocket_api.result_message(msg["id"], panels)) - handler.wfile.write(template_html.encode("UTF-8")) +@callback +def websocket_get_themes(hass, connection, msg): + """Handle get themes command. -def _handle_get_static(handler, path_match, data): - """ Returns a static file for the frontend. """ - req_file = util.sanitize_path(path_match.group('file')) + Async friendly. + """ + connection.send_message( + websocket_api.result_message( + msg["id"], + { + "themes": hass.data[DATA_THEMES], + "default_theme": hass.data[DATA_DEFAULT_THEME], + }, + ) + ) - # Strip md5 hash out of frontend filename - if re.match(r'^frontend-[A-Za-z0-9]{32}\.html$', req_file): - req_file = "frontend.html" - path = os.path.join(os.path.dirname(__file__), 'www_static', req_file) +@websocket_api.async_response +async def websocket_get_translations(hass, connection, msg): + """Handle get translations command. - handler.write_file(path) + Async friendly. + """ + resources = await async_get_translations(hass, msg["language"]) + connection.send_message( + websocket_api.result_message(msg["id"], {"resources": resources}) + ) diff --git a/homeassistant/components/frontend/index.html.template b/homeassistant/components/frontend/index.html.template deleted file mode 100644 index c4da4f0369dca..0000000000000 --- a/homeassistant/components/frontend/index.html.template +++ /dev/null @@ -1,28 +0,0 @@ - - - - - Home Assistant - - - - - - - - - - - - - - -

Initializing Home Assistant

- - - - - diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json new file mode 100644 index 0000000000000..f73dfa14becf6 --- /dev/null +++ b/homeassistant/components/frontend/manifest.json @@ -0,0 +1,21 @@ +{ + "domain": "frontend", + "name": "Home Assistant Frontend", + "documentation": "https://www.home-assistant.io/integrations/frontend", + "requirements": [ + "home-assistant-frontend==20200107.0" + ], + "dependencies": [ + "api", + "auth", + "http", + "lovelace", + "onboarding", + "system_log", + "websocket_api" + ], + "codeowners": [ + "@home-assistant/frontend" + ], + "quality_scale": "internal" +} \ No newline at end of file diff --git a/homeassistant/components/frontend/services.yaml b/homeassistant/components/frontend/services.yaml new file mode 100644 index 0000000000000..dc1fb40be4841 --- /dev/null +++ b/homeassistant/components/frontend/services.yaml @@ -0,0 +1,11 @@ +# Describes the format for available frontend services + +set_theme: + description: Set a theme unless the client selected per-device theme. + fields: + name: + description: Name of a predefined theme or 'default'. + example: 'light' + +reload_themes: + description: Reload themes from yaml configuration. diff --git a/homeassistant/components/frontend/storage.py b/homeassistant/components/frontend/storage.py new file mode 100644 index 0000000000000..2f68c5f8e017c --- /dev/null +++ b/homeassistant/components/frontend/storage.py @@ -0,0 +1,79 @@ +"""API for persistent storage for the frontend.""" +from functools import wraps + +import voluptuous as vol + +from homeassistant.components import websocket_api + +# mypy: allow-untyped-calls, allow-untyped-defs + +DATA_STORAGE = "frontend_storage" +STORAGE_VERSION_USER_DATA = 1 +STORAGE_KEY_USER_DATA = "frontend.user_data_{}" + + +async def async_setup_frontend_storage(hass): + """Set up frontend storage.""" + hass.data[DATA_STORAGE] = ({}, {}) + hass.components.websocket_api.async_register_command(websocket_set_user_data) + hass.components.websocket_api.async_register_command(websocket_get_user_data) + + +def with_store(orig_func): + """Decorate function to provide data.""" + + @wraps(orig_func) + async def with_store_func(hass, connection, msg): + """Provide user specific data and store to function.""" + stores, data = hass.data[DATA_STORAGE] + user_id = connection.user.id + store = stores.get(user_id) + + if store is None: + store = stores[user_id] = hass.helpers.storage.Store( + STORAGE_VERSION_USER_DATA, + STORAGE_KEY_USER_DATA.format(connection.user.id), + ) + + if user_id not in data: + data[user_id] = await store.async_load() or {} + + await orig_func(hass, connection, msg, store, data[user_id]) + + return with_store_func + + +@websocket_api.websocket_command( + { + vol.Required("type"): "frontend/set_user_data", + vol.Required("key"): str, + vol.Required("value"): vol.Any(bool, str, int, float, dict, list, None), + } +) +@websocket_api.async_response +@with_store +async def websocket_set_user_data(hass, connection, msg, store, data): + """Handle set global data command. + + Async friendly. + """ + data[msg["key"]] = msg["value"] + await store.async_save(data) + connection.send_message(websocket_api.result_message(msg["id"])) + + +@websocket_api.websocket_command( + {vol.Required("type"): "frontend/get_user_data", vol.Optional("key"): str} +) +@websocket_api.async_response +@with_store +async def websocket_get_user_data(hass, connection, msg, store, data): + """Handle get global data command. + + Async friendly. + """ + connection.send_message( + websocket_api.result_message( + msg["id"], {"value": data.get(msg["key"]) if "key" in msg else data} + ) + ) diff --git a/homeassistant/components/frontend/version.py b/homeassistant/components/frontend/version.py deleted file mode 100644 index f580952cfeb4b..0000000000000 --- a/homeassistant/components/frontend/version.py +++ /dev/null @@ -1,2 +0,0 @@ -""" DO NOT MODIFY. Auto-generated by build_frontend script """ -VERSION = "a063d1482fd49e9297d64e1329324f1c" diff --git a/homeassistant/components/frontend/www_static/favicon-192x192.png b/homeassistant/components/frontend/www_static/favicon-192x192.png deleted file mode 100644 index 2959efdf89d84..0000000000000 Binary files a/homeassistant/components/frontend/www_static/favicon-192x192.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/frontend.html b/homeassistant/components/frontend/www_static/frontend.html deleted file mode 100644 index aaf95520da66d..0000000000000 --- a/homeassistant/components/frontend/www_static/frontend.html +++ /dev/null @@ -1,228 +0,0 @@ - - - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/images/config_philips_hue.jpg b/homeassistant/components/frontend/www_static/images/config_philips_hue.jpg deleted file mode 100644 index f10d258bf34f8..0000000000000 Binary files a/homeassistant/components/frontend/www_static/images/config_philips_hue.jpg and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/manifest.json b/homeassistant/components/frontend/www_static/manifest.json deleted file mode 100644 index 69143ce517988..0000000000000 --- a/homeassistant/components/frontend/www_static/manifest.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "Home Assistant", - "short_name": "Assistant", - "start_url": "/", - "display": "standalone", - "icons": [ - { - "src": "\/static\/favicon-192x192.png", - "sizes": "192x192", - "type": "image\/png", - "density": "4.0" - } - ] -} diff --git a/homeassistant/components/frontend/www_static/polymer/bower.json b/homeassistant/components/frontend/www_static/polymer/bower.json deleted file mode 100644 index 77c951048e5d8..0000000000000 --- a/homeassistant/components/frontend/www_static/polymer/bower.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "name": "Home Assistant", - "version": "0.1.0", - "authors": [ - "Paulus Schoutsen " - ], - "main": "splash-login.html", - "license": "MIT", - "private": true, - "ignore": [ - "bower_components" - ], - "dependencies": { - "webcomponentsjs": "Polymer/webcomponentsjs#~0.5.5", - "font-roboto": "Polymer/font-roboto#~0.5.5", - "core-header-panel": "polymer/core-header-panel#~0.5.5", - "core-toolbar": "polymer/core-toolbar#~0.5.5", - "core-tooltip": "Polymer/core-tooltip#~0.5.5", - "core-menu": "polymer/core-menu#~0.5.5", - "core-item": "Polymer/core-item#~0.5.5", - "core-input": "Polymer/core-input#~0.5.5", - "core-icons": "polymer/core-icons#~0.5.5", - "core-image": "polymer/core-image#~0.5.5", - "core-style": "polymer/core-style#~0.5.5", - "core-label": "polymer/core-label#~0.5.5", - "paper-toast": "Polymer/paper-toast#~0.5.5", - "paper-dialog": "Polymer/paper-dialog#~0.5.5", - "paper-spinner": "Polymer/paper-spinner#~0.5.5", - "paper-button": "Polymer/paper-button#~0.5.5", - "paper-input": "Polymer/paper-input#~0.5.5", - "paper-toggle-button": "polymer/paper-toggle-button#~0.5.5", - "paper-icon-button": "polymer/paper-icon-button#~0.5.5", - "paper-menu-button": "polymer/paper-menu-button#~0.5.5", - "paper-dropdown": "polymer/paper-dropdown#~0.5.5", - "paper-item": "polymer/paper-item#~0.5.5", - "paper-slider": "polymer/paper-slider#~0.5.5", - "paper-checkbox": "polymer/paper-checkbox#~0.5.5", - "color-picker-element": "~0.0.2", - "google-apis": "GoogleWebComponents/google-apis#~0.4.2", - "core-drawer-panel": "polymer/core-drawer-panel#~0.5.5", - "core-scroll-header-panel": "polymer/core-scroll-header-panel#~0.5.5", - "moment": "~2.9.0" - } -} diff --git a/homeassistant/components/frontend/www_static/polymer/cards/state-card-configurator.html b/homeassistant/components/frontend/www_static/polymer/cards/state-card-configurator.html deleted file mode 100644 index 195a9cb110995..0000000000000 --- a/homeassistant/components/frontend/www_static/polymer/cards/state-card-configurator.html +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/cards/state-card-content.html b/homeassistant/components/frontend/www_static/polymer/cards/state-card-content.html deleted file mode 100644 index b3b7a3b636364..0000000000000 --- a/homeassistant/components/frontend/www_static/polymer/cards/state-card-content.html +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/cards/state-card-display.html b/homeassistant/components/frontend/www_static/polymer/cards/state-card-display.html deleted file mode 100755 index cbafabac21af5..0000000000000 --- a/homeassistant/components/frontend/www_static/polymer/cards/state-card-display.html +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/cards/state-card-scene.html b/homeassistant/components/frontend/www_static/polymer/cards/state-card-scene.html deleted file mode 100644 index 4f2a1403e0994..0000000000000 --- a/homeassistant/components/frontend/www_static/polymer/cards/state-card-scene.html +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/cards/state-card-thermostat.html b/homeassistant/components/frontend/www_static/polymer/cards/state-card-thermostat.html deleted file mode 100644 index 63c6708ce67cc..0000000000000 --- a/homeassistant/components/frontend/www_static/polymer/cards/state-card-thermostat.html +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/cards/state-card-toggle.html b/homeassistant/components/frontend/www_static/polymer/cards/state-card-toggle.html deleted file mode 100755 index fc4eb895e2300..0000000000000 --- a/homeassistant/components/frontend/www_static/polymer/cards/state-card-toggle.html +++ /dev/null @@ -1,81 +0,0 @@ - - - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/cards/state-card.html b/homeassistant/components/frontend/www_static/polymer/cards/state-card.html deleted file mode 100644 index 3305f755a33d4..0000000000000 --- a/homeassistant/components/frontend/www_static/polymer/cards/state-card.html +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/components/domain-icon.html b/homeassistant/components/frontend/www_static/polymer/components/domain-icon.html deleted file mode 100644 index ba2074ab7cad8..0000000000000 --- a/homeassistant/components/frontend/www_static/polymer/components/domain-icon.html +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/components/entity-list.html b/homeassistant/components/frontend/www_static/polymer/components/entity-list.html deleted file mode 100644 index e54e03f409a3d..0000000000000 --- a/homeassistant/components/frontend/www_static/polymer/components/entity-list.html +++ /dev/null @@ -1,60 +0,0 @@ - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/components/events-list.html b/homeassistant/components/frontend/www_static/polymer/components/events-list.html deleted file mode 100644 index ddf89dab379f2..0000000000000 --- a/homeassistant/components/frontend/www_static/polymer/components/events-list.html +++ /dev/null @@ -1,62 +0,0 @@ - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/components/ha-modals.html b/homeassistant/components/frontend/www_static/polymer/components/ha-modals.html deleted file mode 100644 index ef0090142d82c..0000000000000 --- a/homeassistant/components/frontend/www_static/polymer/components/ha-modals.html +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/components/ha-notifications.html b/homeassistant/components/frontend/www_static/polymer/components/ha-notifications.html deleted file mode 100644 index 3072da99e9a22..0000000000000 --- a/homeassistant/components/frontend/www_static/polymer/components/ha-notifications.html +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/components/loading-box.html b/homeassistant/components/frontend/www_static/polymer/components/loading-box.html deleted file mode 100644 index 5049ec2005485..0000000000000 --- a/homeassistant/components/frontend/www_static/polymer/components/loading-box.html +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/components/recent-states.html b/homeassistant/components/frontend/www_static/polymer/components/recent-states.html deleted file mode 100644 index 408c0448836dc..0000000000000 --- a/homeassistant/components/frontend/www_static/polymer/components/recent-states.html +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/components/relative-ha-datetime.html b/homeassistant/components/frontend/www_static/polymer/components/relative-ha-datetime.html deleted file mode 100644 index 910622c5bc758..0000000000000 --- a/homeassistant/components/frontend/www_static/polymer/components/relative-ha-datetime.html +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/components/services-list.html b/homeassistant/components/frontend/www_static/polymer/components/services-list.html deleted file mode 100644 index 809c8079246c0..0000000000000 --- a/homeassistant/components/frontend/www_static/polymer/components/services-list.html +++ /dev/null @@ -1,90 +0,0 @@ - - - - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/components/state-badge.html b/homeassistant/components/frontend/www_static/polymer/components/state-badge.html deleted file mode 100644 index ad362e6f048bf..0000000000000 --- a/homeassistant/components/frontend/www_static/polymer/components/state-badge.html +++ /dev/null @@ -1,105 +0,0 @@ - - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/components/state-cards.html b/homeassistant/components/frontend/www_static/polymer/components/state-cards.html deleted file mode 100755 index 913fc117ccb38..0000000000000 --- a/homeassistant/components/frontend/www_static/polymer/components/state-cards.html +++ /dev/null @@ -1,56 +0,0 @@ - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/components/state-info.html b/homeassistant/components/frontend/www_static/polymer/components/state-info.html deleted file mode 100755 index 05c9ff643bfba..0000000000000 --- a/homeassistant/components/frontend/www_static/polymer/components/state-info.html +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/components/state-timeline.html b/homeassistant/components/frontend/www_static/polymer/components/state-timeline.html deleted file mode 100644 index 9ded10dd3ae9f..0000000000000 --- a/homeassistant/components/frontend/www_static/polymer/components/state-timeline.html +++ /dev/null @@ -1,110 +0,0 @@ - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/components/stream-status.html b/homeassistant/components/frontend/www_static/polymer/components/stream-status.html deleted file mode 100644 index d31efc61d792b..0000000000000 --- a/homeassistant/components/frontend/www_static/polymer/components/stream-status.html +++ /dev/null @@ -1,58 +0,0 @@ - - - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/dialogs/ha-dialog.html b/homeassistant/components/frontend/www_static/polymer/dialogs/ha-dialog.html deleted file mode 100644 index 2cf8de644f063..0000000000000 --- a/homeassistant/components/frontend/www_static/polymer/dialogs/ha-dialog.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/dialogs/more-info-dialog.html b/homeassistant/components/frontend/www_static/polymer/dialogs/more-info-dialog.html deleted file mode 100644 index 98c88b7db3550..0000000000000 --- a/homeassistant/components/frontend/www_static/polymer/dialogs/more-info-dialog.html +++ /dev/null @@ -1,116 +0,0 @@ - - - - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/home-assistant-js b/homeassistant/components/frontend/www_static/polymer/home-assistant-js deleted file mode 160000 index e048bf6ece919..0000000000000 --- a/homeassistant/components/frontend/www_static/polymer/home-assistant-js +++ /dev/null @@ -1 +0,0 @@ -Subproject commit e048bf6ece91983b9f03aafeb414ae5c535288a2 diff --git a/homeassistant/components/frontend/www_static/polymer/home-assistant.html b/homeassistant/components/frontend/www_static/polymer/home-assistant.html deleted file mode 100644 index 808fcea8f878b..0000000000000 --- a/homeassistant/components/frontend/www_static/polymer/home-assistant.html +++ /dev/null @@ -1,65 +0,0 @@ - - - - - - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/layouts/home-assistant-main.html b/homeassistant/components/frontend/www_static/polymer/layouts/home-assistant-main.html deleted file mode 100644 index ade9c9d166bf6..0000000000000 --- a/homeassistant/components/frontend/www_static/polymer/layouts/home-assistant-main.html +++ /dev/null @@ -1,246 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/layouts/login-form.html b/homeassistant/components/frontend/www_static/polymer/layouts/login-form.html deleted file mode 100644 index 7b0628fabfc87..0000000000000 --- a/homeassistant/components/frontend/www_static/polymer/layouts/login-form.html +++ /dev/null @@ -1,147 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/layouts/partial-base.html b/homeassistant/components/frontend/www_static/polymer/layouts/partial-base.html deleted file mode 100644 index b105723974c8b..0000000000000 --- a/homeassistant/components/frontend/www_static/polymer/layouts/partial-base.html +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/polymer/layouts/partial-dev-call-service.html b/homeassistant/components/frontend/www_static/polymer/layouts/partial-dev-call-service.html deleted file mode 100644 index 78e1b414d0909..0000000000000 --- a/homeassistant/components/frontend/www_static/polymer/layouts/partial-dev-call-service.html +++ /dev/null @@ -1,88 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/layouts/partial-dev-fire-event.html b/homeassistant/components/frontend/www_static/polymer/layouts/partial-dev-fire-event.html deleted file mode 100644 index ef4af2cc1fbb3..0000000000000 --- a/homeassistant/components/frontend/www_static/polymer/layouts/partial-dev-fire-event.html +++ /dev/null @@ -1,80 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/layouts/partial-dev-set-state.html b/homeassistant/components/frontend/www_static/polymer/layouts/partial-dev-set-state.html deleted file mode 100644 index dd3032ac5b28f..0000000000000 --- a/homeassistant/components/frontend/www_static/polymer/layouts/partial-dev-set-state.html +++ /dev/null @@ -1,106 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/layouts/partial-history.html b/homeassistant/components/frontend/www_static/polymer/layouts/partial-history.html deleted file mode 100644 index c1576e2f59e27..0000000000000 --- a/homeassistant/components/frontend/www_static/polymer/layouts/partial-history.html +++ /dev/null @@ -1,61 +0,0 @@ - - - - - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/layouts/partial-states.html b/homeassistant/components/frontend/www_static/polymer/layouts/partial-states.html deleted file mode 100644 index 11c29c05a55e4..0000000000000 --- a/homeassistant/components/frontend/www_static/polymer/layouts/partial-states.html +++ /dev/null @@ -1,164 +0,0 @@ - - - - - - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/more-infos/more-info-configurator.html b/homeassistant/components/frontend/www_static/polymer/more-infos/more-info-configurator.html deleted file mode 100644 index 256ee3d5eb692..0000000000000 --- a/homeassistant/components/frontend/www_static/polymer/more-infos/more-info-configurator.html +++ /dev/null @@ -1,112 +0,0 @@ - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/more-infos/more-info-content.html b/homeassistant/components/frontend/www_static/polymer/more-infos/more-info-content.html deleted file mode 100644 index b80a016686b87..0000000000000 --- a/homeassistant/components/frontend/www_static/polymer/more-infos/more-info-content.html +++ /dev/null @@ -1,73 +0,0 @@ - - - - - - - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/more-infos/more-info-default.html b/homeassistant/components/frontend/www_static/polymer/more-infos/more-info-default.html deleted file mode 100644 index 454ac813eaddc..0000000000000 --- a/homeassistant/components/frontend/www_static/polymer/more-infos/more-info-default.html +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/more-infos/more-info-group.html b/homeassistant/components/frontend/www_static/polymer/more-infos/more-info-group.html deleted file mode 100644 index 6355ea6da8ad7..0000000000000 --- a/homeassistant/components/frontend/www_static/polymer/more-infos/more-info-group.html +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/more-infos/more-info-light.html b/homeassistant/components/frontend/www_static/polymer/more-infos/more-info-light.html deleted file mode 100644 index d0b2de56acc63..0000000000000 --- a/homeassistant/components/frontend/www_static/polymer/more-infos/more-info-light.html +++ /dev/null @@ -1,106 +0,0 @@ - - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/more-infos/more-info-script.html b/homeassistant/components/frontend/www_static/polymer/more-infos/more-info-script.html deleted file mode 100644 index d1e75702fbbe5..0000000000000 --- a/homeassistant/components/frontend/www_static/polymer/more-infos/more-info-script.html +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/more-infos/more-info-sun.html b/homeassistant/components/frontend/www_static/polymer/more-infos/more-info-sun.html deleted file mode 100644 index 96c92357d1be2..0000000000000 --- a/homeassistant/components/frontend/www_static/polymer/more-infos/more-info-sun.html +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/more-infos/more-info-thermostat.html b/homeassistant/components/frontend/www_static/polymer/more-infos/more-info-thermostat.html deleted file mode 100644 index 356d49a85c3ff..0000000000000 --- a/homeassistant/components/frontend/www_static/polymer/more-infos/more-info-thermostat.html +++ /dev/null @@ -1,114 +0,0 @@ - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/resources/home-assistant-icons.html b/homeassistant/components/frontend/www_static/polymer/resources/home-assistant-icons.html deleted file mode 100644 index 2d8b6d6e5364b..0000000000000 --- a/homeassistant/components/frontend/www_static/polymer/resources/home-assistant-icons.html +++ /dev/null @@ -1,94 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/polymer/resources/home-assistant-js.html b/homeassistant/components/frontend/www_static/polymer/resources/home-assistant-js.html deleted file mode 100644 index 8d7cedcd7781a..0000000000000 --- a/homeassistant/components/frontend/www_static/polymer/resources/home-assistant-js.html +++ /dev/null @@ -1,83 +0,0 @@ - - - diff --git a/homeassistant/components/frontend/www_static/polymer/resources/home-assistant-style.html b/homeassistant/components/frontend/www_static/polymer/resources/home-assistant-style.html deleted file mode 100644 index 1704901529486..0000000000000 --- a/homeassistant/components/frontend/www_static/polymer/resources/home-assistant-style.html +++ /dev/null @@ -1,127 +0,0 @@ - - - - @-webkit-keyframes ha-spin { - 0% { - -webkit-transform: rotate(0deg); - transform: rotate(0deg); - } - 100% { - -webkit-transform: rotate(359deg); - transform: rotate(359deg); - } - } - @keyframes ha-spin { - 0% { - -webkit-transform: rotate(0deg); - transform: rotate(0deg); - } - 100% { - -webkit-transform: rotate(359deg); - transform: rotate(359deg); - } - } - - .ha-spin { - -webkit-animation: ha-spin 2s infinite linear; - animation: ha-spin 2s infinite linear; - } - - - core-scroll-header-panel, core-header-panel { - background-color: #E5E5E5; - } - - core-toolbar { - background: #03a9f4; - color: white; - font-weight: normal; - } - - - - :host { - font-family: RobotoDraft, 'Helvetica Neue', Helvetica, Arial; - - min-width: 350px; - max-width: 700px; - - /* First two are from core-transition-bottom */ - transition: - transform 0.2s ease-in-out, - opacity 0.2s ease-in, - top .3s, - left .3s !important; - } - - :host .sidebar { - margin-left: 30px; - } - - @media all and (max-width: 620px) { - :host.two-column { - margin: 0; - width: 100%; - max-height: calc(100% - 64px); - bottom: 0px; - left: 0px; - right: 0px; - } - - :host .sidebar { - display: none; - } - } - - @media all and (max-width: 464px) { - :host { - margin: 0; - width: 100%; - max-height: calc(100% - 64px); - bottom: 0px; - left: 0px; - right: 0px; - } - } - - html /deep/ .ha-form paper-input { - display: block; - } - - html /deep/ .ha-form paper-input:first-child { - padding-top: 0; - } - - - - .data-entry { - margin-bottom: 8px; - } - - .data-entry:last-child { - margin-bottom: 0; - } - - .data-entry .key { - margin-right: 8px; - } - - .data-entry .value { - text-align: right; - word-break: break-all; - } - - - - paper-toggle-button::shadow .toggle-ink { - color: #039be5; - } - - paper-toggle-button::shadow [checked] .toggle-bar { - background-color: #039be5; - } - - paper-toggle-button::shadow [checked] .toggle-button { - background-color: #039be5; - } - diff --git a/homeassistant/components/frontend/www_static/polymer/resources/moment-js.html b/homeassistant/components/frontend/www_static/polymer/resources/moment-js.html deleted file mode 100644 index 70e14e72d7f36..0000000000000 --- a/homeassistant/components/frontend/www_static/polymer/resources/moment-js.html +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/homeassistant/components/frontend/www_static/webcomponents.min.js b/homeassistant/components/frontend/www_static/webcomponents.min.js deleted file mode 100644 index 474305f73fc02..0000000000000 --- a/homeassistant/components/frontend/www_static/webcomponents.min.js +++ /dev/null @@ -1,14 +0,0 @@ -/** - * @license - * Copyright (c) 2014 The Polymer Project Authors. All rights reserved. - * This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt - * The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt - * The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt - * Code distributed by Google as part of the polymer project is also - * subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt - */ -// @version 0.5.5 -window.WebComponents=window.WebComponents||{},function(e){var t=e.flags||{},n="webcomponents.js",r=document.querySelector('script[src*="'+n+'"]');if(!t.noOpts){if(location.search.slice(1).split("&").forEach(function(e){e=e.split("="),e[0]&&(t[e[0]]=e[1]||!0)}),r)for(var o,i=0;o=r.attributes[i];i++)"src"!==o.name&&(t[o.name]=o.value||!0);if(t.log){var a=t.log.split(",");t.log={},a.forEach(function(e){t.log[e]=!0})}else t.log={}}t.shadow=t.shadow||t.shadowdom||t.polyfill,t.shadow="native"===t.shadow?!1:t.shadow||!HTMLElement.prototype.createShadowRoot,t.register&&(window.CustomElements=window.CustomElements||{flags:{}},window.CustomElements.flags.register=t.register),e.flags=t}(WebComponents),WebComponents.flags.shadow&&("undefined"==typeof WeakMap&&!function(){var e=Object.defineProperty,t=Date.now()%1e9,n=function(){this.name="__st"+(1e9*Math.random()>>>0)+(t++ +"__")};n.prototype={set:function(t,n){var r=t[this.name];return r&&r[0]===t?r[1]=n:e(t,this.name,{value:[t,n],writable:!0}),this},get:function(e){var t;return(t=e[this.name])&&t[0]===e?t[1]:void 0},"delete":function(e){var t=e[this.name];return t&&t[0]===e?(t[0]=t[1]=void 0,!0):!1},has:function(e){var t=e[this.name];return t?t[0]===e:!1}},window.WeakMap=n}(),window.ShadowDOMPolyfill={},function(e){"use strict";function t(){if("undefined"!=typeof chrome&&chrome.app&&chrome.app.runtime)return!1;if(navigator.getDeviceStorage)return!1;try{var e=new Function("return true;");return e()}catch(t){return!1}}function n(e){if(!e)throw new Error("Assertion failed")}function r(e,t){for(var n=W(t),r=0;rl;l++)c[l]=new Array(s),c[l][0]=l;for(var u=0;s>u;u++)c[0][u]=u;for(var l=1;a>l;l++)for(var u=1;s>u;u++)if(this.equals(e[t+u-1],r[o+l-1]))c[l][u]=c[l-1][u-1];else{var d=c[l-1][u]+1,p=c[l][u-1]+1;c[l][u]=p>d?d:p}return c},spliceOperationsFromEditDistances:function(e){for(var t=e.length-1,n=e[0].length-1,s=e[t][n],c=[];t>0||n>0;)if(0!=t)if(0!=n){var l,u=e[t-1][n-1],d=e[t-1][n],p=e[t][n-1];l=p>d?u>d?d:u:u>p?p:u,l==u?(u==s?c.push(r):(c.push(o),s=u),t--,n--):l==d?(c.push(a),t--,s=d):(c.push(i),n--,s=p)}else c.push(a),t--;else c.push(i),n--;return c.reverse(),c},calcSplices:function(e,n,s,c,l,u){var d=0,p=0,f=Math.min(s-n,u-l);if(0==n&&0==l&&(d=this.sharedPrefix(e,c,f)),s==e.length&&u==c.length&&(p=this.sharedSuffix(e,c,f-d)),n+=d,l+=d,s-=p,u-=p,s-n==0&&u-l==0)return[];if(n==s){for(var h=t(n,[],0);u>l;)h.removed.push(c[l++]);return[h]}if(l==u)return[t(n,[],s-n)];for(var m=this.spliceOperationsFromEditDistances(this.calcEditDistances(e,n,s,c,l,u)),h=void 0,w=[],v=n,g=l,b=0;br;r++)if(!this.equals(e[r],t[r]))return r;return n},sharedSuffix:function(e,t,n){for(var r=e.length,o=t.length,i=0;n>i&&this.equals(e[--r],t[--o]);)i++;return i},calculateSplices:function(e,t){return this.calcSplices(e,0,e.length,t,0,t.length)},equals:function(e,t){return e===t}},e.ArraySplice=n}(window.ShadowDOMPolyfill),function(e){"use strict";function t(){a=!1;var e=i.slice(0);i=[];for(var t=0;t0){for(var u=0;u0&&r.length>0;){var i=n.pop(),a=r.pop();if(i!==a)break;o=i}return o}function u(e,t,n){t instanceof G.Window&&(t=t.document);var o,i=k(t),a=k(n),s=r(n,e),o=l(i,a);o||(o=a.root);for(var c=o;c;c=c.parent)for(var u=0;u0;i--)if(!g(t[i],e,o,t,r))return!1;return!0}function w(e,t,n,r){var o=it,i=t[0]||n;return g(i,e,o,t,r)}function v(e,t,n,r){for(var o=at,i=1;i0&&g(n,e,o,t,r)}function g(e,t,n,r,o){var i=z.get(e);if(!i)return!0;var a=o||s(r,e);if(a===e){if(n===ot)return!0;n===at&&(n=it)}else if(n===at&&!t.bubbles)return!0;if("relatedTarget"in t){var c=q(t),l=c.relatedTarget;if(l){if(l instanceof Object&&l.addEventListener){var d=V(l),p=u(t,e,d);if(p===a)return!0}else p=null;J.set(t,p)}}Z.set(t,n);var f=t.type,h=!1;X.set(t,a),$.set(t,e),i.depth++;for(var m=0,w=i.length;w>m;m++){var v=i[m];if(v.removed)h=!0;else if(!(v.type!==f||!v.capture&&n===ot||v.capture&&n===at))try{if("function"==typeof v.handler?v.handler.call(e,t):v.handler.handleEvent(t),et.get(t))return!1}catch(g){I||(I=g)}}if(i.depth--,h&&0===i.depth){var b=i.slice();i.length=0;for(var m=0;mr;r++)t[r]=a(e[r]);return t.length=o,t}function o(e,t){e.prototype[t]=function(){return r(i(this)[t].apply(i(this),arguments))}}var i=e.unsafeUnwrap,a=e.wrap,s={enumerable:!1};n.prototype={item:function(e){return this[e]}},t(n.prototype,"item"),e.wrappers.NodeList=n,e.addWrapNodeListMethod=o,e.wrapNodeList=r}(window.ShadowDOMPolyfill),function(e){"use strict";e.wrapHTMLCollection=e.wrapNodeList,e.wrappers.HTMLCollection=e.wrappers.NodeList}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){L(e instanceof S)}function n(e){var t=new M;return t[0]=e,t.length=1,t}function r(e,t,n){C(t,"childList",{removedNodes:n,previousSibling:e.previousSibling,nextSibling:e.nextSibling})}function o(e,t){C(e,"childList",{removedNodes:t})}function i(e,t,r,o){if(e instanceof DocumentFragment){var i=s(e);B=!0;for(var a=i.length-1;a>=0;a--)e.removeChild(i[a]),i[a].parentNode_=t;B=!1;for(var a=0;ao;o++)r.appendChild(I(t[o]));return r}function w(e){if(void 0!==e.firstChild_)for(var t=e.firstChild_;t;){var n=t;t=t.nextSibling_,n.parentNode_=n.previousSibling_=n.nextSibling_=void 0}e.firstChild_=e.lastChild_=void 0}function v(e){if(e.invalidateShadowRenderer()){for(var t=e.firstChild;t;){L(t.parentNode===e);var n=t.nextSibling,r=I(t),o=r.parentNode;o&&Y.call(o,r),t.previousSibling_=t.nextSibling_=t.parentNode_=null,t=n}e.firstChild_=e.lastChild_=null}else for(var n,i=I(e),a=i.firstChild;a;)n=a.nextSibling,Y.call(i,a),a=n}function g(e){var t=e.parentNode;return t&&t.invalidateShadowRenderer()}function b(e){for(var t,n=0;ns;s++)i=b(t[s]),!o&&(a=v(i).root)&&a instanceof e.wrappers.ShadowRoot||(r[n++]=i);return n}function n(e){return String(e).replace(/\/deep\/|::shadow/g," ")}function r(e){return String(e).replace(/:host\(([^\s]+)\)/g,"$1").replace(/([^\s]):host/g,"$1").replace(":host","*").replace(/\^|\/shadow\/|\/shadow-deep\/|::shadow|\/deep\/|::content/g," ")}function o(e,t){for(var n,r=e.firstElementChild;r;){if(r.matches(t))return r;if(n=o(r,t))return n;r=r.nextElementSibling}return null}function i(e,t){return e.matches(t)}function a(e,t,n){var r=e.localName;return r===t||r===n&&e.namespaceURI===D}function s(){return!0}function c(e,t,n){return e.localName===n}function l(e,t){return e.namespaceURI===t}function u(e,t,n){return e.namespaceURI===t&&e.localName===n}function d(e,t,n,r,o,i){for(var a=e.firstElementChild;a;)r(a,o,i)&&(n[t++]=a),t=d(a,t,n,r,o,i),a=a.nextElementSibling;return t}function p(n,r,o,i,a){var s,c=g(this),l=v(this).root;if(l instanceof e.wrappers.ShadowRoot)return d(this,r,o,n,i,null);if(c instanceof C)s=T.call(c,i);else{if(!(c instanceof N))return d(this,r,o,n,i,null);s=S.call(c,i)}return t(s,r,o,a)}function f(n,r,o,i,a){var s,c=g(this),l=v(this).root;if(l instanceof e.wrappers.ShadowRoot)return d(this,r,o,n,i,a);if(c instanceof C)s=_.call(c,i,a);else{if(!(c instanceof N))return d(this,r,o,n,i,a);s=M.call(c,i,a)}return t(s,r,o,!1)}function h(n,r,o,i,a){var s,c=g(this),l=v(this).root;if(l instanceof e.wrappers.ShadowRoot)return d(this,r,o,n,i,a);if(c instanceof C)s=O.call(c,i,a);else{if(!(c instanceof N))return d(this,r,o,n,i,a);s=L.call(c,i,a)}return t(s,r,o,!1)}var m=e.wrappers.HTMLCollection,w=e.wrappers.NodeList,v=e.getTreeScope,g=e.unsafeUnwrap,b=e.wrap,y=document.querySelector,E=document.documentElement.querySelector,S=document.querySelectorAll,T=document.documentElement.querySelectorAll,M=document.getElementsByTagName,_=document.documentElement.getElementsByTagName,L=document.getElementsByTagNameNS,O=document.documentElement.getElementsByTagNameNS,C=window.Element,N=window.HTMLDocument||window.Document,D="http://www.w3.org/1999/xhtml",j={querySelector:function(t){var r=n(t),i=r!==t;t=r;var a,s=g(this),c=v(this).root;if(c instanceof e.wrappers.ShadowRoot)return o(this,t);if(s instanceof C)a=b(E.call(s,t));else{if(!(s instanceof N))return o(this,t);a=b(y.call(s,t))}return a&&!i&&(c=v(a).root)&&c instanceof e.wrappers.ShadowRoot?o(this,t):a},querySelectorAll:function(e){var t=n(e),r=t!==e;e=t;var o=new w;return o.length=p.call(this,i,0,o,e,r),o -}},H={matches:function(t){return t=r(t),e.originalMatches.call(g(this),t)}},x={getElementsByTagName:function(e){var t=new m,n="*"===e?s:a;return t.length=f.call(this,n,0,t,e,e.toLowerCase()),t},getElementsByClassName:function(e){return this.querySelectorAll("."+e)},getElementsByTagNameNS:function(e,t){var n=new m,r=null;return r="*"===e?"*"===t?s:c:"*"===t?l:u,n.length=h.call(this,r,0,n,e||null,t),n}};e.GetElementsByInterface=x,e.SelectorsInterface=j,e.MatchesInterface=H}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){for(;e&&e.nodeType!==Node.ELEMENT_NODE;)e=e.nextSibling;return e}function n(e){for(;e&&e.nodeType!==Node.ELEMENT_NODE;)e=e.previousSibling;return e}var r=e.wrappers.NodeList,o={get firstElementChild(){return t(this.firstChild)},get lastElementChild(){return n(this.lastChild)},get childElementCount(){for(var e=0,t=this.firstElementChild;t;t=t.nextElementSibling)e++;return e},get children(){for(var e=new r,t=0,n=this.firstElementChild;n;n=n.nextElementSibling)e[t++]=n;return e.length=t,e},remove:function(){var e=this.parentNode;e&&e.removeChild(this)}},i={get nextElementSibling(){return t(this.nextSibling)},get previousElementSibling(){return n(this.previousSibling)}};e.ChildNodeInterface=i,e.ParentNodeInterface=o}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){r.call(this,e)}var n=e.ChildNodeInterface,r=e.wrappers.Node,o=e.enqueueMutation,i=e.mixin,a=e.registerWrapper,s=e.unsafeUnwrap,c=window.CharacterData;t.prototype=Object.create(r.prototype),i(t.prototype,{get textContent(){return this.data},set textContent(e){this.data=e},get data(){return s(this).data},set data(e){var t=s(this).data;o(this,"characterData",{oldValue:t}),s(this).data=e}}),i(t.prototype,n),a(c,t,document.createTextNode("")),e.wrappers.CharacterData=t}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){return e>>>0}function n(e){r.call(this,e)}var r=e.wrappers.CharacterData,o=(e.enqueueMutation,e.mixin),i=e.registerWrapper,a=window.Text;n.prototype=Object.create(r.prototype),o(n.prototype,{splitText:function(e){e=t(e);var n=this.data;if(e>n.length)throw new Error("IndexSizeError");var r=n.slice(0,e),o=n.slice(e);this.data=r;var i=this.ownerDocument.createTextNode(o);return this.parentNode&&this.parentNode.insertBefore(i,this.nextSibling),i}}),i(a,n,document.createTextNode("")),e.wrappers.Text=n}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){return i(e).getAttribute("class")}function n(e,t){a(e,"attributes",{name:"class",namespace:null,oldValue:t})}function r(t){e.invalidateRendererBasedOnAttribute(t,"class")}function o(e,o,i){var a=e.ownerElement_;if(null==a)return o.apply(e,i);var s=t(a),c=o.apply(e,i);return t(a)!==s&&(n(a,s),r(a)),c}if(!window.DOMTokenList)return void console.warn("Missing DOMTokenList prototype, please include a compatible classList polyfill such as http://goo.gl/uTcepH.");var i=e.unsafeUnwrap,a=e.enqueueMutation,s=DOMTokenList.prototype.add;DOMTokenList.prototype.add=function(){o(this,s,arguments)};var c=DOMTokenList.prototype.remove;DOMTokenList.prototype.remove=function(){o(this,c,arguments)};var l=DOMTokenList.prototype.toggle;DOMTokenList.prototype.toggle=function(){return o(this,l,arguments)}}(window.ShadowDOMPolyfill),function(e){"use strict";function t(t,n){var r=t.parentNode;if(r&&r.shadowRoot){var o=e.getRendererForHost(r);o.dependsOnAttribute(n)&&o.invalidate()}}function n(e,t,n){u(e,"attributes",{name:t,namespace:null,oldValue:n})}function r(e){a.call(this,e)}var o=e.ChildNodeInterface,i=e.GetElementsByInterface,a=e.wrappers.Node,s=e.ParentNodeInterface,c=e.SelectorsInterface,l=e.MatchesInterface,u=(e.addWrapNodeListMethod,e.enqueueMutation),d=e.mixin,p=(e.oneOf,e.registerWrapper),f=e.unsafeUnwrap,h=e.wrappers,m=window.Element,w=["matches","mozMatchesSelector","msMatchesSelector","webkitMatchesSelector"].filter(function(e){return m.prototype[e]}),v=w[0],g=m.prototype[v],b=new WeakMap;r.prototype=Object.create(a.prototype),d(r.prototype,{createShadowRoot:function(){var t=new h.ShadowRoot(this);f(this).polymerShadowRoot_=t;var n=e.getRendererForHost(this);return n.invalidate(),t},get shadowRoot(){return f(this).polymerShadowRoot_||null},setAttribute:function(e,r){var o=f(this).getAttribute(e);f(this).setAttribute(e,r),n(this,e,o),t(this,e)},removeAttribute:function(e){var r=f(this).getAttribute(e);f(this).removeAttribute(e),n(this,e,r),t(this,e)},get classList(){var e=b.get(this);if(!e){if(e=f(this).classList,!e)return;e.ownerElement_=this,b.set(this,e)}return e},get className(){return f(this).className},set className(e){this.setAttribute("class",e)},get id(){return f(this).id},set id(e){this.setAttribute("id",e)}}),w.forEach(function(e){"matches"!==e&&(r.prototype[e]=function(e){return this.matches(e)})}),m.prototype.webkitCreateShadowRoot&&(r.prototype.webkitCreateShadowRoot=r.prototype.createShadowRoot),d(r.prototype,o),d(r.prototype,i),d(r.prototype,s),d(r.prototype,c),d(r.prototype,l),p(m,r,document.createElementNS(null,"x")),e.invalidateRendererBasedOnAttribute=t,e.matchesNames=w,e.originalMatches=g,e.wrappers.Element=r}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){switch(e){case"&":return"&";case"<":return"<";case">":return">";case'"':return""";case" ":return" "}}function n(e){return e.replace(L,t)}function r(e){return e.replace(O,t)}function o(e){for(var t={},n=0;n";case Node.TEXT_NODE:var u=e.data;return t&&N[t.localName]?u:r(u);case Node.COMMENT_NODE:return"";default:throw console.error(e),new Error("not implemented")}}function a(e){e instanceof _.HTMLTemplateElement&&(e=e.content);for(var t="",n=e.firstChild;n;n=n.nextSibling)t+=i(n,e);return t}function s(e,t,n){var r=n||"div";e.textContent="";var o=T(e.ownerDocument.createElement(r));o.innerHTML=t;for(var i;i=o.firstChild;)e.appendChild(M(i))}function c(e){h.call(this,e)}function l(e,t){var n=T(e.cloneNode(!1));n.innerHTML=t;for(var r,o=T(document.createDocumentFragment());r=n.firstChild;)o.appendChild(r);return M(o)}function u(t){return function(){return e.renderAllPending(),S(this)[t]}}function d(e){m(c,e,u(e))}function p(t){Object.defineProperty(c.prototype,t,{get:u(t),set:function(n){e.renderAllPending(),S(this)[t]=n},configurable:!0,enumerable:!0})}function f(t){Object.defineProperty(c.prototype,t,{value:function(){return e.renderAllPending(),S(this)[t].apply(S(this),arguments)},configurable:!0,enumerable:!0})}var h=e.wrappers.Element,m=e.defineGetter,w=e.enqueueMutation,v=e.mixin,g=e.nodesWereAdded,b=e.nodesWereRemoved,y=e.registerWrapper,E=e.snapshotNodeList,S=e.unsafeUnwrap,T=e.unwrap,M=e.wrap,_=e.wrappers,L=/[&\u00A0"]/g,O=/[&\u00A0<>]/g,C=o(["area","base","br","col","command","embed","hr","img","input","keygen","link","meta","param","source","track","wbr"]),N=o(["style","script","xmp","iframe","noembed","noframes","plaintext","noscript"]),D=/MSIE/.test(navigator.userAgent),j=window.HTMLElement,H=window.HTMLTemplateElement;c.prototype=Object.create(h.prototype),v(c.prototype,{get innerHTML(){return a(this)},set innerHTML(e){if(D&&N[this.localName])return void(this.textContent=e);var t=E(this.childNodes);this.invalidateShadowRenderer()?this instanceof _.HTMLTemplateElement?s(this.content,e):s(this,e,this.tagName):!H&&this instanceof _.HTMLTemplateElement?s(this.content,e):S(this).innerHTML=e;var n=E(this.childNodes);w(this,"childList",{addedNodes:n,removedNodes:t}),b(t),g(n,this)},get outerHTML(){return i(this,this.parentNode)},set outerHTML(e){var t=this.parentNode;if(t){t.invalidateShadowRenderer();var n=l(t,e);t.replaceChild(n,this)}},insertAdjacentHTML:function(e,t){var n,r;switch(String(e).toLowerCase()){case"beforebegin":n=this.parentNode,r=this;break;case"afterend":n=this.parentNode,r=this.nextSibling;break;case"afterbegin":n=this,r=this.firstChild;break;case"beforeend":n=this,r=null;break;default:return}var o=l(n,t);n.insertBefore(o,r)},get hidden(){return this.hasAttribute("hidden")},set hidden(e){e?this.setAttribute("hidden",""):this.removeAttribute("hidden")}}),["clientHeight","clientLeft","clientTop","clientWidth","offsetHeight","offsetLeft","offsetTop","offsetWidth","scrollHeight","scrollWidth"].forEach(d),["scrollLeft","scrollTop"].forEach(p),["getBoundingClientRect","getClientRects","scrollIntoView"].forEach(f),y(j,c,document.createElement("b")),e.wrappers.HTMLElement=c,e.getInnerHTML=a,e.setInnerHTML=s}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){n.call(this,e)}var n=e.wrappers.HTMLElement,r=e.mixin,o=e.registerWrapper,i=e.unsafeUnwrap,a=e.wrap,s=window.HTMLCanvasElement;t.prototype=Object.create(n.prototype),r(t.prototype,{getContext:function(){var e=i(this).getContext.apply(i(this),arguments);return e&&a(e)}}),o(s,t,document.createElement("canvas")),e.wrappers.HTMLCanvasElement=t}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){n.call(this,e)}var n=e.wrappers.HTMLElement,r=e.mixin,o=e.registerWrapper,i=window.HTMLContentElement;t.prototype=Object.create(n.prototype),r(t.prototype,{constructor:t,get select(){return this.getAttribute("select")},set select(e){this.setAttribute("select",e)},setAttribute:function(e,t){n.prototype.setAttribute.call(this,e,t),"select"===String(e).toLowerCase()&&this.invalidateShadowRenderer(!0)}}),i&&o(i,t),e.wrappers.HTMLContentElement=t}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){n.call(this,e)}var n=e.wrappers.HTMLElement,r=e.mixin,o=e.registerWrapper,i=e.wrapHTMLCollection,a=e.unwrap,s=window.HTMLFormElement;t.prototype=Object.create(n.prototype),r(t.prototype,{get elements(){return i(a(this).elements)}}),o(s,t,document.createElement("form")),e.wrappers.HTMLFormElement=t}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){r.call(this,e)}function n(e,t){if(!(this instanceof n))throw new TypeError("DOM object constructor cannot be called as a function.");var o=i(document.createElement("img"));r.call(this,o),a(o,this),void 0!==e&&(o.width=e),void 0!==t&&(o.height=t)}var r=e.wrappers.HTMLElement,o=e.registerWrapper,i=e.unwrap,a=e.rewrap,s=window.HTMLImageElement;t.prototype=Object.create(r.prototype),o(s,t,document.createElement("img")),n.prototype=t.prototype,e.wrappers.HTMLImageElement=t,e.wrappers.Image=n}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){n.call(this,e)}var n=e.wrappers.HTMLElement,r=(e.mixin,e.wrappers.NodeList,e.registerWrapper),o=window.HTMLShadowElement;t.prototype=Object.create(n.prototype),t.prototype.constructor=t,o&&r(o,t),e.wrappers.HTMLShadowElement=t}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){if(!e.defaultView)return e;var t=d.get(e);if(!t){for(t=e.implementation.createHTMLDocument("");t.lastChild;)t.removeChild(t.lastChild);d.set(e,t)}return t}function n(e){for(var n,r=t(e.ownerDocument),o=c(r.createDocumentFragment());n=e.firstChild;)o.appendChild(n);return o}function r(e){if(o.call(this,e),!p){var t=n(e);u.set(this,l(t))}}var o=e.wrappers.HTMLElement,i=e.mixin,a=e.registerWrapper,s=e.unsafeUnwrap,c=e.unwrap,l=e.wrap,u=new WeakMap,d=new WeakMap,p=window.HTMLTemplateElement;r.prototype=Object.create(o.prototype),i(r.prototype,{constructor:r,get content(){return p?l(s(this).content):u.get(this)}}),p&&a(p,r),e.wrappers.HTMLTemplateElement=r}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){n.call(this,e)}var n=e.wrappers.HTMLElement,r=e.registerWrapper,o=window.HTMLMediaElement;o&&(t.prototype=Object.create(n.prototype),r(o,t,document.createElement("audio")),e.wrappers.HTMLMediaElement=t)}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){r.call(this,e)}function n(e){if(!(this instanceof n))throw new TypeError("DOM object constructor cannot be called as a function.");var t=i(document.createElement("audio"));r.call(this,t),a(t,this),t.setAttribute("preload","auto"),void 0!==e&&t.setAttribute("src",e)}var r=e.wrappers.HTMLMediaElement,o=e.registerWrapper,i=e.unwrap,a=e.rewrap,s=window.HTMLAudioElement;s&&(t.prototype=Object.create(r.prototype),o(s,t,document.createElement("audio")),n.prototype=t.prototype,e.wrappers.HTMLAudioElement=t,e.wrappers.Audio=n)}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){return e.replace(/\s+/g," ").trim()}function n(e){o.call(this,e)}function r(e,t,n,i){if(!(this instanceof r))throw new TypeError("DOM object constructor cannot be called as a function.");var a=c(document.createElement("option"));o.call(this,a),s(a,this),void 0!==e&&(a.text=e),void 0!==t&&a.setAttribute("value",t),n===!0&&a.setAttribute("selected",""),a.selected=i===!0}var o=e.wrappers.HTMLElement,i=e.mixin,a=e.registerWrapper,s=e.rewrap,c=e.unwrap,l=e.wrap,u=window.HTMLOptionElement;n.prototype=Object.create(o.prototype),i(n.prototype,{get text(){return t(this.textContent)},set text(e){this.textContent=t(String(e))},get form(){return l(c(this).form)}}),a(u,n,document.createElement("option")),r.prototype=n.prototype,e.wrappers.HTMLOptionElement=n,e.wrappers.Option=r}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){n.call(this,e)}var n=e.wrappers.HTMLElement,r=e.mixin,o=e.registerWrapper,i=e.unwrap,a=e.wrap,s=window.HTMLSelectElement;t.prototype=Object.create(n.prototype),r(t.prototype,{add:function(e,t){"object"==typeof t&&(t=i(t)),i(this).add(i(e),t)},remove:function(e){return void 0===e?void n.prototype.remove.call(this):("object"==typeof e&&(e=i(e)),void i(this).remove(e))},get form(){return a(i(this).form)}}),o(s,t,document.createElement("select")),e.wrappers.HTMLSelectElement=t}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){n.call(this,e)}var n=e.wrappers.HTMLElement,r=e.mixin,o=e.registerWrapper,i=e.unwrap,a=e.wrap,s=e.wrapHTMLCollection,c=window.HTMLTableElement;t.prototype=Object.create(n.prototype),r(t.prototype,{get caption(){return a(i(this).caption)},createCaption:function(){return a(i(this).createCaption())},get tHead(){return a(i(this).tHead)},createTHead:function(){return a(i(this).createTHead())},createTFoot:function(){return a(i(this).createTFoot())},get tFoot(){return a(i(this).tFoot)},get tBodies(){return s(i(this).tBodies)},createTBody:function(){return a(i(this).createTBody())},get rows(){return s(i(this).rows)},insertRow:function(e){return a(i(this).insertRow(e))}}),o(c,t,document.createElement("table")),e.wrappers.HTMLTableElement=t}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){n.call(this,e)}var n=e.wrappers.HTMLElement,r=e.mixin,o=e.registerWrapper,i=e.wrapHTMLCollection,a=e.unwrap,s=e.wrap,c=window.HTMLTableSectionElement;t.prototype=Object.create(n.prototype),r(t.prototype,{constructor:t,get rows(){return i(a(this).rows)},insertRow:function(e){return s(a(this).insertRow(e))}}),o(c,t,document.createElement("thead")),e.wrappers.HTMLTableSectionElement=t}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){n.call(this,e)}var n=e.wrappers.HTMLElement,r=e.mixin,o=e.registerWrapper,i=e.wrapHTMLCollection,a=e.unwrap,s=e.wrap,c=window.HTMLTableRowElement;t.prototype=Object.create(n.prototype),r(t.prototype,{get cells(){return i(a(this).cells)},insertCell:function(e){return s(a(this).insertCell(e))}}),o(c,t,document.createElement("tr")),e.wrappers.HTMLTableRowElement=t}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){switch(e.localName){case"content":return new n(e);case"shadow":return new o(e);case"template":return new i(e)}r.call(this,e)}var n=e.wrappers.HTMLContentElement,r=e.wrappers.HTMLElement,o=e.wrappers.HTMLShadowElement,i=e.wrappers.HTMLTemplateElement,a=(e.mixin,e.registerWrapper),s=window.HTMLUnknownElement;t.prototype=Object.create(r.prototype),a(s,t),e.wrappers.HTMLUnknownElement=t}(window.ShadowDOMPolyfill),function(e){"use strict";var t=e.wrappers.Element,n=e.wrappers.HTMLElement,r=e.registerObject,o=e.defineWrapGetter,i="http://www.w3.org/2000/svg",a=document.createElementNS(i,"title"),s=r(a),c=Object.getPrototypeOf(s.prototype).constructor;if(!("classList"in a)){var l=Object.getOwnPropertyDescriptor(t.prototype,"classList");Object.defineProperty(n.prototype,"classList",l),delete t.prototype.classList}o(c,"ownerSVGElement"),e.wrappers.SVGElement=c}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){p.call(this,e)}var n=e.mixin,r=e.registerWrapper,o=e.unwrap,i=e.wrap,a=window.SVGUseElement,s="http://www.w3.org/2000/svg",c=i(document.createElementNS(s,"g")),l=document.createElementNS(s,"use"),u=c.constructor,d=Object.getPrototypeOf(u.prototype),p=d.constructor;t.prototype=Object.create(d),"instanceRoot"in l&&n(t.prototype,{get instanceRoot(){return i(o(this).instanceRoot)},get animatedInstanceRoot(){return i(o(this).animatedInstanceRoot)}}),r(a,t,l),e.wrappers.SVGUseElement=t}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){n.call(this,e)}var n=e.wrappers.EventTarget,r=e.mixin,o=e.registerWrapper,i=e.unsafeUnwrap,a=e.wrap,s=window.SVGElementInstance;s&&(t.prototype=Object.create(n.prototype),r(t.prototype,{get correspondingElement(){return a(i(this).correspondingElement)},get correspondingUseElement(){return a(i(this).correspondingUseElement)},get parentNode(){return a(i(this).parentNode)},get childNodes(){throw new Error("Not implemented")},get firstChild(){return a(i(this).firstChild)},get lastChild(){return a(i(this).lastChild)},get previousSibling(){return a(i(this).previousSibling)},get nextSibling(){return a(i(this).nextSibling)}}),o(s,t),e.wrappers.SVGElementInstance=t)}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){o(e,this)}var n=e.mixin,r=e.registerWrapper,o=e.setWrapper,i=e.unsafeUnwrap,a=e.unwrap,s=e.unwrapIfNeeded,c=e.wrap,l=window.CanvasRenderingContext2D;n(t.prototype,{get canvas(){return c(i(this).canvas)},drawImage:function(){arguments[0]=s(arguments[0]),i(this).drawImage.apply(i(this),arguments)},createPattern:function(){return arguments[0]=a(arguments[0]),i(this).createPattern.apply(i(this),arguments)}}),r(l,t,document.createElement("canvas").getContext("2d")),e.wrappers.CanvasRenderingContext2D=t}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){o(e,this)}var n=e.mixin,r=e.registerWrapper,o=e.setWrapper,i=e.unsafeUnwrap,a=e.unwrapIfNeeded,s=e.wrap,c=window.WebGLRenderingContext;if(c){n(t.prototype,{get canvas(){return s(i(this).canvas)},texImage2D:function(){arguments[5]=a(arguments[5]),i(this).texImage2D.apply(i(this),arguments)},texSubImage2D:function(){arguments[6]=a(arguments[6]),i(this).texSubImage2D.apply(i(this),arguments)}});var l=/WebKit/.test(navigator.userAgent)?{drawingBufferHeight:null,drawingBufferWidth:null}:{};r(c,t,l),e.wrappers.WebGLRenderingContext=t}}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){r(e,this)}var n=e.registerWrapper,r=e.setWrapper,o=e.unsafeUnwrap,i=e.unwrap,a=e.unwrapIfNeeded,s=e.wrap,c=window.Range;t.prototype={get startContainer(){return s(o(this).startContainer)},get endContainer(){return s(o(this).endContainer)},get commonAncestorContainer(){return s(o(this).commonAncestorContainer)},setStart:function(e,t){o(this).setStart(a(e),t)},setEnd:function(e,t){o(this).setEnd(a(e),t)},setStartBefore:function(e){o(this).setStartBefore(a(e))},setStartAfter:function(e){o(this).setStartAfter(a(e))},setEndBefore:function(e){o(this).setEndBefore(a(e))},setEndAfter:function(e){o(this).setEndAfter(a(e))},selectNode:function(e){o(this).selectNode(a(e))},selectNodeContents:function(e){o(this).selectNodeContents(a(e))},compareBoundaryPoints:function(e,t){return o(this).compareBoundaryPoints(e,i(t))},extractContents:function(){return s(o(this).extractContents())},cloneContents:function(){return s(o(this).cloneContents())},insertNode:function(e){o(this).insertNode(a(e))},surroundContents:function(e){o(this).surroundContents(a(e))},cloneRange:function(){return s(o(this).cloneRange())},isPointInRange:function(e,t){return o(this).isPointInRange(a(e),t)},comparePoint:function(e,t){return o(this).comparePoint(a(e),t)},intersectsNode:function(e){return o(this).intersectsNode(a(e))},toString:function(){return o(this).toString()}},c.prototype.createContextualFragment&&(t.prototype.createContextualFragment=function(e){return s(o(this).createContextualFragment(e))}),n(window.Range,t,document.createRange()),e.wrappers.Range=t}(window.ShadowDOMPolyfill),function(e){"use strict";var t=e.GetElementsByInterface,n=e.ParentNodeInterface,r=e.SelectorsInterface,o=e.mixin,i=e.registerObject,a=i(document.createDocumentFragment());o(a.prototype,n),o(a.prototype,r),o(a.prototype,t);var s=i(document.createComment(""));e.wrappers.Comment=s,e.wrappers.DocumentFragment=a}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){var t=d(u(e).ownerDocument.createDocumentFragment());n.call(this,t),c(t,this);var o=e.shadowRoot;f.set(this,o),this.treeScope_=new r(this,a(o||e)),p.set(this,e)}var n=e.wrappers.DocumentFragment,r=e.TreeScope,o=e.elementFromPoint,i=e.getInnerHTML,a=e.getTreeScope,s=e.mixin,c=e.rewrap,l=e.setInnerHTML,u=e.unsafeUnwrap,d=e.unwrap,p=new WeakMap,f=new WeakMap,h=/[ \t\n\r\f]/;t.prototype=Object.create(n.prototype),s(t.prototype,{constructor:t,get innerHTML(){return i(this)},set innerHTML(e){l(this,e),this.invalidateShadowRenderer()},get olderShadowRoot(){return f.get(this)||null},get host(){return p.get(this)||null},invalidateShadowRenderer:function(){return p.get(this).invalidateShadowRenderer()},elementFromPoint:function(e,t){return o(this,this.ownerDocument,e,t)},getElementById:function(e){return h.test(e)?null:this.querySelector('[id="'+e+'"]')}}),e.wrappers.ShadowRoot=t}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){e.previousSibling_=e.previousSibling,e.nextSibling_=e.nextSibling,e.parentNode_=e.parentNode}function n(n,o,i){var a=x(n),s=x(o),c=i?x(i):null;if(r(o),t(o),i)n.firstChild===i&&(n.firstChild_=i),i.previousSibling_=i.previousSibling;else{n.lastChild_=n.lastChild,n.lastChild===n.firstChild&&(n.firstChild_=n.firstChild);var l=R(a.lastChild);l&&(l.nextSibling_=l.nextSibling)}e.originalInsertBefore.call(a,s,c)}function r(n){var r=x(n),o=r.parentNode;if(o){var i=R(o);t(n),n.previousSibling&&(n.previousSibling.nextSibling_=n),n.nextSibling&&(n.nextSibling.previousSibling_=n),i.lastChild===n&&(i.lastChild_=n),i.firstChild===n&&(i.firstChild_=n),e.originalRemoveChild.call(o,r)}}function o(e){I.set(e,[])}function i(e){var t=I.get(e);return t||I.set(e,t=[]),t}function a(e){for(var t=[],n=0,r=e.firstChild;r;r=r.nextSibling)t[n++]=r;return t}function s(){for(var e=0;em;m++){var w=R(i[u++]);s.get(w)||r(w)}for(var v=f.addedCount,g=i[u]&&R(i[u]),m=0;v>m;m++){var b=o[l++],y=b.node;n(t,y,g),s.set(y,!0),b.sync(s)}d+=v}for(var p=d;p=0;o--){var i=r[o],a=m(i);if(a){var s=i.olderShadowRoot;s&&(n=h(s));for(var c=0;c=0;u--)l=Object.create(l);["createdCallback","attachedCallback","detachedCallback","attributeChangedCallback"].forEach(function(e){var t=o[e];t&&(l[e]=function(){C(this)instanceof r||M(this),t.apply(C(this),arguments)})});var d={prototype:l};i&&(d["extends"]=i),r.prototype=o,r.prototype.constructor=r,e.constructorTable.set(l,r),e.nativePrototypeTable.set(o,l);x.call(O(this),t,d);return r},b([window.HTMLDocument||window.Document],["registerElement"])}b([window.HTMLBodyElement,window.HTMLDocument||window.Document,window.HTMLHeadElement,window.HTMLHtmlElement],["appendChild","compareDocumentPosition","contains","getElementsByClassName","getElementsByTagName","getElementsByTagNameNS","insertBefore","querySelector","querySelectorAll","removeChild","replaceChild"]),b([window.HTMLBodyElement,window.HTMLHeadElement,window.HTMLHtmlElement],y),b([window.HTMLDocument||window.Document],["adoptNode","importNode","contains","createComment","createDocumentFragment","createElement","createElementNS","createEvent","createEventNS","createRange","createTextNode","elementFromPoint","getElementById","getElementsByName","getSelection"]),E(t.prototype,l),E(t.prototype,d),E(t.prototype,f),E(t.prototype,{get implementation(){var e=D.get(this); -return e?e:(e=new a(O(this).implementation),D.set(this,e),e)},get defaultView(){return C(O(this).defaultView)}}),S(window.Document,t,document.implementation.createHTMLDocument("")),window.HTMLDocument&&S(window.HTMLDocument,t),N([window.HTMLBodyElement,window.HTMLDocument||window.Document,window.HTMLHeadElement]),s(a,"createDocumentType"),s(a,"createDocument"),s(a,"createHTMLDocument"),c(a,"hasFeature"),S(window.DOMImplementation,a),b([window.DOMImplementation],["createDocumentType","createDocument","createHTMLDocument","hasFeature"]),e.adoptNodeNoRemove=r,e.wrappers.DOMImplementation=a,e.wrappers.Document=t}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){n.call(this,e)}var n=e.wrappers.EventTarget,r=e.wrappers.Selection,o=e.mixin,i=e.registerWrapper,a=e.renderAllPending,s=e.unwrap,c=e.unwrapIfNeeded,l=e.wrap,u=window.Window,d=window.getComputedStyle,p=window.getDefaultComputedStyle,f=window.getSelection;t.prototype=Object.create(n.prototype),u.prototype.getComputedStyle=function(e,t){return l(this||window).getComputedStyle(c(e),t)},p&&(u.prototype.getDefaultComputedStyle=function(e,t){return l(this||window).getDefaultComputedStyle(c(e),t)}),u.prototype.getSelection=function(){return l(this||window).getSelection()},delete window.getComputedStyle,delete window.getDefaultComputedStyle,delete window.getSelection,["addEventListener","removeEventListener","dispatchEvent"].forEach(function(e){u.prototype[e]=function(){var t=l(this||window);return t[e].apply(t,arguments)},delete window[e]}),o(t.prototype,{getComputedStyle:function(e,t){return a(),d.call(s(this),c(e),t)},getSelection:function(){return a(),new r(f.call(s(this)))},get document(){return l(s(this).document)}}),p&&(t.prototype.getDefaultComputedStyle=function(e,t){return a(),p.call(s(this),c(e),t)}),i(u,t,window),e.wrappers.Window=t}(window.ShadowDOMPolyfill),function(e){"use strict";var t=e.unwrap,n=window.DataTransfer||window.Clipboard,r=n.prototype.setDragImage;r&&(n.prototype.setDragImage=function(e,n,o){r.call(this,t(e),n,o)})}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){var t;t=e instanceof i?e:new i(e&&o(e)),r(t,this)}var n=e.registerWrapper,r=e.setWrapper,o=e.unwrap,i=window.FormData;i&&(n(i,t,new i),e.wrappers.FormData=t)}(window.ShadowDOMPolyfill),function(e){"use strict";var t=e.unwrapIfNeeded,n=XMLHttpRequest.prototype.send;XMLHttpRequest.prototype.send=function(e){return n.call(this,t(e))}}(window.ShadowDOMPolyfill),function(e){"use strict";function t(e){var t=n[e],r=window[t];if(r){var o=document.createElement(e),i=o.constructor;window[t]=i}}var n=(e.isWrapperFor,{a:"HTMLAnchorElement",area:"HTMLAreaElement",audio:"HTMLAudioElement",base:"HTMLBaseElement",body:"HTMLBodyElement",br:"HTMLBRElement",button:"HTMLButtonElement",canvas:"HTMLCanvasElement",caption:"HTMLTableCaptionElement",col:"HTMLTableColElement",content:"HTMLContentElement",data:"HTMLDataElement",datalist:"HTMLDataListElement",del:"HTMLModElement",dir:"HTMLDirectoryElement",div:"HTMLDivElement",dl:"HTMLDListElement",embed:"HTMLEmbedElement",fieldset:"HTMLFieldSetElement",font:"HTMLFontElement",form:"HTMLFormElement",frame:"HTMLFrameElement",frameset:"HTMLFrameSetElement",h1:"HTMLHeadingElement",head:"HTMLHeadElement",hr:"HTMLHRElement",html:"HTMLHtmlElement",iframe:"HTMLIFrameElement",img:"HTMLImageElement",input:"HTMLInputElement",keygen:"HTMLKeygenElement",label:"HTMLLabelElement",legend:"HTMLLegendElement",li:"HTMLLIElement",link:"HTMLLinkElement",map:"HTMLMapElement",marquee:"HTMLMarqueeElement",menu:"HTMLMenuElement",menuitem:"HTMLMenuItemElement",meta:"HTMLMetaElement",meter:"HTMLMeterElement",object:"HTMLObjectElement",ol:"HTMLOListElement",optgroup:"HTMLOptGroupElement",option:"HTMLOptionElement",output:"HTMLOutputElement",p:"HTMLParagraphElement",param:"HTMLParamElement",pre:"HTMLPreElement",progress:"HTMLProgressElement",q:"HTMLQuoteElement",script:"HTMLScriptElement",select:"HTMLSelectElement",shadow:"HTMLShadowElement",source:"HTMLSourceElement",span:"HTMLSpanElement",style:"HTMLStyleElement",table:"HTMLTableElement",tbody:"HTMLTableSectionElement",template:"HTMLTemplateElement",textarea:"HTMLTextAreaElement",thead:"HTMLTableSectionElement",time:"HTMLTimeElement",title:"HTMLTitleElement",tr:"HTMLTableRowElement",track:"HTMLTrackElement",ul:"HTMLUListElement",video:"HTMLVideoElement"});Object.keys(n).forEach(t),Object.getOwnPropertyNames(e.wrappers).forEach(function(t){window[t]=e.wrappers[t]})}(window.ShadowDOMPolyfill),function(e){function t(e,t){var n="";return Array.prototype.forEach.call(e,function(e){n+=e.textContent+"\n\n"}),t||(n=n.replace(d,"")),n}function n(e){var t=document.createElement("style");return t.textContent=e,t}function r(e){var t=n(e);document.head.appendChild(t);var r=[];if(t.sheet)try{r=t.sheet.cssRules}catch(o){}else console.warn("sheet not found",t);return t.parentNode.removeChild(t),r}function o(){N.initialized=!0,document.body.appendChild(N);var e=N.contentDocument,t=e.createElement("base");t.href=document.baseURI,e.head.appendChild(t)}function i(e){N.initialized||o(),document.body.appendChild(N),e(N.contentDocument),document.body.removeChild(N)}function a(e,t){if(t){var o;if(e.match("@import")&&j){var a=n(e);i(function(e){e.head.appendChild(a.impl),o=Array.prototype.slice.call(a.sheet.cssRules,0),t(o)})}else o=r(e),t(o)}}function s(e){e&&l().appendChild(document.createTextNode(e))}function c(e,t){var r=n(e);r.setAttribute(t,""),r.setAttribute(x,""),document.head.appendChild(r)}function l(){return D||(D=document.createElement("style"),D.setAttribute(x,""),D[x]=!0),D}var u={strictStyling:!1,registry:{},shimStyling:function(e,n,r){var o=this.prepareRoot(e,n,r),i=this.isTypeExtension(r),a=this.makeScopeSelector(n,i),s=t(o,!0);s=this.scopeCssText(s,a),e&&(e.shimmedStyle=s),this.addCssToDocument(s,n)},shimStyle:function(e,t){return this.shimCssText(e.textContent,t)},shimCssText:function(e,t){return e=this.insertDirectives(e),this.scopeCssText(e,t)},makeScopeSelector:function(e,t){return e?t?"[is="+e+"]":e:""},isTypeExtension:function(e){return e&&e.indexOf("-")<0},prepareRoot:function(e,t,n){var r=this.registerRoot(e,t,n);return this.replaceTextInStyles(r.rootStyles,this.insertDirectives),this.removeStyles(e,r.rootStyles),this.strictStyling&&this.applyScopeToContent(e,t),r.scopeStyles},removeStyles:function(e,t){for(var n,r=0,o=t.length;o>r&&(n=t[r]);r++)n.parentNode.removeChild(n)},registerRoot:function(e,t,n){var r=this.registry[t]={root:e,name:t,extendsName:n},o=this.findStyles(e);r.rootStyles=o,r.scopeStyles=r.rootStyles;var i=this.registry[r.extendsName];return i&&(r.scopeStyles=i.scopeStyles.concat(r.scopeStyles)),r},findStyles:function(e){if(!e)return[];var t=e.querySelectorAll("style");return Array.prototype.filter.call(t,function(e){return!e.hasAttribute(R)})},applyScopeToContent:function(e,t){e&&(Array.prototype.forEach.call(e.querySelectorAll("*"),function(e){e.setAttribute(t,"")}),Array.prototype.forEach.call(e.querySelectorAll("template"),function(e){this.applyScopeToContent(e.content,t)},this))},insertDirectives:function(e){return e=this.insertPolyfillDirectivesInCssText(e),this.insertPolyfillRulesInCssText(e)},insertPolyfillDirectivesInCssText:function(e){return e=e.replace(p,function(e,t){return t.slice(0,-2)+"{"}),e.replace(f,function(e,t){return t+" {"})},insertPolyfillRulesInCssText:function(e){return e=e.replace(h,function(e,t){return t.slice(0,-1)}),e.replace(m,function(e,t,n,r){var o=e.replace(t,"").replace(n,"");return r+o})},scopeCssText:function(e,t){var n=this.extractUnscopedRulesFromCssText(e);if(e=this.insertPolyfillHostInCssText(e),e=this.convertColonHost(e),e=this.convertColonHostContext(e),e=this.convertShadowDOMSelectors(e),t){var e,r=this;a(e,function(n){e=r.scopeRules(n,t)})}return e=e+"\n"+n,e.trim()},extractUnscopedRulesFromCssText:function(e){for(var t,n="";t=w.exec(e);)n+=t[1].slice(0,-1)+"\n\n";for(;t=v.exec(e);)n+=t[0].replace(t[2],"").replace(t[1],t[3])+"\n\n";return n},convertColonHost:function(e){return this.convertColonRule(e,E,this.colonHostPartReplacer)},convertColonHostContext:function(e){return this.convertColonRule(e,S,this.colonHostContextPartReplacer)},convertColonRule:function(e,t,n){return e.replace(t,function(e,t,r,o){if(t=L,r){for(var i,a=r.split(","),s=[],c=0,l=a.length;l>c&&(i=a[c]);c++)i=i.trim(),s.push(n(t,i,o));return s.join(",")}return t+o})},colonHostContextPartReplacer:function(e,t,n){return t.match(g)?this.colonHostPartReplacer(e,t,n):e+t+n+", "+t+" "+e+n},colonHostPartReplacer:function(e,t,n){return e+t.replace(g,"")+n},convertShadowDOMSelectors:function(e){for(var t=0;t","+","~"],r=e,o="["+t+"]";return n.forEach(function(e){var t=r.split(e);r=t.map(function(e){var t=e.trim().replace(O,"");return t&&n.indexOf(t)<0&&t.indexOf(o)<0&&(e=t.replace(/([^:]*)(:*)(.*)/,"$1"+o+"$2$3")),e}).join(e)}),r},insertPolyfillHostInCssText:function(e){return e.replace(_,b).replace(M,g)},propertiesFromRule:function(e){var t=e.style.cssText;e.style.content&&!e.style.content.match(/['"]+|attr/)&&(t=t.replace(/content:[^;]*;/g,"content: '"+e.style.content+"';"));var n=e.style;for(var r in n)"initial"===n[r]&&(t+=r+": initial; ");return t},replaceTextInStyles:function(e,t){e&&t&&(e instanceof Array||(e=[e]),Array.prototype.forEach.call(e,function(e){e.textContent=t.call(this,e.textContent)},this))},addCssToDocument:function(e,t){e.match("@import")?c(e,t):s(e)}},d=/\/\*[^*]*\*+([^/*][^*]*\*+)*\//gim,p=/\/\*\s*@polyfill ([^*]*\*+([^/*][^*]*\*+)*\/)([^{]*?){/gim,f=/polyfill-next-selector[^}]*content\:[\s]*?['"](.*?)['"][;\s]*}([^{]*?){/gim,h=/\/\*\s@polyfill-rule([^*]*\*+([^/*][^*]*\*+)*)\//gim,m=/(polyfill-rule)[^}]*(content\:[\s]*['"](.*?)['"])[;\s]*[^}]*}/gim,w=/\/\*\s@polyfill-unscoped-rule([^*]*\*+([^/*][^*]*\*+)*)\//gim,v=/(polyfill-unscoped-rule)[^}]*(content\:[\s]*['"](.*?)['"])[;\s]*[^}]*}/gim,g="-shadowcsshost",b="-shadowcsscontext",y=")(?:\\(((?:\\([^)(]*\\)|[^)(]*)+?)\\))?([^,{]*)",E=new RegExp("("+g+y,"gim"),S=new RegExp("("+b+y,"gim"),T="([>\\s~+[.,{:][\\s\\S]*)?$",M=/\:host/gim,_=/\:host-context/gim,L=g+"-no-combinator",O=new RegExp(g,"gim"),C=(new RegExp(b,"gim"),[/\^\^/g,/\^/g,/\/shadow\//g,/\/shadow-deep\//g,/::shadow/g,/\/deep\//g,/::content/g]),N=document.createElement("iframe");N.style.display="none";var D,j=navigator.userAgent.match("Chrome"),H="shim-shadowdom",x="shim-shadowdom-css",R="no-shim";if(window.ShadowDOMPolyfill){s("style { display: none !important; }\n");var P=ShadowDOMPolyfill.wrap(document),I=P.querySelector("head");I.insertBefore(l(),I.childNodes[0]),document.addEventListener("DOMContentLoaded",function(){e.urlResolver;if(window.HTMLImports&&!HTMLImports.useNative){var t="link[rel=stylesheet]["+H+"]",n="style["+H+"]";HTMLImports.importer.documentPreloadSelectors+=","+t,HTMLImports.importer.importsPreloadSelectors+=","+t,HTMLImports.parser.documentSelectors=[HTMLImports.parser.documentSelectors,t,n].join(",");var r=HTMLImports.parser.parseGeneric;HTMLImports.parser.parseGeneric=function(e){if(!e[x]){var t=e.__importElement||e;if(!t.hasAttribute(H))return void r.call(this,e);e.__resource&&(t=e.ownerDocument.createElement("style"),t.textContent=e.__resource),HTMLImports.path.resolveUrlsInStyle(t),t.textContent=u.shimStyle(t),t.removeAttribute(H,""),t.setAttribute(x,""),t[x]=!0,t.parentNode!==I&&(e.parentNode===I?I.replaceChild(t,e):this.addElementToDocument(t)),t.__importParsed=!0,this.markParsingComplete(e),this.parseNext()}};var o=HTMLImports.parser.hasResource;HTMLImports.parser.hasResource=function(e){return"link"===e.localName&&"stylesheet"===e.rel&&e.hasAttribute(H)?e.__resource:o.call(this,e)}}})}e.ShadowCSS=u}(window.WebComponents)),function(){window.ShadowDOMPolyfill?(window.wrap=ShadowDOMPolyfill.wrapIfNeeded,window.unwrap=ShadowDOMPolyfill.unwrapIfNeeded):window.wrap=window.unwrap=function(e){return e}}(window.WebComponents),window.HTMLImports=window.HTMLImports||{flags:{}},function(e){function t(e,t){t=t||h,r(function(){i(e,t)},t)}function n(e){return"complete"===e.readyState||e.readyState===v}function r(e,t){if(n(t))e&&e();else{var o=function(){("complete"===t.readyState||t.readyState===v)&&(t.removeEventListener(g,o),r(e,t))};t.addEventListener(g,o)}}function o(e){e.target.__loaded=!0}function i(e,t){function n(){s==c&&e&&e()}function r(e){o(e),s++,n()}var i=t.querySelectorAll("link[rel=import]"),s=0,c=i.length;if(c)for(var l,u=0;c>u&&(l=i[u]);u++)a(l)?r.call(l,{target:l}):(l.addEventListener("load",r),l.addEventListener("error",r));else n()}function a(e){return d?e.__loaded||e["import"]&&"loading"!==e["import"].readyState:e.__importParsed}function s(e){for(var t,n=0,r=e.length;r>n&&(t=e[n]);n++)c(t)&&l(t)}function c(e){return"link"===e.localName&&"import"===e.rel}function l(e){var t=e["import"];t?o({target:e}):(e.addEventListener("load",o),e.addEventListener("error",o))}var u="import",d=Boolean(u in document.createElement("link")),p=Boolean(window.ShadowDOMPolyfill),f=function(e){return p?ShadowDOMPolyfill.wrapIfNeeded(e):e},h=f(document),m={get:function(){var e=HTMLImports.currentScript||document.currentScript||("complete"!==document.readyState?document.scripts[document.scripts.length-1]:null);return f(e)},configurable:!0};Object.defineProperty(document,"_currentScript",m),Object.defineProperty(h,"_currentScript",m);var w=/Trident|Edge/.test(navigator.userAgent),v=w?"complete":"interactive",g="readystatechange";d&&(new MutationObserver(function(e){for(var t,n=0,r=e.length;r>n&&(t=e[n]);n++)t.addedNodes&&s(t.addedNodes)}).observe(document.head,{childList:!0}),function(){if("loading"===document.readyState)for(var e,t=document.querySelectorAll("link[rel=import]"),n=0,r=t.length;r>n&&(e=t[n]);n++)l(e)}()),t(function(){HTMLImports.ready=!0,HTMLImports.readyTime=(new Date).getTime();var e=h.createEvent("CustomEvent");e.initCustomEvent("HTMLImportsLoaded",!0,!0,{}),h.dispatchEvent(e)}),e.IMPORT_LINK_TYPE=u,e.useNative=d,e.rootDocument=h,e.whenReady=t,e.isIE=w}(HTMLImports),function(e){var t=[],n=function(e){t.push(e)},r=function(){t.forEach(function(t){t(e)})};e.addModule=n,e.initializeModules=r}(HTMLImports),HTMLImports.addModule(function(e){var t=/(url\()([^)]*)(\))/g,n=/(@import[\s]+(?!url\())([^;]*)(;)/g,r={resolveUrlsInStyle:function(e){var t=e.ownerDocument,n=t.createElement("a");return e.textContent=this.resolveUrlsInCssText(e.textContent,n),e},resolveUrlsInCssText:function(e,r){var o=this.replaceUrls(e,r,t);return o=this.replaceUrls(o,r,n)},replaceUrls:function(e,t,n){return e.replace(n,function(e,n,r,o){var i=r.replace(/["']/g,"");return t.href=i,i=t.href,n+"'"+i+"'"+o})}};e.path=r}),HTMLImports.addModule(function(e){var t={async:!0,ok:function(e){return e.status>=200&&e.status<300||304===e.status||0===e.status},load:function(n,r,o){var i=new XMLHttpRequest;return(e.flags.debug||e.flags.bust)&&(n+="?"+Math.random()),i.open("GET",n,t.async),i.addEventListener("readystatechange",function(){if(4===i.readyState){var e=i.getResponseHeader("Location"),n=null;if(e)var n="/"===e.substr(0,1)?location.origin+e:e;r.call(o,!t.ok(i)&&i,i.response||i.responseText,n)}}),i.send(),i},loadDocument:function(e,t,n){this.load(e,t,n).responseType="document"}};e.xhr=t}),HTMLImports.addModule(function(e){var t=e.xhr,n=e.flags,r=function(e,t){this.cache={},this.onload=e,this.oncomplete=t,this.inflight=0,this.pending={}};r.prototype={addNodes:function(e){this.inflight+=e.length;for(var t,n=0,r=e.length;r>n&&(t=e[n]);n++)this.require(t);this.checkDone()},addNode:function(e){this.inflight++,this.require(e),this.checkDone()},require:function(e){var t=e.src||e.href;e.__nodeUrl=t,this.dedupe(t,e)||this.fetch(t,e)},dedupe:function(e,t){if(this.pending[e])return this.pending[e].push(t),!0;return this.cache[e]?(this.onload(e,t,this.cache[e]),this.tail(),!0):(this.pending[e]=[t],!1)},fetch:function(e,r){if(n.load&&console.log("fetch",e,r),e)if(e.match(/^data:/)){var o=e.split(","),i=o[0],a=o[1];a=i.indexOf(";base64")>-1?atob(a):decodeURIComponent(a),setTimeout(function(){this.receive(e,r,null,a)}.bind(this),0)}else{var s=function(t,n,o){this.receive(e,r,t,n,o)}.bind(this);t.load(e,s)}else setTimeout(function(){this.receive(e,r,{error:"href must be specified"},null)}.bind(this),0)},receive:function(e,t,n,r,o){this.cache[e]=r;for(var i,a=this.pending[e],s=0,c=a.length;c>s&&(i=a[s]);s++)this.onload(e,i,r,n,o),this.tail();this.pending[e]=null},tail:function(){--this.inflight,this.checkDone()},checkDone:function(){this.inflight||this.oncomplete()}},e.Loader=r}),HTMLImports.addModule(function(e){var t=function(e){this.addCallback=e,this.mo=new MutationObserver(this.handler.bind(this))};t.prototype={handler:function(e){for(var t,n=0,r=e.length;r>n&&(t=e[n]);n++)"childList"===t.type&&t.addedNodes.length&&this.addedNodes(t.addedNodes)},addedNodes:function(e){this.addCallback&&this.addCallback(e);for(var t,n=0,r=e.length;r>n&&(t=e[n]);n++)t.children&&t.children.length&&this.addedNodes(t.children)},observe:function(e){this.mo.observe(e,{childList:!0,subtree:!0})}},e.Observer=t}),HTMLImports.addModule(function(e){function t(e){return"link"===e.localName&&e.rel===u}function n(e){var t=r(e);return"data:text/javascript;charset=utf-8,"+encodeURIComponent(t)}function r(e){return e.textContent+o(e)}function o(e){var t=e.ownerDocument;t.__importedScripts=t.__importedScripts||0;var n=e.ownerDocument.baseURI,r=t.__importedScripts?"-"+t.__importedScripts:"";return t.__importedScripts++,"\n//# sourceURL="+n+r+".js\n"}function i(e){var t=e.ownerDocument.createElement("style");return t.textContent=e.textContent,a.resolveUrlsInStyle(t),t}var a=e.path,s=e.rootDocument,c=e.flags,l=e.isIE,u=e.IMPORT_LINK_TYPE,d="link[rel="+u+"]",p={documentSelectors:d,importsSelectors:[d,"link[rel=stylesheet]","style","script:not([type])",'script[type="text/javascript"]'].join(","),map:{link:"parseLink",script:"parseScript",style:"parseStyle"},dynamicElements:[],parseNext:function(){var e=this.nextToParse();e&&this.parse(e)},parse:function(e){if(this.isParsed(e))return void(c.parse&&console.log("[%s] is already parsed",e.localName));var t=this[this.map[e.localName]];t&&(this.markParsing(e),t.call(this,e))},parseDynamic:function(e,t){this.dynamicElements.push(e),t||this.parseNext()},markParsing:function(e){c.parse&&console.log("parsing",e),this.parsingElement=e},markParsingComplete:function(e){e.__importParsed=!0,this.markDynamicParsingComplete(e),e.__importElement&&(e.__importElement.__importParsed=!0,this.markDynamicParsingComplete(e.__importElement)),this.parsingElement=null,c.parse&&console.log("completed",e)},markDynamicParsingComplete:function(e){var t=this.dynamicElements.indexOf(e);t>=0&&this.dynamicElements.splice(t,1)},parseImport:function(e){if(HTMLImports.__importsParsingHook&&HTMLImports.__importsParsingHook(e),e["import"]&&(e["import"].__importParsed=!0),this.markParsingComplete(e),e.dispatchEvent(e.__resource&&!e.__error?new CustomEvent("load",{bubbles:!1}):new CustomEvent("error",{bubbles:!1})),e.__pending)for(var t;e.__pending.length;)t=e.__pending.shift(),t&&t({target:e});this.parseNext()},parseLink:function(e){t(e)?this.parseImport(e):(e.href=e.href,this.parseGeneric(e))},parseStyle:function(e){var t=e;e=i(e),e.__importElement=t,this.parseGeneric(e)},parseGeneric:function(e){this.trackElement(e),this.addElementToDocument(e)},rootImportForElement:function(e){for(var t=e;t.ownerDocument.__importLink;)t=t.ownerDocument.__importLink;return t},addElementToDocument:function(e){var t=this.rootImportForElement(e.__importElement||e);t.parentNode.insertBefore(e,t)},trackElement:function(e,t){var n=this,r=function(r){t&&t(r),n.markParsingComplete(e),n.parseNext()};if(e.addEventListener("load",r),e.addEventListener("error",r),l&&"style"===e.localName){var o=!1;if(-1==e.textContent.indexOf("@import"))o=!0;else if(e.sheet){o=!0;for(var i,a=e.sheet.cssRules,s=a?a.length:0,c=0;s>c&&(i=a[c]);c++)i.type===CSSRule.IMPORT_RULE&&(o=o&&Boolean(i.styleSheet))}o&&e.dispatchEvent(new CustomEvent("load",{bubbles:!1}))}},parseScript:function(t){var r=document.createElement("script");r.__importElement=t,r.src=t.src?t.src:n(t),e.currentScript=t,this.trackElement(r,function(){r.parentNode.removeChild(r),e.currentScript=null}),this.addElementToDocument(r)},nextToParse:function(){return this._mayParse=[],!this.parsingElement&&(this.nextToParseInDoc(s)||this.nextToParseDynamic())},nextToParseInDoc:function(e,n){if(e&&this._mayParse.indexOf(e)<0){this._mayParse.push(e);for(var r,o=e.querySelectorAll(this.parseSelectorsForNode(e)),i=0,a=o.length;a>i&&(r=o[i]);i++)if(!this.isParsed(r))return this.hasResource(r)?t(r)?this.nextToParseInDoc(r["import"],r):r:void 0}return n},nextToParseDynamic:function(){return this.dynamicElements[0]},parseSelectorsForNode:function(e){var t=e.ownerDocument||e;return t===s?this.documentSelectors:this.importsSelectors},isParsed:function(e){return e.__importParsed},needsDynamicParsing:function(e){return this.dynamicElements.indexOf(e)>=0},hasResource:function(e){return t(e)&&void 0===e["import"]?!1:!0}};e.parser=p,e.IMPORT_SELECTOR=d}),HTMLImports.addModule(function(e){function t(e){return n(e,a)}function n(e,t){return"link"===e.localName&&e.getAttribute("rel")===t}function r(e){return!!Object.getOwnPropertyDescriptor(e,"baseURI")}function o(e,t){var n=document.implementation.createHTMLDocument(a);n._URL=t;var o=n.createElement("base");o.setAttribute("href",t),n.baseURI||r(n)||Object.defineProperty(n,"baseURI",{value:t});var i=n.createElement("meta");return i.setAttribute("charset","utf-8"),n.head.appendChild(i),n.head.appendChild(o),n.body.innerHTML=e,window.HTMLTemplateElement&&HTMLTemplateElement.bootstrap&&HTMLTemplateElement.bootstrap(n),n}var i=e.flags,a=e.IMPORT_LINK_TYPE,s=e.IMPORT_SELECTOR,c=e.rootDocument,l=e.Loader,u=e.Observer,d=e.parser,p={documents:{},documentPreloadSelectors:s,importsPreloadSelectors:[s].join(","),loadNode:function(e){f.addNode(e)},loadSubtree:function(e){var t=this.marshalNodes(e);f.addNodes(t)},marshalNodes:function(e){return e.querySelectorAll(this.loadSelectorsForNode(e))},loadSelectorsForNode:function(e){var t=e.ownerDocument||e;return t===c?this.documentPreloadSelectors:this.importsPreloadSelectors},loaded:function(e,n,r,a,s){if(i.load&&console.log("loaded",e,n),n.__resource=r,n.__error=a,t(n)){var c=this.documents[e];void 0===c&&(c=a?null:o(r,s||e),c&&(c.__importLink=n,this.bootDocument(c)),this.documents[e]=c),n["import"]=c}d.parseNext()},bootDocument:function(e){this.loadSubtree(e),this.observer.observe(e),d.parseNext()},loadedAll:function(){d.parseNext()}},f=new l(p.loaded.bind(p),p.loadedAll.bind(p));if(p.observer=new u,!document.baseURI){var h={get:function(){var e=document.querySelector("base");return e?e.href:window.location.href},configurable:!0};Object.defineProperty(document,"baseURI",h),Object.defineProperty(c,"baseURI",h)}e.importer=p,e.importLoader=f}),HTMLImports.addModule(function(e){var t=e.parser,n=e.importer,r={added:function(e){for(var r,o,i,a,s=0,c=e.length;c>s&&(a=e[s]);s++)r||(r=a.ownerDocument,o=t.isParsed(r)),i=this.shouldLoadNode(a),i&&n.loadNode(a),this.shouldParseNode(a)&&o&&t.parseDynamic(a,i)},shouldLoadNode:function(e){return 1===e.nodeType&&o.call(e,n.loadSelectorsForNode(e))},shouldParseNode:function(e){return 1===e.nodeType&&o.call(e,t.parseSelectorsForNode(e))}};n.observer.addCallback=r.added.bind(r);var o=HTMLElement.prototype.matches||HTMLElement.prototype.matchesSelector||HTMLElement.prototype.webkitMatchesSelector||HTMLElement.prototype.mozMatchesSelector||HTMLElement.prototype.msMatchesSelector}),function(e){function t(){HTMLImports.importer.bootDocument(o)}var n=e.initializeModules,r=e.isIE;if(!e.useNative){r&&"function"!=typeof window.CustomEvent&&(window.CustomEvent=function(e,t){t=t||{};var n=document.createEvent("CustomEvent");return n.initCustomEvent(e,Boolean(t.bubbles),Boolean(t.cancelable),t.detail),n},window.CustomEvent.prototype=window.Event.prototype),n();var o=e.rootDocument;"complete"===document.readyState||"interactive"===document.readyState&&!window.attachEvent?t():document.addEventListener("DOMContentLoaded",t)}}(HTMLImports),window.CustomElements=window.CustomElements||{flags:{}},function(e){var t=e.flags,n=[],r=function(e){n.push(e)},o=function(){n.forEach(function(t){t(e)})};e.addModule=r,e.initializeModules=o,e.hasNative=Boolean(document.registerElement),e.useNative=!t.register&&e.hasNative&&!window.ShadowDOMPolyfill&&(!window.HTMLImports||HTMLImports.useNative)}(CustomElements),CustomElements.addModule(function(e){function t(e,t){n(e,function(e){return t(e)?!0:void r(e,t)}),r(e,t)}function n(e,t,r){var o=e.firstElementChild;if(!o)for(o=e.firstChild;o&&o.nodeType!==Node.ELEMENT_NODE;)o=o.nextSibling;for(;o;)t(o,r)!==!0&&n(o,t,r),o=o.nextElementSibling;return null}function r(e,n){for(var r=e.shadowRoot;r;)t(r,n),r=r.olderShadowRoot}function o(e,t){a=[],i(e,t),a=null}function i(e,t){if(e=wrap(e),!(a.indexOf(e)>=0)){a.push(e);for(var n,r=e.querySelectorAll("link[rel="+s+"]"),o=0,c=r.length;c>o&&(n=r[o]);o++)n["import"]&&i(n["import"],t);t(e)}}var a,s=window.HTMLImports?HTMLImports.IMPORT_LINK_TYPE:"none";e.forDocumentTree=o,e.forSubtree=t}),CustomElements.addModule(function(e){function t(e){return n(e)||r(e)}function n(t){return e.upgrade(t)?!0:void s(t)}function r(e){y(e,function(e){return n(e)?!0:void 0})}function o(e){s(e),p(e)&&y(e,function(e){s(e)})}function i(e){M.push(e),T||(T=!0,setTimeout(a))}function a(){T=!1;for(var e,t=M,n=0,r=t.length;r>n&&(e=t[n]);n++)e();M=[]}function s(e){S?i(function(){c(e)}):c(e)}function c(e){e.__upgraded__&&(e.attachedCallback||e.detachedCallback)&&!e.__attached&&p(e)&&(e.__attached=!0,e.attachedCallback&&e.attachedCallback())}function l(e){u(e),y(e,function(e){u(e)})}function u(e){S?i(function(){d(e)}):d(e)}function d(e){e.__upgraded__&&(e.attachedCallback||e.detachedCallback)&&e.__attached&&!p(e)&&(e.__attached=!1,e.detachedCallback&&e.detachedCallback())}function p(e){for(var t=e,n=wrap(document);t;){if(t==n)return!0;t=t.parentNode||t.host}}function f(e){if(e.shadowRoot&&!e.shadowRoot.__watched){b.dom&&console.log("watching shadow-root for: ",e.localName);for(var t=e.shadowRoot;t;)w(t),t=t.olderShadowRoot}}function h(e){if(b.dom){var n=e[0];if(n&&"childList"===n.type&&n.addedNodes&&n.addedNodes){for(var r=n.addedNodes[0];r&&r!==document&&!r.host;)r=r.parentNode;var o=r&&(r.URL||r._URL||r.host&&r.host.localName)||"";o=o.split("/?").shift().split("/").pop()}console.group("mutations (%d) [%s]",e.length,o||"")}e.forEach(function(e){"childList"===e.type&&(_(e.addedNodes,function(e){e.localName&&t(e)}),_(e.removedNodes,function(e){e.localName&&l(e)}))}),b.dom&&console.groupEnd()}function m(e){for(e=wrap(e),e||(e=wrap(document));e.parentNode;)e=e.parentNode;var t=e.__observer;t&&(h(t.takeRecords()),a())}function w(e){if(!e.__observer){var t=new MutationObserver(h);t.observe(e,{childList:!0,subtree:!0}),e.__observer=t}}function v(e){e=wrap(e),b.dom&&console.group("upgradeDocument: ",e.baseURI.split("/").pop()),t(e),w(e),b.dom&&console.groupEnd()}function g(e){E(e,v)}var b=e.flags,y=e.forSubtree,E=e.forDocumentTree,S=!window.MutationObserver||window.MutationObserver===window.JsMutationObserver;e.hasPolyfillMutations=S;var T=!1,M=[],_=Array.prototype.forEach.call.bind(Array.prototype.forEach),L=Element.prototype.createShadowRoot;L&&(Element.prototype.createShadowRoot=function(){var e=L.call(this);return CustomElements.watchShadow(this),e}),e.watchShadow=f,e.upgradeDocumentTree=g,e.upgradeSubtree=r,e.upgradeAll=t,e.attachedNode=o,e.takeRecords=m}),CustomElements.addModule(function(e){function t(t){if(!t.__upgraded__&&t.nodeType===Node.ELEMENT_NODE){var r=t.getAttribute("is"),o=e.getRegisteredDefinition(r||t.localName);if(o){if(r&&o.tag==t.localName)return n(t,o);if(!r&&!o["extends"])return n(t,o)}}}function n(t,n){return a.upgrade&&console.group("upgrade:",t.localName),n.is&&t.setAttribute("is",n.is),r(t,n),t.__upgraded__=!0,i(t),e.attachedNode(t),e.upgradeSubtree(t),a.upgrade&&console.groupEnd(),t}function r(e,t){Object.__proto__?e.__proto__=t.prototype:(o(e,t.prototype,t["native"]),e.__proto__=t.prototype)}function o(e,t,n){for(var r={},o=t;o!==n&&o!==HTMLElement.prototype;){for(var i,a=Object.getOwnPropertyNames(o),s=0;i=a[s];s++)r[i]||(Object.defineProperty(e,i,Object.getOwnPropertyDescriptor(o,i)),r[i]=1);o=Object.getPrototypeOf(o)}}function i(e){e.createdCallback&&e.createdCallback()}var a=e.flags;e.upgrade=t,e.upgradeWithDefinition=n,e.implementPrototype=r}),CustomElements.addModule(function(e){function t(t,r){var c=r||{};if(!t)throw new Error("document.registerElement: first argument `name` must not be empty");if(t.indexOf("-")<0)throw new Error("document.registerElement: first argument ('name') must contain a dash ('-'). Argument provided was '"+String(t)+"'.");if(o(t))throw new Error("Failed to execute 'registerElement' on 'Document': Registration failed for type '"+String(t)+"'. The type name is invalid.");if(l(t))throw new Error("DuplicateDefinitionError: a type with name '"+String(t)+"' is already registered");return c.prototype||(c.prototype=Object.create(HTMLElement.prototype)),c.__name=t.toLowerCase(),c.lifecycle=c.lifecycle||{},c.ancestry=i(c["extends"]),a(c),s(c),n(c.prototype),u(c.__name,c),c.ctor=d(c),c.ctor.prototype=c.prototype,c.prototype.constructor=c.ctor,e.ready&&w(document),c.ctor}function n(e){if(!e.setAttribute._polyfilled){var t=e.setAttribute;e.setAttribute=function(e,n){r.call(this,e,n,t)};var n=e.removeAttribute;e.removeAttribute=function(e){r.call(this,e,null,n)},e.setAttribute._polyfilled=!0}}function r(e,t,n){e=e.toLowerCase();var r=this.getAttribute(e);n.apply(this,arguments);var o=this.getAttribute(e);this.attributeChangedCallback&&o!==r&&this.attributeChangedCallback(e,r,o)}function o(e){for(var t=0;t=0&&b(r,HTMLElement),r)}function h(e){var t=L.call(this,e);return v(t),t}var m,w=e.upgradeDocumentTree,v=e.upgrade,g=e.upgradeWithDefinition,b=e.implementPrototype,y=e.useNative,E=["annotation-xml","color-profile","font-face","font-face-src","font-face-uri","font-face-format","font-face-name","missing-glyph"],S={},T="http://www.w3.org/1999/xhtml",M=document.createElement.bind(document),_=document.createElementNS.bind(document),L=Node.prototype.cloneNode;m=Object.__proto__||y?function(e,t){return e instanceof t}:function(e,t){for(var n=e;n;){if(n===t.prototype)return!0;n=n.__proto__}return!1},document.registerElement=t,document.createElement=f,document.createElementNS=p,Node.prototype.cloneNode=h,e.registry=S,e["instanceof"]=m,e.reservedTagList=E,e.getRegisteredDefinition=l,document.register=document.registerElement}),function(e){function t(){a(wrap(document)),window.HTMLImports&&(HTMLImports.__importsParsingHook=function(e){a(wrap(e["import"]))}),CustomElements.ready=!0,setTimeout(function(){CustomElements.readyTime=Date.now(),window.HTMLImports&&(CustomElements.elapsed=CustomElements.readyTime-HTMLImports.readyTime),document.dispatchEvent(new CustomEvent("WebComponentsReady",{bubbles:!0}))})}var n=e.useNative,r=e.initializeModules,o=/Trident/.test(navigator.userAgent);if(n){var i=function(){};e.watchShadow=i,e.upgrade=i,e.upgradeAll=i,e.upgradeDocumentTree=i,e.upgradeSubtree=i,e.takeRecords=i,e["instanceof"]=function(e,t){return e instanceof t}}else r();var a=e.upgradeDocumentTree;if(window.wrap||(window.ShadowDOMPolyfill?(window.wrap=ShadowDOMPolyfill.wrapIfNeeded,window.unwrap=ShadowDOMPolyfill.unwrapIfNeeded):window.wrap=window.unwrap=function(e){return e}),o&&"function"!=typeof window.CustomEvent&&(window.CustomEvent=function(e,t){t=t||{};var n=document.createEvent("CustomEvent");return n.initCustomEvent(e,Boolean(t.bubbles),Boolean(t.cancelable),t.detail),n},window.CustomEvent.prototype=window.Event.prototype),"complete"===document.readyState||e.flags.eager)t();else if("interactive"!==document.readyState||window.attachEvent||window.HTMLImports&&!window.HTMLImports.ready){var s=window.HTMLImports&&!HTMLImports.ready?"HTMLImportsLoaded":"DOMContentLoaded";window.addEventListener(s,t)}else t()}(window.CustomElements),function(){Function.prototype.bind||(Function.prototype.bind=function(e){var t=this,n=Array.prototype.slice.call(arguments,1);return function(){var r=n.slice();return r.push.apply(r,arguments),t.apply(e,r)}})}(window.WebComponents),function(e){"use strict";function t(){window.Polymer===o&&(window.Polymer=function(){throw new Error('You tried to use polymer without loading it first. To load polymer, ')})}if(!window.performance){var n=Date.now();window.performance={now:function(){return Date.now()-n}}}window.requestAnimationFrame||(window.requestAnimationFrame=function(){var e=window.webkitRequestAnimationFrame||window.mozRequestAnimationFrame;return e?function(t){return e(function(){t(performance.now())})}:function(e){return window.setTimeout(e,1e3/60)}}()),window.cancelAnimationFrame||(window.cancelAnimationFrame=function(){return window.webkitCancelAnimationFrame||window.mozCancelAnimationFrame||function(e){clearTimeout(e)}}());var r=[],o=function(e){"string"!=typeof e&&1===arguments.length&&Array.prototype.push.call(arguments,document._currentScript),r.push(arguments)};window.Polymer=o,e.consumeDeclarations=function(t){e.consumeDeclarations=function(){throw"Possible attempt to load Polymer twice"},t&&t(r),r=null},HTMLImports.useNative?t():addEventListener("DOMContentLoaded",t)}(window.WebComponents),function(){var e=document.createElement("style");e.textContent="body {transition: opacity ease-in 0.2s; } \nbody[unresolved] {opacity: 0; display: block; overflow: hidden; position: relative; } \n";var t=document.querySelector("head");t.insertBefore(e,t.firstChild)}(window.WebComponents),function(e){window.Platform=e}(window.WebComponents); \ No newline at end of file diff --git a/homeassistant/components/frontier_silicon/__init__.py b/homeassistant/components/frontier_silicon/__init__.py new file mode 100644 index 0000000000000..ddd74ca8efe58 --- /dev/null +++ b/homeassistant/components/frontier_silicon/__init__.py @@ -0,0 +1 @@ +"""The frontier_silicon component.""" diff --git a/homeassistant/components/frontier_silicon/manifest.json b/homeassistant/components/frontier_silicon/manifest.json new file mode 100644 index 0000000000000..d8ca3148accc7 --- /dev/null +++ b/homeassistant/components/frontier_silicon/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "frontier_silicon", + "name": "Frontier Silicon", + "documentation": "https://www.home-assistant.io/integrations/frontier_silicon", + "requirements": ["afsapi==0.0.4"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/frontier_silicon/media_player.py b/homeassistant/components/frontier_silicon/media_player.py new file mode 100644 index 0000000000000..010420d0f9884 --- /dev/null +++ b/homeassistant/components/frontier_silicon/media_player.py @@ -0,0 +1,282 @@ +"""Support for Frontier Silicon Devices (Medion, Hama, Auna,...).""" +import logging + +from afsapi import AFSAPI +import requests +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_PREVIOUS_TRACK, + SUPPORT_SEEK, + SUPPORT_SELECT_SOURCE, + SUPPORT_STOP, + SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, + SUPPORT_VOLUME_MUTE, + SUPPORT_VOLUME_SET, + SUPPORT_VOLUME_STEP, +) +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + STATE_OFF, + STATE_PAUSED, + STATE_PLAYING, + STATE_UNKNOWN, +) +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_FRONTIER_SILICON = ( + SUPPORT_PAUSE + | SUPPORT_VOLUME_SET + | SUPPORT_VOLUME_MUTE + | SUPPORT_VOLUME_STEP + | SUPPORT_PREVIOUS_TRACK + | SUPPORT_NEXT_TRACK + | SUPPORT_SEEK + | SUPPORT_PLAY_MEDIA + | SUPPORT_PLAY + | SUPPORT_STOP + | SUPPORT_TURN_ON + | SUPPORT_TURN_OFF + | SUPPORT_SELECT_SOURCE +) + +DEFAULT_PORT = 80 +DEFAULT_PASSWORD = "1234" +DEVICE_URL = "http://{0}:{1}/device" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, + } +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the Frontier Silicon platform.""" + if discovery_info is not None: + async_add_entities( + [AFSAPIDevice(discovery_info["ssdp_description"], DEFAULT_PASSWORD)], True + ) + return True + + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) + password = config.get(CONF_PASSWORD) + + try: + async_add_entities( + [AFSAPIDevice(DEVICE_URL.format(host, port), password)], True + ) + _LOGGER.debug("FSAPI device %s:%s -> %s", host, port, password) + return True + except requests.exceptions.RequestException: + _LOGGER.error( + "Could not add the FSAPI device at %s:%s -> %s", host, port, password + ) + + return False + + +class AFSAPIDevice(MediaPlayerDevice): + """Representation of a Frontier Silicon device on the network.""" + + def __init__(self, device_url, password): + """Initialize the Frontier Silicon API device.""" + self._device_url = device_url + self._password = password + self._state = None + + self._name = None + self._title = None + self._artist = None + self._album_name = None + self._mute = None + self._source = None + self._source_list = None + self._media_image_url = None + + # Properties + @property + def fs_device(self): + """ + Create a fresh fsapi session. + + A new session is created for each request in case someone else + connected to the device in between the updates and invalidated the + existing session (i.e UNDOK). + """ + return AFSAPI(self._device_url, self._password) + + @property + def should_poll(self): + """Device should be polled.""" + return True + + @property + def name(self): + """Return the device name.""" + return self._name + + @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_album_name(self): + """Album name of current playing media, music track only.""" + return self._album_name + + @property + def media_content_type(self): + """Content type of current playing media.""" + return MEDIA_TYPE_MUSIC + + @property + def supported_features(self): + """Flag of media commands that are supported.""" + return SUPPORT_FRONTIER_SILICON + + @property + def state(self): + """Return the state of the player.""" + return self._state + + # source + @property + def source_list(self): + """List of available input sources.""" + return self._source_list + + @property + def source(self): + """Name of the current input source.""" + return self._source + + @property + def media_image_url(self): + """Image url of current playing media.""" + return self._media_image_url + + async def async_update(self): + """Get the latest date and update device state.""" + fs_device = self.fs_device + + if not self._name: + self._name = await fs_device.get_friendly_name() + + if not self._source_list: + self._source_list = await fs_device.get_mode_list() + + status = await fs_device.get_play_status() + self._state = { + "playing": STATE_PLAYING, + "paused": STATE_PAUSED, + "stopped": STATE_OFF, + "unknown": STATE_UNKNOWN, + None: STATE_OFF, + }.get(status, STATE_UNKNOWN) + + if self._state != STATE_OFF: + info_name = await fs_device.get_play_name() + info_text = await fs_device.get_play_text() + + self._title = " - ".join(filter(None, [info_name, info_text])) + self._artist = await fs_device.get_play_artist() + self._album_name = await fs_device.get_play_album() + + self._source = await fs_device.get_mode() + self._mute = await fs_device.get_mute() + self._media_image_url = await fs_device.get_play_graphic() + else: + self._title = None + self._artist = None + self._album_name = None + + self._source = None + self._mute = None + self._media_image_url = None + + # Management actions + # power control + async def async_turn_on(self): + """Turn on the device.""" + await self.fs_device.set_power(True) + + async def async_turn_off(self): + """Turn off the device.""" + await self.fs_device.set_power(False) + + async def async_media_play(self): + """Send play command.""" + await self.fs_device.play() + + async def async_media_pause(self): + """Send pause command.""" + await self.fs_device.pause() + + async def async_media_play_pause(self): + """Send play/pause command.""" + if "playing" in self._state: + await self.fs_device.pause() + else: + await self.fs_device.play() + + async def async_media_stop(self): + """Send play/pause command.""" + await self.fs_device.pause() + + async def async_media_previous_track(self): + """Send previous track command (results in rewind).""" + await self.fs_device.rewind() + + async def async_media_next_track(self): + """Send next track command (results in fast-forward).""" + await self.fs_device.forward() + + # mute + @property + def is_volume_muted(self): + """Boolean if volume is currently muted.""" + return self._mute + + async def async_mute_volume(self, mute): + """Send mute command.""" + await self.fs_device.set_mute(mute) + + # volume + async def async_volume_up(self): + """Send volume up command.""" + volume = await self.fs_device.get_volume() + await self.fs_device.set_volume(volume + 1) + + async def async_volume_down(self): + """Send volume down command.""" + volume = await self.fs_device.get_volume() + await self.fs_device.set_volume(volume - 1) + + async def async_set_volume_level(self, volume): + """Set volume command.""" + await self.fs_device.set_volume(volume) + + async def async_select_source(self, source): + """Select input source.""" + await self.fs_device.set_mode(source) diff --git a/homeassistant/components/futurenow/__init__.py b/homeassistant/components/futurenow/__init__.py new file mode 100644 index 0000000000000..530911fecf936 --- /dev/null +++ b/homeassistant/components/futurenow/__init__.py @@ -0,0 +1 @@ +"""The futurenow component.""" diff --git a/homeassistant/components/futurenow/light.py b/homeassistant/components/futurenow/light.py new file mode 100644 index 0000000000000..4da3bfd5bc3bd --- /dev/null +++ b/homeassistant/components/futurenow/light.py @@ -0,0 +1,128 @@ +"""Support for FutureNow Ethernet unit outputs as Lights.""" + +import logging + +import pyfnip +import voluptuous as vol + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + PLATFORM_SCHEMA, + SUPPORT_BRIGHTNESS, + Light, +) +from homeassistant.const import CONF_DEVICES, CONF_HOST, CONF_NAME, CONF_PORT +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +CONF_DRIVER = "driver" +CONF_DRIVER_FNIP6X10AD = "FNIP6x10ad" +CONF_DRIVER_FNIP8X10A = "FNIP8x10a" +CONF_DRIVER_TYPES = [CONF_DRIVER_FNIP6X10AD, CONF_DRIVER_FNIP8X10A] + +DEVICE_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAME): cv.string, + vol.Optional("dimmable", default=False): cv.boolean, + } +) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_DRIVER): vol.In(CONF_DRIVER_TYPES), + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PORT): cv.port, + vol.Required(CONF_DEVICES): {cv.string: DEVICE_SCHEMA}, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the light platform for each FutureNow unit.""" + lights = [] + for channel, device_config in config[CONF_DEVICES].items(): + device = {} + device["name"] = device_config[CONF_NAME] + device["dimmable"] = device_config["dimmable"] + device["channel"] = channel + device["driver"] = config[CONF_DRIVER] + device["host"] = config[CONF_HOST] + device["port"] = config[CONF_PORT] + lights.append(FutureNowLight(device)) + + add_entities(lights, True) + + +def to_futurenow_level(level): + """Convert the given Home Assistant light level (0-255) to FutureNow (0-100).""" + return int((level * 100) / 255) + + +def to_hass_level(level): + """Convert the given FutureNow (0-100) light level to Home Assistant (0-255).""" + return int((level * 255) / 100) + + +class FutureNowLight(Light): + """Representation of an FutureNow light.""" + + def __init__(self, device): + """Initialize the light.""" + self._name = device["name"] + self._dimmable = device["dimmable"] + self._channel = device["channel"] + self._brightness = None + self._last_brightness = 255 + self._state = None + + if device["driver"] == CONF_DRIVER_FNIP6X10AD: + self._light = pyfnip.FNIP6x2adOutput( + device["host"], device["port"], self._channel + ) + if device["driver"] == CONF_DRIVER_FNIP8X10A: + self._light = pyfnip.FNIP8x10aOutput( + device["host"], device["port"], self._channel + ) + + @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.""" + if self._dimmable: + return SUPPORT_BRIGHTNESS + return 0 + + def turn_on(self, **kwargs): + """Turn the light on.""" + if self._dimmable: + level = kwargs.get(ATTR_BRIGHTNESS, self._last_brightness) + else: + level = 255 + self._light.turn_on(to_futurenow_level(level)) + + def turn_off(self, **kwargs): + """Turn the light off.""" + self._light.turn_off() + if self._brightness: + self._last_brightness = self._brightness + + def update(self): + """Fetch new state data for this light.""" + state = int(self._light.is_on()) + self._state = bool(state) + self._brightness = to_hass_level(state) diff --git a/homeassistant/components/futurenow/manifest.json b/homeassistant/components/futurenow/manifest.json new file mode 100644 index 0000000000000..6a4599ea942a3 --- /dev/null +++ b/homeassistant/components/futurenow/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "futurenow", + "name": "P5 FutureNow", + "documentation": "https://www.home-assistant.io/integrations/futurenow", + "requirements": ["pyfnip==0.2"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/garadget/__init__.py b/homeassistant/components/garadget/__init__.py new file mode 100644 index 0000000000000..3d3977e959683 --- /dev/null +++ b/homeassistant/components/garadget/__init__.py @@ -0,0 +1 @@ +"""The garadget component.""" diff --git a/homeassistant/components/garadget/cover.py b/homeassistant/components/garadget/cover.py new file mode 100644 index 0000000000000..0eeb5f2b8f9b6 --- /dev/null +++ b/homeassistant/components/garadget/cover.py @@ -0,0 +1,271 @@ +"""Platform for the Garadget cover component.""" +import logging + +import requests +import voluptuous as vol + +from homeassistant.components.cover import PLATFORM_SCHEMA, CoverDevice +from homeassistant.const import ( + CONF_ACCESS_TOKEN, + CONF_COVERS, + CONF_DEVICE, + CONF_NAME, + CONF_PASSWORD, + CONF_USERNAME, + STATE_CLOSED, + STATE_OPEN, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import track_utc_time_change + +_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): cv.schema_with_slug_keys(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 = None + 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: + _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 is None: + 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 = f"{self.particle_url}/oauth/token" + 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 = f"{self.particle_url}/v1/access_tokens/{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"], None) + 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: + _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 = f"{self.particle_url}/v1/devices/{self.device_id}/{func}" + ret = requests.post(url, data=params, timeout=10) + return ret.json() diff --git a/homeassistant/components/garadget/manifest.json b/homeassistant/components/garadget/manifest.json new file mode 100644 index 0000000000000..b86f4e26b11fd --- /dev/null +++ b/homeassistant/components/garadget/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "garadget", + "name": "Garadget", + "documentation": "https://www.home-assistant.io/integrations/garadget", + "requirements": [], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/gc100/__init__.py b/homeassistant/components/gc100/__init__.py new file mode 100644 index 0000000000000..36779b28df29a --- /dev/null +++ b/homeassistant/components/gc100/__init__.py @@ -0,0 +1,70 @@ +"""Support for controlling Global Cache gc100.""" +import logging + +import gc100 +import voluptuous as vol + +from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +CONF_PORTS = "ports" + +DEFAULT_PORT = 4998 +DOMAIN = "gc100" + +DATA_GC100 = "gc100" + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +# pylint: disable=no-member +def setup(hass, base_config): + """Set up the gc100 component.""" + config = base_config[DOMAIN] + host = config[CONF_HOST] + port = config[CONF_PORT] + + gc_device = gc100.GC100SocketClient(host, port) + + def cleanup_gc100(event): + """Stuff to do before stopping.""" + gc_device.quit() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, cleanup_gc100) + + hass.data[DATA_GC100] = GC100Device(hass, gc_device) + + return True + + +class GC100Device: + """The GC100 component.""" + + def __init__(self, hass, gc_device): + """Init a gc100 device.""" + self.hass = hass + self.gc_device = gc_device + + def read_sensor(self, port_addr, callback): + """Read a value from a digital input.""" + self.gc_device.read_sensor(port_addr, callback) + + def write_switch(self, port_addr, state, callback): + """Write a value to a relay.""" + self.gc_device.write_switch(port_addr, state, callback) + + def subscribe(self, port_addr, callback): + """Add detection for RISING and FALLING events.""" + self.gc_device.subscribe_notify(port_addr, callback) diff --git a/homeassistant/components/gc100/binary_sensor.py b/homeassistant/components/gc100/binary_sensor.py new file mode 100644 index 0000000000000..a2f8ba4a0a26c --- /dev/null +++ b/homeassistant/components/gc100/binary_sensor.py @@ -0,0 +1,59 @@ +"""Support for binary sensor using GC100.""" +import voluptuous as vol + +from homeassistant.components.binary_sensor import PLATFORM_SCHEMA, BinarySensorDevice +from homeassistant.const import DEVICE_DEFAULT_NAME +import homeassistant.helpers.config_validation as cv + +from . import CONF_PORTS, DATA_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/gc100/manifest.json b/homeassistant/components/gc100/manifest.json new file mode 100644 index 0000000000000..e566643a4f8d8 --- /dev/null +++ b/homeassistant/components/gc100/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "gc100", + "name": "Global Caché GC-100", + "documentation": "https://www.home-assistant.io/integrations/gc100", + "requirements": ["python-gc100==1.0.3a"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/gc100/switch.py b/homeassistant/components/gc100/switch.py new file mode 100644 index 0000000000000..6be42e35deb6a --- /dev/null +++ b/homeassistant/components/gc100/switch.py @@ -0,0 +1,63 @@ +"""Support for switches using GC100.""" +import voluptuous as vol + +from homeassistant.components.switch import PLATFORM_SCHEMA +from homeassistant.const import DEVICE_DEFAULT_NAME +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import ToggleEntity + +from . import CONF_PORTS, DATA_GC100 + +_SWITCH_SCHEMA = vol.Schema({cv.string: cv.string}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + {vol.Required(CONF_PORTS): vol.All(cv.ensure_list, [_SWITCH_SCHEMA])} +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the GC100 devices.""" + switches = [] + ports = config.get(CONF_PORTS) + for port in ports: + for port_addr, port_name in port.items(): + switches.append(GC100Switch(port_name, port_addr, hass.data[DATA_GC100])) + add_entities(switches, True) + + +class GC100Switch(ToggleEntity): + """Represent a switch/relay from GC100.""" + + def __init__(self, name, port_addr, gc100): + """Initialize the GC100 switch.""" + self._name = name or DEVICE_DEFAULT_NAME + self._port_addr = port_addr + self._gc100 = gc100 + self._state = None + + @property + def name(self): + """Return the name of the switch.""" + return self._name + + @property + def is_on(self): + """Return the state of the entity.""" + return self._state + + def turn_on(self, **kwargs): + """Turn the device on.""" + self._gc100.write_switch(self._port_addr, 1, self.set_state) + + def turn_off(self, **kwargs): + """Turn the device off.""" + self._gc100.write_switch(self._port_addr, 0, self.set_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/gearbest/__init__.py b/homeassistant/components/gearbest/__init__.py new file mode 100644 index 0000000000000..c97d94692963e --- /dev/null +++ b/homeassistant/components/gearbest/__init__.py @@ -0,0 +1 @@ +"""The gearbest component.""" diff --git a/homeassistant/components/gearbest/manifest.json b/homeassistant/components/gearbest/manifest.json new file mode 100644 index 0000000000000..03f2b83cf3a34 --- /dev/null +++ b/homeassistant/components/gearbest/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "gearbest", + "name": "Gearbest", + "documentation": "https://www.home-assistant.io/integrations/gearbest", + "requirements": ["gearbest_parser==1.0.7"], + "dependencies": [], + "codeowners": ["@HerrHofrat"] +} diff --git a/homeassistant/components/gearbest/sensor.py b/homeassistant/components/gearbest/sensor.py new file mode 100644 index 0000000000000..b9b2a35b89d83 --- /dev/null +++ b/homeassistant/components/gearbest/sensor.py @@ -0,0 +1,123 @@ +"""Parse prices of an item from gearbest.""" +from datetime import timedelta +import logging + +from gearbest_parser import CurrencyConverter, GearbestParser +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_CURRENCY, CONF_ID, CONF_NAME, CONF_URL +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import track_time_interval +from homeassistant.util import Throttle + +_LOGGER = logging.getLogger(__name__) + +CONF_ITEMS = "items" + +ICON = "mdi:coin" +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=2 * 60 * 60) # 2h +MIN_TIME_BETWEEN_CURRENCY_UPDATES = timedelta(seconds=12 * 60 * 60) # 12h + + +_ITEM_SCHEMA = vol.All( + vol.Schema( + { + vol.Exclusive(CONF_URL, "XOR"): cv.string, + vol.Exclusive(CONF_ID, "XOR"): cv.string, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_CURRENCY): cv.string, + } + ), + cv.has_at_least_one_key(CONF_URL, CONF_ID), +) + +_ITEMS_SCHEMA = vol.Schema([_ITEM_SCHEMA]) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + {vol.Required(CONF_ITEMS): _ITEMS_SCHEMA, vol.Required(CONF_CURRENCY): cv.string} +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Gearbest sensor.""" + + currency = config.get(CONF_CURRENCY) + + sensors = [] + items = config.get(CONF_ITEMS) + + converter = CurrencyConverter() + converter.update() + + for item in items: + try: + sensors.append(GearbestSensor(converter, item, currency)) + except ValueError as exc: + _LOGGER.error(exc) + + def currency_update(event_time): + """Update currency list.""" + converter.update() + + track_time_interval(hass, currency_update, MIN_TIME_BETWEEN_CURRENCY_UPDATES) + + add_entities(sensors, True) + + +class GearbestSensor(Entity): + """Implementation of the sensor.""" + + def __init__(self, converter, item, currency): + """Initialize the sensor.""" + + self._name = item.get(CONF_NAME) + self._parser = GearbestParser() + self._parser.set_currency_converter(converter) + self._item = self._parser.load( + item.get(CONF_ID), item.get(CONF_URL), item.get(CONF_CURRENCY, currency) + ) + if self._item is None: + raise ValueError("id and url could not be resolved") + + @property + def name(self): + """Return the name of the item.""" + return self._name if self._name is not None else self._item.name + + @property + def icon(self): + """Return the icon for the frontend.""" + return ICON + + @property + def state(self): + """Return the price of the selected product.""" + return self._item.price + + @property + def unit_of_measurement(self): + """Return the currency.""" + return self._item.currency + + @property + def entity_picture(self): + """Return the image.""" + return self._item.image + + @property + def device_state_attributes(self): + """Return the state attributes.""" + attrs = { + "name": self._item.name, + "description": self._item.description, + "currency": self._item.currency, + "url": self._item.url, + } + return attrs + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest price from gearbest and updates the state.""" + self._item.update() diff --git a/homeassistant/components/geizhals/__init__.py b/homeassistant/components/geizhals/__init__.py new file mode 100644 index 0000000000000..28b1d62307358 --- /dev/null +++ b/homeassistant/components/geizhals/__init__.py @@ -0,0 +1 @@ +"""The geizhals component.""" diff --git a/homeassistant/components/geizhals/manifest.json b/homeassistant/components/geizhals/manifest.json new file mode 100644 index 0000000000000..12ff420982071 --- /dev/null +++ b/homeassistant/components/geizhals/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "geizhals", + "name": "Geizhals", + "documentation": "https://www.home-assistant.io/integrations/geizhals", + "requirements": ["geizhals==0.0.9"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/geizhals/sensor.py b/homeassistant/components/geizhals/sensor.py new file mode 100644 index 0000000000000..9d5605cc40454 --- /dev/null +++ b/homeassistant/components/geizhals/sensor.py @@ -0,0 +1,97 @@ +"""Parse prices of a device from geizhals.""" +from datetime import timedelta +import logging + +from geizhals import Device, Geizhals +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 +from homeassistant.util import Throttle + +_LOGGER = logging.getLogger(__name__) + +CONF_DESCRIPTION = "description" +CONF_PRODUCT_ID = "product_id" +CONF_LOCALE = "locale" + +ICON = "mdi:coin" + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=120) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_PRODUCT_ID): cv.positive_int, + vol.Optional(CONF_DESCRIPTION, default="Price"): cv.string, + vol.Optional(CONF_LOCALE, default="DE"): vol.In(["AT", "EU", "DE", "UK", "PL"]), + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Geizwatch sensor.""" + name = config.get(CONF_NAME) + description = config.get(CONF_DESCRIPTION) + product_id = config.get(CONF_PRODUCT_ID) + domain = config.get(CONF_LOCALE) + + add_entities([Geizwatch(name, description, product_id, domain)], True) + + +class Geizwatch(Entity): + """Implementation of Geizwatch.""" + + def __init__(self, name, description, product_id, domain): + """Initialize the sensor.""" + + # internal + self._name = name + self._geizhals = Geizhals(product_id, domain) + self._device = Device() + + # external + self.description = description + self.product_id = product_id + + @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 best price of the selected product.""" + if not self._device.prices: + return None + + return self._device.prices[0] + + @property + def device_state_attributes(self): + """Return the state attributes.""" + while len(self._device.prices) < 4: + self._device.prices.append("None") + attrs = { + "device_name": self._device.name, + "description": self.description, + "unit_of_measurement": self._device.price_currency, + "product_id": self.product_id, + "price1": self._device.prices[0], + "price2": self._device.prices[1], + "price3": self._device.prices[2], + "price4": self._device.prices[3], + } + return attrs + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest price from geizhals and updates the state.""" + self._device = self._geizhals.parse() diff --git a/homeassistant/components/generic/__init__.py b/homeassistant/components/generic/__init__.py new file mode 100644 index 0000000000000..28f79fc91e626 --- /dev/null +++ b/homeassistant/components/generic/__init__.py @@ -0,0 +1 @@ +"""The generic component.""" diff --git a/homeassistant/components/generic/camera.py b/homeassistant/components/generic/camera.py new file mode 100644 index 0000000000000..3d39d75ff4a49 --- /dev/null +++ b/homeassistant/components/generic/camera.py @@ -0,0 +1,163 @@ +"""Support for IP Cameras.""" +import asyncio +import logging + +import aiohttp +import async_timeout +import requests +from requests.auth import HTTPDigestAuth +import voluptuous as vol + +from homeassistant.components.camera import ( + DEFAULT_CONTENT_TYPE, + PLATFORM_SCHEMA, + SUPPORT_STREAM, + Camera, +) +from homeassistant.const import ( + CONF_AUTHENTICATION, + CONF_NAME, + CONF_PASSWORD, + CONF_USERNAME, + CONF_VERIFY_SSL, + HTTP_BASIC_AUTHENTICATION, + HTTP_DIGEST_AUTHENTICATION, +) +from homeassistant.exceptions import TemplateError +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +_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_STREAM_SOURCE = "stream_source" +CONF_FRAMERATE = "framerate" + +DEFAULT_NAME = "Generic Camera" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_STILL_IMAGE_URL): cv.template, + vol.Optional(CONF_STREAM_SOURCE, default=None): vol.Any(None, cv.string), + 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, + } +) + + +async 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._stream_source = device_info[CONF_STREAM_SOURCE] + 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._supported_features = SUPPORT_STREAM if self._stream_source else 0 + 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 supported_features(self): + """Return supported features for this camera.""" + return self._supported_features + + @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 asyncio.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.""" + 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 = await 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): + response = await websession.get(url, auth=self._auth) + self._last_image = await response.read() + except asyncio.TimeoutError: + _LOGGER.error("Timeout getting image from: %s", self._name) + 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 + + async def stream_source(self): + """Return the source of the stream.""" + return self._stream_source diff --git a/homeassistant/components/generic/manifest.json b/homeassistant/components/generic/manifest.json new file mode 100644 index 0000000000000..9d59d5b991933 --- /dev/null +++ b/homeassistant/components/generic/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "generic", + "name": "Generic", + "documentation": "https://www.home-assistant.io/integrations/generic", + "requirements": [], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/generic_thermostat/__init__.py b/homeassistant/components/generic_thermostat/__init__.py new file mode 100644 index 0000000000000..d0bc392e4f4f2 --- /dev/null +++ b/homeassistant/components/generic_thermostat/__init__.py @@ -0,0 +1 @@ +"""The generic_thermostat component.""" diff --git a/homeassistant/components/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py new file mode 100644 index 0000000000000..58514934fc776 --- /dev/null +++ b/homeassistant/components/generic_thermostat/climate.py @@ -0,0 +1,463 @@ +"""Adds support for generic thermostat units.""" +import asyncio +import logging + +import voluptuous as vol + +from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateDevice +from homeassistant.components.climate.const import ( + ATTR_PRESET_MODE, + CURRENT_HVAC_COOL, + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, + CURRENT_HVAC_OFF, + HVAC_MODE_COOL, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + PRESET_AWAY, + PRESET_NONE, + SUPPORT_PRESET_MODE, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_TEMPERATURE, + CONF_NAME, + EVENT_HOMEASSISTANT_START, + PRECISION_HALVES, + PRECISION_TENTHS, + PRECISION_WHOLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_ON, + STATE_UNKNOWN, +) +from homeassistant.core import DOMAIN as HA_DOMAIN, callback +from homeassistant.helpers import condition +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import ( + async_track_state_change, + async_track_time_interval, +) +from homeassistant.helpers.restore_state import RestoreEntity + +_LOGGER = logging.getLogger(__name__) + +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_HVAC_MODE = "initial_hvac_mode" +CONF_AWAY_TEMP = "away_temp" +CONF_PRECISION = "precision" +SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE + +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_HVAC_MODE): vol.In( + [HVAC_MODE_COOL, HVAC_MODE_HEAT, HVAC_MODE_OFF] + ), + vol.Optional(CONF_AWAY_TEMP): vol.Coerce(float), + vol.Optional(CONF_PRECISION): vol.In( + [PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE] + ), + } +) + + +async 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_hvac_mode = config.get(CONF_INITIAL_HVAC_MODE) + away_temp = config.get(CONF_AWAY_TEMP) + precision = config.get(CONF_PRECISION) + unit = hass.config.units.temperature_unit + + async_add_entities( + [ + GenericThermostat( + 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_hvac_mode, + away_temp, + precision, + unit, + ) + ] + ) + + +class GenericThermostat(ClimateDevice, RestoreEntity): + """Representation of a Generic Thermostat device.""" + + def __init__( + self, + 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_hvac_mode, + away_temp, + precision, + unit, + ): + """Initialize the thermostat.""" + self._name = name + self.heater_entity_id = heater_entity_id + self.sensor_entity_id = sensor_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._hvac_mode = initial_hvac_mode + self._saved_target_temp = target_temp or away_temp + self._temp_precision = precision + if self.ac_mode: + self._hvac_list = [HVAC_MODE_COOL, HVAC_MODE_OFF] + else: + self._hvac_list = [HVAC_MODE_HEAT, HVAC_MODE_OFF] + 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 = unit + self._support_flags = SUPPORT_FLAGS + if away_temp: + self._support_flags = SUPPORT_FLAGS | SUPPORT_PRESET_MODE + self._away_temp = away_temp + self._is_away = False + + async def async_added_to_hass(self): + """Run when entity about to be added.""" + await super().async_added_to_hass() + + # Add listener + async_track_state_change( + self.hass, self.sensor_entity_id, self._async_sensor_changed + ) + async_track_state_change( + self.hass, self.heater_entity_id, self._async_switch_changed + ) + + if self._keep_alive: + async_track_time_interval( + self.hass, self._async_control_heating, self._keep_alive + ) + + @callback + def _async_startup(event): + """Init on startup.""" + sensor_state = self.hass.states.get(self.sensor_entity_id) + if sensor_state and sensor_state.state != STATE_UNKNOWN: + self._async_update_temp(sensor_state) + + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _async_startup) + + # Check If we have an old state + old_state = await self.async_get_last_state() + 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_PRESET_MODE) == PRESET_AWAY: + self._is_away = True + if not self._hvac_mode and old_state.state: + self._hvac_mode = old_state.state + + 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 + ) + + # Set default state to off + if not self._hvac_mode: + self._hvac_mode = HVAC_MODE_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 precision(self): + """Return the precision of the system.""" + if self._temp_precision is not None: + return self._temp_precision + return super().precision + + @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 hvac_mode(self): + """Return current operation.""" + return self._hvac_mode + + @property + def hvac_action(self): + """Return the current running hvac operation if supported. + + Need to be one of CURRENT_HVAC_*. + """ + if self._hvac_mode == HVAC_MODE_OFF: + return CURRENT_HVAC_OFF + if not self._is_device_active: + return CURRENT_HVAC_IDLE + if self.ac_mode: + return CURRENT_HVAC_COOL + return CURRENT_HVAC_HEAT + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._target_temp + + @property + def hvac_modes(self): + """List of available operation modes.""" + return self._hvac_list + + @property + def preset_mode(self): + """Return the current preset mode, e.g., home, away, temp.""" + return PRESET_AWAY if self._is_away else PRESET_NONE + + @property + def preset_modes(self): + """Return a list of available preset modes or PRESET_NONE if _away_temp is undefined.""" + return [PRESET_NONE, PRESET_AWAY] if self._away_temp else PRESET_NONE + + async def async_set_hvac_mode(self, hvac_mode): + """Set hvac mode.""" + if hvac_mode == HVAC_MODE_HEAT: + self._hvac_mode = HVAC_MODE_HEAT + await self._async_control_heating(force=True) + elif hvac_mode == HVAC_MODE_COOL: + self._hvac_mode = HVAC_MODE_COOL + await self._async_control_heating(force=True) + elif hvac_mode == HVAC_MODE_OFF: + self._hvac_mode = HVAC_MODE_OFF + if self._is_device_active: + await self._async_heater_turn_off() + else: + _LOGGER.error("Unrecognized hvac mode: %s", hvac_mode) + return + # Ensure we update the current operation after changing the mode + self.schedule_update_ha_state() + + 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(force=True) + 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, force=False): + """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 self._hvac_mode == HVAC_MODE_OFF: + return + + if not force and time is None: + # If the `force` argument is True, we + # ignore `min_cycle_duration`. + # If the `time` argument is not none, we were invoked for + # keep-alive purposes, and `min_cycle_duration` is irrelevant. + if self.min_cycle_duration: + if self._is_device_active: + current_state = STATE_ON + else: + current_state = HVAC_MODE_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) + + async def async_set_preset_mode(self, preset_mode: str): + """Set new preset mode. + + This method must be run in the event loop and returns a coroutine. + """ + if preset_mode == PRESET_AWAY and not self._is_away: + self._is_away = True + self._saved_target_temp = self._target_temp + self._target_temp = self._away_temp + await self._async_control_heating(force=True) + elif preset_mode == PRESET_NONE and self._is_away: + self._is_away = False + self._target_temp = self._saved_target_temp + await self._async_control_heating(force=True) + + await self.async_update_ha_state() diff --git a/homeassistant/components/generic_thermostat/manifest.json b/homeassistant/components/generic_thermostat/manifest.json new file mode 100644 index 0000000000000..c601c1297d550 --- /dev/null +++ b/homeassistant/components/generic_thermostat/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "generic_thermostat", + "name": "Generic Thermostat", + "documentation": "https://www.home-assistant.io/integrations/generic_thermostat", + "requirements": [], + "dependencies": ["sensor", "switch"], + "codeowners": [] +} diff --git a/homeassistant/components/generic_thermostat/services.yaml b/homeassistant/components/generic_thermostat/services.yaml new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/homeassistant/components/geniushub/__init__.py b/homeassistant/components/geniushub/__init__.py new file mode 100644 index 0000000000000..977656149c5a2 --- /dev/null +++ b/homeassistant/components/geniushub/__init__.py @@ -0,0 +1,273 @@ +"""Support for a Genius Hub system.""" +from datetime import timedelta +import logging +from typing import Any, Dict, Optional + +import aiohttp +from geniushubclient import GeniusHub +import voluptuous as vol + +from homeassistant.const import ( + ATTR_TEMPERATURE, + CONF_HOST, + CONF_MAC, + CONF_PASSWORD, + CONF_TOKEN, + CONF_USERNAME, + TEMP_CELSIUS, +) +from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession +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 +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.typing import ConfigType, HomeAssistantType +import homeassistant.util.dt as dt_util + +ATTR_DURATION = "duration" + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "geniushub" + +# temperature is repeated here, as it gives access to high-precision temps +GH_ZONE_ATTRS = ["mode", "temperature", "type", "occupied", "override"] +GH_DEVICE_ATTRS = { + "luminance": "luminance", + "measuredTemperature": "measured_temperature", + "occupancyTrigger": "occupancy_trigger", + "setback": "setback", + "setTemperature": "set_temperature", + "wakeupInterval": "wakeup_interval", +} + +SCAN_INTERVAL = timedelta(seconds=60) + +MAC_ADDRESS_REGEXP = r"^([0-9A-F]{2}:){5}([0-9A-F]{2})$" + +V1_API_SCHEMA = vol.Schema( + { + vol.Required(CONF_TOKEN): cv.string, + vol.Required(CONF_MAC): vol.Match(MAC_ADDRESS_REGEXP), + } +) +V3_API_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_MAC): vol.Match(MAC_ADDRESS_REGEXP), + } +) +CONFIG_SCHEMA = vol.Schema( + {DOMAIN: vol.Any(V3_API_SCHEMA, V1_API_SCHEMA)}, extra=vol.ALLOW_EXTRA +) + + +async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: + """Create a Genius Hub system.""" + hass.data[DOMAIN] = {} + + kwargs = dict(config[DOMAIN]) + if CONF_HOST in kwargs: + args = (kwargs.pop(CONF_HOST),) + else: + args = (kwargs.pop(CONF_TOKEN),) + hub_uid = kwargs.pop(CONF_MAC, None) + + client = GeniusHub(*args, **kwargs, session=async_get_clientsession(hass)) + + broker = hass.data[DOMAIN]["broker"] = GeniusBroker(hass, client, hub_uid) + + try: + await client.update() + except aiohttp.ClientResponseError as err: + _LOGGER.error("Setup failed, check your configuration, %s", err) + return False + broker.make_debug_log_entries() + + async_track_time_interval(hass, broker.async_update, SCAN_INTERVAL) + + for platform in ["climate", "water_heater", "sensor", "binary_sensor", "switch"]: + hass.async_create_task(async_load_platform(hass, platform, DOMAIN, {}, config)) + + return True + + +class GeniusBroker: + """Container for geniushub client and data.""" + + def __init__(self, hass, client, hub_uid) -> None: + """Initialize the geniushub client.""" + self.hass = hass + self.client = client + self._hub_uid = hub_uid + + @property + def hub_uid(self) -> int: + """Return the Hub UID (MAC address).""" + # pylint: disable=no-member + return self._hub_uid if self._hub_uid is not None else self.client.uid + + async def async_update(self, now, **kwargs) -> None: + """Update the geniushub client's data.""" + try: + await self.client.update() + except aiohttp.ClientResponseError as err: + _LOGGER.warning("Update failed, message is: %s", err) + return + self.make_debug_log_entries() + + async_dispatcher_send(self.hass, DOMAIN) + + def make_debug_log_entries(self) -> None: + """Make any useful debug log entries.""" + # pylint: disable=protected-access + _LOGGER.debug( + "Raw JSON: \n\nclient._zones = %s \n\nclient._devices = %s", + self.client._zones, + self.client._devices, + ) + + +class GeniusEntity(Entity): + """Base for all Genius Hub entities.""" + + def __init__(self) -> None: + """Initialize the entity.""" + self._unique_id = self._name = None + + async def async_added_to_hass(self) -> None: + """Set up a listener when this entity is added to HA.""" + async_dispatcher_connect(self.hass, DOMAIN, self._refresh) + + @callback + def _refresh(self) -> None: + self.async_schedule_update_ha_state(force_refresh=True) + + @property + def unique_id(self) -> Optional[str]: + """Return a unique ID.""" + return self._unique_id + + @property + def name(self) -> str: + """Return the name of the geniushub entity.""" + return self._name + + @property + def should_poll(self) -> bool: + """Return False as geniushub entities should not be polled.""" + return False + + +class GeniusDevice(GeniusEntity): + """Base for all Genius Hub devices.""" + + def __init__(self, broker, device) -> None: + """Initialize the Device.""" + super().__init__() + + self._device = device + self._unique_id = f"{broker.hub_uid}_device_{device.id}" + + self._last_comms = self._state_attr = None + + @property + def device_state_attributes(self) -> Dict[str, Any]: + """Return the device state attributes.""" + + attrs = {} + attrs["assigned_zone"] = self._device.data["assignedZones"][0]["name"] + if self._last_comms: + attrs["last_comms"] = self._last_comms.isoformat() + + state = dict(self._device.data["state"]) + if "_state" in self._device.data: # only for v3 API + state.update(self._device.data["_state"]) + + attrs["state"] = { + GH_DEVICE_ATTRS[k]: v for k, v in state.items() if k in GH_DEVICE_ATTRS + } + + return attrs + + async def async_update(self) -> None: + """Update an entity's state data.""" + if "_state" in self._device.data: # only for v3 API + self._last_comms = dt_util.utc_from_timestamp( + self._device.data["_state"]["lastComms"] + ) + + +class GeniusZone(GeniusEntity): + """Base for all Genius Hub zones.""" + + def __init__(self, broker, zone) -> None: + """Initialize the Zone.""" + super().__init__() + + self._zone = zone + self._unique_id = f"{broker.hub_uid}_zone_{zone.id}" + + @property + def name(self) -> str: + """Return the name of the climate device.""" + return self._zone.name + + @property + def device_state_attributes(self) -> Dict[str, Any]: + """Return the device state attributes.""" + status = {k: v for k, v in self._zone.data.items() if k in GH_ZONE_ATTRS} + return {"status": status} + + +class GeniusHeatingZone(GeniusZone): + """Base for Genius Heating Zones.""" + + def __init__(self, broker, zone) -> None: + """Initialize the Zone.""" + super().__init__(broker, zone) + + self._max_temp = self._min_temp = self._supported_features = None + + @property + def current_temperature(self) -> Optional[float]: + """Return the current temperature.""" + return self._zone.data.get("temperature") + + @property + def target_temperature(self) -> float: + """Return the temperature we try to reach.""" + return self._zone.data["setpoint"] + + @property + def min_temp(self) -> float: + """Return max valid temperature that can be set.""" + return self._min_temp + + @property + def max_temp(self) -> float: + """Return max valid temperature that can be set.""" + return self._max_temp + + @property + def temperature_unit(self) -> str: + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def supported_features(self) -> int: + """Return the bitmask of supported features.""" + return self._supported_features + + async def async_set_temperature(self, **kwargs) -> None: + """Set a new target temperature for this zone.""" + await self._zone.set_override( + kwargs[ATTR_TEMPERATURE], kwargs.get(ATTR_DURATION, 3600) + ) diff --git a/homeassistant/components/geniushub/binary_sensor.py b/homeassistant/components/geniushub/binary_sensor.py new file mode 100644 index 0000000000000..33458d049a284 --- /dev/null +++ b/homeassistant/components/geniushub/binary_sensor.py @@ -0,0 +1,45 @@ +"""Support for Genius Hub binary_sensor devices.""" +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.helpers.typing import ConfigType, HomeAssistantType + +from . import DOMAIN, GeniusDevice + +GH_STATE_ATTR = "outputOnOff" + + +async def async_setup_platform( + hass: HomeAssistantType, config: ConfigType, async_add_entities, discovery_info=None +) -> None: + """Set up the Genius Hub sensor entities.""" + if discovery_info is None: + return + + broker = hass.data[DOMAIN]["broker"] + + switches = [ + GeniusBinarySensor(broker, d, GH_STATE_ATTR) + for d in broker.client.device_objs + if GH_STATE_ATTR in d.data["state"] + ] + + async_add_entities(switches, update_before_add=True) + + +class GeniusBinarySensor(GeniusDevice, BinarySensorDevice): + """Representation of a Genius Hub binary_sensor.""" + + def __init__(self, broker, device, state_attr) -> None: + """Initialize the binary sensor.""" + super().__init__(broker, device) + + self._state_attr = state_attr + + if device.type[:21] == "Dual Channel Receiver": + self._name = f"{device.type[:21]} {device.id}" + else: + self._name = f"{device.type} {device.id}" + + @property + def is_on(self) -> bool: + """Return the status of the sensor.""" + return self._device.data["state"][self._state_attr] diff --git a/homeassistant/components/geniushub/climate.py b/homeassistant/components/geniushub/climate.py new file mode 100644 index 0000000000000..2221b8706c855 --- /dev/null +++ b/homeassistant/components/geniushub/climate.py @@ -0,0 +1,103 @@ +"""Support for Genius Hub climate devices.""" +from typing import List, Optional + +from homeassistant.components.climate import ClimateDevice +from homeassistant.components.climate.const import ( + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, + CURRENT_HVAC_OFF, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + PRESET_ACTIVITY, + PRESET_BOOST, + SUPPORT_PRESET_MODE, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.helpers.typing import ConfigType, HomeAssistantType + +from . import DOMAIN, GeniusHeatingZone + +# GeniusHub Zones support: Off, Timer, Override/Boost, Footprint & Linked modes +HA_HVAC_TO_GH = {HVAC_MODE_OFF: "off", HVAC_MODE_HEAT: "timer"} +GH_HVAC_TO_HA = {v: k for k, v in HA_HVAC_TO_GH.items()} + +HA_PRESET_TO_GH = {PRESET_ACTIVITY: "footprint", PRESET_BOOST: "override"} +GH_PRESET_TO_HA = {v: k for k, v in HA_PRESET_TO_GH.items()} + +GH_ZONES = ["radiator", "wet underfloor"] + + +async def async_setup_platform( + hass: HomeAssistantType, config: ConfigType, async_add_entities, discovery_info=None +) -> None: + """Set up the Genius Hub climate entities.""" + if discovery_info is None: + return + + broker = hass.data[DOMAIN]["broker"] + + async_add_entities( + [ + GeniusClimateZone(broker, z) + for z in broker.client.zone_objs + if z.data["type"] in GH_ZONES + ] + ) + + +class GeniusClimateZone(GeniusHeatingZone, ClimateDevice): + """Representation of a Genius Hub climate device.""" + + def __init__(self, broker, zone) -> None: + """Initialize the climate device.""" + super().__init__(broker, zone) + + self._max_temp = 28.0 + self._min_temp = 4.0 + self._supported_features = SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE + + @property + def icon(self) -> str: + """Return the icon to use in the frontend UI.""" + return "mdi:radiator" + + @property + def hvac_mode(self) -> str: + """Return hvac operation ie. heat, cool mode.""" + return GH_HVAC_TO_HA.get(self._zone.data["mode"], HVAC_MODE_HEAT) + + @property + def hvac_modes(self) -> List[str]: + """Return the list of available hvac operation modes.""" + return list(HA_HVAC_TO_GH) + + @property + def hvac_action(self) -> Optional[str]: + """Return the current running hvac operation if supported.""" + if "_state" in self._zone.data: # only for v3 API + if not self._zone.data["_state"].get("bIsActive"): + return CURRENT_HVAC_OFF + if self._zone.data["_state"].get("bOutRequestHeat"): + return CURRENT_HVAC_HEAT + return CURRENT_HVAC_IDLE + return None + + @property + def preset_mode(self) -> Optional[str]: + """Return the current preset mode, e.g., home, away, temp.""" + return GH_PRESET_TO_HA.get(self._zone.data["mode"]) + + @property + def preset_modes(self) -> Optional[List[str]]: + """Return a list of available preset modes.""" + if "occupied" in self._zone.data: # if has a movement sensor + return [PRESET_ACTIVITY, PRESET_BOOST] + return [PRESET_BOOST] + + async def async_set_hvac_mode(self, hvac_mode: str) -> None: + """Set a new hvac mode.""" + await self._zone.set_mode(HA_HVAC_TO_GH.get(hvac_mode)) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set a new preset mode.""" + await self._zone.set_mode(HA_PRESET_TO_GH.get(preset_mode, "timer")) diff --git a/homeassistant/components/geniushub/manifest.json b/homeassistant/components/geniushub/manifest.json new file mode 100644 index 0000000000000..ab9349d147202 --- /dev/null +++ b/homeassistant/components/geniushub/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "geniushub", + "name": "Genius Hub", + "documentation": "https://www.home-assistant.io/integrations/geniushub", + "requirements": ["geniushub-client==0.6.30"], + "dependencies": [], + "codeowners": ["@zxdavb"] +} diff --git a/homeassistant/components/geniushub/sensor.py b/homeassistant/components/geniushub/sensor.py new file mode 100644 index 0000000000000..bd73c700e6547 --- /dev/null +++ b/homeassistant/components/geniushub/sensor.py @@ -0,0 +1,117 @@ +"""Support for Genius Hub sensor devices.""" +from datetime import timedelta +from typing import Any, Dict + +from homeassistant.const import DEVICE_CLASS_BATTERY +from homeassistant.helpers.typing import ConfigType, HomeAssistantType +import homeassistant.util.dt as dt_util + +from . import DOMAIN, GeniusDevice, GeniusEntity + +GH_STATE_ATTR = "batteryLevel" + +GH_LEVEL_MAPPING = { + "error": "Errors", + "warning": "Warnings", + "information": "Information", +} + + +async def async_setup_platform( + hass: HomeAssistantType, config: ConfigType, async_add_entities, discovery_info=None +) -> None: + """Set up the Genius Hub sensor entities.""" + if discovery_info is None: + return + + broker = hass.data[DOMAIN]["broker"] + + sensors = [ + GeniusBattery(broker, d, GH_STATE_ATTR) + for d in broker.client.device_objs + if GH_STATE_ATTR in d.data["state"] + ] + issues = [GeniusIssue(broker, i) for i in list(GH_LEVEL_MAPPING)] + + async_add_entities(sensors + issues, update_before_add=True) + + +class GeniusBattery(GeniusDevice): + """Representation of a Genius Hub sensor.""" + + def __init__(self, broker, device, state_attr) -> None: + """Initialize the sensor.""" + super().__init__(broker, device) + + self._state_attr = state_attr + + self._name = f"{device.type} {device.id}" + + @property + def icon(self) -> str: + """Return the icon of the sensor.""" + if "_state" in self._device.data: # only for v3 API + interval = timedelta( + seconds=self._device.data["_state"].get("wakeupInterval", 30 * 60) + ) + if self._last_comms < dt_util.utcnow() - interval * 3: + return "mdi:battery-unknown" + + battery_level = self._device.data["state"][self._state_attr] + if battery_level == 255: + return "mdi:battery-unknown" + if battery_level < 40: + return "mdi:battery-alert" + + icon = "mdi:battery" + if battery_level <= 95: + icon += f"-{int(round(battery_level / 10 - 0.01)) * 10}" + + return icon + + @property + def device_class(self) -> str: + """Return the device class of the sensor.""" + return DEVICE_CLASS_BATTERY + + @property + def unit_of_measurement(self) -> str: + """Return the unit of measurement of the sensor.""" + return "%" + + @property + def state(self) -> str: + """Return the state of the sensor.""" + level = self._device.data["state"][self._state_attr] + return level if level != 255 else 0 + + +class GeniusIssue(GeniusEntity): + """Representation of a Genius Hub sensor.""" + + def __init__(self, broker, level) -> None: + """Initialize the sensor.""" + super().__init__() + + self._hub = broker.client + self._unique_id = f"{broker.hub_uid}_{GH_LEVEL_MAPPING[level]}" + + self._name = f"GeniusHub {GH_LEVEL_MAPPING[level]}" + self._level = level + self._issues = [] + + @property + def state(self) -> str: + """Return the number of issues.""" + return len(self._issues) + + @property + def device_state_attributes(self) -> Dict[str, Any]: + """Return the device state attributes.""" + return {f"{self._level}_list": self._issues} + + async def async_update(self) -> None: + """Process the sensor's state data.""" + self._issues = [ + i["description"] for i in self._hub.issues if i["level"] == self._level + ] diff --git a/homeassistant/components/geniushub/switch.py b/homeassistant/components/geniushub/switch.py new file mode 100644 index 0000000000000..b73c9a89041c0 --- /dev/null +++ b/homeassistant/components/geniushub/switch.py @@ -0,0 +1,55 @@ +"""Support for Genius Hub switch/outlet devices.""" +from homeassistant.components.switch import DEVICE_CLASS_OUTLET, SwitchDevice +from homeassistant.helpers.typing import ConfigType, HomeAssistantType + +from . import DOMAIN, GeniusZone + +ATTR_DURATION = "duration" + +GH_ON_OFF_ZONE = "on / off" + + +async def async_setup_platform( + hass: HomeAssistantType, config: ConfigType, async_add_entities, discovery_info=None +) -> None: + """Set up the Genius Hub switch entities.""" + if discovery_info is None: + return + + broker = hass.data[DOMAIN]["broker"] + + async_add_entities( + [ + GeniusSwitch(broker, z) + for z in broker.client.zone_objs + if z.data["type"] == GH_ON_OFF_ZONE + ] + ) + + +class GeniusSwitch(GeniusZone, SwitchDevice): + """Representation of a Genius Hub switch.""" + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return DEVICE_CLASS_OUTLET + + @property + def is_on(self) -> bool: + """Return the current state of the on/off zone. + + The zone is considered 'on' if & only if it is override/on (e.g. timer/on is 'off'). + """ + return self._zone.data["mode"] == "override" and self._zone.data["setpoint"] + + async def async_turn_off(self, **kwargs) -> None: + """Send the zone to Timer mode. + + The zone is deemed 'off' in this mode, although the plugs may actually be on. + """ + await self._zone.set_mode("timer") + + async def async_turn_on(self, **kwargs) -> None: + """Set the zone to override/on ({'setpoint': true}) for x seconds.""" + await self._zone.set_override(1, kwargs.get(ATTR_DURATION, 3600)) diff --git a/homeassistant/components/geniushub/water_heater.py b/homeassistant/components/geniushub/water_heater.py new file mode 100644 index 0000000000000..e7e3278eaf6c1 --- /dev/null +++ b/homeassistant/components/geniushub/water_heater.py @@ -0,0 +1,75 @@ +"""Support for Genius Hub water_heater devices.""" +from typing import List + +from homeassistant.components.water_heater import ( + SUPPORT_OPERATION_MODE, + SUPPORT_TARGET_TEMPERATURE, + WaterHeaterDevice, +) +from homeassistant.const import STATE_OFF +from homeassistant.helpers.typing import ConfigType, HomeAssistantType + +from . import DOMAIN, GeniusHeatingZone + +STATE_AUTO = "auto" +STATE_MANUAL = "manual" + +# Genius Hub HW zones support only Off, Override/Boost & Timer modes +HA_OPMODE_TO_GH = {STATE_OFF: "off", STATE_AUTO: "timer", STATE_MANUAL: "override"} +GH_STATE_TO_HA = { + "off": STATE_OFF, + "timer": STATE_AUTO, + "footprint": None, + "away": None, + "override": STATE_MANUAL, + "early": None, + "test": None, + "linked": None, + "other": None, +} + +GH_HEATERS = ["hot water temperature"] + + +async def async_setup_platform( + hass: HomeAssistantType, config: ConfigType, async_add_entities, discovery_info=None +) -> None: + """Set up the Genius Hub water_heater entities.""" + if discovery_info is None: + return + + broker = hass.data[DOMAIN]["broker"] + + async_add_entities( + [ + GeniusWaterHeater(broker, z) + for z in broker.client.zone_objs + if z.data["type"] in GH_HEATERS + ] + ) + + +class GeniusWaterHeater(GeniusHeatingZone, WaterHeaterDevice): + """Representation of a Genius Hub water_heater device.""" + + def __init__(self, broker, zone) -> None: + """Initialize the water_heater device.""" + super().__init__(broker, zone) + + self._max_temp = 80.0 + self._min_temp = 30.0 + self._supported_features = SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE + + @property + def operation_list(self) -> List[str]: + """Return the list of available operation modes.""" + return list(HA_OPMODE_TO_GH) + + @property + def current_operation(self) -> str: + """Return the current operation mode.""" + return GH_STATE_TO_HA[self._zone.data["mode"]] + + async def async_set_operation_mode(self, operation_mode) -> None: + """Set a new operation mode for this boiler.""" + await self._zone.set_mode(HA_OPMODE_TO_GH[operation_mode]) diff --git a/homeassistant/components/geo_json_events/__init__.py b/homeassistant/components/geo_json_events/__init__.py new file mode 100644 index 0000000000000..0bc612b6e8b8c --- /dev/null +++ b/homeassistant/components/geo_json_events/__init__.py @@ -0,0 +1 @@ +"""The geo_json_events component.""" diff --git a/homeassistant/components/geo_json_events/geo_location.py b/homeassistant/components/geo_json_events/geo_location.py new file mode 100644 index 0000000000000..2f881232495d0 --- /dev/null +++ b/homeassistant/components/geo_json_events/geo_location.py @@ -0,0 +1,212 @@ +"""Support for generic GeoJSON events.""" +from datetime import timedelta +import logging +from typing import Optional + +from geojson_client.generic_feed import GenericFeedManager +import voluptuous as vol + +from homeassistant.components.geo_location import PLATFORM_SCHEMA, GeolocationEvent +from homeassistant.const import ( + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_RADIUS, + CONF_SCAN_INTERVAL, + CONF_URL, + EVENT_HOMEASSISTANT_START, +) +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send +from homeassistant.helpers.event import track_time_interval + +_LOGGER = logging.getLogger(__name__) + +ATTR_EXTERNAL_ID = "external_id" + +DEFAULT_RADIUS_IN_KM = 20.0 +DEFAULT_UNIT_OF_MEASUREMENT = "km" + +SCAN_INTERVAL = timedelta(minutes=5) + +SIGNAL_DELETE_ENTITY = "geo_json_events_delete_{}" +SIGNAL_UPDATE_ENTITY = "geo_json_events_update_{}" + +SOURCE = "geo_json_events" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_URL): cv.string, + vol.Optional(CONF_LATITUDE): cv.latitude, + vol.Optional(CONF_LONGITUDE): cv.longitude, + vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS_IN_KM): vol.Coerce(float), + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the GeoJSON Events platform.""" + url = config[CONF_URL] + scan_interval = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) + coordinates = ( + config.get(CONF_LATITUDE, hass.config.latitude), + config.get(CONF_LONGITUDE, hass.config.longitude), + ) + radius_in_km = config[CONF_RADIUS] + # Initialize the entity manager. + feed = GeoJsonFeedEntityManager( + hass, add_entities, scan_interval, coordinates, url, radius_in_km + ) + + def start_feed_manager(event): + """Start feed manager.""" + feed.startup() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_feed_manager) + + +class GeoJsonFeedEntityManager: + """Feed Entity Manager for GeoJSON feeds.""" + + def __init__( + self, hass, add_entities, scan_interval, coordinates, url, radius_in_km + ): + """Initialize the GeoJSON Feed Manager.""" + + self._hass = hass + self._feed_manager = GenericFeedManager( + self._generate_entity, + self._update_entity, + self._remove_entity, + coordinates, + url, + filter_radius=radius_in_km, + ) + self._add_entities = add_entities + self._scan_interval = scan_interval + + def startup(self): + """Start up this manager.""" + self._feed_manager.update() + self._init_regular_updates() + + def _init_regular_updates(self): + """Schedule regular updates at the specified interval.""" + track_time_interval( + self._hass, lambda now: self._feed_manager.update(), self._scan_interval + ) + + def get_entry(self, external_id): + """Get feed entry by external id.""" + return self._feed_manager.feed_entries.get(external_id) + + def _generate_entity(self, external_id): + """Generate new entity.""" + new_entity = GeoJsonLocationEvent(self, external_id) + # Add new entities to HA. + self._add_entities([new_entity], True) + + def _update_entity(self, external_id): + """Update entity.""" + dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY.format(external_id)) + + def _remove_entity(self, external_id): + """Remove entity.""" + dispatcher_send(self._hass, SIGNAL_DELETE_ENTITY.format(external_id)) + + +class GeoJsonLocationEvent(GeolocationEvent): + """This represents an external event with GeoJSON data.""" + + def __init__(self, feed_manager, external_id): + """Initialize entity with data from feed entry.""" + self._feed_manager = feed_manager + self._external_id = external_id + self._name = None + self._distance = None + self._latitude = None + self._longitude = None + self._remove_signal_delete = None + self._remove_signal_update = None + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + self._remove_signal_delete = async_dispatcher_connect( + self.hass, + SIGNAL_DELETE_ENTITY.format(self._external_id), + self._delete_callback, + ) + self._remove_signal_update = async_dispatcher_connect( + self.hass, + SIGNAL_UPDATE_ENTITY.format(self._external_id), + self._update_callback, + ) + + @callback + def _delete_callback(self): + """Remove this entity.""" + self._remove_signal_delete() + self._remove_signal_update() + self.hass.async_create_task(self.async_remove()) + + @callback + def _update_callback(self): + """Call update method.""" + self.async_schedule_update_ha_state(True) + + @property + def should_poll(self): + """No polling needed for GeoJSON location events.""" + return False + + async def async_update(self): + """Update this entity from the data held in the feed manager.""" + _LOGGER.debug("Updating %s", self._external_id) + feed_entry = self._feed_manager.get_entry(self._external_id) + if feed_entry: + self._update_from_feed(feed_entry) + + def _update_from_feed(self, feed_entry): + """Update the internal state from the provided feed entry.""" + self._name = feed_entry.title + self._distance = feed_entry.distance_to_home + self._latitude = feed_entry.coordinates[0] + self._longitude = feed_entry.coordinates[1] + + @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 entity.""" + return self._name + + @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 DEFAULT_UNIT_OF_MEASUREMENT + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + attributes = {} + if self._external_id: + attributes[ATTR_EXTERNAL_ID] = self._external_id + return attributes diff --git a/homeassistant/components/geo_json_events/manifest.json b/homeassistant/components/geo_json_events/manifest.json new file mode 100644 index 0000000000000..bb1e8f942ad44 --- /dev/null +++ b/homeassistant/components/geo_json_events/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "geo_json_events", + "name": "GeoJSON", + "documentation": "https://www.home-assistant.io/integrations/geo_json_events", + "requirements": ["geojson_client==0.4"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/geo_location/__init__.py b/homeassistant/components/geo_location/__init__.py new file mode 100644 index 0000000000000..6142fa222095d --- /dev/null +++ b/homeassistant/components/geo_location/__init__.py @@ -0,0 +1,87 @@ +"""Support for Geolocation.""" +from datetime import timedelta +import logging +from typing import Optional + +from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE +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 + +# mypy: allow-untyped-defs, no-check-untyped-defs + +_LOGGER = logging.getLogger(__name__) + +ATTR_DISTANCE = "distance" +ATTR_SOURCE = "source" + +DOMAIN = "geo_location" + +ENTITY_ID_FORMAT = DOMAIN + ".{}" + +SCAN_INTERVAL = timedelta(seconds=60) + + +async def async_setup(hass, config): + """Set up the Geolocation component.""" + component = hass.data[DOMAIN] = EntityComponent( + _LOGGER, DOMAIN, hass, SCAN_INTERVAL + ) + await component.async_setup(config) + 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 GeolocationEvent(Entity): + """This represents an external event with an associated geolocation.""" + + @property + def state(self): + """Return the state of the sensor.""" + if self.distance is not None: + return round(self.distance, 1) + return None + + @property + def source(self) -> str: + """Return source value of this external event.""" + raise NotImplementedError + + @property + def distance(self) -> Optional[float]: + """Return distance value of this external event.""" + return None + + @property + def latitude(self) -> Optional[float]: + """Return latitude value of this external event.""" + return None + + @property + def longitude(self) -> Optional[float]: + """Return longitude value of this external event.""" + return None + + @property + def state_attributes(self): + """Return the state attributes of this external event.""" + data = {} + if self.latitude is not None: + data[ATTR_LATITUDE] = round(self.latitude, 5) + if self.longitude is not None: + data[ATTR_LONGITUDE] = round(self.longitude, 5) + if self.source is not None: + data[ATTR_SOURCE] = self.source + return data diff --git a/homeassistant/components/geo_location/manifest.json b/homeassistant/components/geo_location/manifest.json new file mode 100644 index 0000000000000..80067b2b5b6c2 --- /dev/null +++ b/homeassistant/components/geo_location/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "geo_location", + "name": "Geolocation", + "documentation": "https://www.home-assistant.io/integrations/geo_location", + "requirements": [], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/geo_rss_events/__init__.py b/homeassistant/components/geo_rss_events/__init__.py new file mode 100644 index 0000000000000..3a1a907296027 --- /dev/null +++ b/homeassistant/components/geo_rss_events/__init__.py @@ -0,0 +1 @@ +"""The geo_rss_events component.""" diff --git a/homeassistant/components/geo_rss_events/manifest.json b/homeassistant/components/geo_rss_events/manifest.json new file mode 100644 index 0000000000000..3390324646367 --- /dev/null +++ b/homeassistant/components/geo_rss_events/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "geo_rss_events", + "name": "GeoRSS", + "documentation": "https://www.home-assistant.io/integrations/geo_rss_events", + "requirements": ["georss_generic_client==0.3"], + "dependencies": [], + "codeowners": ["@exxamalte"] +} diff --git a/homeassistant/components/geo_rss_events/sensor.py b/homeassistant/components/geo_rss_events/sensor.py new file mode 100644 index 0000000000000..b8891cdef0d2b --- /dev/null +++ b/homeassistant/components/geo_rss_events/sensor.py @@ -0,0 +1,172 @@ +""" +Generic GeoRSS events service. + +Retrieves current events (typically incidents or alerts) in GeoRSS format, and +shows information on events filtered by distance to the HA instance's location +and grouped by category. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.geo_rss_events/ +""" +from datetime import timedelta +import logging + +from georss_client import UPDATE_OK, UPDATE_OK_NO_DATA +from georss_client.generic_feed import GenericFeed +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_NAME, + CONF_RADIUS, + CONF_UNIT_OF_MEASUREMENT, + CONF_URL, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +ATTR_CATEGORY = "category" +ATTR_DISTANCE = "distance" +ATTR_TITLE = "title" + +CONF_CATEGORIES = "categories" + +DEFAULT_ICON = "mdi:alert" +DEFAULT_NAME = "Event Service" +DEFAULT_RADIUS_IN_KM = 20.0 +DEFAULT_UNIT_OF_MEASUREMENT = "Events" + +DOMAIN = "geo_rss_events" + +SCAN_INTERVAL = timedelta(minutes=5) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_URL): cv.string, + vol.Optional(CONF_LATITUDE): cv.latitude, + vol.Optional(CONF_LONGITUDE): cv.longitude, + vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS_IN_KM): vol.Coerce(float), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_CATEGORIES, default=[]): vol.All(cv.ensure_list, [cv.string]), + vol.Optional( + CONF_UNIT_OF_MEASUREMENT, default=DEFAULT_UNIT_OF_MEASUREMENT + ): cv.string, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the GeoRSS component.""" + latitude = config.get(CONF_LATITUDE, hass.config.latitude) + longitude = config.get(CONF_LONGITUDE, hass.config.longitude) + url = config.get(CONF_URL) + radius_in_km = config.get(CONF_RADIUS) + name = config.get(CONF_NAME) + categories = config.get(CONF_CATEGORIES) + unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) + + _LOGGER.debug( + "latitude=%s, longitude=%s, url=%s, radius=%s", + latitude, + longitude, + url, + radius_in_km, + ) + + # Create all sensors based on categories. + devices = [] + if not categories: + device = GeoRssServiceSensor( + (latitude, longitude), url, radius_in_km, None, name, unit_of_measurement + ) + devices.append(device) + else: + for category in categories: + device = GeoRssServiceSensor( + (latitude, longitude), + url, + radius_in_km, + category, + name, + unit_of_measurement, + ) + devices.append(device) + add_entities(devices, True) + + +class GeoRssServiceSensor(Entity): + """Representation of a Sensor.""" + + def __init__( + self, coordinates, url, radius, category, service_name, unit_of_measurement + ): + """Initialize the sensor.""" + self._category = category + self._service_name = service_name + self._state = None + self._state_attributes = None + self._unit_of_measurement = unit_of_measurement + + self._feed = GenericFeed( + coordinates, + url, + filter_radius=radius, + filter_categories=None if not category else [category], + ) + + @property + def name(self): + """Return the name of the sensor.""" + return "{} {}".format( + self._service_name, "Any" if self._category is None else self._category + ) + + @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_of_measurement + + @property + def icon(self): + """Return the default icon to use in the frontend.""" + return DEFAULT_ICON + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._state_attributes + + def update(self): + """Update this sensor from the GeoRSS service.""" + + status, feed_entries = self._feed.update() + if status == UPDATE_OK: + _LOGGER.debug( + "Adding events to sensor %s: %s", self.entity_id, feed_entries + ) + self._state = len(feed_entries) + # And now compute the attributes from the filtered events. + matrix = {} + for entry in feed_entries: + matrix[entry.title] = f"{entry.distance_to_home:.0f}km" + self._state_attributes = matrix + elif status == UPDATE_OK_NO_DATA: + _LOGGER.debug("Update successful, but no data received from %s", self._feed) + # Don't change the state or state attributes. + else: + _LOGGER.warning( + "Update not successful, no data received from %s", self._feed + ) + # If no events were found due to an error then just set state to + # zero. + self._state = 0 + self._state_attributes = {} diff --git a/homeassistant/components/geofency/.translations/bg.json b/homeassistant/components/geofency/.translations/bg.json new file mode 100644 index 0000000000000..b9bfa2a3b41b3 --- /dev/null +++ b/homeassistant/components/geofency/.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 Geofency.", + "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 Geofency. \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 Geofency Webhook?", + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0432\u0430\u043d\u0435 \u043d\u0430 Geofency Webhook" + } + }, + "title": "Geofency Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/geofency/.translations/ca.json b/homeassistant/components/geofency/.translations/ca.json new file mode 100644 index 0000000000000..44377ce302141 --- /dev/null +++ b/homeassistant/components/geofency/.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 Geofency.", + "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 Geofency.\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 Geofency?", + "title": "Configuraci\u00f3 del Webhook de Geofency" + } + }, + "title": "Webhook de Geofency" + } +} \ No newline at end of file diff --git a/homeassistant/components/geofency/.translations/cs.json b/homeassistant/components/geofency/.translations/cs.json new file mode 100644 index 0000000000000..2fa1dfc9f4b33 --- /dev/null +++ b/homeassistant/components/geofency/.translations/cs.json @@ -0,0 +1,14 @@ +{ + "config": { + "create_entry": { + "default": "Chcete-li odes\u00edlat ud\u00e1losti do aplikace Home Assistant, mus\u00edte v aplikaci Geofency nastavit funkci webhook. \n\n Vypl\u0148te n\u00e1sleduj\u00edc\u00ed informace: \n\n - URL: `{webhook_url}` \n - Metoda: POST \n\n Dal\u0161\u00ed informace viz [dokumentace]({docs_url})." + }, + "step": { + "user": { + "description": "Opravdu chcete nastavit Geofency Webhook?", + "title": "Nastavit Geofency Webhook" + } + }, + "title": "Geofency Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/geofency/.translations/da.json b/homeassistant/components/geofency/.translations/da.json new file mode 100644 index 0000000000000..6e9443af5e898 --- /dev/null +++ b/homeassistant/components/geofency/.translations/da.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Din Home Assistant-instans skal v\u00e6re tilg\u00e6ngelig fra internettet for at modtage Geofency-meddelelser.", + "one_instance_allowed": "Det er kun n\u00f8dvendigt med en ops\u00e6tning" + }, + "create_entry": { + "default": "For at sende h\u00e6ndelser til Home Assistant skal du konfigurere webhook-funktionen i Geofency.\n\n Udfyld f\u00f8lgende oplysninger: \n\n - Webadresse: `{webhook_url}`\n - Metode: POST\n \nSe [dokumentationen]({docs_url}) for yderligere oplysninger." + }, + "step": { + "user": { + "description": "Er du sikker p\u00e5 at du vil konfigurere Geofency Webhook?", + "title": "Ops\u00e6tning af Geofency Webhook" + } + }, + "title": "Geofency Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/geofency/.translations/de.json b/homeassistant/components/geofency/.translations/de.json new file mode 100644 index 0000000000000..ad4722fa9fc70 --- /dev/null +++ b/homeassistant/components/geofency/.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 Geofency zu erhalten.", + "one_instance_allowed": "Es ist nur eine einzige Instanz erforderlich." + }, + "create_entry": { + "default": "Um Ereignisse an den Home Assistant zu senden, musst das Webhook Feature in Geofency 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\u00f6chtest du den Geofency Webhook wirklich einrichten?", + "title": "Richten Sie den Geofency Webhook ein" + } + }, + "title": "Geofency Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/geofency/.translations/en.json b/homeassistant/components/geofency/.translations/en.json new file mode 100644 index 0000000000000..27b6335c6f9b4 --- /dev/null +++ b/homeassistant/components/geofency/.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 Geofency.", + "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 Geofency.\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 Geofency Webhook?", + "title": "Set up the Geofency Webhook" + } + }, + "title": "Geofency Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/geofency/.translations/es-419.json b/homeassistant/components/geofency/.translations/es-419.json new file mode 100644 index 0000000000000..637a430a1f8a4 --- /dev/null +++ b/homeassistant/components/geofency/.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 Geofency.", + "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 Geofency. \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\u00e1s seguro de que quieres montar el Webhook de Geofency?", + "title": "Configurar el Webhook de Geofency" + } + }, + "title": "Geofency Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/geofency/.translations/es.json b/homeassistant/components/geofency/.translations/es.json new file mode 100644 index 0000000000000..04d5c01e03e91 --- /dev/null +++ b/homeassistant/components/geofency/.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 Geofency.\n\nRellene la siguiente informaci\u00f3n:\n\n- URL: ``{webhook_url}``\n- M\u00e9todo: POST\n\nVer[la documentaci\u00f3n]({docs_url}) para m\u00e1s detalles." + }, + "step": { + "user": { + "description": "\u00bfEst\u00e1s seguro de que quieres configurar el webhook de Geofency?", + "title": "Configurar el Webhook de Geofency" + } + }, + "title": "Webhook de Geofency" + } +} \ No newline at end of file diff --git a/homeassistant/components/geofency/.translations/fr.json b/homeassistant/components/geofency/.translations/fr.json new file mode 100644 index 0000000000000..b390f2dab44bd --- /dev/null +++ b/homeassistant/components/geofency/.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 Geofency.", + "one_instance_allowed": "Une seule instance est n\u00e9cessaire." + }, + "create_entry": { + "default": "Pour envoyer des \u00e9v\u00e9nements \u00e0 Home Assistant, vous devez configurer la fonctionnalit\u00e9 Webhook dans Geofency. \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 Geofency ?", + "title": "Configurer le Webhook Geofency" + } + }, + "title": "Geofency Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/geofency/.translations/hu.json b/homeassistant/components/geofency/.translations/hu.json new file mode 100644 index 0000000000000..85f71d74434cd --- /dev/null +++ b/homeassistant/components/geofency/.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 Geofency \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 Geofencyben. \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 Geofency Webhookot?", + "title": "A Geofency Webhook be\u00e1ll\u00edt\u00e1sa" + } + }, + "title": "Geofency Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/geofency/.translations/it.json b/homeassistant/components/geofency/.translations/it.json new file mode 100644 index 0000000000000..1adad3825a302 --- /dev/null +++ b/homeassistant/components/geofency/.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 Geofency.", + "one_instance_allowed": "\u00c8 necessaria una sola istanza." + }, + "create_entry": { + "default": "Per inviare eventi a Home Assistant, dovrai configurare la funzionalit\u00e0 webhook in Geofency.\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 Geofency?", + "title": "Configura il webhook di Geofency" + } + }, + "title": "Webhook di Geofency" + } +} \ No newline at end of file diff --git a/homeassistant/components/geofency/.translations/ko.json b/homeassistant/components/geofency/.translations/ko.json new file mode 100644 index 0000000000000..37f5ef0e76a60 --- /dev/null +++ b/homeassistant/components/geofency/.translations/ko.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Geofency \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 Geofency \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": "Geofency Webhook \uc744 \uc124\uc815\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "title": "Geofency Webhook \uc124\uc815" + } + }, + "title": "Geofency Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/geofency/.translations/lb.json b/homeassistant/components/geofency/.translations/lb.json new file mode 100644 index 0000000000000..490026b366dd3 --- /dev/null +++ b/homeassistant/components/geofency/.translations/lb.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "\u00c4r Home Assistant Instanz muss iwwert Internet accessibel si fir Geofency 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 Geofency 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 Geofency Webhook anzeriichten?", + "title": "Geofency Webhook ariichten" + } + }, + "title": "Geofency Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/geofency/.translations/nl.json b/homeassistant/components/geofency/.translations/nl.json new file mode 100644 index 0000000000000..04aec33b5d686 --- /dev/null +++ b/homeassistant/components/geofency/.translations/nl.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Uw Home Assistant instantie moet toegankelijk zijn vanaf het internet om Geofency-berichten te ontvangen.", + "one_instance_allowed": "Slechts \u00e9\u00e9n instantie is nodig." + }, + "create_entry": { + "default": "Om locaties naar Home Assistant te sturen, moet u de Webhook-functie instellen in Geofency.\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 u zeker dat u de Geofency Webhook wilt instellen?", + "title": "Geofency Webhook instellen" + } + }, + "title": "Geofency Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/geofency/.translations/no.json b/homeassistant/components/geofency/.translations/no.json new file mode 100644 index 0000000000000..1956c453a9f08 --- /dev/null +++ b/homeassistant/components/geofency/.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 Geofency.", + "one_instance_allowed": "Kun en forekomst er n\u00f8dvendig." + }, + "create_entry": { + "default": "For \u00e5 kunne sende hendelser til Home Assistant, m\u00e5 du sette opp webhook-funksjonen i Geofency. \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 konfigurere Geofency Webhook?", + "title": "Sett opp Geofency Webhook" + } + }, + "title": "Geofency Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/geofency/.translations/pl.json b/homeassistant/components/geofency/.translations/pl.json new file mode 100644 index 0000000000000..e72e99242c1cb --- /dev/null +++ b/homeassistant/components/geofency/.translations/pl.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Tw\u00f3j Home Assistant musi by\u0107 dost\u0119pny z Internetu, aby odbiera\u0107 komunikaty z Geofency.", + "one_instance_allowed": "Wymagana jest tylko jedna instancja." + }, + "create_entry": { + "default": "Aby wysy\u0142a\u0107 zdarzenia do Home Assistant'a, musisz skonfigurowa\u0107 webhook w Geofency. \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": "Geofency Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/geofency/.translations/pt-BR.json b/homeassistant/components/geofency/.translations/pt-BR.json new file mode 100644 index 0000000000000..20f21df5ac89d --- /dev/null +++ b/homeassistant/components/geofency/.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 da Geofency.", + "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 Geofency. \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 certeza de que deseja configurar o Geofency Webhook?", + "title": "Configurar o Geofency Webhook" + } + }, + "title": "Geofency Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/geofency/.translations/pt.json b/homeassistant/components/geofency/.translations/pt.json new file mode 100644 index 0000000000000..bc68c3ec8223c --- /dev/null +++ b/homeassistant/components/geofency/.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 IFTTT.", + "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 Geofency. \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 Geofency Webhook?", + "title": "Configurar o Geofency Webhook" + } + }, + "title": "Geofency Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/geofency/.translations/ru.json b/homeassistant/components/geofency/.translations/ru.json new file mode 100644 index 0000000000000..3663ff0114cea --- /dev/null +++ b/homeassistant/components/geofency/.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 Geofency.", + "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 Geofency.\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 Geofency?", + "title": "Geofency" + } + }, + "title": "Geofency" + } +} \ No newline at end of file diff --git a/homeassistant/components/geofency/.translations/sl.json b/homeassistant/components/geofency/.translations/sl.json new file mode 100644 index 0000000000000..e56d41d4f1aac --- /dev/null +++ b/homeassistant/components/geofency/.translations/sl.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Va\u0161 Home Assistant mora biti dostopen prek interneta, da boste lahko prejemali Geofency sporo\u010dila.", + "one_instance_allowed": "Potrebna je samo ena instanca." + }, + "create_entry": { + "default": "\u010ce \u017eelite dogodke poslati v Home Assistant, morate v Geofency-ju 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 geofency webhook?", + "title": "Nastavite Geofency Webhook" + } + }, + "title": "Geofency Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/geofency/.translations/sv.json b/homeassistant/components/geofency/.translations/sv.json new file mode 100644 index 0000000000000..88c9709147fcc --- /dev/null +++ b/homeassistant/components/geofency/.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 Geofency.", + "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 Geofency.\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 Geofency Webhook?", + "title": "Konfigurera Geofency Webhook" + } + }, + "title": "Geofency Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/geofency/.translations/zh-Hans.json b/homeassistant/components/geofency/.translations/zh-Hans.json new file mode 100644 index 0000000000000..d18d8bc82807b --- /dev/null +++ b/homeassistant/components/geofency/.translations/zh-Hans.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "\u60a8\u7684 Home Assistant \u5b9e\u4f8b\u9700\u8981\u63a5\u5165\u4e92\u8054\u7f51\u4ee5\u63a5\u6536 Geofency \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 Geofency \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 Geofency Webhook \u5417?", + "title": "\u8bbe\u7f6e Geofency Webhook" + } + }, + "title": "Geofency Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/geofency/.translations/zh-Hant.json b/homeassistant/components/geofency/.translations/zh-Hant.json new file mode 100644 index 0000000000000..003a3db8bf181 --- /dev/null +++ b/homeassistant/components/geofency/.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 Geofency \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 Geofency \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 Geofency Webhook\uff1f", + "title": "\u8a2d\u5b9a Geofency Webhook" + } + }, + "title": "Geofency Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/geofency/__init__.py b/homeassistant/components/geofency/__init__.py new file mode 100644 index 0000000000000..9afc9a8bfacba --- /dev/null +++ b/homeassistant/components/geofency/__init__.py @@ -0,0 +1,158 @@ +"""Support for Geofency.""" +import logging + +from aiohttp import web +import voluptuous as vol + +from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER +from homeassistant.const import ( + ATTR_LATITUDE, + ATTR_LONGITUDE, + ATTR_NAME, + CONF_WEBHOOK_ID, + HTTP_OK, + HTTP_UNPROCESSABLE_ENTITY, + STATE_NOT_HOME, +) +from homeassistant.helpers import config_entry_flow +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.util import slugify + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +CONF_MOBILE_BEACONS = "mobile_beacons" + +CONFIG_SCHEMA = vol.Schema( + { + vol.Optional(DOMAIN): vol.Schema( + { + vol.Optional(CONF_MOBILE_BEACONS, default=[]): vol.All( + cv.ensure_list, [cv.string] + ) + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + +ATTR_ADDRESS = "address" +ATTR_BEACON_ID = "beaconUUID" +ATTR_CURRENT_LATITUDE = "currentLatitude" +ATTR_CURRENT_LONGITUDE = "currentLongitude" +ATTR_DEVICE = "device" +ATTR_ENTRY = "entry" + +BEACON_DEV_PREFIX = "beacon" + +LOCATION_ENTRY = "1" +LOCATION_EXIT = "0" + +TRACKER_UPDATE = f"{DOMAIN}_tracker_update" + + +def _address(value: str) -> str: + r"""Coerce address by replacing '\n' with ' '.""" + return value.replace("\n", " ") + + +WEBHOOK_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ADDRESS): vol.All(cv.string, _address), + vol.Required(ATTR_DEVICE): vol.All(cv.string, slugify), + vol.Required(ATTR_ENTRY): vol.Any(LOCATION_ENTRY, LOCATION_EXIT), + vol.Required(ATTR_LATITUDE): cv.latitude, + vol.Required(ATTR_LONGITUDE): cv.longitude, + vol.Required(ATTR_NAME): vol.All(cv.string, slugify), + vol.Optional(ATTR_CURRENT_LATITUDE): cv.latitude, + vol.Optional(ATTR_CURRENT_LONGITUDE): cv.longitude, + vol.Optional(ATTR_BEACON_ID): cv.string, + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, hass_config): + """Set up the Geofency component.""" + config = hass_config.get(DOMAIN, {}) + mobile_beacons = config.get(CONF_MOBILE_BEACONS, []) + hass.data[DOMAIN] = { + "beacons": [slugify(beacon) for beacon in mobile_beacons], + "devices": set(), + "unsub_device_tracker": {}, + } + return True + + +async def handle_webhook(hass, webhook_id, request): + """Handle incoming webhook from Geofency.""" + try: + data = WEBHOOK_SCHEMA(dict(await request.post())) + except vol.MultipleInvalid as error: + return web.Response(text=error.error_message, status=HTTP_UNPROCESSABLE_ENTITY) + + if _is_mobile_beacon(data, hass.data[DOMAIN]["beacons"]): + return _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 _set_location(hass, data, location_name) + + +def _is_mobile_beacon(data, mobile_beacons): + """Check if we have a mobile beacon.""" + return ATTR_BEACON_ID in data and data["name"] in mobile_beacons + + +def _device_name(data): + """Return name of device tracker.""" + if ATTR_BEACON_ID in data: + return "{}_{}".format(BEACON_DEV_PREFIX, data["name"]) + return data["device"] + + +def _set_location(hass, data, location_name): + """Fire HA event to set location.""" + device = _device_name(data) + + async_dispatcher_send( + hass, + TRACKER_UPDATE, + device, + (data[ATTR_LATITUDE], data[ATTR_LONGITUDE]), + location_name, + data, + ) + + 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, "Geofency", 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/geofency/config_flow.py b/homeassistant/components/geofency/config_flow.py new file mode 100644 index 0000000000000..2d8bce86d741d --- /dev/null +++ b/homeassistant/components/geofency/config_flow.py @@ -0,0 +1,10 @@ +"""Config flow for Geofency.""" +from homeassistant.helpers import config_entry_flow + +from .const import DOMAIN + +config_entry_flow.register_webhook_flow( + DOMAIN, + "Geofency Webhook", + {"docs_url": "https://www.home-assistant.io/integrations/geofency/"}, +) diff --git a/homeassistant/components/geofency/const.py b/homeassistant/components/geofency/const.py new file mode 100644 index 0000000000000..b0c54a4d4079d --- /dev/null +++ b/homeassistant/components/geofency/const.py @@ -0,0 +1,3 @@ +"""Const for Geofency.""" + +DOMAIN = "geofency" diff --git a/homeassistant/components/geofency/device_tracker.py b/homeassistant/components/geofency/device_tracker.py new file mode 100644 index 0000000000000..49bd70192ef04 --- /dev/null +++ b/homeassistant/components/geofency/device_tracker.py @@ -0,0 +1,141 @@ +"""Support for the Geofency device tracker platform.""" +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_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 . import DOMAIN as GF_DOMAIN, TRACKER_UPDATE + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Geofency config entry.""" + + @callback + def _receive_data(device, gps, location_name, attributes): + """Fire HA event to set location.""" + if device in hass.data[GF_DOMAIN]["devices"]: + return + + hass.data[GF_DOMAIN]["devices"].add(device) + + async_add_entities([GeofencyEntity(device, gps, location_name, attributes)]) + + hass.data[GF_DOMAIN]["unsub_device_tracker"][ + config_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] == GF_DOMAIN + } + + if dev_ids: + hass.data[GF_DOMAIN]["devices"].update(dev_ids) + async_add_entities(GeofencyEntity(dev_id) for dev_id in dev_ids) + + return True + + +class GeofencyEntity(TrackerEntity, RestoreEntity): + """Represent a tracked device.""" + + def __init__(self, device, gps=None, location_name=None, attributes=None): + """Set up Geofency entity.""" + self._attributes = attributes or {} + self._name = device + self._location_name = location_name + self._gps = gps + self._unsub_dispatcher = None + self._unique_id = device + + @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._gps[0] + + @property + def longitude(self): + """Return longitude value of the device.""" + return self._gps[1] + + @property + def location_name(self): + """Return a location name for the current location of the device.""" + return self._location_name + + @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": {(GF_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 + ) + + if self._attributes: + return + + state = await self.async_get_last_state() + + if state is None: + self._gps = (None, None) + return + + attr = state.attributes + self._gps = (attr.get(ATTR_LATITUDE), attr.get(ATTR_LONGITUDE)) + + async def async_will_remove_from_hass(self): + """Clean up after entity before removal.""" + await super().async_will_remove_from_hass() + self._unsub_dispatcher() + self.hass.data[GF_DOMAIN]["devices"].remove(self._unique_id) + + @callback + def _async_receive_data(self, device, gps, location_name, attributes): + """Mark the device as seen.""" + if device != self.name: + return + + self._attributes.update(attributes) + self._location_name = location_name + self._gps = gps + self.async_write_ha_state() diff --git a/homeassistant/components/geofency/manifest.json b/homeassistant/components/geofency/manifest.json new file mode 100644 index 0000000000000..c48474a2927e9 --- /dev/null +++ b/homeassistant/components/geofency/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "geofency", + "name": "Geofency", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/geofency", + "requirements": [], + "dependencies": ["webhook"], + "codeowners": [] +} diff --git a/homeassistant/components/geofency/strings.json b/homeassistant/components/geofency/strings.json new file mode 100644 index 0000000000000..e67af592c1680 --- /dev/null +++ b/homeassistant/components/geofency/strings.json @@ -0,0 +1,18 @@ +{ + "config": { + "title": "Geofency Webhook", + "step": { + "user": { + "title": "Set up the Geofency Webhook", + "description": "Are you sure you want to set up the Geofency 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 Geofency." + }, + "create_entry": { + "default": "To send events to Home Assistant, you will need to setup the webhook feature in Geofency.\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/geonetnz_quakes/.translations/bg.json b/homeassistant/components/geonetnz_quakes/.translations/bg.json new file mode 100644 index 0000000000000..48d6eacda917d --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/.translations/bg.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "identifier_exists": "\u041c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0430\u043d\u043e" + }, + "step": { + "user": { + "data": { + "mmi": "MMI", + "radius": "Radius" + }, + "title": "\u041f\u043e\u043f\u044a\u043b\u043d\u0435\u0442\u0435 \u0434\u0430\u043d\u043d\u0438\u0442\u0435 \u0437\u0430 \u0444\u0438\u043b\u0442\u044a\u0440\u0430 \u0441\u0438." + } + }, + "title": "GeoNet NZ \u0417\u0435\u043c\u0435\u0442\u0440\u0435\u0441\u0435\u043d\u0438\u044f" + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/.translations/ca.json b/homeassistant/components/geonetnz_quakes/.translations/ca.json new file mode 100644 index 0000000000000..57ce2b4ee81bf --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/.translations/ca.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "identifier_exists": "Ubicaci\u00f3 ja registrada" + }, + "step": { + "user": { + "data": { + "mmi": "MMI", + "radius": "Radi" + }, + "title": "Introdueix els detalls del filtre." + } + }, + "title": "GeoNet NZ Quakes" + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/.translations/da.json b/homeassistant/components/geonetnz_quakes/.translations/da.json new file mode 100644 index 0000000000000..0d0e927bc4be4 --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/.translations/da.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "identifier_exists": "Placering allerede registreret" + }, + "step": { + "user": { + "data": { + "mmi": "MMI", + "radius": "Radius" + }, + "title": "Udfyld dine filteroplysninger." + } + }, + "title": "GeoNet NZ Quakes" + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/.translations/de.json b/homeassistant/components/geonetnz_quakes/.translations/de.json new file mode 100644 index 0000000000000..7c6fd08af96c8 --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/.translations/de.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "identifier_exists": "Standort bereits registriert" + }, + "step": { + "user": { + "data": { + "mmi": "MMI", + "radius": "Radius" + }, + "title": "F\u00fcllen Sie Ihre Filterdaten aus." + } + }, + "title": "GeoNet NZ Erdbeben" + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/.translations/en.json b/homeassistant/components/geonetnz_quakes/.translations/en.json new file mode 100644 index 0000000000000..4143efcdf9647 --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/.translations/en.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "identifier_exists": "Location already registered" + }, + "step": { + "user": { + "data": { + "mmi": "MMI", + "radius": "Radius" + }, + "title": "Fill in your filter details." + } + }, + "title": "GeoNet NZ Quakes" + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/.translations/es.json b/homeassistant/components/geonetnz_quakes/.translations/es.json new file mode 100644 index 0000000000000..f6f592675ab33 --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/.translations/es.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "identifier_exists": "Ubicaci\u00f3n ya registrada" + }, + "step": { + "user": { + "data": { + "mmi": "MMI", + "radius": "Radio" + }, + "title": "Complete todos los campos requeridos" + } + }, + "title": "GeoNet NZ Quakes" + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/.translations/fr.json b/homeassistant/components/geonetnz_quakes/.translations/fr.json new file mode 100644 index 0000000000000..74ae5541754ef --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/.translations/fr.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "identifier_exists": "Emplacement d\u00e9j\u00e0 enregistr\u00e9" + }, + "step": { + "user": { + "data": { + "mmi": "MMI", + "radius": "Rayon" + }, + "title": "Remplissez les d\u00e9tails de votre filtre." + } + }, + "title": "GeoNet NZ Quakes" + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/.translations/hu.json b/homeassistant/components/geonetnz_quakes/.translations/hu.json new file mode 100644 index 0000000000000..4a163d24b7592 --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/.translations/hu.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "radius": "Sug\u00e1r" + }, + "title": "T\u00f6ltsd ki a sz\u0171r\u0151 adatait." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/.translations/it.json b/homeassistant/components/geonetnz_quakes/.translations/it.json new file mode 100644 index 0000000000000..2a019aa39d94a --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/.translations/it.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "identifier_exists": "Localit\u00e0 gi\u00e0 registrata" + }, + "step": { + "user": { + "data": { + "mmi": "Intensit\u00e0 in Scala Mercalli Modificata", + "radius": "Raggio" + }, + "title": "Inserisci i tuoi dettagli del filtro." + } + }, + "title": "GeoNet NZ Quakes" + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/.translations/ko.json b/homeassistant/components/geonetnz_quakes/.translations/ko.json new file mode 100644 index 0000000000000..26caa2ebe54ca --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/.translations/ko.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "identifier_exists": "\uc704\uce58\uac00 \uc774\ubbf8 \ub4f1\ub85d\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "mmi": "MMI", + "radius": "\ubc18\uacbd" + }, + "title": "\ud544\ud130 \uc138\ubd80 \uc0ac\ud56d\uc744 \uc785\ub825\ud574\uc8fc\uc138\uc694" + } + }, + "title": "GeoNet NZ Quakes" + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/.translations/lb.json b/homeassistant/components/geonetnz_quakes/.translations/lb.json new file mode 100644 index 0000000000000..2499befecbb82 --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/.translations/lb.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "identifier_exists": "Standuert ass scho registr\u00e9iert" + }, + "step": { + "user": { + "data": { + "mmi": "MMI", + "radius": "Radius" + }, + "title": "F\u00ebllt \u00e4r Filter D\u00e9tailer aus." + } + }, + "title": "GeoNet NZ Quakes" + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/.translations/nl.json b/homeassistant/components/geonetnz_quakes/.translations/nl.json new file mode 100644 index 0000000000000..d6af28240eb3f --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/.translations/nl.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "identifier_exists": "Locatie al geregistreerd" + }, + "step": { + "user": { + "data": { + "mmi": "MMI", + "radius": "Straal" + }, + "title": "Vul uw filtergegevens in." + } + }, + "title": "GeoNet NZ Quakes" + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/.translations/nn.json b/homeassistant/components/geonetnz_quakes/.translations/nn.json new file mode 100644 index 0000000000000..d8afb1e7aaead --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/.translations/nn.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "mmi": "MMI", + "radius": "Radius" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/.translations/no.json b/homeassistant/components/geonetnz_quakes/.translations/no.json new file mode 100644 index 0000000000000..40b695d6f5148 --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/.translations/no.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "identifier_exists": "Beliggenhet allerede er registrert" + }, + "step": { + "user": { + "data": { + "mmi": "MMI", + "radius": "Radius" + }, + "title": "Fyll ut filterdetaljene." + } + }, + "title": "GeoNet NZ Quakes" + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/.translations/pl.json b/homeassistant/components/geonetnz_quakes/.translations/pl.json new file mode 100644 index 0000000000000..fd82bba43b573 --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/.translations/pl.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "identifier_exists": "Lokalizacja ju\u017c zarejestrowana" + }, + "step": { + "user": { + "data": { + "mmi": "MMI", + "radius": "Promie\u0144" + }, + "title": "Wprowad\u017a szczeg\u00f3\u0142owe dane filtra." + } + }, + "title": "GeoNet NZ Quakes" + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/.translations/pt-BR.json b/homeassistant/components/geonetnz_quakes/.translations/pt-BR.json new file mode 100644 index 0000000000000..7e3ee3b24dab4 --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/.translations/pt-BR.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "identifier_exists": "Localiza\u00e7\u00e3o j\u00e1 registrada" + }, + "step": { + "user": { + "data": { + "mmi": "MMI", + "radius": "Radius" + }, + "title": "Preencha os detalhes do filtro." + } + }, + "title": "GeoNet NZ Quakes" + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/.translations/ru.json b/homeassistant/components/geonetnz_quakes/.translations/ru.json new file mode 100644 index 0000000000000..dddb5c47bb963 --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/.translations/ru.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "identifier_exists": "\u041c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u0443\u0436\u0435 \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d\u043e." + }, + "step": { + "user": { + "data": { + "mmi": "MMI", + "radius": "\u0420\u0430\u0434\u0438\u0443\u0441" + }, + "title": "GeoNet NZ Quakes" + } + }, + "title": "GeoNet NZ Quakes" + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/.translations/sl.json b/homeassistant/components/geonetnz_quakes/.translations/sl.json new file mode 100644 index 0000000000000..bdd05d339535b --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/.translations/sl.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "identifier_exists": "Lokacija je \u017ee registrirana" + }, + "step": { + "user": { + "data": { + "mmi": "MMI", + "radius": "Radij" + }, + "title": "Izpolnite podrobnosti filtra." + } + }, + "title": "GeoNet NZ Potresi" + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/.translations/zh-Hans.json b/homeassistant/components/geonetnz_quakes/.translations/zh-Hans.json new file mode 100644 index 0000000000000..3786b03f41fc3 --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/.translations/zh-Hans.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "title": "\u586b\u5199\u60a8\u7684filter\u8be6\u7ec6\u4fe1\u606f\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/.translations/zh-Hant.json b/homeassistant/components/geonetnz_quakes/.translations/zh-Hant.json new file mode 100644 index 0000000000000..487ac9ea8c0d2 --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/.translations/zh-Hant.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "identifier_exists": "\u5ea7\u6a19\u5df2\u8a3b\u518a" + }, + "step": { + "user": { + "data": { + "mmi": "MMI", + "radius": "\u534a\u5f91" + }, + "title": "\u586b\u5beb\u904e\u6ffe\u5668\u8cc7\u8a0a\u3002" + } + }, + "title": "\u7d10\u897f\u862d GeoNet \u5730\u9707\u9810\u8b66" + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/__init__.py b/homeassistant/components/geonetnz_quakes/__init__.py new file mode 100644 index 0000000000000..141d05068473e --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/__init__.py @@ -0,0 +1,235 @@ +"""The GeoNet NZ Quakes integration.""" +import asyncio +from datetime import timedelta +import logging + +from aio_geojson_geonetnz_quakes import GeonetnzQuakesFeedManager +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import ( + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_RADIUS, + CONF_SCAN_INTERVAL, + CONF_UNIT_SYSTEM, + CONF_UNIT_SYSTEM_IMPERIAL, + LENGTH_MILES, +) +from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.util.unit_system import METRIC_SYSTEM + +from .config_flow import configured_instances +from .const import ( + CONF_MINIMUM_MAGNITUDE, + CONF_MMI, + DEFAULT_FILTER_TIME_INTERVAL, + DEFAULT_MINIMUM_MAGNITUDE, + DEFAULT_MMI, + DEFAULT_RADIUS, + DEFAULT_SCAN_INTERVAL, + DOMAIN, + FEED, + PLATFORMS, + SIGNAL_DELETE_ENTITY, + SIGNAL_NEW_GEOLOCATION, + SIGNAL_STATUS, + SIGNAL_UPDATE_ENTITY, +) + +_LOGGER = logging.getLogger(__name__) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Optional(CONF_LATITUDE): cv.latitude, + vol.Optional(CONF_LONGITUDE): cv.longitude, + vol.Optional(CONF_MMI, default=DEFAULT_MMI): vol.All( + vol.Coerce(int), vol.Range(min=-1, max=8) + ), + vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS): vol.Coerce(float), + vol.Optional( + CONF_MINIMUM_MAGNITUDE, default=DEFAULT_MINIMUM_MAGNITUDE + ): vol.All(vol.Coerce(float), vol.Range(min=0)), + vol.Optional( + CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL + ): cv.time_period, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, config): + """Set up the GeoNet NZ Quakes component.""" + if DOMAIN not in config: + return True + + conf = config[DOMAIN] + + latitude = conf.get(CONF_LATITUDE, hass.config.latitude) + longitude = conf.get(CONF_LONGITUDE, hass.config.longitude) + mmi = conf[CONF_MMI] + scan_interval = conf[CONF_SCAN_INTERVAL] + + identifier = f"{latitude}, {longitude}" + if identifier in configured_instances(hass): + return True + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_LATITUDE: latitude, + CONF_LONGITUDE: longitude, + CONF_RADIUS: conf[CONF_RADIUS], + CONF_MINIMUM_MAGNITUDE: conf[CONF_MINIMUM_MAGNITUDE], + CONF_MMI: mmi, + CONF_SCAN_INTERVAL: scan_interval, + }, + ) + ) + + return True + + +async def async_setup_entry(hass, config_entry): + """Set up the GeoNet NZ Quakes component as config entry.""" + if DOMAIN not in hass.data: + hass.data[DOMAIN] = {} + if FEED not in hass.data[DOMAIN]: + hass.data[DOMAIN][FEED] = {} + + radius = config_entry.data[CONF_RADIUS] + unit_system = config_entry.data[CONF_UNIT_SYSTEM] + if unit_system == CONF_UNIT_SYSTEM_IMPERIAL: + radius = METRIC_SYSTEM.length(radius, LENGTH_MILES) + # Create feed entity manager for all platforms. + manager = GeonetnzQuakesFeedEntityManager(hass, config_entry, radius, unit_system) + hass.data[DOMAIN][FEED][config_entry.entry_id] = manager + _LOGGER.debug("Feed entity manager added for %s", config_entry.entry_id) + await manager.async_init() + return True + + +async def async_unload_entry(hass, config_entry): + """Unload an GeoNet NZ Quakes component config entry.""" + manager = hass.data[DOMAIN][FEED].pop(config_entry.entry_id) + await manager.async_stop() + await asyncio.wait( + [ + hass.config_entries.async_forward_entry_unload(config_entry, domain) + for domain in PLATFORMS + ] + ) + return True + + +class GeonetnzQuakesFeedEntityManager: + """Feed Entity Manager for GeoNet NZ Quakes feed.""" + + def __init__(self, hass, config_entry, radius_in_km, unit_system): + """Initialize the Feed Entity Manager.""" + self._hass = hass + self._config_entry = config_entry + coordinates = ( + config_entry.data[CONF_LATITUDE], + config_entry.data[CONF_LONGITUDE], + ) + websession = aiohttp_client.async_get_clientsession(hass) + self._feed_manager = GeonetnzQuakesFeedManager( + websession, + self._generate_entity, + self._update_entity, + self._remove_entity, + coordinates, + mmi=config_entry.data[CONF_MMI], + filter_radius=radius_in_km, + filter_minimum_magnitude=config_entry.data[CONF_MINIMUM_MAGNITUDE], + filter_time=DEFAULT_FILTER_TIME_INTERVAL, + status_callback=self._status_update, + ) + self._config_entry_id = config_entry.entry_id + self._scan_interval = timedelta(seconds=config_entry.data[CONF_SCAN_INTERVAL]) + self._unit_system = unit_system + self._track_time_remove_callback = None + self._status_info = None + self.listeners = [] + + async def async_init(self): + """Schedule initial and regular updates based on configured time interval.""" + + for domain in PLATFORMS: + self._hass.async_create_task( + self._hass.config_entries.async_forward_entry_setup( + self._config_entry, domain + ) + ) + + async def update(event_time): + """Update.""" + await self.async_update() + + # Trigger updates at regular intervals. + self._track_time_remove_callback = async_track_time_interval( + self._hass, update, self._scan_interval + ) + + _LOGGER.debug("Feed entity manager initialized") + + async def async_update(self): + """Refresh data.""" + await self._feed_manager.update() + _LOGGER.debug("Feed entity manager updated") + + async def async_stop(self): + """Stop this feed entity manager from refreshing.""" + for unsub_dispatcher in self.listeners: + unsub_dispatcher() + self.listeners = [] + if self._track_time_remove_callback: + self._track_time_remove_callback() + _LOGGER.debug("Feed entity manager stopped") + + @callback + def async_event_new_entity(self): + """Return manager specific event to signal new entity.""" + return SIGNAL_NEW_GEOLOCATION.format(self._config_entry_id) + + def get_entry(self, external_id): + """Get feed entry by external id.""" + return self._feed_manager.feed_entries.get(external_id) + + def status_info(self): + """Return latest status update info received.""" + return self._status_info + + async def _generate_entity(self, external_id): + """Generate new entity.""" + async_dispatcher_send( + self._hass, + self.async_event_new_entity(), + self, + external_id, + self._unit_system, + ) + + async def _update_entity(self, external_id): + """Update entity.""" + async_dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY.format(external_id)) + + async def _remove_entity(self, external_id): + """Remove entity.""" + async_dispatcher_send(self._hass, SIGNAL_DELETE_ENTITY.format(external_id)) + + async def _status_update(self, status_info): + """Propagate status update.""" + _LOGGER.debug("Status update received: %s", status_info) + self._status_info = status_info + async_dispatcher_send(self._hass, SIGNAL_STATUS.format(self._config_entry_id)) diff --git a/homeassistant/components/geonetnz_quakes/config_flow.py b/homeassistant/components/geonetnz_quakes/config_flow.py new file mode 100644 index 0000000000000..cc40f31f1fbee --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/config_flow.py @@ -0,0 +1,94 @@ +"""Config flow to configure the GeoNet NZ Quakes integration.""" +import logging + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import ( + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_RADIUS, + CONF_SCAN_INTERVAL, + CONF_UNIT_SYSTEM, + CONF_UNIT_SYSTEM_IMPERIAL, + CONF_UNIT_SYSTEM_METRIC, +) +from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv + +from .const import ( + CONF_MINIMUM_MAGNITUDE, + CONF_MMI, + DEFAULT_MINIMUM_MAGNITUDE, + DEFAULT_MMI, + DEFAULT_RADIUS, + DEFAULT_SCAN_INTERVAL, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + + +@callback +def configured_instances(hass): + """Return a set of configured GeoNet NZ Quakes instances.""" + return set( + f"{entry.data[CONF_LATITUDE]}, {entry.data[CONF_LONGITUDE]}" + for entry in hass.config_entries.async_entries(DOMAIN) + ) + + +@config_entries.HANDLERS.register(DOMAIN) +class GeonetnzQuakesFlowHandler(config_entries.ConfigFlow): + """Handle a GeoNet NZ Quakes config flow.""" + + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def _show_form(self, errors=None): + """Show the form to the user.""" + data_schema = vol.Schema( + { + vol.Optional(CONF_MMI, default=DEFAULT_MMI): vol.All( + vol.Coerce(int), vol.Range(min=-1, max=8) + ), + vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS): cv.positive_int, + } + ) + + return self.async_show_form( + step_id="user", data_schema=data_schema, errors=errors or {} + ) + + 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.""" + _LOGGER.debug("User input: %s", user_input) + if not user_input: + return await self._show_form() + + latitude = user_input.get(CONF_LATITUDE, self.hass.config.latitude) + user_input[CONF_LATITUDE] = latitude + longitude = user_input.get(CONF_LONGITUDE, self.hass.config.longitude) + user_input[CONF_LONGITUDE] = longitude + + identifier = f"{user_input[CONF_LATITUDE]}, {user_input[CONF_LONGITUDE]}" + if identifier in configured_instances(self.hass): + return await self._show_form({"base": "identifier_exists"}) + + if self.hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL: + user_input[CONF_UNIT_SYSTEM] = CONF_UNIT_SYSTEM_IMPERIAL + else: + user_input[CONF_UNIT_SYSTEM] = CONF_UNIT_SYSTEM_METRIC + + scan_interval = user_input.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + user_input[CONF_SCAN_INTERVAL] = scan_interval.seconds + + minimum_magnitude = user_input.get( + CONF_MINIMUM_MAGNITUDE, DEFAULT_MINIMUM_MAGNITUDE + ) + user_input[CONF_MINIMUM_MAGNITUDE] = minimum_magnitude + + return self.async_create_entry(title=identifier, data=user_input) diff --git a/homeassistant/components/geonetnz_quakes/const.py b/homeassistant/components/geonetnz_quakes/const.py new file mode 100644 index 0000000000000..d564d407f7c7e --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/const.py @@ -0,0 +1,23 @@ +"""Define constants for the GeoNet NZ Quakes integration.""" +from datetime import timedelta + +DOMAIN = "geonetnz_quakes" + +PLATFORMS = ("sensor", "geo_location") + +CONF_MINIMUM_MAGNITUDE = "minimum_magnitude" +CONF_MMI = "mmi" + +FEED = "feed" + +DEFAULT_FILTER_TIME_INTERVAL = timedelta(days=7) +DEFAULT_MINIMUM_MAGNITUDE = 0.0 +DEFAULT_MMI = 3 +DEFAULT_RADIUS = 50.0 +DEFAULT_SCAN_INTERVAL = timedelta(minutes=5) + +SIGNAL_DELETE_ENTITY = "geonetnz_quakes_delete_{}" +SIGNAL_UPDATE_ENTITY = "geonetnz_quakes_update_{}" +SIGNAL_STATUS = "geonetnz_quakes_status_{}" + +SIGNAL_NEW_GEOLOCATION = "geonetnz_quakes_new_geolocation_{}" diff --git a/homeassistant/components/geonetnz_quakes/geo_location.py b/homeassistant/components/geonetnz_quakes/geo_location.py new file mode 100644 index 0000000000000..ae8b8fef48d95 --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/geo_location.py @@ -0,0 +1,187 @@ +"""Geolocation support for GeoNet NZ Quakes Feeds.""" +import logging +from typing import Optional + +from homeassistant.components.geo_location import GeolocationEvent +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_TIME, + CONF_UNIT_SYSTEM_IMPERIAL, + LENGTH_KILOMETERS, + LENGTH_MILES, +) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.util.unit_system import IMPERIAL_SYSTEM + +from .const import DOMAIN, FEED, SIGNAL_DELETE_ENTITY, SIGNAL_UPDATE_ENTITY + +_LOGGER = logging.getLogger(__name__) + +ATTR_DEPTH = "depth" +ATTR_EXTERNAL_ID = "external_id" +ATTR_LOCALITY = "locality" +ATTR_MAGNITUDE = "magnitude" +ATTR_MMI = "mmi" +ATTR_PUBLICATION_DATE = "publication_date" +ATTR_QUALITY = "quality" + +SOURCE = "geonetnz_quakes" + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up the GeoNet NZ Quakes Feed platform.""" + manager = hass.data[DOMAIN][FEED][entry.entry_id] + + @callback + def async_add_geolocation(feed_manager, external_id, unit_system): + """Add gelocation entity from feed.""" + new_entity = GeonetnzQuakesEvent(feed_manager, external_id, unit_system) + _LOGGER.debug("Adding geolocation %s", new_entity) + async_add_entities([new_entity], True) + + manager.listeners.append( + async_dispatcher_connect( + hass, manager.async_event_new_entity(), async_add_geolocation + ) + ) + hass.async_create_task(manager.async_update()) + _LOGGER.debug("Geolocation setup done") + + +class GeonetnzQuakesEvent(GeolocationEvent): + """This represents an external event with GeoNet NZ Quakes feed data.""" + + def __init__(self, feed_manager, external_id, unit_system): + """Initialize entity with data from feed entry.""" + self._feed_manager = feed_manager + self._external_id = external_id + self._unit_system = unit_system + self._title = None + self._distance = None + self._latitude = None + self._longitude = None + self._attribution = None + self._depth = None + self._locality = None + self._magnitude = None + self._mmi = None + self._quality = None + self._time = None + self._remove_signal_delete = None + self._remove_signal_update = None + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + self._remove_signal_delete = async_dispatcher_connect( + self.hass, + SIGNAL_DELETE_ENTITY.format(self._external_id), + self._delete_callback, + ) + self._remove_signal_update = async_dispatcher_connect( + self.hass, + SIGNAL_UPDATE_ENTITY.format(self._external_id), + self._update_callback, + ) + + async def async_will_remove_from_hass(self) -> None: + """Call when entity will be removed from hass.""" + self._remove_signal_delete() + self._remove_signal_update() + + @callback + def _delete_callback(self): + """Remove this entity.""" + self.hass.async_create_task(self.async_remove()) + + @callback + def _update_callback(self): + """Call update method.""" + self.async_schedule_update_ha_state(True) + + @property + def should_poll(self): + """No polling needed for GeoNet NZ Quakes feed location events.""" + return False + + async def async_update(self): + """Update this entity from the data held in the feed manager.""" + _LOGGER.debug("Updating %s", self._external_id) + feed_entry = self._feed_manager.get_entry(self._external_id) + if feed_entry: + self._update_from_feed(feed_entry) + + def _update_from_feed(self, feed_entry): + """Update the internal state from the provided feed entry.""" + self._title = feed_entry.title + # Convert distance if not metric system. + if self._unit_system == CONF_UNIT_SYSTEM_IMPERIAL: + self._distance = IMPERIAL_SYSTEM.length( + feed_entry.distance_to_home, LENGTH_KILOMETERS + ) + else: + self._distance = feed_entry.distance_to_home + self._latitude = feed_entry.coordinates[0] + self._longitude = feed_entry.coordinates[1] + self._attribution = feed_entry.attribution + self._depth = feed_entry.depth + self._locality = feed_entry.locality + self._magnitude = feed_entry.magnitude + self._mmi = feed_entry.mmi + self._quality = feed_entry.quality + self._time = feed_entry.time + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return "mdi:pulse" + + @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 entity.""" + return self._title + + @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.""" + if self._unit_system == CONF_UNIT_SYSTEM_IMPERIAL: + return LENGTH_MILES + return LENGTH_KILOMETERS + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + attributes = {} + for key, value in ( + (ATTR_EXTERNAL_ID, self._external_id), + (ATTR_ATTRIBUTION, self._attribution), + (ATTR_DEPTH, self._depth), + (ATTR_LOCALITY, self._locality), + (ATTR_MAGNITUDE, self._magnitude), + (ATTR_MMI, self._mmi), + (ATTR_QUALITY, self._quality), + (ATTR_TIME, self._time), + ): + if value or isinstance(value, bool): + attributes[key] = value + return attributes diff --git a/homeassistant/components/geonetnz_quakes/manifest.json b/homeassistant/components/geonetnz_quakes/manifest.json new file mode 100644 index 0000000000000..775ca8760bc37 --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "geonetnz_quakes", + "name": "GeoNet NZ Quakes", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/geonetnz_quakes", + "requirements": ["aio_geojson_geonetnz_quakes==0.11"], + "dependencies": [], + "codeowners": ["@exxamalte"] +} diff --git a/homeassistant/components/geonetnz_quakes/sensor.py b/homeassistant/components/geonetnz_quakes/sensor.py new file mode 100644 index 0000000000000..e0be94d1b261d --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/sensor.py @@ -0,0 +1,139 @@ +"""Feed Entity Manager Sensor support for GeoNet NZ Quakes Feeds.""" +import logging +from typing import Optional + +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity +from homeassistant.util import dt + +from .const import DOMAIN, FEED, SIGNAL_STATUS + +_LOGGER = logging.getLogger(__name__) + +ATTR_STATUS = "status" +ATTR_LAST_UPDATE = "last_update" +ATTR_LAST_UPDATE_SUCCESSFUL = "last_update_successful" +ATTR_LAST_TIMESTAMP = "last_timestamp" +ATTR_CREATED = "created" +ATTR_UPDATED = "updated" +ATTR_REMOVED = "removed" + +DEFAULT_ICON = "mdi:pulse" +DEFAULT_UNIT_OF_MEASUREMENT = "quakes" + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up the GeoNet NZ Quakes Feed platform.""" + manager = hass.data[DOMAIN][FEED][entry.entry_id] + sensor = GeonetnzQuakesSensor(entry.entry_id, entry.title, manager) + async_add_entities([sensor]) + _LOGGER.debug("Sensor setup done") + + +class GeonetnzQuakesSensor(Entity): + """This is a status sensor for the GeoNet NZ Quakes integration.""" + + def __init__(self, config_entry_id, config_title, manager): + """Initialize entity.""" + self._config_entry_id = config_entry_id + self._config_title = config_title + self._manager = manager + self._status = None + self._last_update = None + self._last_update_successful = None + self._last_timestamp = None + self._total = None + self._created = None + self._updated = None + self._removed = None + self._remove_signal_status = None + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + self._remove_signal_status = async_dispatcher_connect( + self.hass, + SIGNAL_STATUS.format(self._config_entry_id), + self._update_status_callback, + ) + _LOGGER.debug("Waiting for updates %s", self._config_entry_id) + # First update is manual because of how the feed entity manager is updated. + await self.async_update() + + async def async_will_remove_from_hass(self) -> None: + """Call when entity will be removed from hass.""" + if self._remove_signal_status: + self._remove_signal_status() + + @callback + def _update_status_callback(self): + """Call status update method.""" + _LOGGER.debug("Received status update for %s", self._config_entry_id) + self.async_schedule_update_ha_state(True) + + @property + def should_poll(self): + """No polling needed for GeoNet NZ Quakes status sensor.""" + return False + + async def async_update(self): + """Update this entity from the data held in the feed manager.""" + _LOGGER.debug("Updating %s", self._config_entry_id) + if self._manager: + status_info = self._manager.status_info() + if status_info: + self._update_from_status_info(status_info) + + def _update_from_status_info(self, status_info): + """Update the internal state from the provided information.""" + self._status = status_info.status + self._last_update = ( + dt.as_utc(status_info.last_update) if status_info.last_update else None + ) + self._last_update_successful = ( + dt.as_utc(status_info.last_update_successful) + if status_info.last_update_successful + else None + ) + self._last_timestamp = status_info.last_timestamp + self._total = status_info.total + self._created = status_info.created + self._updated = status_info.updated + self._removed = status_info.removed + + @property + def state(self): + """Return the state of the sensor.""" + return self._total + + @property + def name(self) -> Optional[str]: + """Return the name of the entity.""" + return f"GeoNet NZ Quakes ({self._config_title})" + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return DEFAULT_ICON + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return DEFAULT_UNIT_OF_MEASUREMENT + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + attributes = {} + for key, value in ( + (ATTR_STATUS, self._status), + (ATTR_LAST_UPDATE, self._last_update), + (ATTR_LAST_UPDATE_SUCCESSFUL, self._last_update_successful), + (ATTR_LAST_TIMESTAMP, self._last_timestamp), + (ATTR_CREATED, self._created), + (ATTR_UPDATED, self._updated), + (ATTR_REMOVED, self._removed), + ): + if value or isinstance(value, bool): + attributes[key] = value + return attributes diff --git a/homeassistant/components/geonetnz_quakes/strings.json b/homeassistant/components/geonetnz_quakes/strings.json new file mode 100644 index 0000000000000..6ec915eb68d4f --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/strings.json @@ -0,0 +1,17 @@ +{ + "config": { + "title": "GeoNet NZ Quakes", + "step": { + "user": { + "title": "Fill in your filter details.", + "data": { + "radius": "Radius", + "mmi": "MMI" + } + } + }, + "error": { + "identifier_exists": "Location already registered" + } + } +} diff --git a/homeassistant/components/geonetnz_volcano/.translations/bg.json b/homeassistant/components/geonetnz_volcano/.translations/bg.json new file mode 100644 index 0000000000000..f895d28290255 --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/.translations/bg.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "identifier_exists": "\u041c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0430\u043d\u043e" + }, + "step": { + "user": { + "data": { + "radius": "\u0420\u0430\u0434\u0438\u0443\u0441" + }, + "title": "\u041f\u043e\u043f\u044a\u043b\u043d\u0435\u0442\u0435 \u0434\u0430\u043d\u043d\u0438\u0442\u0435 \u0437\u0430 \u0444\u0438\u043b\u0442\u044a\u0440\u0430 \u0441\u0438." + } + }, + "title": "GeoNet NZ Volcano" + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_volcano/.translations/ca.json b/homeassistant/components/geonetnz_volcano/.translations/ca.json new file mode 100644 index 0000000000000..2e595b7304046 --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/.translations/ca.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "identifier_exists": "Ubicaci\u00f3 ja registrada" + }, + "step": { + "user": { + "data": { + "radius": "Radi" + }, + "title": "Introdueix els detalls del filtre." + } + }, + "title": "GeoNet NZ Volcano" + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_volcano/.translations/da.json b/homeassistant/components/geonetnz_volcano/.translations/da.json new file mode 100644 index 0000000000000..a8c238a60b016 --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/.translations/da.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "identifier_exists": "Lokalitet allerede registreret" + }, + "step": { + "user": { + "data": { + "radius": "Radius" + }, + "title": "Udfyld dine filteroplysninger." + } + }, + "title": "GeoNet NZ vulkan" + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_volcano/.translations/de.json b/homeassistant/components/geonetnz_volcano/.translations/de.json new file mode 100644 index 0000000000000..fa87d24811c19 --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/.translations/de.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "identifier_exists": "Standort bereits registriert" + }, + "step": { + "user": { + "data": { + "radius": "Radius" + }, + "title": "F\u00fcllen Sie Ihre Filterangaben aus." + } + }, + "title": "GeoNet NZ Volcano" + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_volcano/.translations/en.json b/homeassistant/components/geonetnz_volcano/.translations/en.json new file mode 100644 index 0000000000000..1175597908e90 --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/.translations/en.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "identifier_exists": "Location already registered" + }, + "step": { + "user": { + "data": { + "radius": "Radius" + }, + "title": "Fill in your filter details." + } + }, + "title": "GeoNet NZ Volcano" + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_volcano/.translations/es.json b/homeassistant/components/geonetnz_volcano/.translations/es.json new file mode 100644 index 0000000000000..c6b92e830898d --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/.translations/es.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "identifier_exists": "Lugar ya registrado" + }, + "step": { + "user": { + "data": { + "radius": "Radio" + }, + "title": "Complete los detalles de su filtro." + } + }, + "title": "GeoNet NZ Volc\u00e1n" + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_volcano/.translations/fr.json b/homeassistant/components/geonetnz_volcano/.translations/fr.json new file mode 100644 index 0000000000000..c93ae906a4661 --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/.translations/fr.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "identifier_exists": "Emplacement d\u00e9j\u00e0 enregistr\u00e9" + }, + "step": { + "user": { + "data": { + "radius": "Rayon" + }, + "title": "Remplissez les d\u00e9tails de votre filtre." + } + }, + "title": "GeoNet NZ Volcano" + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_volcano/.translations/it.json b/homeassistant/components/geonetnz_volcano/.translations/it.json new file mode 100644 index 0000000000000..85bfc7297ee24 --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/.translations/it.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "identifier_exists": "Localit\u00e0 gi\u00e0 registrata" + }, + "step": { + "user": { + "data": { + "radius": "Raggio" + }, + "title": "Inserisci i tuoi dettagli del filtro." + } + }, + "title": "GeoNet NZ Vulcano" + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_volcano/.translations/ko.json b/homeassistant/components/geonetnz_volcano/.translations/ko.json new file mode 100644 index 0000000000000..5d393fef4c49f --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/.translations/ko.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "identifier_exists": "\uc704\uce58\uac00 \uc774\ubbf8 \ub4f1\ub85d\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "radius": "\ubc18\uacbd" + }, + "title": "\ud544\ud130 \uc138\ubd80 \uc0ac\ud56d\uc744 \uc785\ub825\ud574\uc8fc\uc138\uc694" + } + }, + "title": "GeoNet NZ Volcano" + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_volcano/.translations/lb.json b/homeassistant/components/geonetnz_volcano/.translations/lb.json new file mode 100644 index 0000000000000..a7ad17e6bd573 --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/.translations/lb.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "identifier_exists": "Standuert ass scho registr\u00e9iert" + }, + "step": { + "user": { + "data": { + "radius": "Radius" + }, + "title": "F\u00ebllt \u00e4r Filter D\u00e9tailer aus." + } + }, + "title": "GeoNet NZ Vulkan" + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_volcano/.translations/nl.json b/homeassistant/components/geonetnz_volcano/.translations/nl.json new file mode 100644 index 0000000000000..44d814b9db201 --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/.translations/nl.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "identifier_exists": "Locatie al geregistreerd" + }, + "step": { + "user": { + "data": { + "radius": "Straal" + }, + "title": "Vul uw filtergegevens in." + } + }, + "title": "GeoNet NZ Volcano" + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_volcano/.translations/no.json b/homeassistant/components/geonetnz_volcano/.translations/no.json new file mode 100644 index 0000000000000..d66e0eb6d7d81 --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/.translations/no.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "identifier_exists": "Beliggenhet er allerede registrert" + }, + "step": { + "user": { + "data": { + "radius": "Radius" + }, + "title": "Fyll ut filterdetaljene." + } + }, + "title": "GeoNet NZ Volcano" + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_volcano/.translations/pl.json b/homeassistant/components/geonetnz_volcano/.translations/pl.json new file mode 100644 index 0000000000000..7d329815f3fd4 --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/.translations/pl.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "identifier_exists": "Lokalizacja ju\u017c zarejestrowana" + }, + "step": { + "user": { + "data": { + "radius": "Promie\u0144" + }, + "title": "Wprowad\u017a szczeg\u00f3\u0142owe dane filtra." + } + }, + "title": "GeoNet NZ Volcano" + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_volcano/.translations/pt-BR.json b/homeassistant/components/geonetnz_volcano/.translations/pt-BR.json new file mode 100644 index 0000000000000..b16295999265f --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/.translations/pt-BR.json @@ -0,0 +1,14 @@ +{ + "config": { + "error": { + "identifier_exists": "Local j\u00e1 registrado" + }, + "step": { + "user": { + "data": { + "radius": "Raio" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_volcano/.translations/ro.json b/homeassistant/components/geonetnz_volcano/.translations/ro.json new file mode 100644 index 0000000000000..4c0cd317d48be --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/.translations/ro.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "user": { + "data": { + "radius": "Raz\u0103" + }, + "title": "Completa\u021bi detaliile filtrului." + } + }, + "title": "Vulcanul GeoNet NZ" + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_volcano/.translations/ru.json b/homeassistant/components/geonetnz_volcano/.translations/ru.json new file mode 100644 index 0000000000000..6e7411f28b97a --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/.translations/ru.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "identifier_exists": "\u041c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u0443\u0436\u0435 \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d\u043e." + }, + "step": { + "user": { + "data": { + "radius": "\u0420\u0430\u0434\u0438\u0443\u0441" + }, + "title": "GeoNet NZ Volcano" + } + }, + "title": "GeoNet NZ Volcano" + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_volcano/.translations/sl.json b/homeassistant/components/geonetnz_volcano/.translations/sl.json new file mode 100644 index 0000000000000..e31f473c26f2d --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/.translations/sl.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "identifier_exists": "Lokacija je \u017ee registrirana" + }, + "step": { + "user": { + "data": { + "radius": "Radij" + }, + "title": "Izpolnite podrobnosti filtra." + } + }, + "title": "GeoNet NZ vulkan" + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_volcano/.translations/zh-Hant.json b/homeassistant/components/geonetnz_volcano/.translations/zh-Hant.json new file mode 100644 index 0000000000000..0f74841fd7b1e --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/.translations/zh-Hant.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "identifier_exists": "\u5ea7\u6a19\u5df2\u8a3b\u518a" + }, + "step": { + "user": { + "data": { + "radius": "\u534a\u5f91" + }, + "title": "\u586b\u5beb\u904e\u6ffe\u5668\u8cc7\u8a0a\u3002" + } + }, + "title": "\u7d10\u897f\u862d GeoNet \u706b\u5c71\u9810\u8b66" + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_volcano/__init__.py b/homeassistant/components/geonetnz_volcano/__init__.py new file mode 100644 index 0000000000000..e24de7fdc5da9 --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/__init__.py @@ -0,0 +1,205 @@ +"""The GeoNet NZ Volcano integration.""" +import asyncio +from datetime import datetime, timedelta +import logging +from typing import Optional + +from aio_geojson_geonetnz_volcano import GeonetnzVolcanoFeedManager +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import ( + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_RADIUS, + CONF_SCAN_INTERVAL, + CONF_UNIT_SYSTEM, + CONF_UNIT_SYSTEM_IMPERIAL, + LENGTH_MILES, +) +from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.util.unit_system import METRIC_SYSTEM + +from .config_flow import configured_instances +from .const import ( + DEFAULT_RADIUS, + DEFAULT_SCAN_INTERVAL, + DOMAIN, + FEED, + SIGNAL_NEW_SENSOR, + SIGNAL_UPDATE_ENTITY, +) + +_LOGGER = logging.getLogger(__name__) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Optional(CONF_LATITUDE): cv.latitude, + vol.Optional(CONF_LONGITUDE): cv.longitude, + vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS): vol.Coerce(float), + vol.Optional( + CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL + ): cv.time_period, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, config): + """Set up the GeoNet NZ Volcano component.""" + if DOMAIN not in config: + return True + + conf = config[DOMAIN] + + latitude = conf.get(CONF_LATITUDE, hass.config.latitude) + longitude = conf.get(CONF_LONGITUDE, hass.config.longitude) + scan_interval = conf[CONF_SCAN_INTERVAL] + + identifier = f"{latitude}, {longitude}" + if identifier in configured_instances(hass): + return True + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_LATITUDE: latitude, + CONF_LONGITUDE: longitude, + CONF_RADIUS: conf[CONF_RADIUS], + CONF_SCAN_INTERVAL: scan_interval, + }, + ) + ) + + return True + + +async def async_setup_entry(hass, config_entry): + """Set up the GeoNet NZ Volcano component as config entry.""" + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN].setdefault(FEED, {}) + + radius = config_entry.data[CONF_RADIUS] + unit_system = config_entry.data[CONF_UNIT_SYSTEM] + if unit_system == CONF_UNIT_SYSTEM_IMPERIAL: + radius = METRIC_SYSTEM.length(radius, LENGTH_MILES) + # Create feed entity manager for all platforms. + manager = GeonetnzVolcanoFeedEntityManager(hass, config_entry, radius, unit_system) + hass.data[DOMAIN][FEED][config_entry.entry_id] = manager + _LOGGER.debug("Feed entity manager added for %s", config_entry.entry_id) + await manager.async_init() + return True + + +async def async_unload_entry(hass, config_entry): + """Unload an GeoNet NZ Volcano component config entry.""" + manager = hass.data[DOMAIN][FEED].pop(config_entry.entry_id) + await manager.async_stop() + await asyncio.wait( + [hass.config_entries.async_forward_entry_unload(config_entry, "sensor")] + ) + return True + + +class GeonetnzVolcanoFeedEntityManager: + """Feed Entity Manager for GeoNet NZ Volcano feed.""" + + def __init__(self, hass, config_entry, radius_in_km, unit_system): + """Initialize the Feed Entity Manager.""" + self._hass = hass + self._config_entry = config_entry + coordinates = ( + config_entry.data[CONF_LATITUDE], + config_entry.data[CONF_LONGITUDE], + ) + websession = aiohttp_client.async_get_clientsession(hass) + self._feed_manager = GeonetnzVolcanoFeedManager( + websession, + self._generate_entity, + self._update_entity, + self._remove_entity, + coordinates, + filter_radius=radius_in_km, + ) + self._config_entry_id = config_entry.entry_id + self._scan_interval = timedelta(seconds=config_entry.data[CONF_SCAN_INTERVAL]) + self._unit_system = unit_system + self._track_time_remove_callback = None + self.listeners = [] + + async def async_init(self): + """Schedule initial and regular updates based on configured time interval.""" + + self._hass.async_create_task( + self._hass.config_entries.async_forward_entry_setup( + self._config_entry, "sensor" + ) + ) + + async def update(event_time): + """Update.""" + await self.async_update() + + # Trigger updates at regular intervals. + self._track_time_remove_callback = async_track_time_interval( + self._hass, update, self._scan_interval + ) + + _LOGGER.debug("Feed entity manager initialized") + + async def async_update(self): + """Refresh data.""" + await self._feed_manager.update() + _LOGGER.debug("Feed entity manager updated") + + async def async_stop(self): + """Stop this feed entity manager from refreshing.""" + for unsub_dispatcher in self.listeners: + unsub_dispatcher() + self.listeners = [] + if self._track_time_remove_callback: + self._track_time_remove_callback() + _LOGGER.debug("Feed entity manager stopped") + + @callback + def async_event_new_entity(self): + """Return manager specific event to signal new entity.""" + return SIGNAL_NEW_SENSOR.format(self._config_entry_id) + + def get_entry(self, external_id): + """Get feed entry by external id.""" + return self._feed_manager.feed_entries.get(external_id) + + def last_update(self) -> Optional[datetime]: + """Return the last update of this feed.""" + return self._feed_manager.last_update + + def last_update_successful(self) -> Optional[datetime]: + """Return the last successful update of this feed.""" + return self._feed_manager.last_update_successful + + async def _generate_entity(self, external_id): + """Generate new entity.""" + async_dispatcher_send( + self._hass, + self.async_event_new_entity(), + self, + external_id, + self._unit_system, + ) + + async def _update_entity(self, external_id): + """Update entity.""" + async_dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY.format(external_id)) + + async def _remove_entity(self, external_id): + """Ignore removing entity.""" diff --git a/homeassistant/components/geonetnz_volcano/config_flow.py b/homeassistant/components/geonetnz_volcano/config_flow.py new file mode 100644 index 0000000000000..7c079c432ddd0 --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/config_flow.py @@ -0,0 +1,74 @@ +"""Config flow to configure the GeoNet NZ Volcano integration.""" +import logging + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import ( + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_RADIUS, + CONF_SCAN_INTERVAL, + CONF_UNIT_SYSTEM, + CONF_UNIT_SYSTEM_IMPERIAL, + CONF_UNIT_SYSTEM_METRIC, +) +from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv + +from .const import DEFAULT_RADIUS, DEFAULT_SCAN_INTERVAL, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +@callback +def configured_instances(hass): + """Return a set of configured GeoNet NZ Volcano instances.""" + return set( + f"{entry.data[CONF_LATITUDE]}, {entry.data[CONF_LONGITUDE]}" + for entry in hass.config_entries.async_entries(DOMAIN) + ) + + +class GeonetnzVolcanoFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a GeoNet NZ Volcano config flow.""" + + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def _show_form(self, errors=None): + """Show the form to the user.""" + data_schema = vol.Schema( + {vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS): cv.positive_int} + ) + + return self.async_show_form( + step_id="user", data_schema=data_schema, errors=errors or {} + ) + + 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() + + latitude = user_input.get(CONF_LATITUDE, self.hass.config.latitude) + user_input[CONF_LATITUDE] = latitude + longitude = user_input.get(CONF_LONGITUDE, self.hass.config.longitude) + user_input[CONF_LONGITUDE] = longitude + + identifier = f"{user_input[CONF_LATITUDE]}, {user_input[CONF_LONGITUDE]}" + if identifier in configured_instances(self.hass): + return await self._show_form({"base": "identifier_exists"}) + + if self.hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL: + user_input[CONF_UNIT_SYSTEM] = CONF_UNIT_SYSTEM_IMPERIAL + else: + user_input[CONF_UNIT_SYSTEM] = CONF_UNIT_SYSTEM_METRIC + + scan_interval = user_input.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + user_input[CONF_SCAN_INTERVAL] = scan_interval.seconds + + return self.async_create_entry(title=identifier, data=user_input) diff --git a/homeassistant/components/geonetnz_volcano/const.py b/homeassistant/components/geonetnz_volcano/const.py new file mode 100644 index 0000000000000..7bc15d3a6a1cd --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/const.py @@ -0,0 +1,19 @@ +"""Define constants for the GeoNet NZ Volcano integration.""" +from datetime import timedelta + +DOMAIN = "geonetnz_volcano" + +FEED = "feed" + +ATTR_ACTIVITY = "activity" +ATTR_DISTANCE = "distance" +ATTR_EXTERNAL_ID = "external_id" +ATTR_HAZARDS = "hazards" + +# Icon alias "mdi:mountain" not working. +DEFAULT_ICON = "mdi:image-filter-hdr" +DEFAULT_RADIUS = 50.0 +DEFAULT_SCAN_INTERVAL = timedelta(minutes=5) + +SIGNAL_NEW_SENSOR = "geonetnz_volcano_new_sensor_{}" +SIGNAL_UPDATE_ENTITY = "geonetnz_volcano_update_{}" diff --git a/homeassistant/components/geonetnz_volcano/manifest.json b/homeassistant/components/geonetnz_volcano/manifest.json new file mode 100644 index 0000000000000..2fa10812d378a --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "geonetnz_volcano", + "name": "GeoNet NZ Volcano", + "config_flow": true, + "documentation": "https://www.home-assistant.io/components/geonetnz_volcano", + "requirements": ["aio_geojson_geonetnz_volcano==0.5"], + "dependencies": [], + "codeowners": ["@exxamalte"] +} diff --git a/homeassistant/components/geonetnz_volcano/sensor.py b/homeassistant/components/geonetnz_volcano/sensor.py new file mode 100644 index 0000000000000..f87ea88fc1cba --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/sensor.py @@ -0,0 +1,169 @@ +"""Feed Entity Manager Sensor support for GeoNet NZ Volcano Feeds.""" +import logging +from typing import Optional + +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_LATITUDE, + ATTR_LONGITUDE, + CONF_UNIT_SYSTEM_IMPERIAL, + LENGTH_KILOMETERS, +) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity +from homeassistant.util import dt +from homeassistant.util.unit_system import IMPERIAL_SYSTEM + +from .const import ( + ATTR_ACTIVITY, + ATTR_DISTANCE, + ATTR_EXTERNAL_ID, + ATTR_HAZARDS, + DEFAULT_ICON, + DOMAIN, + FEED, + SIGNAL_UPDATE_ENTITY, +) + +_LOGGER = logging.getLogger(__name__) + +ATTR_LAST_UPDATE = "feed_last_update" +ATTR_LAST_UPDATE_SUCCESSFUL = "feed_last_update_successful" + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up the GeoNet NZ Volcano Feed platform.""" + manager = hass.data[DOMAIN][FEED][entry.entry_id] + + @callback + def async_add_sensor(feed_manager, external_id, unit_system): + """Add sensor entity from feed.""" + new_entity = GeonetnzVolcanoSensor( + entry.entry_id, feed_manager, external_id, unit_system + ) + _LOGGER.debug("Adding sensor %s", new_entity) + async_add_entities([new_entity], True) + + manager.listeners.append( + async_dispatcher_connect( + hass, manager.async_event_new_entity(), async_add_sensor + ) + ) + hass.async_create_task(manager.async_update()) + _LOGGER.debug("Sensor setup done") + + +class GeonetnzVolcanoSensor(Entity): + """This represents an external event with GeoNet NZ Volcano feed data.""" + + def __init__(self, config_entry_id, feed_manager, external_id, unit_system): + """Initialize entity with data from feed entry.""" + self._config_entry_id = config_entry_id + self._feed_manager = feed_manager + self._external_id = external_id + self._unit_system = unit_system + self._title = None + self._distance = None + self._latitude = None + self._longitude = None + self._attribution = None + self._alert_level = None + self._activity = None + self._hazards = None + self._feed_last_update = None + self._feed_last_update_successful = None + self._remove_signal_update = None + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + self._remove_signal_update = async_dispatcher_connect( + self.hass, + SIGNAL_UPDATE_ENTITY.format(self._external_id), + self._update_callback, + ) + + async def async_will_remove_from_hass(self) -> None: + """Call when entity will be removed from hass.""" + if self._remove_signal_update: + self._remove_signal_update() + + @callback + def _update_callback(self): + """Call update method.""" + self.async_schedule_update_ha_state(True) + + @property + def should_poll(self): + """No polling needed for GeoNet NZ Volcano feed location events.""" + return False + + async def async_update(self): + """Update this entity from the data held in the feed manager.""" + _LOGGER.debug("Updating %s", self._external_id) + feed_entry = self._feed_manager.get_entry(self._external_id) + last_update = self._feed_manager.last_update() + last_update_successful = self._feed_manager.last_update_successful() + if feed_entry: + self._update_from_feed(feed_entry, last_update, last_update_successful) + + def _update_from_feed(self, feed_entry, last_update, last_update_successful): + """Update the internal state from the provided feed entry.""" + self._title = feed_entry.title + # Convert distance if not metric system. + if self._unit_system == CONF_UNIT_SYSTEM_IMPERIAL: + self._distance = round( + IMPERIAL_SYSTEM.length(feed_entry.distance_to_home, LENGTH_KILOMETERS), + 1, + ) + else: + self._distance = round(feed_entry.distance_to_home, 1) + self._latitude = round(feed_entry.coordinates[0], 5) + self._longitude = round(feed_entry.coordinates[1], 5) + self._attribution = feed_entry.attribution + self._alert_level = feed_entry.alert_level + self._activity = feed_entry.activity + self._hazards = feed_entry.hazards + self._feed_last_update = dt.as_utc(last_update) if last_update else None + self._feed_last_update_successful = ( + dt.as_utc(last_update_successful) if last_update_successful else None + ) + + @property + def state(self): + """Return the state of the sensor.""" + return self._alert_level + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return DEFAULT_ICON + + @property + def name(self) -> Optional[str]: + """Return the name of the entity.""" + return f"Volcano {self._title}" + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return "alert level" + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + attributes = {} + for key, value in ( + (ATTR_EXTERNAL_ID, self._external_id), + (ATTR_ATTRIBUTION, self._attribution), + (ATTR_ACTIVITY, self._activity), + (ATTR_HAZARDS, self._hazards), + (ATTR_LONGITUDE, self._longitude), + (ATTR_LATITUDE, self._latitude), + (ATTR_DISTANCE, self._distance), + (ATTR_LAST_UPDATE, self._feed_last_update), + (ATTR_LAST_UPDATE_SUCCESSFUL, self._feed_last_update_successful), + ): + if value or isinstance(value, bool): + attributes[key] = value + return attributes diff --git a/homeassistant/components/geonetnz_volcano/strings.json b/homeassistant/components/geonetnz_volcano/strings.json new file mode 100644 index 0000000000000..93ec8603d03b0 --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/strings.json @@ -0,0 +1,16 @@ +{ + "config": { + "title": "GeoNet NZ Volcano", + "step": { + "user": { + "title": "Fill in your filter details.", + "data": { + "radius": "Radius" + } + } + }, + "error": { + "identifier_exists": "Location already registered" + } + } +} diff --git a/homeassistant/components/gios/.translations/ca.json b/homeassistant/components/gios/.translations/ca.json new file mode 100644 index 0000000000000..80fedcafdd97d --- /dev/null +++ b/homeassistant/components/gios/.translations/ca.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "cannot_connect": "No s'ha pogut connectar al servidor de GIO\u015a.", + "invalid_sensors_data": "Les dades dels sensors d'aquesta estaci\u00f3 de mesura s\u00f3n inv\u00e0lides.", + "wrong_station_id": "L'ID de l'estaci\u00f3 de mesura \u00e9s incorrecte." + }, + "step": { + "user": { + "data": { + "name": "Nom de la integraci\u00f3", + "station_id": "ID de l'estaci\u00f3 de mesura" + }, + "description": "Integraci\u00f3 de mesura de qualitat de l\u2019aire GIO\u015a (Polish Chief Inspectorate Of Environmental Protection). Si necessites ajuda amb la configuraci\u00f3, fes un cop d'ull a: https://www.home-assistant.io/integrations/gios", + "title": "GIO\u015a (Polish Chief Inspectorate Of Environmental Protection)" + } + }, + "title": "GIO\u015a" + } +} \ No newline at end of file diff --git a/homeassistant/components/gios/.translations/da.json b/homeassistant/components/gios/.translations/da.json new file mode 100644 index 0000000000000..b4855da795168 --- /dev/null +++ b/homeassistant/components/gios/.translations/da.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "cannot_connect": "Kan ikke oprette forbindelse til GIO\u015a-serveren.", + "invalid_sensors_data": "Ugyldige sensordata for denne m\u00e5lestation.", + "wrong_station_id": "M\u00e5lestationens ID er ikke korrekt." + }, + "step": { + "user": { + "data": { + "name": "Navn p\u00e5 integrationen", + "station_id": "ID for m\u00e5lestationen" + }, + "description": "Ops\u00e6t GIO\u015a (polsk inspektorat for milj\u00f8beskyttelse) luftkvalitet-integration. Hvis du har brug for hj\u00e6lp med konfigurationen, kig her: https://www.home-assistant.io/integrations/gios", + "title": "GIO\u015a (Polish Chief Inspectorate Of Environmental Protection)" + } + }, + "title": "GIO\u015a" + } +} \ No newline at end of file diff --git a/homeassistant/components/gios/.translations/de.json b/homeassistant/components/gios/.translations/de.json new file mode 100644 index 0000000000000..36813d71d760b --- /dev/null +++ b/homeassistant/components/gios/.translations/de.json @@ -0,0 +1,18 @@ +{ + "config": { + "error": { + "cannot_connect": "Es kann keine Verbindung zum GIO\u015a-Server hergestellt werden.", + "invalid_sensors_data": "Ung\u00fcltige Sensordaten f\u00fcr diese Messstation.", + "wrong_station_id": "ID der Messstation ist nicht korrekt." + }, + "step": { + "user": { + "data": { + "name": "Name der Integration", + "station_id": "ID der Messstation" + } + } + }, + "title": "GIO\u015a" + } +} \ No newline at end of file diff --git a/homeassistant/components/gios/.translations/en.json b/homeassistant/components/gios/.translations/en.json new file mode 100644 index 0000000000000..2ff0d8c60f323 --- /dev/null +++ b/homeassistant/components/gios/.translations/en.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "cannot_connect": "Cannot connect to the GIO\u015a server.", + "invalid_sensors_data": "Invalid sensors' data for this measuring station.", + "wrong_station_id": "ID of the measuring station is not correct." + }, + "step": { + "user": { + "data": { + "name": "Name of the integration", + "station_id": "ID of the measuring station" + }, + "description": "Set up GIO\u015a (Polish Chief Inspectorate Of Environmental Protection) air quality integration. If you need help with the configuration have a look here: https://www.home-assistant.io/integrations/gios", + "title": "GIO\u015a (Polish Chief Inspectorate Of Environmental Protection)" + } + }, + "title": "GIO\u015a" + } +} \ No newline at end of file diff --git a/homeassistant/components/gios/.translations/es.json b/homeassistant/components/gios/.translations/es.json new file mode 100644 index 0000000000000..9be1581329aab --- /dev/null +++ b/homeassistant/components/gios/.translations/es.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "cannot_connect": "No se puede conectar al servidor GIO\u015a.", + "invalid_sensors_data": "Datos de sensores no v\u00e1lidos para esta estaci\u00f3n de medici\u00f3n.", + "wrong_station_id": "El ID de la estaci\u00f3n de medici\u00f3n no es correcta." + }, + "step": { + "user": { + "data": { + "name": "Nombre de la integraci\u00f3n", + "station_id": "ID de la estaci\u00f3n de medici\u00f3n" + }, + "description": "Configurar la integraci\u00f3n de la calidad del aire GIO\u015a (Inspecci\u00f3n Jefe de Protecci\u00f3n Ambiental de Polonia). Si necesita ayuda con la configuraci\u00f3n, eche un vistazo aqu\u00ed: https://www.home-assistant.io/integrations/gios", + "title": "GIO\u015a (Inspecci\u00f3n Jefe de Protecci\u00f3n del Medio Ambiente de Polonia)" + } + }, + "title": "GIO\u015a" + } +} \ No newline at end of file diff --git a/homeassistant/components/gios/.translations/fr.json b/homeassistant/components/gios/.translations/fr.json new file mode 100644 index 0000000000000..2a9136bab4f66 --- /dev/null +++ b/homeassistant/components/gios/.translations/fr.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "Impossible de se connecter au serveur GIOS", + "invalid_sensors_data": "Donn\u00e9es des capteurs non valides pour cette station de mesure.", + "wrong_station_id": "L'identifiant de la station de mesure n'est pas correct." + }, + "step": { + "user": { + "data": { + "name": "Nom de l'int\u00e9gration", + "station_id": "Identifiant de la station de mesure" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gios/.translations/ko.json b/homeassistant/components/gios/.translations/ko.json new file mode 100644 index 0000000000000..2a92f935794de --- /dev/null +++ b/homeassistant/components/gios/.translations/ko.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "cannot_connect": "GIO\u015a \uc11c\ubc84\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", + "invalid_sensors_data": "\uc774 \uce21\uc815 \uc2a4\ud14c\uc774\uc158\uc5d0 \ub300\ud55c \uc13c\uc11c \ub370\uc774\ud130\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", + "wrong_station_id": "\uce21\uc815 \uc2a4\ud14c\uc774\uc158\uc758 ID \uac00 \uc62c\ubc14\ub974\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4." + }, + "step": { + "user": { + "data": { + "name": "\ud1b5\ud569 \uad6c\uc131\uc694\uc18c\uc758 \uc774\ub984", + "station_id": "\uce21\uc815 \uc2a4\ud14c\uc774\uc158\uc758 ID" + }, + "description": "\ud3f4\ub780\ub4dc \ud658\uacbd\uccad (GIO\u015a) \ub300\uae30\uc9c8 \ud1b5\ud569 \uad6c\uc131\uc694\uc18c\ub97c \uc124\uc815\ud569\ub2c8\ub2e4. \uad6c\uc131\uc5d0 \ub3c4\uc6c0\uc774 \ud544\uc694\ud55c \uacbd\uc6b0 https://www.home-assistant.io/integrations/gios \ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694", + "title": "\ud3f4\ub780\ub4dc \ud658\uacbd\uccad (GIO\u015a)" + } + }, + "title": "GIO\u015a" + } +} \ No newline at end of file diff --git a/homeassistant/components/gios/.translations/no.json b/homeassistant/components/gios/.translations/no.json new file mode 100644 index 0000000000000..3abfe3bfbb896 --- /dev/null +++ b/homeassistant/components/gios/.translations/no.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "cannot_connect": "Kan ikke koble til GIO\u015a-tjeneren", + "invalid_sensors_data": "Ugyldig sensordata for denne m\u00e5lestasjonen", + "wrong_station_id": "ID for m\u00e5lestasjon er ikke korrekt" + }, + "step": { + "user": { + "data": { + "name": "Navn p\u00e5 integrasjon", + "station_id": "ID til m\u00e5lestasjon" + }, + "description": "Sett opp GIO\u015a (Polish Chief Inspectorate Of Environmental Protection) luftkvalitet integrering. Hvis du trenger hjelp med konfigurasjonen ta en titt her: https://www.home-assistant.io/integrations/gios", + "title": "GIO\u015a (Polish Chief Inspectorate Of Environmental Protection)" + } + }, + "title": "GIO\u015a" + } +} \ No newline at end of file diff --git a/homeassistant/components/gios/.translations/pl.json b/homeassistant/components/gios/.translations/pl.json new file mode 100644 index 0000000000000..d3623004fba21 --- /dev/null +++ b/homeassistant/components/gios/.translations/pl.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "cannot_connect": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z serwerem GIO\u015a.", + "invalid_sensors_data": "Nieprawid\u0142owe dane sensor\u00f3w dla tej stacji pomiarowej.", + "wrong_station_id": "Identyfikator stacji pomiarowej nie jest prawid\u0142owy." + }, + "step": { + "user": { + "data": { + "name": "Nazwa integracji", + "station_id": "Identyfikator stacji pomiarowej" + }, + "description": "Konfiguracja integracji jako\u015bci powietrza GIO\u015a (G\u0142\u00f3wny Inspektorat Ochrony \u015arodowiska). Je\u015bli potrzebujesz pomocy z konfiguracj\u0105, przejd\u017a na stron\u0119: https://www.home-assistant.io/integrations/gios", + "title": "G\u0142\u00f3wny Inspektorat Ochrony \u015arodowiska (GIO\u015a)" + } + }, + "title": "GIO\u015a" + } +} \ No newline at end of file diff --git a/homeassistant/components/gios/.translations/pt-BR.json b/homeassistant/components/gios/.translations/pt-BR.json new file mode 100644 index 0000000000000..83add749e4710 --- /dev/null +++ b/homeassistant/components/gios/.translations/pt-BR.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "title": "GIO\u015a (Inspetor-Chefe Polon\u00eas de Prote\u00e7\u00e3o Ambiental)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gios/.translations/ru.json b/homeassistant/components/gios/.translations/ru.json new file mode 100644 index 0000000000000..ea2c2997d4d70 --- /dev/null +++ b/homeassistant/components/gios/.translations/ru.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "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 GIO\u015a.", + "invalid_sensors_data": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 \u0434\u0430\u0442\u0447\u0438\u043a\u043e\u0432 \u0434\u043b\u044f \u044d\u0442\u043e\u0439 \u0438\u0437\u043c\u0435\u0440\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0439 \u0441\u0442\u0430\u043d\u0446\u0438\u0438.", + "wrong_station_id": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 ID \u0438\u0437\u043c\u0435\u0440\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0439 \u0441\u0442\u0430\u043d\u0446\u0438\u0438." + }, + "step": { + "user": { + "data": { + "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438", + "station_id": "ID \u0438\u0437\u043c\u0435\u0440\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0439 \u0441\u0442\u0430\u043d\u0446\u0438\u0438" + }, + "description": "\u0418\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f \u043e \u043a\u0430\u0447\u0435\u0441\u0442\u0432\u0435 \u0432\u043e\u0437\u0434\u0443\u0445\u0430 \u043e\u0442 \u041f\u043e\u043b\u044c\u0441\u043a\u043e\u0439 \u0438\u043d\u0441\u043f\u0435\u043a\u0446\u0438\u0438 \u043f\u043e \u043e\u0445\u0440\u0430\u043d\u0435 \u043e\u043a\u0440\u0443\u0436\u0430\u044e\u0449\u0435\u0439 \u0441\u0440\u0435\u0434\u044b (GIO\u015a). \u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 \u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u0435\u0439 \u043f\u043e \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0435 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438: https://www.home-assistant.io/integrations/gios.", + "title": "GIO\u015a (\u041f\u043e\u043b\u044c\u0441\u043a\u0430\u044f \u0438\u043d\u0441\u043f\u0435\u043a\u0446\u0438\u044f \u043f\u043e \u043e\u0445\u0440\u0430\u043d\u0435 \u043e\u043a\u0440\u0443\u0436\u0430\u044e\u0449\u0435\u0439 \u0441\u0440\u0435\u0434\u044b)" + } + }, + "title": "GIO\u015a" + } +} \ No newline at end of file diff --git a/homeassistant/components/gios/.translations/sl.json b/homeassistant/components/gios/.translations/sl.json new file mode 100644 index 0000000000000..da3995dd0b341 --- /dev/null +++ b/homeassistant/components/gios/.translations/sl.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "cannot_connect": "Ne morem se povezati s stre\u017enikom GIO\u015a.", + "invalid_sensors_data": "Neveljavni podatki senzorjev za to merilno postajo.", + "wrong_station_id": "ID merilne postaje ni pravilen." + }, + "step": { + "user": { + "data": { + "name": "Ime integracije", + "station_id": "ID merilne postaje" + }, + "description": "Nastavite GIO\u015a (poljski glavni in\u0161pektorat za varstvo okolja) integracijo kakovosti zraka. \u010ce potrebujete pomo\u010d pri konfiguraciji si oglejte tukaj: https://www.home-assistant.io/integrations/gios", + "title": "GIO\u015a (glavni poljski in\u0161pektorat za varstvo okolja)" + } + }, + "title": "GIO\u015a" + } +} \ No newline at end of file diff --git a/homeassistant/components/gios/.translations/zh-Hant.json b/homeassistant/components/gios/.translations/zh-Hant.json new file mode 100644 index 0000000000000..19d13572c72b2 --- /dev/null +++ b/homeassistant/components/gios/.translations/zh-Hant.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "cannot_connect": "\u7121\u6cd5\u9023\u7dda\u81f3 GIO\u015a \u4f3a\u670d\u5668\u3002", + "invalid_sensors_data": "\u6b64\u76e3\u6e2c\u7ad9\u50b3\u611f\u5668\u8cc7\u6599\u7121\u6548\u3002", + "wrong_station_id": "\u76e3\u6e2c\u7ad9 ID \u4e0d\u6b63\u78ba\u3002" + }, + "step": { + "user": { + "data": { + "name": "\u6574\u5408\u540d\u7a31", + "station_id": "\u76e3\u6e2c\u7ad9 ID" + }, + "description": "\u8a2d\u5b9a GIO\u015a\uff08\u6ce2\u862d\u7e3d\u74b0\u5883\u4fdd\u8b77\u7763\u5bdf\u8655\uff09\u7a7a\u6c23\u54c1\u8cea\u6574\u5408\u3002\u5047\u5982\u9700\u8981\u5354\u52a9\uff0c\u8acb\u53c3\u8003\uff1ahttps://www.home-assistant.io/integrations/gios", + "title": "GIO\u015a\uff08\u6ce2\u862d\u7e3d\u74b0\u5883\u4fdd\u8b77\u7763\u5bdf\u8655\uff09" + } + }, + "title": "GIO\u015a" + } +} \ No newline at end of file diff --git a/homeassistant/components/gios/__init__.py b/homeassistant/components/gios/__init__.py new file mode 100644 index 0000000000000..981de6395deae --- /dev/null +++ b/homeassistant/components/gios/__init__.py @@ -0,0 +1,78 @@ +"""The GIOS component.""" +import asyncio +import logging + +from aiohttp.client_exceptions import ClientConnectorError +from async_timeout import timeout +from gios import ApiError, Gios, NoStationError + +from homeassistant.core import Config, HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.util import Throttle + +from .const import CONF_STATION_ID, DATA_CLIENT, DEFAULT_SCAN_INTERVAL, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistant, config: Config) -> bool: + """Set up configured GIOS.""" + hass.data[DOMAIN] = {} + hass.data[DOMAIN][DATA_CLIENT] = {} + return True + + +async def async_setup_entry(hass, config_entry): + """Set up GIOS as config entry.""" + station_id = config_entry.data[CONF_STATION_ID] + _LOGGER.debug("Using station_id: %s", station_id) + + websession = async_get_clientsession(hass) + + gios = GiosData(websession, station_id) + + await gios.async_update() + + hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] = gios + + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, "air_quality") + ) + 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") + return True + + +class GiosData: + """Define an object to hold GIOS data.""" + + def __init__(self, session, station_id): + """Initialize.""" + self._gios = Gios(station_id, session) + self.station_id = station_id + self.sensors = {} + self.latitude = None + self.longitude = None + self.station_name = None + self.available = True + + @Throttle(DEFAULT_SCAN_INTERVAL) + async def async_update(self): + """Update GIOS data.""" + try: + with timeout(30): + await self._gios.update() + except asyncio.TimeoutError: + _LOGGER.error("Asyncio Timeout Error") + except (ApiError, NoStationError, ClientConnectorError) as error: + _LOGGER.error("GIOS data update failed: %s", error) + self.available = self._gios.available + self.latitude = self._gios.latitude + self.longitude = self._gios.longitude + self.station_name = self._gios.station_name + self.sensors = self._gios.data diff --git a/homeassistant/components/gios/air_quality.py b/homeassistant/components/gios/air_quality.py new file mode 100644 index 0000000000000..f7285c8cc5a85 --- /dev/null +++ b/homeassistant/components/gios/air_quality.py @@ -0,0 +1,158 @@ +"""Support for the GIOS service.""" +from homeassistant.components.air_quality import ( + ATTR_CO, + ATTR_NO2, + ATTR_OZONE, + ATTR_PM_2_5, + ATTR_PM_10, + ATTR_SO2, + AirQualityEntity, +) +from homeassistant.const import CONF_NAME + +from .const import ATTR_STATION, DATA_CLIENT, DEFAULT_SCAN_INTERVAL, DOMAIN, ICONS_MAP + +ATTRIBUTION = "Data provided by GIOŚ" +SCAN_INTERVAL = DEFAULT_SCAN_INTERVAL + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Add a GIOS entities from a config_entry.""" + name = config_entry.data[CONF_NAME] + + data = hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] + + async_add_entities([GiosAirQuality(data, name)], 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 GiosAirQuality(AirQualityEntity): + """Define an GIOS sensor.""" + + def __init__(self, gios, name): + """Initialize.""" + self.gios = gios + self._name = name + self._aqi = None + self._co = None + self._no2 = None + self._o3 = None + self._pm_2_5 = None + self._pm_10 = None + self._so2 = None + self._attrs = {} + + @property + def name(self): + """Return the name.""" + return self._name + + @property + def icon(self): + """Return the icon.""" + if self._aqi in ICONS_MAP: + return ICONS_MAP[self._aqi] + return "mdi:blur" + + @property + 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 + @round_state + def ozone(self): + """Return the O3 (ozone) level.""" + return self._o3 + + @property + @round_state + def carbon_monoxide(self): + """Return the CO (carbon monoxide) level.""" + return self._co + + @property + @round_state + def sulphur_dioxide(self): + """Return the SO2 (sulphur dioxide) level.""" + return self._so2 + + @property + @round_state + def nitrogen_dioxide(self): + """Return the NO2 (nitrogen dioxide) level.""" + return self._no2 + + @property + def attribution(self): + """Return the attribution.""" + return ATTRIBUTION + + @property + def unique_id(self): + """Return a unique_id for this entity.""" + return self.gios.station_id + + @property + def available(self): + """Return True if entity is available.""" + return self.gios.available + + @property + def device_state_attributes(self): + """Return the state attributes.""" + self._attrs[ATTR_STATION] = self.gios.station_name + return self._attrs + + async def async_update(self): + """Get the data from GIOS.""" + await self.gios.async_update() + + if self.gios.available: + # Different measuring stations have different sets of sensors. We don't know + # what data we will get. + if "AQI" in self.gios.sensors: + self._aqi = self.gios.sensors["AQI"]["value"] + if "CO" in self.gios.sensors: + self._co = self.gios.sensors["CO"]["value"] + self._attrs[f"{ATTR_CO}_index"] = self.gios.sensors["CO"]["index"] + if "NO2" in self.gios.sensors: + self._no2 = self.gios.sensors["NO2"]["value"] + self._attrs[f"{ATTR_NO2}_index"] = self.gios.sensors["NO2"]["index"] + if "O3" in self.gios.sensors: + self._o3 = self.gios.sensors["O3"]["value"] + self._attrs[f"{ATTR_OZONE}_index"] = self.gios.sensors["O3"]["index"] + if "PM2.5" in self.gios.sensors: + self._pm_2_5 = self.gios.sensors["PM2.5"]["value"] + self._attrs[f"{ATTR_PM_2_5}_index"] = self.gios.sensors["PM2.5"][ + "index" + ] + if "PM10" in self.gios.sensors: + self._pm_10 = self.gios.sensors["PM10"]["value"] + self._attrs[f"{ATTR_PM_10}_index"] = self.gios.sensors["PM10"]["index"] + if "SO2" in self.gios.sensors: + self._so2 = self.gios.sensors["SO2"]["value"] + self._attrs[f"{ATTR_SO2}_index"] = self.gios.sensors["SO2"]["index"] diff --git a/homeassistant/components/gios/config_flow.py b/homeassistant/components/gios/config_flow.py new file mode 100644 index 0000000000000..368d610c22640 --- /dev/null +++ b/homeassistant/components/gios/config_flow.py @@ -0,0 +1,65 @@ +"""Adds config flow for GIOS.""" +import asyncio + +from aiohttp.client_exceptions import ClientConnectorError +from async_timeout import timeout +from gios import ApiError, Gios, NoStationError +import voluptuous as vol + +from homeassistant import config_entries, exceptions +from homeassistant.const import CONF_NAME +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_STATION_ID, DEFAULT_NAME, DOMAIN # pylint:disable=unused-import + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_STATION_ID): int, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): str, + } +) + + +class GiosFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Config flow for GIOS.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + errors = {} + + if user_input is not None: + try: + await self.async_set_unique_id( + user_input[CONF_STATION_ID], raise_on_progress=False + ) + self._abort_if_unique_id_configured() + + websession = async_get_clientsession(self.hass) + + with timeout(30): + gios = Gios(user_input[CONF_STATION_ID], websession) + await gios.update() + + if not gios.available: + raise InvalidSensorsData() + + return self.async_create_entry( + title=user_input[CONF_STATION_ID], data=user_input, + ) + except (ApiError, ClientConnectorError, asyncio.TimeoutError): + errors["base"] = "cannot_connect" + except NoStationError: + errors[CONF_STATION_ID] = "wrong_station_id" + except InvalidSensorsData: + errors[CONF_STATION_ID] = "invalid_sensors_data" + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + +class InvalidSensorsData(exceptions.HomeAssistantError): + """Error to indicate invalid sensors data.""" diff --git a/homeassistant/components/gios/const.py b/homeassistant/components/gios/const.py new file mode 100644 index 0000000000000..3588b5e8dfcf0 --- /dev/null +++ b/homeassistant/components/gios/const.py @@ -0,0 +1,25 @@ +"""Constants for GIOS integration.""" +from datetime import timedelta + +ATTR_NAME = "name" +ATTR_STATION = "station" +CONF_STATION_ID = "station_id" +DATA_CLIENT = "client" +DEFAULT_NAME = "GIOŚ" +# Term of service GIOŚ allow downloading data no more than twice an hour. +DEFAULT_SCAN_INTERVAL = timedelta(minutes=30) +DOMAIN = "gios" + +AQI_GOOD = "dobry" +AQI_MODERATE = "umiarkowany" +AQI_POOR = "dostateczny" +AQI_VERY_GOOD = "bardzo dobry" +AQI_VERY_POOR = "zły" + +ICONS_MAP = { + AQI_VERY_GOOD: "mdi:emoticon-excited", + AQI_GOOD: "mdi:emoticon-happy", + AQI_MODERATE: "mdi:emoticon-neutral", + AQI_POOR: "mdi:emoticon-sad", + AQI_VERY_POOR: "mdi:emoticon-dead", +} diff --git a/homeassistant/components/gios/manifest.json b/homeassistant/components/gios/manifest.json new file mode 100644 index 0000000000000..b3d125d8ab642 --- /dev/null +++ b/homeassistant/components/gios/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "gios", + "name": "GIOŚ", + "documentation": "https://www.home-assistant.io/integrations/gios", + "dependencies": [], + "codeowners": ["@bieniu"], + "requirements": ["gios==0.0.3"], + "config_flow": true +} diff --git a/homeassistant/components/gios/strings.json b/homeassistant/components/gios/strings.json new file mode 100644 index 0000000000000..cc05a471b4a13 --- /dev/null +++ b/homeassistant/components/gios/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "title": "GIOŚ", + "step": { + "user": { + "title": "GIOŚ (Polish Chief Inspectorate Of Environmental Protection)", + "description": "Set up GIOŚ (Polish Chief Inspectorate Of Environmental Protection) air quality integration. If you need help with the configuration have a look here: https://www.home-assistant.io/integrations/gios", + "data": { + "name": "Name of the integration", + "station_id": "ID of the measuring station" + } + } + }, + "error": { + "wrong_station_id": "ID of the measuring station is not correct.", + "invalid_sensors_data": "Invalid sensors' data for this measuring station.", + "cannot_connect": "Cannot connect to the GIOŚ server." + } + } +} diff --git a/homeassistant/components/github/__init__.py b/homeassistant/components/github/__init__.py new file mode 100644 index 0000000000000..6dd5d7f16ff68 --- /dev/null +++ b/homeassistant/components/github/__init__.py @@ -0,0 +1 @@ +"""The github component.""" diff --git a/homeassistant/components/github/manifest.json b/homeassistant/components/github/manifest.json new file mode 100644 index 0000000000000..c2686346e5b29 --- /dev/null +++ b/homeassistant/components/github/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "github", + "name": "GitHub", + "documentation": "https://www.home-assistant.io/integrations/github", + "requirements": ["PyGithub==1.43.8"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/github/sensor.py b/homeassistant/components/github/sensor.py new file mode 100644 index 0000000000000..c77cf7930b83d --- /dev/null +++ b/homeassistant/components/github/sensor.py @@ -0,0 +1,214 @@ +"""Support for GitHub.""" +from datetime import timedelta +import logging + +import github +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + ATTR_NAME, + CONF_ACCESS_TOKEN, + CONF_NAME, + CONF_PATH, + CONF_URL, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +CONF_REPOS = "repositories" + +ATTR_LATEST_COMMIT_MESSAGE = "latest_commit_message" +ATTR_LATEST_COMMIT_SHA = "latest_commit_sha" +ATTR_LATEST_RELEASE_URL = "latest_release_url" +ATTR_LATEST_OPEN_ISSUE_URL = "latest_open_issue_url" +ATTR_OPEN_ISSUES = "open_issues" +ATTR_LATEST_OPEN_PULL_REQUEST_URL = "latest_open_pull_request_url" +ATTR_OPEN_PULL_REQUESTS = "open_pull_requests" +ATTR_PATH = "path" +ATTR_STARGAZERS = "stargazers" + +DEFAULT_NAME = "GitHub" + +SCAN_INTERVAL = timedelta(seconds=300) + +REPO_SCHEMA = vol.Schema( + {vol.Required(CONF_PATH): cv.string, vol.Optional(CONF_NAME): cv.string} +) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_ACCESS_TOKEN): cv.string, + vol.Optional(CONF_URL): cv.url, + vol.Required(CONF_REPOS): vol.All(cv.ensure_list, [REPO_SCHEMA]), + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the GitHub sensor platform.""" + sensors = [] + for repository in config[CONF_REPOS]: + data = GitHubData( + repository=repository, + access_token=config.get(CONF_ACCESS_TOKEN), + server_url=config.get(CONF_URL), + ) + if data.setup_error is True: + _LOGGER.error( + "Error setting up GitHub platform. %s", + "Check previous errors for details", + ) + return + sensors.append(GitHubSensor(data)) + add_entities(sensors, True) + + +class GitHubSensor(Entity): + """Representation of a GitHub sensor.""" + + def __init__(self, github_data): + """Initialize the GitHub sensor.""" + self._unique_id = github_data.repository_path + self._name = None + self._state = None + self._available = False + self._repository_path = None + self._latest_commit_message = None + self._latest_commit_sha = None + self._latest_release_url = None + self._open_issue_count = None + self._latest_open_issue_url = None + self._pull_request_count = None + self._latest_open_pr_url = None + self._stargazers = None + self._github_data = github_data + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def unique_id(self): + """Return unique ID for the sensor.""" + return self._unique_id + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def available(self): + """Return True if entity is available.""" + return self._available + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + ATTR_PATH: self._repository_path, + ATTR_NAME: self._name, + ATTR_LATEST_COMMIT_MESSAGE: self._latest_commit_message, + ATTR_LATEST_COMMIT_SHA: self._latest_commit_sha, + ATTR_LATEST_RELEASE_URL: self._latest_release_url, + ATTR_LATEST_OPEN_ISSUE_URL: self._latest_open_issue_url, + ATTR_OPEN_ISSUES: self._open_issue_count, + ATTR_LATEST_OPEN_PULL_REQUEST_URL: self._latest_open_pr_url, + ATTR_OPEN_PULL_REQUESTS: self._pull_request_count, + ATTR_STARGAZERS: self._stargazers, + } + + @property + def icon(self): + """Return the icon to use in the frontend.""" + return "mdi:github-circle" + + def update(self): + """Collect updated data from GitHub API.""" + self._github_data.update() + + self._name = self._github_data.name + self._state = self._github_data.latest_commit_sha + self._repository_path = self._github_data.repository_path + self._available = self._github_data.available + self._latest_commit_message = self._github_data.latest_commit_message + self._latest_commit_sha = self._github_data.latest_commit_sha + self._latest_release_url = self._github_data.latest_release_url + self._open_issue_count = self._github_data.open_issue_count + self._latest_open_issue_url = self._github_data.latest_open_issue_url + self._pull_request_count = self._github_data.pull_request_count + self._latest_open_pr_url = self._github_data.latest_open_pr_url + self._stargazers = self._github_data.stargazers + + +class GitHubData: + """GitHub Data object.""" + + def __init__(self, repository, access_token=None, server_url=None): + """Set up GitHub.""" + self._github = github + + self.setup_error = False + + try: + if server_url is not None: + server_url += "/api/v3" + self._github_obj = github.Github(access_token, base_url=server_url) + else: + self._github_obj = github.Github(access_token) + + self.repository_path = repository[CONF_PATH] + + repo = self._github_obj.get_repo(self.repository_path) + except self._github.GithubException as err: + _LOGGER.error("GitHub error for %s: %s", self.repository_path, err) + self.setup_error = True + return + + self.name = repository.get(CONF_NAME, repo.name) + + self.available = False + self.latest_commit_message = None + self.latest_commit_sha = None + self.latest_release_url = None + self.open_issue_count = None + self.latest_open_issue_url = None + self.pull_request_count = None + self.latest_open_pr_url = None + self.stargazers = None + + def update(self): + """Update GitHub Sensor.""" + try: + repo = self._github_obj.get_repo(self.repository_path) + + self.stargazers = repo.stargazers_count + + open_issues = repo.get_issues(state="open", sort="created") + if open_issues is not None: + self.open_issue_count = open_issues.totalCount + if open_issues.totalCount > 0: + self.latest_open_issue_url = open_issues[0].html_url + + open_pull_requests = repo.get_pulls(state="open", sort="created") + if open_pull_requests is not None: + self.pull_request_count = open_pull_requests.totalCount + if open_pull_requests.totalCount > 0: + self.latest_open_pr_url = open_pull_requests[0].html_url + + latest_commit = repo.get_commits()[0] + self.latest_commit_sha = latest_commit.sha + self.latest_commit_message = latest_commit.commit.message + + releases = repo.get_releases() + if releases and releases.totalCount > 0: + self.latest_release_url = releases[0].html_url + + self.available = True + except self._github.GithubException as err: + _LOGGER.error("GitHub error for %s: %s", self.repository_path, err) + self.available = False diff --git a/homeassistant/components/gitlab_ci/__init__.py b/homeassistant/components/gitlab_ci/__init__.py new file mode 100644 index 0000000000000..93b2a08c714a4 --- /dev/null +++ b/homeassistant/components/gitlab_ci/__init__.py @@ -0,0 +1 @@ +"""The gitlab_ci component.""" diff --git a/homeassistant/components/gitlab_ci/manifest.json b/homeassistant/components/gitlab_ci/manifest.json new file mode 100644 index 0000000000000..ba29f56cfba37 --- /dev/null +++ b/homeassistant/components/gitlab_ci/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "gitlab_ci", + "name": "GitLab-CI", + "documentation": "https://www.home-assistant.io/integrations/gitlab_ci", + "requirements": ["python-gitlab==1.6.0"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/gitlab_ci/sensor.py b/homeassistant/components/gitlab_ci/sensor.py new file mode 100644 index 0000000000000..9edbe9733a899 --- /dev/null +++ b/homeassistant/components/gitlab_ci/sensor.py @@ -0,0 +1,181 @@ +"""Sensor for retrieving latest GitLab CI job information.""" +from datetime import timedelta +import logging + +from gitlab import Gitlab, GitlabAuthenticationError, GitlabGetError +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + ATTR_ATTRIBUTION, + CONF_NAME, + CONF_SCAN_INTERVAL, + CONF_TOKEN, + CONF_URL, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +_LOGGER = logging.getLogger(__name__) + +ATTR_BUILD_BRANCH = "build branch" +ATTR_BUILD_COMMIT_DATE = "commit date" +ATTR_BUILD_COMMIT_ID = "commit id" +ATTR_BUILD_DURATION = "build_duration" +ATTR_BUILD_FINISHED = "build_finished" +ATTR_BUILD_ID = "build id" +ATTR_BUILD_STARTED = "build_started" +ATTR_BUILD_STATUS = "build_status" +ATTRIBUTION = "Information provided by https://gitlab.com/" + +CONF_GITLAB_ID = "gitlab_id" + +DEFAULT_NAME = "GitLab CI Status" +DEFAULT_URL = "https://gitlab.com" + +ICON_HAPPY = "mdi:emoticon-happy" +ICON_OTHER = "mdi:git" +ICON_SAD = "mdi:emoticon-sad" + +SCAN_INTERVAL = timedelta(seconds=300) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_GITLAB_ID): cv.string, + vol.Required(CONF_TOKEN): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_URL, default=DEFAULT_URL): cv.string, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the GitLab sensor platform.""" + _name = config.get(CONF_NAME) + _interval = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) + _url = config.get(CONF_URL) + + _gitlab_data = GitLabData( + priv_token=config[CONF_TOKEN], + gitlab_id=config[CONF_GITLAB_ID], + interval=_interval, + url=_url, + ) + + add_entities([GitLabSensor(_gitlab_data, _name)], True) + + +class GitLabSensor(Entity): + """Representation of a GitLab sensor.""" + + def __init__(self, gitlab_data, name): + """Initialize the GitLab sensor.""" + self._available = False + self._state = None + self._started_at = None + self._finished_at = None + self._duration = None + self._commit_id = None + self._commit_date = None + self._build_id = None + self._branch = None + self._gitlab_data = gitlab_data + self._name = name + + @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 available(self): + """Return True if entity is available.""" + return self._available + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + ATTR_ATTRIBUTION: ATTRIBUTION, + ATTR_BUILD_STATUS: self._state, + ATTR_BUILD_STARTED: self._started_at, + ATTR_BUILD_FINISHED: self._finished_at, + ATTR_BUILD_DURATION: self._duration, + ATTR_BUILD_COMMIT_ID: self._commit_id, + ATTR_BUILD_COMMIT_DATE: self._commit_date, + ATTR_BUILD_ID: self._build_id, + ATTR_BUILD_BRANCH: self._branch, + } + + @property + def icon(self): + """Return the icon to use in the frontend.""" + if self._state == "success": + return ICON_HAPPY + if self._state == "failed": + return ICON_SAD + return ICON_OTHER + + def update(self): + """Collect updated data from GitLab API.""" + self._gitlab_data.update() + + self._state = self._gitlab_data.status + self._started_at = self._gitlab_data.started_at + self._finished_at = self._gitlab_data.finished_at + self._duration = self._gitlab_data.duration + self._commit_id = self._gitlab_data.commit_id + self._commit_date = self._gitlab_data.commit_date + self._build_id = self._gitlab_data.build_id + self._branch = self._gitlab_data.branch + self._available = self._gitlab_data.available + + +class GitLabData: + """GitLab Data object.""" + + def __init__(self, gitlab_id, priv_token, interval, url): + """Fetch data from GitLab API for most recent CI job.""" + + self._gitlab_id = gitlab_id + self._gitlab = Gitlab(url, private_token=priv_token, per_page=1) + self._gitlab.auth() + self.update = Throttle(interval)(self._update) + + self.available = False + self.status = None + self.started_at = None + self.finished_at = None + self.duration = None + self.commit_id = None + self.commit_date = None + self.build_id = None + self.branch = None + + def _update(self): + try: + _projects = self._gitlab.projects.get(self._gitlab_id) + _last_pipeline = _projects.pipelines.list(page=1)[0] + _last_job = _last_pipeline.jobs.list(page=1)[0] + self.status = _last_pipeline.attributes.get("status") + self.started_at = _last_job.attributes.get("started_at") + self.finished_at = _last_job.attributes.get("finished_at") + self.duration = _last_job.attributes.get("duration") + _commit = _last_job.attributes.get("commit") + self.commit_id = _commit.get("id") + self.commit_date = _commit.get("committed_date") + self.build_id = _last_job.attributes.get("id") + self.branch = _last_job.attributes.get("ref") + self.available = True + except GitlabAuthenticationError as erra: + _LOGGER.error("Authentication Error: %s", erra) + self.available = False + except GitlabGetError as errg: + _LOGGER.error("Project Not Found: %s", errg) + self.available = False diff --git a/homeassistant/components/gitter/__init__.py b/homeassistant/components/gitter/__init__.py new file mode 100644 index 0000000000000..25656f70f5671 --- /dev/null +++ b/homeassistant/components/gitter/__init__.py @@ -0,0 +1 @@ +"""The gitter component.""" diff --git a/homeassistant/components/gitter/manifest.json b/homeassistant/components/gitter/manifest.json new file mode 100644 index 0000000000000..35904b3a57b19 --- /dev/null +++ b/homeassistant/components/gitter/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "gitter", + "name": "Gitter", + "documentation": "https://www.home-assistant.io/integrations/gitter", + "requirements": ["gitterpy==0.1.7"], + "dependencies": [], + "codeowners": ["@fabaff"] +} diff --git a/homeassistant/components/gitter/sensor.py b/homeassistant/components/gitter/sensor.py new file mode 100644 index 0000000000000..4f1eeca7d7190 --- /dev/null +++ b/homeassistant/components/gitter/sensor.py @@ -0,0 +1,105 @@ +"""Support for displaying details about a Gitter.im chat room.""" +import logging + +from gitterpy.client import GitterClient +from gitterpy.errors import GitterRoomError, GitterTokenError +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_ROOM +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +ATTR_MENTION = "mention" +ATTR_ROOM = "room" +ATTR_USERNAME = "username" + +DEFAULT_NAME = "Gitter messages" +DEFAULT_ROOM = "home-assistant/home-assistant" + +ICON = "mdi:message-settings-variant" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_API_KEY): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_ROOM, default=DEFAULT_ROOM): cv.string, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Gitter sensor.""" + + name = config.get(CONF_NAME) + api_key = config.get(CONF_API_KEY) + room = config.get(CONF_ROOM) + + gitter = GitterClient(api_key) + try: + username = gitter.auth.get_my_id["name"] + except GitterTokenError: + _LOGGER.error("Token is not valid") + return + + add_entities([GitterSensor(gitter, room, name, username)], True) + + +class GitterSensor(Entity): + """Representation of a Gitter sensor.""" + + def __init__(self, data, room, name, username): + """Initialize the sensor.""" + self._name = name + self._data = data + self._room = room + self._username = username + self._state = None + self._mention = 0 + self._unit_of_measurement = "Msg" + + @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.""" + return { + ATTR_USERNAME: self._username, + ATTR_ROOM: self._room, + ATTR_MENTION: self._mention, + } + + @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.""" + + try: + data = self._data.user.unread_items(self._room) + except GitterRoomError as error: + _LOGGER.error(error) + return + + if "error" not in data.keys(): + self._mention = len(data["mention"]) + self._state = len(data["chat"]) + else: + _LOGGER.error("Not joined: %s", self._room) diff --git a/homeassistant/components/glances/.translations/bg.json b/homeassistant/components/glances/.translations/bg.json new file mode 100644 index 0000000000000..8604dda565a32 --- /dev/null +++ b/homeassistant/components/glances/.translations/bg.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured": "\u0410\u0434\u0440\u0435\u0441\u044a\u0442 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d." + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u0441 \u0430\u0434\u0440\u0435\u0441\u0430", + "wrong_version": "\u0412\u0435\u0440\u0441\u0438\u044f\u0442\u0430 \u043d\u0435 \u0441\u0435 \u043f\u043e\u0434\u0434\u044a\u0440\u0436\u0430 (\u043f\u043e\u0434\u0434\u044a\u0440\u0436\u0430\u043d\u0438 \u0432\u0435\u0440\u0441\u0438\u0438: 2 \u0438\u043b\u0438 3)" + }, + "step": { + "user": { + "data": { + "host": "\u0410\u0434\u0440\u0435\u0441", + "name": "\u0418\u043c\u0435", + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "port": "\u041f\u043e\u0440\u0442", + "ssl": "\u0418\u0437\u043f\u043e\u043b\u0437\u0432\u0430\u0439\u0442\u0435 SSL/TLS, \u0437\u0430 \u0434\u0430 \u0441\u0435 \u0441\u0432\u044a\u0440\u0436\u0435\u0442\u0435 \u043a\u044a\u043c \u0441\u0438\u0441\u0442\u0435\u043c\u0430\u0442\u0430 Glances", + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435", + "verify_ssl": "\u041f\u0440\u043e\u0432\u0435\u0440\u043a\u0430 \u043d\u0430 \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u0446\u0438\u0440\u0430\u043d\u0435\u0442\u043e \u043d\u0430 \u0441\u0438\u0441\u0442\u0435\u043c\u0430\u0442\u0430", + "version": "Glances API \u0432\u0435\u0440\u0441\u0438\u044f (2 \u0438\u043b\u0438 3)" + }, + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043d\u0430 Glances" + } + }, + "title": "Glances" + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "\u0427\u0435\u0441\u0442\u043e\u0442\u0430 \u043d\u0430 \u0430\u043a\u0442\u0443\u0430\u043b\u0438\u0437\u0438\u0440\u0430\u043d\u0435" + }, + "description": "\u041a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u043e\u043f\u0446\u0438\u0438 \u0437\u0430 Glances" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/glances/.translations/ca.json b/homeassistant/components/glances/.translations/ca.json new file mode 100644 index 0000000000000..2610fe156aab1 --- /dev/null +++ b/homeassistant/components/glances/.translations/ca.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured": "L'amfitri\u00f3 ja est\u00e0 configurat." + }, + "error": { + "cannot_connect": "No s'ha pogut connectar amb l'amfitri\u00f3", + "wrong_version": "Versi\u00f3 no compatible (2 o 3 necess\u00e0ria)" + }, + "step": { + "user": { + "data": { + "host": "Amfitri\u00f3", + "name": "Nom", + "password": "Contrasenya", + "port": "Port", + "ssl": "Utilitza SSL/TLS per connectar-te al sistema Glances", + "username": "Nom d'usuari", + "verify_ssl": "Verifica la certificaci\u00f3 del sistema", + "version": "Versi\u00f3 de l'API de Glances (2 o 3)" + }, + "title": "Configuraci\u00f3 de Glances" + } + }, + "title": "Glances" + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Freq\u00fc\u00e8ncia d\u2019actualitzaci\u00f3" + }, + "description": "Opcions de configuraci\u00f3 de Glances" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/glances/.translations/da.json b/homeassistant/components/glances/.translations/da.json new file mode 100644 index 0000000000000..7779c6e40a049 --- /dev/null +++ b/homeassistant/components/glances/.translations/da.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured": "V\u00e6rten er allerede konfigureret." + }, + "error": { + "cannot_connect": "Kunne ikke oprette forbindelse til v\u00e6rt", + "wrong_version": "Version underst\u00f8ttes ikke (kun 2 eller 3)" + }, + "step": { + "user": { + "data": { + "host": "V\u00e6rt", + "name": "Navn", + "password": "Adgangskode", + "port": "Port", + "ssl": "Brug SSL/TLS til at oprette forbindelse til Glances-systemet", + "username": "Brugernavn", + "verify_ssl": "Bekr\u00e6ft certificering af systemet", + "version": "Glances API version (2 eller 3)" + }, + "title": "Ops\u00e6tning af Glances" + } + }, + "title": "Glances" + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Opdateringsfrekvens" + }, + "description": "Konfigurationsindstillinger for Glances" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/glances/.translations/de.json b/homeassistant/components/glances/.translations/de.json new file mode 100644 index 0000000000000..8330745f4b44a --- /dev/null +++ b/homeassistant/components/glances/.translations/de.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured": "Host ist bereits konfiguriert." + }, + "error": { + "cannot_connect": "Verbindung zum Host nicht m\u00f6glich", + "wrong_version": "Version nicht unterst\u00fctzt (nur 2 oder 3)" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Name", + "password": "Passwort", + "port": "Port", + "ssl": "Verwenden Sie SSL / TLS, um eine Verbindung zum Glances-System herzustellen", + "username": "Benutzername", + "verify_ssl": "\u00dcberpr\u00fcfen Sie die Zertifizierung des Systems", + "version": "Glances API-Version (2 oder 3)" + }, + "title": "Glances einrichten" + } + }, + "title": "Glances" + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Aktualisierungsfrequenz" + }, + "description": "Konfigurieren Sie die Optionen f\u00fcr Glances" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/glances/.translations/en.json b/homeassistant/components/glances/.translations/en.json new file mode 100644 index 0000000000000..ef1a8fb5e3171 --- /dev/null +++ b/homeassistant/components/glances/.translations/en.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured": "Host is already configured." + }, + "error": { + "cannot_connect": "Unable to connect to host", + "wrong_version": "Version not supported (2 or 3 only)" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Name", + "password": "Password", + "port": "Port", + "ssl": "Use SSL/TLS to connect to the Glances system", + "username": "Username", + "verify_ssl": "Verify the certification of the system", + "version": "Glances API Version (2 or 3)" + }, + "title": "Setup Glances" + } + }, + "title": "Glances" + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Update frequency" + }, + "description": "Configure options for Glances" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/glances/.translations/es.json b/homeassistant/components/glances/.translations/es.json new file mode 100644 index 0000000000000..1b6b0335192a6 --- /dev/null +++ b/homeassistant/components/glances/.translations/es.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured": "El host ya est\u00e1 configurado." + }, + "error": { + "cannot_connect": "No se puede conectar al host", + "wrong_version": "Versi\u00f3n no soportada (s\u00f3lo 2 o 3)" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Nombre", + "password": "Contrase\u00f1a", + "port": "Puerto", + "ssl": "Utilice SSL/TLS para conectarse al sistema Glances", + "username": "Nombre de usuario", + "verify_ssl": "Verificar la certificaci\u00f3n del sistema", + "version": "Versi\u00f3n API Glances (2 o 3)" + }, + "title": "Configurar Glances" + } + }, + "title": "Glances" + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Frecuencia de actualizaci\u00f3n" + }, + "description": "Configurar opciones para Glances" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/glances/.translations/fi.json b/homeassistant/components/glances/.translations/fi.json new file mode 100644 index 0000000000000..43ccf405d145f --- /dev/null +++ b/homeassistant/components/glances/.translations/fi.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "user": { + "data": { + "name": "Nimi", + "password": "Salasana", + "port": "portti" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/glances/.translations/fr.json b/homeassistant/components/glances/.translations/fr.json new file mode 100644 index 0000000000000..b65df092b32af --- /dev/null +++ b/homeassistant/components/glances/.translations/fr.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured": "L'h\u00f4te est d\u00e9j\u00e0 configur\u00e9." + }, + "error": { + "cannot_connect": "Impossible de se connecter \u00e0 l'h\u00f4te", + "wrong_version": "Version non prise en charge (2 ou 3 uniquement)" + }, + "step": { + "user": { + "data": { + "host": "H\u00f4te", + "name": "Nom", + "password": "Mot de passe", + "port": "Port", + "ssl": "Utiliser SSL / TLS pour se connecter au syst\u00e8me Glances", + "username": "Nom d'utilisateur", + "verify_ssl": "V\u00e9rifier la certification du syst\u00e8me", + "version": "Glances API Version (2 ou 3)" + }, + "title": "Installation de Glances" + } + }, + "title": "Glances" + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Fr\u00e9quence de mise \u00e0 jour" + }, + "description": "Configurer les options pour Glances" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/glances/.translations/it.json b/homeassistant/components/glances/.translations/it.json new file mode 100644 index 0000000000000..5fbfba547d9bb --- /dev/null +++ b/homeassistant/components/glances/.translations/it.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured": "L'host \u00e8 gi\u00e0 configurato." + }, + "error": { + "cannot_connect": "Impossibile connettersi all'host", + "wrong_version": "Versione non supportata (solo 2 o 3)" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Nome", + "password": "Password", + "port": "Porta", + "ssl": "Utilizzare SSL/TLS per connettersi al sistema Glances", + "username": "Nome utente", + "verify_ssl": "Verificare la certificazione del sistema", + "version": "Glances API Version (2 o 3)" + }, + "title": "Impostare Glances" + } + }, + "title": "Glances" + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Frequenza di aggiornamento" + }, + "description": "Configura le opzioni per Glances" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/glances/.translations/ko.json b/homeassistant/components/glances/.translations/ko.json new file mode 100644 index 0000000000000..ad19b589d5de9 --- /dev/null +++ b/homeassistant/components/glances/.translations/ko.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured": "\ud638\uc2a4\ud2b8\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + }, + "error": { + "cannot_connect": "\ud638\uc2a4\ud2b8\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", + "wrong_version": "\ud574\ub2f9 \ubc84\uc804\uc740 \uc9c0\uc6d0\ub418\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4 (2 \ub610\ub294 3\ub9cc \uc9c0\uc6d0)" + }, + "step": { + "user": { + "data": { + "host": "\ud638\uc2a4\ud2b8", + "name": "\uc774\ub984", + "password": "\ube44\ubc00\ubc88\ud638", + "port": "\ud3ec\ud2b8", + "ssl": "SSL/TLS \ub97c \uc0ac\uc6a9\ud558\uc5ec Glances \uc2dc\uc2a4\ud15c\uc5d0 \uc5f0\uacb0", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984", + "verify_ssl": "\uc2dc\uc2a4\ud15c \uc778\uc99d \ud655\uc778", + "version": "Glances API \ubc84\uc804 (2 \ub610\ub294 3)" + }, + "title": "Glances \uc124\uce58" + } + }, + "title": "Glances" + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "\uc5c5\ub370\uc774\ud2b8 \ube48\ub3c4" + }, + "description": "Glances \uc635\uc158 \uad6c\uc131" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/glances/.translations/lb.json b/homeassistant/components/glances/.translations/lb.json new file mode 100644 index 0000000000000..06723a4bd1280 --- /dev/null +++ b/homeassistant/components/glances/.translations/lb.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured": "Apparat ass scho konfigur\u00e9iert" + }, + "error": { + "cannot_connect": "Kann sech net mam Server verbannen.", + "wrong_version": "Versioun net \u00ebnnerst\u00ebtzt (n\u00ebmmen 2 oder 3)" + }, + "step": { + "user": { + "data": { + "host": "Apparat", + "name": "Numm", + "password": "Passwuert", + "port": "Port", + "ssl": "Benotzt SSL/TLS fir sech mam Usiichte System ze verbannen", + "username": "Benotzernumm", + "verify_ssl": "Zertifikatioun vum System iwwerpr\u00e9iwen", + "version": "API Versioun vun den Usiichten (2 oder 3)" + }, + "title": "Usiichten konfigur\u00e9ieren" + } + }, + "title": "Usiichten" + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Intervalle vun de Mise \u00e0 jour" + }, + "description": "Optioune konfigur\u00e9ieren fir d'Usiichten" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/glances/.translations/nl.json b/homeassistant/components/glances/.translations/nl.json new file mode 100644 index 0000000000000..7de81bfee98fe --- /dev/null +++ b/homeassistant/components/glances/.translations/nl.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured": "Host is al geconfigureerd." + }, + "error": { + "cannot_connect": "Kan geen verbinding maken met host", + "wrong_version": "Versie niet ondersteund (alleen 2 of 3)" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Naam", + "password": "Wachtwoord", + "port": "Poort", + "ssl": "Gebruik SSL / TLS om verbinding te maken met het Glances-systeem", + "username": "Gebruikersnaam", + "verify_ssl": "Controleer de certificering van het systeem", + "version": "Glances API-versie (2 of 3)" + }, + "title": "Glances instellen" + } + }, + "title": "Glances" + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Update frequentie" + }, + "description": "Configureer opties voor Glances" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/glances/.translations/nn.json b/homeassistant/components/glances/.translations/nn.json new file mode 100644 index 0000000000000..2c9acc227bd90 --- /dev/null +++ b/homeassistant/components/glances/.translations/nn.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Glances" + } +} \ No newline at end of file diff --git a/homeassistant/components/glances/.translations/no.json b/homeassistant/components/glances/.translations/no.json new file mode 100644 index 0000000000000..7cf28cc34d048 --- /dev/null +++ b/homeassistant/components/glances/.translations/no.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured": "Verten er allerede konfigurert." + }, + "error": { + "cannot_connect": "Kan ikke koble til vert", + "wrong_version": "Versjonen st\u00f8ttes ikke (bare 2 eller 3)" + }, + "step": { + "user": { + "data": { + "host": "Vert", + "name": "Navn", + "password": "Passord", + "port": "Port", + "ssl": "Bruk SSL / TLS for \u00e5 koble til Glances-systemet", + "username": "Brukernavn", + "verify_ssl": "Bekreft sertifiseringen av systemet", + "version": "Glances API-versjon (2 eller 3)" + }, + "title": "Oppsett av Glances" + } + }, + "title": "Glances" + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Oppdater frekvens" + }, + "description": "Konfigurasjonsalternativer for Glances" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/glances/.translations/pl.json b/homeassistant/components/glances/.translations/pl.json new file mode 100644 index 0000000000000..f53d4d413e0a4 --- /dev/null +++ b/homeassistant/components/glances/.translations/pl.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured": "Host jest ju\u017c skonfigurowany." + }, + "error": { + "cannot_connect": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z hostem", + "wrong_version": "Wersja nieobs\u0142ugiwana (tylko 2 lub 3)" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Nazwa", + "password": "Has\u0142o", + "port": "Port", + "ssl": "U\u017cyj SSL/TLS, aby po\u0142\u0105czy\u0107 si\u0119 z systemem Glances", + "username": "Nazwa u\u017cytkownika", + "verify_ssl": "Sprawd\u017a certyfikacj\u0119 systemu", + "version": "Glances wersja API (2 lub 3)" + }, + "title": "Konfiguracja Glances" + } + }, + "title": "Glances" + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Cz\u0119stotliwo\u015b\u0107 aktualizacji" + }, + "description": "Konfiguracja opcji dla Glances" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/glances/.translations/pt-BR.json b/homeassistant/components/glances/.translations/pt-BR.json new file mode 100644 index 0000000000000..05ea657c8b303 --- /dev/null +++ b/homeassistant/components/glances/.translations/pt-BR.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Nome de usu\u00e1rio" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Frequ\u00eancia de atualiza\u00e7\u00e3o" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/glances/.translations/pt.json b/homeassistant/components/glances/.translations/pt.json new file mode 100644 index 0000000000000..b46423599731a --- /dev/null +++ b/homeassistant/components/glances/.translations/pt.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Palavra-passe", + "username": "Nome de Utilizador" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/glances/.translations/ru.json b/homeassistant/components/glances/.translations/ru.json new file mode 100644 index 0000000000000..8effcc6ab163e --- /dev/null +++ b/homeassistant/components/glances/.translations/ru.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0445\u043e\u0441\u0442\u0443.", + "wrong_version": "\u041f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u044e\u0442\u0441\u044f \u0442\u043e\u043b\u044c\u043a\u043e \u0432\u0435\u0440\u0441\u0438\u0438 2 \u0438 3." + }, + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "port": "\u041f\u043e\u0440\u0442", + "ssl": "\u0418\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c SSL / TLS \u0434\u043b\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f", + "username": "\u041b\u043e\u0433\u0438\u043d", + "verify_ssl": "\u041f\u0440\u043e\u0432\u0435\u0440\u044f\u0442\u044c \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044e \u0441\u0438\u0441\u0442\u0435\u043c\u044b", + "version": "\u0412\u0435\u0440\u0441\u0438\u044f API Glances (2 \u0438\u043b\u0438 3)" + }, + "title": "Glances" + } + }, + "title": "Glances" + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "\u0427\u0430\u0441\u0442\u043e\u0442\u0430 \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f" + }, + "description": "\u0414\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0435 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b Glances" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/glances/.translations/sl.json b/homeassistant/components/glances/.translations/sl.json new file mode 100644 index 0000000000000..b1d0fda94b5d1 --- /dev/null +++ b/homeassistant/components/glances/.translations/sl.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured": "Gostitelj je \u017ee konfiguriran." + }, + "error": { + "cannot_connect": "Ni mogo\u010de vzpostaviti povezave z gostiteljem", + "wrong_version": "Razli\u010dica ni podprta (samo 2 ali 3)" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Ime", + "password": "Geslo", + "port": "Vrata", + "ssl": "Za povezavo s sistemom Glances uporabite SSL/TLS", + "username": "Uporabni\u0161ko ime", + "verify_ssl": "Preverite veljavnost potrdila sistema", + "version": "Glances API Version (2 ali 3)" + }, + "title": "Nastavite Glances" + } + }, + "title": "Glances" + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Pogostost posodabljanja" + }, + "description": "Konfiguracija mo\u017enosti za Glances" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/glances/.translations/th.json b/homeassistant/components/glances/.translations/th.json new file mode 100644 index 0000000000000..718c857c490f5 --- /dev/null +++ b/homeassistant/components/glances/.translations/th.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "\u0e0a\u0e37\u0e48\u0e2d\u0e1c\u0e39\u0e49\u0e43\u0e0a\u0e49" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/glances/.translations/zh-Hant.json b/homeassistant/components/glances/.translations/zh-Hant.json new file mode 100644 index 0000000000000..12ba7670355d8 --- /dev/null +++ b/homeassistant/components/glances/.translations/zh-Hant.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured": "\u4e3b\u6a5f\u7aef\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210\u3002" + }, + "error": { + "cannot_connect": "\u7121\u6cd5\u9023\u7dda\u81f3\u4e3b\u6a5f\u7aef", + "wrong_version": "\u7248\u672c\u4e0d\u652f\u63f4\uff08\u50c5 2 \u6216 3\uff09" + }, + "step": { + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef", + "name": "\u540d\u7a31", + "password": "\u5bc6\u78bc", + "port": "\u901a\u8a0a\u57e0", + "ssl": "\u4f7f\u7528 SSL/TLS \u9023\u7dda\u81f3 Glances \u7cfb\u7d71", + "username": "\u4f7f\u7528\u8005\u540d\u7a31", + "verify_ssl": "\u9a57\u8b49\u7cfb\u7d71\u8a8d\u8b49", + "version": "Glances API \u7248\u672c\uff082 \u6216 3\uff09" + }, + "title": "\u8a2d\u5b9a Glances" + } + }, + "title": "Glances" + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "\u66f4\u65b0\u983b\u7387" + }, + "description": "Glances \u8a2d\u5b9a\u9078\u9805" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/glances/__init__.py b/homeassistant/components/glances/__init__.py new file mode 100644 index 0000000000000..d09aa78253474 --- /dev/null +++ b/homeassistant/components/glances/__init__.py @@ -0,0 +1,174 @@ +"""The Glances component.""" +from datetime import timedelta +import logging + +from glances_api import Glances, exceptions +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_SCAN_INTERVAL, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.core import Config, HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_time_interval + +from .const import ( + CONF_VERSION, + DATA_UPDATED, + DEFAULT_HOST, + DEFAULT_NAME, + DEFAULT_PORT, + DEFAULT_SCAN_INTERVAL, + DEFAULT_VERSION, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + +GLANCES_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(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, + vol.Optional(CONF_USERNAME): cv.string, + vol.Optional(CONF_PASSWORD): cv.string, + vol.Optional(CONF_SSL, default=False): cv.boolean, + vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, + vol.Optional(CONF_VERSION, default=DEFAULT_VERSION): vol.In([2, 3]), + } + ) +) + +CONFIG_SCHEMA = vol.Schema( + {DOMAIN: vol.All(cv.ensure_list, [GLANCES_SCHEMA])}, extra=vol.ALLOW_EXTRA +) + + +async def async_setup(hass: HomeAssistant, config: Config) -> bool: + """Configure Glances using config flow only.""" + if DOMAIN in config: + for entry in config[DOMAIN]: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=entry + ) + ) + + return True + + +async def async_setup_entry(hass, config_entry): + """Set up Glances from config entry.""" + client = GlancesData(hass, config_entry) + hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = client + if not await client.async_setup(): + return False + + return True + + +async def async_unload_entry(hass, config_entry): + """Unload a config entry.""" + await hass.config_entries.async_forward_entry_unload(config_entry, "sensor") + hass.data[DOMAIN].pop(config_entry.entry_id) + return True + + +class GlancesData: + """Get the latest data from Glances api.""" + + def __init__(self, hass, config_entry): + """Initialize the Glances data.""" + self.hass = hass + self.config_entry = config_entry + self.api = None + self.unsub_timer = None + self.available = False + + @property + def host(self): + """Return client host.""" + return self.config_entry.data[CONF_HOST] + + async def async_update(self): + """Get the latest data from the Glances REST API.""" + try: + await self.api.get_data() + self.available = True + except exceptions.GlancesApiError: + _LOGGER.error("Unable to fetch data from Glances") + self.available = False + _LOGGER.debug("Glances data updated") + async_dispatcher_send(self.hass, DATA_UPDATED) + + async def async_setup(self): + """Set up the Glances client.""" + try: + self.api = get_api(self.hass, self.config_entry.data) + await self.api.get_data() + self.available = True + _LOGGER.debug("Successfully connected to Glances") + + except exceptions.GlancesApiConnectionError: + _LOGGER.debug("Can not connect to Glances") + raise ConfigEntryNotReady + + self.add_options() + self.set_scan_interval(self.config_entry.options[CONF_SCAN_INTERVAL]) + self.config_entry.add_update_listener(self.async_options_updated) + + self.hass.async_create_task( + self.hass.config_entries.async_forward_entry_setup( + self.config_entry, "sensor" + ) + ) + return True + + def add_options(self): + """Add options for Glances integration.""" + if not self.config_entry.options: + options = {CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL} + self.hass.config_entries.async_update_entry( + self.config_entry, options=options + ) + + def set_scan_interval(self, scan_interval): + """Update scan interval.""" + + async def refresh(event_time): + """Get the latest data from Glances api.""" + await self.async_update() + + if self.unsub_timer is not None: + self.unsub_timer() + self.unsub_timer = async_track_time_interval( + self.hass, refresh, timedelta(seconds=scan_interval) + ) + + @staticmethod + async def async_options_updated(hass, entry): + """Triggered by config entry options updates.""" + hass.data[DOMAIN][entry.entry_id].set_scan_interval( + entry.options[CONF_SCAN_INTERVAL] + ) + + +def get_api(hass, entry): + """Return the api from glances_api.""" + params = entry.copy() + params.pop(CONF_NAME) + verify_ssl = params.pop(CONF_VERIFY_SSL) + session = async_get_clientsession(hass, verify_ssl) + return Glances(hass.loop, session, **params) diff --git a/homeassistant/components/glances/config_flow.py b/homeassistant/components/glances/config_flow.py new file mode 100644 index 0000000000000..3c86fae035735 --- /dev/null +++ b/homeassistant/components/glances/config_flow.py @@ -0,0 +1,130 @@ +"""Config flow for Glances.""" +import glances_api +import voluptuous as vol + +from homeassistant import config_entries, core, exceptions +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_SCAN_INTERVAL, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.core import callback + +from . import get_api +from .const import ( + CONF_VERSION, + DEFAULT_HOST, + DEFAULT_NAME, + DEFAULT_PORT, + DEFAULT_SCAN_INTERVAL, + DEFAULT_VERSION, + DOMAIN, + SUPPORTED_VERSIONS, +) + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAME, default=DEFAULT_NAME): str, + vol.Required(CONF_HOST, default=DEFAULT_HOST): str, + vol.Optional(CONF_USERNAME): str, + vol.Optional(CONF_PASSWORD): str, + vol.Required(CONF_PORT, default=DEFAULT_PORT): int, + vol.Required(CONF_VERSION, default=DEFAULT_VERSION): int, + vol.Optional(CONF_SSL, default=False): bool, + vol.Optional(CONF_VERIFY_SSL, default=False): bool, + } +) + + +async def validate_input(hass: core.HomeAssistant, data): + """Validate the user input allows us to connect.""" + for entry in hass.config_entries.async_entries(DOMAIN): + if entry.data[CONF_HOST] == data[CONF_HOST]: + raise AlreadyConfigured + + if data[CONF_VERSION] not in SUPPORTED_VERSIONS: + raise WrongVersion + try: + api = get_api(hass, data) + await api.get_data() + except glances_api.exceptions.GlancesApiConnectionError: + raise CannotConnect + + +class GlancesFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a Glances config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return GlancesOptionsFlowHandler(config_entry) + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + try: + await validate_input(self.hass, user_input) + return self.async_create_entry( + title=user_input[CONF_NAME], data=user_input + ) + except AlreadyConfigured: + return self.async_abort(reason="already_configured") + except CannotConnect: + errors["base"] = "cannot_connect" + except WrongVersion: + errors[CONF_VERSION] = "wrong_version" + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + async def async_step_import(self, import_config): + """Import from Glances sensor config.""" + + return await self.async_step_user(user_input=import_config) + + +class GlancesOptionsFlowHandler(config_entries.OptionsFlow): + """Handle Glances client options.""" + + def __init__(self, config_entry): + """Initialize Glances options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Manage the Glances options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + options = { + vol.Optional( + CONF_SCAN_INTERVAL, + default=self.config_entry.options.get( + CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL + ), + ): int + } + + return self.async_show_form(step_id="init", data_schema=vol.Schema(options)) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class AlreadyConfigured(exceptions.HomeAssistantError): + """Error to indicate host is already configured.""" + + +class WrongVersion(exceptions.HomeAssistantError): + """Error to indicate the selected version is wrong.""" diff --git a/homeassistant/components/glances/const.py b/homeassistant/components/glances/const.py new file mode 100644 index 0000000000000..e47586ea245b5 --- /dev/null +++ b/homeassistant/components/glances/const.py @@ -0,0 +1,36 @@ +"""Constants for Glances component.""" +from homeassistant.const import TEMP_CELSIUS + +DOMAIN = "glances" +CONF_VERSION = "version" + +DEFAULT_HOST = "localhost" +DEFAULT_NAME = "Glances" +DEFAULT_PORT = 61208 +DEFAULT_VERSION = 3 +DEFAULT_SCAN_INTERVAL = 60 + +DATA_UPDATED = "glances_data_updated" +SUPPORTED_VERSIONS = [2, 3] + +SENSOR_TYPES = { + "disk_use_percent": ["Disk used percent", "%", "mdi:harddisk"], + "disk_use": ["Disk used", "GiB", "mdi:harddisk"], + "disk_free": ["Disk free", "GiB", "mdi:harddisk"], + "memory_use_percent": ["RAM used percent", "%", "mdi:memory"], + "memory_use": ["RAM used", "MiB", "mdi:memory"], + "memory_free": ["RAM free", "MiB", "mdi:memory"], + "swap_use_percent": ["Swap used percent", "%", "mdi:memory"], + "swap_use": ["Swap used", "GiB", "mdi:memory"], + "swap_free": ["Swap free", "GiB", "mdi:memory"], + "processor_load": ["CPU load", "15 min", "mdi:memory"], + "process_running": ["Running", "Count", "mdi:memory"], + "process_total": ["Total", "Count", "mdi:memory"], + "process_thread": ["Thread", "Count", "mdi:memory"], + "process_sleeping": ["Sleeping", "Count", "mdi:memory"], + "cpu_use_percent": ["CPU used", "%", "mdi:memory"], + "cpu_temp": ["CPU Temp", TEMP_CELSIUS, "mdi:thermometer"], + "docker_active": ["Containers active", "", "mdi:docker"], + "docker_cpu_use": ["Containers CPU used", "%", "mdi:docker"], + "docker_memory_use": ["Containers RAM used", "MiB", "mdi:docker"], +} diff --git a/homeassistant/components/glances/manifest.json b/homeassistant/components/glances/manifest.json new file mode 100644 index 0000000000000..761f77510b640 --- /dev/null +++ b/homeassistant/components/glances/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "glances", + "name": "Glances", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/glances", + "requirements": ["glances_api==0.2.0"], + "dependencies": [], + "codeowners": ["@fabaff", "@engrbm87"] +} diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py new file mode 100644 index 0000000000000..760958f0dee0d --- /dev/null +++ b/homeassistant/components/glances/sensor.py @@ -0,0 +1,188 @@ +"""Support gathering system information of hosts which are running glances.""" +import logging + +from homeassistant.const import CONF_NAME, STATE_UNAVAILABLE +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from .const import DATA_UPDATED, DOMAIN, SENSOR_TYPES + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the Glances sensors is done through async_setup_entry.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Glances sensors.""" + + glances_data = hass.data[DOMAIN][config_entry.entry_id] + name = config_entry.data[CONF_NAME] + dev = [] + for sensor_type in SENSOR_TYPES: + dev.append( + GlancesSensor(glances_data, name, SENSOR_TYPES[sensor_type][0], sensor_type) + ) + + async_add_entities(dev, True) + + +class GlancesSensor(Entity): + """Implementation of a Glances sensor.""" + + def __init__(self, glances_data, name, sensor_name, sensor_type): + """Initialize the sensor.""" + self.glances_data = glances_data + self._sensor_name = sensor_name + self._name = name + 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._name} {self._sensor_name}" + + @property + def unique_id(self): + """Set unique_id for sensor.""" + return f"{self.glances_data.host}-{self.name}" + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return SENSOR_TYPES[self.type][2] + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self._unit_of_measurement + + @property + def available(self): + """Could the device be accessed during the last update call.""" + return self.glances_data.available + + @property + def state(self): + """Return the state of the resources.""" + return self._state + + @property + def should_poll(self): + """Return the polling requirement for this sensor.""" + return False + + async def async_added_to_hass(self): + """Handle entity which will be added.""" + async_dispatcher_connect( + self.hass, DATA_UPDATED, self._schedule_immediate_update + ) + + @callback + def _schedule_immediate_update(self): + self.async_schedule_update_ha_state(True) + + async def async_update(self): + """Get the latest data from REST API.""" + value = self.glances_data.api.data + + if value is not None: + if self.type == "disk_use_percent": + self._state = value["fs"][0]["percent"] + elif self.type == "disk_use": + self._state = round(value["fs"][0]["used"] / 1024 ** 3, 1) + elif self.type == "disk_free": + try: + self._state = round(value["fs"][0]["free"] / 1024 ** 3, 1) + except KeyError: + self._state = round( + (value["fs"][0]["size"] - value["fs"][0]["used"]) / 1024 ** 3, 1 + ) + elif self.type == "memory_use_percent": + self._state = value["mem"]["percent"] + elif self.type == "memory_use": + self._state = round(value["mem"]["used"] / 1024 ** 2, 1) + elif self.type == "memory_free": + self._state = round(value["mem"]["free"] / 1024 ** 2, 1) + elif self.type == "swap_use_percent": + self._state = value["memswap"]["percent"] + elif self.type == "swap_use": + self._state = round(value["memswap"]["used"] / 1024 ** 3, 1) + elif self.type == "swap_free": + self._state = round(value["memswap"]["free"] / 1024 ** 3, 1) + elif self.type == "processor_load": + # Windows systems don't provide load details + try: + self._state = value["load"]["min15"] + except KeyError: + self._state = value["cpu"]["total"] + elif self.type == "process_running": + self._state = value["processcount"]["running"] + elif self.type == "process_total": + self._state = value["processcount"]["total"] + elif self.type == "process_thread": + self._state = value["processcount"]["thread"] + elif self.type == "process_sleeping": + self._state = value["processcount"]["sleeping"] + elif self.type == "cpu_use_percent": + self._state = value["quicklook"]["cpu"] + elif self.type == "cpu_temp": + for sensor in value["sensors"]: + if sensor["label"] in [ + "amdgpu 1", + "aml_thermal", + "Core 0", + "Core 1", + "CPU Temperature", + "CPU", + "cpu-thermal 1", + "cpu_thermal 1", + "exynos-therm 1", + "Package id 0", + "Physical id 0", + "radeon 1", + "soc-thermal 1", + "soc_thermal 1", + ]: + self._state = sensor["value"] + elif self.type == "docker_active": + count = 0 + try: + for container in value["docker"]["containers"]: + if ( + container["Status"] == "running" + or "Up" in container["Status"] + ): + count += 1 + self._state = count + except KeyError: + self._state = count + elif self.type == "docker_cpu_use": + cpu_use = 0.0 + try: + for container in value["docker"]["containers"]: + if ( + container["Status"] == "running" + or "Up" in container["Status"] + ): + cpu_use += container["cpu"]["total"] + self._state = round(cpu_use, 1) + except KeyError: + self._state = STATE_UNAVAILABLE + elif self.type == "docker_memory_use": + mem_use = 0.0 + try: + for container in value["docker"]["containers"]: + if ( + container["Status"] == "running" + or "Up" in container["Status"] + ): + mem_use += container["memory"]["usage"] + self._state = round(mem_use / 1024 ** 2, 1) + except KeyError: + self._state = STATE_UNAVAILABLE diff --git a/homeassistant/components/glances/strings.json b/homeassistant/components/glances/strings.json new file mode 100644 index 0000000000000..1bd7275daeff1 --- /dev/null +++ b/homeassistant/components/glances/strings.json @@ -0,0 +1,37 @@ +{ + "config": { + "title": "Glances", + "step": { + "user": { + "title": "Setup Glances", + "data": { + "name": "Name", + "host": "Host", + "username": "Username", + "password": "Password", + "port": "Port", + "version": "Glances API Version (2 or 3)", + "ssl": "Use SSL/TLS to connect to the Glances system", + "verify_ssl": "Verify the certification of the system" + } + } + }, + "error": { + "cannot_connect": "Unable to connect to host", + "wrong_version": "Version not supported (2 or 3 only)" + }, + "abort": { + "already_configured": "Host is already configured." + } + }, + "options": { + "step": { + "init": { + "description": "Configure options for Glances", + "data": { + "scan_interval": "Update frequency" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gntp/__init__.py b/homeassistant/components/gntp/__init__.py new file mode 100644 index 0000000000000..c2814f86f06d8 --- /dev/null +++ b/homeassistant/components/gntp/__init__.py @@ -0,0 +1 @@ +"""The gntp component.""" diff --git a/homeassistant/components/gntp/manifest.json b/homeassistant/components/gntp/manifest.json new file mode 100644 index 0000000000000..3433b369456df --- /dev/null +++ b/homeassistant/components/gntp/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "gntp", + "name": "Growl (GnGNTP)", + "documentation": "https://www.home-assistant.io/integrations/gntp", + "requirements": ["gntp==1.0.3"], + "dependencies": [], + "codeowners": ["@robbiet480"] +} diff --git a/homeassistant/components/gntp/notify.py b/homeassistant/components/gntp/notify.py new file mode 100644 index 0000000000000..5c05b097a1fd8 --- /dev/null +++ b/homeassistant/components/gntp/notify.py @@ -0,0 +1,93 @@ +"""GNTP (aka Growl) notification service.""" +import logging +import os + +import gntp.errors +import gntp.notifier +import voluptuous as vol + +from homeassistant.components.notify import ( + ATTR_TITLE, + ATTR_TITLE_DEFAULT, + PLATFORM_SCHEMA, + BaseNotificationService, +) +from homeassistant.const import CONF_PASSWORD, CONF_PORT +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +_GNTP_LOGGER = logging.getLogger("gntp") +_GNTP_LOGGER.setLevel(logging.ERROR) + + +CONF_APP_NAME = "app_name" +CONF_APP_ICON = "app_icon" +CONF_HOSTNAME = "hostname" + +DEFAULT_APP_NAME = "HomeAssistant" +DEFAULT_HOST = "localhost" +DEFAULT_PORT = 23053 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_APP_NAME, default=DEFAULT_APP_NAME): cv.string, + vol.Optional(CONF_APP_ICON): vol.Url, + vol.Optional(CONF_HOSTNAME, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_PASSWORD): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + } +) + + +def get_service(hass, config, discovery_info=None): + """Get the GNTP notification service.""" + if config.get(CONF_APP_ICON) is None: + icon_file = os.path.join( + os.path.dirname(__file__), + "..", + "frontend", + "www_static", + "icons", + "favicon-192x192.png", + ) + with open(icon_file, "rb") as file: + app_icon = file.read() + else: + app_icon = config.get(CONF_APP_ICON) + + return GNTPNotificationService( + config.get(CONF_APP_NAME), + app_icon, + config.get(CONF_HOSTNAME), + config.get(CONF_PASSWORD), + config.get(CONF_PORT), + ) + + +class GNTPNotificationService(BaseNotificationService): + """Implement the notification service for GNTP.""" + + def __init__(self, app_name, app_icon, hostname, password, port): + """Initialize the service.""" + self.gntp = gntp.notifier.GrowlNotifier( + applicationName=app_name, + notifications=["Notification"], + applicationIcon=app_icon, + hostname=hostname, + password=password, + port=port, + ) + try: + self.gntp.register() + except gntp.errors.NetworkError: + _LOGGER.error("Unable to register with the GNTP host") + return + + def send_message(self, message="", **kwargs): + """Send a message to a user.""" + self.gntp.notify( + noteType="Notification", + title=kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT), + description=message, + ) diff --git a/homeassistant/components/goalfeed/__init__.py b/homeassistant/components/goalfeed/__init__.py new file mode 100644 index 0000000000000..cdca99e030975 --- /dev/null +++ b/homeassistant/components/goalfeed/__init__.py @@ -0,0 +1,63 @@ +"""Component for the Goalfeed service.""" +import json + +import pysher +import requests +import voluptuous as vol + +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +import homeassistant.helpers.config_validation as cv + +# Version downgraded due to regression in library +# For details: https://github.com/nlsdfnbch/Pysher/issues/38 +DOMAIN = "goalfeed" + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + +GOALFEED_HOST = "feed.goalfeed.ca" +GOALFEED_AUTH_ENDPOINT = "https://goalfeed.ca/feed/auth" +GOALFEED_APP_ID = "bfd4ed98c1ff22c04074" + + +def setup(hass, config): + """Set up the Goalfeed component.""" + conf = config[DOMAIN] + username = conf.get(CONF_USERNAME) + password = conf.get(CONF_PASSWORD) + + def goal_handler(data): + """Handle goal events.""" + goal = json.loads(json.loads(data)) + + hass.bus.fire("goal", event_data=goal) + + def connect_handler(data): + """Handle connection.""" + post_data = { + "username": username, + "password": password, + "connection_info": data, + } + resp = requests.post(GOALFEED_AUTH_ENDPOINT, post_data, timeout=30).json() + + channel = pusher.subscribe("private-goals", resp["auth"]) + channel.bind("goal", goal_handler) + + pusher = pysher.Pusher( + GOALFEED_APP_ID, secure=False, port=8080, custom_host=GOALFEED_HOST + ) + + pusher.connection.bind("pusher:connection_established", connect_handler) + pusher.connect() + + return True diff --git a/homeassistant/components/goalfeed/manifest.json b/homeassistant/components/goalfeed/manifest.json new file mode 100644 index 0000000000000..f0202dbb4f325 --- /dev/null +++ b/homeassistant/components/goalfeed/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "goalfeed", + "name": "Goalfeed", + "documentation": "https://www.home-assistant.io/integrations/goalfeed", + "requirements": ["pysher==1.0.1"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/gogogate2/__init__.py b/homeassistant/components/gogogate2/__init__.py new file mode 100644 index 0000000000000..ef802a4aa59af --- /dev/null +++ b/homeassistant/components/gogogate2/__init__.py @@ -0,0 +1 @@ +"""The gogogate2 component.""" diff --git a/homeassistant/components/gogogate2/cover.py b/homeassistant/components/gogogate2/cover.py new file mode 100644 index 0000000000000..fcb0182ec0e4d --- /dev/null +++ b/homeassistant/components/gogogate2/cover.py @@ -0,0 +1,114 @@ +"""Support for Gogogate2 garage Doors.""" +import logging + +from pygogogate2 import Gogogate2API as pygogogate2 +import voluptuous as vol + +from homeassistant.components.cover import SUPPORT_CLOSE, SUPPORT_OPEN, CoverDevice +from homeassistant.const import ( + CONF_IP_ADDRESS, + CONF_NAME, + CONF_PASSWORD, + CONF_USERNAME, + STATE_CLOSED, +) +import homeassistant.helpers.config_validation as cv + +_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.""" + + 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/gogogate2/manifest.json b/homeassistant/components/gogogate2/manifest.json new file mode 100644 index 0000000000000..690f2098cac24 --- /dev/null +++ b/homeassistant/components/gogogate2/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "gogogate2", + "name": "Gogogate2", + "documentation": "https://www.home-assistant.io/integrations/gogogate2", + "requirements": ["pygogogate2==0.1.1"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/google/__init__.py b/homeassistant/components/google/__init__.py new file mode 100644 index 0000000000000..0e7ccd33b3320 --- /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 0000000000000..8a6eb6446210b --- /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 0000000000000..5c1be98bb5683 --- /dev/null +++ b/homeassistant/components/google/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "google", + "name": "Google Calendars", + "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 0000000000000..048e886dc4e56 --- /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 new file mode 100644 index 0000000000000..2d848101def80 --- /dev/null +++ b/homeassistant/components/google_assistant/__init__.py @@ -0,0 +1,124 @@ +"""Support for Actions on Google Assistant Smart Home Control.""" +import logging +from typing import Any, Dict + +import voluptuous as vol + +# Typing imports +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import config_validation as cv + +from .const import ( + 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 .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__) + +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, + } +) + +GOOGLE_SERVICE_ACCOUNT = vol.Schema( + { + vol.Required(CONF_PRIVATE_KEY): cv.string, + vol.Required(CONF_CLIENT_EMAIL): cv.string, + }, + extra=vol.ALLOW_EXTRA, +) + + +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"), + cv.deprecated(CONF_API_KEY, invalidation_version="0.105"), + 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, {}) + + 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.""" + agent_user_id = call.data.get("agent_user_id") or call.context.user_id + + 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 + ) + + return True diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py new file mode 100644 index 0000000000000..dcb87d1d93d3a --- /dev/null +++ b/homeassistant/components/google_assistant/const.py @@ -0,0 +1,145 @@ +"""Constants for 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, +) + +DOMAIN = "google_assistant" + +GOOGLE_ASSISTANT_API_ENDPOINT = "/api/google_assistant" + +CONF_EXPOSE = "expose" +CONF_ENTITY_CONFIG = "entity_config" +CONF_EXPOSE_BY_DEFAULT = "expose_by_default" +CONF_EXPOSED_DOMAINS = "exposed_domains" +CONF_PROJECT_ID = "project_id" +CONF_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 = [ + "climate", + "cover", + "fan", + "group", + "input_boolean", + "light", + "media_player", + "scene", + "script", + "switch", + "vacuum", + "lock", + "binary_sensor", + "sensor", + "alarm_control_panel", +] + +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/" +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/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_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 0000000000000..82c256067eb80 --- /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 new file mode 100644 index 0000000000000..6493d75988098 --- /dev/null +++ b/homeassistant/components/google_assistant/helpers.py @@ -0,0 +1,507 @@ +"""Helper classes for Google Assistant integration.""" +from abc import ABC, abstractmethod +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 AbstractConfig(ABC): + """Hold the configuration for Google Assistant.""" + + _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 + + @abstractmethod + def get_agent_user_id(self, context): + """Get agent user ID from context.""" + + @abstractmethod + def should_expose(self, state) -> bool: + """Return if entity should be exposed.""" + + 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 new file mode 100644 index 0000000000000..f8fa51da8d785 --- /dev/null +++ b/homeassistant/components/google_assistant/http.py @@ -0,0 +1,243 @@ +"""Support for Google Actions Smart Home Control.""" +import asyncio +from datetime import timedelta +import logging +from uuid import uuid4 + +from aiohttp import ClientError, ClientResponseError +from aiohttp.web import Request, Response +import jwt + +# Typing imports +from homeassistant.components.http import HomeAssistantView +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 ( + 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 + +_LOGGER = logging.getLogger(__name__) + + +def _get_homegraph_jwt(time, iss, key): + now = int(time.timestamp()) + + 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 + + if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + return False + + 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 + + return is_default_exposed or explicit_expose + + def get_agent_user_id(self, context): + """Get agent user ID making request.""" + return context.user_id + + 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 = True + + def __init__(self, config): + """Initialize the Google Assistant request handler.""" + self.config = config + + async def post(self, request: Request) -> Response: + """Handle Google Assistant requests.""" + message: dict = await request.json() + result = await async_handle_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 0000000000000..0f266801343a0 --- /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 0000000000000..1e8b6c020de1e --- /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 new file mode 100644 index 0000000000000..33a52c8ef6050 --- /dev/null +++ b/homeassistant/components/google_assistant/services.yaml @@ -0,0 +1,5 @@ +request_sync: + 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 new file mode 100644 index 0000000000000..8033bcec8650b --- /dev/null +++ b/homeassistant/components/google_assistant/smart_home.py @@ -0,0 +1,247 @@ +"""Support for Google Assistant Smart Home API.""" +import asyncio +from itertools import product +import logging + +from homeassistant.const import ATTR_ENTITY_ID, __version__ +from homeassistant.util.decorator import Registry + +from .const import ( + ERR_DEVICE_OFFLINE, + ERR_PROTOCOL_ERROR, + ERR_UNKNOWN_ERROR, + EVENT_COMMAND_RECEIVED, + EVENT_QUERY_RECEIVED, + EVENT_SYNC_RECEIVED, +) +from .error import SmartHomeError +from .helpers import GoogleEntity, RequestData, async_get_entities + +HANDLERS = Registry() +_LOGGER = logging.getLogger(__name__) + + +async def async_handle_message(hass, config, user_id, message): + """Handle incoming API messages.""" + data = RequestData(config, user_id, message["requestId"], message.get("devices")) + + 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, data, message): + """Process a message.""" + inputs: list = message.get("inputs") + + if len(inputs) != 1: + return { + "requestId": data.request_id, + "payload": {"errorCode": ERR_PROTOCOL_ERROR}, + } + + handler = HANDLERS.get(inputs[0].get("intent")) + + if handler is None: + return { + "requestId": data.request_id, + "payload": {"errorCode": ERR_PROTOCOL_ERROR}, + } + + try: + 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": data.request_id, + "payload": {"errorCode": ERR_UNKNOWN_ERROR}, + } + + if result is None: + return None + + 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/assistant/smarthome/develop/process-intents#SYNC + """ + hass.bus.async_fire( + EVENT_SYNC_RECEIVED, {"request_id": data.request_id}, context=data.context + ) + + agent_user_id = data.config.get_agent_user_id(data.context) + + devices = await asyncio.gather( + *( + entity.sync_serialize(agent_user_id) + for entity in async_get_entities(hass, data.config) + if entity.should_expose() + ) + ) + + response = {"agentUserId": 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, data, payload): + """Handle action.devices.QUERY request. + + https://developers.google.com/assistant/smarthome/develop/process-intents#QUERY + """ + devices = {} + 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} + continue + + entity = GoogleEntity(hass, data.config, state) + devices[devid] = entity.query_serialize() + + return {"devices": devices} + + +@HANDLERS.register("action.devices.EXECUTE") +async def handle_devices_execute(hass, data, payload): + """Handle action.devices.EXECUTE request. + + 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"] + + 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: + continue + + if entity_id not in entities: + state = hass.states.get(entity_id) + + if state is None: + results[entity_id] = { + "ids": [entity_id], + "status": "ERROR", + "errorCode": ERR_DEVICE_OFFLINE, + } + continue + + entities[entity_id] = GoogleEntity(hass, data.config, state) + + try: + await entities[entity_id].execute(data, execution) + except SmartHomeError as err: + results[entity_id] = { + "ids": [entity_id], + "status": "ERROR", + **err.to_response(), + } + + final_results = list(results.values()) + + for entity in entities.values(): + if entity.entity_id in results: + continue + + entity.async_update() + + final_results.append( + { + "ids": [entity.entity_id], + "status": "SUCCESS", + "states": entity.query_serialize(), + } + ) + + return {"commands": final_results} + + +@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 new file mode 100644 index 0000000000000..14839066ebeff --- /dev/null +++ b/homeassistant/components/google_assistant/trait.py @@ -0,0 +1,1452 @@ +"""Implement the Google Smart Home traits.""" +import logging + +from homeassistant.components import ( + alarm_control_panel, + binary_sensor, + camera, + cover, + fan, + group, + input_boolean, + 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 ( + 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 + +_LOGGER = logging.getLogger(__name__) + +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" +) +COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE = ( + 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 = [] + + +def register_trait(trait): + """Decorate a function to register a trait.""" + TRAITS.append(trait) + return trait + + +def _google_temp_unit(units): + """Return Google temperature unit.""" + if units == TEMP_FAHRENHEIT: + return "F" + return "C" + + +class _Trait: + """Represents a Trait inside Google Assistant skill.""" + + commands = [] + + @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.""" + raise NotImplementedError + + def query_attributes(self): + """Return the attributes of this trait for this entity.""" + raise NotImplementedError + + def can_execute(self, command, params): + """Test if command can be executed.""" + return command in self.commands + + async def execute(self, command, data, params, challenge): + """Execute a trait command.""" + raise NotImplementedError + + +@register_trait +class BrightnessTrait(_Trait): + """Trait to control brightness of a device. + + https://developers.google.com/actions/smarthome/traits/brightness + """ + + name = TRAIT_BRIGHTNESS + commands = [COMMAND_BRIGHTNESS_ABSOLUTE] + + @staticmethod + def supported(domain, features, device_class): + """Test if state is supported.""" + if domain == light.DOMAIN: + return features & light.SUPPORT_BRIGHTNESS + + return False + + def sync_attributes(self): + """Return brightness attributes for a sync request.""" + return {} + + def query_attributes(self): + """Return brightness query attributes.""" + domain = self.state.domain + response = {} + + if domain == light.DOMAIN: + brightness = self.state.attributes.get(light.ATTR_BRIGHTNESS) + if brightness is not None: + response["brightness"] = int(100 * (brightness / 255)) + else: + response["brightness"] = 0 + + return response + + 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, + { + ATTR_ENTITY_ID: self.state.entity_id, + 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 +class OnOffTrait(_Trait): + """Trait to offer basic on and off functionality. + + https://developers.google.com/actions/smarthome/traits/onoff + """ + + name = TRAIT_ONOFF + commands = [COMMAND_ONOFF] + + @staticmethod + def supported(domain, features, device_class): + """Test if state is supported.""" + return domain in ( + group.DOMAIN, + input_boolean.DOMAIN, + switch.DOMAIN, + fan.DOMAIN, + light.DOMAIN, + media_player.DOMAIN, + ) + + def sync_attributes(self): + """Return OnOff attributes for a sync request.""" + return {} + + def query_attributes(self): + """Return OnOff query attributes.""" + return {"on": self.state.state != STATE_OFF} + + async def execute(self, command, data, params, challenge): + """Execute an OnOff command.""" + domain = self.state.domain + + if domain == group.DOMAIN: + service_domain = HA_DOMAIN + service = SERVICE_TURN_ON if params["on"] else SERVICE_TURN_OFF + + else: + service_domain = domain + service = SERVICE_TURN_ON if params["on"] else SERVICE_TURN_OFF + + await self.hass.services.async_call( + service_domain, + service, + {ATTR_ENTITY_ID: self.state.entity_id}, + blocking=True, + context=data.context, + ) + + +@register_trait +class ColorSettingTrait(_Trait): + """Trait to offer color temperature functionality. + + https://developers.google.com/actions/smarthome/traits/colortemperature + """ + + name = TRAIT_COLOR_SETTING + commands = [COMMAND_COLOR_ABSOLUTE] + + @staticmethod + def supported(domain, features, device_class): + """Test if state is supported.""" + if domain != light.DOMAIN: + return False + + 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 + features = attrs.get(ATTR_SUPPORTED_FEATURES, 0) + response = {} + + 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 query_attributes(self): + """Return color temperature query attributes.""" + features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + color = {} + + 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.""" + 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 + ), + ) + + 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 +class SceneTrait(_Trait): + """Trait to offer scene functionality. + + https://developers.google.com/actions/smarthome/traits/scene + """ + + name = TRAIT_SCENE + commands = [COMMAND_ACTIVATE_SCENE] + + @staticmethod + def supported(domain, features, device_class): + """Test if state is supported.""" + return domain in (scene.DOMAIN, script.DOMAIN) + + def sync_attributes(self): + """Return scene attributes for a sync request.""" + # Neither supported domain can support sceneReversible + return {} + + def query_attributes(self): + """Return scene query attributes.""" + return {} + + async def execute(self, 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, + 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 +class TemperatureSettingTrait(_Trait): + """Trait to offer handling both temperature point and modes functionality. + + https://developers.google.com/actions/smarthome/traits/temperaturesetting + """ + + name = TRAIT_TEMPERATURE_SETTING + commands = [ + COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, + COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE, + COMMAND_THERMOSTAT_SET_MODE, + ] + # We do not support "on" as we are unable to know how to restore + # the last mode. + 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_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, device_class): + """Test if state is supported.""" + if domain == climate.DOMAIN: + return True + + 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.""" + response = {} + attrs = self.state.attributes + domain = self.state.domain + response["thermostatTemperatureUnit"] = _google_temp_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: + response["queryOnlyTemperatureSetting"] = True + + 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 + + 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 + ) + 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 + ), + ) + + await self.hass.services.async_call( + 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 + ) + 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), + ) + + temp_low = temp_util.convert( + 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), + ) + + 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, + 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_HVAC_MODE, + { + ATTR_ENTITY_ID: self.state.entity_id, + 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] + + SYNONYMS = { + "input source": ["input source", "input", "source"], + "sound mode": ["sound mode", "effects"], + } + + @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 + or features & media_player.SUPPORT_SELECT_SOUND_MODE + ) + + def sync_attributes(self): + """Return mode attributes for a sync request.""" + + def _generate(name, settings): + mode = { + "name": name, + "name_values": [ + {"name_synonym": self.SYNONYMS.get(name, [name]), "lang": "en"} + ], + "settings": [], + "ordered": False, + } + for setting in settings: + mode["settings"].append( + { + "setting_name": setting, + "setting_values": [ + { + "setting_synonym": self.SYNONYMS.get( + setting, [setting] + ), + "lang": "en", + } + ], + } + ) + return mode + + attrs = self.state.attributes + modes = [] + if media_player.ATTR_INPUT_SOURCE_LIST in attrs: + modes.append( + _generate("input source", attrs[media_player.ATTR_INPUT_SOURCE_LIST]) + ) + + if media_player.ATTR_SOUND_MODE_LIST in attrs: + modes.append( + _generate("sound mode", attrs[media_player.ATTR_SOUND_MODE_LIST]) + ) + + payload = {"availableModes": modes} + + return payload + + def query_attributes(self): + """Return current modes.""" + attrs = self.state.attributes + response = {} + mode_settings = {} + + if media_player.ATTR_INPUT_SOURCE_LIST in attrs: + mode_settings["input source"] = attrs.get(media_player.ATTR_INPUT_SOURCE) + + if media_player.ATTR_SOUND_MODE_LIST in attrs: + mode_settings["sound mode"] = attrs.get(media_player.ATTR_SOUND_MODE) + + 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("input source") + sound_mode = settings.get("sound mode") + + if requested_source: + 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: requested_source, + }, + blocking=True, + context=data.context, + ) + + if sound_mode: + await self.hass.services.async_call( + media_player.DOMAIN, + media_player.SERVICE_SELECT_SOUND_MODE, + { + ATTR_ENTITY_ID: self.state.entity_id, + media_player.ATTR_SOUND_MODE: sound_mode, + }, + 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 0000000000000..97b669245d2de --- /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 0000000000000..bef8a2f08a996 --- /dev/null +++ b/homeassistant/components/google_cloud/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..6721520d130c2 --- /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/__init__.py b/homeassistant/components/google_domains/__init__.py new file mode 100644 index 0000000000000..d440567d9ad93 --- /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 0000000000000..0d47135be50fa --- /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 0000000000000..929df26fa0f38 --- /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 0000000000000..9e33ff5f71546 --- /dev/null +++ b/homeassistant/components/google_maps/device_tracker.py @@ -0,0 +1,123 @@ +"""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) + self._prev_seen = {} + + 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 + + last_seen = dt_util.as_utc(person.datetime) + if last_seen < self._prev_seen.get(dev_id, last_seen): + _LOGGER.warning( + "Ignoring %s update because timestamp " + "is older than last timestamp", + person.nickname, + ) + _LOGGER.debug("%s < %s", last_seen, self._prev_seen[dev_id]) + continue + self._prev_seen[dev_id] = last_seen + + attrs = { + ATTR_ADDRESS: person.address, + ATTR_FULL_NAME: person.full_name, + ATTR_ID: person.id, + ATTR_LAST_SEEN: last_seen, + 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 0000000000000..dc93bbe5c94e7 --- /dev/null +++ b/homeassistant/components/google_maps/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..bc7811a7a8f0c --- /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 0000000000000..1a59e453c6e99 --- /dev/null +++ b/homeassistant/components/google_pubsub/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "google_pubsub", + "name": "Google Pub/Sub", + "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 0000000000000..f7860c57d993f --- /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 0000000000000..dba7020d076a9 --- /dev/null +++ b/homeassistant/components/google_translate/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "google_translate", + "name": "Google Translate Text-to-Speech", + "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 0000000000000..e35a229ab98eb --- /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 0000000000000..9d9a7cffe1ddd --- /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 0000000000000..ce7ca9d10abe5 --- /dev/null +++ b/homeassistant/components/google_travel_time/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "google_travel_time", + "name": "Google Maps 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 0000000000000..3ee72928fc1c1 --- /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 0000000000000..a12bd9d4b8ced --- /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 0000000000000..b46cea0ca46e1 --- /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 0000000000000..9d6f3ea3d58d1 --- /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 0000000000000..a8aa82c69c3d6 --- /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 0000000000000..c2128b27eeba2 --- /dev/null +++ b/homeassistant/components/gpmdp/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "gpmdp", + "name": "Google Play Music Desktop Player (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 0000000000000..e7b18aacc15c6 --- /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 0000000000000..71656d4d13d72 --- /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 0000000000000..16a1bbd51df73 --- /dev/null +++ b/homeassistant/components/gpsd/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..8696dde72cb40 --- /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 0000000000000..f7ce9a67f5489 --- /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 0000000000000..296159f2e5ae0 --- /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 0000000000000..f79a9f5d739df --- /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 0000000000000..b118783cd3c1d --- /dev/null +++ b/homeassistant/components/gpslogger/.translations/da.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Din Home Assistant-instans skal v\u00e6re tilg\u00e6ngelig 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 h\u00e6ndelser til Home Assistant skal du konfigurere webhook-funktionen i GPSLogger.\n\n Udfyld f\u00f8lgende oplysninger: \n\n - Webadresse: `{webhook_url}`\n - Metode: POST\n \nSe [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 0000000000000..82c1dfa3e53b5 --- /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 0000000000000..ad8f978bc5925 --- /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 0000000000000..960198eb04ef5 --- /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 0000000000000..7b90a5c5caa2b --- /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 0000000000000..ae2b217771216 --- /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 0000000000000..2d1dcad217417 --- /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 0000000000000..aab8edbe44a0a --- /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 0000000000000..19bfc36e42423 --- /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 0000000000000..78df911c8689b --- /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 0000000000000..f34a21b789792 --- /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 0000000000000..488a09c376869 --- /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 0000000000000..434fdd2220a48 --- /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 0000000000000..86c68a4cfb959 --- /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 0000000000000..4dcfda527534a --- /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 0000000000000..b33bde95aec7f --- /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 0000000000000..8e205bef437af --- /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 0000000000000..3a927a70e61e9 --- /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 0000000000000..f99efa91c6196 --- /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 0000000000000..c21e76a6eee20 --- /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 0000000000000..aa95d17cbfc12 --- /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 0000000000000..ef90a8d16074f --- /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 0000000000000..48dc9e7a4315b --- /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 0000000000000..d8afc377d400b --- /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 0000000000000..f4fc556961bf5 --- /dev/null +++ b/homeassistant/components/gpslogger/manifest.json @@ -0,0 +1,9 @@ +{ + "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 0000000000000..d5641ef5db819 --- /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/__init__.py b/homeassistant/components/graphite/__init__.py new file mode 100644 index 0000000000000..bf34bc3ddeaac --- /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 0000000000000..49748128258e4 --- /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 0000000000000..4f5899f6a4a6d --- /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 0000000000000..0c55b644d94af --- /dev/null +++ b/homeassistant/components/greeneye_monitor/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "greeneye_monitor", + "name": "GreenEye Monitor (GEM)", + "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 0000000000000..c4b5fc67898d9 --- /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 0000000000000..a7bd0cf437ead --- /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 0000000000000..8b85de598b0c8 --- /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 0000000000000..f0cdd6590d85d --- /dev/null +++ b/homeassistant/components/greenwave/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "greenwave", + "name": "Greenwave Reality", + "documentation": "https://www.home-assistant.io/integrations/greenwave", + "requirements": ["greenwavereality==0.5.1"], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/group.py b/homeassistant/components/group.py deleted file mode 100644 index 4c5e6adb2c685..0000000000000 --- a/homeassistant/components/group.py +++ /dev/null @@ -1,221 +0,0 @@ -""" -homeassistant.components.groups -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Provides functionality to group devices that can be turned on or off. -""" - -import homeassistant as ha -from homeassistant.helpers import generate_entity_id -import homeassistant.util as util -from homeassistant.const import ( - ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, STATE_ON, STATE_OFF, - STATE_HOME, STATE_NOT_HOME, STATE_UNKNOWN) - -DOMAIN = "group" -DEPENDENCIES = [] - -ENTITY_ID_FORMAT = DOMAIN + ".{}" - -ATTR_AUTO = "auto" - -# List of ON/OFF state tuples for groupable states -_GROUP_TYPES = [(STATE_ON, STATE_OFF), (STATE_HOME, STATE_NOT_HOME)] - - -def _get_group_on_off(state): - """ Determine the group on/off states based on a state. """ - for states in _GROUP_TYPES: - if state in states: - return states - - return None, None - - -def is_on(hass, entity_id): - """ Returns if the group state is in its ON-state. """ - state = hass.states.get(entity_id) - - if state: - group_on, _ = _get_group_on_off(state.state) - - # If we found a group_type, compare to ON-state - return group_on is not None and state.state == group_on - - return False - - -def expand_entity_ids(hass, entity_ids): - """ Returns the given list of entity ids and expands group ids into - the entity ids it represents if found. """ - found_ids = [] - - for entity_id in entity_ids: - if not isinstance(entity_id, str): - continue - - entity_id = entity_id.lower() - - try: - # If entity_id points at a group, expand it - domain, _ = util.split_entity_id(entity_id) - - if domain == DOMAIN: - found_ids.extend( - ent_id for ent_id - in get_entity_ids(hass, entity_id) - if ent_id not in found_ids) - - else: - if entity_id not in found_ids: - found_ids.append(entity_id) - - except AttributeError: - # Raised by util.split_entity_id if entity_id is not a string - pass - - return found_ids - - -def get_entity_ids(hass, entity_id, domain_filter=None): - """ Get the entity ids that make up this group. """ - entity_id = entity_id.lower() - - try: - entity_ids = hass.states.get(entity_id).attributes[ATTR_ENTITY_ID] - - if domain_filter: - domain_filter = domain_filter.lower() - - return [ent_id for ent_id in entity_ids - if ent_id.startswith(domain_filter)] - else: - return entity_ids - - except (AttributeError, KeyError): - # AttributeError if state did not exist - # KeyError if key did not exist in attributes - return [] - - -def setup(hass, config): - """ Sets up all groups found definded in the configuration. """ - for name, entity_ids in config.get(DOMAIN, {}).items(): - # Support old deprecated method - 2/28/2015 - if isinstance(entity_ids, str): - entity_ids = entity_ids.split(",") - - setup_group(hass, name, entity_ids) - - return True - - -class Group(object): - """ Tracks a group of entity ids. """ - def __init__(self, hass, name, entity_ids=None, user_defined=True): - self.hass = hass - self.name = name - self.user_defined = user_defined - - self.entity_id = generate_entity_id(ENTITY_ID_FORMAT, name, hass=hass) - - self.tracking = [] - self.group_on, self.group_off = None, None - - if entity_ids is not None: - self.update_tracked_entity_ids(entity_ids) - else: - self.force_update() - - @property - def state(self): - """ Return the current state from the group. """ - return self.hass.states.get(self.entity_id) - - @property - def state_attr(self): - """ State attributes of this group. """ - return { - ATTR_ENTITY_ID: self.tracking, - ATTR_AUTO: not self.user_defined, - ATTR_FRIENDLY_NAME: self.name - } - - def update_tracked_entity_ids(self, entity_ids): - """ Update the tracked entity IDs. """ - self.stop() - self.tracking = tuple(ent_id.lower() for ent_id in entity_ids) - self.group_on, self.group_off = None, None - - self.force_update() - - self.start() - - def force_update(self): - """ Query all the tracked states and update group state. """ - for entity_id in self.tracking: - state = self.hass.states.get(entity_id) - - if state is not None: - self._update_group_state(state.entity_id, None, state) - - # If parsing the entitys did not result in a state, set UNKNOWN - if self.state is None: - self.hass.states.set( - self.entity_id, STATE_UNKNOWN, self.state_attr) - - def start(self): - """ Starts the tracking. """ - self.hass.states.track_change(self.tracking, self._update_group_state) - - def stop(self): - """ Unregisters the group from Home Assistant. """ - self.hass.states.remove(self.entity_id) - - self.hass.bus.remove_listener( - ha.EVENT_STATE_CHANGED, self._update_group_state) - - def _update_group_state(self, entity_id, old_state, new_state): - """ Updates the group state based on a state change by - a tracked entity. """ - - # We have not determined type of group yet - if self.group_on is None: - self.group_on, self.group_off = _get_group_on_off(new_state.state) - - if self.group_on is not None: - # New state of the group is going to be based on the first - # state that we can recognize - self.hass.states.set( - self.entity_id, new_state.state, self.state_attr) - - return - - # There is already a group state - cur_gr_state = self.hass.states.get(self.entity_id).state - group_on, group_off = self.group_on, self.group_off - - # if cur_gr_state = OFF and new_state = ON: set ON - # if cur_gr_state = ON and new_state = OFF: research - # else: ignore - - if cur_gr_state == group_off and new_state.state == group_on: - - self.hass.states.set( - self.entity_id, group_on, self.state_attr) - - elif (cur_gr_state == group_on and - new_state.state == group_off): - - # Check if any of the other states is still on - if not any(self.hass.states.is_state(ent_id, group_on) - for ent_id in self.tracking if entity_id != ent_id): - self.hass.states.set( - self.entity_id, group_off, self.state_attr) - - -def setup_group(hass, name, entity_ids, user_defined=True): - """ Sets up a group state that is the combined state of - several states. Supports ON/OFF and DEVICE_HOME/DEVICE_NOT_HOME. """ - - return Group(hass, name, entity_ids, user_defined) diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py new file mode 100644 index 0000000000000..c8a138abe41cb --- /dev/null +++ b/homeassistant/components/group/__init__.py @@ -0,0 +1,683 @@ +"""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_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 +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 +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.loader import bind_hass + +# mypy: allow-untyped-calls, allow-untyped-defs, no-check-untyped-defs + +DOMAIN = "group" + +ENTITY_ID_FORMAT = DOMAIN + ".{}" + +CONF_ENTITIES = "entities" +CONF_VIEW = "view" +CONF_CONTROL = "control" +CONF_ALL = "all" + +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" + +SERVICE_SET_VISIBILITY = "set_visibility" +SERVICE_SET = "set" +SERVICE_REMOVE = "remove" + +CONTROL_TYPES = vol.In(["hidden", None]) + +_LOGGER = logging.getLogger(__name__) + + +def _conf_preprocess(value): + """Preprocess alternative configuration formats.""" + if not isinstance(value, dict): + value = {CONF_ENTITIES: 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, + CONF_ALL: cv.boolean, + } +) + +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), +] + + +def _get_group_on_off(state): + """Determine the group on/off states based on a state.""" + for states in _GROUP_TYPES: + if state in states: + return states + + return None, None + + +@bind_hass +def is_on(hass, entity_id): + """Test if the group state is in its ON-state.""" + state = hass.states.get(entity_id) + + if state: + group_on, _ = _get_group_on_off(state.state) + + # If we found a group_type, compare to ON-state + return group_on is not None and state.state == group_on + + return False + + +@bind_hass +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: List[str] = [] + for entity_id in entity_ids: + if not isinstance(entity_id, str): + continue + + entity_id = entity_id.lower() + + try: + # If entity_id points at a group, expand it + domain, _ = ha.split_entity_id(entity_id) + + if domain == DOMAIN: + child_entities = get_entity_ids(hass, entity_id) + if entity_id in child_entities: + 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 + ) + + else: + if entity_id not in found_ids: + found_ids.append(entity_id) + + except AttributeError: + # Raised by split_entity_id if entity_id is not a string + pass + + return found_ids + + +@bind_hass +def get_entity_ids( + hass: HomeAssistantType, entity_id: str, domain_filter: Optional[str] = None +) -> List[str]: + """Get members of this group. + + Async friendly. + """ + group = hass.states.get(entity_id) + + if not group or ATTR_ENTITY_ID not in group.attributes: + return [] + + entity_ids = group.attributes[ATTR_ENTITY_ID] + if not domain_filter: + return cast(List[str], entity_ids) + + domain_filter = domain_filter.lower() + "." + + return [ent_id for ent_id in entity_ids if ent_id.startswith(domain_filter)] + + +async def async_setup(hass, config): + """Set up all groups found defined in the configuration.""" + component = hass.data.get(DOMAIN) + + if component is None: + component = hass.data[DOMAIN] = EntityComponent(_LOGGER, DOMAIN, hass) + + await _async_process_config(hass, config, component) + + async def reload_service_handler(service): + """Remove all user-defined groups and load new ones from config.""" + auto = list(filter(lambda e: not e.user_defined, component.entities)) + + conf = await component.async_prepare_reload() + if conf is None: + return + await _async_process_config(hass, conf, component) + + await component.async_add_entities(auto) + + hass.services.async_register( + 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.""" + object_id = service.data[ATTR_OBJECT_ID] + entity_id = ENTITY_ID_FORMAT.format(object_id) + group = component.get_entity(entity_id) + + # new group + if service.service == SERVICE_SET and group is 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 + } + + await Group.async_create_group( + hass, + service.data.get(ATTR_NAME, object_id), + object_id=object_id, + entity_ids=entity_ids, + user_defined=False, + 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) + return + + # update group + if service.service == SERVICE_SET: + need_update = False + + if ATTR_ADD_ENTITIES in service.data: + delta = service.data[ATTR_ADD_ENTITIES] + entity_ids = set(group.tracking) | set(delta) + await group.async_update_tracked_entity_ids(entity_ids) + + if ATTR_ENTITIES in service.data: + entity_ids = service.data[ATTR_ENTITIES] + await group.async_update_tracked_entity_ids(entity_ids) + + if ATTR_NAME in service.data: + group.name = service.data[ATTR_NAME] + need_update = True + + if ATTR_VISIBLE in service.data: + group.visible = service.data[ATTR_VISIBLE] + need_update = True + + if ATTR_ICON in service.data: + group.icon = service.data[ATTR_ICON] + need_update = True + + if ATTR_CONTROL in service.data: + group.control = service.data[ATTR_CONTROL] + need_update = True + + if ATTR_VIEW in service.data: + 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() + + return + + # remove group + if service.service == SERVICE_REMOVE: + await component.async_remove_entity(entity_id) + + hass.services.async_register( + 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=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 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) + + hass.services.async_register( + DOMAIN, + SERVICE_SET_VISIBILITY, + visibility_service_handler, + schema=make_entity_service_schema({vol.Required(ATTR_VISIBLE): cv.boolean}), + ) + + return True + + +async def _async_process_config(hass, config, component): + """Process group configuration.""" + for object_id, conf in config.get(DOMAIN, {}).items(): + name = conf.get(CONF_NAME, object_id) + entity_ids = conf.get(CONF_ENTITIES) or [] + 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, + 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, + mode=None, + ): + """Initialize a group. + + This Object has factory function for creation. + """ + self.hass = hass + self._name = name + self._state = STATE_UNKNOWN + self._icon = icon + self.view = view + if entity_ids: + self.tracking = tuple(ent_id.lower() for ent_id in entity_ids) + else: + self.tracking = tuple() + self.group_on = None + self.group_off = None + self.visible = visible + self.control = control + self.user_defined = user_defined + self.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, + mode=None, + ): + """Initialize a group.""" + return asyncio.run_coroutine_threadsafe( + Group.async_create_group( + 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, + mode=None, + ): + """Initialize a group. + + This method must be run in the event loop. + """ + group = Group( + 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, + mode=mode, + ) + + group.entity_id = async_generate_entity_id( + 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) + + await component.async_add_entities([group], True) + + return group + + @property + def should_poll(self): + """No need to poll because groups will update themselves.""" + return False + + @property + def name(self): + """Return the name of the group.""" + return self._name + + @name.setter + def name(self, value): + """Set Group name.""" + self._name = value + + @property + def state(self): + """Return the state of the group.""" + return self._state + + @property + def icon(self): + """Return the icon of the group.""" + return self._icon + + @icon.setter + def icon(self, value): + """Set Icon for group.""" + self._icon = value + + @property + def hidden(self): + """If group should be hidden or not.""" + if self.visible and not self.view: + return False + return True + + @property + def state_attributes(self): + """Return the state attributes for the group.""" + data = {ATTR_ENTITY_ID: self.tracking, ATTR_ORDER: self._order} + if not self.user_defined: + data[ATTR_AUTO] = True + if self.view: + data[ATTR_VIEW] = True + if self.control: + data[ATTR_CONTROL] = self.control + return data + + @property + def assumed_state(self): + """Test if any member has an assumed state.""" + return self._assumed_state + + def update_tracked_entity_ids(self, entity_ids): + """Update the member entity IDs.""" + asyncio.run_coroutine_threadsafe( + self.async_update_tracked_entity_ids(entity_ids), self.hass.loop + ).result() + + async def async_update_tracked_entity_ids(self, entity_ids): + """Update the member entity IDs. + + This method must be run in the event loop. + """ + await self.async_stop() + self.tracking = tuple(ent_id.lower() for ent_id in entity_ids) + self.group_on, self.group_off = None, None + + await self.async_update_ha_state(True) + self.async_start() + + @callback + def async_start(self): + """Start tracking members. + + This method must be run in the event loop. + """ + if self._async_unsub_state_changed is None: + self._async_unsub_state_changed = async_track_state_change( + self.hass, self.tracking, self._async_state_changed_listener + ) + + async def async_stop(self): + """Unregister the group from Home Assistant. + + This method must be run in the event loop. + """ + if self._async_unsub_state_changed: + self._async_unsub_state_changed() + self._async_unsub_state_changed = None + + async def async_update(self): + """Query all members and determine current group state.""" + self._state = STATE_UNKNOWN + self._async_update_group_state() + + async def async_added_to_hass(self): + """Handle addition to Home Assistant.""" + if self.tracking: + self.async_start() + + async def async_will_remove_from_hass(self): + """Handle removal from Home Assistant.""" + if self._async_unsub_state_changed: + self._async_unsub_state_changed() + self._async_unsub_state_changed = None + + 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. + """ + # removed + if self._async_unsub_state_changed is None: + return + + self._async_update_group_state(new_state) + await self.async_update_ha_state() + + @property + def _tracking_states(self): + """Return the states that the group is tracking.""" + states = [] + + for entity_id in self.tracking: + state = self.hass.states.get(entity_id) + + if state is not None: + states.append(state) + + return states + + @callback + def _async_update_group_state(self, tr_state=None): + """Update group state. + + Optionally you can provide the only state changed since last update + allowing this method to take shortcuts. + + This method must be run in the event loop. + """ + # To store current states of group entities. Might not be needed. + states = None + gr_state = self._state + gr_on = self.group_on + gr_off = self.group_off + + # We have not determined type of group yet + if gr_on is None: + if tr_state is None: + states = self._tracking_states + + for state in states: + gr_on, gr_off = _get_group_on_off(state.state) + if gr_on is not None: + break + else: + gr_on, gr_off = _get_group_on_off(tr_state.state) + + if gr_on is not None: + self.group_on, self.group_off = gr_on, gr_off + + # We cannot determine state of the group + if gr_on is None: + return + + 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 self.mode(state.state == gr_on for state in states): + self._state = gr_on + else: + self._state = gr_off + + 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 states is None: + states = self._tracking_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 0000000000000..d9efdfa53c692 --- /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 0000000000000..3abca98bd2c97 --- /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 Home Assistant.""" + 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 0000000000000..bd117ac9a6ff6 --- /dev/null +++ b/homeassistant/components/group/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "group", + "name": "Group", + "documentation": "https://www.home-assistant.io/integrations/group", + "requirements": [], + "dependencies": [], + "codeowners": ["@home-assistant/core"], + "quality_scale": "internal" +} diff --git a/homeassistant/components/group/notify.py b/homeassistant/components/group/notify.py new file mode 100644 index 0000000000000..2209e0e233326 --- /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 0000000000000..787907019343b --- /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 new file mode 100644 index 0000000000000..68c2f04f06498 --- /dev/null +++ b/homeassistant/components/group/services.yaml @@ -0,0 +1,53 @@ +# Describes the format for available group services + +reload: + description: Reload group configuration. + +set_visibility: + description: Hide or show a group. + fields: + entity_id: + description: Name(s) of entities to set value. + example: 'group.travel' + visible: + description: True if group should be shown or False if it should be hidden. + example: True + +set: + description: Create/Update a user group. + fields: + object_id: + description: Group id and part of entity id. + example: 'test_group' + name: + description: Name of group + example: 'My test group' + view: + description: Boolean for if the group is a view. + example: True + icon: + description: Name of icon for the group. + example: 'mdi:camera' + control: + description: Value for control the group control. + example: 'hidden' + visible: + description: If the group is visible on UI. + example: True + entities: + description: List of all members in the group. Not compatible with 'delta'. + example: domain.entity_id1, domain.entity_id2 + add_entities: + description: List of members they will change on group listening. + example: domain.entity_id1, domain.entity_id2 + 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. + fields: + object_id: + description: Group id and part of entity id. + example: 'test_group' + diff --git a/homeassistant/components/growatt_server/__init__.py b/homeassistant/components/growatt_server/__init__.py new file mode 100644 index 0000000000000..14205e8d9ba3c --- /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 0000000000000..7457ef14254e8 --- /dev/null +++ b/homeassistant/components/growatt_server/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "growatt_server", + "name": "Growatt", + "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 0000000000000..2816b86be843c --- /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 0000000000000..9fb97d257441a --- /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 0000000000000..81078b1a18b5f --- /dev/null +++ b/homeassistant/components/gstreamer/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..9b371bfffcaec --- /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 0000000000000..9c503c2bb96ee --- /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 0000000000000..a795958450464 --- /dev/null +++ b/homeassistant/components/gtfs/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "gtfs", + "name": "General Transit Feed Specification (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 0000000000000..07b450dd33e15 --- /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 new file mode 100644 index 0000000000000..52326555aab7b --- /dev/null +++ b/homeassistant/components/habitica/__init__.py @@ -0,0 +1,153 @@ +"""Support for Habitica devices.""" +from collections import namedtuple +import logging + +from habitipy.aio import HabitipyAsync +import voluptuous as vol + +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__) + +CONF_API_USER = "api_user" + +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"]), +} + +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 users are unique.""" + api_users = [user[CONF_API_USER] for user in value] + has_unique_values(api_users) + return value + + +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") + 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] +) + +CONFIG_SCHEMA = vol.Schema({DOMAIN: INSTANCE_LIST_SCHEMA}, extra=vol.ALLOW_EXTRA) + +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") + +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.""" + + conf = config[DOMAIN] + data = hass.data[DOMAIN] = {} + websession = async_get_clientsession(hass) + + class HAHabitipyAsync(HabitipyAsync): + """Closure API class to hold session.""" + + def __call__(self, **kwargs): + return super().__call__(websession, **kwargs) + + for instance in conf: + url = instance[CONF_URL] + username = instance[CONF_API_USER] + password = instance[CONF_API_KEY] + name = instance.get(CONF_NAME) + config_dict = {"url": url, "login": username, "password": password} + api = HAHabitipyAsync(config_dict) + user = await api.user.get() + if name is None: + name = user["profile"]["name"] + data[name] = api + if CONF_SENSORS in instance: + hass.async_create_task( + discovery.async_load_platform( + hass, + "sensor", + DOMAIN, + {"name": name, "sensors": instance[CONF_SENSORS]}, + 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) + 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 + ) + 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.services.async_register( + 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 0000000000000..ff0d0eb27ac1d --- /dev/null +++ b/homeassistant/components/habitica/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..1fa4ad63b36bf --- /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/habitica/services.yaml b/homeassistant/components/habitica/services.yaml new file mode 100644 index 0000000000000..a063b1577f5cc --- /dev/null +++ b/homeassistant/components/habitica/services.yaml @@ -0,0 +1,15 @@ +# Describes the format for Habitica service + +--- +api_call: + description: Call Habitica api + fields: + name: + description: Habitica's username to call for + example: 'xxxNotAValidNickxxx' + path: + description: "Items from API URL in form of an array with method attached at the end. Consult https://habitica.com/apidoc/. Example uses https://habitica.com/apidoc/#api-Task-CreateUserTasks" + example: '["tasks", "user", "post"]' + args: + description: Any additional json or url parameter arguments. See apidoc mentioned for path. Example uses same api endpoint + example: '{"text": "Use API from Home Assistant", "type": "todo"}' diff --git a/homeassistant/components/hangouts/.translations/bg.json b/homeassistant/components/hangouts/.translations/bg.json new file mode 100644 index 0000000000000..10c1666074c08 --- /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 new file mode 100644 index 0000000000000..0dcc0f029c296 --- /dev/null +++ b/homeassistant/components/hangouts/.translations/ca.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured": "Google Hangouts ja est\u00e0 configurat", + "unknown": "S'ha produ\u00eft un error desconegut." + }, + "error": { + "invalid_2fa": "La verificaci\u00f3 en dos passos no \u00e9s v\u00e0lida, torna-ho a provar.", + "invalid_2fa_method": "El m\u00e8tode de verificaci\u00f3 en dos passos no \u00e9s v\u00e0lid (verifica-ho al m\u00f2bil).", + "invalid_login": "L'inici de sessi\u00f3 no \u00e9s v\u00e0lid, torna-ho a provar." + }, + "step": { + "2fa": { + "data": { + "2fa": "Pin 2FA" + }, + "description": "buit", + "title": "Verificaci\u00f3 en dos passos" + }, + "user": { + "data": { + "authorization_code": "Codi d'autoritzaci\u00f3 (necessari per a l'autenticaci\u00f3 manual)", + "email": "Correu electr\u00f2nic", + "password": "Contrasenya" + }, + "description": "buit", + "title": "Inici de sessi\u00f3 de Google Hangouts" + } + }, + "title": "Google Hangouts" + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/.translations/cs.json b/homeassistant/components/hangouts/.translations/cs.json new file mode 100644 index 0000000000000..badd381f2bee2 --- /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 0000000000000..2ceb78ddde8c7 --- /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 tofaktor-godkendelse, pr\u00f8v 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": "Tofaktor-godkendelse" + }, + "user": { + "data": { + "authorization_code": "Godkendelseskode (kr\u00e6vet til manuel godkendelse)", + "email": "Emailadresse", + "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 new file mode 100644 index 0000000000000..fa96c00f666cd --- /dev/null +++ b/homeassistant/components/hangouts/.translations/de.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured": "Google Hangouts ist bereits konfiguriert", + "unknown": "Ein unbekannter Fehler ist aufgetreten." + }, + "error": { + "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." + }, + "step": { + "2fa": { + "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" + } + }, + "title": "Google Hangouts" + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/.translations/en.json b/homeassistant/components/hangouts/.translations/en.json new file mode 100644 index 0000000000000..31e5f9894f96f --- /dev/null +++ b/homeassistant/components/hangouts/.translations/en.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Google Hangouts is already configured", + "unknown": "Unknown error occurred." + }, + "error": { + "invalid_2fa": "Invalid 2 Factor Authentication, please try again.", + "invalid_2fa_method": "Invalid 2FA Method (Verify on Phone).", + "invalid_login": "Invalid Login, please try again." + }, + "step": { + "2fa": { + "data": { + "2fa": "2FA Pin" + }, + "title": "2-Factor-Authentication" + }, + "user": { + "data": { + "authorization_code": "Authorization Code (required for manual authentication)", + "email": "E-Mail Address", + "password": "Password" + }, + "title": "Google Hangouts Login" + } + }, + "title": "Google Hangouts" + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/.translations/es-419.json b/homeassistant/components/hangouts/.translations/es-419.json new file mode 100644 index 0000000000000..3a297eb15ea3d --- /dev/null +++ b/homeassistant/components/hangouts/.translations/es-419.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "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" + }, + "title": "Inicio de sesi\u00f3n de Google Hangouts" + } + }, + "title": "Google Hangouts" + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/.translations/es.json b/homeassistant/components/hangouts/.translations/es.json new file mode 100644 index 0000000000000..dfa463fb148a7 --- /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 0000000000000..4bd26876ac6aa --- /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 new file mode 100644 index 0000000000000..13142fee5137c --- /dev/null +++ b/homeassistant/components/hangouts/.translations/fr.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured": "Google Hangouts est d\u00e9j\u00e0 configur\u00e9", + "unknown": "Une erreur inconnue s'est produite" + }, + "error": { + "invalid_2fa": "Authentification \u00e0 2 facteurs invalide, veuillez r\u00e9essayer.", + "invalid_2fa_method": "M\u00e9thode 2FA non valide (v\u00e9rifiez sur le t\u00e9l\u00e9phone).", + "invalid_login": "Login invalide, veuillez r\u00e9essayer." + }, + "step": { + "2fa": { + "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" + } + }, + "title": "Google Hangouts" + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/.translations/he.json b/homeassistant/components/hangouts/.translations/he.json new file mode 100644 index 0000000000000..28326d97142b9 --- /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 new file mode 100644 index 0000000000000..f6e46e259852e --- /dev/null +++ b/homeassistant/components/hangouts/.translations/hu.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "A Google Hangouts m\u00e1r konfigur\u00e1lva van", + "unknown": "Ismeretlen hiba t\u00f6rt\u00e9nt." + }, + "error": { + "invalid_2fa": "\u00c9rv\u00e9nytelen K\u00e9tfaktoros hiteles\u00edt\u00e9s, pr\u00f3b\u00e1ld \u00fajra.", + "invalid_2fa_method": "\u00c9rv\u00e9nytelen 2FA M\u00f3dszer (Ellen\u0151rz\u00e9s a Telefonon).", + "invalid_login": "\u00c9rv\u00e9nytelen bejelentkez\u00e9s, pr\u00f3b\u00e1ld \u00fajra." + }, + "step": { + "2fa": { + "data": { + "2fa": "2FA Pin" + }, + "description": "\u00dcres", + "title": "K\u00e9tfaktoros Hiteles\u00edt\u00e9s" + }, + "user": { + "data": { + "email": "E-Mail C\u00edm", + "password": "Jelsz\u00f3" + }, + "description": "\u00dcres", + "title": "Google Hangouts Bejelentkez\u00e9s" + } + }, + "title": "Google Hangouts" + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/.translations/id.json b/homeassistant/components/hangouts/.translations/id.json new file mode 100644 index 0000000000000..46a574bdf8a71 --- /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 new file mode 100644 index 0000000000000..ff0a8238d49ca --- /dev/null +++ b/homeassistant/components/hangouts/.translations/it.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured": "Google Hangouts \u00e8 gi\u00e0 configurato", + "unknown": "Si \u00e8 verificato un errore sconosciuto." + }, + "error": { + "invalid_2fa": "Autenticazione a 2 fattori non valida, riprovare.", + "invalid_2fa_method": "Metodo 2FA non valido (verifica sul telefono).", + "invalid_login": "Accesso non valido, si prega di riprovare." + }, + "step": { + "2fa": { + "data": { + "2fa": "2FA Pin" + }, + "description": "Vuoto", + "title": "Autenticazione a due fattori" + }, + "user": { + "data": { + "authorization_code": "Codice di autorizzazione (necessario per l'autenticazione manuale)", + "email": "Indirizzo E-mail", + "password": "Password" + }, + "description": "Vuoto", + "title": "Accesso a Google Hangouts" + } + }, + "title": "Google Hangouts" + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/.translations/ko.json b/homeassistant/components/hangouts/.translations/ko.json new file mode 100644 index 0000000000000..385fc128b3b73 --- /dev/null +++ b/homeassistant/components/hangouts/.translations/ko.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "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_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." + }, + "step": { + "2fa": { + "data": { + "2fa": "2\ub2e8\uacc4 \uc778\uc99d PIN" + }, + "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": "2\ub2e8\uacc4 \uc778\uc99d" + }, + "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 \ud589\uc544\uc6c3 \ub85c\uadf8\uc778" + } + }, + "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 new file mode 100644 index 0000000000000..c22b02fd7ed38 --- /dev/null +++ b/homeassistant/components/hangouts/.translations/lb.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured": "Google Hangouts ass scho konfigur\u00e9iert", + "unknown": "Onbekannten Feeler opgetrueden" + }, + "error": { + "invalid_2fa": "Ong\u00eblteg 2-Faktor Authentifikatioun, prob\u00e9iert w.e.g. nach emol.", + "invalid_2fa_method": "Ong\u00eblteg 2FA Methode (Iwwerpr\u00e9ift et um Telefon)", + "invalid_login": "Ong\u00ebltege Login, prob\u00e9iert w.e.g. nach emol." + }, + "step": { + "2fa": { + "data": { + "2fa": "2FA Pin" + }, + "description": "Eidel", + "title": "2-Faktor-Authentifikatioun" + }, + "user": { + "data": { + "authorization_code": "Autorisatioun's Code (n\u00e9ideg fir eng manuell Authentifikatioun)", + "email": "E-Mail Adress", + "password": "Passwuert" + }, + "description": "Eidel", + "title": "Google Hangouts Login" + } + }, + "title": "Google Hangouts" + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/.translations/nl.json b/homeassistant/components/hangouts/.translations/nl.json new file mode 100644 index 0000000000000..9f9b121a7c2a8 --- /dev/null +++ b/homeassistant/components/hangouts/.translations/nl.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured": "Google Hangouts is al geconfigureerd", + "unknown": "Onbekende fout opgetreden." + }, + "error": { + "invalid_2fa": "Ongeldige twee-factor-authenticatie, probeer het opnieuw.", + "invalid_2fa_method": "Ongeldige 2FA-methode (verifi\u00ebren op telefoon).", + "invalid_login": "Ongeldige aanmelding, probeer het opnieuw." + }, + "step": { + "2fa": { + "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" + } + }, + "title": "Google Hangouts" + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/.translations/nn.json b/homeassistant/components/hangouts/.translations/nn.json new file mode 100644 index 0000000000000..c8a5fb4481b8c --- /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 new file mode 100644 index 0000000000000..ab061ee1a807e --- /dev/null +++ b/homeassistant/components/hangouts/.translations/no.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured": "Google Hangouts er allerede konfigurert", + "unknown": "Ukjent feil oppstod." + }, + "error": { + "invalid_2fa": "Ugyldig tofaktorautentisering, vennligst pr\u00f8v igjen.", + "invalid_2fa_method": "Ugyldig 2FA-metode (Bekreft p\u00e5 telefon).", + "invalid_login": "Ugyldig innlogging, vennligst pr\u00f8v igjen." + }, + "step": { + "2fa": { + "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" + } + }, + "title": "Google Hangouts" + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/.translations/pl.json b/homeassistant/components/hangouts/.translations/pl.json new file mode 100644 index 0000000000000..5da1e21979970 --- /dev/null +++ b/homeassistant/components/hangouts/.translations/pl.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured": "Google Hangouts jest ju\u017c skonfigurowany", + "unknown": "Wyst\u0105pi\u0142 nieznany b\u0142\u0105d." + }, + "error": { + "invalid_2fa": "Nieprawid\u0142owe uwierzytelnienie dwusk\u0142adnikowe, spr\u00f3buj ponownie.", + "invalid_2fa_method": "Nieprawid\u0142owa metoda uwierzytelniania dwusk\u0142adnikowego (u\u017cyj weryfikacji przez telefon).", + "invalid_login": "Nieprawid\u0142owy login, spr\u00f3buj ponownie." + }, + "step": { + "2fa": { + "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" + } + }, + "title": "Google Hangouts" + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/.translations/pt-BR.json b/homeassistant/components/hangouts/.translations/pt-BR.json new file mode 100644 index 0000000000000..553360d8da671 --- /dev/null +++ b/homeassistant/components/hangouts/.translations/pt-BR.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured": "Hangouts do Google j\u00e1 est\u00e1 configurado.", + "unknown": "Ocorreu um erro desconhecido." + }, + "error": { + "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": { + "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" + } + }, + "title": "Hangouts do Google" + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/.translations/pt.json b/homeassistant/components/hangouts/.translations/pt.json new file mode 100644 index 0000000000000..a16c60128c1b2 --- /dev/null +++ b/homeassistant/components/hangouts/.translations/pt.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Google Hangouts j\u00e1 est\u00e1 configurado", + "unknown": "Ocorreu um erro desconhecido." + }, + "error": { + "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." + }, + "step": { + "2fa": { + "data": { + "2fa": "Pin 2FA" + }, + "description": "Vazio", + "title": "Autentica\u00e7\u00e3o de 2 Fatores" + }, + "user": { + "data": { + "email": "Endere\u00e7o de e-mail", + "password": "Palavra-passe" + }, + "description": "Vazio", + "title": "Login Google Hangouts" + } + }, + "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 0000000000000..d1c3ed767cef5 --- /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 new file mode 100644 index 0000000000000..5bb98effb9f76 --- /dev/null +++ b/homeassistant/components/hangouts/.translations/ru.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "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.", + "invalid_2fa_method": "\u041d\u0435\u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u044b\u0439 \u0441\u043f\u043e\u0441\u043e\u0431 \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 (\u043f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c \u043d\u0430 \u0442\u0435\u043b\u0435\u0444\u043e\u043d\u0435).", + "invalid_login": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043b\u043e\u0433\u0438\u043d, \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." + }, + "step": { + "2fa": { + "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" + }, + "description": "\u043f\u0443\u0441\u0442\u043e", + "title": "Google Hangouts" + } + }, + "title": "Google Hangouts" + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/.translations/sl.json b/homeassistant/components/hangouts/.translations/sl.json new file mode 100644 index 0000000000000..64ca6da10acd3 --- /dev/null +++ b/homeassistant/components/hangouts/.translations/sl.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured": "Google Hangouts je \u017ee konfiguriran", + "unknown": "Pri\u0161lo je do neznane napake" + }, + "error": { + "invalid_2fa": "Neveljavna 2FA avtorizacija, prosimo, poskusite znova.", + "invalid_2fa_method": "Neveljavna 2FA Metoda (Preverite na Telefonu).", + "invalid_login": "Neveljavna Prijava, prosimo, poskusite znova." + }, + "step": { + "2fa": { + "data": { + "2fa": "2FA Pin" + }, + "description": "prazno", + "title": "Dvofaktorska avtorizacija" + }, + "user": { + "data": { + "authorization_code": "Koda pooblastila (potrebna za ro\u010dno overjanje)", + "email": "E-po\u0161tni naslov", + "password": "Geslo" + }, + "description": "prazno", + "title": "Prijava za Google Hangouts" + } + }, + "title": "Google Hangouts" + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/.translations/sv.json b/homeassistant/components/hangouts/.translations/sv.json new file mode 100644 index 0000000000000..993a191ef89a6 --- /dev/null +++ b/homeassistant/components/hangouts/.translations/sv.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured": "Google Hangouts \u00e4r redan inst\u00e4llt", + "unknown": "Ett ok\u00e4nt fel intr\u00e4ffade" + }, + "error": { + "invalid_2fa": "Ogiltig 2FA autentisering, f\u00f6rs\u00f6k igen.", + "invalid_2fa_method": "Ogiltig 2FA-metod (Verifiera med telefon).", + "invalid_login": "Ogiltig inloggning, f\u00f6rs\u00f6k igen." + }, + "step": { + "2fa": { + "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" + } + }, + "title": "Google Hangouts" + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/.translations/th.json b/homeassistant/components/hangouts/.translations/th.json new file mode 100644 index 0000000000000..bcc59392e2e9e --- /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 0000000000000..d794a0b5afafa --- /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 new file mode 100644 index 0000000000000..d4e6e360c23be --- /dev/null +++ b/homeassistant/components/hangouts/.translations/zh-Hans.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Google Hangouts \u5df2\u914d\u7f6e\u5b8c\u6210", + "unknown": "\u53d1\u751f\u672a\u77e5\u9519\u8bef\u3002" + }, + "error": { + "invalid_2fa": "\u53cc\u91cd\u8ba4\u8bc1\u5931\u8d25\uff0c\u8bf7\u91cd\u8bd5\u3002", + "invalid_2fa_method": "\u65e0\u6548\u7684\u53cc\u91cd\u8ba4\u8bc1\u65b9\u6cd5\uff08\u7535\u8bdd\u9a8c\u8bc1\uff09\u3002", + "invalid_login": "\u767b\u9646\u5931\u8d25\uff0c\u8bf7\u518d\u8bd5\u4e00\u6b21\u3002" + }, + "step": { + "2fa": { + "data": { + "2fa": "2FA Pin" + }, + "description": "\u65e0", + "title": "\u53cc\u91cd\u8ba4\u8bc1" + }, + "user": { + "data": { + "email": "\u7535\u5b50\u90ae\u4ef6\u5730\u5740", + "password": "\u5bc6\u7801" + }, + "description": "\u65e0", + "title": "\u767b\u5f55 Google Hangouts" + } + }, + "title": "Google Hangouts" + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/.translations/zh-Hant.json b/homeassistant/components/hangouts/.translations/zh-Hant.json new file mode 100644 index 0000000000000..c8da604e6f268 --- /dev/null +++ b/homeassistant/components/hangouts/.translations/zh-Hant.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured": "Google Hangouts \u5df2\u7d93\u8a2d\u5b9a", + "unknown": "\u767c\u751f\u672a\u77e5\u932f\u8aa4\u3002" + }, + "error": { + "invalid_2fa": "\u5169\u6b65\u9a5f\u9a57\u8b49\u7121\u6548\uff0c\u8acb\u518d\u8a66\u4e00\u6b21\u3002", + "invalid_2fa_method": "\u8a8d\u8b49\u65b9\u5f0f\u7121\u6548\uff08\u65bc\u96fb\u8a71\u4e0a\u9a57\u8b49\uff09\u3002", + "invalid_login": "\u767b\u5165\u5931\u6557\uff0c\u8acb\u518d\u8a66\u4e00\u6b21\u3002" + }, + "step": { + "2fa": { + "data": { + "2fa": "\u8a8d\u8b49\u78bc" + }, + "description": "\u7a7a\u767d", + "title": "\u5169\u6b65\u9a5f\u9a57\u8b49" + }, + "user": { + "data": { + "authorization_code": "\u9a57\u8b49\u78bc\uff08\u624b\u52d5\u9a57\u8b49\u5fc5\u9808\uff09", + "email": "\u96fb\u5b50\u90f5\u4ef6", + "password": "\u5bc6\u78bc" + }, + "description": "\u7a7a\u767d", + "title": "\u767b\u5165 Google Hangouts" + } + }, + "title": "Google Hangouts" + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/__init__.py b/homeassistant/components/hangouts/__init__.py new file mode 100644 index 0000000000000..d4892c6689098 --- /dev/null +++ b/homeassistant/components/hangouts/__init__.py @@ -0,0 +1,158 @@ +"""Support for Hangouts.""" +import logging + +from hangups.auth import GoogleAuthError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.conversation.util import create_matcher +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.helpers import dispatcher, intent +import homeassistant.helpers.config_validation as cv + +# We need an import from .config_flow, without it .config_flow is never loaded. +from .config_flow import HangoutsFlowHandler # noqa: F401 +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, +) + + +async def async_setup(hass, config): + """Set up the Hangouts bot component.""" + config = config.get(DOMAIN) + if config is None: + hass.data[DOMAIN] = { + CONF_INTENTS: {}, + CONF_DEFAULT_CONVERSATIONS: [], + CONF_ERROR_SUPPRESSED_CONVERSATIONS: [], + } + return True + + hass.data[DOMAIN] = { + CONF_INTENTS: config[CONF_INTENTS], + CONF_DEFAULT_CONVERSATIONS: config[CONF_DEFAULT_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"]} + + for data in hass.data[DOMAIN][CONF_INTENTS].values(): + matchers = [] + for sentence in data[CONF_SENTENCES]: + matchers.append(create_matcher(sentence)) + + data[CONF_MATCHERS] = matchers + + 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.""" + try: + 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_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 + ) + + dispatcher.async_dispatcher_connect( + hass, EVENT_HANGOUTS_CONVERSATIONS_CHANGED, bot.async_resolve_conversations + ) + + dispatcher.async_dispatcher_connect( + hass, + EVENT_HANGOUTS_CONVERSATIONS_RESOLVED, + bot.async_update_conversation_commands, + ) + + 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_RECONNECT, bot.async_handle_reconnect, schema=vol.Schema({}) + ) + + intent.async_register(hass, HelpIntent(hass)) + + return True + + +async def async_unload_entry(hass, _): + """Unload a config entry.""" + bot = hass.data[DOMAIN].pop(CONF_BOT) + await bot.async_disconnect() + return True diff --git a/homeassistant/components/hangouts/config_flow.py b/homeassistant/components/hangouts/config_flow.py new file mode 100644 index 0000000000000..f253df4934194 --- /dev/null +++ b/homeassistant/components/hangouts/config_flow.py @@ -0,0 +1,129 @@ +"""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_AUTH_CODE, + CONF_REFRESH_TOKEN, + DOMAIN as HANGOUTS_DOMAIN, +) +from .hangups_utils import ( + Google2FAError, + GoogleAuthError, + HangoutsCredentials, + HangoutsRefreshToken, +) + + +@callback +def configured_hangouts(hass): + """Return the configures Google Hangouts Account.""" + entries = hass.config_entries.async_entries(HANGOUTS_DOMAIN) + if entries: + return entries[0] + return None + + +@config_entries.HANDLERS.register(HANGOUTS_DOMAIN) +class HangoutsFlowHandler(config_entries.ConfigFlow): + """Config flow Google Hangouts.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH + + def __init__(self): + """Initialize Google Hangouts config flow.""" + self._credentials = None + self._refresh_token = None + + async def async_step_user(self, user_input=None): + """Handle a flow start.""" + errors = {} + + if configured_hangouts(self.hass) is not None: + return self.async_abort(reason="already_configured") + + if user_input is not None: + 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( + 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" + else: + 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, + vol.Optional(CONF_AUTH_CODE): str, + } + ), + errors=errors, + ) + + async def async_step_2fa(self, user_input=None): + """Handle the 2fa step, if needed.""" + errors = {} + + if user_input is not None: + self._credentials.set_verification_code(user_input[CONF_2FA]) + try: + 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" + + return self.async_show_form( + step_id=CONF_2FA, + data_schema=vol.Schema({vol.Required(CONF_2FA): str}), + errors=errors, + ) + + async def async_step_final(self): + """Handle the final step, create the config entry.""" + return self.async_create_entry( + title=self._credentials.get_email(), + data={ + CONF_EMAIL: self._credentials.get_email(), + CONF_REFRESH_TOKEN: self._refresh_token.get(), + }, + ) + + async def async_step_import(self, _): + """Handle a flow import.""" + return await self.async_step_user() diff --git a/homeassistant/components/hangouts/const.py b/homeassistant/components/hangouts/const.py new file mode 100644 index 0000000000000..0508bf4870347 --- /dev/null +++ b/homeassistant/components/hangouts/const.py @@ -0,0 +1,85 @@ +"""Constants for Google Hangouts Component.""" +import logging + +import voluptuous as vol + +from homeassistant.components.notify import ATTR_DATA, ATTR_MESSAGE, ATTR_TARGET +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(".") + + +DOMAIN = "hangouts" + +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_INTENTS = "intents" +CONF_INTENT_TYPE = "intent_type" +CONF_SENTENCES = "sentences" +CONF_MATCHERS = "matchers" + +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" + +CONF_CONVERSATION_ID = "id" +CONF_CONVERSATION_NAME = "name" + +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), +) +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_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], + } + ) +) diff --git a/homeassistant/components/hangouts/hangouts_bot.py b/homeassistant/components/hangouts/hangouts_bot.py new file mode 100644 index 0000000000000..fd14ec0b0949f --- /dev/null +++ b/homeassistant/components/hangouts/hangouts_bot.py @@ -0,0 +1,346 @@ +"""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_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__) + + +class HangoutsBot: + """The Hangouts Bot.""" + + def __init__( + self, hass, refresh_token, intents, default_convs, error_suppressed_convs + ): + """Set up the client.""" + self.hass = hass + self._connected = False + + self._refresh_token = refresh_token + + self._intents = intents + self._conversation_intents = None + + self._client = None + self._user_list = None + self._conversation_list = None + self._default_convs = default_convs + self._default_conv_ids = None + self._error_suppressed_convs = error_suppressed_convs + self._error_suppressed_conv_ids = None + + dispatcher.async_dispatcher_connect( + self.hass, + EVENT_HANGOUTS_MESSAGE_RECEIVED, + self._async_handle_conversation_message, + ) + + def _resolve_conversation_id(self, obj): + if CONF_CONVERSATION_ID in obj: + return obj[CONF_CONVERSATION_ID] + if CONF_CONVERSATION_NAME in obj: + conv = self._resolve_conversation_name(obj[CONF_CONVERSATION_NAME]) + if conv is not None: + return conv.id_ + return None + + def _resolve_conversation_name(self, name): + for conv in self._conversation_list.get_all(): + if conv.name == name: + return conv + return None + + def async_update_conversation_commands(self): + """Refresh the commands for every conversation.""" + self._conversation_intents = {} + + for intent_type, data in self._intents.items(): + if data.get(CONF_CONVERSATIONS): + conversations = [] + for conversation in data.get(CONF_CONVERSATIONS): + conv_id = self._resolve_conversation_id(conversation) + if conv_id is not None: + conversations.append(conv_id) + data[f"_{CONF_CONVERSATIONS}"] = conversations + elif self._default_conv_ids: + data[f"_{CONF_CONVERSATIONS}"] = self._default_conv_ids + else: + data[f"_{CONF_CONVERSATIONS}"] = [ + conv.id_ for conv in self._conversation_list.get_all() + ] + + for conv_id in data[f"_{CONF_CONVERSATIONS}"]: + if conv_id not in self._conversation_intents: + self._conversation_intents[conv_id] = {} + + self._conversation_intents[conv_id][intent_type] = data + + try: + self._conversation_list.on_event.remove_observer( + self._async_handle_conversation_event + ) + except ValueError: + pass + self._conversation_list.on_event.add_observer( + self._async_handle_conversation_event + ) + + def async_resolve_conversations(self, _): + """Resolve the list of default and error suppressed conversations.""" + self._default_conv_ids = [] + self._error_suppressed_conv_ids = [] + + for conversation in self._default_convs: + conv_id = self._resolve_conversation_id(conversation) + if conv_id is not None: + self._default_conv_ids.append(conv_id) + + for conversation in self._error_suppressed_convs: + 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 + ) + + async def _async_handle_conversation_event(self, event): + if isinstance(event, ChatMessageEvent): + 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): + """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) + + 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) + except (intent.UnknownIntent, intent.IntentHandleError) as err: + is_error = True + intent_result = intent.IntentResponse() + intent_result.async_set_speech(str(err)) + + if intent_result is None: + is_error = True + intent_result = intent.IntentResponse() + intent_result.async_set_speech("Sorry, I didn't understand that") + + 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 + ): + await self._async_send_message( + [{"text": message, "parse_str": True}], + [{CONF_CONVERSATION_ID: conv_id}], + None, + ) + + async def _async_process(self, intents, text, conv_id): + """Detect a matching intent.""" + for intent_type, data in intents.items(): + for matcher in data.get(CONF_MATCHERS, []): + match = matcher.match(text) + + if not match: + continue + if intent_type == INTENT_HELP: + return await self.hass.helpers.intent.async_handle( + 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, + ) + + async def async_connect(self): + """Login to the Google Hangouts.""" + session = await self.hass.async_add_executor_job( + get_auth, + HangoutsCredentials(None, None, None), + HangoutsRefreshToken(self._refresh_token), + ) + + self._client = Client(session) + self._client.on_connect.add_observer(self._on_connect) + self._client.on_disconnect.add_observer(self._on_disconnect) + + self.hass.loop.create_task(self._client.connect()) + + def _on_connect(self): + _LOGGER.debug("Connected!") + self._connected = True + dispatcher.async_dispatcher_send(self.hass, EVENT_HANGOUTS_CONNECTED) + + async def _on_disconnect(self): + """Handle disconnecting.""" + 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, data): + conversations = [] + for target in targets: + conversation = None + if CONF_CONVERSATION_ID in target: + conversation = self._conversation_list.get(target[CONF_CONVERSATION_ID]) + elif CONF_CONVERSATION_NAME in target: + conversation = self._resolve_conversation_name( + target[CONF_CONVERSATION_NAME] + ) + if conversation is not None: + conversations.append(conversation) + + if not conversations: + return False + + 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"])) + else: + 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, image_file) + + async def _async_list_conversations(self): + ( + 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, + } + + 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], + 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 new file mode 100644 index 0000000000000..d2556ac15a08c --- /dev/null +++ b/homeassistant/components/hangouts/hangups_utils.py @@ -0,0 +1,96 @@ +"""Utils needed for Google Hangouts.""" + +from hangups import CredentialsPrompt, GoogleAuthError, RefreshTokenCache + + +class Google2FAError(GoogleAuthError): + """A Google authentication request failed.""" + + +class HangoutsCredentials(CredentialsPrompt): + """Google account credentials. + + This implementation gets the user data as params. + """ + + def __init__(self, email, password, pin=None, auth_code=None): + """Google account credentials. + + :param email: Google account email address. + :param password: Google account password. + :param pin: Google account verification code. + """ + self._email = email + self._password = password + self._pin = pin + self._auth_code = auth_code + + def get_email(self): + """Return email. + + :return: Google account email address. + """ + return self._email + + def get_password(self): + """Return password. + + :return: Google account password. + """ + return self._password + + def get_verification_code(self): + """Return the verification code. + + :return: Google account verification code. + """ + if self._pin is None: + raise Google2FAError() + return self._pin + + def set_verification_code(self, pin): + """Set the verification code. + + :param pin: Google account verification code. + """ + 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.""" + + def __init__(self, token): + """Memory-based cache for refresh token. + + :param token: Initial refresh token. + """ + super().__init__("") + self._token = token + + def get(self): + """Get cached refresh token. + + :return: Cached refresh token. + """ + return self._token + + def set(self, refresh_token): + """Cache a refresh token. + + :param refresh_token: Refresh token to cache. + """ + self._token = refresh_token diff --git a/homeassistant/components/hangouts/intents.py b/homeassistant/components/hangouts/intents.py new file mode 100644 index 0000000000000..5e4c6ff206bfe --- /dev/null +++ b/homeassistant/components/hangouts/intents.py @@ -0,0 +1,31 @@ +"""Intents for the Hangouts component.""" +from homeassistant.helpers import intent +import homeassistant.helpers.config_validation as cv + +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} + + def __init__(self, hass): + """Set up the intent.""" + self.hass = hass + + async def async_handle(self, intent_obj): + """Handle the intent.""" + slots = self.async_validate_slots(intent_obj.slots) + 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 += 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 0000000000000..b08387c7fd70d --- /dev/null +++ b/homeassistant/components/hangouts/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "hangouts", + "name": "Google 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 0000000000000..01e4208fd4871 --- /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 new file mode 100644 index 0000000000000..26a7193493b40 --- /dev/null +++ b/homeassistant/components/hangouts/services.yaml @@ -0,0 +1,18 @@ +update: + description: Updates the list of conversations. + +send_message: + description: Send a notification to a specific target. + fields: + target: + description: List of targets with id or name. [Required] + 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"}, ...]' + 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 new file mode 100644 index 0000000000000..8c155784ebe1c --- /dev/null +++ b/homeassistant/components/hangouts/strings.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Google Hangouts is already configured", + "unknown": "Unknown error occurred." + }, + "error": { + "invalid_login": "Invalid Login, please try again.", + "invalid_2fa": "Invalid 2 Factor Authentication, please try again.", + "invalid_2fa_method": "Invalid 2FA Method (Verify on Phone)." + }, + "step": { + "user": { + "data": { + "email": "E-Mail Address", + "password": "Password", + "authorization_code": "Authorization Code (required for manual authentication)" + }, + "title": "Google Hangouts Login" + }, + "2fa": { + "data": { + "2fa": "2FA Pin" + }, + "title": "2-Factor-Authentication" + } + }, + "title": "Google Hangouts" + } +} diff --git a/homeassistant/components/harman_kardon_avr/__init__.py b/homeassistant/components/harman_kardon_avr/__init__.py new file mode 100644 index 0000000000000..c9e3afd6be390 --- /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 0000000000000..060d78fbdeeeb --- /dev/null +++ b/homeassistant/components/harman_kardon_avr/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..fd7cddcaed9ee --- /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 0000000000000..12ccc78077e93 --- /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 0000000000000..12e710506657e --- /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 0000000000000..a0e8baa0b5891 --- /dev/null +++ b/homeassistant/components/harmony/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "harmony", + "name": "Logitech Harmony Hub", + "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 0000000000000..c48d5fb00b064 --- /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 0000000000000..1b9ae225c7f80 --- /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 new file mode 100644 index 0000000000000..28e06cc5d6a19 --- /dev/null +++ b/homeassistant/components/hassio/__init__.py @@ -0,0 +1,301 @@ +"""Support for Hass.io.""" +from datetime import timedelta +import logging +import os + +import voluptuous as vol + +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, + 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 .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" +STORAGE_KEY = DOMAIN +STORAGE_VERSION = 1 + +CONF_FRONTEND_REPO = "development_repo" + +CONFIG_SCHEMA = vol.Schema( + {vol.Optional(DOMAIN): vol.Schema({vol.Optional(CONF_FRONTEND_REPO): cv.isdir})}, + extra=vol.ALLOW_EXTRA, +) + + +DATA_HOMEASSISTANT_VERSION = "hassio_hass_version" +HASSIO_UPDATE_INTERVAL = timedelta(minutes=55) + +SERVICE_ADDON_START = "addon_start" +SERVICE_ADDON_STOP = "addon_stop" +SERVICE_ADDON_RESTART = "addon_restart" +SERVICE_ADDON_STDIN = "addon_stdin" +SERVICE_HOST_SHUTDOWN = "host_shutdown" +SERVICE_HOST_REBOOT = "host_reboot" +SERVICE_SNAPSHOT_FULL = "snapshot_full" +SERVICE_SNAPSHOT_PARTIAL = "snapshot_partial" +SERVICE_RESTORE_FULL = "restore_full" +SERVICE_RESTORE_PARTIAL = "restore_partial" + +ATTR_ADDON = "addon" +ATTR_INPUT = "input" +ATTR_SNAPSHOT = "snapshot" +ATTR_ADDONS = "addons" +ATTR_FOLDERS = "folders" +ATTR_HOMEASSISTANT = "homeassistant" +ATTR_PASSWORD = "password" + +SCHEMA_NO_DATA = vol.Schema({}) + +SCHEMA_ADDON = vol.Schema({vol.Required(ATTR_ADDON): cv.slug}) + +SCHEMA_ADDON_STDIN = SCHEMA_ADDON.extend( + {vol.Required(ATTR_INPUT): vol.Any(dict, cv.string)} +) + +SCHEMA_SNAPSHOT_FULL = vol.Schema( + {vol.Optional(ATTR_NAME): cv.string, vol.Optional(ATTR_PASSWORD): cv.string} +) + +SCHEMA_SNAPSHOT_PARTIAL = SCHEMA_SNAPSHOT_FULL.extend( + { + vol.Optional(ATTR_FOLDERS): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(ATTR_ADDONS): vol.All(cv.ensure_list, [cv.string]), + } +) + +SCHEMA_RESTORE_FULL = vol.Schema( + {vol.Required(ATTR_SNAPSHOT): cv.slug, vol.Optional(ATTR_PASSWORD): cv.string} +) + +SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend( + { + vol.Optional(ATTR_HOMEASSISTANT): cv.boolean, + vol.Optional(ATTR_FOLDERS): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(ATTR_ADDONS): vol.All(cv.ensure_list, [cv.string]), + } +) + +MAP_SERVICE_API = { + SERVICE_ADDON_START: ("/addons/{addon}/start", SCHEMA_ADDON, 60, False), + SERVICE_ADDON_STOP: ("/addons/{addon}/stop", SCHEMA_ADDON, 60, False), + SERVICE_ADDON_RESTART: ("/addons/{addon}/restart", SCHEMA_ADDON, 60, False), + SERVICE_ADDON_STDIN: ("/addons/{addon}/stdin", SCHEMA_ADDON_STDIN, 60, False), + SERVICE_HOST_SHUTDOWN: ("/host/shutdown", SCHEMA_NO_DATA, 60, False), + SERVICE_HOST_REBOOT: ("/host/reboot", SCHEMA_NO_DATA, 60, False), + SERVICE_SNAPSHOT_FULL: ("/snapshots/new/full", SCHEMA_SNAPSHOT_FULL, 300, True), + SERVICE_SNAPSHOT_PARTIAL: ( + "/snapshots/new/partial", + SCHEMA_SNAPSHOT_PARTIAL, + 300, + True, + ), + SERVICE_RESTORE_FULL: ( + "/snapshots/{snapshot}/restore/full", + SCHEMA_RESTORE_FULL, + 300, + True, + ), + SERVICE_RESTORE_PARTIAL: ( + "/snapshots/{snapshot}/restore/partial", + SCHEMA_RESTORE_PARTIAL, + 300, + True, + ), +} + + +@callback +@bind_hass +def get_homeassistant_version(hass): + """Return latest available Home Assistant version. + + Async friendly. + """ + return hass.data.get(DATA_HOMEASSISTANT_VERSION) + + +@callback +@bind_hass +def is_hassio(hass): + """Return true if Hass.io is loaded. + + Async friendly. + """ + return DOMAIN in hass.config.components + + +async def async_setup(hass, config): + """Set up the Hass.io component.""" + # 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 await hassio.is_connected(): + _LOGGER.warning("Not connected with Hass.io / system to busy!") + + store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + data = await store.async_load() + + if data is None: + data = {} + + refresh_token = None + 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 = 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 + ) + + hass.http.register_view(HassIOView(host, websession)) + + 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, + ) + + await hassio.update_hass_api(config.get("http", {}), refresh_token.token) + + async def push_config(_): + """Push core config to Hass.io.""" + await hassio.update_hass_timezone(str(hass.config.time_zone)) + + hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, push_config) + + 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() + addon = data.pop(ATTR_ADDON, None) + snapshot = data.pop(ATTR_SNAPSHOT, None) + payload = None + + # Pass data to Hass.io API + if service.service == SERVICE_ADDON_STDIN: + payload = data[ATTR_INPUT] + elif MAP_SERVICE_API[service.service][3]: + payload = data + + # Call API + 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] + ) + + async def update_homeassistant_version(now): + """Update last available Home Assistant 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 + ) + + # Fetch last version + await update_homeassistant_version(None) + + async def async_handle_core_service(call): + """Service handler for handling core services.""" + if call.service == SERVICE_HOMEASSISTANT_STOP: + await hassio.stop_homeassistant() + 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"{HASS_DOMAIN}.check_config", + ) + return + + if call.service == SERVICE_HOMEASSISTANT_RESTART: + 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) + + # 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 0000000000000..cb509cb19a121 --- /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 0000000000000..800801b435073 --- /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 0000000000000..ffccb32539563 --- /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 0000000000000..fc6efbe0e5815 --- /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 new file mode 100644 index 0000000000000..5213443614cbe --- /dev/null +++ b/homeassistant/components/hassio/handler.py @@ -0,0 +1,177 @@ +"""Handler for Hass.io.""" +import asyncio +import logging +import os + +import aiohttp +import async_timeout + +from homeassistant.components.http import ( + CONF_SERVER_HOST, + CONF_SERVER_PORT, + CONF_SSL_CERTIFICATE, +) +from homeassistant.const import SERVER_PORT + +from .const import X_HASSIO + +_LOGGER = logging.getLogger(__name__) + + +class HassioAPIError(RuntimeError): + """Return if a API trow a error.""" + + +def _api_bool(funct): + """Return a boolean.""" + + async def _wrapper(*argv, **kwargs): + """Wrap function.""" + 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["result"] == "ok": + return data["data"] + raise HassioAPIError(data["message"]) + + return _wrapper + + +class HassIO: + """Small API wrapper for Hass.io.""" + + def __init__(self, loop, websession, ip): + """Initialize Hass.io API.""" + self.loop = loop + self.websession = websession + self._ip = ip + + @_api_bool + def is_connected(self): + """Return true if it connected to Hass.io supervisor. + + This method return a coroutine. + """ + return self.send_command("/supervisor/ping", method="get", timeout=15) + + @_api_data + def get_homeassistant_info(self): + """Return data for Home Assistant. + + This method return a coroutine. + """ + return self.send_command("/homeassistant/info", method="get") + + @_api_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. + + This method return a coroutine. + """ + return self.send_command("/homeassistant/restart") + + @_api_bool + def stop_homeassistant(self): + """Stop Home-Assistant container. + + This method return a coroutine. + """ + return self.send_command("/homeassistant/stop") + + @_api_data + def retrieve_discovery_messages(self): + """Return all discovery data from Hass.io API. + + This method return a coroutine. + """ + 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, + "watchdog": True, + "refresh_token": refresh_token, + } + + if CONF_SERVER_HOST in http_config: + options["watchdog"] = False + _LOGGER.warning("Don't use 'server_host' options with Hass.io") + + return await self.send_command("/homeassistant/options", payload=options) + + @_api_bool + 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": timezone}) + + 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): + 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) + raise HassioAPIError() + + answer = await request.json() + return answer + + except asyncio.TimeoutError: + _LOGGER.error("Timeout on %s request", command) + + except aiohttp.ClientError as err: + _LOGGER.error("Client error on %s request %s", command, err) + + raise HassioAPIError() diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py new file mode 100644 index 0000000000000..ddb9269219b9d --- /dev/null +++ b/homeassistant/components/hassio/http.py @@ -0,0 +1,138 @@ +"""HTTP Support for Hass.io.""" +import asyncio +import logging +import os +import re +from typing import Dict, Union + +import aiohttp +from aiohttp import web +from aiohttp.hdrs import CONTENT_LENGTH, CONTENT_TYPE +from aiohttp.web_exceptions import HTTPBadGateway +import async_timeout + +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__) + + +NO_TIMEOUT = re.compile( + 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")$") + + +class HassIOView(HomeAssistantView): + """Hass.io view to handle base part.""" + + name = "api:hassio" + url = "/api/hassio/{path:.+}" + requires_auth = False + + def __init__(self, host: str, websession: aiohttp.ClientSession): + """Initialize a Hass.io base view.""" + self._host = host + self._websession = websession + + 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) + + return await self._command_proxy(path, request) + + get = _handle + post = _handle + + 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) + data = None + headers = _init_header(request) + + try: + with async_timeout.timeout(10): + data = await request.read() + + method = getattr(self._websession, request.method.lower()) + client = await method( + f"http://{self._host}/{path}", + data=data, + headers=headers, + timeout=read_timeout, + ) + + # 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) + + except asyncio.TimeoutError: + _LOGGER.error("Client timeout error on API request %s", path) + + raise HTTPBadGateway() + + +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 _get_timeout(path: str) -> int: + """Return timeout for a URL path.""" + if NO_TIMEOUT.match(path): + return 0 + return 300 + + +def _need_auth(path: str) -> bool: + """Return if a path need authentication.""" + if NO_AUTH.match(path): + return False + return True diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py new file mode 100644 index 0000000000000..c69d2078468fd --- /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 0000000000000..d3dd7dc9c9418 --- /dev/null +++ b/homeassistant/components/hassio/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..30314c646b030 --- /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 0000000000000..adead4ec46e02 --- /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 0000000000000..0016bf586cd7c --- /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 0000000000000..99f9449947872 --- /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 0000000000000..121238df9fe2f --- /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 0000000000000..6f1d10a9355f7 --- /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 0000000000000..a1052b0440a11 --- /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/__init__.py b/homeassistant/components/hdmi_cec/__init__.py new file mode 100644 index 0000000000000..b460020546fa0 --- /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 0000000000000..683b735ec5069 --- /dev/null +++ b/homeassistant/components/hdmi_cec/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..42c5f0b456c08 --- /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 0000000000000..f2e5f0b837a40 --- /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 0000000000000..53384397cf40f --- /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 0000000000000..bc6313f9e3db4 --- /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 0000000000000..553ae8f4bc3b8 --- /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 0000000000000..d8ecb505390df --- /dev/null +++ b/homeassistant/components/heatmiser/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "heatmiser", + "name": "Heatmiser", + "documentation": "https://www.home-assistant.io/integrations/heatmiser", + "requirements": ["heatmiserV3==1.1.18"], + "dependencies": [], + "codeowners": ["@andylockran"] +} diff --git a/homeassistant/components/heos/.translations/bg.json b/homeassistant/components/heos/.translations/bg.json new file mode 100644 index 0000000000000..dea7dd9bb24c3 --- /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 0000000000000..0987e11430b61 --- /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 0000000000000..fac6458c5b8a8 --- /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 0000000000000..f2d9441e48a97 --- /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 0000000000000..e98df7466ff69 --- /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 0000000000000..6d4d83192c754 --- /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 0000000000000..4d442a4543b50 --- /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 0000000000000..da5d5e0ab89ee --- /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 0000000000000..549cd00e8e0cb --- /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 0000000000000..20ae78ae3161f --- /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 0000000000000..824f7c3fb50d8 --- /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 0000000000000..9237800bf482f --- /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 0000000000000..cfe1d347b0cc2 --- /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 0000000000000..3e7105e8cb358 --- /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 0000000000000..ec2dc29450011 --- /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 0000000000000..d41051b6674eb --- /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 0000000000000..d427acc3a986f --- /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 0000000000000..5bcd39efd20ad --- /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 0000000000000..099d1978436aa --- /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 0000000000000..8aacc8e165dd3 --- /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 0000000000000..2978d2bbbe6f7 --- /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 0000000000000..96d4991a5b859 --- /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 0000000000000..9efacaf163f9d --- /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 0000000000000..f7e1ce5bc5862 --- /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 0000000000000..7e7fe067874d5 --- /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 0000000000000..503df40ccd498 --- /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 0000000000000..02f3d03ae5244 --- /dev/null +++ b/homeassistant/components/heos/manifest.json @@ -0,0 +1,14 @@ +{ + "domain": "heos", + "name": "Denon 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 0000000000000..10ea28ca16cb1 --- /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 0000000000000..ee5df1b483b55 --- /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 0000000000000..8274240368f80 --- /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 0000000000000..9a00ac6a4bd12 --- /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 0000000000000..9a5c8ec32aca4 --- /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 0000000000000..fcef464aa88fb --- /dev/null +++ b/homeassistant/components/here_travel_time/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..316e73dc09604 --- /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 Home Assistant 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 0000000000000..dbf7991b3c4a0 --- /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 0000000000000..9db9121730023 --- /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 0000000000000..45b2686ada254 --- /dev/null +++ b/homeassistant/components/hikvision/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..32a2a86b28fae --- /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 0000000000000..277617a90320b --- /dev/null +++ b/homeassistant/components/hikvisioncam/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "hikvisioncam", + "name": "Hikvision", + "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 0000000000000..f86853a5468ae --- /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 0000000000000..c758e9cc20d71 --- /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 0000000000000..7b237aecdab44 --- /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/da.json b/homeassistant/components/hisense_aehw4a1/.translations/da.json new file mode 100644 index 0000000000000..3d479543231ea --- /dev/null +++ b/homeassistant/components/hisense_aehw4a1/.translations/da.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Ingen Hisense AEH-W4A1-enheder fundet p\u00e5 netv\u00e6rket.", + "single_instance_allowed": "Kun en enkelt konfiguration af 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/de.json b/homeassistant/components/hisense_aehw4a1/.translations/de.json new file mode 100644 index 0000000000000..8b474ea0418e4 --- /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 0000000000000..b70fc8f05ecdc --- /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 0000000000000..69f071bf5d89b --- /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 0000000000000..50c753538c78f --- /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 0000000000000..b584d18e8bf13 --- /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/ko.json b/homeassistant/components/hisense_aehw4a1/.translations/ko.json new file mode 100644 index 0000000000000..6d8b6b4b44ce1 --- /dev/null +++ b/homeassistant/components/hisense_aehw4a1/.translations/ko.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Hisense AEH-W4A1 \uae30\uae30\uac00 \ub124\ud2b8\uc6cc\ud06c\uc5d0\uc11c \ubc1c\uacac\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4.", + "single_instance_allowed": "\ud558\ub098\uc758 Hisense AEH-W4A1 \ub9cc \uad6c\uc131 \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4." + }, + "step": { + "confirm": { + "description": "Hisense AEH-W4A1 \uc744 \uc124\uc815\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "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 0000000000000..33b9334830018 --- /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/nl.json b/homeassistant/components/hisense_aehw4a1/.translations/nl.json new file mode 100644 index 0000000000000..7360908a11d43 --- /dev/null +++ b/homeassistant/components/hisense_aehw4a1/.translations/nl.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Geen Hisense AEH-W4A1-apparaten gevonden op het netwerk.", + "single_instance_allowed": "Slechts een enkele configuratie van Hisense AEH-W4A1 is mogelijk." + }, + "step": { + "confirm": { + "description": "Wilt u Hisense AEH-W4A1 instellen?", + "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 0000000000000..e44e818ea6076 --- /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 0000000000000..e0ab5cddbda91 --- /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 0000000000000..c65a5277f62cd --- /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 0000000000000..3c15eecf6e189 --- /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 0000000000000..d4f87905da946 --- /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 0000000000000..721039d0e1c88 --- /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 0000000000000..da18419c2642f --- /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 0000000000000..52926ba796879 --- /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 0000000000000..8f381492b62bf --- /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 0000000000000..da8e3ad941932 --- /dev/null +++ b/homeassistant/components/hisense_aehw4a1/manifest.json @@ -0,0 +1,9 @@ +{ + "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 0000000000000..67031c41710d0 --- /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 10c4fdb41f669..0000000000000 --- a/homeassistant/components/history.py +++ /dev/null @@ -1,128 +0,0 @@ -""" -homeassistant.components.history -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Provide pre-made queries on top of the recorder component. -""" -import re -from datetime import datetime, timedelta -from itertools import groupby -from collections import defaultdict - -import homeassistant.components.recorder as recorder - -DOMAIN = 'history' -DEPENDENCIES = ['recorder', 'http'] - - -def last_5_states(entity_id): - """ Return the last 5 states for entity_id. """ - entity_id = entity_id.lower() - - query = """ - SELECT * FROM states WHERE entity_id=? AND - last_changed=last_updated - ORDER BY last_changed DESC LIMIT 0, 5 - """ - - return recorder.query_states(query, (entity_id, )) - - -def state_changes_during_period(start_time, end_time=None, entity_id=None): - """ - Return states changes during period start_time - end_time. - """ - where = "last_changed=last_updated AND last_changed > ? " - data = [start_time] - - if end_time is not None: - where += "AND last_changed < ? " - data.append(end_time) - - if entity_id is not None: - where += "AND entity_id = ? " - data.append(entity_id.lower()) - - query = ("SELECT * FROM states WHERE {} " - "ORDER BY entity_id, last_changed ASC").format(where) - - states = recorder.query_states(query, data) - - result = defaultdict(list) - - # Get the states at the start time - for state in get_states(start_time): - state.last_changed = start_time - result[state.entity_id].append(state) - - # Append all changes to it - for entity_id, group in groupby(states, lambda state: state.entity_id): - result[entity_id].extend(group) - - return result - - -def get_states(point_in_time, entity_ids=None, run=None): - """ Returns the states at a specific point in time. """ - if run is None: - run = recorder.run_information(point_in_time) - - where = run.where_after_start_run + "AND created < ? " - where_data = [point_in_time] - - if entity_ids is not None: - where += "AND entity_id IN ({}) ".format( - ",".join(['?'] * len(entity_ids))) - where_data.extend(entity_ids) - - query = """ - SELECT * FROM states - INNER JOIN ( - SELECT max(state_id) AS max_state_id - FROM states WHERE {} - GROUP BY entity_id) - WHERE state_id = max_state_id - """.format(where) - - return recorder.query_states(query, where_data) - - -def get_state(point_in_time, entity_id, run=None): - """ Return a state at a specific point in time. """ - states = get_states(point_in_time, (entity_id,), run) - - return states[0] if states else None - - -def setup(hass, config): - """ Setup history hooks. """ - hass.http.register_path( - 'GET', - re.compile( - r'/api/history/entity/(?P[a-zA-Z\._0-9]+)/' - r'recent_states'), - _api_last_5_states) - - hass.http.register_path( - 'GET', re.compile(r'/api/history/period'), _api_history_period) - - return True - - -# pylint: disable=invalid-name -def _api_last_5_states(handler, path_match, data): - """ Return the last 5 states for an entity id as JSON. """ - entity_id = path_match.group('entity_id') - - handler.write_json(last_5_states(entity_id)) - - -def _api_history_period(handler, path_match, data): - """ Return history over a period of time. """ - # 1 day for now.. - start_time = datetime.now() - timedelta(seconds=86400) - - entity_id = data.get('filter_entity_id') - - handler.write_json( - state_changes_during_period(start_time, entity_id=entity_id).values()) diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py new file mode 100644 index 0000000000000..7fcbf519bf34d --- /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 0000000000000..47f74ec4fdefb --- /dev/null +++ b/homeassistant/components/history/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "history", + "name": "History", + "documentation": "https://www.home-assistant.io/integrations/history", + "requirements": [], + "dependencies": ["http", "recorder"], + "codeowners": ["@home-assistant/core"], + "quality_scale": "internal" +} diff --git a/homeassistant/components/history_graph/__init__.py b/homeassistant/components/history_graph/__init__.py new file mode 100644 index 0000000000000..2b89556818fb9 --- /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 0000000000000..e34907d05ced3 --- /dev/null +++ b/homeassistant/components/history_graph/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "history_graph", + "name": "History Graph", + "documentation": "https://www.home-assistant.io/integrations/history_graph", + "requirements": [], + "dependencies": ["history"], + "codeowners": ["@andrey-git"], + "quality_scale": "internal" +} diff --git a/homeassistant/components/history_stats/__init__.py b/homeassistant/components/history_stats/__init__.py new file mode 100644 index 0000000000000..3c5385be6adb0 --- /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 0000000000000..e51fa20bb6516 --- /dev/null +++ b/homeassistant/components/history_stats/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "history_stats", + "name": "History Stats", + "documentation": "https://www.home-assistant.io/integrations/history_stats", + "requirements": [], + "dependencies": ["history"], + "codeowners": [], + "quality_scale": "internal" +} diff --git a/homeassistant/components/history_stats/sensor.py b/homeassistant/components/history_stats/sensor.py new file mode 100644 index 0000000000000..3eb604b3957b4 --- /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 0000000000000..de65a34f3a470 --- /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 0000000000000..12b03acbcc503 --- /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 0000000000000..05f82999198f4 --- /dev/null +++ b/homeassistant/components/hitron_coda/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "hitron_coda", + "name": "Rogers Hitron CODA", + "documentation": "https://www.home-assistant.io/integrations/hitron_coda", + "requirements": [], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/hive/__init__.py b/homeassistant/components/hive/__init__.py new file mode 100644 index 0000000000000..976821513b618 --- /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 0000000000000..fa91d6862a21a --- /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 0000000000000..202cea7bf8e47 --- /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 0000000000000..33175de543da1 --- /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 0000000000000..6572b0dbda216 --- /dev/null +++ b/homeassistant/components/hive/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "hive", + "name": "Hive", + "documentation": "https://www.home-assistant.io/integrations/hive", + "requirements": ["pyhiveapi==0.2.19.3"], + "dependencies": [], + "codeowners": ["@Rendili", "@KJonline"] +} diff --git a/homeassistant/components/hive/sensor.py b/homeassistant/components/hive/sensor.py new file mode 100644 index 0000000000000..360fb61bfbee8 --- /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 0000000000000..6513d76ca8998 --- /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 0000000000000..53e1ec6a069b3 --- /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 0000000000000..d7d98426df5ea --- /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 0000000000000..e7264c4e0dd93 --- /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 0000000000000..741c81b367c8b --- /dev/null +++ b/homeassistant/components/hlk_sw16/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "hlk_sw16", + "name": "Hi-Link 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 0000000000000..e9c190678a652 --- /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 0000000000000..8aa1d7e020aca --- /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 0000000000000..50b771611d340 --- /dev/null +++ b/homeassistant/components/homeassistant/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "homeassistant", + "name": "Home Assistant Core Integration", + "documentation": "https://www.home-assistant.io/integrations/homeassistant", + "requirements": [], + "dependencies": [], + "codeowners": ["@home-assistant/core"], + "quality_scale": "internal" +} diff --git a/homeassistant/components/homeassistant/scene.py b/homeassistant/components/homeassistant/scene.py new file mode 100644 index 0000000000000..c79c22e36a353 --- /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 0000000000000..cb3efb0d524a6 --- /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 new file mode 100644 index 0000000000000..ca5a601068a62 --- /dev/null +++ b/homeassistant/components/homekit/__init__.py @@ -0,0 +1,401 @@ +"""Support for Apple HomeKit.""" +import ipaddress +import logging +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_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_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__) + +MAX_DEVICES = 100 +TYPES = Registry() + +# #### Driver Status #### +STATUS_READY = 0 +STATUS_RUNNING = 1 +STATUS_STOPPED = 2 +STATUS_WAIT = 3 + +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_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") + + 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, + 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) + return True + + def handle_homekit_service_start(service): + """Handle start HomeKit service call.""" + if homekit.status != STATUS_READY: + _LOGGER.warning( + "HomeKit is not ready. Either it is already running or has " + "been stopped." + ) + return + homekit.start() + + hass.services.async_register( + DOMAIN, SERVICE_HOMEKIT_START, handle_homekit_service_start + ) + + return True + + +def get_accessory(hass, driver, state, aid, config): + """Take state and return an accessory object if supported.""" + if not aid: + _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" + + elif state.domain in ("binary_sensor", "device_tracker", "person"): + a_type = "BinarySensor" + + elif state.domain == "climate": + a_type = "Thermostat" + + 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" + elif features & cover.SUPPORT_SET_POSITION: + a_type = "WindowCovering" + elif features & (cover.SUPPORT_OPEN | cover.SUPPORT_CLOSE): + a_type = "WindowCoveringBasic" + + elif state.domain == "fan": + a_type = "Fan" + + elif state.domain == "light": + a_type = "Light" + + elif state.domain == "lock": + a_type = "Lock" + + elif state.domain == "media_player": + device_class = state.attributes.get(ATTR_DEVICE_CLASS) + feature_list = config.get(CONF_FEATURE_LIST) + + 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_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": + switch_type = config.get(CONF_TYPE, TYPE_SWITCH) + a_type = SWITCH_TYPES[switch_type] + + 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 + + _LOGGER.debug('Add "%s" as "%s"', state.entity_id, a_type) + return TYPES[a_type](hass, driver, name, state.entity_id, aid, config) + + +def generate_aid(entity_id): + """Generate accessory aid with zlib adler32.""" + aid = adler32(entity_id.encode("utf-8")) + if aid in (0, 1): + return None + return aid + + +class HomeKit: + """Class to handle all actions between HomeKit and Home Assistant.""" + + 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 + self._port = port + 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 + self.driver = None + + 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) + + 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, + 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.""" + if not state or not self._filter(state.entity_id): + return + aid = generate_aid(state.entity_id) + conf = self._config.pop(state.entity_id, {}) + acc = get_accessory(self.hass, self.driver, state, aid, conf) + if acc is not None: + self.bridge.add_accessory(acc) + + def 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 + + 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) + self.driver.add_accessory(self.bridge) + + if not self.driver.state.paired: + show_setup_message(self.hass, self.driver.state.pincode) + + 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 + + def stop(self, *args): + """Stop the accessory driver.""" + if self.status != STATUS_RUNNING: + return + self.status = STATUS_STOPPED + + _LOGGER.debug("Driver stop") + self.hass.add_job(self.driver.stop) diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py new file mode 100644 index 0000000000000..ddcc795d26251 --- /dev/null +++ b/homeassistant/components/homekit/accessories.py @@ -0,0 +1,259 @@ +"""Extend the basic Accessory and Bridge functions.""" +from datetime import timedelta +from functools import partial, wraps +from inspect import getmodule +import logging + +from pyhap.accessory import Accessory, Bridge +from pyhap.accessory_driver import AccessoryDriver +from pyhap.const import CATEGORY_OTHER + +from homeassistant.const import ( + 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, +) +from homeassistant.util import dt as dt_util + +from .const import ( + 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_executor_job(func, self, *debounce_params[1:]) + + @wraps(func) + def wrapper(self, *args): + """Start async timer.""" + debounce_params = self.debounce.pop(func.__name__, None) + if debounce_params: + debounce_params[0]() # remove listener + remove_listener = track_point_in_utc_time( + self.hass, + partial(call_later_listener, self), + dt_util.utcnow() + timedelta(seconds=DEBOUNCE_TIMEOUT), + ) + self.debounce[func.__name__] = (remove_listener, *args) + logger.debug( + "%s: Start %s timeout", self.entity_id, func.__name__.replace("set_", "") + ) + + name = getmodule(func).__name__ + logger = logging.getLogger(name) + return wrapper + + +class HomeAccessory(Accessory): + """Adapter class for Accessory.""" + + 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, + ) + self.category = category + 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_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", 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_low_battery = serv_battery.configure_char( + 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.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): + """Handle state change listener callback.""" + _LOGGER.debug("New_state: %s", new_state) + if new_state is None: + return + 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)) + 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 < 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) + if charging is None: + self._support_battery_charging = False + 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) + + def update_state(self, new_state): + """Handle state change to update HomeKit value. + + Overridden by accessory types. + """ + 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.""" + + def __init__(self, hass, driver, name): + """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, + ) + self.hass = hass + + def setup_message(self): + """Prevent print of pyhap setup message to terminal.""" + pass + + +class HomeDriver(AccessoryDriver): + """Adapter class for AccessoryDriver.""" + + def __init__(self, hass, **kwargs): + """Initialize a AccessoryDriver object.""" + super().__init__(**kwargs) + self.hass = hass + + def pair(self, client_uuid, client_public): + """Override super function to dismiss setup message if paired.""" + success = super().pair(client_uuid, client_public) + if success: + dismiss_setup_message(self.hass) + return success + + def unpair(self, client_uuid): + """Override super function to show setup message if unpaired.""" + super().unpair(client_uuid) + show_setup_message(self.hass, self.state.pincode) diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py new file mode 100644 index 0000000000000..82ec296da4bde --- /dev/null +++ b/homeassistant/components/homekit/const.py @@ -0,0 +1,177 @@ +"""Constants used be the HomeKit component.""" +# #### Misc #### +DEBOUNCE_TIMEOUT = 0.5 +DOMAIN = "homekit" +HOMEKIT_FILE = ".homekit.state" +HOMEKIT_NOTIFY_ID = 4663548 + +# #### Attributes #### +ATTR_DISPLAY_NAME = "display_name" +ATTR_VALUE = "value" + +# #### Config #### +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" + +# #### HomeKit Component Event #### +EVENT_HOMEKIT_CHANGED = "homekit_state_change" + +# #### HomeKit Component Services #### +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" + +# #### Switch Types #### +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_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_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_MIN_STEP = "minStep" +PROP_CELSIUS = {"minValue": -273, "maxValue": 999} + +# #### Device Classes #### +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 0000000000000..69e4554d81bc7 --- /dev/null +++ b/homeassistant/components/homekit/manifest.json @@ -0,0 +1,8 @@ +{ + "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/services.yaml b/homeassistant/components/homekit/services.yaml new file mode 100644 index 0000000000000..e30e71301b3e0 --- /dev/null +++ b/homeassistant/components/homekit/services.yaml @@ -0,0 +1,4 @@ +# Describes the format for available HomeKit services + +start: + description: Starts the HomeKit component driver. diff --git a/homeassistant/components/homekit/type_covers.py b/homeassistant/components/homekit/type_covers.py new file mode 100644 index 0000000000000..807941c7a6d6c --- /dev/null +++ b/homeassistant/components/homekit/type_covers.py @@ -0,0 +1,202 @@ +"""Class to hold all cover accessories.""" +import logging + +from pyhap.const import CATEGORY_GARAGE_DOOR_OPENER, CATEGORY_WINDOW_COVERING + +from homeassistant.components.cover import ( + ATTR_CURRENT_POSITION, + ATTR_POSITION, + DOMAIN, + SUPPORT_STOP, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + SERVICE_SET_COVER_POSITION, + SERVICE_STOP_COVER, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, +) + +from . import TYPES +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, +) + +_LOGGER = logging.getLogger(__name__) + + +@TYPES.register("GarageDoorOpener") +class GarageDoorOpener(HomeAccessory): + """Generate a Garage Door Opener accessory for a cover entity. + + The cover entity must be in the 'garage' device class + and support no more than open, close, and stop. + """ + + def __init__(self, *args): + """Initialize a GarageDoorOpener accessory object.""" + super().__init__(*args, category=CATEGORY_GARAGE_DOOR_OPENER) + self._flag_state = False + + serv_garage_door = self.add_preload_service(SERV_GARAGE_DOOR_OPENER) + self.char_current_state = serv_garage_door.configure_char( + CHAR_CURRENT_DOOR_STATE, value=0 + ) + self.char_target_state = serv_garage_door.configure_char( + CHAR_TARGET_DOOR_STATE, value=0, setter_callback=self.set_state + ) + + def set_state(self, value): + """Change garage state if call came from HomeKit.""" + _LOGGER.debug("%s: Set state to %d", self.entity_id, value) + self._flag_state = True + + params = {ATTR_ENTITY_ID: self.entity_id} + if value == 0: + 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: + 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.""" + hass_state = new_state.state + if hass_state in (STATE_OPEN, STATE_CLOSED): + current_state = 0 if hass_state == STATE_OPEN else 1 + self.char_current_state.set_value(current_state) + if not self._flag_state: + self.char_target_state.set_value(current_state) + self._flag_state = False + + +@TYPES.register("WindowCovering") +class WindowCovering(HomeAccessory): + """Generate a Window accessory for a cover entity. + + The cover entity must support: set_cover_position. + """ + + def __init__(self, *args): + """Initialize a WindowCovering accessory object.""" + super().__init__(*args, category=CATEGORY_WINDOW_COVERING) + self._homekit_target = None + + serv_cover = self.add_preload_service(SERV_WINDOW_COVERING) + self.char_current_position = serv_cover.configure_char( + CHAR_CURRENT_POSITION, value=0 + ) + self.char_target_position = serv_cover.configure_char( + CHAR_TARGET_POSITION, value=0, setter_callback=self.move_cover + ) + self.char_position_state = serv_cover.configure_char( + CHAR_POSITION_STATE, value=2 + ) + + @debounce + def move_cover(self, value): + """Move cover to value if call came from HomeKit.""" + _LOGGER.debug("%s: Set position to %d", self.entity_id, value) + self._homekit_target = value + + params = {ATTR_ENTITY_ID: self.entity_id, ATTR_POSITION: value} + 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 + ): + self.char_target_position.set_value(current_position) + self._homekit_target = None + if new_state.state == STATE_OPENING: + self.char_position_state.set_value(1) + elif new_state.state == STATE_CLOSING: + self.char_position_state.set_value(0) + else: + self.char_position_state.set_value(2) + + +@TYPES.register("WindowCoveringBasic") +class WindowCoveringBasic(HomeAccessory): + """Generate a Window accessory for a cover entity. + + The cover entity must support: open_cover, close_cover, + stop_cover (optional). + """ + + def __init__(self, *args): + """Initialize a WindowCovering accessory object.""" + super().__init__(*args, category=CATEGORY_WINDOW_COVERING) + features = self.hass.states.get(self.entity_id).attributes.get( + ATTR_SUPPORTED_FEATURES + ) + self._supports_stop = features & SUPPORT_STOP + + serv_cover = self.add_preload_service(SERV_WINDOW_COVERING) + self.char_current_position = serv_cover.configure_char( + CHAR_CURRENT_POSITION, value=0 + ) + self.char_target_position = serv_cover.configure_char( + CHAR_TARGET_POSITION, value=0, setter_callback=self.move_cover + ) + self.char_position_state = serv_cover.configure_char( + CHAR_POSITION_STATE, value=2 + ) + + @debounce + def move_cover(self, value): + """Move cover to value if call came from HomeKit.""" + _LOGGER.debug("%s: Set position to %d", self.entity_id, value) + + if self._supports_stop: + if value > 70: + service, position = (SERVICE_OPEN_COVER, 100) + elif value < 30: + service, position = (SERVICE_CLOSE_COVER, 0) + else: + service, position = (SERVICE_STOP_COVER, 50) + else: + if value >= 50: + service, position = (SERVICE_OPEN_COVER, 100) + else: + service, position = (SERVICE_CLOSE_COVER, 0) + + params = {ATTR_ENTITY_ID: self.entity_id} + self.call_service(DOMAIN, service, params) + + # Snap the current/target position to the expected final position. + self.char_current_position.set_value(position) + self.char_target_position.set_value(position) + + def update_state(self, new_state): + """Update cover position after state changed.""" + position_mapping = {STATE_OPEN: 100, STATE_CLOSED: 0} + hk_position = position_mapping.get(new_state.state) + if hk_position is not None: + self.char_current_position.set_value(hk_position) + self.char_target_position.set_value(hk_position) + if new_state.state == STATE_OPENING: + self.char_position_state.set_value(1) + elif new_state.state == STATE_CLOSING: + self.char_position_state.set_value(0) + else: + self.char_position_state.set_value(2) diff --git a/homeassistant/components/homekit/type_fans.py b/homeassistant/components/homekit/type_fans.py new file mode 100644 index 0000000000000..e6d128d1e28bf --- /dev/null +++ b/homeassistant/components/homekit/type_fans.py @@ -0,0 +1,186 @@ +"""Class to hold all light accessories.""" +import logging + +from pyhap.const import CATEGORY_FAN + +from homeassistant.components.fan import ( + 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, +) + +from . import TYPES +from .accessories import HomeAccessory, debounce +from .const import ( + CHAR_ACTIVE, + CHAR_ROTATION_DIRECTION, + CHAR_ROTATION_SPEED, + CHAR_SWING_MODE, + SERV_FANV2, +) +from .util import HomeKitSpeedMapping + +_LOGGER = logging.getLogger(__name__) + + +@TYPES.register("Fan") +class Fan(HomeAccessory): + """Generate a Fan accessory for a fan entity. + + Currently supports: state, speed, oscillate, direction. + """ + + def __init__(self, *args): + """Initialize a new Light accessory object.""" + super().__init__(*args, category=CATEGORY_FAN) + self._flag = { + CHAR_ACTIVE: False, + CHAR_ROTATION_DIRECTION: False, + CHAR_SWING_MODE: False, + } + self._state = 0 + + chars = [] + features = self.hass.states.get(self.entity_id).attributes.get( + ATTR_SUPPORTED_FEATURES + ) + if features & SUPPORT_DIRECTION: + chars.append(CHAR_ROTATION_DIRECTION) + if features & SUPPORT_OSCILLATE: + 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, chars) + self.char_active = serv_fan.configure_char( + CHAR_ACTIVE, value=0, setter_callback=self.set_state + ) + + 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 + ) + + 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 + ) + + def set_state(self, value): + """Set state if call came from HomeKit.""" + _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.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) + 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.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) + self._flag[CHAR_SWING_MODE] = True + 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.""" + # Handle State + state = new_state.state + if state in (STATE_ON, STATE_OFF): + self._state = 1 if state == STATE_ON else 0 + if not self._flag[CHAR_ACTIVE] and self.char_active.value != self._state: + self.char_active.set_value(self._state) + self._flag[CHAR_ACTIVE] = False + + # Handle Direction + if 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, + ): + 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 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): + hk_oscillating = 1 if oscillating else 0 + if self.char_swing.value != hk_oscillating: + self.char_swing.set_value(hk_oscillating) + self._flag[CHAR_SWING_MODE] = False diff --git a/homeassistant/components/homekit/type_lights.py b/homeassistant/components/homekit/type_lights.py new file mode 100644 index 0000000000000..7f195b276d698 --- /dev/null +++ b/homeassistant/components/homekit/type_lights.py @@ -0,0 +1,229 @@ +"""Class to hold all light accessories.""" +import logging + +from pyhap.const import CATEGORY_LIGHTBULB + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_BRIGHTNESS_PCT, + ATTR_COLOR_TEMP, + ATTR_HS_COLOR, + ATTR_MAX_MIREDS, + ATTR_MIN_MIREDS, + DOMAIN, + SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, + SUPPORT_COLOR_TEMP, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, +) + +from . import TYPES +from .accessories import HomeAccessory, debounce +from .const import ( + 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" + + +@TYPES.register("Light") +class Light(HomeAccessory): + """Generate a Light accessory for a light entity. + + Currently supports: state, brightness, color temperature, rgb_color. + """ + + def __init__(self, *args): + """Initialize a new Light accessory object.""" + super().__init__(*args, category=CATEGORY_LIGHTBULB) + self._flag = { + CHAR_ON: False, + CHAR_BRIGHTNESS: False, + CHAR_HUE: False, + CHAR_SATURATION: False, + CHAR_COLOR_TEMPERATURE: False, + RGB_COLOR: False, + } + self._state = 0 + + self.chars = [] + self._features = self.hass.states.get(self.entity_id).attributes.get( + ATTR_SUPPORTED_FEATURES + ) + if self._features & SUPPORT_BRIGHTNESS: + self.chars.append(CHAR_BRIGHTNESS) + if self._features & SUPPORT_COLOR_TEMP: + self.chars.append(CHAR_COLOR_TEMPERATURE) + if self._features & SUPPORT_COLOR: + self.chars.append(CHAR_HUE) + self.chars.append(CHAR_SATURATION) + self._hue = None + self._saturation = None + + serv_light = self.add_preload_service(SERV_LIGHTBULB, self.chars) + self.char_on = serv_light.configure_char( + CHAR_ON, value=self._state, setter_callback=self.set_state + ) + + if CHAR_BRIGHTNESS in self.chars: + # 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=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 + ) + self.char_color_temperature = serv_light.configure_char( + CHAR_COLOR_TEMPERATURE, + value=min_mireds, + properties={PROP_MIN_VALUE: min_mireds, PROP_MAX_VALUE: max_mireds}, + setter_callback=self.set_color_temperature, + ) + if CHAR_HUE in self.chars: + self.char_hue = serv_light.configure_char( + CHAR_HUE, value=0, setter_callback=self.set_hue + ) + if CHAR_SATURATION in self.chars: + self.char_saturation = serv_light.configure_char( + CHAR_SATURATION, value=75, setter_callback=self.set_saturation + ) + + def set_state(self, value): + """Set state if call came from HomeKit.""" + if self._state == value: + return + + _LOGGER.debug("%s: Set state to %d", self.entity_id, value) + self._flag[CHAR_ON] = True + params = {ATTR_ENTITY_ID: self.entity_id} + service = SERVICE_TURN_ON if value == 1 else SERVICE_TURN_OFF + self.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) + 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.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) + self._flag[CHAR_COLOR_TEMPERATURE] = True + params = {ATTR_ENTITY_ID: self.entity_id, ATTR_COLOR_TEMP: value} + 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) + self._flag[CHAR_SATURATION] = True + self._saturation = value + self.set_color() + + def set_hue(self, value): + """Set hue if call came from HomeKit.""" + _LOGGER.debug("%s: Set hue to %d", self.entity_id, value) + self._flag[CHAR_HUE] = True + self._hue = value + self.set_color() + + def set_color(self): + """Set color if call came from HomeKit.""" + if ( + self._features & SUPPORT_COLOR + and self._flag[CHAR_HUE] + and self._flag[CHAR_SATURATION] + ): + color = (self._hue, self._saturation) + _LOGGER.debug("%s: Set hs_color to %s", self.entity_id, color) + self._flag.update( + {CHAR_HUE: False, CHAR_SATURATION: False, RGB_COLOR: True} + ) + params = {ATTR_ENTITY_ID: self.entity_id, ATTR_HS_COLOR: color} + self.call_service(DOMAIN, SERVICE_TURN_ON, params, f"set color at {color}") + + def update_state(self, new_state): + """Update light after state change.""" + # Handle State + state = new_state.state + if state in (STATE_ON, STATE_OFF): + self._state = 1 if state == STATE_ON else 0 + if not self._flag[CHAR_ON] and self.char_on.value != self._state: + self.char_on.set_value(self._state) + self._flag[CHAR_ON] = False + + # Handle Brightness + if CHAR_BRIGHTNESS in self.chars: + brightness = new_state.attributes.get(ATTR_BRIGHTNESS) + if not self._flag[CHAR_BRIGHTNESS] and isinstance(brightness, int): + brightness = round(brightness / 255 * 100, 0) + if self.char_brightness.value != brightness: + # 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 + ): + self.char_color_temperature.set_value(color_temperature) + self._flag[CHAR_COLOR_TEMPERATURE] = False + + # Handle Color + if CHAR_SATURATION in self.chars and CHAR_HUE in self.chars: + hue, saturation = new_state.attributes.get(ATTR_HS_COLOR, (None, None)) + if ( + not self._flag[RGB_COLOR] + and (hue != self._hue or saturation != self._saturation) + and isinstance(hue, (int, float)) + and isinstance(saturation, (int, float)) + ): + self.char_hue.set_value(hue) + self.char_saturation.set_value(saturation) + self._hue, self._saturation = (hue, saturation) + self._flag[RGB_COLOR] = False diff --git a/homeassistant/components/homekit/type_locks.py b/homeassistant/components/homekit/type_locks.py new file mode 100644 index 0000000000000..3a7211ab2adda --- /dev/null +++ b/homeassistant/components/homekit/type_locks.py @@ -0,0 +1,80 @@ +"""Class to hold all lock accessories.""" +import logging + +from pyhap.const import CATEGORY_DOOR_LOCK + +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 +from .const import CHAR_LOCK_CURRENT_STATE, CHAR_LOCK_TARGET_STATE, SERV_LOCK + +_LOGGER = logging.getLogger(__name__) + +HASS_TO_HOMEKIT = { + STATE_UNLOCKED: 0, + STATE_LOCKED: 1, + # Value 2 is Jammed which hass doesn't have a state for + STATE_UNKNOWN: 3, +} + +HOMEKIT_TO_HASS = {c: s for s, c in HASS_TO_HOMEKIT.items()} + +STATE_TO_SERVICE = {STATE_LOCKED: "lock", STATE_UNLOCKED: "unlock"} + + +@TYPES.register("Lock") +class Lock(HomeAccessory): + """Generate a Lock accessory for a lock entity. + + The lock entity must support: unlock and lock. + """ + + def __init__(self, *args): + """Initialize a Lock accessory object.""" + super().__init__(*args, category=CATEGORY_DOOR_LOCK) + self._code = self.config.get(ATTR_CODE) + self._flag_state = False + + serv_lock_mechanism = self.add_preload_service(SERV_LOCK) + self.char_current_state = serv_lock_mechanism.configure_char( + CHAR_LOCK_CURRENT_STATE, value=HASS_TO_HOMEKIT[STATE_UNKNOWN] + ) + self.char_target_state = serv_lock_mechanism.configure_char( + CHAR_LOCK_TARGET_STATE, + value=HASS_TO_HOMEKIT[STATE_LOCKED], + setter_callback=self.set_state, + ) + + def set_state(self, value): + """Set lock state to value if call came from HomeKit.""" + _LOGGER.debug("%s: Set state to %d", self.entity_id, value) + self._flag_state = True + + hass_value = HOMEKIT_TO_HASS.get(value) + service = STATE_TO_SERVICE[hass_value] + + params = {ATTR_ENTITY_ID: self.entity_id} + if self._code: + params[ATTR_CODE] = self._code + self.call_service(DOMAIN, service, params) + + def update_state(self, new_state): + """Update lock after state changed.""" + hass_state = new_state.state + if hass_state in HASS_TO_HOMEKIT: + current_lock_state = HASS_TO_HOMEKIT[hass_state] + self.char_current_state.set_value(current_lock_state) + _LOGGER.debug( + "%s: Updated current state to %s (%d)", + self.entity_id, + hass_state, + current_lock_state, + ) + + # LockTargetState only supports locked and unlocked + if hass_state in (STATE_LOCKED, STATE_UNLOCKED): + if not self._flag_state: + self.char_target_state.set_value(current_lock_state) + self._flag_state = False diff --git a/homeassistant/components/homekit/type_media_players.py b/homeassistant/components/homekit/type_media_players.py new file mode 100644 index 0000000000000..3c5dce4fa7a11 --- /dev/null +++ b/homeassistant/components/homekit/type_media_players.py @@ -0,0 +1,429 @@ +"""Class to hold all media player accessories.""" +import logging + +from pyhap.const import CATEGORY_SWITCH, CATEGORY_TELEVISION + +from homeassistant.components.media_player import ( + 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_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__) + +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") +class MediaPlayer(HomeAccessory): + """Generate a Media Player accessory.""" + + def __init__(self, *args): + """Initialize a Switch accessory object.""" + super().__init__(*args, category=CATEGORY_SWITCH) + self._flag = { + FEATURE_ON_OFF: False, + FEATURE_PLAY_PAUSE: False, + FEATURE_PLAY_STOP: False, + FEATURE_TOGGLE_MUTE: False, + } + self.chars = { + FEATURE_ON_OFF: None, + FEATURE_PLAY_PAUSE: None, + FEATURE_PLAY_STOP: None, + FEATURE_TOGGLE_MUTE: None, + } + feature_list = self.config[CONF_FEATURE_LIST] + + if FEATURE_ON_OFF in feature_list: + name = self.generate_service_name(FEATURE_ON_OFF) + serv_on_off = self.add_preload_service(SERV_SWITCH, CHAR_NAME) + serv_on_off.configure_char(CHAR_NAME, value=name) + self.chars[FEATURE_ON_OFF] = serv_on_off.configure_char( + CHAR_ON, value=False, setter_callback=self.set_on_off + ) + + if FEATURE_PLAY_PAUSE in feature_list: + name = self.generate_service_name(FEATURE_PLAY_PAUSE) + serv_play_pause = self.add_preload_service(SERV_SWITCH, CHAR_NAME) + serv_play_pause.configure_char(CHAR_NAME, value=name) + self.chars[FEATURE_PLAY_PAUSE] = serv_play_pause.configure_char( + CHAR_ON, value=False, setter_callback=self.set_play_pause + ) + + if FEATURE_PLAY_STOP in feature_list: + name = self.generate_service_name(FEATURE_PLAY_STOP) + serv_play_stop = self.add_preload_service(SERV_SWITCH, CHAR_NAME) + serv_play_stop.configure_char(CHAR_NAME, value=name) + self.chars[FEATURE_PLAY_STOP] = serv_play_stop.configure_char( + CHAR_ON, value=False, setter_callback=self.set_play_stop + ) + + if FEATURE_TOGGLE_MUTE in feature_list: + name = self.generate_service_name(FEATURE_TOGGLE_MUTE) + serv_toggle_mute = self.add_preload_service(SERV_SWITCH, CHAR_NAME) + serv_toggle_mute.configure_char(CHAR_NAME, value=name) + self.chars[FEATURE_TOGGLE_MUTE] = serv_toggle_mute.configure_char( + CHAR_ON, value=False, setter_callback=self.set_toggle_mute + ) + + def generate_service_name(self, mode): + """Generate name for individual service.""" + return "{} {}".format(self.display_name, MODE_FRIENDLY_NAME[mode]) + + def set_on_off(self, value): + """Move switch state to value if call came from HomeKit.""" + _LOGGER.debug('%s: Set switch state for "on_off" to %s', self.entity_id, value) + self._flag[FEATURE_ON_OFF] = True + service = SERVICE_TURN_ON if value else SERVICE_TURN_OFF + params = {ATTR_ENTITY_ID: self.entity_id} + self.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 + ) + self._flag[FEATURE_PLAY_PAUSE] = True + service = SERVICE_MEDIA_PLAY if value else SERVICE_MEDIA_PAUSE + params = {ATTR_ENTITY_ID: self.entity_id} + 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 + ) + self._flag[FEATURE_PLAY_STOP] = True + service = SERVICE_MEDIA_PLAY if value else SERVICE_MEDIA_STOP + params = {ATTR_ENTITY_ID: self.entity_id} + 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 + ) + self._flag[FEATURE_TOGGLE_MUTE] = True + 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") + if not self._flag[FEATURE_ON_OFF]: + _LOGGER.debug( + '%s: Set current state for "on_off" to %s', self.entity_id, hk_state + ) + self.chars[FEATURE_ON_OFF].set_value(hk_state) + self._flag[FEATURE_ON_OFF] = False + + if self.chars[FEATURE_PLAY_PAUSE]: + hk_state = current_state == STATE_PLAYING + if not self._flag[FEATURE_PLAY_PAUSE]: + _LOGGER.debug( + '%s: Set current state for "play_pause" to %s', + self.entity_id, + hk_state, + ) + self.chars[FEATURE_PLAY_PAUSE].set_value(hk_state) + self._flag[FEATURE_PLAY_PAUSE] = False + + if self.chars[FEATURE_PLAY_STOP]: + hk_state = current_state == STATE_PLAYING + if not self._flag[FEATURE_PLAY_STOP]: + _LOGGER.debug( + '%s: Set current state for "play_stop" to %s', + self.entity_id, + hk_state, + ) + self.chars[FEATURE_PLAY_STOP].set_value(hk_state) + self._flag[FEATURE_PLAY_STOP] = False + + if self.chars[FEATURE_TOGGLE_MUTE]: + current_state = new_state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) + if not self._flag[FEATURE_TOGGLE_MUTE]: + _LOGGER.debug( + '%s: Set current state for "toggle_mute" to %s', + self.entity_id, + current_state, + ) + self.chars[FEATURE_TOGGLE_MUTE].set_value(current_state) + self._flag[FEATURE_TOGGLE_MUTE] = False + + +@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 Home Assistant", + 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 new file mode 100644 index 0000000000000..345709eb7daac --- /dev/null +++ b/homeassistant/components/homekit/type_security_systems.py @@ -0,0 +1,95 @@ +"""Class to hold all alarm control panel accessories.""" +import logging + +from pyhap.const import CATEGORY_ALARM_SYSTEM + +from homeassistant.components.alarm_control_panel import DOMAIN +from homeassistant.const import ( + ATTR_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, +) + +_LOGGER = logging.getLogger(__name__) + +HASS_TO_HOMEKIT = { + STATE_ALARM_ARMED_HOME: 0, + STATE_ALARM_ARMED_AWAY: 1, + STATE_ALARM_ARMED_NIGHT: 2, + STATE_ALARM_DISARMED: 3, + STATE_ALARM_TRIGGERED: 4, +} + +HOMEKIT_TO_HASS = {c: s for s, c in HASS_TO_HOMEKIT.items()} + +STATE_TO_SERVICE = { + STATE_ALARM_ARMED_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, +} + + +@TYPES.register("SecuritySystem") +class SecuritySystem(HomeAccessory): + """Generate an SecuritySystem accessory for an alarm control panel.""" + + def __init__(self, *args): + """Initialize a SecuritySystem accessory object.""" + super().__init__(*args, category=CATEGORY_ALARM_SYSTEM) + self._alarm_code = self.config.get(ATTR_CODE) + self._flag_state = False + + serv_alarm = self.add_preload_service(SERV_SECURITY_SYSTEM) + self.char_current_state = serv_alarm.configure_char( + CHAR_CURRENT_SECURITY_STATE, value=3 + ) + self.char_target_state = serv_alarm.configure_char( + CHAR_TARGET_SECURITY_STATE, value=3, setter_callback=self.set_security_state + ) + + def set_security_state(self, value): + """Move security state to value if call came from HomeKit.""" + _LOGGER.debug("%s: Set security state to %d", self.entity_id, value) + self._flag_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.call_service(DOMAIN, service, params) + + def update_state(self, new_state): + """Update security state after state changed.""" + hass_state = new_state.state + if hass_state in HASS_TO_HOMEKIT: + current_security_state = HASS_TO_HOMEKIT[hass_state] + self.char_current_state.set_value(current_security_state) + _LOGGER.debug( + "%s: Updated current state to %s (%d)", + self.entity_id, + hass_state, + current_security_state, + ) + + # SecuritySystemTargetState does not support triggered + if not self._flag_state and hass_state != STATE_ALARM_TRIGGERED: + self.char_target_state.set_value(current_security_state) + self._flag_state = False diff --git a/homeassistant/components/homekit/type_sensors.py b/homeassistant/components/homekit/type_sensors.py new file mode 100644 index 0000000000000..a1450518e0cf8 --- /dev/null +++ b/homeassistant/components/homekit/type_sensors.py @@ -0,0 +1,255 @@ +"""Class to hold all sensor accessories.""" +import logging + +from pyhap.const import CATEGORY_SENSOR + +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_UNIT_OF_MEASUREMENT, + STATE_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_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_DOOR: (SERV_CONTACT_SENSOR, CHAR_CONTACT_SENSOR_STATE), + DEVICE_CLASS_GARAGE_DOOR: (SERV_CONTACT_SENSOR, CHAR_CONTACT_SENSOR_STATE), + DEVICE_CLASS_GAS: (SERV_CARBON_MONOXIDE_SENSOR, CHAR_CARBON_MONOXIDE_DETECTED), + DEVICE_CLASS_MOISTURE: (SERV_LEAK_SENSOR, CHAR_LEAK_DETECTED), + DEVICE_CLASS_MOTION: (SERV_MOTION_SENSOR, CHAR_MOTION_DETECTED), + DEVICE_CLASS_OCCUPANCY: (SERV_OCCUPANCY_SENSOR, CHAR_OCCUPANCY_DETECTED), + DEVICE_CLASS_OPENING: (SERV_CONTACT_SENSOR, CHAR_CONTACT_SENSOR_STATE), + DEVICE_CLASS_SMOKE: (SERV_SMOKE_SENSOR, CHAR_SMOKE_DETECTED), + DEVICE_CLASS_WINDOW: (SERV_CONTACT_SENSOR, CHAR_CONTACT_SENSOR_STATE), +} + + +@TYPES.register("TemperatureSensor") +class TemperatureSensor(HomeAccessory): + """Generate a TemperatureSensor accessory for a temperature sensor. + + Sensor entity must return temperature in °C, °F. + """ + + def __init__(self, *args): + """Initialize a TemperatureSensor accessory object.""" + super().__init__(*args, category=CATEGORY_SENSOR) + serv_temp = self.add_preload_service(SERV_TEMPERATURE_SENSOR) + self.char_temp = serv_temp.configure_char( + CHAR_CURRENT_TEMPERATURE, value=0, properties=PROP_CELSIUS + ) + + def update_state(self, new_state): + """Update temperature after state changed.""" + unit = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS) + temperature = convert_to_float(new_state.state) + if temperature: + temperature = temperature_to_homekit(temperature, unit) + self.char_temp.set_value(temperature) + _LOGGER.debug( + "%s: Current temperature set to %.1f°C", self.entity_id, temperature + ) + + +@TYPES.register("HumiditySensor") +class HumiditySensor(HomeAccessory): + """Generate a HumiditySensor accessory as humidity sensor.""" + + def __init__(self, *args): + """Initialize a HumiditySensor accessory object.""" + super().__init__(*args, category=CATEGORY_SENSOR) + serv_humidity = self.add_preload_service(SERV_HUMIDITY_SENSOR) + self.char_humidity = serv_humidity.configure_char( + CHAR_CURRENT_HUMIDITY, value=0 + ) + + def update_state(self, new_state): + """Update accessory after state change.""" + humidity = convert_to_float(new_state.state) + if humidity: + self.char_humidity.set_value(humidity) + _LOGGER.debug("%s: Percent set to %d%%", self.entity_id, humidity) + + +@TYPES.register("AirQualitySensor") +class AirQualitySensor(HomeAccessory): + """Generate a AirQualitySensor accessory as air quality sensor.""" + + def __init__(self, *args): + """Initialize a AirQualitySensor accessory object.""" + super().__init__(*args, category=CATEGORY_SENSOR) + + serv_air_quality = self.add_preload_service( + SERV_AIR_QUALITY_SENSOR, [CHAR_AIR_PARTICULATE_DENSITY] + ) + self.char_quality = serv_air_quality.configure_char(CHAR_AIR_QUALITY, value=0) + self.char_density = serv_air_quality.configure_char( + CHAR_AIR_PARTICULATE_DENSITY, value=0 + ) + + def update_state(self, new_state): + """Update accessory after state change.""" + density = convert_to_float(new_state.state) + if density: + self.char_density.set_value(density) + self.char_quality.set_value(density_to_air_quality(density)) + _LOGGER.debug("%s: Set to %d", self.entity_id, density) + + +@TYPES.register("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.""" + + def __init__(self, *args): + """Initialize a CarbonDioxideSensor accessory object.""" + super().__init__(*args, category=CATEGORY_SENSOR) + + serv_co2 = self.add_preload_service( + SERV_CARBON_DIOXIDE_SENSOR, + [CHAR_CARBON_DIOXIDE_LEVEL, CHAR_CARBON_DIOXIDE_PEAK_LEVEL], + ) + self.char_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 + ) + self.char_detected = serv_co2.configure_char( + CHAR_CARBON_DIOXIDE_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_CO2) + _LOGGER.debug("%s: Set to %d", self.entity_id, value) + + +@TYPES.register("LightSensor") +class LightSensor(HomeAccessory): + """Generate a LightSensor accessory as light sensor.""" + + def __init__(self, *args): + """Initialize a LightSensor accessory object.""" + super().__init__(*args, category=CATEGORY_SENSOR) + + serv_light = self.add_preload_service(SERV_LIGHT_SENSOR) + self.char_light = serv_light.configure_char( + CHAR_CURRENT_AMBIENT_LIGHT_LEVEL, value=0 + ) + + def update_state(self, new_state): + """Update accessory after state change.""" + luminance = convert_to_float(new_state.state) + if luminance: + self.char_light.set_value(luminance) + _LOGGER.debug("%s: Set to %d", self.entity_id, luminance) + + +@TYPES.register("BinarySensor") +class BinarySensor(HomeAccessory): + """Generate a BinarySensor accessory as binary sensor.""" + + def __init__(self, *args): + """Initialize a BinarySensor accessory object.""" + super().__init__(*args, category=CATEGORY_SENSOR) + device_class = self.hass.states.get(self.entity_id).attributes.get( + ATTR_DEVICE_CLASS + ) + service_char = ( + BINARY_SENSOR_SERVICE_MAP[device_class] + if device_class in BINARY_SENSOR_SERVICE_MAP + else BINARY_SENSOR_SERVICE_MAP[DEVICE_CLASS_OCCUPANCY] + ) + + service = self.add_preload_service(service_char[0]) + self.char_detected = service.configure_char(service_char[1], value=0) + + def update_state(self, new_state): + """Update accessory after state change.""" + state = new_state.state + detected = state in (STATE_ON, STATE_HOME) + self.char_detected.set_value(detected) + _LOGGER.debug("%s: Set to %d", self.entity_id, detected) diff --git a/homeassistant/components/homekit/type_switches.py b/homeassistant/components/homekit/type_switches.py new file mode 100644 index 0000000000000..66d3037b89442 --- /dev/null +++ b/homeassistant/components/homekit/type_switches.py @@ -0,0 +1,182 @@ +"""Class to hold all switch accessories.""" +import logging + +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, + 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_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") +class Outlet(HomeAccessory): + """Generate an Outlet accessory.""" + + def __init__(self, *args): + """Initialize an Outlet accessory object.""" + super().__init__(*args, category=CATEGORY_OUTLET) + 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 + ) + self.char_outlet_in_use = serv_outlet.configure_char( + CHAR_OUTLET_IN_USE, value=True + ) + + def set_state(self, value): + """Move switch state to value if call came from HomeKit.""" + _LOGGER.debug("%s: Set switch state to %s", self.entity_id, value) + self._flag_state = True + 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_on.set_value(current_state) + self._flag_state = False + + +@TYPES.register("Switch") +class Switch(HomeAccessory): + """Generate a Switch accessory.""" + + def __init__(self, *args): + """Initialize a Switch accessory object.""" + super().__init__(*args, category=CATEGORY_SWITCH) + self._domain = split_entity_id(self.entity_id)[0] + self._flag_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 + ) + + 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) + 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.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.""" + 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_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 new file mode 100644 index 0000000000000..79a9d156f101f --- /dev/null +++ b/homeassistant/components/homekit/type_thermostats.py @@ -0,0 +1,446 @@ +"""Class to hold all thermostat accessories.""" +import logging + +from pyhap.const import CATEGORY_THERMOSTAT + +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, + ATTR_TEMPERATURE, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) + +from . import TYPES +from .accessories import HomeAccessory, debounce +from .const import ( + 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 = { + 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()} + +HC_HASS_TO_HOMEKIT_ACTION = { + CURRENT_HVAC_OFF: 0, + CURRENT_HVAC_IDLE: 0, + CURRENT_HVAC_HEAT: 1, + CURRENT_HVAC_COOL: 2, +} + + +@TYPES.register("Thermostat") +class Thermostat(HomeAccessory): + """Generate a Thermostat accessory for a climate.""" + + def __init__(self, *args): + """Initialize a Thermostat accessory object.""" + super().__init__(*args, category=CATEGORY_THERMOSTAT) + self._unit = self.hass.config.units.temperature_unit + self._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 = [] + 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 mode characteristics + self.char_current_heat_cool = serv_thermostat.configure_char( + 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, + valid_values=hc_valid_values, + ) + + # Current and target temperature characteristics + self.char_current_temp = serv_thermostat.configure_char( + CHAR_CURRENT_TEMPERATURE, value=21.0 + ) + self.char_target_temp = serv_thermostat.configure_char( + CHAR_TARGET_TEMPERATURE, + value=21.0, + properties={ + PROP_MIN_VALUE: min_temp, + PROP_MAX_VALUE: max_temp, + 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 + ) + + # 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, + 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, + 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 + 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 + else DEFAULT_MIN_TEMP + ) + 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 = 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 %.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, + 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 %.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, + } + 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 %.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 thermostat state after state changed.""" + # Update current temperature + current_temp = new_state.attributes.get(ATTR_CURRENT_TEMPERATURE) + if isinstance(current_temp, (int, float)): + current_temp = temperature_to_homekit(current_temp, self._unit) + self.char_current_temp.set_value(current_temp) + + # Update target temperature + target_temp = new_state.attributes.get(ATTR_TEMPERATURE) + if isinstance(target_temp, (int, float)): + target_temp = temperature_to_homekit(target_temp, self._unit) + if not self._flag_temperature: + self.char_target_temp.set_value(target_temp) + 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._flag_coolingthresh: + self.char_cooling_thresh_temp.set_value(cooling_thresh) + 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._flag_heatingthresh: + self.char_heating_thresh_temp.set_value(heating_thresh) + 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 + 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 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] + ) + + +@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 new file mode 100644 index 0000000000000..0fe97cfca63a3 --- /dev/null +++ b/homeassistant/components/homekit/util.py @@ -0,0 +1,256 @@ +"""Collection of useful functions for the HomeKit component.""" +from collections import OrderedDict, namedtuple +import logging + +import voluptuous as vol + +from homeassistant.components import fan, media_player, sensor +from homeassistant.const import ( + 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, + 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, + 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} +) + +CODE_SCHEMA = BASIC_INFO_SCHEMA.extend( + {vol.Optional(ATTR_CODE, default=None): vol.Any(None, cv.string)} +) + +MEDIA_PLAYER_SCHEMA = vol.Schema( + { + vol.Required(CONF_FEATURE): vol.All( + cv.string, + vol.In( + ( + FEATURE_ON_OFF, + FEATURE_PLAY_PAUSE, + FEATURE_PLAY_STOP, + FEATURE_TOGGLE_MUTE, + ) + ), + ) + } +) + +SWITCH_TYPE_SCHEMA = BASIC_INFO_SCHEMA.extend( + { + vol.Optional(CONF_TYPE, default=TYPE_SWITCH): vol.All( + cv.string, + vol.In( + ( + TYPE_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) + ) + + if domain in ("alarm_control_panel", "lock"): + config = CODE_SCHEMA(config) + + 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(f"A feature can be added only once for {entity}") + feature_list[key] = params + config[CONF_FEATURE_LIST] = feature_list + + elif domain == "switch": + config = SWITCH_TYPE_SCHEMA(config) + + else: + config = BASIC_INFO_SCHEMA(config) + + entities[entity] = config + return entities + + +def validate_media_player_features(state, feature_list): + """Validate features for media players.""" + features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + + supported_modes = [] + if features & ( + media_player.const.SUPPORT_TURN_ON | media_player.const.SUPPORT_TURN_OFF + ): + supported_modes.append(FEATURE_ON_OFF) + if features & (media_player.const.SUPPORT_PLAY | media_player.const.SUPPORT_PAUSE): + supported_modes.append(FEATURE_PLAY_PAUSE) + if features & (media_player.const.SUPPORT_PLAY | media_player.const.SUPPORT_STOP): + supported_modes.append(FEATURE_PLAY_STOP) + if features & media_player.const.SUPPORT_VOLUME_MUTE: + supported_modes.append(FEATURE_TOGGLE_MUTE) + + error_list = [] + for feature in feature_list: + if feature not in supported_modes: + error_list.append(feature) + + if error_list: + _LOGGER.error("%s does not support features: %s", state.entity_id, error_list) + return False + return True + + +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) + ) + hass.components.persistent_notification.create( + message, "HomeKit Setup", HOMEKIT_NOTIFY_ID + ) + + +def dismiss_setup_message(hass): + """Dismiss persistent notification and remove QR code.""" + hass.components.persistent_notification.dismiss(HOMEKIT_NOTIFY_ID) + + +def convert_to_float(state): + """Return float of state, catch errors.""" + try: + return float(state) + except (ValueError, TypeError): + return None + + +def temperature_to_homekit(temperature, unit): + """Convert temperature to Celsius for HomeKit.""" + return round(temp_util.convert(temperature, unit, TEMP_CELSIUS), 1) + + +def temperature_to_states(temperature, unit): + """Convert temperature back from Celsius to Home Assistant unit.""" + return round(temp_util.convert(temperature, TEMP_CELSIUS, unit) * 2) / 2 + + +def density_to_air_quality(density): + """Map PM2.5 density to HomeKit AirQuality level.""" + if density <= 35: + return 1 + if density <= 75: + return 2 + if density <= 115: + return 3 + if density <= 150: + return 4 + return 5 diff --git a/homeassistant/components/homekit_controller/.translations/bg.json b/homeassistant/components/homekit_controller/.translations/bg.json new file mode 100644 index 0000000000000..b1909ca2ec00c --- /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 0000000000000..f2ed4bd0c2159 --- /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 0000000000000..e70ed3973d207 --- /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 0000000000000..59e402080f3f4 --- /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 0000000000000..20b209752eb89 --- /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": "Enhedskonfiguration 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 af denne model er blokeret, da en mere funktionskomplet indbygget integration er tilg\u00e6ngelig.", + "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 parre da den allerede er parret med en anden controller.", + "max_peers_error": "Enheden n\u00e6gtede at parre da den ikke har nok frit parringslagerplads.", + "max_tries_error": "Enheden n\u00e6gtede at parre 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 0000000000000..22420b79661e5 --- /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 0000000000000..31731a52203a2 --- /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 0000000000000..67a65f752b48f --- /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 0000000000000..67f6daa8469d8 --- /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 0000000000000..7f0566ddd4236 --- /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 0000000000000..60bd173dc8ecc --- /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 0000000000000..7ed026a529c2f --- /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 0000000000000..8837e501a8aae --- /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 0000000000000..ca7bce4450824 --- /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 0000000000000..30494295f0ea0 --- /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 0000000000000..995d67792389d --- /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 0000000000000..db8b8b035e000 --- /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 0000000000000..e66353c5000d7 --- /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 0000000000000..479f6c6a97cf6 --- /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 0000000000000..c60ed155569f1 --- /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 0000000000000..44a57a1eb258e --- /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 0000000000000..2af8a2a7ab5c5 --- /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 0000000000000..b4b721b7ff91f --- /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 0000000000000..c0311b0f19894 --- /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 0000000000000..cc16ebc70c455 --- /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 0000000000000..d9fdc8f91c2e1 --- /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 0000000000000..68e87e9aea8be --- /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 new file mode 100644 index 0000000000000..dc65796a569cb --- /dev/null +++ b/homeassistant/components/homekit_controller/__init__.py @@ -0,0 +1,246 @@ +"""Support for Homekit device discovery.""" +import logging + +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 + +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 escape_characteristic_name(char_name): + """Escape any dash or dots in a characteristics name.""" + return char_name.replace("-", "_").replace(".", "_") + + +class HomeKitEntity(Entity): + """Representation of a Home Assistant HomeKit device.""" + + def __init__(self, accessory, devinfo): + """Initialise a generic HomeKit device.""" + self._accessory = accessory + self._aid = devinfo["aid"] + self._iid = devinfo["iid"] + self._features = 0 + self._chars = {} + self.setup() + + 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 + self._accessory_info = get_accessory_information(accessory) + for service in accessory["services"]: + if service["iid"] != self._iid: + continue + 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) + + def get_hk_char_value(self, characteristic_type): + """Return the value for a given characteristic type enum.""" + state = self._accessory.current_state.get(self._aid) + if not state: + return None + char = self._chars.get(CharacteristicsTypes.get_short(characteristic_type)) + if not char: + return None + return state.get(char, {}).get("value") + + @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.""" + 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._accessory_info.get("name") + + @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 + + +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 + + # For backwards compat + if entry.unique_id is None: + hass.config_entries.async_update_entry( + entry, unique_id=normalize_hkid(conn.unique_id) + ) + + if not await conn.async_setup(): + del hass.data[KNOWN_DEVICES][conn.unique_id] + raise ConfigEntryNotReady + + conn_info = conn.connection_info + + 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"), + ) + + 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/air_quality.py b/homeassistant/components/homekit_controller/air_quality.py new file mode 100644 index 0000000000000..854c12e6f88b5 --- /dev/null +++ b/homeassistant/components/homekit_controller/air_quality.py @@ -0,0 +1,98 @@ +"""Support for HomeKit Controller air quality sensors.""" +from homekit.model.characteristics import CharacteristicsTypes + +from homeassistant.components.air_quality import AirQualityEntity + +from . import KNOWN_DEVICES, HomeKitEntity + +AIR_QUALITY_TEXT = { + 0: "unknown", + 1: "excellent", + 2: "good", + 3: "fair", + 4: "inferior", + 5: "poor", +} + + +class HomeAirQualitySensor(HomeKitEntity, AirQualityEntity): + """Representation of a HomeKit Controller Air Quality sensor.""" + + def get_characteristic_types(self): + """Define the homekit characteristics the entity cares about.""" + return [ + CharacteristicsTypes.AIR_QUALITY, + CharacteristicsTypes.DENSITY_PM25, + CharacteristicsTypes.DENSITY_PM10, + CharacteristicsTypes.DENSITY_OZONE, + CharacteristicsTypes.DENSITY_NO2, + CharacteristicsTypes.DENSITY_SO2, + CharacteristicsTypes.DENSITY_VOC, + ] + + @property + def particulate_matter_2_5(self): + """Return the particulate matter 2.5 level.""" + return self.get_hk_char_value(CharacteristicsTypes.DENSITY_PM25) + + @property + def particulate_matter_10(self): + """Return the particulate matter 10 level.""" + return self.get_hk_char_value(CharacteristicsTypes.DENSITY_PM10) + + @property + def ozone(self): + """Return the O3 (ozone) level.""" + return self.get_hk_char_value(CharacteristicsTypes.DENSITY_OZONE) + + @property + def sulphur_dioxide(self): + """Return the SO2 (sulphur dioxide) level.""" + return self.get_hk_char_value(CharacteristicsTypes.DENSITY_SO2) + + @property + def nitrogen_dioxide(self): + """Return the NO2 (nitrogen dioxide) level.""" + return self.get_hk_char_value(CharacteristicsTypes.DENSITY_NO2) + + @property + def air_quality_text(self): + """Return the Air Quality Index (AQI).""" + air_quality = self.get_hk_char_value(CharacteristicsTypes.AIR_QUALITY) + return AIR_QUALITY_TEXT.get(air_quality, "unknown") + + @property + def volatile_organic_compounds(self): + """Return the volatile organic compounds (VOC) level.""" + return self.get_hk_char_value(CharacteristicsTypes.DENSITY_VOC) + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + data = {"air_quality_text": self.air_quality_text} + + voc = self.volatile_organic_compounds + if voc: + data["volatile_organic_compounds"] = voc + + return data + + +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 air quality sensor.""" + hkid = config_entry.data["AccessoryPairingID"] + conn = hass.data[KNOWN_DEVICES][hkid] + + def async_add_service(aid, service): + if service["stype"] != "air-quality": + return False + info = {"aid": aid, "iid": service["iid"]} + async_add_entities([HomeAirQualitySensor(conn, info)], True) + return True + + conn.add_listener(async_add_service) 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 0000000000000..8cdbe9b2f369d --- /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 0000000000000..2998ce18641f3 --- /dev/null +++ b/homeassistant/components/homekit_controller/binary_sensor.py @@ -0,0 +1,114 @@ +"""Support for Homekit motion sensors.""" +import logging + +from homekit.model.characteristics import CharacteristicsTypes + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_SMOKE, + 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 + + +class HomeKitSmokeSensor(HomeKitEntity, BinarySensorDevice): + """Representation of a Homekit smoke sensor.""" + + def __init__(self, *args): + """Initialise the entity.""" + super().__init__(*args) + self._state = None + + @property + def device_class(self) -> str: + """Return the class of this sensor.""" + return DEVICE_CLASS_SMOKE + + def get_characteristic_types(self): + """Define the homekit characteristics the entity is tracking.""" + return [CharacteristicsTypes.SMOKE_DETECTED] + + def _update_smoke_detected(self, value): + self._state = value + + @property + def is_on(self): + """Return true if smoke is currently detected.""" + return self._state == 1 + + +ENTITY_TYPES = { + "motion": HomeKitMotionSensor, + "contact": HomeKitContactSensor, + "smoke": HomeKitSmokeSensor, +} + + +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 0000000000000..d0ab7bd2e990d --- /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 0000000000000..507a5cbb70a92 --- /dev/null +++ b/homeassistant/components/homekit_controller/config_flow.py @@ -0,0 +1,385 @@ +"""Config flow to configure homekit_controller.""" +import json +import logging +import os +import re + +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" + +PIN_FORMAT = re.compile(r"^(\d{3})-{0,1}(\d{2})-{0,1}(\d{3})$") + +_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 + + +def ensure_pin_format(pin): + """ + Ensure a pin code is correctly formatted. + + Ensures a pin code is in the format 111-11-111. Handles codes with and without dashes. + + If incorrect code is entered, an exception is raised. + """ + match = PIN_FORMAT.search(pin) + if not match: + raise homekit.exceptions.MalformedPinError(f"Invalid PIN code f{pin}") + return "{}-{}-{}".format(*match.groups()) + + +@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: + code = ensure_pin_format(code) + + 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.exceptions.MalformedPinError: + # Library claimed pin was invalid before even making an API call + errors["pairing_code"] = "authentication_error" + 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 0000000000000..f3e728c6cdc49 --- /dev/null +++ b/homeassistant/components/homekit_controller/connection.py @@ -0,0 +1,359 @@ +"""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 Home Assistant.""" + 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"] + + self._polling_interval_remover = async_track_time_interval( + self.hass, self.async_update, DEFAULT_SCAN_INTERVAL + ) + + self.hass.async_create_task(self.async_process_entity_map()) + + return True + + async def async_process_entity_map(self): + """ + Process the entity map and load any platforms or entities that need adding. + + This is idempotent and will be called at startup and when we detect metadata changes + via the c# counter on the zeroconf record. + """ + # Ensure the Pairing object has access to the latest version of the entity map. This + # is especially important for BLE, as 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 + + await self.async_load_platforms() + + self.add_entities() + + await self.async_update() + + 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 False + + self.hass.data[ENTITY_MAP].async_create_or_update_map( + self.unique_id, config_num, self.accessories + ) + + self.config_num = config_num + self.hass.async_create_task(self.async_process_entity_map()) + + 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 + + async 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.platforms.add(platform) + try: + await self.hass.config_entries.async_forward_entry_setup( + self.config_entry, platform + ) + except Exception: + self.platforms.remove(platform) + raise + + 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 0000000000000..684f83ba5d4f1 --- /dev/null +++ b/homeassistant/components/homekit_controller/const.py @@ -0,0 +1,33 @@ +"""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", + "smoke": "binary_sensor", + "fan": "fan", + "fanv2": "fan", + "air-quality": "air_quality", +} diff --git a/homeassistant/components/homekit_controller/cover.py b/homeassistant/components/homekit_controller/cover.py new file mode 100644 index 0000000000000..7e5591d9505ea --- /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/fan.py b/homeassistant/components/homekit_controller/fan.py new file mode 100644 index 0000000000000..efb41808429d8 --- /dev/null +++ b/homeassistant/components/homekit_controller/fan.py @@ -0,0 +1,251 @@ +"""Support for Homekit fans.""" +import logging + +from homekit.model.characteristics import CharacteristicsTypes + +from homeassistant.components.fan import ( + DIRECTION_FORWARD, + DIRECTION_REVERSE, + SPEED_HIGH, + SPEED_LOW, + SPEED_MEDIUM, + SPEED_OFF, + SUPPORT_DIRECTION, + SUPPORT_OSCILLATE, + SUPPORT_SET_SPEED, + FanEntity, +) + +from . import KNOWN_DEVICES, HomeKitEntity + +_LOGGER = logging.getLogger(__name__) + +# 0 is clockwise, 1 is counter-clockwise. The match to forward and reverse is so that +# its consistent with homeassistant.components.homekit. +DIRECTION_TO_HK = { + DIRECTION_REVERSE: 1, + DIRECTION_FORWARD: 0, +} +HK_DIRECTION_TO_HA = {v: k for (k, v) in DIRECTION_TO_HK.items()} + +SPEED_TO_PCNT = { + SPEED_HIGH: 100, + SPEED_MEDIUM: 50, + SPEED_LOW: 25, + SPEED_OFF: 0, +} + + +class BaseHomeKitFan(HomeKitEntity, FanEntity): + """Representation of a Homekit fan.""" + + # This must be set in subclasses to the name of a boolean characteristic + # that controls whether the fan is on or off. + on_characteristic = None + + def __init__(self, *args): + """Initialise the fan.""" + self._on = None + self._features = 0 + self._rotation_direction = 0 + self._rotation_speed = 0 + self._swing_mode = 0 + + super().__init__(*args) + + def get_characteristic_types(self): + """Define the homekit characteristics the entity cares about.""" + return [ + CharacteristicsTypes.SWING_MODE, + CharacteristicsTypes.ROTATION_DIRECTION, + CharacteristicsTypes.ROTATION_SPEED, + ] + + def _setup_rotation_direction(self, char): + self._features |= SUPPORT_DIRECTION + + def _setup_rotation_speed(self, char): + self._features |= SUPPORT_SET_SPEED + + def _setup_swing_mode(self, char): + self._features |= SUPPORT_OSCILLATE + + def _update_rotation_direction(self, value): + self._rotation_direction = value + + def _update_rotation_speed(self, value): + self._rotation_speed = value + + def _update_swing_mode(self, value): + self._swing_mode = value + + @property + def is_on(self): + """Return true if device is on.""" + return self._on + + @property + def speed(self): + """Return the current speed.""" + if not self.is_on: + return SPEED_OFF + if self._rotation_speed > SPEED_TO_PCNT[SPEED_MEDIUM]: + return SPEED_HIGH + if self._rotation_speed > SPEED_TO_PCNT[SPEED_LOW]: + return SPEED_MEDIUM + if self._rotation_speed > SPEED_TO_PCNT[SPEED_OFF]: + return SPEED_LOW + return SPEED_OFF + + @property + def speed_list(self): + """Get the list of available speeds.""" + if self.supported_features & SUPPORT_SET_SPEED: + return [SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] + return [] + + @property + def current_direction(self): + """Return the current direction of the fan.""" + return HK_DIRECTION_TO_HA[self._rotation_direction] + + @property + def oscillating(self): + """Return whether or not the fan is currently oscillating.""" + return self._swing_mode == 1 + + @property + def supported_features(self): + """Flag supported features.""" + return self._features + + async def async_set_direction(self, direction): + """Set the direction of the fan.""" + await self._accessory.put_characteristics( + [ + { + "aid": self._aid, + "iid": self._chars["rotation.direction"], + "value": DIRECTION_TO_HK[direction], + } + ] + ) + + async def async_set_speed(self, speed): + """Set the speed of the fan.""" + if speed == SPEED_OFF: + return await self.async_turn_off() + + await self._accessory.put_characteristics( + [ + { + "aid": self._aid, + "iid": self._chars["rotation.speed"], + "value": SPEED_TO_PCNT[speed], + } + ] + ) + + async def async_oscillate(self, oscillating: bool): + """Oscillate the fan.""" + await self._accessory.put_characteristics( + [ + { + "aid": self._aid, + "iid": self._chars["swing-mode"], + "value": 1 if oscillating else 0, + } + ] + ) + + async def async_turn_on(self, speed=None, **kwargs): + """Turn the specified fan on.""" + + characteristics = [] + + if not self.is_on: + characteristics.append( + { + "aid": self._aid, + "iid": self._chars[self.on_characteristic], + "value": True, + } + ) + + if self.supported_features & SUPPORT_SET_SPEED and speed: + characteristics.append( + { + "aid": self._aid, + "iid": self._chars["rotation.speed"], + "value": SPEED_TO_PCNT[speed], + }, + ) + + if not characteristics: + return + + await self._accessory.put_characteristics(characteristics) + + async def async_turn_off(self, **kwargs): + """Turn the specified fan off.""" + characteristics = [ + { + "aid": self._aid, + "iid": self._chars[self.on_characteristic], + "value": False, + } + ] + await self._accessory.put_characteristics(characteristics) + + +class HomeKitFanV1(BaseHomeKitFan): + """Implement fan support for public.hap.service.fan.""" + + on_characteristic = "on" + + def get_characteristic_types(self): + """Define the homekit characteristics the entity cares about.""" + return [CharacteristicsTypes.ON] + super().get_characteristic_types() + + def _update_on(self, value): + self._on = value == 1 + + +class HomeKitFanV2(BaseHomeKitFan): + """Implement fan support for public.hap.service.fanv2.""" + + on_characteristic = "active" + + def get_characteristic_types(self): + """Define the homekit characteristics the entity cares about.""" + return [CharacteristicsTypes.ACTIVE] + super().get_characteristic_types() + + def _update_active(self, value): + self._on = value == 1 + + +ENTITY_TYPES = { + "fan": HomeKitFanV1, + "fanv2": HomeKitFanV2, +} + + +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 fans.""" + 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/light.py b/homeassistant/components/homekit_controller/light.py new file mode 100644 index 0000000000000..fe2a0e9bc97ad --- /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 0000000000000..53f7bb5dfd5bf --- /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 0000000000000..c7eb02a479c11 --- /dev/null +++ b/homeassistant/components/homekit_controller/manifest.json @@ -0,0 +1,10 @@ +{ + "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 0000000000000..f91dae26ba04f --- /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 0000000000000..ffc2da5fbf213 --- /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 0000000000000..b51dcb1f6d854 --- /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 0000000000000..7eedda1b19179 --- /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 new file mode 100644 index 0000000000000..cfaffad6ce3e9 --- /dev/null +++ b/homeassistant/components/homematic/__init__.py @@ -0,0 +1,1051 @@ +"""Support for HomeMatic devices.""" +from datetime import datetime, timedelta +from functools import partial +import logging + +from pyhomematic import HMConnection +import voluptuous as vol + +from homeassistant.const import ( + 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 + +_LOGGER = logging.getLogger(__name__) + +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_BATTERY = "homematic.battery" + +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" + +HM_DEVICE_TYPES = { + DISCOVER_SWITCHES: [ + "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", + "IPBrightnessSensor", + "IPGarage", + "UniversalSensor", + "MotionIPV2", + "IPMultiIO", + "IPThermostatWall2", + ], + DISCOVER_CLIMATE: [ + "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", + "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_EXCEPTIONS = { + "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": ["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", +] + +HM_IMPULSE_EVENTS = ["SEQUENCE_OK"] + +CONF_RESOLVENAMES_OPTIONS = ["metadata", "json", "xml", False] + +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" + +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_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=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_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_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_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_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_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, + } +) + + +def setup(hass, config): + """Set up the Homematic component.""" + + conf = config[DOMAIN] + hass.data[DATA_CONF] = remotes = {} + hass.data[DATA_STORE] = set() + + # Create hosts-dictionary for pyhomematic + for rname, rconfig in conf[CONF_INTERFACES].items(): + remotes[rname] = { + "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": sconfig.get(CONF_HOST), + "port": sconfig[CONF_PORT], + "username": sconfig.get(CONF_USERNAME), + "password": sconfig.get(CONF_PASSWORD), + "connect": False, + } + + # Create server thread + bound_system_callback = partial(_system_callback_handler, hass, config) + hass.data[DATA_HOMEMATIC] = homematic = HMConnection( + local=config[DOMAIN].get(CONF_LOCAL_IP), + localport=config[DOMAIN].get(CONF_LOCAL_PORT, DEFAULT_LOCAL_PORT), + remotes=remotes, + systemcallback=bound_system_callback, + interface_id="homeassistant", + ) + + # Start server thread, connect to hosts, initialize to receive events + homematic.start() + + # Stops server when Home Assistant is shutting down + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, hass.data[DATA_HOMEMATIC].stop) + + # Init homematic hubs + entity_hubs = [] + for hub_name in conf[CONF_HOSTS].keys(): + entity_hubs.append(HMHub(hass, homematic, hub_name)) + + def _hm_service_virtualkey(service): + """Service to handle virtualkey servicecalls.""" + address = service.data.get(ATTR_ADDRESS) + channel = service.data.get(ATTR_CHANNEL) + param = service.data.get(ATTR_PARAM) + + # Device not found + hmdevice = _device_from_servicecall(hass, service) + if hmdevice is None: + _LOGGER.error("%s not found for service virtualkey!", address) + return + + # Parameter doesn't exist for device + if param not in hmdevice.ACTIONNODE: + _LOGGER.error("%s not datapoint in hm device %s", param, address) + return + + # 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) + return + + # Call parameter + hmdevice.actionNodeData(param, True, channel) + + hass.services.register( + DOMAIN, + SERVICE_VIRTUALKEY, + _hm_service_virtualkey, + schema=SCHEMA_SERVICE_VIRTUALKEY, + ) + + def _service_handle_value(service): + """Service to call setValue method for HomeMatic system variable.""" + entity_ids = service.data.get(ATTR_ENTITY_ID) + name = service.data[ATTR_NAME] + value = service.data[ATTR_VALUE] + + if entity_ids: + entities = [ + entity for entity in entity_hubs if entity.entity_id in entity_ids + ] + else: + entities = entity_hubs + + if not entities: + _LOGGER.error("No HomeMatic hubs available") + return + + for hub in entities: + hub.hm_set_variable(name, value) + + hass.services.register( + 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, + ) + + def _service_handle_device(service): + """Service to call setValue method for HomeMatic devices.""" + address = service.data.get(ATTR_ADDRESS) + 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) + if hmdevice is None: + _LOGGER.error("%s not found!", address) + return + + hmdevice.setValue(param, value, channel) + + hass.services.register( + DOMAIN, + SERVICE_SET_DEVICE_VALUE, + _service_handle_device, + schema=SCHEMA_SERVICE_SET_DEVICE_VALUE, + ) + + def _service_handle_install_mode(service): + """Service to set interface into install mode.""" + interface = service.data.get(ATTR_INTERFACE) + mode = service.data.get(ATTR_MODE) + time = service.data.get(ATTR_TIME) + address = service.data.get(ATTR_ADDRESS) + + homematic.setInstallMode(interface, t=time, mode=mode, address=address) + + hass.services.register( + DOMAIN, + SERVICE_SET_INSTALL_MODE, + _service_handle_install_mode, + schema=SCHEMA_SERVICE_SET_INSTALL_MODE, + ) + + def _service_put_paramset(service): + """Service to call the putParamset method on a HomeMatic connection.""" + interface = service.data.get(ATTR_INTERFACE) + address = service.data.get(ATTR_ADDRESS) + paramset_key = service.data.get(ATTR_PARAMSET_KEY) + # When passing in the paramset from a YAML file we get an OrderedDict + # here instead of a dict, so add this explicit cast. + # The service schema makes sure that this cast works. + paramset = dict(service.data.get(ATTR_PARAMSET)) + + _LOGGER.debug( + "Calling putParamset: %s, %s, %s, %s", + 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, + ) + + return True + + +def _system_callback_handler(hass, config, src, *args): + """System callback handler.""" + # New devices available at hub + if src == "newDevices": + (interface_id, dev_descriptions) = args + interface = interface_id.split("-")[-1] + + # Device support active? + if not hass.data[DATA_CONF][interface]["connect"]: + return + + addresses = [] + for dev in dev_descriptions: + address = dev["ADDRESS"].split(":")[0] + if address not in hass.data[DATA_STORE]: + hass.data[DATA_STORE].add(address) + addresses.append(address) + + # Register EVENTS + # Search all devices with an EVENTNODE that includes data + bound_event_callback = partial(_hm_event_handler, hass, interface) + for dev in addresses: + hmdevice = hass.data[DATA_HOMEMATIC].devices[interface].get(dev) + + if hmdevice.EVENTNODE: + hmdevice.setEventCallback(callback=bound_event_callback, bequeath=True) + + # Create Home Assistant 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), + ("binary_sensor", DISCOVER_BATTERY), + ): + # Get all devices of a specific type + found_devices = _get_devices(hass, discovery_type, addresses, interface) + + # When devices of this type are found + # they are setup in Home Assistant and a discovery event is fired + if found_devices: + discovery.load_platform( + hass, + component_name, + DOMAIN, + { + ATTR_DISCOVER_DEVICES: found_devices, + ATTR_DISCOVERY_TYPE: discovery_type, + }, + config, + ) + + # Homegear error message + elif src == "error": + _LOGGER.error("Error: %s", args) + (interface_id, errorcode, message) = args + hass.bus.fire(EVENT_ERROR, {ATTR_ERRORCODE: errorcode, ATTR_MESSAGE: message}) + + +def _get_devices(hass, discovery_type, keys, interface): + """Get the HomeMatic devices for given discovery_type.""" + device_arr = [] + + for key in keys: + device = hass.data[DATA_HOMEMATIC].devices[interface][key] + class_name = device.__class__.__name__ + metadata = {} + + # Class not supported by 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 + if discovery_type == DISCOVER_SENSORS: + 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, []) + ): + 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 + ) + for channel in channels: + name = _create_ha_id( + name=device.NAME, channel=channel, param=param, count=len(channels) + ) + unique_id = _create_ha_id( + name=key, channel=channel, param=param, count=len(channels) + ) + device_dict = { + CONF_PLATFORM: "homematic", + ATTR_ADDRESS: key, + ATTR_INTERFACE: interface, + ATTR_NAME: name, + ATTR_CHANNEL: channel, + ATTR_UNIQUE_ID: unique_id, + } + if param is not None: + device_dict[ATTR_PARAM] = param + + # Add new device + try: + DEVICE_SCHEMA(device_dict) + device_arr.append(device_dict) + except vol.MultipleInvalid as err: + _LOGGER.error("Invalid device config: %s", str(err)) + return device_arr + + +def _create_ha_id(name, channel, param, count): + """Generate a unique entity id.""" + # HMDevice is a simple device + if count == 1 and param is None: + return name + + # Has multiple elements/channels + if count > 1 and param is None: + return f"{name} {channel}" + + # With multiple parameters on first channel + if count == 1 and param is not None: + return f"{name} {param}" + + # Multiple parameters with multiple channels + if count > 1 and param is not None: + return f"{name} {channel} {param}" + + +def _hm_event_handler(hass, interface, device, caller, attribute, value): + """Handle all pyhomematic device events.""" + try: + channel = int(device.split(":")[1]) + address = device.split(":")[0] + hmdevice = hass.data[DATA_HOMEMATIC].devices[interface].get(address) + except (TypeError, ValueError): + _LOGGER.error("Event handling channel convert error!") + return + + # Return if not an event supported by device + if attribute not in hmdevice.EVENTNODE: + return + + _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}, + ) + return + + # Impulse event + if attribute in HM_IMPULSE_EVENTS: + hass.bus.fire(EVENT_IMPULSE, {ATTR_NAME: hmdevice.NAME, ATTR_CHANNEL: channel}) + return + + _LOGGER.warning("Event is unknown and not forwarded") + + +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 interface: + return hass.data[DATA_HOMEMATIC].devices[interface].get(address) + + for devices in hass.data[DATA_HOMEMATIC].devices.values(): + if address in devices: + return devices[address] + + +class HMHub(Entity): + """The HomeMatic hub. (CCU2/HomeGear).""" + + def __init__(self, hass, homematic, name): + """Initialize HomeMatic hub.""" + self.hass = hass + self.entity_id = "{}.{}".format(DOMAIN, name.lower()) + self._homematic = homematic + self._variables = {} + self._name = name + self._state = None + + # Load data + 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.hass.add_job(self._update_variables, None) + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def should_poll(self): + """Return false. HomeMatic Hub object updates variables.""" + return False + + @property + def state(self): + """Return the state of the entity.""" + return self._state + + @property + def state_attributes(self): + """Return the state attributes.""" + attr = self._variables.copy() + return attr + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return "mdi:gradient" + + def _update_hub(self, now): + """Retrieve latest state.""" + service_message = self._homematic.getServiceMessages(self._name) + state = None if service_message is None else len(service_message) + + # state have change? + if self._state != state: + self._state = state + self.schedule_update_ha_state() + + def _update_variables(self, now): + """Retrieve all variable data and update hmvariable states.""" + variables = self._homematic.getAllSystemVariables(self._name) + if variables is None: + return + + state_change = False + for key, value in variables.items(): + if key in self._variables and value == self._variables[key]: + continue + + state_change = True + self._variables.update({key: value}) + + if state_change: + self.schedule_update_ha_state() + + def hm_set_variable(self, name, value): + """Set variable value on CCU/Homegear.""" + if name not in self._variables: + _LOGGER.error("Variable %s not found on %s", name, self.name) + return + old_value = self._variables.get(name) + if isinstance(old_value, bool): + value = cv.boolean(value) + else: + value = float(value) + self._homematic.setSystemVariable(self.name, name, value) + + self._variables.update({name: value}) + self.schedule_update_ha_state() + + +class HMDevice(Entity): + """The HomeMatic device base object.""" + + def __init__(self, config): + """Initialize a generic HomeMatic device.""" + self._name = config.get(ATTR_NAME) + self._address = config.get(ATTR_ADDRESS) + self._interface = config.get(ATTR_INTERFACE) + self._channel = config.get(ATTR_CHANNEL) + self._state = config.get(ATTR_PARAM) + self._unique_id = config.get(ATTR_UNIQUE_ID) + self._data = {} + self._homematic = None + self._hmdevice = None + self._connected = False + self._available = False + + # Set parameter to uppercase + if self._state: + self._state = self._state.upper() + + async def async_added_to_hass(self): + """Load data init callbacks.""" + await self.hass.async_add_job(self.link_homematic) + + @property + def unique_id(self): + """Return unique ID. HomeMatic entity IDs are unique by default.""" + return self._unique_id.replace(" ", "_") + + @property + def should_poll(self): + """Return false. HomeMatic states are pushed by the XML-RPC Server.""" + return False + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def available(self): + """Return true if device is available.""" + return self._available + + @property + def device_state_attributes(self): + """Return device specific state attributes.""" + attr = {} + + # Generate a dictionary with attributes + for node, data in HM_ATTRIBUTE_SUPPORT.items(): + # Is an attribute and exists for this object + if node in self._data: + value = data[1].get(self._data[node], self._data[node]) + attr[data[0]] = value + + # Static attributes + attr["id"] = self._hmdevice.ADDRESS + attr["interface"] = self._interface + + return attr + + def link_homematic(self): + """Connect to HomeMatic.""" + if self._connected: + return True + + # Initialize + self._homematic = self.hass.data[DATA_HOMEMATIC] + self._hmdevice = self._homematic.devices[self._interface][self._address] + self._connected = True + + try: + # Initialize datapoints of this object + self._init_data() + self._load_data_from_hm() + + # Link events from pyhomematic + self._subscribe_homematic_events() + self._available = not self._hmdevice.UNREACH + except Exception as err: # pylint: disable=broad-except + self._connected = False + _LOGGER.error("Exception while linking %s: %s", self._address, str(err)) + + def _hm_event_callback(self, device, caller, attribute, value): + """Handle all pyhomematic device events.""" + _LOGGER.debug("%s received event '%s' value: %s", self._name, attribute, value) + has_changed = False + + # Is data needed for this instance? + if attribute in self._data: + # Did data change? + if self._data[attribute] != value: + self._data[attribute] = value + has_changed = True + + # Availability has changed + if self.available != (not self._hmdevice.UNREACH): + self._available = not self._hmdevice.UNREACH + has_changed = True + + # If it has changed data point, update Home Assistant + if has_changed: + self.schedule_update_ha_state() + + def _subscribe_homematic_events(self): + """Subscribe all required events to handle job.""" + channels_to_sub = set() + + # 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 node, channels in metadata.items(): + # Data is needed for this instance + if node in self._data: + # chan is current channel + if len(channels) == 1: + channel = channels[0] + else: + channel = self._channel + + # Prepare for subscription + try: + channels_to_sub.add(int(channel)) + except (ValueError, TypeError): + _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) + self._hmdevice.setEventCallback( + callback=self._hm_event_callback, bequeath=False, channel=channel + ) + + def _load_data_from_hm(self): + """Load first value from pyhomematic.""" + if not self._connected: + return False + + # 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), + ): + for node in metadata: + if metadata[node] and node in self._data: + self._data[node] = funct(name=node, channel=self._channel) + + return True + + def _hm_set_state(self, value): + """Set data to main datapoint.""" + if self._state in self._data: + self._data[self._state] = value + + def _hm_get_state(self): + """Get data from main datapoint.""" + if self._state in self._data: + return self._data[self._state] + return None + + def _init_data(self): + """Generate a data dict (self._data) from the HomeMatic metadata.""" + # Add all attributes to data dictionary + for data_note in self._hmdevice.ATTRIBUTENODE: + self._data.update({data_note: STATE_UNKNOWN}) + + # Initialize device specific data + self._init_data_struct() + + def _init_data_struct(self): + """Generate a data dictionary from the HomeMatic device metadata.""" + raise NotImplementedError diff --git a/homeassistant/components/homematic/binary_sensor.py b/homeassistant/components/homematic/binary_sensor.py new file mode 100644 index 0000000000000..cc2907c64fb22 --- /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 0000000000000..935ebb9b49762 --- /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 0000000000000..893b3ce892177 --- /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 0000000000000..29992bccef317 --- /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 0000000000000..7f796b32885cb --- /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 0000000000000..709e99a232e4e --- /dev/null +++ b/homeassistant/components/homematic/manifest.json @@ -0,0 +1,8 @@ +{ + "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 0000000000000..9fd94b9832c86 --- /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 0000000000000..10c402a0dd439 --- /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/services.yaml b/homeassistant/components/homematic/services.yaml new file mode 100644 index 0000000000000..044bcfa46adc7 --- /dev/null +++ b/homeassistant/components/homematic/services.yaml @@ -0,0 +1,85 @@ +# Describes the format for available component services + +virtualkey: + description: Press a virtual key from CCU/Homegear or simulate keypress. + fields: + address: + description: Address of homematic device or BidCoS-RF for virtual remote. + example: BidCoS-RF + channel: + description: Channel for calling a keypress. + example: 1 + param: + description: Event to send i.e. PRESS_LONG, PRESS_SHORT. + example: PRESS_LONG + interface: + description: (Optional) for set an interface value. + example: Interfaces name from config + +set_variable_value: + description: Set the name of a node. + fields: + entity_id: + description: Name(s) of homematic central to set value. + example: 'homematic.ccu2' + name: + description: Name of the variable to set. + example: 'testvariable' + value: + description: New value + example: 1 + +set_device_value: + description: Set a device property on RPC XML interface. + fields: + address: + description: Address of homematic device or BidCoS-RF for virtual remote + example: BidCoS-RF + channel: + description: Channel for calling a keypress + example: 1 + param: + description: Event to send i.e. PRESS_LONG, PRESS_SHORT + example: PRESS_LONG + interface: + description: (Optional) for set an interface value + example: Interfaces name from config + value: + description: New value + example: 1 + +reconnect: + description: Reconnect to all Homematic Hubs. + +set_install_mode: + description: Set a RPC XML interface into installation mode. + fields: + interface: + description: Select the given interface into install mode + example: Interfaces name from config + mode: + description: (Default 1) 1= Normal mode / 2= Remove exists old links + example: 1 + time: + description: (Default 60) Time in seconds to run in install mode + example: 1 + address: + description: (Optional) Address of homematic device or BidCoS-RF to learn + example: LEQ3948571 + +put_paramset: + description: Call to putParamset in the RPC XML interface + fields: + interface: + description: The interfaces name from the config + example: wireless + address: + description: Address of Homematic device + example: LEQ3948571 + paramset_key: + description: The paramset_key argument to putParamset + example: MASTER + paramset: + description: A paramset dictionary + example: '{"WEEK_PROGRAM_POINTER": 1}' + diff --git a/homeassistant/components/homematic/switch.py b/homeassistant/components/homematic/switch.py new file mode 100644 index 0000000000000..b77b3a1f7008b --- /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 0000000000000..d2b9a1b1761ca --- /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 new file mode 100644 index 0000000000000..f7c1497098272 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/ca.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "El punt d'acc\u00e9s ja est\u00e0 configurat", + "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, 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": { + "data": { + "hapid": "Identificador del punt d'acc\u00e9s (SGTIN)", + "name": "Nom (opcional, s'utilitza com a nom prefix per a tots els dispositius)", + "pin": "Codi PIN (opcional)" + }, + "title": "Tria el punt d'acc\u00e9s HomematicIP" + }, + "link": { + "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" + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/cs.json b/homeassistant/components/homematicip_cloud/.translations/cs.json new file mode 100644 index 0000000000000..fa98029f6b0c8 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/cs.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "P\u0159\u00edstupov\u00fd bod je ji\u017e nakonfigurov\u00e1n", + "connection_aborted": "Nelze se p\u0159ipojit k HMIP serveru", + "unknown": "Do\u0161lo k nezn\u00e1m\u00e9 chyb\u011b" + }, + "error": { + "invalid_pin": "Neplatn\u00fd k\u00f3d PIN, zkuste to znovu.", + "press_the_button": "Stiskn\u011bte modr\u00e9 tla\u010d\u00edtko.", + "register_failed": "Registrace se nezda\u0159ila, zkuste to znovu.", + "timeout_button": "\u010casov\u00fd limit stisknut\u00ed modr\u00e9ho tla\u010d\u00edtka vypr\u0161el. Zkuste to znovu." + }, + "step": { + "init": { + "data": { + "hapid": "ID p\u0159\u00edstupov\u00e9ho bodu (SGTIN)", + "name": "N\u00e1zev (nepovinn\u00e9, pou\u017e\u00edv\u00e1 se jako p\u0159edpona n\u00e1zvu pro v\u0161echna za\u0159\u00edzen\u00ed)", + "pin": "Pin k\u00f3d (nepovinn\u00e9)" + }, + "title": "Vyberte p\u0159\u00edstupov\u00fd bod HomematicIP" + }, + "link": { + "description": "Stiskn\u011bte modr\u00e9 tla\u010d\u00edtko na p\u0159\u00edstupov\u00e9m bodu a tla\u010d\u00edtko pro registraci HomematicIP s dom\u00e1c\u00edm asistentem. \n\n ! [Um\u00edst\u011bn\u00ed tla\u010d\u00edtka na za\u0159\u00edzen\u00ed] (/static/images/config_flows/config_homematicip_cloud.png)", + "title": "P\u0159ipojit se k p\u0159\u00edstupov\u00e9mu bodu" + } + }, + "title": "HomematicIP Cloud" + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/da.json b/homeassistant/components/homematicip_cloud/.translations/da.json new file mode 100644 index 0000000000000..4b8371fc748ac --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/da.json @@ -0,0 +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.", + "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 new file mode 100644 index 0000000000000..c2a7579e4fcf7 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/de.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Der Accesspoint ist bereits konfiguriert", + "connection_aborted": "Konnte nicht mit HMIP Server verbinden", + "unknown": "Ein unbekannter Fehler ist aufgetreten." + }, + "error": { + "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": { + "data": { + "hapid": "Accesspoint ID (SGTIN)", + "name": "Name (optional, wird als Pr\u00e4fix f\u00fcr alle Ger\u00e4te verwendet)", + "pin": "PIN Code (optional)" + }, + "title": "HomematicIP Accesspoint ausw\u00e4hlen" + }, + "link": { + "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" + } + }, + "title": "HomematicIP Cloud" + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/en.json b/homeassistant/components/homematicip_cloud/.translations/en.json new file mode 100644 index 0000000000000..605bb0d250bba --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/en.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Access point is already configured", + "connection_aborted": "Could not connect to HMIP server", + "unknown": "Unknown error occurred." + }, + "error": { + "invalid_pin": "Invalid PIN, please try again.", + "press_the_button": "Please press the blue button.", + "register_failed": "Failed to register, please try again.", + "timeout_button": "Blue button press timeout, please try again." + }, + "step": { + "init": { + "data": { + "hapid": "Access point ID (SGTIN)", + "name": "Name (optional, used as name prefix for all devices)", + "pin": "Pin Code (optional)" + }, + "title": "Pick HomematicIP Access point" + }, + "link": { + "description": "Press the blue button on the access point and the submit button to register HomematicIP with Home Assistant.\n\n![Location of button on bridge](/static/images/config_flows/config_homematicip_cloud.png)", + "title": "Link Access point" + } + }, + "title": "HomematicIP Cloud" + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/es-419.json b/homeassistant/components/homematicip_cloud/.translations/es-419.json new file mode 100644 index 0000000000000..5102b25aaee92 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/es-419.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Accesspoint ya est\u00e1 configurado", + "connection_aborted": "No se pudo conectar al servidor HMIP", + "unknown": "Se produjo un error desconocido." + }, + "error": { + "invalid_pin": "PIN no v\u00e1lido, por favor intente de nuevo.", + "press_the_button": "Por favor, presione el bot\u00f3n azul.", + "register_failed": "No se pudo registrar, por favor intente de nuevo." + }, + "step": { + "init": { + "data": { + "hapid": "ID de punto de acceso (SGTIN)", + "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" + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/es.json b/homeassistant/components/homematicip_cloud/.translations/es.json new file mode 100644 index 0000000000000..206bd05a34596 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/es.json @@ -0,0 +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", + "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 0000000000000..7aedd80b5d0b7 --- /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 new file mode 100644 index 0000000000000..0e724d62bbe2a --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/fr.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Le point d'acc\u00e8s est d\u00e9j\u00e0 configur\u00e9", + "connection_aborted": "Impossible de se connecter au serveur HMIP", + "unknown": "Une erreur inconnue s'est produite." + }, + "error": { + "invalid_pin": "Code PIN invalide, veuillez r\u00e9essayer.", + "press_the_button": "Veuillez appuyer sur le bouton bleu.", + "register_failed": "\u00c9chec d'enregistrement. Veuillez r\u00e9essayer.", + "timeout_button": "D\u00e9lai d'attente expir\u00e9, veuillez r\u00e9\u00e9ssayer." + }, + "step": { + "init": { + "data": { + "hapid": "ID du point d'acc\u00e8s (SGTIN)", + "name": "Nom (facultatif, utilis\u00e9 comme pr\u00e9fixe de nom pour tous les p\u00e9riph\u00e9riques)", + "pin": "Code PIN (facultatif)" + }, + "title": "Choisissez le point d'acc\u00e8s HomematicIP" + }, + "link": { + "description": "Appuyez sur le bouton bleu du point d'acc\u00e8s et sur le bouton Envoyer pour enregistrer HomematicIP avec Home Assistant. \n\n ![Emplacement du bouton sur le pont](/static/images/config_flows/config_homematicip_cloud.png)", + "title": "Lier le point d'acc\u00e8s" + } + }, + "title": "HomematicIP Cloud" + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/he.json b/homeassistant/components/homematicip_cloud/.translations/he.json new file mode 100644 index 0000000000000..c60294e21d5b0 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/he.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "\u05e0\u05e7\u05d5\u05d3\u05ea \u05d4\u05d2\u05d9\u05e9\u05d4 \u05db\u05d1\u05e8 \u05de\u05d5\u05d2\u05d3\u05e8\u05ea", + "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": { + "invalid_pin": "PIN \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9, \u05e0\u05e1\u05d4 \u05e9\u05d5\u05d1.", + "press_the_button": "\u05dc\u05d7\u05e5 \u05e2\u05dc \u05d4\u05db\u05e4\u05ea\u05d5\u05e8 \u05d4\u05db\u05d7\u05d5\u05dc.", + "register_failed": "\u05d4\u05e8\u05d9\u05e9\u05d5\u05dd \u05e0\u05db\u05e9\u05dc, \u05e0\u05e1\u05d4 \u05e9\u05d5\u05d1.", + "timeout_button": "\u05e2\u05d1\u05e8 \u05d4\u05d6\u05de\u05df \u05d4\u05e7\u05e6\u05d5\u05d1 \u05dc\u05dc\u05d7\u05d9\u05e6\u05d4 \u05e2\u05dc \u05d4\u05db\u05e4\u05ea\u05d5\u05e8 \u05d4\u05db\u05d7\u05d5\u05dc, \u05e0\u05e1\u05d4 \u05e9\u05d5\u05d1" + }, + "step": { + "init": { + "data": { + "hapid": "\u05de\u05d6\u05d4\u05d4 \u05e0\u05e7\u05d5\u05d3\u05ea \u05d2\u05d9\u05e9\u05d4 (SGTIN)", + "name": "\u05e9\u05dd (\u05d0\u05d5\u05e4\u05e6\u05d9\u05d5\u05e0\u05dc\u05d9, \u05de\u05e9\u05de\u05e9 \u05db\u05e7\u05d9\u05d3\u05d5\u05de\u05ea \u05e2\u05d1\u05d5\u05e8 \u05db\u05dc \u05d4\u05de\u05db\u05e9\u05d9\u05e8\u05d9\u05dd)", + "pin": "\u05e7\u05d5\u05d3 PIN (\u05d0\u05d5\u05e4\u05e6\u05d9\u05d5\u05e0\u05dc\u05d9)" + }, + "title": "\u05d1\u05d7\u05e8 \u05e0\u05e7\u05d5\u05d3\u05ea \u05d2\u05d9\u05e9\u05d4 HomematicIP" + }, + "link": { + "description": "\u05dc\u05d7\u05e5 \u05e2\u05dc \u05d4\u05db\u05e4\u05ea\u05d5\u05e8 \u05d4\u05db\u05d7\u05d5\u05dc \u05d1\u05e0\u05e7\u05d5\u05d3\u05ea \u05d2\u05d9\u05e9\u05d4 \u05d5\u05e2\u05dc \u05db\u05e4\u05ea\u05d5\u05e8 \u05d4\u05e9\u05dc\u05d9\u05d7\u05d4 \u05db\u05d3\u05d9 \u05dc\u05d7\u05d1\u05e8 \u05d0\u05ea HomematicIP \u05e2\u05ddHome Assistant.\n\n![\u05de\u05d9\u05e7\u05d5\u05dd \u05d4\u05db\u05e4\u05ea\u05d5\u05e8 \u05d1\u05de\u05d2\u05e9\u05e8](/static/images/config_flows/config_homematicip_cloud.png)", + "title": "\u05d7\u05d1\u05e8 \u05e0\u05e7\u05d5\u05d3\u05ea \u05d2\u05d9\u05e9\u05d4" + } + }, + "title": "\u05e2\u05e0\u05df HomematicIP" + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/hr.json b/homeassistant/components/homematicip_cloud/.translations/hr.json new file mode 100644 index 0000000000000..648dbfe73f98e --- /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 new file mode 100644 index 0000000000000..61ff5ac5fe255 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/hu.json @@ -0,0 +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 0000000000000..0487434274c58 --- /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 new file mode 100644 index 0000000000000..c7f1af21f2270 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/it.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Il punto di accesso \u00e8 gi\u00e0 configurato", + "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.", + "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 new file mode 100644 index 0000000000000..6a03f3ec76b74 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/ja.json @@ -0,0 +1,12 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30af\u30bb\u30b9\u30dd\u30a4\u30f3\u30c8\u306f\u65e2\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "unknown": "\u4e0d\u660e\u306a\u30a8\u30e9\u30fc\u304c\u767a\u751f\u3057\u307e\u3057\u305f\u3002" + }, + "error": { + "invalid_pin": "PIN\u304c\u7121\u52b9\u3067\u3059\u3001\u3082\u3046\u4e00\u5ea6\u304a\u8a66\u3057\u304f\u3060\u3055\u3044\u3002", + "press_the_button": "\u9752\u3044\u30dc\u30bf\u30f3\u3092\u62bc\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/ko.json b/homeassistant/components/homematicip_cloud/.translations/ko.json new file mode 100644 index 0000000000000..2f47fcddf28ff --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/ko.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "\uc561\uc138\uc2a4 \ud3ec\uc778\ud2b8\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\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" + }, + "error": { + "invalid_pin": "PIN\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "press_the_button": "\ud30c\ub780\uc0c9 \ubc84\ud2bc\uc744 \ub20c\ub7ec\uc8fc\uc138\uc694.", + "register_failed": "\ub4f1\ub85d\uc5d0 \uc2e4\ud328\ud558\uc600\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "timeout_button": "\uc815\ud574\uc9c4 \uc2dc\uac04\ub0b4\uc5d0 \ud30c\ub780\uc0c9 \ubc84\ud2bc\uc744 \ub20c\ub974\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694." + }, + "step": { + "init": { + "data": { + "hapid": "\uc561\uc138\uc2a4 \ud3ec\uc778\ud2b8 ID (SGTIN)", + "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 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" + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/lb.json b/homeassistant/components/homematicip_cloud/.translations/lb.json new file mode 100644 index 0000000000000..f8ae990d36442 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/lb.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Acesspoint ass schon konfigur\u00e9iert", + "connection_aborted": "Konnt sech net mam HMIP Server verbannen", + "unknown": "Onbekannten Feeler opgetrueden" + }, + "error": { + "invalid_pin": "Ong\u00ebltege Pin, prob\u00e9iert w.e.g. nach emol.", + "press_the_button": "Dr\u00e9ckt w.e.g. de bloe Kn\u00e4ppchen.", + "register_failed": "Feeler beim registr\u00e9ieren, prob\u00e9iert w.e.g. nach emol.", + "timeout_button": "Z\u00e4itiwwerschreidung beim dr\u00e9cken vum bloe Kn\u00e4ppchen, prob\u00e9iert w.e.g. nach emol." + }, + "step": { + "init": { + "data": { + "hapid": "ID vum Accesspoint (SGTIN)", + "name": "Numm (optional, g\u00ebtt als prefixe fir all Apparat benotzt)", + "pin": "Pin Code (Optional)" + }, + "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.\n\n![Standuert vum Kn\u00e4ppchen op der Bridge](/static/images/config_flows/config_homematicip_cloud.png)", + "title": "Accesspoint verbannen" + } + }, + "title": "HomematicIP Cloud" + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/nl.json b/homeassistant/components/homematicip_cloud/.translations/nl.json new file mode 100644 index 0000000000000..ff3e2dea2cdee --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/nl.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Accesspoint is al geconfigureerd", + "connection_aborted": "Kon geen verbinding maken met de HMIP-server", + "unknown": "Er is een onbekende fout opgetreden." + }, + "error": { + "invalid_pin": "Ongeldige PIN-code, probeer het nogmaals.", + "press_the_button": "Druk op de blauwe knop.", + "register_failed": "Kan niet registreren, gelieve opnieuw te proberen.", + "timeout_button": "Blauwe knop druk op timeout, probeer het opnieuw." + }, + "step": { + "init": { + "data": { + "hapid": "Accesspoint ID (SGTIN)", + "name": "Naam (optioneel, gebruikt als naamprefix voor alle apparaten)", + "pin": "Pin-Code (optioneel)" + }, + "title": "Kies HomematicIP accesspoint" + }, + "link": { + "description": "Druk op de blauwe knop op het accesspoint en de verzendknop om HomematicIP bij Home Assistant te registreren. \n\n![Locatie van knop op bridge](/static/images/config_flows/\nconfig_homematicip_cloud.png)", + "title": "Link accesspoint" + } + }, + "title": "HomematicIP Cloud" + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/nn.json b/homeassistant/components/homematicip_cloud/.translations/nn.json new file mode 100644 index 0000000000000..da375563d917d --- /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 new file mode 100644 index 0000000000000..9a4dd424beefa --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/no.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Tilgangspunktet er allerede konfigurert", + "connection_aborted": "Kunne ikke koble til HMIP serveren", + "unknown": "Ukjent feil oppstod." + }, + "error": { + "invalid_pin": "Ugyldig PIN kode, pr\u00f8v igjen.", + "press_the_button": "Vennligst trykk p\u00e5 den bl\u00e5 knappen.", + "register_failed": "Kunne ikke registrere, vennligst pr\u00f8v igjen.", + "timeout_button": "Bl\u00e5 knapp-trykk tok for lang tid, vennligst pr\u00f8v igjen." + }, + "step": { + "init": { + "data": { + "hapid": "Tilgangspunkt-ID (SGTIN)", + "name": "Navn (valgfritt, brukes som prefiks for alle enheter)", + "pin": "PIN kode (valgfritt)" + }, + "title": "Velg HomematicIP tilgangspunkt" + }, + "link": { + "description": "Trykk p\u00e5 den bl\u00e5 knappen p\u00e5 tilgangspunktet og p\u00e5 send knappen for \u00e5 registrere HomematicIP med Home Assistant. \n\n![Plassering av knapp p\u00e5 bridge](/static/images/config_flows/config_homematicip_cloud.png)", + "title": "Link tilgangspunkt" + } + }, + "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 new file mode 100644 index 0000000000000..7c8714c2c113f --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/pl.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Punkt dost\u0119pu jest ju\u017c skonfigurowany", + "connection_aborted": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z serwerem HMIP", + "unknown": "Wyst\u0105pi\u0142 nieznany b\u0142\u0105d" + }, + "error": { + "invalid_pin": "Nieprawid\u0142owy kod PIN, spr\u00f3buj ponownie.", + "press_the_button": "Prosz\u0119 nacisn\u0105\u0107 niebieski przycisk.", + "register_failed": "Nie uda\u0142o si\u0119 zarejestrowa\u0107, spr\u00f3buj ponownie.", + "timeout_button": "Oczekiwania na naci\u015bni\u0119cie niebieskiego przycisku zako\u0144czone, spr\u00f3buj ponownie." + }, + "step": { + "init": { + "data": { + "hapid": "ID punktu dost\u0119pu (SGTIN)", + "name": "Nazwa (opcjonalnie, u\u017cywana jako prefiks nazwy dla wszystkich urz\u0105dze\u0144)", + "pin": "Kod PIN (opcjonalnie)" + }, + "title": "Wybierz punkt dost\u0119pu HomematicIP" + }, + "link": { + "description": "Naci\u015bnij niebieski przycisk na punkcie dost\u0119pu i przycisk przesy\u0142ania, aby zarejestrowa\u0107 HomematicIP w Home Assistant. \n\n![Location of button on bridge](/static/images/config_flows/config_homematicip_cloud.png)", + "title": "Po\u0142\u0105cz z punktem dost\u0119pu" + } + }, + "title": "Chmura HomematicIP" + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/pt-BR.json b/homeassistant/components/homematicip_cloud/.translations/pt-BR.json new file mode 100644 index 0000000000000..82166a1aaaf5d --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/pt-BR.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "O Accesspoint j\u00e1 est\u00e1 configurado", + "connection_aborted": "N\u00e3o foi poss\u00edvel conectar ao servidor HMIP", + "unknown": "Ocorreu um erro desconhecido." + }, + "error": { + "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.", + "timeout_button": "Tempo para pressionar o Bot\u00e3o Azul expirou, por favor tente novamente." + }, + "step": { + "init": { + "data": { + "hapid": "ID do AccessPoint (SGTIN)", + "name": "Nome (opcional, usado como prefixo de nome para todos os dispositivos)", + "pin": "C\u00f3digo PIN (opcional)" + }, + "title": "Escolha um HomematicIP Accesspoint" + }, + "link": { + "description": "Pressione o bot\u00e3o azul no ponto de acesso e o bot\u00e3o enviar para registrar o HomematicIP com o Home Assistant.\n\n![Location of button on bridge](/static/images/config_flows/config_homematicip_cloud.png)", + "title": "Accesspoint link" + } + }, + "title": "Nuvem do HomematicIP" + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/pt.json b/homeassistant/components/homematicip_cloud/.translations/pt.json new file mode 100644 index 0000000000000..0954f3ff4f9aa --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/pt.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "O ponto de acesso j\u00e1 se encontra configurado", + "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.", + "press_the_button": "Por favor, pressione o bot\u00e3o azul.", + "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": { + "init": { + "data": { + "hapid": "ID do ponto de acesso (SGTIN)", + "name": "Nome (opcional, usado como prefixo de nome para todos os dispositivos)", + "pin": "C\u00f3digo PIN (opcional)" + }, + "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 bridge](/ static/images/config_flows/config_homematicip_cloud.png)", + "title": "Associar ponto de acesso" + } + }, + "title": "Nuvem do HomematicIP" + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/ro.json b/homeassistant/components/homematicip_cloud/.translations/ro.json new file mode 100644 index 0000000000000..a5399e7e68cfa --- /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 new file mode 100644 index 0000000000000..35f52a7b284b2 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/ru.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "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\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": { + "data": { + "hapid": "\u0418\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440 \u0442\u043e\u0447\u043a\u0438 \u0434\u043e\u0441\u0442\u0443\u043f\u0430 (SGTIN)", + "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": "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 **\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" + } + }, + "title": "HomematicIP Cloud" + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/sl.json b/homeassistant/components/homematicip_cloud/.translations/sl.json new file mode 100644 index 0000000000000..cdde0f12d7856 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/sl.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "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" + }, + "error": { + "invalid_pin": "Neveljavna koda PIN, poskusite znova.", + "press_the_button": "Prosimo, pritisnite modri gumb.", + "register_failed": "Registracija ni uspela, poskusite znova", + "timeout_button": "Potekla je \u010dasovna omejitev za pritisk modrega gumba, poskusite znova." + }, + "step": { + "init": { + "data": { + "hapid": "ID dostopne to\u010dke (SGTIN)", + "name": "Ime (neobvezno, ki se uporablja kot predpona za vse naprave)", + "pin": "Koda PIN (neobvezno)" + }, + "title": "Izberite dostopno to\u010dko HomematicIP" + }, + "link": { + "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" + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/sv.json b/homeassistant/components/homematicip_cloud/.translations/sv.json new file mode 100644 index 0000000000000..f155e8fd1c15a --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/sv.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Accesspunkten \u00e4r redan konfigurerad", + "connection_aborted": "Det gick inte att ansluta till HMIP-servern", + "unknown": "Ett ok\u00e4nt fel har intr\u00e4ffat" + }, + "error": { + "invalid_pin": "Ogiltig PIN-kod, f\u00f6rs\u00f6k igen.", + "press_the_button": "V\u00e4nligen tryck p\u00e5 den bl\u00e5 knappen.", + "register_failed": "Misslyckades med att registrera, f\u00f6rs\u00f6k igen.", + "timeout_button": "Bl\u00e5 knapptryckning timeout, f\u00f6rs\u00f6k igen." + }, + "step": { + "init": { + "data": { + "hapid": "Accesspunkt-ID (SGTIN)", + "name": "Namn (frivilligt, anv\u00e4nds som namnprefix f\u00f6r alla enheter)", + "pin": "Pin-kod (frivilligt)" + }, + "title": "V\u00e4lj HomematicIP Accesspunkt" + }, + "link": { + "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" + } + }, + "title": "HomematicIP Moln" + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/th.json b/homeassistant/components/homematicip_cloud/.translations/th.json new file mode 100644 index 0000000000000..cae3361a6ec08 --- /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 new file mode 100644 index 0000000000000..4c2b6268eec35 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/zh-Hans.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "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" + }, + "error": { + "invalid_pin": "PIN \u65e0\u6548\uff0c\u8bf7\u518d\u8bd5\u4e00\u6b21\u3002", + "press_the_button": "\u8bf7\u6309\u4e0b\u84dd\u8272\u6309\u94ae\u3002", + "register_failed": "\u6ce8\u518c\u5931\u8d25\uff0c\u8bf7\u91cd\u8bd5", + "timeout_button": "\u6309\u4e0b\u84dd\u8272\u6309\u94ae\u8d85\u65f6\uff0c\u8bf7\u518d\u8bd5\u4e00\u6b21\u3002" + }, + "step": { + "init": { + "data": { + "hapid": "\u63a5\u5165\u70b9 ID (SGTIN)", + "name": "\u540d\u79f0\uff08\u53ef\u9009\uff0c\u7528\u4f5c\u6240\u6709\u8bbe\u5907\u7684\u540d\u79f0\u524d\u7f00\uff09", + "pin": "PIN \u7801\uff08\u53ef\u9009\uff09" + }, + "title": "\u9009\u62e9 HomematicIP \u63a5\u5165\u70b9" + }, + "link": { + "description": "\u6309\u4e0b\u63a5\u5165\u70b9\u4e0a\u7684\u84dd\u8272\u6309\u94ae\u7136\u540e\u70b9\u51fb\u63d0\u4ea4\u6309\u94ae\uff0c\u4ee5\u5c06 HomematicIP \u6ce8\u518c\u5230 Home Assistant\u3002\n\n![\u63a5\u5165\u70b9\u7684\u6309\u94ae\u4f4d\u7f6e]\n(/static/images/config_flows/config_homematicip_cloud.png)", + "title": "\u8fde\u63a5\u63a5\u5165\u70b9" + } + }, + "title": "HomematicIP Cloud" + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/zh-Hant.json b/homeassistant/components/homematicip_cloud/.translations/zh-Hant.json new file mode 100644 index 0000000000000..c6a960f1a6836 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/zh-Hant.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "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" + }, + "error": { + "invalid_pin": "PIN \u78bc\u7121\u6548\uff0c\u8acb\u518d\u8a66\u4e00\u6b21\u3002", + "press_the_button": "\u8acb\u6309\u4e0b\u85cd\u8272\u6309\u9215\u3002", + "register_failed": "\u8a3b\u518a\u5931\u6557\uff0c\u8acb\u518d\u8a66\u4e00\u6b21\u3002", + "timeout_button": "\u85cd\u8272\u6309\u9215\u903e\u6642\uff0c\u8acb\u518d\u8a66\u4e00\u6b21\u3002" + }, + "step": { + "init": { + "data": { + "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 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 Access point" + } + }, + "title": "HomematicIP Cloud" + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/__init__.py b/homeassistant/components/homematicip_cloud/__init__.py new file mode 100644 index 0000000000000..f3e1fc9fbece9 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/__init__.py @@ -0,0 +1,360 @@ +"""Support for HomematicIP Cloud devices.""" +import logging +from pathlib import Path +from typing import Optional + +from homematicip.aio.device import AsyncSwitchMeasuring +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.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, +) +from .device import HomematicipGenericDevice # noqa: F401 +from .hap import HomematicipAuth, HomematicipHAP # noqa: F401 + +_LOGGER = logging.getLogger(__name__) + +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_RESET_ENERGY_COUNTER = "reset_energy_counter" +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, + } +) + +SCHEMA_RESET_ENERGY_COUNTER = vol.Schema( + {vol.Required(ATTR_ENTITY_ID): comp_entity_ids} +) + + +async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: + """Set up the HomematicIP Cloud component.""" + hass.data[DOMAIN] = {} + + accesspoints = config.get(DOMAIN, []) + + 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], + }, + ) + ) + + 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 and isinstance(group, AsyncHeatingGroup): + 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, + ) + + async def _async_reset_energy_counter(service): + """Service to reset the energy counter.""" + entity_id_list = service.data[ATTR_ENTITY_ID] + + for hap in hass.data[DOMAIN].values(): + if entity_id_list != "all": + for entity_id in entity_id_list: + device = hap.hmip_device_by_entity_id.get(entity_id) + if device and isinstance(device, AsyncSwitchMeasuring): + await device.reset_energy_counter() + else: + for device in hap.home.devices: + if isinstance(device, AsyncSwitchMeasuring): + await device.reset_energy_counter() + + hass.helpers.service.async_register_admin_service( + DOMAIN, + SERVICE_RESET_ENERGY_COUNTER, + _async_reset_energy_counter, + schema=SCHEMA_RESET_ENERGY_COUNTER, + ) + + 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: HomeAssistantType, entry: ConfigEntry) -> bool: + """Set up an access point from a config entry.""" + hap = HomematicipHAP(hass, entry) + hapid = entry.data[HMIPC_HAPID].replace("-", "").upper() + hass.data[DOMAIN][hapid] = hap + + 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: 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 0000000000000..f9a9120342647 --- /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 0000000000000..3efd4ad91bc68 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/binary_sensor.py @@ -0,0 +1,454 @@ +"""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, + AsyncPluggableMainsFailureSurveillance, + 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_POWER, + 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, AsyncPluggableMainsFailureSurveillance): + entities.append( + HomematicipPluggableMainsFailureSurveillanceSensor(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 HomematicipPluggableMainsFailureSurveillanceSensor( + HomematicipGenericDevice, BinarySensorDevice +): + """Representation of a HomematicIP Cloud pluggable mains failure surveillance sensor.""" + + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize pluggable mains failure surveillance sensor.""" + super().__init__(hap, device) + + @property + def device_class(self) -> str: + """Return the class of this sensor.""" + return DEVICE_CLASS_POWER + + @property + def is_on(self) -> bool: + """Return true if power mains fails.""" + return not self._device.powerMainsFailure + + +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 0000000000000..e3c922dc5775a --- /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 new file mode 100644 index 0000000000000..8d85dfda3289e --- /dev/null +++ b/homeassistant/components/homematicip_cloud/config_flow.py @@ -0,0 +1,109 @@ +"""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 ( + _LOGGER, + DOMAIN as HMIPC_DOMAIN, + HMIPC_AUTHTOKEN, + HMIPC_HAPID, + HMIPC_NAME, + HMIPC_PIN, +) +from .hap import HomematicipAuth + + +@callback +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) + ) + + +@config_entries.HANDLERS.register(HMIPC_DOMAIN) +class HomematicipCloudFlowHandler(config_entries.ConfigFlow): + """Config flow for the HomematicIP Cloud component.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH + + def __init__(self) -> None: + """Initialize HomematicIP Cloud config flow.""" + self.auth = 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) -> Dict[str, Any]: + """Handle a flow start.""" + errors = {} + + if user_input is not None: + 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") + + self.auth = HomematicipAuth(self.hass, user_input) + connected = await self.auth.async_setup() + if connected: + _LOGGER.info("Connection to HomematicIP Cloud established") + 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, + ) + + async def async_step_link(self, user_input=None) -> Dict[str, Any]: + """Attempt to link with the HomematicIP Cloud access point.""" + errors = {} + + pressed = await self.auth.async_checkbutton() + if pressed: + authtoken = await self.auth.async_register() + if authtoken: + _LOGGER.info("Write config entry for HomematicIP Cloud") + return self.async_create_entry( + title=self.auth.config.get(HMIPC_HAPID), + 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" + + return self.async_show_form(step_id="link", errors=errors) + + 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() + if hapid in configured_haps(self.hass): + 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}, + ) diff --git a/homeassistant/components/homematicip_cloud/const.py b/homeassistant/components/homematicip_cloud/const.py new file mode 100644 index 0000000000000..5c48de975f97c --- /dev/null +++ b/homeassistant/components/homematicip_cloud/const.py @@ -0,0 +1,25 @@ +"""Constants for the HomematicIP Cloud component.""" +import logging + +_LOGGER = logging.getLogger(".") + +DOMAIN = "homematicip_cloud" + +COMPONENTS = [ + "alarm_control_panel", + "binary_sensor", + "climate", + "cover", + "light", + "sensor", + "switch", + "weather", +] + +CONF_ACCESSPOINT = "accesspoint" +CONF_AUTHTOKEN = "authtoken" + +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 0000000000000..e3efe9a950870 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/cover.py @@ -0,0 +1,147 @@ +"""Support for HomematicIP Cloud cover devices.""" +import logging +from typing import Optional + +from homematicip.aio.device import ( + AsyncFullFlushBlind, + AsyncFullFlushShutter, + AsyncGarageDoorModuleTormatic, +) +from homematicip.base.enums import DoorCommand, DoorState + +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)) + elif isinstance(device, AsyncGarageDoorModuleTormatic): + entities.append(HomematicipGarageDoorModuleTormatic(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() + + +class HomematicipGarageDoorModuleTormatic(HomematicipGenericDevice, CoverDevice): + """Representation of a HomematicIP Garage Door Module for Tormatic.""" + + @property + def current_cover_position(self) -> int: + """Return current position of cover.""" + door_state_to_position = { + DoorState.CLOSED: 0, + DoorState.OPEN: 100, + DoorState.VENTILATION_POSITION: 10, + DoorState.POSITION_UNKNOWN: None, + } + return door_state_to_position.get(self._device.doorState) + + @property + def is_closed(self) -> Optional[bool]: + """Return if the cover is closed.""" + return self._device.doorState == DoorState.CLOSED + + async def async_open_cover(self, **kwargs) -> None: + """Open the cover.""" + await self._device.send_door_command(DoorCommand.OPEN) + + async def async_close_cover(self, **kwargs) -> None: + """Close the cover.""" + await self._device.send_door_command(DoorCommand.CLOSE) + + async def async_stop_cover(self, **kwargs) -> None: + """Stop the cover.""" + await self._device.send_door_command(DoorCommand.STOP) diff --git a/homeassistant/components/homematicip_cloud/device.py b/homeassistant/components/homematicip_cloud/device.py new file mode 100644 index 0000000000000..f35b696767ce8 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/device.py @@ -0,0 +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_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, hap: HomematicipHAP, device, post: Optional[str] = None) -> None: + """Initialize the generic device.""" + 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) + + @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 + + 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.""" + # 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) -> str: + """Return the name of the generic device.""" + name = self._device.label + 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) -> bool: + """No polling needed.""" + return False + + @property + def available(self) -> bool: + """Device available.""" + return not self._device.unreach + + @property + 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.""" + for attr, icon in DEVICE_ATTRIBUTE_ICONS.items(): + if getattr(self._device, attr, None): + return icon + + return None + + @property + def device_state_attributes(self) -> Dict[str, Any]: + """Return the state attributes of the generic device.""" + 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/errors.py b/homeassistant/components/homematicip_cloud/errors.py new file mode 100644 index 0000000000000..1102cde6fbecc --- /dev/null +++ b/homeassistant/components/homematicip_cloud/errors.py @@ -0,0 +1,22 @@ +"""Errors for the HomematicIP Cloud component.""" +from homeassistant.exceptions import HomeAssistantError + + +class HmipcException(HomeAssistantError): + """Base class for HomematicIP Cloud exceptions.""" + + +class HmipcConnectionError(HmipcException): + """Unable to connect to the HomematicIP Cloud server.""" + + +class HmipcConnectionWait(HmipcException): + """Wait for registration to the HomematicIP Cloud server.""" + + +class HmipcRegistrationFailed(HmipcException): + """Registration on HomematicIP Cloud failed.""" + + +class HmipcPressButton(HmipcException): + """User needs to press the blue button.""" diff --git a/homeassistant/components/homematicip_cloud/hap.py b/homeassistant/components/homematicip_cloud/hap.py new file mode 100644 index 0000000000000..63bdf3166ebf1 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/hap.py @@ -0,0 +1,247 @@ +"""Access point for the HomematicIP Cloud component.""" +import asyncio +import logging + +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 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__) + + +class HomematicipAuth: + """Manages HomematicIP client registration.""" + + 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) -> 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) + ) + return True + except HmipcConnectionError: + return False + + async def async_checkbutton(self) -> bool: + """Check blue butten has been pressed.""" + try: + return await self.auth.isRequestAcknowledged() + except HmipConnectionError: + return False + + async def async_register(self): + """Register client at HomematicIP.""" + try: + authtoken = await self.auth.requestAuthToken() + await self.auth.confirmAuthToken(authtoken) + return authtoken + except HmipConnectionError: + return False + + async def get_auth(self, hass: HomeAssistantType, hapid, pin): + """Create a HomematicIP access point object.""" + auth = AsyncAuth(hass.loop, async_get_clientsession(hass)) + try: + await auth.init(hapid) + if pin: + auth.pin = pin + await auth.connectionRequest("HomeAssistant") + except HmipConnectionError: + return False + return auth + + +class HomematicipHAP: + """Manages HomematicIP HTTP and WebSocket connection.""" + + def __init__(self, hass: HomeAssistantType, config_entry: ConfigEntry) -> None: + """Initialize HomematicIP Cloud connection.""" + self.hass = hass + self.config_entry = config_entry + self.home = None + + self._ws_close_requested = False + self._retry_task = None + self._tries = 0 + self._accesspoint_connected = True + self.hmip_device_by_entity_id = {} + + 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), + ) + except HmipcConnectionError: + raise ConfigEntryNotReady + + _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 + ) + ) + return True + + @callback + 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. + 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") + 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_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. + + 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) -> None: + """Execute when get_state coroutine has finished.""" + 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_create_task(self.home.disable_events()) + + 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) -> None: + """Signal all devices to update their state.""" + for device in self.home.devices: + device.fire_update_event() + + async def async_connect(self) -> None: + """Start WebSocket connection.""" + 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: + _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 + + try: + 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) -> bool: + """Close the websocket connection.""" + self._ws_close_requested = True + if self._retry_task is not None: + self._retry_task.cancel() + await self.home.disable_events() + _LOGGER.info("Closed connection to HomematicIP cloud server") + for component in COMPONENTS: + await self.hass.config_entries.async_forward_entry_unload( + self.config_entry, component + ) + self.hmip_device_by_entity_id = {} + return True + + async def get_hap( + self, hass: HomeAssistantType, hapid: str, authtoken: str, name: str + ) -> AsyncHome: + """Create a HomematicIP access point object.""" + home = AsyncHome(hass.loop, async_get_clientsession(hass)) + + home.name = name + home.label = "Access Point" + home.modelType = "HmIP-HAP" + + home.set_auth_token(authtoken) + try: + await home.init(hapid) + await home.get_current_state() + 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 0000000000000..79083f031ae30 --- /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 0000000000000..e920a847292a4 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "homematicip_cloud", + "name": "HomematicIP Cloud", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/homematicip_cloud", + "requirements": ["homematicip==0.10.15"], + "dependencies": [], + "codeowners": ["@SukramJ"] +} diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py new file mode 100644 index 0000000000000..a8ca3d17eb942 --- /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 0000000000000..750528eddf894 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/services.yaml @@ -0,0 +1,78 @@ +# 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 + +reset_energy_counter: + description: Reset the energy counter of a measuring entity. + fields: + entity_id: + description: The ID of the measuring entity. Use 'all' keyword to reset all energy counters. + example: switch.livingroom diff --git a/homeassistant/components/homematicip_cloud/strings.json b/homeassistant/components/homematicip_cloud/strings.json new file mode 100644 index 0000000000000..f2d38a1dc7b83 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/strings.json @@ -0,0 +1,30 @@ +{ + "config": { + "title": "HomematicIP Cloud", + "step": { + "init": { + "title": "Pick HomematicIP Access point", + "data": { + "hapid": "Access point ID (SGTIN)", + "pin": "Pin Code (optional)", + "name": "Name (optional, used as name prefix for all devices)" + } + }, + "link": { + "title": "Link Access point", + "description": "Press the blue button on the access point and the submit button to register HomematicIP with Home Assistant.\n\n![Location of button on bridge](/static/images/config_flows/config_homematicip_cloud.png)" + } + }, + "error": { + "register_failed": "Failed to register, please try again.", + "invalid_pin": "Invalid PIN, please try again.", + "press_the_button": "Please press the blue button.", + "timeout_button": "Blue button press timeout, please try again." + }, + "abort": { + "unknown": "Unknown error occurred.", + "connection_aborted": "Could not connect to HMIP server", + "already_configured": "Access point is already configured" + } + } +} diff --git a/homeassistant/components/homematicip_cloud/switch.py b/homeassistant/components/homematicip_cloud/switch.py new file mode 100644 index 0000000000000..8f3f6a3a17715 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/switch.py @@ -0,0 +1,179 @@ +"""Support for HomematicIP Cloud switches.""" +import logging +from typing import Any, Dict + +from homematicip.aio.device import ( + AsyncBrandSwitchMeasuring, + AsyncFullFlushSwitchMeasuring, + AsyncHeatingSwitch2, + 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, AsyncHeatingSwitch2): + for channel in range(1, 3): + 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 0000000000000..ebc7eacf78ea3 --- /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 0000000000000..c6296d8f4c69e --- /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 0000000000000..2c0034ee9862c --- /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 0000000000000..e28230662f8f7 --- /dev/null +++ b/homeassistant/components/homeworks/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "homeworks", + "name": "Lutron 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 0000000000000..57176c9acf807 --- /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 0000000000000..f8537bfe96a3c --- /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 0000000000000..5d17824e0cb60 --- /dev/null +++ b/homeassistant/components/honeywell/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "honeywell", + "name": "Honeywell Total Connect Comfort (TCC)", + "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 0000000000000..bc85e27d74264 --- /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 0000000000000..035354c969a82 --- /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 0000000000000..582dc61af14fb --- /dev/null +++ b/homeassistant/components/hook/switch.py @@ -0,0 +1,130 @@ +"""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 0000000000000..77ac25098c300 --- /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 0000000000000..620a90d6c0953 --- /dev/null +++ b/homeassistant/components/horizon/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "horizon", + "name": "Unitymedia Horizon HD Recorder", + "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 0000000000000..44e93d26a405f --- /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 0000000000000..67135b947e455 --- /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 0000000000000..c863651699ad8 --- /dev/null +++ b/homeassistant/components/hp_ilo/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "hp_ilo", + "name": "HP Integrated Lights-Out (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 0000000000000..04c715dc01098 --- /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 0000000000000..88e437ef5666d --- /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 0000000000000..1d0689511b2f8 --- /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 0000000000000..1aaf4aed53965 --- /dev/null +++ b/homeassistant/components/html5/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "html5", + "name": "HTML5 Push 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 0000000000000..b966f5ae6a15b --- /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 0000000000000..5fd068a64dcd5 --- /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.py b/homeassistant/components/http.py deleted file mode 100644 index fed43cb43de76..0000000000000 --- a/homeassistant/components/http.py +++ /dev/null @@ -1,379 +0,0 @@ -""" -homeassistant.components.httpinterface -~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -This module provides an API and a HTTP interface for debug purposes. - -By default it will run on port 8123. - -All API calls have to be accompanied by an 'api_password' parameter and will -return JSON. If successful calls will return status code 200 or 201. - -Other status codes that can occur are: - - 400 (Bad Request) - - 401 (Unauthorized) - - 404 (Not Found) - - 405 (Method not allowed) - -The api supports the following actions: - -/api - GET -Returns message if API is up and running. -Example result: -{ - "message": "API running." -} - -/api/states - GET -Returns a list of entities for which a state is available -Example result: -[ - { .. state object .. }, - { .. state object .. } -] - -/api/states/ - GET -Returns the current state from an entity -Example result: -{ - "attributes": { - "next_rising": "07:04:15 29-10-2013", - "next_setting": "18:00:31 29-10-2013" - }, - "entity_id": "weather.sun", - "last_changed": "23:24:33 28-10-2013", - "state": "below_horizon" -} - -/api/states/ - POST -Updates the current state of an entity. Returns status code 201 if successful -with location header of updated resource and as body the new state. -parameter: new_state - string -optional parameter: attributes - JSON encoded object -Example result: -{ - "attributes": { - "next_rising": "07:04:15 29-10-2013", - "next_setting": "18:00:31 29-10-2013" - }, - "entity_id": "weather.sun", - "last_changed": "23:24:33 28-10-2013", - "state": "below_horizon" -} - -/api/events/ - POST -Fires an event with event_type -optional parameter: event_data - JSON encoded object -Example result: -{ - "message": "Event download_file fired." -} - -""" - -import json -import threading -import logging -import time -import gzip -import os -from http.server import SimpleHTTPRequestHandler, HTTPServer -from socketserver import ThreadingMixIn -from urllib.parse import urlparse, parse_qs - -import homeassistant as ha -from homeassistant.const import ( - SERVER_PORT, CONTENT_TYPE_JSON, - HTTP_HEADER_HA_AUTH, HTTP_HEADER_CONTENT_TYPE, HTTP_HEADER_ACCEPT_ENCODING, - HTTP_HEADER_CONTENT_ENCODING, HTTP_HEADER_VARY, HTTP_HEADER_CONTENT_LENGTH, - HTTP_HEADER_CACHE_CONTROL, HTTP_HEADER_EXPIRES, HTTP_OK, HTTP_UNAUTHORIZED, - HTTP_NOT_FOUND, HTTP_METHOD_NOT_ALLOWED, HTTP_UNPROCESSABLE_ENTITY) -import homeassistant.remote as rem -import homeassistant.util as util -import homeassistant.bootstrap as bootstrap - -DOMAIN = "http" -DEPENDENCIES = [] - -CONF_API_PASSWORD = "api_password" -CONF_SERVER_HOST = "server_host" -CONF_SERVER_PORT = "server_port" -CONF_DEVELOPMENT = "development" - -DATA_API_PASSWORD = 'api_password' - -_LOGGER = logging.getLogger(__name__) - - -def setup(hass, config=None): - """ Sets up the HTTP API and debug interface. """ - - if config is None or DOMAIN not in config: - config = {DOMAIN: {}} - - api_password = util.convert(config[DOMAIN].get(CONF_API_PASSWORD), str) - - no_password_set = api_password is None - - if no_password_set: - api_password = util.get_random_string() - - # If no server host is given, accept all incoming requests - server_host = config[DOMAIN].get(CONF_SERVER_HOST, '0.0.0.0') - - server_port = config[DOMAIN].get(CONF_SERVER_PORT, SERVER_PORT) - - development = str(config[DOMAIN].get(CONF_DEVELOPMENT, "")) == "1" - - server = HomeAssistantHTTPServer( - (server_host, server_port), RequestHandler, hass, api_password, - development, no_password_set) - - hass.bus.listen_once( - ha.EVENT_HOMEASSISTANT_START, - lambda event: - threading.Thread(target=server.start, daemon=True).start()) - - hass.http = server - hass.config.api = rem.API(util.get_local_ip(), api_password, server_port) - - return True - - -class HomeAssistantHTTPServer(ThreadingMixIn, HTTPServer): - """ Handle HTTP requests in a threaded fashion. """ - # pylint: disable=too-few-public-methods - - allow_reuse_address = True - daemon_threads = True - - # pylint: disable=too-many-arguments - def __init__(self, server_address, request_handler_class, - hass, api_password, development, no_password_set): - super().__init__(server_address, request_handler_class) - - self.server_address = server_address - self.hass = hass - self.api_password = api_password - self.development = development - self.no_password_set = no_password_set - self.paths = [] - - # We will lazy init this one if needed - self.event_forwarder = None - - if development: - _LOGGER.info("running http in development mode") - - def start(self): - """ Starts the server. """ - self.hass.bus.listen_once( - ha.EVENT_HOMEASSISTANT_STOP, - lambda event: self.shutdown()) - - _LOGGER.info( - "Starting web interface at http://%s:%d", *self.server_address) - - # 31-1-2015: Refactored frontend/api components out of this component - # To prevent stuff from breaking, load the two extracted components - bootstrap.setup_component(self.hass, 'api') - bootstrap.setup_component(self.hass, 'frontend') - - self.serve_forever() - - def register_path(self, method, url, callback, require_auth=True): - """ Regitsters a path wit the server. """ - self.paths.append((method, url, callback, require_auth)) - - -# pylint: disable=too-many-public-methods,too-many-locals -class RequestHandler(SimpleHTTPRequestHandler): - """ - Handles incoming HTTP requests - - We extend from SimpleHTTPRequestHandler instead of Base so we - can use the guess content type methods. - """ - - server_version = "HomeAssistant/1.0" - - def _handle_request(self, method): # pylint: disable=too-many-branches - """ Does some common checks and calls appropriate method. """ - url = urlparse(self.path) - - # Read query input - data = parse_qs(url.query) - - # parse_qs gives a list for each value, take the latest element - for key in data: - data[key] = data[key][-1] - - # Did we get post input ? - content_length = int(self.headers.get(HTTP_HEADER_CONTENT_LENGTH, 0)) - - if content_length: - body_content = self.rfile.read(content_length).decode("UTF-8") - - try: - data.update(json.loads(body_content)) - except (TypeError, ValueError): - # TypeError if JSON object is not a dict - # ValueError if we could not parse JSON - _LOGGER.exception( - "Exception parsing JSON: %s", body_content) - self.write_json_message( - "Error parsing JSON", HTTP_UNPROCESSABLE_ENTITY) - return - - if self.server.no_password_set: - api_password = self.server.api_password - else: - api_password = self.headers.get(HTTP_HEADER_HA_AUTH) - - if not api_password and DATA_API_PASSWORD in data: - api_password = data[DATA_API_PASSWORD] - - if '_METHOD' in data: - method = data.pop('_METHOD') - - # Var to keep track if we found a path that matched a handler but - # the method was different - path_matched_but_not_method = False - - # Var to hold the handler for this path and method if found - handle_request_method = False - require_auth = True - - # Check every handler to find matching result - for t_method, t_path, t_handler, t_auth in self.server.paths: - # we either do string-comparison or regular expression matching - # pylint: disable=maybe-no-member - if isinstance(t_path, str): - path_match = url.path == t_path - else: - path_match = t_path.match(url.path) - - if path_match and method == t_method: - # Call the method - handle_request_method = t_handler - require_auth = t_auth - break - - elif path_match: - path_matched_but_not_method = True - - # Did we find a handler for the incoming request? - if handle_request_method: - - # For some calls we need a valid password - if require_auth and api_password != self.server.api_password: - self.write_json_message( - "API password missing or incorrect.", HTTP_UNAUTHORIZED) - - else: - handle_request_method(self, path_match, data) - - elif path_matched_but_not_method: - self.send_response(HTTP_METHOD_NOT_ALLOWED) - self.end_headers() - - else: - self.send_response(HTTP_NOT_FOUND) - self.end_headers() - - def do_HEAD(self): # pylint: disable=invalid-name - """ HEAD request handler. """ - self._handle_request('HEAD') - - def do_GET(self): # pylint: disable=invalid-name - """ GET request handler. """ - self._handle_request('GET') - - def do_POST(self): # pylint: disable=invalid-name - """ POST request handler. """ - self._handle_request('POST') - - def do_PUT(self): # pylint: disable=invalid-name - """ PUT request handler. """ - self._handle_request('PUT') - - def do_DELETE(self): # pylint: disable=invalid-name - """ DELETE request handler. """ - self._handle_request('DELETE') - - def write_json_message(self, message, status_code=HTTP_OK): - """ Helper method to return a message to the caller. """ - self.write_json({'message': message}, status_code=status_code) - - def write_json(self, data=None, status_code=HTTP_OK, location=None): - """ Helper method to return JSON to the caller. """ - self.send_response(status_code) - self.send_header(HTTP_HEADER_CONTENT_TYPE, CONTENT_TYPE_JSON) - - if location: - self.send_header('Location', location) - - self.end_headers() - - if data is not None: - self.wfile.write( - json.dumps(data, indent=4, sort_keys=True, - cls=rem.JSONEncoder).encode("UTF-8")) - - def write_file(self, path): - """ Returns a file to the user. """ - try: - with open(path, 'rb') as inp: - self.write_file_pointer(self.guess_type(path), inp) - - except IOError: - self.send_response(HTTP_NOT_FOUND) - self.end_headers() - _LOGGER.exception("Unable to serve %s", path) - - def write_file_pointer(self, content_type, inp): - """ - Helper function to write a file pointer to the user. - Does not do error handling. - """ - do_gzip = 'gzip' in self.headers.get(HTTP_HEADER_ACCEPT_ENCODING, '') - - self.send_response(HTTP_OK) - self.send_header(HTTP_HEADER_CONTENT_TYPE, content_type) - - self.set_cache_header() - - if do_gzip: - gzip_data = gzip.compress(inp.read()) - - self.send_header(HTTP_HEADER_CONTENT_ENCODING, "gzip") - self.send_header(HTTP_HEADER_VARY, HTTP_HEADER_ACCEPT_ENCODING) - self.send_header(HTTP_HEADER_CONTENT_LENGTH, str(len(gzip_data))) - - else: - fst = os.fstat(inp.fileno()) - self.send_header(HTTP_HEADER_CONTENT_LENGTH, str(fst[6])) - - self.end_headers() - - if self.command == 'HEAD': - return - - elif do_gzip: - self.wfile.write(gzip_data) - - else: - self.copyfile(inp, self.wfile) - - def set_cache_header(self): - """ Add cache headers if not in development """ - if not self.server.development: - # 1 year in seconds - cache_time = 365 * 86400 - - self.send_header( - HTTP_HEADER_CACHE_CONTROL, - "public, max-age={}".format(cache_time)) - self.send_header( - HTTP_HEADER_EXPIRES, - self.date_time_string(time.time()+cache_time)) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py new file mode 100644 index 0000000000000..c720d134c9fcb --- /dev/null +++ b/homeassistant/components/http/__init__.py @@ -0,0 +1,328 @@ +"""Support to serve the Home Assistant API as WSGI application.""" +from ipaddress import ip_network +import logging +import os +import ssl +from typing import Optional + +from aiohttp import web +from aiohttp.web_exceptions import HTTPMovedPermanently +import voluptuous as vol + +from homeassistant.const import ( + EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP, + SERVER_PORT, +) +import homeassistant.helpers.config_validation as cv +import homeassistant.util as hass_util +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 CACHE_HEADERS, CachingStaticResource +from .view import HomeAssistantView # noqa: F401 + +# mypy: allow-untyped-defs, no-check-untyped-defs + +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" + +SSL_MODERN = "modern" +SSL_INTERMEDIATE = "intermediate" + +_LOGGER = logging.getLogger(__name__) + +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_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 + ) -> None: + """Initialize a new API config object.""" + self.host = host + self.port = port + self.use_ssl = use_ssl + + host = host.rstrip("/") + if host.startswith(("http://", "https://")): + self.base_url = host + elif use_ssl: + self.base_url = f"https://{host}" + else: + self.base_url = f"http://{host}" + + if port is not None: + self.base_url += f":{port}" + + +async def async_setup(hass, config): + """Set up the HTTP API and debug interface.""" + conf = config.get(DOMAIN) + + if conf is None: + conf = HTTP_SCHEMA({}) + + server_host = conf[CONF_SERVER_HOST] + server_port = conf[CONF_SERVER_PORT] + ssl_certificate = conf.get(CONF_SSL_CERTIFICATE) + ssl_peer_certificate = conf.get(CONF_SSL_PEER_CERTIFICATE) + ssl_key = conf.get(CONF_SSL_KEY) + 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, []) + is_ban_enabled = conf[CONF_IP_BAN_ENABLED] + login_threshold = conf[CONF_LOGIN_ATTEMPTS_THRESHOLD] + ssl_profile = conf[CONF_SSL_PROFILE] + + server = HomeAssistantHTTP( + hass, + server_host=server_host, + server_port=server_port, + 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, + login_threshold=login_threshold, + is_ban_enabled=is_ban_enabled, + ssl_profile=ssl_profile, + ) + + async def stop_server(event): + """Stop the server.""" + await server.stop() + + async def start_server(event): + """Start the server.""" + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_server) + await server.start() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_server) + + hass.http = server + + host = conf.get(CONF_BASE_URL) + + if host: + port = None + elif server_host != DEFAULT_SERVER_HOST: + host = server_host + port = server_port + else: + host = hass_util.get_local_ip() + port = server_port + + hass.config.api = ApiConfig(host, port, ssl_certificate is not None) + + return True + + +class HomeAssistantHTTP: + """HTTP server for Home Assistant.""" + + 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=[]) + app[KEY_HASS] = hass + + # This order matters + setup_real_ip(app, use_x_forwarded_for, trusted_proxies) + + if is_ban_enabled: + setup_bans(hass, app, login_threshold) + + setup_auth(hass, app) + + setup_cors(app, cors_origins) + + self.hass = hass + 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_proxies = trusted_proxies + self.is_ban_enabled = is_ban_enabled + self.ssl_profile = ssl_profile + self._handler = None + self.runner = None + self.site = None + + def register_view(self, view): + """Register a view with the WSGI server. + + The view argument must be a class that inherits from HomeAssistantView. + It is optional to instantiate it before registering; this method will + handle it either way. + """ + if isinstance(view, type): + # Instantiate the view, if needed + view = view() + + if not hasattr(view, "url"): + class_name = view.__class__.__name__ + raise AttributeError(f'{class_name} missing required attribute "url"') + + if not hasattr(view, "name"): + class_name = view.__class__.__name__ + raise AttributeError(f'{class_name} missing required attribute "name"') + + view.register(self.app, self.app.router) + + def register_redirect(self, url, redirect_to): + """Register a redirect with the server. + + If given this must be either a string or callable. In case of a + callable it's called with the url adapter that triggered the match and + the values of the URL as keyword arguments and has to return the target + for the redirect, otherwise it has to be a string with placeholders in + rule syntax. + """ + + async def redirect(request): + """Redirect to location.""" + raise HTTPMovedPermanently(redirect_to) + + 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.""" + if os.path.isdir(path): + if cache_headers: + resource = CachingStaticResource + else: + resource = web.StaticResource + self.app.router.register_resource(resource(url_path, path)) + return + + if cache_headers: + + async def serve_file(request): + """Serve file from disk.""" + return web.FileResponse(path, headers=CACHE_HEADERS) + + else: + + async def serve_file(request): + """Serve file from disk.""" + return web.FileResponse(path) + + self.app.router.add_route("GET", url_path, serve_file) + + async def start(self): + """Start the aiohttp server.""" + if self.ssl_certificate: + try: + if self.ssl_profile == SSL_INTERMEDIATE: + context = ssl_util.server_context_intermediate() + else: + context = ssl_util.server_context_modern() + await self.hass.async_add_executor_job( + 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, + ) + 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 + ) + + else: + context = None + + # Aiohttp freezes apps after start so that no changes can be made. + # However in Home Assistant components can be discovered after boot. + # This will now raise a RunTimeError. + # To work around this we now 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 + ) + try: + await self.site.start() + except OSError as error: + _LOGGER.error( + "Failed to create HTTP server at port %d: %s", self.server_port, error + ) + + async def stop(self): + """Stop the aiohttp server.""" + await self.site.stop() + await self.runner.cleanup() diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py new file mode 100644 index 0000000000000..58814b77e2dc4 --- /dev/null +++ b/homeassistant/components/http/auth.py @@ -0,0 +1,137 @@ +"""Authentication for HTTP component.""" +import logging +import secrets + +from aiohttp import hdrs +from aiohttp.web import middleware +import jwt + +from homeassistant.core import callback +from homeassistant.util import dt as dt_util + +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 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.""" + + 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 hdrs.AUTHORIZATION in request.headers and await async_validate_auth_header( + request + ): + authenticated = True + auth_type = "bearer token" + + # 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" + + 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) + + app.middlewares.append(auth_middleware) diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py new file mode 100644 index 0000000000000..da406c071e452 --- /dev/null +++ b/homeassistant/components/http/ban.py @@ -0,0 +1,199 @@ +"""Ban logic for HTTP component.""" +from collections import defaultdict +from datetime import datetime +from ipaddress import ip_address +import logging +from typing import List, Optional + +from aiohttp.web import middleware +from aiohttp.web_exceptions import HTTPForbidden, HTTPUnauthorized +import voluptuous as vol + +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" + +NOTIFICATION_ID_BAN = "ip-ban" +NOTIFICATION_ID_LOGIN = "http-login" + +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)} +) + + +@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[KEY_BANNED_IPS] = await async_load_ip_bans_config( + hass, hass.config.path(IP_BANS_FILE) + ) + + app.on_startup.append(ban_startup) + + +@middleware +async def ban_middleware(request, handler): + """IP Ban middleware.""" + if KEY_BANNED_IPS not in request.app: + _LOGGER.error("IP Ban middleware loaded but banned IPs not loaded") + return await handler(request) + + # Verify if IP is not banned + ip_address_ = request[KEY_REAL_IP] + is_banned = any( + ip_ban.ip_address == ip_address_ for ip_ban in request.app[KEY_BANNED_IPS] + ) + + if is_banned: + raise HTTPForbidden() + + try: + return await handler(request) + except HTTPUnauthorized: + await process_wrong_login(request) + raise + + +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 + + +async def process_wrong_login(request): + """Process a wrong login attempt. + + Increase failed login attempts counter for remote IP address. + Add ip ban entry if failed login attempts exceeds threshold. + """ + remote_addr = request[KEY_REAL_IP] + + msg = "Login attempt or request with invalid authentication from {}".format( + remote_addr + ) + _LOGGER.warning(msg) + + hass = request.app["hass"] + hass.components.persistent_notification.async_create( + 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: + return + + request.app[KEY_FAILED_LOGIN_ATTEMPTS][remote_addr] += 1 + + if ( + request.app[KEY_FAILED_LOGIN_ATTEMPTS][remote_addr] + >= request.app[KEY_LOGIN_THRESHOLD] + ): + new_ban = IpBan(remote_addr) + request.app[KEY_BANNED_IPS].append(new_ban) + + await hass.async_add_job( + update_ip_bans_config, hass.config.path(IP_BANS_FILE), new_ban + ) + + _LOGGER.warning("Banned IP %s for too many login attempts", remote_addr) + + hass.components.persistent_notification.async_create( + f"Too many login attempts from {remote_addr}", + "Banning IP address", + NOTIFICATION_ID_BAN, + ) + + +async def process_success_login(request): + """Process a success login attempt. + + Reset failed login attempts counter for remote IP address. + No release IP address from banned list function, it can only be done by + manual modify ip bans config file. + """ + 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: + 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 + ) + request.app[KEY_FAILED_LOGIN_ATTEMPTS].pop(remote_addr) + + +class IpBan: + """Represents banned IP address.""" + + 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() + + +async def async_load_ip_bans_config(hass: HomeAssistant, path: str) -> List[IpBan]: + """Load list of banned IPs from config file.""" + ip_list: List[IpBan] = [] + + try: + 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)) + 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"])) + except vol.Invalid as err: + _LOGGER.error("Failed to load IP ban %s: %s", ip_info, err) + continue + + return ip_list + + +def update_ip_bans_config(path: str, ip_ban: IpBan) -> 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") + out.write(dump(ip_)) diff --git a/homeassistant/components/http/const.py b/homeassistant/components/http/const.py new file mode 100644 index 0000000000000..9392e790d6299 --- /dev/null +++ b/homeassistant/components/http/const.py @@ -0,0 +1,5 @@ +"""HTTP specific constants.""" +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 new file mode 100644 index 0000000000000..2d99a049e4bbd --- /dev/null +++ b/homeassistant/components/http/cors.py @@ -0,0 +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 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, + 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_added = set() + + def _allow_cors(route, config=None): + """Allow CORS on a route.""" + 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="*" + ) + }, + ) + + if not origins: + return + + async def cors_startup(app): + """Initialize CORS when app starts up.""" + 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 new file mode 100644 index 0000000000000..51b3b5617e49c --- /dev/null +++ b/homeassistant/components/http/data_validator.py @@ -0,0 +1,53 @@ +"""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__) + + +class RequestDataValidator: + """Decorator that will validate the incoming data. + + Takes in a voluptuous schema and adds 'post_data' as + keyword argument to the function call. + + Will return a 400 if no JSON provided or doesn't match schema. + """ + + def __init__(self, schema, allow_empty=False): + """Initialize the decorator.""" + 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.""" + data = None + try: + data = await request.json() + except ValueError: + if not self._allow_empty or (await request.content.read()) != b"": + _LOGGER.error("Invalid JSON received.") + return view.json_message("Invalid JSON.", 400) + data = {} + + try: + kwargs["data"] = self._schema(data) + except vol.Invalid as err: + _LOGGER.error("Data does not match schema: %s", err) + return view.json_message(f"Message format incorrect: {err}", 400) + + result = await method(view, request, *args, **kwargs) + return result + + return wrapper diff --git a/homeassistant/components/http/manifest.json b/homeassistant/components/http/manifest.json new file mode 100644 index 0000000000000..6f8328b33fb26 --- /dev/null +++ b/homeassistant/components/http/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "http", + "name": "HTTP", + "documentation": "https://www.home-assistant.io/integrations/http", + "requirements": ["aiohttp_cors==0.7.0"], + "dependencies": [], + "codeowners": ["@home-assistant/core"], + "quality_scale": "internal" +} diff --git a/homeassistant/components/http/real_ip.py b/homeassistant/components/http/real_ip.py new file mode 100644 index 0000000000000..f2334ce0a2ff8 --- /dev/null +++ b/homeassistant/components/http/real_ip.py @@ -0,0 +1,41 @@ +"""Middleware to fetch real IP.""" +from ipaddress import ip_address + +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]) + 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 + ) + ): + request[KEY_REAL_IP] = ip_address( + request.headers.get(X_FORWARDED_FOR).split(", ")[-1] + ) + except ValueError: + pass + + return await handler(request) + + app.middlewares.append(real_ip_middleware) diff --git a/homeassistant/components/http/static.py b/homeassistant/components/http/static.py new file mode 100644 index 0000000000000..a5fe686a651bd --- /dev/null +++ b/homeassistant/components/http/static.py @@ -0,0 +1,48 @@ +"""Static file handling for HTTP component.""" +from pathlib import Path + +from aiohttp import hdrs +from aiohttp.web import FileResponse +from aiohttp.web_exceptions import HTTPForbidden, HTTPNotFound +from aiohttp.web_urldispatcher import StaticResource + +# 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): + rel_url = request.match_info["filename"] + try: + 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) + except (ValueError, FileNotFoundError) as error: + # relatively safe + raise HTTPNotFound() from error + except Exception as error: + # perm error or other kind! + 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 FileResponse( + filepath, + chunk_size=self._chunk_size, + # type ignore: https://github.com/aio-libs/aiohttp/pull/3976 + headers=CACHE_HEADERS, # type: ignore + ) + raise HTTPNotFound diff --git a/homeassistant/components/http/view.py b/homeassistant/components/http/view.py new file mode 100644 index 0000000000000..e60091684d3cd --- /dev/null +++ b/homeassistant/components/http/view.py @@ -0,0 +1,153 @@ +"""Support for views.""" +import asyncio +import json +import logging +from typing import List, Optional + +from aiohttp import web +from aiohttp.web_exceptions import ( + HTTPBadRequest, + HTTPInternalServerError, + HTTPUnauthorized, +) +import voluptuous as vol + +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_HASS, KEY_REAL_IP + +_LOGGER = logging.getLogger(__name__) + + +# mypy: allow-untyped-defs, no-check-untyped-defs + + +class HomeAssistantView: + """Base view for all views.""" + + url: Optional[str] = None + extra_urls: List[str] = [] + # Views inheriting from this class can override this + requires_auth = True + cors_allowed = False + + @staticmethod + def context(request): + """Generate a context from a request.""" + user = request.get("hass_user") + if user is None: + return Context() + + return Context(user_id=user.id) + + @staticmethod + def json(result, status_code=200, headers=None): + """Return a JSON response.""" + try: + msg = json.dumps( + 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, + ) + response.enable_compression() + return response + + def json_message(self, message, status_code=200, message_code=None, headers=None): + """Return a JSON message response.""" + data = {"message": message} + if message_code is not None: + data["code"] = message_code + return self.json(data, status_code, headers=headers) + + def register(self, app, router): + """Register the view with a router.""" + 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", "patch", "head", "options"): + handler = getattr(self, method, None) + + if not handler: + continue + + handler = request_handler_factory(self, handler) + + for url in urls: + routes.append(router.add_route(method, url, handler)) + + if not self.cors_allowed: + return + + for route in routes: + 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." + + async def handle(request): + """Handle incoming request.""" + if not request.app[KEY_HASS].is_running: + return web.Response(status=503) + + authenticated = request.get(KEY_AUTHENTICATED, False) + + if view.requires_auth and not authenticated: + raise HTTPUnauthorized() + + _LOGGER.debug( + "Serving %s to %s (auth: %s)", + request.path, + request.get(KEY_REAL_IP), + authenticated, + ) + + try: + result = handler(request, **request.match_info) + + 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 + return result + + status_code = 200 + + if isinstance(result, tuple): + result, status_code = result + + if isinstance(result, str): + result = result.encode("utf-8") + elif result is None: + result = b"" + elif not isinstance(result, bytes): + assert ( + False + ), "Result should be None, string, bytes or Response. Got: {}".format( + result + ) + + return web.Response(body=result, status=status_code) + + return handle diff --git a/homeassistant/components/htu21d/__init__.py b/homeassistant/components/htu21d/__init__.py new file mode 100644 index 0000000000000..c36c8bfcffbde --- /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 0000000000000..2b36c4b66fb92 --- /dev/null +++ b/homeassistant/components/htu21d/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "htu21d", + "name": "HTU21D(F) Sensor", + "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 0000000000000..954ba60abbf75 --- /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/.translations/bg.json b/homeassistant/components/huawei_lte/.translations/bg.json new file mode 100644 index 0000000000000..44746468b351a --- /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 0000000000000..594c2e3b16de2 --- /dev/null +++ b/homeassistant/components/huawei_lte/.translations/ca.json @@ -0,0 +1,43 @@ +{ + "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": { + "name": "Nom del servei de notificacions", + "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 0000000000000..8d7ac01c55acf --- /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/da.json b/homeassistant/components/huawei_lte/.translations/da.json new file mode 100644 index 0000000000000..19bc69b77fd75 --- /dev/null +++ b/homeassistant/components/huawei_lte/.translations/da.json @@ -0,0 +1,43 @@ +{ + "config": { + "abort": { + "already_configured": "Denne enhed er allerede konfigureret", + "already_in_progress": "Denne enhed er allerede ved at blive konfigureret", + "not_huawei_lte": "Ikke en Huawei LTE-enhed" + }, + "error": { + "connection_failed": "Forbindelsen mislykkedes", + "connection_timeout": "Timeout for forbindelse", + "incorrect_password": "Forkert adgangskode", + "incorrect_username": "Forkert brugernavn", + "incorrect_username_or_password": "Forkert brugernavn eller adgangskode", + "invalid_url": "Ugyldig webadresse", + "login_attempts_exceeded": "Maksimale loginfors\u00f8g overskredet. Pr\u00f8v igen senere", + "response_error": "Ukendt fejl fra enheden", + "unknown_connection_error": "Ukendt fejl ved tilslutning til enheden" + }, + "step": { + "user": { + "data": { + "password": "Adgangskode", + "url": "Webadresse", + "username": "Brugernavn" + }, + "description": "Indtast oplysninger om enhedsadgang. Det er valgfrit at specificere brugernavn og adgangskode, men muligg\u00f8r underst\u00f8ttelse af flere integrationsfunktioner. P\u00e5 den anden side kan brug af en autoriseret forbindelse for\u00e5rsage problemer med at f\u00e5 adgang til enhedens webgr\u00e6nseflade uden for Home Assistant, mens integrationen er aktiv, og omvendt.", + "title": "Konfigurer Huawei LTE" + } + }, + "title": "Huawei LTE" + }, + "options": { + "step": { + "init": { + "data": { + "name": "Navn p\u00e5 meddelelsestjeneste (\u00e6ndring kr\u00e6ver genstart)", + "recipient": "Modtagere af SMS-meddelelse", + "track_new_devices": "Spor nye enheder" + } + } + } + } +} \ 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 0000000000000..ddf6ad55eaa04 --- /dev/null +++ b/homeassistant/components/huawei_lte/.translations/de.json @@ -0,0 +1,43 @@ +{ + "config": { + "abort": { + "already_configured": "Dieses Ger\u00e4t wurde bereits konfiguriert", + "already_in_progress": "Dieses Ger\u00e4t wurde bereits konfiguriert", + "not_huawei_lte": "Kein Huawei LTE-Ger\u00e4t" + }, + "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", + "login_attempts_exceeded": "Maximale Anzahl von Anmeldeversuchen \u00fcberschritten. Bitte versuchen Sie es sp\u00e4ter erneut", + "response_error": "Unbekannter Fehler vom Ger\u00e4t", + "unknown_connection_error": "Unbekannter Fehler beim Herstellen der Verbindung zum Ger\u00e4t" + }, + "step": { + "user": { + "data": { + "password": "Passwort", + "url": "URL", + "username": "Benutzername" + }, + "description": "Geben Sie die Zugangsdaten zum Ger\u00e4t ein. Die Angabe von Benutzername und Passwort ist optional, erm\u00f6glicht aber die Unterst\u00fctzung weiterer Integrationsfunktionen. Andererseits kann die Verwendung einer autorisierten Verbindung zu Problemen beim Zugriff auf die Web-Schnittstelle des Ger\u00e4ts von au\u00dferhalb des Home Assistant f\u00fchren, w\u00e4hrend die Integration aktiv ist, und umgekehrt.", + "title": "Konfigurieren Sie Huawei LTE" + } + }, + "title": "Huawei LTE" + }, + "options": { + "step": { + "init": { + "data": { + "name": "Name des Benachrichtigungsdienstes (\u00c4nderung erfordert Neustart)", + "recipient": "SMS-Benachrichtigungsempf\u00e4nger", + "track_new_devices": "Neue Ger\u00e4te verfolgen" + } + } + } + } +} \ 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 0000000000000..c5f2b4a2a02e6 --- /dev/null +++ b/homeassistant/components/huawei_lte/.translations/en.json @@ -0,0 +1,43 @@ +{ + "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": { + "name": "Notification service name (change requires restart)", + "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 0000000000000..c35d1eacf2339 --- /dev/null +++ b/homeassistant/components/huawei_lte/.translations/es.json @@ -0,0 +1,43 @@ +{ + "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": { + "name": "Nombre del servicio de notificaci\u00f3n", + "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 0000000000000..9f6ae9a09bf5b --- /dev/null +++ b/homeassistant/components/huawei_lte/.translations/fr.json @@ -0,0 +1,43 @@ +{ + "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": { + "name": "Nom du service de notification (red\u00e9marrage requis)", + "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 0000000000000..4ad17ecaa36c2 --- /dev/null +++ b/homeassistant/components/huawei_lte/.translations/it.json @@ -0,0 +1,43 @@ +{ + "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": { + "name": "Nome del servizio di notifica (la modifica richiede il riavvio)", + "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 0000000000000..f6b3d8556793a --- /dev/null +++ b/homeassistant/components/huawei_lte/.translations/ko.json @@ -0,0 +1,43 @@ +{ + "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": { + "name": "\uc54c\ub9bc \uc11c\ube44\uc2a4 \uc774\ub984 (\ubcc0\uacbd \uc2dc \ub2e4\uc2dc \uc2dc\uc791\ud574\uc57c \ud568)", + "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 0000000000000..56d383edba32b --- /dev/null +++ b/homeassistant/components/huawei_lte/.translations/lb.json @@ -0,0 +1,43 @@ +{ + "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": { + "name": "Numm vum Notifikatioun's Service", + "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 0000000000000..297ec922abf11 --- /dev/null +++ b/homeassistant/components/huawei_lte/.translations/nl.json @@ -0,0 +1,43 @@ +{ + "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": { + "name": "Naam meldingsservice (wijziging vereist opnieuw opstarten)", + "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 0000000000000..1a5c63f10f863 --- /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 0000000000000..39cb5bf87fe05 --- /dev/null +++ b/homeassistant/components/huawei_lte/.translations/no.json @@ -0,0 +1,43 @@ +{ + "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": { + "name": "Navn p\u00e5 varslingstjeneste (endring krever omstart)", + "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 0000000000000..a4e7d72852a31 --- /dev/null +++ b/homeassistant/components/huawei_lte/.translations/pl.json @@ -0,0 +1,43 @@ +{ + "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": { + "name": "Nazwa us\u0142ugi powiadomie\u0144 (zmiana wymaga ponownego uruchomienia)", + "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 0000000000000..6e3a06ac662c2 --- /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 0000000000000..3850b86167a95 --- /dev/null +++ b/homeassistant/components/huawei_lte/.translations/ru.json @@ -0,0 +1,43 @@ +{ + "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": { + "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 \u0441\u043b\u0443\u0436\u0431\u044b \u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u0439 (\u043f\u043e\u0442\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u043f\u0435\u0440\u0435\u0437\u0430\u043f\u0443\u0441\u043a)", + "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 0000000000000..c2d7e0bd98351 --- /dev/null +++ b/homeassistant/components/huawei_lte/.translations/sl.json @@ -0,0 +1,43 @@ +{ + "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": { + "name": "Ime storitve obve\u0161\u010danja (sprememba zahteva ponovni zagon)", + "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 0000000000000..201e9afec4bb6 --- /dev/null +++ b/homeassistant/components/huawei_lte/.translations/zh-Hant.json @@ -0,0 +1,43 @@ +{ + "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": { + "name": "\u901a\u77e5\u670d\u52d9\u540d\u7a31\uff08\u8b8a\u66f4\u5f8c\u9700\u91cd\u555f\uff09", + "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 0000000000000..97a57405ae07b --- /dev/null +++ b/homeassistant/components/huawei_lte/__init__.py @@ -0,0 +1,585 @@ +"""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_NAME, + 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 ( + ADMIN_SERVICES, + ALL_KEYS, + CONNECTION_TIMEOUT, + DEFAULT_DEVICE_NAME, + DEFAULT_NOTIFY_SERVICE_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, + SERVICE_RESUME_INTEGRATION, + SERVICE_SUSPEND_INTEGRATION, + 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_NAME): cv.string, + 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 + suspended = attr.ib(init=False, default=False) + + 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.""" + + if self.suspended: + _LOGGER.debug("Integration suspended, not updating data") + return + + 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 logout(self) -> None: + """Log out router session.""" + 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) + + def cleanup(self, *_) -> None: + """Clean up resources.""" + + self.subscriptions.clear() + + for handler in self.unload_handlers: + handler() + self.unload_handlers.clear() + + self.logout() + + +@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 + yaml_notify_name = yaml_config.get(NOTIFY_DOMAIN, {}).get(CONF_NAME) + if ( + yaml_notify_name is not None + and yaml_notify_name != config_entry.options.get(f"{CONF_NAME}_from_yaml") + ): + new_options[f"{CONF_NAME}_from_yaml"] = yaml_notify_name + new_options[CONF_NAME] = yaml_notify_name + # 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_NAME: config_entry.options.get(CONF_NAME, DEFAULT_NOTIFY_SERVICE_NAME), + 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 not routers: + _LOGGER.error("%s: no routers configured", service.service) + return + 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: + if router.suspended: + _LOGGER.debug("%s: ignored, integration suspended", service.service) + return + result = router.client.monitoring.set_clear_traffic() + _LOGGER.debug("%s: %s", service.service, result) + elif service.service == SERVICE_REBOOT: + if router.suspended: + _LOGGER.debug("%s: ignored, integration suspended", service.service) + return + result = router.client.device.reboot() + _LOGGER.debug("%s: %s", service.service, result) + elif service.service == SERVICE_RESUME_INTEGRATION: + # Login will be handled automatically on demand + router.suspended = False + _LOGGER.debug("%s: %s", service.service, "done") + elif service.service == SERVICE_SUSPEND_INTEGRATION: + router.logout() + router.suspended = True + _LOGGER.debug("%s: %s", service.service, "done") + else: + _LOGGER.error("%s: unsupported service", service.service) + + for service in ADMIN_SERVICES: + 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 0000000000000..104933fe71492 --- /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 0000000000000..0dcdb6636c690 --- /dev/null +++ b/homeassistant/components/huawei_lte/config_flow.py @@ -0,0 +1,269 @@ +"""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_NAME, + 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, DEFAULT_NOTIFY_SERVICE_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: + # Preserve existing options, for example *_from_yaml markers + data = {**self.config_entry.options, **user_input} + return self.async_create_entry(title="", data=data) + + data_schema = vol.Schema( + { + vol.Optional( + CONF_NAME, + default=self.config_entry.options.get( + CONF_NAME, DEFAULT_NOTIFY_SERVICE_NAME + ), + ): str, + 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 0000000000000..c6837fce06c65 --- /dev/null +++ b/homeassistant/components/huawei_lte/const.py @@ -0,0 +1,48 @@ +"""Huawei LTE constants.""" + +DOMAIN = "huawei_lte" + +DEFAULT_DEVICE_NAME = "LTE" +DEFAULT_NOTIFY_SERVICE_NAME = DOMAIN + +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" +SERVICE_RESUME_INTEGRATION = "resume_integration" +SERVICE_SUSPEND_INTEGRATION = "suspend_integration" + +ADMIN_SERVICES = { + SERVICE_CLEAR_TRAFFIC_STATISTICS, + SERVICE_REBOOT, + SERVICE_RESUME_INTEGRATION, + SERVICE_SUSPEND_INTEGRATION, +} + +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 0000000000000..a9c61831fdd02 --- /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 0000000000000..1f5fa69d34142 --- /dev/null +++ b/homeassistant/components/huawei_lte/manifest.json @@ -0,0 +1,20 @@ +{ + "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 0000000000000..5619a5d702c41 --- /dev/null +++ b/homeassistant/components/huawei_lte/notify.py @@ -0,0 +1,59 @@ +"""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 + + if self.router.suspended: + _LOGGER.debug( + "Integration suspended, not sending notification to %s", targets + ) + 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 0000000000000..3b6b75edfba68 --- /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 0000000000000..bcb9be33299cc --- /dev/null +++ b/homeassistant/components/huawei_lte/services.yaml @@ -0,0 +1,30 @@ +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/ + +resume_integration: + description: Resume suspended integration. + fields: + url: + description: URL of router to resume integration for; optional when only one is configured. + example: http://192.168.100.1/ + +suspend_integration: + description: > + Suspend integration. Suspending logs the integration out from the router, and stops accessing it. + Useful e.g. if accessing the router web interface from another source such as a web browser is temporarily required. + Invoke the resume_integration service to resume. + fields: + url: + description: URL of router to resume integration for; 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 0000000000000..c5f2b4a2a02e6 --- /dev/null +++ b/homeassistant/components/huawei_lte/strings.json @@ -0,0 +1,43 @@ +{ + "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": { + "name": "Notification service name (change requires restart)", + "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 0000000000000..44d2da0c898e4 --- /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 0000000000000..861809992c687 --- /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 0000000000000..be34b26be0d4b --- /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