diff --git a/.coveragerc b/.coveragerc index 967c560198c6b4..397db5394d6bbf 100644 --- a/.coveragerc +++ b/.coveragerc @@ -13,6 +13,10 @@ omit = homeassistant/components/abode/* 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/airvisual/sensor.py @@ -153,6 +157,7 @@ omit = homeassistant/components/eight_sleep/* homeassistant/components/eliqonline/sensor.py homeassistant/components/elkm1/* + homeassistant/components/elv/switch.py homeassistant/components/emby/media_player.py homeassistant/components/emoncms/sensor.py homeassistant/components/emoncms_history/* @@ -161,6 +166,7 @@ omit = 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 @@ -223,6 +229,7 @@ omit = 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/googlehome/* @@ -312,6 +319,7 @@ omit = 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 @@ -444,6 +452,7 @@ omit = homeassistant/components/ping/device_tracker.py homeassistant/components/pioneer/media_player.py homeassistant/components/pjlink/media_player.py + homeassistant/components/plaato/* homeassistant/components/plex/media_player.py homeassistant/components/plex/sensor.py homeassistant/components/plum_lightpad/* @@ -544,6 +553,7 @@ omit = homeassistant/components/slack/notify.py 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 @@ -551,7 +561,9 @@ omit = homeassistant/components/sochain/sensor.py homeassistant/components/socialblade/sensor.py homeassistant/components/solaredge/sensor.py + homeassistant/components/solaredge_local/sensor.py homeassistant/components/solax/sensor.py + homeassistant/components/somfy/* homeassistant/components/somfy_mylink/* homeassistant/components/sonarr/sensor.py homeassistant/components/songpal/media_player.py @@ -567,6 +579,7 @@ omit = homeassistant/components/starlingbank/sensor.py homeassistant/components/steam_online/sensor.py homeassistant/components/stiebel_eltron/* + homeassistant/components/streamlabswater/* homeassistant/components/stride/notify.py homeassistant/components/supervisord/sensor.py homeassistant/components/swiss_hydrological_data/sensor.py @@ -620,6 +633,7 @@ omit = 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 @@ -651,6 +665,7 @@ omit = homeassistant/components/viaggiatreno/sensor.py homeassistant/components/vizio/media_player.py homeassistant/components/vlc/media_player.py + homeassistant/components/vlc_telnet/media_player.py homeassistant/components/volkszaehler/sensor.py homeassistant/components/volumio/media_player.py homeassistant/components/volvooncall/* diff --git a/.gitignore b/.gitignore index 7a0cb29bc2b26c..397a584c28eab9 100644 --- a/.gitignore +++ b/.gitignore @@ -95,6 +95,7 @@ virtualization/vagrant/config # Visual Studio Code .vscode +.devcontainer # Built docs docs/build diff --git a/CODEOWNERS b/CODEOWNERS index 636c1f5587e7fd..86e731264ec1e8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -17,6 +17,7 @@ virtualization/Docker/* @home-assistant/docker homeassistant/scripts/check_config.py @kellerza # Integrations +homeassistant/components/adguard/* @frenck homeassistant/components/airvisual/* @bachya homeassistant/components/alarm_control_panel/* @colinodell homeassistant/components/alpha_vantage/* @fabaff @@ -24,12 +25,14 @@ homeassistant/components/amazon_polly/* @robbiet480 homeassistant/components/ambiclimate/* @danielhiversen homeassistant/components/ambient_station/* @bachya homeassistant/components/api/* @home-assistant/core +homeassistant/components/aprs/* @PhilRW homeassistant/components/arduino/* @fabaff homeassistant/components/arest/* @fabaff homeassistant/components/asuswrt/* @kennedyshead homeassistant/components/auth/* @home-assistant/core homeassistant/components/automatic/* @armills homeassistant/components/automation/* @home-assistant/core +homeassistant/components/awair/* @danielsjf homeassistant/components/aws/* @awarecan @robbiet480 homeassistant/components/axis/* @kane610 homeassistant/components/azure_event_hub/* @eavanvalkenburg @@ -41,6 +44,7 @@ homeassistant/components/braviatv/* @robbiet480 homeassistant/components/broadlink/* @danielhiversen homeassistant/components/brunt/* @eavanvalkenburg homeassistant/components/bt_smarthub/* @jxwolstenholme +homeassistant/components/buienradar/* @ties homeassistant/components/cisco_ios/* @fbradyirl homeassistant/components/cisco_mobility_express/* @fbradyirl homeassistant/components/cisco_webex_teams/* @fbradyirl @@ -59,6 +63,7 @@ homeassistant/components/daikin/* @fredrike @rofrantz homeassistant/components/darksky/* @fabaff homeassistant/components/deconz/* @kane610 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 @@ -67,9 +72,11 @@ homeassistant/components/ecovacs/* @OverloadUT homeassistant/components/edp_redy/* @abmantis homeassistant/components/egardia/* @jeroenterheerdt homeassistant/components/eight_sleep/* @mezz64 +homeassistant/components/elv/* @majuss homeassistant/components/emby/* @mezz64 homeassistant/components/enigma2/* @fbradyirl homeassistant/components/enocean/* @bdurrer +homeassistant/components/environment_canada/* @michaeldavie homeassistant/components/ephember/* @ttroy50 homeassistant/components/epsonworkforce/* @ThaStealth homeassistant/components/eq3btsmart/* @rytilahti @@ -90,6 +97,7 @@ homeassistant/components/geniushub/* @zxdavb homeassistant/components/gitter/* @fabaff homeassistant/components/glances/* @fabaff homeassistant/components/gntp/* @robbiet480 +homeassistant/components/google_cloud/* @lufton homeassistant/components/google_translate/* @awarecan homeassistant/components/google_travel_time/* @robbiet480 homeassistant/components/googlehome/* @ludeeus @@ -108,6 +116,7 @@ homeassistant/components/homeassistant/* @home-assistant/core homeassistant/components/homekit/* @cdce8p homeassistant/components/homekit_controller/* @Jc2k homeassistant/components/homematic/* @pvizeli @danielperna84 +homeassistant/components/honeywell/* @zxdavb homeassistant/components/html5/* @robbiet480 homeassistant/components/http/* @home-assistant/core homeassistant/components/huawei_lte/* @scop @@ -133,9 +142,11 @@ homeassistant/components/konnected/* @heythisisnate homeassistant/components/lametric/* @robbiet480 homeassistant/components/launch_library/* @ludeeus homeassistant/components/lcn/* @alengwenus +homeassistant/components/life360/* @pnbruckner homeassistant/components/lifx/* @amelchio homeassistant/components/lifx_cloud/* @amelchio homeassistant/components/lifx_legacy/* @amelchio +homeassistant/components/linky/* @tiste @Quentame homeassistant/components/linux_battery/* @fabaff homeassistant/components/liveboxplaytv/* @pschmitt homeassistant/components/logger/* @home-assistant/core @@ -149,6 +160,7 @@ 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 @@ -181,12 +193,14 @@ homeassistant/components/panel_iframe/* @home-assistant/frontend homeassistant/components/persistent_notification/* @home-assistant/core homeassistant/components/philips_js/* @elupus homeassistant/components/pi_hole/* @fabaff +homeassistant/components/plaato/* @JohNan homeassistant/components/plant/* @ChristianKuehnel homeassistant/components/point/* @fredrike 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 @@ -201,6 +215,7 @@ homeassistant/components/ruter/* @ludeeus homeassistant/components/scene/* @home-assistant/core homeassistant/components/scrape/* @fabaff homeassistant/components/script/* @home-assistant/core +homeassistant/components/sense/* @kbickar homeassistant/components/sensibo/* @andrey-git homeassistant/components/serial/* @fabaff homeassistant/components/seventeentrack/* @bachya @@ -211,8 +226,11 @@ homeassistant/components/simplisafe/* @bachya 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 homeassistant/components/solax/* @squishykid +homeassistant/components/somfy/* @tetienne homeassistant/components/sonos/* @amelchio homeassistant/components/spaceapi/* @fabaff homeassistant/components/spider/* @peternijssen @@ -248,7 +266,6 @@ homeassistant/components/tradfri/* @ggravlingen homeassistant/components/tts/* @robbiet480 homeassistant/components/twilio_call/* @robbiet480 homeassistant/components/twilio_sms/* @robbiet480 -homeassistant/components/uber/* @robbiet480 homeassistant/components/unifi/* @kane610 homeassistant/components/upcloud/* @scop homeassistant/components/updater/* @home-assistant/core @@ -258,6 +275,7 @@ homeassistant/components/utility_meter/* @dgomes homeassistant/components/velux/* @Julius2342 homeassistant/components/version/* @fabaff homeassistant/components/vizio/* @raman325 +homeassistant/components/vlc_telnet/* @rodripf homeassistant/components/waqi/* @andrey-git homeassistant/components/watson_tts/* @rutkai homeassistant/components/weather/* @fabaff @@ -275,6 +293,7 @@ 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 diff --git a/azure-pipelines-ci.yml b/azure-pipelines-ci.yml new file mode 100644 index 00000000000000..4464050f91934e --- /dev/null +++ b/azure-pipelines-ci.yml @@ -0,0 +1,150 @@ +# https://dev.azure.com/home-assistant + +trigger: + batch: true + branches: + include: + - dev +pr: none + +resources: + containers: + - container: 35 + image: homeassistant/ci-azure:3.5 + - container: 36 + image: homeassistant/ci-azure:3.6 + - container: 37 + image: homeassistant/ci-azure:3.7 + + +variables: + - name: ArtifactFeed + value: '2df3ae11-3bf6-49bc-a809-ba0d340d6a6d' + - name: PythonMain + value: '35' + + +jobs: + +- job: 'Lint' + pool: + vmImage: 'ubuntu-latest' + container: $[ variables['PythonMain'] ] + steps: + - script: | + python -m venv lint + + . lint/bin/activate + pip install flake8 + flake8 homeassistant tests script + displayName: 'Run flake8' + + +- job: 'Check' + dependsOn: + - Lint + pool: + vmImage: 'ubuntu-latest' + strategy: + maxParallel: 1 + matrix: + Python35: + python.version: '3.5' + python.container: '35' + Python36: + python.version: '3.6' + python.container: '36' + Python37: + python.version: '3.7' + python.container: '37' + container: $[ variables['python.container'] ] + steps: + - script: | + echo "$(python.version)" > .cache + displayName: 'Set python $(python.version) for requirement cache' + + - task: 1ESLighthouseEng.PipelineArtifactCaching.RestoreCacheV1.RestoreCache@1 + displayName: 'Restore artifacts based on Requirements' + inputs: + keyfile: 'requirements_test_all.txt, .cache' + targetfolder: './venv' + vstsFeed: '$(ArtifactFeed)' + + - script: | + set -e + python -m venv venv + + . venv/bin/activate + pip install -U pip setuptools + pip install -r requirements_test_all.txt -c homeassistant/package_constraints.txt + displayName: 'Create Virtual Environment & Install Requirements' + condition: and(succeeded(), ne(variables['CacheRestored'], 'true')) + + - task: 1ESLighthouseEng.PipelineArtifactCaching.SaveCacheV1.SaveCache@1 + displayName: 'Save artifacts based on Requirements' + inputs: + keyfile: 'requirements_test_all.txt, .cache' + targetfolder: './venv' + vstsFeed: '$(ArtifactFeed)' + + - script: | + . venv/bin/activate + pip install -e . + displayName: 'Install Home Assistant for python $(python.version)' + + - script: | + . venv/bin/activate + pytest --timeout=9 --durations=10 --junitxml=junit/test-results.xml -qq -o console_output_style=count -p no:sugar tests + displayName: 'Run pytest for python $(python.version)' + + - task: PublishTestResults@2 + condition: succeededOrFailed() + inputs: + testResultsFiles: '**/test-*.xml' + testRunTitle: 'Publish test results for Python $(python.version)' + +- job: 'FullCheck' + dependsOn: + - Check + pool: + vmImage: 'ubuntu-latest' + container: $[ variables['PythonMain'] ] + steps: + - script: | + echo "$(PythonMain)" > .cache + displayName: 'Set python $(python.version) for requirement cache' + - task: 1ESLighthouseEng.PipelineArtifactCaching.RestoreCacheV1.RestoreCache@1 + displayName: 'Restore artifacts based on Requirements' + inputs: + keyfile: 'requirements_all.txt, requirements_test.txt, .cache' + targetfolder: './venv' + vstsFeed: '$(ArtifactFeed)' + + - script: | + set -e + python -m venv venv + + . venv/bin/activate + pip install -U pip setuptools + pip install -r requirements_all.txt -c homeassistant/package_constraints.txt + pip install -r requirements_test.txt -c homeassistant/package_constraints.txt + displayName: 'Create Virtual Environment & Install Requirements' + condition: and(succeeded(), ne(variables['CacheRestored'], 'true')) + + - task: 1ESLighthouseEng.PipelineArtifactCaching.SaveCacheV1.SaveCache@1 + displayName: 'Save artifacts based on Requirements' + inputs: + keyfile: 'requirements_all.txt, requirements_test.txt, .cache' + targetfolder: './venv' + vstsFeed: '$(ArtifactFeed)' + + - script: | + . venv/bin/activate + pip install -e . + displayName: 'Install Home Assistant for python $(python.version)' + + - script: | + . venv/bin/activate + pylint homeassistant + displayName: 'Run pylint' + diff --git a/azure-pipelines-release.yml b/azure-pipelines-release.yml index 8f250f16ce3456..d6395dad5aca3f 100644 --- a/azure-pipelines-release.yml +++ b/azure-pipelines-release.yml @@ -8,7 +8,7 @@ trigger: pr: none variables: - name: versionBuilder - value: '3.2' + value: '4.2' - group: docker - group: github - group: twine diff --git a/azure-pipelines-wheels.yml b/azure-pipelines-wheels.yml new file mode 100644 index 00000000000000..c49c7ee0358d44 --- /dev/null +++ b/azure-pipelines-wheels.yml @@ -0,0 +1,100 @@ +# https://dev.azure.com/home-assistant + +trigger: + batch: true + branches: + include: + - dev + paths: + include: + - requirements_all.txt +pr: none +variables: + - name: versionWheels + value: '0.7' + - group: wheels + + +jobs: + +- job: 'Wheels' + condition: or(eq(variables['Build.SourceBranchName'], 'dev'), eq(variables['Build.SourceBranchName'], 'master')) + timeoutInMinutes: 360 + pool: + vmImage: 'ubuntu-latest' + strategy: + maxParallel: 3 + matrix: + amd64: + buildArch: 'amd64' + i386: + buildArch: 'i386' + armhf: + buildArch: 'armhf' + armv7: + buildArch: 'armv7' + aarch64: + buildArch: 'aarch64' + steps: + - script: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + qemu-user-static \ + binfmt-support \ + curl + + sudo mount binfmt_misc -t binfmt_misc /proc/sys/fs/binfmt_misc + sudo update-binfmts --enable qemu-arm + sudo update-binfmts --enable qemu-aarch64 + displayName: 'Initial cross build' + - script: | + mkdir -p .ssh + echo -e "-----BEGIN RSA PRIVATE KEY-----\n$(wheelsSSH)\n-----END RSA PRIVATE KEY-----" >> .ssh/id_rsa + ssh-keyscan -H $(wheelsHost) >> .ssh/known_hosts + chmod 600 .ssh/* + displayName: 'Install ssh key' + - script: sudo docker pull homeassistant/$(buildArch)-wheels:$(versionWheels) + displayName: 'Install wheels builder' + - 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|# pytradfri|pytradfri|g" ${requirement_file} + 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|# PySwitchbot|PySwitchbot|g" ${requirement_file} + sed -i "s|# pySwitchmate|pySwitchmate|g" ${requirement_file} + sed -i "s|# face_recognition|face_recognition|g" ${requirement_file} + done + displayName: 'Prepare requirements files for Hass.io' + - script: | + sudo docker run --rm -v $(pwd):/data:ro -v $(pwd)/.ssh:/root/.ssh:rw \ + homeassistant/$(buildArch)-wheels:$(versionWheels) \ + --apk "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;linux-headers;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev" \ + --index $(wheelsIndex) \ + --requirement requirements_wheels.txt \ + --requirement-diff requirements_diff.txt \ + --upload rsync \ + --remote wheels@$(wheelsHost):/opt/wheels + displayName: 'Run wheels build' diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 96417c54b127b5..79e5ec248ae128 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -17,7 +17,6 @@ from homeassistant.util.package import async_get_user_site, is_virtual_env from homeassistant.util.yaml import clear_secret_cache from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -101,51 +100,6 @@ async def async_from_config_dict(config: Dict[str, Any], "upgrade Python.", "Python version", "python_version" ) - # TEMP: warn users for invalid slugs - # Remove after 0.94 or 1.0 - if cv.INVALID_SLUGS_FOUND or cv.INVALID_ENTITY_IDS_FOUND: - msg = [] - - if cv.INVALID_ENTITY_IDS_FOUND: - msg.append( - "Your configuration contains invalid entity ID references. " - "Please find and update the following. " - "This will become a breaking change." - ) - msg.append('\n'.join('- {} -> {}'.format(*item) - for item - in cv.INVALID_ENTITY_IDS_FOUND.items())) - - if cv.INVALID_SLUGS_FOUND: - msg.append( - "Your configuration contains invalid slugs. " - "Please find and update the following. " - "This will become a breaking change." - ) - msg.append('\n'.join('- {} -> {}'.format(*item) - for item in cv.INVALID_SLUGS_FOUND.items())) - - hass.components.persistent_notification.async_create( - '\n\n'.join(msg), "Config Warning", "config_warning" - ) - - # TEMP: warn users of invalid extra keys - # Remove after 0.92 - if cv.INVALID_EXTRA_KEYS_FOUND: - msg = [] - msg.append( - "Your configuration contains extra keys " - "that the platform does not support (but were silently " - "accepted before 0.88). Please find and remove the following." - "This will become a breaking change." - ) - msg.append('\n'.join('- {}'.format(it) - for it in cv.INVALID_EXTRA_KEYS_FOUND)) - - hass.components.persistent_notification.async_create( - '\n\n'.join(msg), "Config Warning", "config_warning" - ) - return hass diff --git a/homeassistant/components/adguard/.translations/ca.json b/homeassistant/components/adguard/.translations/ca.json new file mode 100644 index 00000000000000..1966002ea136f5 --- /dev/null +++ b/homeassistant/components/adguard/.translations/ca.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "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/de.json b/homeassistant/components/adguard/.translations/de.json new file mode 100644 index 00000000000000..c72293c6afb18b --- /dev/null +++ b/homeassistant/components/adguard/.translations/de.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "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." + } + } + } +} \ 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 00000000000000..d5f5e9ff78c6cf --- /dev/null +++ b/homeassistant/components/adguard/.translations/en.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "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 00000000000000..c3d57832cf4c3e --- /dev/null +++ b/homeassistant/components/adguard/.translations/es-419.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "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": "AdGuard Home" + } +} \ 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 00000000000000..6cd8767334dc9f --- /dev/null +++ b/homeassistant/components/adguard/.translations/it.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u00c8 consentita solo una singola configurazione di AdGuard Home." + }, + "error": { + "connection_error": "Impossibile connettersi." + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Password", + "port": "Porta", + "ssl": "AdGuard Home utilizza un certificato SSL", + "username": "Nome utente" + } + } + } + } +} \ 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 00000000000000..fe58b5d74d5125 --- /dev/null +++ b/homeassistant/components/adguard/.translations/ko.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "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\ud569\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 00000000000000..71a8488a93ac94 --- /dev/null +++ b/homeassistant/components/adguard/.translations/lb.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "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 00000000000000..e0e61c045255d4 --- /dev/null +++ b/homeassistant/components/adguard/.translations/nl.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "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/no.json b/homeassistant/components/adguard/.translations/no.json new file mode 100644 index 00000000000000..0e18537dcf8b2d --- /dev/null +++ b/homeassistant/components/adguard/.translations/no.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Kun \u00e9n enkelt 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 00000000000000..8ba1c18f72262d --- /dev/null +++ b/homeassistant/components/adguard/.translations/pl.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Dozwolona jest tylko jedna konfiguracja AdGuard Home." + }, + "error": { + "connection_error": "Po\u0142\u0105czenie nieudane." + }, + "step": { + "hassio_confirm": { + "description": "Czy chcesz skonfigurowa\u0107 Home Assistant'a, 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 swoj\u0105 instancj\u0119 AdGuard Home, aby umo\u017cliwi\u0107 monitorowanie i nadz\u00f3r sieci.", + "title": "Po\u0142\u0105cz sw\u00f3j 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 00000000000000..a6115800787503 --- /dev/null +++ b/homeassistant/components/adguard/.translations/pt-BR.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "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/ru.json b/homeassistant/components/adguard/.translations/ru.json new file mode 100644 index 00000000000000..cddced8018de75 --- /dev/null +++ b/homeassistant/components/adguard/.translations/ru.json @@ -0,0 +1,29 @@ +{ + "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": "\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 00000000000000..c098f382bfd4c5 --- /dev/null +++ b/homeassistant/components/adguard/.translations/sl.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "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 00000000000000..b4bd7f7481b6f4 --- /dev/null +++ b/homeassistant/components/adguard/.translations/sv.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "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 00000000000000..1b76fef567192d --- /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-Hant.json b/homeassistant/components/adguard/.translations/zh-Hant.json new file mode 100644 index 00000000000000..b97d50aa0b6ec6 --- /dev/null +++ b/homeassistant/components/adguard/.translations/zh-Hant.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "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 00000000000000..15b8b9978f6db9 --- /dev/null +++ b/homeassistant/components/adguard/__init__.py @@ -0,0 +1,180 @@ +"""Support for AdGuard Home.""" +import logging +from typing import Any, Dict + +from adguardhome import AdGuardHome, AdGuardHomeError +import voluptuous as vol + +from homeassistant.components.adguard.const import ( + CONF_FORCE, DATA_ADGUARD_CLIENT, DATA_ADGUARD_VERION, DOMAIN, + 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.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import ConfigType, HomeAssistantType + +_LOGGER = logging.getLogger(__name__) + +SERVICE_URL_SCHEMA = vol.Schema({vol.Required(CONF_URL): cv.url}) +SERVICE_ADD_URL_SCHEMA = vol.Schema( + {vol.Required(CONF_NAME): cv.string, vol.Required(CONF_URL): cv.url} +) +SERVICE_REFRESH_SCHEMA = vol.Schema( + {vol.Optional(CONF_FORCE, default=False): cv.boolean} +) + + +async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: + """Set up the AdGuard Home components.""" + return True + + +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry +) -> bool: + """Set up AdGuard Home from a config entry.""" + session = async_get_clientsession(hass, entry.data[CONF_VERIFY_SSL]) + adguard = AdGuardHome( + entry.data[CONF_HOST], + port=entry.data[CONF_PORT], + username=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + tls=entry.data[CONF_SSL], + verify_ssl=entry.data[CONF_VERIFY_SSL], + loop=hass.loop, + session=session, + ) + + hass.data.setdefault(DOMAIN, {})[DATA_ADGUARD_CLIENT] = adguard + + 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 00000000000000..9ef789f83a8f4b --- /dev/null +++ b/homeassistant/components/adguard/config_flow.py @@ -0,0 +1,168 @@ +"""Config flow to configure the AdGuard Home integration.""" +import logging + +from adguardhome import AdGuardHome, AdGuardHomeConnectionError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.adguard.const import DOMAIN +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import ( + CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_SSL, CONF_USERNAME, + CONF_VERIFY_SSL) +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +_LOGGER = logging.getLogger(__name__) + + +@config_entries.HANDLERS.register(DOMAIN) +class AdGuardHomeFlowHandler(ConfigFlow): + """Handle a AdGuard Home config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + _hassio_discovery = None + + def __init__(self): + """Initialize AgGuard Home flow.""" + pass + + async def _show_setup_form(self, errors=None): + """Show the setup form to the user.""" + return self.async_show_form( + step_id='user', + data_schema=vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=3000): vol.Coerce(int), + vol.Optional(CONF_USERNAME): str, + vol.Optional(CONF_PASSWORD): str, + vol.Required(CONF_SSL, default=True): bool, + vol.Required(CONF_VERIFY_SSL, default=True): bool, + } + ), + errors=errors or {}, + ) + + async def _show_hassio_form(self, errors=None): + """Show the Hass.io confirmation form to the user.""" + return self.async_show_form( + step_id='hassio_confirm', + description_placeholders={ + 'addon': self._hassio_discovery['addon'] + }, + data_schema=vol.Schema({}), + errors=errors or {}, + ) + + async def async_step_user(self, user_input=None): + """Handle a flow initiated by the user.""" + if self._async_current_entries(): + return self.async_abort(reason='single_instance_allowed') + + if user_input is None: + return await self._show_setup_form(user_input) + + errors = {} + + session = async_get_clientsession( + self.hass, user_input[CONF_VERIFY_SSL] + ) + + adguard = AdGuardHome( + user_input[CONF_HOST], + port=user_input[CONF_PORT], + username=user_input.get(CONF_USERNAME), + password=user_input.get(CONF_PASSWORD), + tls=user_input[CONF_SSL], + verify_ssl=user_input[CONF_VERIFY_SSL], + loop=self.hass.loop, + session=session, + ) + + try: + await adguard.version() + except AdGuardHomeConnectionError: + errors['base'] = 'connection_error' + return await self._show_setup_form(errors) + + return self.async_create_entry( + title=user_input[CONF_HOST], + data={ + CONF_HOST: user_input[CONF_HOST], + CONF_PASSWORD: user_input.get(CONF_PASSWORD), + CONF_PORT: user_input[CONF_PORT], + CONF_SSL: user_input[CONF_SSL], + CONF_USERNAME: user_input.get(CONF_USERNAME), + CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL], + }, + ) + + async def async_step_hassio(self, user_input=None): + """Prepare configuration for a Hass.io AdGuard Home add-on. + + This flow is triggered by the discovery component. + """ + entries = self._async_current_entries() + + if not entries: + self._hassio_discovery = user_input + return await self.async_step_hassio_confirm() + + cur_entry = entries[0] + + if (cur_entry.data[CONF_HOST] == user_input[CONF_HOST] and + cur_entry.data[CONF_PORT] == user_input[CONF_PORT]): + return self.async_abort(reason='single_instance_allowed') + + is_loaded = cur_entry.state == config_entries.ENTRY_STATE_LOADED + + if is_loaded: + await self.hass.config_entries.async_unload(cur_entry.entry_id) + + self.hass.config_entries.async_update_entry(cur_entry, data={ + **cur_entry.data, + CONF_HOST: user_input[CONF_HOST], + CONF_PORT: user_input[CONF_PORT], + }) + + if is_loaded: + await self.hass.config_entries.async_setup(cur_entry.entry_id) + + return self.async_abort(reason='existing_instance_updated') + + async def async_step_hassio_confirm(self, user_input=None): + """Confirm Hass.io discovery.""" + if user_input is None: + return await self._show_hassio_form() + + errors = {} + + session = async_get_clientsession(self.hass, False) + + adguard = AdGuardHome( + self._hassio_discovery[CONF_HOST], + port=self._hassio_discovery[CONF_PORT], + tls=False, + loop=self.hass.loop, + session=session, + ) + + try: + await adguard.version() + except AdGuardHomeConnectionError: + errors['base'] = 'connection_error' + return await self._show_hassio_form(errors) + + 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 00000000000000..6bbabdafaf17c5 --- /dev/null +++ b/homeassistant/components/adguard/const.py @@ -0,0 +1,14 @@ +"""Constants for the AdGuard Home integration.""" + +DOMAIN = 'adguard' + +DATA_ADGUARD_CLIENT = 'adguard_client' +DATA_ADGUARD_VERION = 'adguard_version' + +CONF_FORCE = 'force' + +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 00000000000000..0063f1ec37064c --- /dev/null +++ b/homeassistant/components/adguard/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "adguard", + "name": "AdGuard Home", + "config_flow": true, + "documentation": "https://www.home-assistant.io/components/adguard", + "requirements": [ + "adguardhome==0.2.1" + ], + "dependencies": [], + "codeowners": [ + "@frenck" + ] +} \ No newline at end of file diff --git a/homeassistant/components/adguard/sensor.py b/homeassistant/components/adguard/sensor.py new file mode 100644 index 00000000000000..abb5309b449b8d --- /dev/null +++ b/homeassistant/components/adguard/sensor.py @@ -0,0 +1,232 @@ +"""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 = "{:.2f}".format(percentage) + + +class AdGuardHomeReplacedParentalSensor(AdGuardHomeSensor): + """Defines a AdGuard Home replaced by parental control sensor.""" + + def __init__(self, adguard): + """Initialize AdGuard Home sensor.""" + super().__init__( + adguard, + 'AdGuard Parental Control Blocked', + 'mdi:human-male-girl', + 'blocked_parental', + 'requests', + ) + + async def _adguard_update(self) -> None: + """Update AdGuard Home entity.""" + self._state = await self.adguard.stats.replaced_parental() + + +class AdGuardHomeReplacedSafeBrowsingSensor(AdGuardHomeSensor): + """Defines a AdGuard Home replaced by safe browsing sensor.""" + + def __init__(self, adguard): + """Initialize AdGuard Home sensor.""" + super().__init__( + adguard, + 'AdGuard Safe Browsing Blocked', + 'mdi:shield-half-full', + 'blocked_safebrowsing', + 'requests', + ) + + async def _adguard_update(self) -> None: + """Update AdGuard Home entity.""" + self._state = await self.adguard.stats.replaced_safebrowsing() + + +class AdGuardHomeReplacedSafeSearchSensor(AdGuardHomeSensor): + """Defines a AdGuard Home replaced by safe search sensor.""" + + def __init__(self, adguard): + """Initialize AdGuard Home sensor.""" + super().__init__( + adguard, + 'Searches Safe Search Enforced', + 'mdi:shield-search', + 'enforced_safesearch', + 'requests', + ) + + async def _adguard_update(self) -> None: + """Update AdGuard Home entity.""" + self._state = await self.adguard.stats.replaced_safesearch() + + +class AdGuardHomeAverageProcessingTimeSensor(AdGuardHomeSensor): + """Defines a AdGuard Home average processing time sensor.""" + + def __init__(self, adguard): + """Initialize AdGuard Home sensor.""" + super().__init__( + adguard, + 'AdGuard Average Processing Speed', + 'mdi:speedometer', + 'average_speed', + 'ms', + ) + + async def _adguard_update(self) -> None: + """Update AdGuard Home entity.""" + average = await self.adguard.stats.avg_processing_time() + self._state = "{:.2f}".format(average) + + +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 00000000000000..736acdd923c853 --- /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 00000000000000..b3966bca8206a3 --- /dev/null +++ b/homeassistant/components/adguard/strings.json @@ -0,0 +1,30 @@ +{ + "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": { + "single_instance_allowed": "Only a single configuration of AdGuard Home is allowed.", + "existing_instance_updated": "Updated existing configuration." + } + } +} diff --git a/homeassistant/components/adguard/switch.py b/homeassistant/components/adguard/switch.py new file mode 100644 index 00000000000000..601bf25b5b06e4 --- /dev/null +++ b/homeassistant/components/adguard/switch.py @@ -0,0 +1,233 @@ +"""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/aftership/sensor.py b/homeassistant/components/aftership/sensor.py index fae3e38c96b3a3..630b0d400c8f42 100644 --- a/homeassistant/components/aftership/sensor.py +++ b/homeassistant/components/aftership/sensor.py @@ -177,6 +177,11 @@ async def async_update(self, **kwargs): if track['title'] is None else track['title'] ) + last_checkpoint = ( + "Shipment pending" + if track['tag'] == "Pending" + else track['checkpoints'][-1] + ) status_counts[status] = status_counts.get(status, 0) + 1 trackings.append({ 'name': name, @@ -187,7 +192,7 @@ async def async_update(self, **kwargs): 'last_update': track['updated_at'], 'expected_delivery': track['expected_delivery'], 'status': track['tag'], - 'last_checkpoint': track['checkpoints'][-1] + 'last_checkpoint': last_checkpoint }) if status not in status_to_ignore: diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py index 36a68eda174b32..47f92bfe641856 100644 --- a/homeassistant/components/alarm_control_panel/__init__.py +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -19,6 +19,7 @@ ATTR_CHANGED_BY = 'changed_by' FORMAT_TEXT = 'text' FORMAT_NUMBER = 'number' +ATTR_CODE_ARM_REQUIRED = 'code_arm_required' ENTITY_ID_FORMAT = DOMAIN + '.{}' @@ -87,6 +88,11 @@ 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() @@ -159,6 +165,7 @@ def state_attributes(self): """Return the state attributes.""" state_attr = { ATTR_CODE_FORMAT: self.code_format, - ATTR_CHANGED_BY: self.changed_by + ATTR_CHANGED_BY: self.changed_by, + ATTR_CODE_ARM_REQUIRED: self.code_arm_required } return state_attr diff --git a/homeassistant/components/alexa/__init__.py b/homeassistant/components/alexa/__init__.py index 862605b64b5700..a15d87175dbcc8 100644 --- a/homeassistant/components/alexa/__init__.py +++ b/homeassistant/components/alexa/__init__.py @@ -5,12 +5,13 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers import entityfilter +from homeassistant.const import CONF_NAME -from . import flash_briefings, intent, smart_home +from . import flash_briefings, intent, smart_home_http from .const import ( CONF_AUDIO, CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_DISPLAY_URL, CONF_ENDPOINT, CONF_TEXT, CONF_TITLE, CONF_UID, DOMAIN, CONF_FILTER, - CONF_ENTITY_CONFIG) + CONF_ENTITY_CONFIG, CONF_DESCRIPTION, CONF_DISPLAY_CATEGORIES) _LOGGER = logging.getLogger(__name__) @@ -18,9 +19,9 @@ CONF_SMART_HOME = 'smart_home' ALEXA_ENTITY_SCHEMA = vol.Schema({ - vol.Optional(smart_home.CONF_DESCRIPTION): cv.string, - vol.Optional(smart_home.CONF_DISPLAY_CATEGORIES): cv.string, - vol.Optional(smart_home.CONF_NAME): cv.string, + vol.Optional(CONF_DESCRIPTION): cv.string, + vol.Optional(CONF_DISPLAY_CATEGORIES): cv.string, + vol.Optional(CONF_NAME): cv.string, }) SMART_HOME_SCHEMA = vol.Schema({ @@ -65,6 +66,6 @@ async def async_setup(hass, config): pass else: smart_home_config = smart_home_config or SMART_HOME_SCHEMA({}) - await smart_home.async_setup(hass, smart_home_config) + await smart_home_http.async_setup(hass, smart_home_config) return True diff --git a/homeassistant/components/alexa/auth.py b/homeassistant/components/alexa/auth.py index 0717532f64d633..dd61018d739d1b 100644 --- a/homeassistant/components/alexa/auth.py +++ b/homeassistant/components/alexa/auth.py @@ -9,7 +9,6 @@ from homeassistant.core import callback from homeassistant.helpers import aiohttp_client from homeassistant.util import dt -from .const import DEFAULT_TIMEOUT _LOGGER = logging.getLogger(__name__) @@ -97,7 +96,7 @@ async def _async_request_new_token(self, lwa_params): try: session = aiohttp_client.async_get_clientsession(self.hass) - with async_timeout.timeout(DEFAULT_TIMEOUT): + with async_timeout.timeout(10): response = await session.post(LWA_TOKEN_URI, headers=LWA_HEADERS, data=lwa_params, diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py new file mode 100644 index 00000000000000..801005b4b4a74f --- /dev/null +++ b/homeassistant/components/alexa/capabilities.py @@ -0,0 +1,597 @@ +"""Alexa capabilities.""" +from datetime import datetime +import logging + +from homeassistant.const import ( + ATTR_SUPPORTED_FEATURES, + ATTR_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT, + STATE_LOCKED, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + STATE_UNLOCKED, +) +import homeassistant.components.climate.const as climate +from homeassistant.components import ( + light, + fan, + cover, +) +import homeassistant.util.color as color_util + +from .const import ( + API_TEMP_UNITS, + API_THERMOSTAT_MODES, + DATE_FORMAT, + PERCENTAGE_FAN_MAP, +) +from .errors import UnsupportedProperty + + +_LOGGER = logging.getLogger(__name__) + + +class AlexaCapibility: + """Base class for Alexa capability interfaces. + + The Smart Home Skills API defines a number of "capability interfaces", + roughly analogous to domains in Home Assistant. The supported interfaces + describe what actions can be performed on a particular device. + + https://developer.amazon.com/docs/device-apis/message-guide.html + """ + + def __init__(self, entity): + """Initialize an Alexa capibility.""" + self.entity = entity + + def name(self): + """Return the Alexa API name of this interface.""" + raise NotImplementedError + + @staticmethod + def properties_supported(): + """Return what properties this entity supports.""" + return [] + + @staticmethod + def properties_proactively_reported(): + """Return True if properties asynchronously reported.""" + return False + + @staticmethod + def properties_retrievable(): + """Return True if properties can be retrieved.""" + return False + + @staticmethod + def get_property(name): + """Read and return a property. + + Return value should be a dict, or raise UnsupportedProperty. + + Properties can also have a timeOfSample and uncertaintyInMilliseconds, + but returning those metadata is not yet implemented. + """ + raise UnsupportedProperty(name) + + @staticmethod + def supports_deactivation(): + """Applicable only to scenes.""" + return None + + def serialize_discovery(self): + """Serialize according to the Discovery API.""" + result = { + 'type': 'AlexaInterface', + 'interface': self.name(), + 'version': '3', + 'properties': { + 'supported': self.properties_supported(), + 'proactivelyReported': self.properties_proactively_reported(), + 'retrievable': self.properties_retrievable(), + }, + } + + # pylint: disable=assignment-from-none + supports_deactivation = self.supports_deactivation() + if supports_deactivation is not None: + result['supportsDeactivation'] = supports_deactivation + return result + + def serialize_properties(self): + """Return properties serialized for an API response.""" + for prop in self.properties_supported(): + prop_name = prop['name'] + # pylint: disable=assignment-from-no-return + prop_value = self.get_property(prop_name) + if prop_value is not None: + yield { + 'name': prop_name, + 'namespace': self.name(), + 'value': prop_value, + 'timeOfSample': datetime.now().strftime(DATE_FORMAT), + 'uncertaintyInMilliseconds': 0 + } + + +class AlexaEndpointHealth(AlexaCapibility): + """Implements Alexa.EndpointHealth. + + https://developer.amazon.com/docs/smarthome/state-reporting-for-a-smart-home-skill.html#report-state-when-alexa-requests-it + """ + + def __init__(self, hass, entity): + """Initialize the entity.""" + super().__init__(entity) + self.hass = hass + + def name(self): + """Return the Alexa API name of this interface.""" + return 'Alexa.EndpointHealth' + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{'name': 'connectivity'}] + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return False + + 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(AlexaCapibility): + """Implements Alexa.PowerController. + + https://developer.amazon.com/docs/device-apis/alexa-powercontroller.html + """ + + def name(self): + """Return the Alexa API name of this interface.""" + return 'Alexa.PowerController' + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{'name': 'powerState'}] + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != 'powerState': + raise UnsupportedProperty(name) + + if self.entity.state == STATE_OFF: + return 'OFF' + return 'ON' + + +class AlexaLockController(AlexaCapibility): + """Implements Alexa.LockController. + + https://developer.amazon.com/docs/device-apis/alexa-lockcontroller.html + """ + + def name(self): + """Return the Alexa API name of this interface.""" + return 'Alexa.LockController' + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{'name': 'lockState'}] + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != 'lockState': + raise UnsupportedProperty(name) + + if self.entity.state == STATE_LOCKED: + return 'LOCKED' + if self.entity.state == STATE_UNLOCKED: + return 'UNLOCKED' + return 'JAMMED' + + +class AlexaSceneController(AlexaCapibility): + """Implements Alexa.SceneController. + + https://developer.amazon.com/docs/device-apis/alexa-scenecontroller.html + """ + + def __init__(self, entity, supports_deactivation): + """Initialize the entity.""" + super().__init__(entity) + self.supports_deactivation = lambda: supports_deactivation + + def name(self): + """Return the Alexa API name of this interface.""" + return 'Alexa.SceneController' + + +class AlexaBrightnessController(AlexaCapibility): + """Implements Alexa.BrightnessController. + + https://developer.amazon.com/docs/device-apis/alexa-brightnesscontroller.html + """ + + def name(self): + """Return the Alexa API name of this interface.""" + return 'Alexa.BrightnessController' + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{'name': 'brightness'}] + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != 'brightness': + raise UnsupportedProperty(name) + if 'brightness' in self.entity.attributes: + return round(self.entity.attributes['brightness'] / 255.0 * 100) + return 0 + + +class AlexaColorController(AlexaCapibility): + """Implements Alexa.ColorController. + + https://developer.amazon.com/docs/device-apis/alexa-colorcontroller.html + """ + + def name(self): + """Return the Alexa API name of this interface.""" + return 'Alexa.ColorController' + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{'name': 'color'}] + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != 'color': + raise UnsupportedProperty(name) + + hue, saturation = self.entity.attributes.get( + light.ATTR_HS_COLOR, (0, 0)) + + return { + 'hue': hue, + 'saturation': saturation / 100.0, + 'brightness': self.entity.attributes.get( + light.ATTR_BRIGHTNESS, 0) / 255.0, + } + + +class AlexaColorTemperatureController(AlexaCapibility): + """Implements Alexa.ColorTemperatureController. + + https://developer.amazon.com/docs/device-apis/alexa-colortemperaturecontroller.html + """ + + def name(self): + """Return the Alexa API name of this interface.""" + return 'Alexa.ColorTemperatureController' + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{'name': 'colorTemperatureInKelvin'}] + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != 'colorTemperatureInKelvin': + raise UnsupportedProperty(name) + if 'color_temp' in self.entity.attributes: + return color_util.color_temperature_mired_to_kelvin( + self.entity.attributes['color_temp']) + return 0 + + +class AlexaPercentageController(AlexaCapibility): + """Implements Alexa.PercentageController. + + https://developer.amazon.com/docs/device-apis/alexa-percentagecontroller.html + """ + + def name(self): + """Return the Alexa API name of this interface.""" + return 'Alexa.PercentageController' + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{'name': 'percentage'}] + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != 'percentage': + raise UnsupportedProperty(name) + + if self.entity.domain == fan.DOMAIN: + speed = self.entity.attributes.get(fan.ATTR_SPEED) + + return PERCENTAGE_FAN_MAP.get(speed, 0) + + if self.entity.domain == cover.DOMAIN: + return self.entity.attributes.get(cover.ATTR_CURRENT_POSITION, 0) + + return 0 + + +class AlexaSpeaker(AlexaCapibility): + """Implements Alexa.Speaker. + + https://developer.amazon.com/docs/device-apis/alexa-speaker.html + """ + + def name(self): + """Return the Alexa API name of this interface.""" + return 'Alexa.Speaker' + + +class AlexaStepSpeaker(AlexaCapibility): + """Implements Alexa.StepSpeaker. + + https://developer.amazon.com/docs/device-apis/alexa-stepspeaker.html + """ + + def name(self): + """Return the Alexa API name of this interface.""" + return 'Alexa.StepSpeaker' + + +class AlexaPlaybackController(AlexaCapibility): + """Implements Alexa.PlaybackController. + + https://developer.amazon.com/docs/device-apis/alexa-playbackcontroller.html + """ + + def name(self): + """Return the Alexa API name of this interface.""" + return 'Alexa.PlaybackController' + + +class AlexaInputController(AlexaCapibility): + """Implements Alexa.InputController. + + https://developer.amazon.com/docs/device-apis/alexa-inputcontroller.html + """ + + def name(self): + """Return the Alexa API name of this interface.""" + return 'Alexa.InputController' + + +class AlexaTemperatureSensor(AlexaCapibility): + """Implements Alexa.TemperatureSensor. + + https://developer.amazon.com/docs/device-apis/alexa-temperaturesensor.html + """ + + def __init__(self, hass, entity): + """Initialize the entity.""" + super().__init__(entity) + self.hass = hass + + def name(self): + """Return the Alexa API name of this interface.""" + return 'Alexa.TemperatureSensor' + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{'name': 'temperature'}] + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != 'temperature': + raise UnsupportedProperty(name) + + unit = self.entity.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + temp = self.entity.state + if self.entity.domain == climate.DOMAIN: + unit = self.hass.config.units.temperature_unit + temp = self.entity.attributes.get( + climate.ATTR_CURRENT_TEMPERATURE) + return { + 'value': float(temp), + 'scale': API_TEMP_UNITS[unit], + } + + +class AlexaContactSensor(AlexaCapibility): + """Implements Alexa.ContactSensor. + + The Alexa.ContactSensor interface describes the properties and events used + to report the state of an endpoint that detects contact between two + surfaces. For example, a contact sensor can report whether a door or window + is open. + + https://developer.amazon.com/docs/device-apis/alexa-contactsensor.html + """ + + def __init__(self, hass, entity): + """Initialize the entity.""" + super().__init__(entity) + self.hass = hass + + def name(self): + """Return the Alexa API name of this interface.""" + return 'Alexa.ContactSensor' + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{'name': 'detectionState'}] + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != 'detectionState': + raise UnsupportedProperty(name) + + if self.entity.state == STATE_ON: + return 'DETECTED' + return 'NOT_DETECTED' + + +class AlexaMotionSensor(AlexaCapibility): + """Implements Alexa.MotionSensor. + + https://developer.amazon.com/docs/device-apis/alexa-motionsensor.html + """ + + def __init__(self, hass, entity): + """Initialize the entity.""" + super().__init__(entity) + self.hass = hass + + def name(self): + """Return the Alexa API name of this interface.""" + return 'Alexa.MotionSensor' + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{'name': 'detectionState'}] + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != 'detectionState': + raise UnsupportedProperty(name) + + if self.entity.state == STATE_ON: + return 'DETECTED' + return 'NOT_DETECTED' + + +class AlexaThermostatController(AlexaCapibility): + """Implements Alexa.ThermostatController. + + https://developer.amazon.com/docs/device-apis/alexa-thermostatcontroller.html + """ + + def __init__(self, hass, entity): + """Initialize the entity.""" + super().__init__(entity) + self.hass = hass + + def name(self): + """Return the Alexa API name of this interface.""" + return 'Alexa.ThermostatController' + + def properties_supported(self): + """Return what properties this entity supports.""" + properties = [] + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported & climate.SUPPORT_TARGET_TEMPERATURE: + properties.append({'name': 'targetSetpoint'}) + if supported & climate.SUPPORT_TARGET_TEMPERATURE_LOW: + properties.append({'name': 'lowerSetpoint'}) + if supported & climate.SUPPORT_TARGET_TEMPERATURE_HIGH: + properties.append({'name': 'upperSetpoint'}) + if supported & climate.SUPPORT_OPERATION_MODE: + properties.append({'name': 'thermostatMode'}) + return properties + + def properties_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 == 'thermostatMode': + ha_mode = self.entity.attributes.get(climate.ATTR_OPERATION_MODE) + mode = API_THERMOSTAT_MODES.get(ha_mode) + if mode is None: + _LOGGER.error("%s (%s) has unsupported %s value '%s'", + self.entity.entity_id, type(self.entity), + climate.ATTR_OPERATION_MODE, ha_mode) + raise UnsupportedProperty(name) + return mode + + unit = self.hass.config.units.temperature_unit + if name == 'targetSetpoint': + temp = self.entity.attributes.get(ATTR_TEMPERATURE) + elif name == 'lowerSetpoint': + temp = self.entity.attributes.get(climate.ATTR_TARGET_TEMP_LOW) + elif name == 'upperSetpoint': + temp = self.entity.attributes.get(climate.ATTR_TARGET_TEMP_HIGH) + else: + raise UnsupportedProperty(name) + + if temp is None: + return None + + return { + 'value': float(temp), + 'scale': API_TEMP_UNITS[unit], + } diff --git a/homeassistant/components/alexa/config.py b/homeassistant/components/alexa/config.py new file mode 100644 index 00000000000000..a22ebbcd30d431 --- /dev/null +++ b/homeassistant/components/alexa/config.py @@ -0,0 +1,69 @@ +"""Config helpers for Alexa.""" +from .state_report import async_enable_proactive_mode + + +class AbstractConfig: + """Hold the configuration for Alexa.""" + + _unsub_proactive_report = None + + def __init__(self, hass): + """Initialize abstract config.""" + self.hass = hass + + @property + def supports_auth(self): + """Return if config supports auth.""" + return False + + @property + def should_report_state(self): + """Return if states should be proactively reported.""" + return False + + @property + def endpoint(self): + """Endpoint for report state.""" + return None + + @property + def entity_config(self): + """Return entity config.""" + return {} + + @property + def is_reporting_states(self): + """Return if proactive mode is enabled.""" + return self._unsub_proactive_report is not None + + async def async_enable_proactive_mode(self): + """Enable proactive mode.""" + if self._unsub_proactive_report is None: + self._unsub_proactive_report = self.hass.async_create_task( + async_enable_proactive_mode(self.hass, self) + ) + try: + await self._unsub_proactive_report + except Exception: # pylint: disable=broad-except + self._unsub_proactive_report = None + raise + + async def async_disable_proactive_mode(self): + """Disable proactive mode.""" + unsub_func = await self._unsub_proactive_report + if unsub_func: + unsub_func() + self._unsub_proactive_report = None + + def should_expose(self, entity_id): + """If an entity should be exposed.""" + # pylint: disable=no-self-use + return False + + async def async_get_access_token(self): + """Get an access token.""" + raise NotImplementedError + + async def async_accept_grant(self, code): + """Accept a grant.""" + raise NotImplementedError diff --git a/homeassistant/components/alexa/const.py b/homeassistant/components/alexa/const.py index 78f7d02f5f03e3..513c4ac43d7151 100644 --- a/homeassistant/components/alexa/const.py +++ b/homeassistant/components/alexa/const.py @@ -1,4 +1,15 @@ """Constants for the Alexa integration.""" +from collections import OrderedDict + +from homeassistant.const import ( + STATE_OFF, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) +from homeassistant.components.climate import const as climate +from homeassistant.components import fan + + DOMAIN = 'alexa' # Flash briefing constants @@ -25,4 +36,73 @@ DATE_FORMAT = '%Y-%m-%dT%H:%M:%S.0Z' -DEFAULT_TIMEOUT = 30 +API_DIRECTIVE = 'directive' +API_ENDPOINT = 'endpoint' +API_EVENT = 'event' +API_CONTEXT = 'context' +API_HEADER = 'header' +API_PAYLOAD = 'payload' +API_SCOPE = 'scope' +API_CHANGE = 'change' + +CONF_DESCRIPTION = 'description' +CONF_DISPLAY_CATEGORIES = 'display_categories' + +API_TEMP_UNITS = { + TEMP_FAHRENHEIT: 'FAHRENHEIT', + TEMP_CELSIUS: 'CELSIUS', +} + +# Needs to be ordered dict for `async_api_set_thermostat_mode` which does a +# reverse mapping of this dict and we want to map the first occurrance of OFF +# back to HA state. +API_THERMOSTAT_MODES = OrderedDict([ + (climate.STATE_HEAT, 'HEAT'), + (climate.STATE_COOL, 'COOL'), + (climate.STATE_AUTO, 'AUTO'), + (climate.STATE_ECO, 'ECO'), + (climate.STATE_MANUAL, 'AUTO'), + (STATE_OFF, 'OFF'), + (climate.STATE_IDLE, 'OFF'), + (climate.STATE_FAN_ONLY, 'OFF'), + (climate.STATE_DRY, 'OFF'), +]) + +PERCENTAGE_FAN_MAP = { + fan.SPEED_LOW: 33, + fan.SPEED_MEDIUM: 66, + fan.SPEED_HIGH: 100, +} + + +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' diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py new file mode 100644 index 00000000000000..65deabadd17893 --- /dev/null +++ b/homeassistant/components/alexa/entities.py @@ -0,0 +1,459 @@ +"""Alexa entity adapters.""" +from typing import List + +from homeassistant.core import callback +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.util.decorator import Registry +from homeassistant.components.climate import const as climate +from homeassistant.components import ( + alert, automation, binary_sensor, cover, fan, group, + input_boolean, light, lock, media_player, scene, script, sensor, switch) + +from .const import CONF_DESCRIPTION, CONF_DISPLAY_CATEGORIES +from .capabilities import ( + AlexaBrightnessController, + AlexaColorController, + AlexaColorTemperatureController, + AlexaContactSensor, + AlexaEndpointHealth, + AlexaInputController, + AlexaLockController, + AlexaMotionSensor, + AlexaPercentageController, + AlexaPlaybackController, + AlexaPowerController, + AlexaSceneController, + AlexaSpeaker, + AlexaStepSpeaker, + AlexaTemperatureSensor, + AlexaThermostatController, +) + +ENTITY_ADAPTERS = Registry() + + +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 an endpoint that detects and reports contact. + CONTACT_SENSOR = "CONTACT_SENSOR" + + # Indicates a door. + DOOR = "DOOR" + + # Indicates light sources or fixtures. + LIGHT = "LIGHT" + + # Indicates an endpoint that detects and reports motion. + MOTION_SENSOR = "MOTION_SENSOR" + + # An endpoint that cannot be described in on of the other categories. + OTHER = "OTHER" + + # Describes a combination of devices set to a specific state, when the + # order of the state change is not important. For example a bedtime scene + # might include turning off lights and lowering the thermostat, but the + # order is unimportant. Applies to Scenes + SCENE_TRIGGER = "SCENE_TRIGGER" + + # Indicates an endpoint that locks. + SMARTLOCK = "SMARTLOCK" + + # Indicates modules that are plugged into an existing electrical outlet. + # Can control a variety of devices. + SMARTPLUG = "SMARTPLUG" + + # Indicates the endpoint is a speaker or speaker system. + SPEAKER = "SPEAKER" + + # Indicates in-wall switches wired to the electrical system. Can control a + # variety of devices. + SWITCH = "SWITCH" + + # Indicates endpoints that report the temperature only. + TEMPERATURE_SENSOR = "TEMPERATURE_SENSOR" + + # Indicates endpoints that control temperature, stand-alone air + # conditioners, or heaters with direct temperature control. + THERMOSTAT = "THERMOSTAT" + + # Indicates the endpoint is a television. + TV = "TV" + + +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) + + def description(self): + """Return the Alexa API description.""" + return self.entity_conf.get(CONF_DESCRIPTION, self.entity.entity_id) + + def alexa_id(self): + """Return the Alexa API entity id.""" + return self.entity.entity_id.replace('.', '#') + + def display_categories(self): + """Return a list of display categories.""" + entity_conf = self.config.entity_config.get(self.entity.entity_id, {}) + if CONF_DISPLAY_CATEGORIES in entity_conf: + return [entity_conf[CONF_DISPLAY_CATEGORIES]] + return self.default_display_categories() + + def default_display_categories(self): + """Return a list of default display categories. + + This can be overridden by the user in the Home Assistant configuration. + + See also DisplayCategory. + """ + raise NotImplementedError + + def get_interface(self, capability): + """Return the given AlexaInterface. + + Raises _UnsupportedInterface. + """ + pass + + def interfaces(self): + """Return a list of supported interfaces. + + Used for discovery. The list should contain AlexaInterface instances. + If the list is empty, this entity will not be discovered. + """ + raise NotImplementedError + + def serialize_properties(self): + """Yield each supported property in API format.""" + for interface in self.interfaces(): + for prop in interface.serialize_properties(): + yield prop + + def serialize_discovery(self): + """Serialize the entity for discovery.""" + return { + 'displayCategories': self.display_categories(), + 'cookie': {}, + 'endpointId': self.alexa_id(), + 'friendlyName': self.friendly_name(), + 'description': self.description(), + 'manufacturerName': 'Home Assistant', + 'capabilities': [ + i.serialize_discovery() for i in self.interfaces() + ] + } + + +@callback +def async_get_entities(hass, config) -> List[AlexaEntity]: + """Return all entities that are supported by Alexa.""" + entities = [] + for state in hass.states.async_all(): + if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + continue + + if state.domain not in ENTITY_ADAPTERS: + continue + + alexa_entity = ENTITY_ADAPTERS[state.domain](hass, config, state) + + if not list(alexa_entity.interfaces()): + continue + + entities.append(alexa_entity) + + return entities + + +@ENTITY_ADAPTERS.register(alert.DOMAIN) +@ENTITY_ADAPTERS.register(automation.DOMAIN) +@ENTITY_ADAPTERS.register(group.DOMAIN) +@ENTITY_ADAPTERS.register(input_boolean.DOMAIN) +class GenericCapabilities(AlexaEntity): + """A generic, on/off device. + + The choice of last resort. + """ + + def default_display_categories(self): + """Return the display categories for this entity.""" + return [DisplayCategory.OTHER] + + def interfaces(self): + """Yield the supported interfaces.""" + return [AlexaPowerController(self.entity), + AlexaEndpointHealth(self.hass, self.entity)] + + +@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.""" + return [DisplayCategory.SWITCH] + + def interfaces(self): + """Yield the supported interfaces.""" + return [AlexaPowerController(self.entity), + AlexaEndpointHealth(self.hass, self.entity)] + + +@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.""" + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported & climate.SUPPORT_ON_OFF: + yield AlexaPowerController(self.entity) + yield AlexaThermostatController(self.hass, self.entity) + yield AlexaTemperatureSensor(self.hass, self.entity) + yield AlexaEndpointHealth(self.hass, self.entity) + + +@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.""" + return [DisplayCategory.DOOR] + + def interfaces(self): + """Yield the supported interfaces.""" + yield AlexaPowerController(self.entity) + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported & cover.SUPPORT_SET_POSITION: + yield AlexaPercentageController(self.entity) + yield AlexaEndpointHealth(self.hass, self.entity) + + +@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) + + +@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.OTHER] + + 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 AlexaEndpointHealth(self.hass, self.entity) + + +@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)] + + +@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.""" + return [DisplayCategory.TV] + + def interfaces(self): + """Yield the supported interfaces.""" + yield AlexaEndpointHealth(self.hass, self.entity) + + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported & media_player.const.SUPPORT_VOLUME_SET: + yield AlexaSpeaker(self.entity) + + power_features = (media_player.SUPPORT_TURN_ON | + media_player.SUPPORT_TURN_OFF) + if supported & power_features: + yield AlexaPowerController(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) + + if supported & media_player.SUPPORT_SELECT_SOURCE: + yield AlexaInputController(self.entity) + + +@ENTITY_ADAPTERS.register(scene.DOMAIN) +class SceneCapabilities(AlexaEntity): + """Class to represent Scene capabilities.""" + + def description(self): + """Return the description of the entity.""" + # Required description as per Amazon Scene docs + scene_fmt = '{} (Scene connected via Home Assistant)' + return scene_fmt.format(AlexaEntity.description(self)) + + def default_display_categories(self): + """Return the display categories for this entity.""" + return [DisplayCategory.SCENE_TRIGGER] + + def interfaces(self): + """Yield the supported interfaces.""" + return [AlexaSceneController(self.entity, + supports_deactivation=False)] + + +@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)] + + +@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) + + +@ENTITY_ADAPTERS.register(binary_sensor.DOMAIN) +class BinarySensorCapabilities(AlexaEntity): + """Class to represent BinarySensor capabilities.""" + + TYPE_CONTACT = 'contact' + TYPE_MOTION = 'motion' + + 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] + + 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) + + yield AlexaEndpointHealth(self.hass, self.entity) + + def get_type(self): + """Return the type of binary sensor.""" + attrs = self.entity.attributes + if attrs.get(ATTR_DEVICE_CLASS) in ( + 'door', + 'garage_door', + 'opening', + 'window', + ): + return self.TYPE_CONTACT + if attrs.get(ATTR_DEVICE_CLASS) == 'motion': + return self.TYPE_MOTION diff --git a/homeassistant/components/alexa/errors.py b/homeassistant/components/alexa/errors.py new file mode 100644 index 00000000000000..76ec92edf8dc86 --- /dev/null +++ b/homeassistant/components/alexa/errors.py @@ -0,0 +1,91 @@ +"""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 = 'The endpoint {} does not exist'.format(endpoint_id) + 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 = 'The requested temperature {} is out of range'.format(temp) + + AlexaError.__init__(self, msg, payload) + + +class AlexaBridgeUnreachableError(AlexaError): + """Class to represent BridgeUnreachable errors.""" + + namespace = 'Alexa' + error_type = 'BRIDGE_UNREACHABLE' diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py new file mode 100644 index 00000000000000..89cf171c83c37d --- /dev/null +++ b/homeassistant/components/alexa/handlers.py @@ -0,0 +1,719 @@ +"""Alexa message handlers.""" +from datetime import datetime +import logging +import math + +from homeassistant import core as ha +from homeassistant.util.decorator import Registry +import homeassistant.util.color as color_util +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_TEMPERATURE, + SERVICE_LOCK, + SERVICE_MEDIA_NEXT_TRACK, + SERVICE_MEDIA_PAUSE, + SERVICE_MEDIA_PLAY, + SERVICE_MEDIA_PREVIOUS_TRACK, + SERVICE_MEDIA_STOP, + SERVICE_SET_COVER_POSITION, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + SERVICE_UNLOCK, + SERVICE_VOLUME_DOWN, + SERVICE_VOLUME_MUTE, + SERVICE_VOLUME_SET, + SERVICE_VOLUME_UP, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) +from homeassistant.components.climate import const as climate +from homeassistant.components import cover, fan, group, light, media_player +from homeassistant.util.temperature import convert as convert_temperature + +from .const import ( + API_TEMP_UNITS, + API_THERMOSTAT_MODES, + Cause, +) +from .entities import async_get_entities +from .state_report import async_enable_proactive_mode +from .errors import ( + AlexaInvalidValueError, + AlexaTempRangeError, + AlexaUnsupportedThermostatModeError, +) + +_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 == cover.DOMAIN: + service = cover.SERVICE_OPEN_COVER + + await hass.services.async_call(domain, service, { + ATTR_ENTITY_ID: entity.entity_id + }, blocking=False, context=context) + + return 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 entity.domain == cover.DOMAIN: + service = cover.SERVICE_CLOSE_COVER + + await hass.services.async_call(domain, service, { + ATTR_ENTITY_ID: entity.entity_id + }, blocking=False, context=context) + + return 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': '%sZ' % (datetime.utcnow().isoformat(),) + } + + 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': '%sZ' % (datetime.utcnow().isoformat(),) + } + + 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 + + elif entity.domain == cover.DOMAIN: + service = SERVICE_SET_COVER_POSITION + data[cover.ATTR_POSITION] = percentage + + await hass.services.async_call( + entity.domain, service, data, blocking=False, context=context) + + return 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) + + if speed == "off": + current = 0 + elif speed == "low": + current = 33 + elif speed == "medium": + current = 66 + elif speed == "high": + current = 100 + + # set percentage + percentage = max(0, percentage_delta + current) + speed = "off" + + if percentage <= 33: + speed = "low" + elif percentage <= 66: + speed = "medium" + elif percentage <= 100: + speed = "high" + + data[fan.ATTR_SPEED] = speed + + elif entity.domain == cover.DOMAIN: + service = SERVICE_SET_COVER_POSITION + + current = entity.attributes.get(cover.ATTR_POSITION) + + data[cover.ATTR_POSITION] = max(0, percentage_delta + current) + + await hass.services.async_call( + entity.domain, service, data, blocking=False, context=context) + + return 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 + + +# Not supported by Alexa yet +@HANDLERS.register(('Alexa.LockController', 'Unlock')) +async def async_api_unlock(hass, config, directive, context): + """Process an unlock request.""" + entity = directive.entity + await hass.services.async_call(entity.domain, SERVICE_UNLOCK, { + ATTR_ENTITY_ID: entity.entity_id + }, blocking=False, context=context) + + return directive.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 + source_list = entity.attributes[ + media_player.const.ATTR_INPUT_SOURCE_LIST] or [] + for source in source_list: + # response will always be space separated, so format the source in the + # most likely way to find a match + formatted_source = source.lower().replace('-', ' ').replace('_', ' ') + if formatted_source in media_input.lower(): + media_input = source + break + else: + msg = 'failed to map input {} to a media source on {}'.format( + media_input, entity.entity_id) + 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. + # For now we use the volumeSteps returned to figure out if we + # should step up/down + volume_step = directive.payload['volumeSteps'] + entity = directive.entity + + data = { + ATTR_ENTITY_ID: entity.entity_id, + } + + if volume_step > 0: + await hass.services.async_call( + entity.domain, SERVICE_VOLUME_UP, + data, blocking=False, context=context) + elif volume_step < 0: + await hass.services.async_call( + entity.domain, SERVICE_VOLUME_DOWN, + 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'] + + operation_list = entity.attributes.get(climate.ATTR_OPERATION_LIST) + ha_mode = next( + (k for k, v in API_THERMOSTAT_MODES.items() if v == mode), + None + ) + if ha_mode not in operation_list: + msg = 'The requested thermostat mode {} is not supported'.format(mode) + raise AlexaUnsupportedThermostatModeError(msg) + + data = { + ATTR_ENTITY_ID: entity.entity_id, + climate.ATTR_OPERATION_MODE: ha_mode, + } + + response = directive.response() + await hass.services.async_call( + entity.domain, climate.SERVICE_SET_OPERATION_MODE, 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') diff --git a/homeassistant/components/alexa/messages.py b/homeassistant/components/alexa/messages.py new file mode 100644 index 00000000000000..c1b0ac9c025b76 --- /dev/null +++ b/homeassistant/components/alexa/messages.py @@ -0,0 +1,200 @@ +"""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 = 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 + + 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) + + 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 theromstat 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/smart_home.py b/homeassistant/components/alexa/smart_home.py index a69a0cf6ec7a62..688828b20bd5ec 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -1,1338 +1,19 @@ """Support for alexa Smart Home Skill API.""" -import asyncio -import json import logging -import math -from collections import OrderedDict -from datetime import datetime -from uuid import uuid4 -import aiohttp -import async_timeout - -import homeassistant.core as ha -import homeassistant.util.color as color_util -from homeassistant.components import ( - alert, automation, binary_sensor, cover, fan, group, http, - input_boolean, light, lock, media_player, scene, script, sensor, switch) -from homeassistant.components.climate import const as climate -from homeassistant.const import ( - ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, - ATTR_TEMPERATURE, ATTR_UNIT_OF_MEASUREMENT, CLOUD_NEVER_EXPOSED_ENTITIES, - CONF_NAME, SERVICE_LOCK, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, - SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_STOP, - SERVICE_SET_COVER_POSITION, SERVICE_TURN_OFF, SERVICE_TURN_ON, - SERVICE_UNLOCK, SERVICE_VOLUME_DOWN, SERVICE_VOLUME_UP, SERVICE_VOLUME_SET, - SERVICE_VOLUME_MUTE, STATE_LOCKED, STATE_ON, STATE_OFF, STATE_UNAVAILABLE, - STATE_UNLOCKED, TEMP_CELSIUS, TEMP_FAHRENHEIT, MATCH_ALL) -from homeassistant.helpers import aiohttp_client -from homeassistant.helpers.event import async_track_state_change -from homeassistant.util.decorator import Registry -from homeassistant.util.temperature import convert as convert_temperature - -from .auth import Auth -from .const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_ENDPOINT, \ - CONF_ENTITY_CONFIG, CONF_FILTER, DATE_FORMAT, DEFAULT_TIMEOUT - -_LOGGER = logging.getLogger(__name__) - -API_DIRECTIVE = 'directive' -API_ENDPOINT = 'endpoint' -API_EVENT = 'event' -API_CONTEXT = 'context' -API_HEADER = 'header' -API_PAYLOAD = 'payload' -API_SCOPE = 'scope' -API_CHANGE = 'change' - -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 occurrance of OFF -# back to HA state. -API_THERMOSTAT_MODES = OrderedDict([ - (climate.STATE_HEAT, 'HEAT'), - (climate.STATE_COOL, 'COOL'), - (climate.STATE_AUTO, 'AUTO'), - (climate.STATE_ECO, 'ECO'), - (climate.STATE_MANUAL, 'AUTO'), - (STATE_OFF, 'OFF'), - (climate.STATE_IDLE, 'OFF'), - (climate.STATE_FAN_ONLY, 'OFF'), - (climate.STATE_DRY, 'OFF'), -]) - -PERCENTAGE_FAN_MAP = { - fan.SPEED_LOW: 33, - fan.SPEED_MEDIUM: 66, - fan.SPEED_HIGH: 100, -} - -SMART_HOME_HTTP_ENDPOINT = '/api/alexa/smart_home' - -CONF_DESCRIPTION = 'description' -CONF_DISPLAY_CATEGORIES = 'display_categories' - -HANDLERS = Registry() -ENTITY_ADAPTERS = Registry() -EVENT_ALEXA_SMART_HOME = 'alexa_smart_home' - -AUTH_KEY = "alexa.smart_home.auth" - - -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 an endpoint that detects and reports contact. - CONTACT_SENSOR = "CONTACT_SENSOR" - - # Indicates a door. - DOOR = "DOOR" - - # Indicates light sources or fixtures. - LIGHT = "LIGHT" - - # Indicates an endpoint that detects and reports motion. - MOTION_SENSOR = "MOTION_SENSOR" - - # An endpoint that cannot be described in on of the other categories. - OTHER = "OTHER" - - # Describes a combination of devices set to a specific state, when the - # order of the state change is not important. For example a bedtime scene - # might include turning off lights and lowering the thermostat, but the - # order is unimportant. Applies to Scenes - SCENE_TRIGGER = "SCENE_TRIGGER" - - # Indicates an endpoint that locks. - SMARTLOCK = "SMARTLOCK" - - # Indicates modules that are plugged into an existing electrical outlet. - # Can control a variety of devices. - SMARTPLUG = "SMARTPLUG" - - # Indicates the endpoint is a speaker or speaker system. - SPEAKER = "SPEAKER" - - # Indicates in-wall switches wired to the electrical system. Can control a - # variety of devices. - SWITCH = "SWITCH" - - # Indicates endpoints that report the temperature only. - TEMPERATURE_SENSOR = "TEMPERATURE_SENSOR" - - # Indicates endpoints that control temperature, stand-alone air - # conditioners, or heaters with direct temperature control. - THERMOSTAT = "THERMOSTAT" - - # Indicates the endpoint is a television. - TV = "TV" - - -def _capability(interface, - version=3, - supports_deactivation=None, - retrievable=None, - properties_supported=None, - cap_type='AlexaInterface'): - """Return a Smart Home API capability object. - - https://developer.amazon.com/docs/device-apis/alexa-discovery.html#capability-object - - There are some additional fields allowed but not implemented here since - we've no use case for them yet: - - - proactively_reported - - `supports_deactivation` applies only to scenes. - """ - result = { - 'type': cap_type, - 'interface': interface, - 'version': version, - } - - if supports_deactivation is not None: - result['supportsDeactivation'] = supports_deactivation - - if retrievable is not None: - result['retrievable'] = retrievable - - if properties_supported is not None: - result['properties'] = {'supported': properties_supported} - - return result - - -class _UnsupportedInterface(Exception): - """This entity does not support the requested Smart Home API interface.""" - - -class _UnsupportedProperty(Exception): - """This entity does not support the requested Smart Home API property.""" - - -class _AlexaError(Exception): - """Base class for errors that can be serialized by 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): - 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): - msg = 'The endpoint {} does not exist'.format(endpoint_id) - _AlexaError.__init__(self, msg) - self.endpoint_id = endpoint_id - - -class _AlexaInvalidValueError(_AlexaError): - namespace = 'Alexa' - error_type = 'INVALID_VALUE' - - -class _AlexaUnsupportedThermostatModeError(_AlexaError): - namespace = 'Alexa.ThermostatController' - error_type = 'UNSUPPORTED_THERMOSTAT_MODE' - - -class _AlexaTempRangeError(_AlexaError): - namespace = 'Alexa' - error_type = 'TEMPERATURE_VALUE_OUT_OF_RANGE' - - def __init__(self, hass, temp, min_temp, max_temp): - 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 = 'The requested temperature {} is out of range'.format(temp) - - _AlexaError.__init__(self, msg, payload) - - -class _AlexaBridgeUnreachableError(_AlexaError): - namespace = 'Alexa' - error_type = 'BRIDGE_UNREACHABLE' - - -class _AlexaEntity: - """An adaptation of an entity, expressed in Alexa's terms. - - The API handlers should manipulate entities only through this interface. - """ - - def __init__(self, hass, config, entity): - self.hass = hass - self.config = config - self.entity = entity - self.entity_conf = config.entity_config.get(entity.entity_id, {}) - - def friendly_name(self): - """Return the Alexa API friendly name.""" - return self.entity_conf.get(CONF_NAME, self.entity.name) - - def description(self): - """Return the Alexa API description.""" - return self.entity_conf.get(CONF_DESCRIPTION, self.entity.entity_id) - - def entity_id(self): - """Return the Alexa API entity id.""" - return self.entity.entity_id.replace('.', '#') - - def display_categories(self): - """Return a list of display categories.""" - entity_conf = self.config.entity_config.get(self.entity.entity_id, {}) - if CONF_DISPLAY_CATEGORIES in entity_conf: - return [entity_conf[CONF_DISPLAY_CATEGORIES]] - return self.default_display_categories() - - def default_display_categories(self): - """Return a list of default display categories. - - This can be overridden by the user in the Home Assistant configuration. - - See also _DisplayCategory. - """ - raise NotImplementedError - - def get_interface(self, capability): - """Return the given _AlexaInterface. - - Raises _UnsupportedInterface. - """ - pass - - def interfaces(self): - """Return a list of supported interfaces. - - Used for discovery. The list should contain _AlexaInterface instances. - If the list is empty, this entity will not be discovered. - """ - raise NotImplementedError - - def serialize_properties(self): - """Yield each supported property in API format.""" - for interface in self.interfaces(): - for prop in interface.serialize_properties(): - yield prop - - -class _AlexaInterface: - """Base class for Alexa capability interfaces. - - The Smart Home Skills API defines a number of "capability interfaces", - roughly analogous to domains in Home Assistant. The supported interfaces - describe what actions can be performed on a particular device. - - https://developer.amazon.com/docs/device-apis/message-guide.html - """ - - def __init__(self, entity): - self.entity = entity - - def name(self): - """Return the Alexa API name of this interface.""" - raise NotImplementedError - - @staticmethod - def properties_supported(): - """Return what properties this entity supports.""" - return [] - - @staticmethod - def properties_proactively_reported(): - """Return True if properties asynchronously reported.""" - return False - - @staticmethod - def properties_retrievable(): - """Return True if properties can be retrieved.""" - return False - - @staticmethod - def get_property(name): - """Read and return a property. - - Return value should be a dict, or raise _UnsupportedProperty. - - Properties can also have a timeOfSample and uncertaintyInMilliseconds, - but returning those metadata is not yet implemented. - """ - raise _UnsupportedProperty(name) - - @staticmethod - def supports_deactivation(): - """Applicable only to scenes.""" - return None - - def serialize_discovery(self): - """Serialize according to the Discovery API.""" - result = { - 'type': 'AlexaInterface', - 'interface': self.name(), - 'version': '3', - 'properties': { - 'supported': self.properties_supported(), - 'proactivelyReported': self.properties_proactively_reported(), - 'retrievable': self.properties_retrievable(), - }, - } - - # pylint: disable=assignment-from-none - supports_deactivation = self.supports_deactivation() - if supports_deactivation is not None: - result['supportsDeactivation'] = supports_deactivation - return result - - def serialize_properties(self): - """Return properties serialized for an API response.""" - for prop in self.properties_supported(): - prop_name = prop['name'] - # pylint: disable=assignment-from-no-return - prop_value = self.get_property(prop_name) - if prop_value is not None: - yield { - 'name': prop_name, - 'namespace': self.name(), - 'value': prop_value, - 'timeOfSample': datetime.now().strftime(DATE_FORMAT), - 'uncertaintyInMilliseconds': 0 - } - - -class _AlexaEndpointHealth(_AlexaInterface): - """Implements Alexa.EndpointHealth. - - https://developer.amazon.com/docs/smarthome/state-reporting-for-a-smart-home-skill.html#report-state-when-alexa-requests-it - """ - - def __init__(self, hass, entity): - super().__init__(entity) - self.hass = hass - - def name(self): - return 'Alexa.EndpointHealth' - - def properties_supported(self): - return [{'name': 'connectivity'}] - - def properties_proactively_reported(self): - return False - - def properties_retrievable(self): - return True - - def get_property(self, name): - if name != 'connectivity': - raise _UnsupportedProperty(name) - - if self.entity.state == STATE_UNAVAILABLE: - return {'value': 'UNREACHABLE'} - return {'value': 'OK'} - - -class _AlexaPowerController(_AlexaInterface): - """Implements Alexa.PowerController. - - https://developer.amazon.com/docs/device-apis/alexa-powercontroller.html - """ - - def name(self): - return 'Alexa.PowerController' - - def properties_supported(self): - return [{'name': 'powerState'}] - - def properties_proactively_reported(self): - return True - - def properties_retrievable(self): - return True - - def get_property(self, name): - if name != 'powerState': - raise _UnsupportedProperty(name) - - if self.entity.state == STATE_OFF: - return 'OFF' - return 'ON' - - -class _AlexaLockController(_AlexaInterface): - """Implements Alexa.LockController. - - https://developer.amazon.com/docs/device-apis/alexa-lockcontroller.html - """ - - def name(self): - return 'Alexa.LockController' - - def properties_supported(self): - return [{'name': 'lockState'}] - - def properties_retrievable(self): - return True - - def properties_proactively_reported(self): - return True - - def get_property(self, name): - if name != 'lockState': - raise _UnsupportedProperty(name) - - if self.entity.state == STATE_LOCKED: - return 'LOCKED' - if self.entity.state == STATE_UNLOCKED: - return 'UNLOCKED' - return 'JAMMED' - - -class _AlexaSceneController(_AlexaInterface): - """Implements Alexa.SceneController. - - https://developer.amazon.com/docs/device-apis/alexa-scenecontroller.html - """ - - def __init__(self, entity, supports_deactivation): - _AlexaInterface.__init__(self, entity) - self.supports_deactivation = lambda: supports_deactivation - - def name(self): - return 'Alexa.SceneController' - - -class _AlexaBrightnessController(_AlexaInterface): - """Implements Alexa.BrightnessController. - - https://developer.amazon.com/docs/device-apis/alexa-brightnesscontroller.html - """ - - def name(self): - return 'Alexa.BrightnessController' - - def properties_supported(self): - return [{'name': 'brightness'}] - - def properties_proactively_reported(self): - return True - - def properties_retrievable(self): - return True - - def get_property(self, name): - if name != 'brightness': - raise _UnsupportedProperty(name) - if 'brightness' in self.entity.attributes: - return round(self.entity.attributes['brightness'] / 255.0 * 100) - return 0 - - -class _AlexaColorController(_AlexaInterface): - """Implements Alexa.ColorController. - - https://developer.amazon.com/docs/device-apis/alexa-colorcontroller.html - """ - - def name(self): - return 'Alexa.ColorController' - - def properties_supported(self): - return [{'name': 'color'}] - - def properties_retrievable(self): - return True - - def get_property(self, name): - 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(_AlexaInterface): - """Implements Alexa.ColorTemperatureController. - - https://developer.amazon.com/docs/device-apis/alexa-colortemperaturecontroller.html - """ - - def name(self): - return 'Alexa.ColorTemperatureController' - - def properties_supported(self): - return [{'name': 'colorTemperatureInKelvin'}] - - def properties_retrievable(self): - return True - - def get_property(self, name): - 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 0 - - -class _AlexaPercentageController(_AlexaInterface): - """Implements Alexa.PercentageController. - - https://developer.amazon.com/docs/device-apis/alexa-percentagecontroller.html - """ - - def name(self): - return 'Alexa.PercentageController' - - def properties_supported(self): - return [{'name': 'percentage'}] - - def properties_retrievable(self): - return True - - def get_property(self, name): - 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(_AlexaInterface): - """Implements Alexa.Speaker. - - https://developer.amazon.com/docs/device-apis/alexa-speaker.html - """ - - def name(self): - return 'Alexa.Speaker' - - -class _AlexaStepSpeaker(_AlexaInterface): - """Implements Alexa.StepSpeaker. - - https://developer.amazon.com/docs/device-apis/alexa-stepspeaker.html - """ - - def name(self): - return 'Alexa.StepSpeaker' - - -class _AlexaPlaybackController(_AlexaInterface): - """Implements Alexa.PlaybackController. - - https://developer.amazon.com/docs/device-apis/alexa-playbackcontroller.html - """ - - def name(self): - return 'Alexa.PlaybackController' - - -class _AlexaInputController(_AlexaInterface): - """Implements Alexa.InputController. - - https://developer.amazon.com/docs/device-apis/alexa-inputcontroller.html - """ - - def name(self): - return 'Alexa.InputController' - - -class _AlexaTemperatureSensor(_AlexaInterface): - """Implements Alexa.TemperatureSensor. - - https://developer.amazon.com/docs/device-apis/alexa-temperaturesensor.html - """ - - def __init__(self, hass, entity): - _AlexaInterface.__init__(self, entity) - self.hass = hass - - def name(self): - return 'Alexa.TemperatureSensor' - - def properties_supported(self): - return [{'name': 'temperature'}] - - def properties_proactively_reported(self): - return True - - def properties_retrievable(self): - return True - - def get_property(self, name): - if name != 'temperature': - raise _UnsupportedProperty(name) - - unit = self.entity.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - temp = self.entity.state - if self.entity.domain == climate.DOMAIN: - unit = self.hass.config.units.temperature_unit - temp = self.entity.attributes.get( - climate.ATTR_CURRENT_TEMPERATURE) - return { - 'value': float(temp), - 'scale': API_TEMP_UNITS[unit], - } - - -class _AlexaContactSensor(_AlexaInterface): - """Implements Alexa.ContactSensor. - - The Alexa.ContactSensor interface describes the properties and events used - to report the state of an endpoint that detects contact between two - surfaces. For example, a contact sensor can report whether a door or window - is open. - - https://developer.amazon.com/docs/device-apis/alexa-contactsensor.html - """ - - def __init__(self, hass, entity): - _AlexaInterface.__init__(self, entity) - self.hass = hass - - def name(self): - return 'Alexa.ContactSensor' - - def properties_supported(self): - return [{'name': 'detectionState'}] - - def properties_proactively_reported(self): - return True - - def properties_retrievable(self): - return True - - def get_property(self, name): - if name != 'detectionState': - raise _UnsupportedProperty(name) - - if self.entity.state == STATE_ON: - return 'DETECTED' - return 'NOT_DETECTED' - - -class _AlexaMotionSensor(_AlexaInterface): - def __init__(self, hass, entity): - _AlexaInterface.__init__(self, entity) - self.hass = hass - - def name(self): - return 'Alexa.MotionSensor' - - def properties_supported(self): - return [{'name': 'detectionState'}] - - def properties_proactively_reported(self): - return True - - def properties_retrievable(self): - return True - - def get_property(self, name): - if name != 'detectionState': - raise _UnsupportedProperty(name) - - if self.entity.state == STATE_ON: - return 'DETECTED' - return 'NOT_DETECTED' - - -class _AlexaThermostatController(_AlexaInterface): - """Implements Alexa.ThermostatController. - - https://developer.amazon.com/docs/device-apis/alexa-thermostatcontroller.html - """ - - def __init__(self, hass, entity): - _AlexaInterface.__init__(self, entity) - self.hass = hass - - def name(self): - return 'Alexa.ThermostatController' - - def properties_supported(self): - properties = [] - supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - if supported & climate.SUPPORT_TARGET_TEMPERATURE: - properties.append({'name': 'targetSetpoint'}) - if supported & climate.SUPPORT_TARGET_TEMPERATURE_LOW: - properties.append({'name': 'lowerSetpoint'}) - if supported & climate.SUPPORT_TARGET_TEMPERATURE_HIGH: - properties.append({'name': 'upperSetpoint'}) - if supported & climate.SUPPORT_OPERATION_MODE: - properties.append({'name': 'thermostatMode'}) - return properties - - def properties_proactively_reported(self): - return True - - def properties_retrievable(self): - return True - - def get_property(self, name): - if name == 'thermostatMode': - ha_mode = self.entity.attributes.get(climate.ATTR_OPERATION_MODE) - mode = API_THERMOSTAT_MODES.get(ha_mode) - if mode is None: - _LOGGER.error("%s (%s) has unsupported %s value '%s'", - self.entity.entity_id, type(self.entity), - climate.ATTR_OPERATION_MODE, ha_mode) - raise _UnsupportedProperty(name) - return mode - - unit = self.hass.config.units.temperature_unit - if name == 'targetSetpoint': - temp = self.entity.attributes.get(ATTR_TEMPERATURE) - elif name == 'lowerSetpoint': - temp = self.entity.attributes.get(climate.ATTR_TARGET_TEMP_LOW) - elif name == 'upperSetpoint': - temp = self.entity.attributes.get(climate.ATTR_TARGET_TEMP_HIGH) - else: - raise _UnsupportedProperty(name) - - if temp is None: - return None - - return { - 'value': float(temp), - 'scale': API_TEMP_UNITS[unit], - } - - -@ENTITY_ADAPTERS.register(alert.DOMAIN) -@ENTITY_ADAPTERS.register(automation.DOMAIN) -@ENTITY_ADAPTERS.register(group.DOMAIN) -@ENTITY_ADAPTERS.register(input_boolean.DOMAIN) -class _GenericCapabilities(_AlexaEntity): - """A generic, on/off device. - - The choice of last resort. - """ - - def default_display_categories(self): - return [_DisplayCategory.OTHER] - - def interfaces(self): - return [_AlexaPowerController(self.entity), - _AlexaEndpointHealth(self.hass, self.entity)] - - -@ENTITY_ADAPTERS.register(switch.DOMAIN) -class _SwitchCapabilities(_AlexaEntity): - def default_display_categories(self): - return [_DisplayCategory.SWITCH] - - def interfaces(self): - return [_AlexaPowerController(self.entity), - _AlexaEndpointHealth(self.hass, self.entity)] - - -@ENTITY_ADAPTERS.register(climate.DOMAIN) -class _ClimateCapabilities(_AlexaEntity): - def default_display_categories(self): - return [_DisplayCategory.THERMOSTAT] - - def interfaces(self): - supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - if supported & climate.SUPPORT_ON_OFF: - yield _AlexaPowerController(self.entity) - yield _AlexaThermostatController(self.hass, self.entity) - yield _AlexaTemperatureSensor(self.hass, self.entity) - yield _AlexaEndpointHealth(self.hass, self.entity) - - -@ENTITY_ADAPTERS.register(cover.DOMAIN) -class _CoverCapabilities(_AlexaEntity): - def default_display_categories(self): - return [_DisplayCategory.DOOR] - - def interfaces(self): - yield _AlexaPowerController(self.entity) - supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - if supported & cover.SUPPORT_SET_POSITION: - yield _AlexaPercentageController(self.entity) - yield _AlexaEndpointHealth(self.hass, self.entity) - - -@ENTITY_ADAPTERS.register(light.DOMAIN) -class _LightCapabilities(_AlexaEntity): - def default_display_categories(self): - return [_DisplayCategory.LIGHT] - - def interfaces(self): - yield _AlexaPowerController(self.entity) - - supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - if supported & light.SUPPORT_BRIGHTNESS: - yield _AlexaBrightnessController(self.entity) - if supported & light.SUPPORT_COLOR: - yield _AlexaColorController(self.entity) - if supported & light.SUPPORT_COLOR_TEMP: - yield _AlexaColorTemperatureController(self.entity) - yield _AlexaEndpointHealth(self.hass, self.entity) - - -@ENTITY_ADAPTERS.register(fan.DOMAIN) -class _FanCapabilities(_AlexaEntity): - def default_display_categories(self): - return [_DisplayCategory.OTHER] - - def interfaces(self): - yield _AlexaPowerController(self.entity) - supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - if supported & fan.SUPPORT_SET_SPEED: - yield _AlexaPercentageController(self.entity) - yield _AlexaEndpointHealth(self.hass, self.entity) - - -@ENTITY_ADAPTERS.register(lock.DOMAIN) -class _LockCapabilities(_AlexaEntity): - def default_display_categories(self): - return [_DisplayCategory.SMARTLOCK] - - def interfaces(self): - return [_AlexaLockController(self.entity), - _AlexaEndpointHealth(self.hass, self.entity)] - - -@ENTITY_ADAPTERS.register(media_player.const.DOMAIN) -class _MediaPlayerCapabilities(_AlexaEntity): - def default_display_categories(self): - return [_DisplayCategory.TV] - - def interfaces(self): - yield _AlexaEndpointHealth(self.hass, self.entity) - - supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - if supported & media_player.const.SUPPORT_VOLUME_SET: - yield _AlexaSpeaker(self.entity) - - power_features = (media_player.SUPPORT_TURN_ON | - media_player.SUPPORT_TURN_OFF) - if supported & power_features: - yield _AlexaPowerController(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) - - if supported & media_player.SUPPORT_SELECT_SOURCE: - yield _AlexaInputController(self.entity) - - -@ENTITY_ADAPTERS.register(scene.DOMAIN) -class _SceneCapabilities(_AlexaEntity): - def description(self): - # Required description as per Amazon Scene docs - scene_fmt = '{} (Scene connected via Home Assistant)' - return scene_fmt.format(_AlexaEntity.description(self)) - - def default_display_categories(self): - return [_DisplayCategory.SCENE_TRIGGER] - - def interfaces(self): - return [_AlexaSceneController(self.entity, - supports_deactivation=False)] - - -@ENTITY_ADAPTERS.register(script.DOMAIN) -class _ScriptCapabilities(_AlexaEntity): - def default_display_categories(self): - return [_DisplayCategory.ACTIVITY_TRIGGER] - - def interfaces(self): - can_cancel = bool(self.entity.attributes.get('can_cancel')) - return [_AlexaSceneController(self.entity, - supports_deactivation=can_cancel)] - - -@ENTITY_ADAPTERS.register(sensor.DOMAIN) -class _SensorCapabilities(_AlexaEntity): - def default_display_categories(self): - # although there are other kinds of sensors, all but temperature - # sensors are currently ignored. - return [_DisplayCategory.TEMPERATURE_SENSOR] - - def interfaces(self): - attrs = self.entity.attributes - if attrs.get(ATTR_UNIT_OF_MEASUREMENT) in ( - TEMP_FAHRENHEIT, - TEMP_CELSIUS, - ): - yield _AlexaTemperatureSensor(self.hass, self.entity) - yield _AlexaEndpointHealth(self.hass, self.entity) - - -@ENTITY_ADAPTERS.register(binary_sensor.DOMAIN) -class _BinarySensorCapabilities(_AlexaEntity): - TYPE_CONTACT = 'contact' - TYPE_MOTION = 'motion' - - def default_display_categories(self): - 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] - - def interfaces(self): - 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) - - yield _AlexaEndpointHealth(self.hass, self.entity) - - def get_type(self): - """Return the type of binary sensor.""" - attrs = self.entity.attributes - if attrs.get(ATTR_DEVICE_CLASS) in ( - 'door', - 'garage_door', - 'opening', - 'window', - ): - return self.TYPE_CONTACT - if attrs.get(ATTR_DEVICE_CLASS) == 'motion': - return self.TYPE_MOTION - - -class _Cause: - """Possible causes for property changes. - - https://developer.amazon.com/docs/smarthome/state-reporting-for-a-smart-home-skill.html#cause-object - """ - - # Indicates that the event was caused by a customer interaction with an - # application. For example, a customer switches on a light, or locks a door - # using the Alexa app or an app provided by a device vendor. - APP_INTERACTION = 'APP_INTERACTION' - - # Indicates that the event was caused by a physical interaction with an - # endpoint. For example manually switching on a light or manually locking a - # door lock - PHYSICAL_INTERACTION = 'PHYSICAL_INTERACTION' - - # Indicates that the event was caused by the periodic poll of an appliance, - # which found a change in value. For example, you might poll a temperature - # sensor every hour, and send the updated temperature to Alexa. - PERIODIC_POLL = 'PERIODIC_POLL' - - # Indicates that the event was caused by the application of a device rule. - # For example, a customer configures a rule to switch on a light if a - # motion sensor detects motion. In this case, Alexa receives an event from - # the motion sensor, and another event from the light to indicate that its - # state change was caused by the rule. - RULE_TRIGGER = 'RULE_TRIGGER' - - # Indicates that the event was caused by a voice interaction with Alexa. - # For example a user speaking to their Echo device. - VOICE_INTERACTION = 'VOICE_INTERACTION' - - -class Config: - """Hold the configuration for Alexa.""" - - def __init__(self, endpoint, async_get_access_token, should_expose, - entity_config=None): - """Initialize the configuration.""" - self.endpoint = endpoint - self.async_get_access_token = async_get_access_token - self.should_expose = should_expose - self.entity_config = entity_config or {} - - -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. - """ - if config.get(CONF_CLIENT_ID) and config.get(CONF_CLIENT_SECRET): - hass.data[AUTH_KEY] = Auth(hass, config[CONF_CLIENT_ID], - config[CONF_CLIENT_SECRET]) - - async_get_access_token = \ - hass.data[AUTH_KEY].async_get_access_token if AUTH_KEY in hass.data \ - else None - - smart_home_config = Config( - endpoint=config.get(CONF_ENDPOINT), - async_get_access_token=async_get_access_token, - should_expose=config[CONF_FILTER], - entity_config=config.get(CONF_ENTITY_CONFIG), - ) - hass.http.register_view(SmartHomeView(smart_home_config)) - - if AUTH_KEY in hass.data: - await async_enable_proactive_mode(hass, smart_home_config) - - -async def async_enable_proactive_mode(hass, smart_home_config): - """Enable the proactive mode. - - Proactive mode makes this component report state changes to Alexa. - """ - if smart_home_config.async_get_access_token is None: - # no function to call to get token - return - - if await smart_home_config.async_get_access_token() is None: - # not ready yet - return - - async def async_entity_state_listener(changed_entity, old_state, - new_state): - if not smart_home_config.should_expose(changed_entity): - _LOGGER.debug("Not exposing %s because filtered by config", - changed_entity) - return - - if new_state.domain not in ENTITY_ADAPTERS: - 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 - - async_track_state_change(hass, MATCH_ALL, async_entity_state_listener) - - -class SmartHomeView(http.HomeAssistantView): - """Expose Smart Home v3 payload interface via HTTP POST.""" - - url = SMART_HOME_HTTP_ENDPOINT - name = 'api:alexa:smart_home' - - def __init__(self, smart_home_config): - """Initialize.""" - self.smart_home_config = smart_home_config - - 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[http.KEY_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=ha.Context(user_id=user.id) - ) - _LOGGER.debug("Sending Alexa Smart Home response: %s", response) - return b'' if response is None else self.json(response) - - -class _AlexaDirective: - def __init__(self, request): - 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 = 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 - - 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: - raise _AlexaInvalidEndpointError(_endpoint_id) - - self.endpoint = ENTITY_ADAPTERS[self.entity.domain]( - hass, config, self.entity) - - 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: - def __init__(self, name, namespace, payload=None): - 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 theromstat 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. +import homeassistant.core as ha - Handlers should be using .add_context_property(). - """ - properties = self._properties() - already_set = {(p['namespace'], p['name']) for p in properties} +from .const import API_DIRECTIVE, API_HEADER +from .errors import ( + AlexaError, + AlexaBridgeUnreachableError, +) +from .handlers import HANDLERS +from .messages import AlexaDirective - for prop in endpoint.serialize_properties(): - if (prop['namespace'], prop['name']) not in already_set: - self.add_context_property(prop) +_LOGGER = logging.getLogger(__name__) - def serialize(self): - """Return response as a JSON-able data structure.""" - return self._response +EVENT_ALEXA_SMART_HOME = 'alexa_smart_home' async def async_handle_message( @@ -1353,11 +34,11 @@ async def async_handle_message( if context is None: context = ha.Context() - directive = _AlexaDirective(request) + directive = AlexaDirective(request) try: if not enabled: - raise _AlexaBridgeUnreachableError( + raise AlexaBridgeUnreachableError( 'Alexa API not enabled in Home Assistant configuration') if directive.has_endpoint: @@ -1375,7 +56,7 @@ async def async_handle_message( directive.name, ) response = directive.error() - except _AlexaError as err: + except AlexaError as err: response = directive.error( error_type=err.error_type, error_message=err.error_message) @@ -1397,758 +78,3 @@ async def async_handle_message( }, context=context) return response.serialize() - - -async def async_send_changereport_message(hass, config, alexa_entity): - """Send a ChangeReport message for an Alexa entity.""" - token = await config.async_get_access_token() - if not token: - _LOGGER.error("Invalid access token.") - return - - headers = { - "Authorization": "Bearer {}".format(token) - } - - endpoint = alexa_entity.entity_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() - - try: - session = aiohttp_client.async_get_clientsession(hass) - 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 calling LWA to get auth token.") - return None - - 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: - response_json = json.loads(response_text) - _LOGGER.error("Error when sending ChangeReport to Alexa: %s: %s", - response_json["payload"]["code"], - response_json["payload"]["description"]) - - -@HANDLERS.register(('Alexa.Discovery', 'Discover')) -async def async_api_discovery(hass, config, directive, context): - """Create a API formatted discovery response. - - Async friendly. - """ - discovery_endpoints = [] - - for entity in hass.states.async_all(): - if entity.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: - _LOGGER.debug("Not exposing %s because it is never exposed", - entity.entity_id) - continue - - if not config.should_expose(entity.entity_id): - _LOGGER.debug("Not exposing %s because filtered by config", - entity.entity_id) - continue - - if entity.domain not in ENTITY_ADAPTERS: - continue - alexa_entity = ENTITY_ADAPTERS[entity.domain](hass, config, entity) - - endpoint = { - 'displayCategories': alexa_entity.display_categories(), - 'cookie': {}, - 'endpointId': alexa_entity.entity_id(), - 'friendlyName': alexa_entity.friendly_name(), - 'description': alexa_entity.description(), - 'manufacturerName': 'Home Assistant', - } - - endpoint['capabilities'] = [ - i.serialize_discovery() for i in alexa_entity.interfaces()] - - if not endpoint['capabilities']: - _LOGGER.debug( - "Not exposing %s because it has no capabilities", - entity.entity_id) - continue - discovery_endpoints.append(endpoint) - - return 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 AUTH_KEY in hass.data: - await hass.data[AUTH_KEY].async_do_auth(auth_code) - 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 == cover.DOMAIN: - service = cover.SERVICE_OPEN_COVER - - await hass.services.async_call(domain, service, { - ATTR_ENTITY_ID: entity.entity_id - }, blocking=False, context=context) - - return 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 entity.domain == cover.DOMAIN: - service = cover.SERVICE_CLOSE_COVER - - await hass.services.async_call(domain, service, { - ATTR_ENTITY_ID: entity.entity_id - }, blocking=False, context=context) - - return 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': '%sZ' % (datetime.utcnow().isoformat(),) - } - - 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': '%sZ' % (datetime.utcnow().isoformat(),) - } - - 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 - - elif entity.domain == cover.DOMAIN: - service = SERVICE_SET_COVER_POSITION - data[cover.ATTR_POSITION] = percentage - - await hass.services.async_call( - entity.domain, service, data, blocking=False, context=context) - - return 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) - - if speed == "off": - current = 0 - elif speed == "low": - current = 33 - elif speed == "medium": - current = 66 - elif speed == "high": - current = 100 - - # set percentage - percentage = max(0, percentage_delta + current) - speed = "off" - - if percentage <= 33: - speed = "low" - elif percentage <= 66: - speed = "medium" - elif percentage <= 100: - speed = "high" - - data[fan.ATTR_SPEED] = speed - - elif entity.domain == cover.DOMAIN: - service = SERVICE_SET_COVER_POSITION - - current = entity.attributes.get(cover.ATTR_POSITION) - - data[cover.ATTR_POSITION] = max(0, percentage_delta + current) - - await hass.services.async_call( - entity.domain, service, data, blocking=False, context=context) - - return 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 - - -# Not supported by Alexa yet -@HANDLERS.register(('Alexa.LockController', 'Unlock')) -async def async_api_unlock(hass, config, directive, context): - """Process an unlock request.""" - entity = directive.entity - await hass.services.async_call(entity.domain, SERVICE_UNLOCK, { - ATTR_ENTITY_ID: entity.entity_id - }, blocking=False, context=context) - - return directive.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 - source_list = entity.attributes[ - media_player.const.ATTR_INPUT_SOURCE_LIST] or [] - for source in source_list: - # response will always be space separated, so format the source in the - # most likely way to find a match - formatted_source = source.lower().replace('-', ' ').replace('_', ' ') - if formatted_source in media_input.lower(): - media_input = source - break - else: - msg = 'failed to map input {} to a media source on {}'.format( - media_input, entity.entity_id) - 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. - # For now we use the volumeSteps returned to figure out if we - # should step up/down - volume_step = directive.payload['volumeSteps'] - entity = directive.entity - - data = { - ATTR_ENTITY_ID: entity.entity_id, - } - - if volume_step > 0: - await hass.services.async_call( - entity.domain, SERVICE_VOLUME_UP, - data, blocking=False, context=context) - elif volume_step < 0: - await hass.services.async_call( - entity.domain, SERVICE_VOLUME_DOWN, - 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'] - - operation_list = entity.attributes.get(climate.ATTR_OPERATION_LIST) - ha_mode = next( - (k for k, v in API_THERMOSTAT_MODES.items() if v == mode), - None - ) - if ha_mode not in operation_list: - msg = 'The requested thermostat mode {} is not supported'.format(mode) - raise _AlexaUnsupportedThermostatModeError(msg) - - data = { - ATTR_ENTITY_ID: entity.entity_id, - climate.ATTR_OPERATION_MODE: ha_mode, - } - - response = directive.response() - await hass.services.async_call( - entity.domain, climate.SERVICE_SET_OPERATION_MODE, 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') diff --git a/homeassistant/components/alexa/smart_home_http.py b/homeassistant/components/alexa/smart_home_http.py new file mode 100644 index 00000000000000..e9437a411d6f36 --- /dev/null +++ b/homeassistant/components/alexa/smart_home_http.py @@ -0,0 +1,114 @@ +"""Alexa HTTP interface.""" +import logging + +from homeassistant import core +from homeassistant.components.http.view import HomeAssistantView + +from .auth import Auth +from .config import AbstractConfig +from .const import ( + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + CONF_ENDPOINT, + CONF_ENTITY_CONFIG, + CONF_FILTER +) +from .state_report import async_enable_proactive_mode +from .smart_home import async_handle_message + +_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, {}) + + def should_expose(self, entity_id): + """If an entity should be exposed.""" + return self._config[CONF_FILTER](entity_id) + + 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 00000000000000..022b38be59d2b8 --- /dev/null +++ b/homeassistant/components/alexa/state_report.py @@ -0,0 +1,185 @@ +"""Alexa state report code.""" +import asyncio +import json +import logging + +import aiohttp +import async_timeout + +from homeassistant.const import MATCH_ALL + +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 + + return hass.helpers.event.async_track_state_change( + MATCH_ALL, async_entity_state_listener + ) + + +async def async_send_changereport_message(hass, config, alexa_entity): + """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": "Bearer {}".format(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 None + + 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: + response_json = json.loads(response_text) + _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": "Bearer {}".format(token) + } + + endpoints = [] + + for entity_id in entity_ids: + domain = entity_id.split('.', 1)[0] + 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": "Bearer {}".format(token) + } + + endpoints = [] + + for entity_id in entity_ids: + domain = entity_id.split('.', 1)[0] + 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) diff --git a/homeassistant/components/ambiclimate/.translations/es-419.json b/homeassistant/components/ambiclimate/.translations/es-419.json new file mode 100644 index 00000000000000..eaac252d605da5 --- /dev/null +++ b/homeassistant/components/ambiclimate/.translations/es-419.json @@ -0,0 +1,12 @@ +{ + "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" + } + } +} \ 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 00000000000000..b062eb67c1ffea --- /dev/null +++ b/homeassistant/components/ambiclimate/.translations/it.json @@ -0,0 +1,8 @@ +{ + "config": { + "abort": { + "already_setup": "L'account Ambiclimate \u00e8 configurato." + }, + "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 00000000000000..ca4d0b912ab7b3 --- /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/pt-BR.json b/homeassistant/components/ambiclimate/.translations/pt-BR.json new file mode 100644 index 00000000000000..4de4190d0558c2 --- /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/climate.py b/homeassistant/components/ambiclimate/climate.py index ae61163ab0520c..3dc6431bb8c1f3 100644 --- a/homeassistant/components/ambiclimate/climate.py +++ b/homeassistant/components/ambiclimate/climate.py @@ -56,14 +56,15 @@ async def async_setup_entry(hass, entry, async_add_entities): websession) try: - _token_info = await oauth.refresh_access_token(token_info) + 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 - if _token_info: - await store.async_save(_token_info) - token_info = _token_info + await store.async_save(token_info) data_connection = ambiclimate.AmbiclimateConnection(oauth, token_info=token_info, diff --git a/homeassistant/components/ambiclimate/manifest.json b/homeassistant/components/ambiclimate/manifest.json index 70c0570487318e..e0d4e29a8e568b 100644 --- a/homeassistant/components/ambiclimate/manifest.json +++ b/homeassistant/components/ambiclimate/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/components/ambiclimate", "requirements": [ - "ambiclimate==0.1.2" + "ambiclimate==0.2.0" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py index 2c185c3bc71de5..1abdad5e925e00 100644 --- a/homeassistant/components/ambient_station/__init__.py +++ b/homeassistant/components/ambient_station/__init__.py @@ -327,7 +327,7 @@ 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: + if self._watchdog_listener is not None: self._watchdog_listener() self._watchdog_listener = async_call_later( self._hass, DEFAULT_WATCHDOG_SECONDS, _ws_reconnect) diff --git a/homeassistant/components/ambient_station/manifest.json b/homeassistant/components/ambient_station/manifest.json index 3e9bbf6a5b8644..510edd540ecca3 100644 --- a/homeassistant/components/ambient_station/manifest.json +++ b/homeassistant/components/ambient_station/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/components/ambient_station", "requirements": [ - "aioambient==0.3.0" + "aioambient==0.3.1" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/amcrest/__init__.py b/homeassistant/components/amcrest/__init__.py index 6de31caa90e325..1c9303b2c52355 100644 --- a/homeassistant/components/amcrest/__init__.py +++ b/homeassistant/components/amcrest/__init__.py @@ -1,8 +1,10 @@ """Support for Amcrest IP cameras.""" import logging from datetime import timedelta +import threading import aiohttp +from amcrest import AmcrestError, Http, LoginError import voluptuous as vol from homeassistant.auth.permissions.const import POLICY_CONTROL @@ -17,12 +19,14 @@ 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 +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 .binary_sensor import BINARY_SENSOR_MOTION_DETECTED, BINARY_SENSORS from .camera import CAMERA_SERVICES, STREAM_SOURCE_LIST -from .const import DOMAIN, DATA_AMCREST +from .const import CAMERAS, DOMAIN, DATA_AMCREST, DEVICES, SERVICE_UPDATE from .helpers import service_signal from .sensor import SENSOR_MOTION_DETECTOR, SENSORS from .switch import SWITCHES @@ -32,11 +36,14 @@ 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' @@ -56,20 +63,21 @@ def _deprecated_sensor_values(sensors): if SENSOR_MOTION_DETECTOR in sensors: _LOGGER.warning( - "The 'sensors' option value '%s' is deprecated, " + "The '%s' option value '%s' is deprecated, " "please remove it from your configuration and use " - "the 'binary_sensors' option with value 'motion_detected' " - "instead.", SENSOR_MOTION_DETECTOR) + "the '%s' option with value '%s' instead", + CONF_SENSORS, SENSOR_MOTION_DETECTOR, CONF_BINARY_SENSORS, + BINARY_SENSOR_MOTION_DETECTED) return sensors def _deprecated_switches(config): if CONF_SWITCHES in config: _LOGGER.warning( - "The 'switches' option (with value %s) is deprecated, " + "The '%s' option (with value %s) is deprecated, " "please remove it from your configuration and use " - "camera services and attributes instead.", - config[CONF_SWITCHES]) + "services and attributes instead", + CONF_SWITCHES, config[CONF_SWITCHES]) return config @@ -103,6 +111,7 @@ def _has_unique_names(devices): _deprecated_sensor_values), vol.Optional(CONF_SWITCHES): vol.All(cv.ensure_list, [vol.In(SWITCHES)]), + vol.Optional(CONF_CONTROL_LIGHT, default=True): cv.boolean, }), _deprecated_switches ) @@ -112,35 +121,81 @@ def _has_unique_names(devices): }, 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) + 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.""" - from amcrest import AmcrestCamera, AmcrestError + hass.data.setdefault(DATA_AMCREST, {DEVICES: {}, CAMERAS: []}) - hass.data.setdefault(DATA_AMCREST, {'devices': {}, 'cameras': []}) - devices = config[DOMAIN] - - for device in devices: + for device in config[DOMAIN]: name = device[CONF_NAME] username = device[CONF_USERNAME] password = device[CONF_PASSWORD] try: - api = AmcrestCamera(device[CONF_HOST], - device[CONF_PORT], - username, - password).camera - # pylint: disable=pointless-statement - # Test camera communications. - api.current_time - - except AmcrestError as ex: - _LOGGER.error("Unable to connect to %s camera: %s", name, 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) + 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] @@ -149,6 +204,7 @@ def setup(hass, config): sensors = device.get(CONF_SENSORS) switches = device.get(CONF_SWITCHES) stream_source = device[CONF_STREAM_SOURCE] + control_light = device.get(CONF_CONTROL_LIGHT) # currently aiohttp only works with basic authentication # only valid for mjpeg streaming @@ -157,9 +213,9 @@ def setup(hass, config): else: authentication = None - hass.data[DATA_AMCREST]['devices'][name] = AmcrestDevice( + hass.data[DATA_AMCREST][DEVICES][name] = AmcrestDevice( api, authentication, ffmpeg_arguments, stream_source, - resolution) + resolution, control_light) discovery.load_platform( hass, CAMERA, DOMAIN, { @@ -187,7 +243,7 @@ def setup(hass, config): CONF_SWITCHES: switches }, config) - if not hass.data[DATA_AMCREST]['devices']: + if not hass.data[DATA_AMCREST][DEVICES]: return False def have_permission(user, entity_id): @@ -205,13 +261,13 @@ async def async_extract_from_service(call): 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'] + 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']: + for entity_id in hass.data[DATA_AMCREST][CAMERAS]: if entity_id not in call_ids: continue if not have_permission(user, entity_id): @@ -245,10 +301,11 @@ class AmcrestDevice: """Representation of a base Amcrest discovery device.""" def __init__(self, api, authentication, ffmpeg_arguments, - stream_source, resolution): + 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 index 0eb9e42e707dd7..9489fc60d4daa1 100644 --- a/homeassistant/components/amcrest/binary_sensor.py +++ b/homeassistant/components/amcrest/binary_sensor.py @@ -2,18 +2,27 @@ from datetime import timedelta import logging +from amcrest import AmcrestError + from homeassistant.components.binary_sensor import ( - BinarySensorDevice, DEVICE_CLASS_MOTION) + BinarySensorDevice, DEVICE_CLASS_CONNECTIVITY, DEVICE_CLASS_MOTION) from homeassistant.const import CONF_NAME, CONF_BINARY_SENSORS +from homeassistant.helpers.dispatcher import async_dispatcher_connect -from .const import BINARY_SENSOR_SCAN_INTERVAL_SECS, DATA_AMCREST +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 = { - 'motion_detected': 'Motion Detected' + BINARY_SENSOR_MOTION_DETECTED: ('Motion Detected', DEVICE_CLASS_MOTION), + BINARY_SENSOR_ONLINE: ('Online', DEVICE_CLASS_CONNECTIVITY), } @@ -24,7 +33,7 @@ async def async_setup_platform(hass, config, async_add_entities, return name = discovery_info[CONF_NAME] - device = hass.data[DATA_AMCREST]['devices'][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]], @@ -36,10 +45,18 @@ class AmcrestBinarySensor(BinarySensorDevice): def __init__(self, name, device, sensor_type): """Initialize entity.""" - self._name = '{} {}'.format(name, BINARY_SENSORS[sensor_type]) + 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): @@ -54,17 +71,39 @@ def is_on(self): @property def device_class(self): """Return device class.""" - return DEVICE_CLASS_MOTION + 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.""" - from amcrest import AmcrestError - - _LOGGER.debug('Pulling data from %s binary sensor', self._name) + if not self.available: + return + _LOGGER.debug('Updating %s binary sensor', self._name) try: - self._state = self._api.is_motion_detected + 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: - _LOGGER.error( - 'Could not update %s binary sensor due to error: %s', - self.name, 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 index d75475dbb26c6e..685d92d5ae6be5 100644 --- a/homeassistant/components/amcrest/camera.py +++ b/homeassistant/components/amcrest/camera.py @@ -1,7 +1,10 @@ """Support for Amcrest IP cameras.""" import asyncio +from datetime import timedelta import logging +from urllib3.exceptions import HTTPError +from amcrest import AmcrestError import voluptuous as vol from homeassistant.components.camera import ( @@ -14,11 +17,14 @@ async_get_clientsession) from homeassistant.helpers.dispatcher import async_dispatcher_connect -from .const import CAMERA_WEB_SESSION_TIMEOUT, DATA_AMCREST -from .helpers import service_signal +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', @@ -76,7 +82,7 @@ async def async_setup_platform(hass, config, async_add_entities, return name = discovery_info[CONF_NAME] - device = hass.data[DATA_AMCREST]['devices'][name] + device = hass.data[DATA_AMCREST][DEVICES][name] async_add_entities([ AmcrestCam(name, device, hass.data[DATA_FFMPEG])], True) @@ -94,33 +100,36 @@ def __init__(self, name, device, ffmpeg): 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.""" - from amcrest import AmcrestError - - if not self.is_on: - _LOGGER.error( - 'Attempt to take snaphot when %s camera is off', self.name) + 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, self._resolution) + self._api.snapshot) return response.data - except AmcrestError as error: - _LOGGER.error( - 'Could not get image from %s camera due to error: %s', - self.name, error) + 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): @@ -129,6 +138,12 @@ async def handle_async_mjpeg_stream(self, request): 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) @@ -143,7 +158,7 @@ async def handle_async_mjpeg_stream(self, request): # streaming via ffmpeg from haffmpeg.camera import CameraMjpeg - streaming_url = self._api.rtsp_url(typeno=self._resolution) + 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) @@ -158,6 +173,14 @@ async def handle_async_mjpeg_stream(self, request): # 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.""" @@ -176,6 +199,11 @@ def device_state_attributes(self): 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.""" @@ -191,7 +219,7 @@ def is_recording(self): @property def brand(self): """Return the camera brand.""" - return 'Amcrest' + return self._brand @property def motion_detection_enabled(self): @@ -205,7 +233,7 @@ def model(self): async def stream_source(self): """Return the source of the stream.""" - return self._api.rtsp_url(typeno=self._resolution) + return self._rtsp_url @property def is_on(self): @@ -214,6 +242,10 @@ def is_on(self): # 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(): @@ -221,28 +253,37 @@ async def async_added_to_hass(self): self.hass, service_signal(service, self.entity_id), getattr(self, params[1]))) - self.hass.data[DATA_AMCREST]['cameras'].append(self.entity_id) + 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) + 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.""" - from amcrest import AmcrestError - - _LOGGER.debug('Pulling data from %s camera', self.name) - if self._model is None: - try: - self._model = self._api.device_type.split('=')[-1].strip() - except AmcrestError as error: - _LOGGER.error( - 'Could not get %s camera model due to error: %s', - self.name, error) - self._model = '' + 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 = ( @@ -251,10 +292,13 @@ def update(self): 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: - _LOGGER.error( - 'Could not get %s camera attributes due to error: %s', - self.name, error) + log_update_error( + _LOGGER, 'get', self.name, 'camera attributes', error) + self._update_succeeded = False + else: + self._update_succeeded = True # Other Camera method overrides @@ -322,8 +366,6 @@ async def async_stop_tour(self): def _enable_video_stream(self, enable): """Enable or disable camera video stream.""" - from amcrest import AmcrestError - # 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. @@ -332,17 +374,17 @@ def _enable_video_stream(self, enable): try: self._api.video_enabled = enable except AmcrestError as error: - _LOGGER.error( - 'Could not %s %s camera video stream due to error: %s', - 'enable' if enable else 'disable', self.name, 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.""" - from amcrest import AmcrestError - # 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. @@ -353,88 +395,89 @@ def _enable_recording(self, enable): self._api.record_mode = rec_mode[ 'Manual' if enable else 'Automatic'] except AmcrestError as error: - _LOGGER.error( - 'Could not %s %s camera recording due to error: %s', - 'enable' if enable else 'disable', self.name, 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.""" - from amcrest import AmcrestError - try: self._api.motion_detection = str(enable).lower() except AmcrestError as error: - _LOGGER.error( - 'Could not %s %s camera motion detection due to error: %s', - 'enable' if enable else 'disable', self.name, 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.""" - from amcrest import AmcrestError - try: self._api.audio_enabled = enable except AmcrestError as error: - _LOGGER.error( - 'Could not %s %s camera audio stream due to error: %s', - 'enable' if enable else 'disable', self.name, 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.""" - from amcrest import AmcrestError - try: self._api.motion_recording = str(enable).lower() except AmcrestError as error: - _LOGGER.error( - 'Could not %s %s camera motion recording due to error: %s', - 'enable' if enable else 'disable', self.name, 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.""" - from amcrest import AmcrestError - try: self._api.go_to_preset( action='start', preset_point_number=preset) except AmcrestError as error: - _LOGGER.error( - 'Could not move %s camera to preset %i due to error: %s', - self.name, preset, error) + log_update_error( + _LOGGER, 'move', self.name, + 'camera to preset {}'.format(preset), error) def _set_color_bw(self, cbw): """Set camera color mode.""" - from amcrest import AmcrestError - try: self._api.day_night_color = _CBW.index(cbw) except AmcrestError as error: - _LOGGER.error( - 'Could not set %s camera color mode to %s due to error: %s', - self.name, cbw, error) + log_update_error( + _LOGGER, 'set', self.name, + 'camera color mode to {}'.format(cbw), error) else: self._color_bw = cbw self.schedule_update_ha_state() def _start_tour(self, start): """Start camera tour.""" - from amcrest import AmcrestError - try: self._api.tour(start=start) except AmcrestError as error: - _LOGGER.error( - 'Could not %s %s camera tour due to error: %s', - 'start' if start else 'stop', self.name, 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 index a0230937e95b81..fe07659b48af8d 100644 --- a/homeassistant/components/amcrest/const.py +++ b/homeassistant/components/amcrest/const.py @@ -1,7 +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 index 270c969a6cc9fa..69d7f5ef28825c 100644 --- a/homeassistant/components/amcrest/helpers.py +++ b/homeassistant/components/amcrest/helpers.py @@ -2,9 +2,16 @@ from .const import DOMAIN -def service_signal(service, entity_id=None): - """Encode service and entity_id into signal.""" +def service_signal(service, ident=None): + """Encode service and identifier into signal.""" signal = '{}_{}'.format(DOMAIN, service) - if entity_id: - signal += '_{}'.format(entity_id.replace('.', '_')) + 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 index a2eb8c24e212f2..f79ce34897b92c 100644 --- a/homeassistant/components/amcrest/manifest.json +++ b/homeassistant/components/amcrest/manifest.json @@ -3,7 +3,7 @@ "name": "Amcrest", "documentation": "https://www.home-assistant.io/components/amcrest", "requirements": [ - "amcrest==1.4.0" + "amcrest==1.5.3" ], "dependencies": [ "ffmpeg" diff --git a/homeassistant/components/amcrest/sensor.py b/homeassistant/components/amcrest/sensor.py index 718d08358c4210..1788b9c62b0e13 100644 --- a/homeassistant/components/amcrest/sensor.py +++ b/homeassistant/components/amcrest/sensor.py @@ -2,21 +2,28 @@ 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, SENSOR_SCAN_INTERVAL_SECS +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 types are defined like: Name, units, icon SENSOR_MOTION_DETECTOR = 'motion_detector' +SENSOR_PTZ_PRESET = 'ptz_preset' +SENSOR_SDCARD = 'sdcard' +# Sensor types are defined like: Name, units, icon SENSORS = { SENSOR_MOTION_DETECTOR: ['Motion Detected', None, 'mdi:run'], - 'sdcard': ['SD Used', '%', 'mdi:sd'], - 'ptz_preset': ['PTZ Preset', None, 'mdi:camera-iris'], + SENSOR_PTZ_PRESET: ['PTZ Preset', None, 'mdi:camera-iris'], + SENSOR_SDCARD: ['SD Used', '%', 'mdi:sd'], } @@ -27,7 +34,7 @@ async def async_setup_platform( return name = discovery_info[CONF_NAME] - device = hass.data[DATA_AMCREST]['devices'][name] + device = hass.data[DATA_AMCREST][DEVICES][name] async_add_entities( [AmcrestSensor(name, device, sensor_type) for sensor_type in discovery_info[CONF_SENSORS]], @@ -40,12 +47,14 @@ class AmcrestSensor(Entity): 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): @@ -72,28 +81,53 @@ 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.""" - _LOGGER.debug("Pulling data from %s sensor.", self._name) - - if self._sensor_type == 'motion_detector': - self._state = self._api.is_motion_detected - self._attrs['Record Mode'] = self._api.record_mode - - elif self._sensor_type == 'ptz_preset': - self._state = self._api.ptz_presets_count - - elif self._sensor_type == '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'] + if not self.available: + return + _LOGGER.debug("Updating %s sensor", self._name) + + try: + if self._sensor_type == SENSOR_MOTION_DETECTOR: + self._state = self._api.is_motion_detected + self._attrs['Record Mode'] = self._api.record_mode + + elif self._sensor_type == SENSOR_PTZ_PRESET: + self._state = self._api.ptz_presets_count + + elif self._sensor_type == SENSOR_SDCARD: + storage = self._api.storage_all + try: + self._attrs['Total'] = '{:.2f} {}'.format( + *storage['total']) + except ValueError: + self._attrs['Total'] = '{} {}'.format(*storage['total']) + try: + self._attrs['Used'] = '{:.2f} {}'.format(*storage['used']) + except ValueError: + self._attrs['Used'] = '{} {}'.format(*storage['used']) + try: + self._state = '{:.2f}'.format(storage['used_percent']) + except ValueError: + self._state = storage['used_percent'] + except AmcrestError as error: + log_update_error(_LOGGER, 'update', self.name, 'sensor', error) + + async def async_on_demand_update(self): + """Update state.""" + self.async_schedule_update_ha_state(True) + + async def async_added_to_hass(self): + """Subscribe to update signal.""" + self._unsub_dispatcher = async_dispatcher_connect( + self.hass, service_signal(SERVICE_UPDATE, self._signal_name), + self.async_on_demand_update) + + async def async_will_remove_from_hass(self): + """Disconnect from update signal.""" + self._unsub_dispatcher() diff --git a/homeassistant/components/amcrest/switch.py b/homeassistant/components/amcrest/switch.py index 5989d4daf1e38a..ec286b4f4047e1 100644 --- a/homeassistant/components/amcrest/switch.py +++ b/homeassistant/components/amcrest/switch.py @@ -1,17 +1,23 @@ """Support for toggling Amcrest IP camera settings.""" import logging +from amcrest import AmcrestError + from homeassistant.const import CONF_NAME, CONF_SWITCHES +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import ToggleEntity -from .const import DATA_AMCREST +from .const import DATA_AMCREST, DEVICES, SERVICE_UPDATE +from .helpers import log_update_error, service_signal _LOGGER = logging.getLogger(__name__) +MOTION_DETECTION = 'motion_detection' +MOTION_RECORDING = 'motion_recording' # Switch types are defined like: Name, icon SWITCHES = { - 'motion_detection': ['Motion Detection', 'mdi:run-fast'], - 'motion_recording': ['Motion Recording', 'mdi:record-rec'] + MOTION_DETECTION: ['Motion Detection', 'mdi:run-fast'], + MOTION_RECORDING: ['Motion Recording', 'mdi:record-rec'] } @@ -22,7 +28,7 @@ async def async_setup_platform( return name = discovery_info[CONF_NAME] - device = hass.data[DATA_AMCREST]['devices'][name] + device = hass.data[DATA_AMCREST][DEVICES][name] async_add_entities( [AmcrestSwitch(name, device, setting) for setting in discovery_info[CONF_SWITCHES]], @@ -35,10 +41,12 @@ class AmcrestSwitch(ToggleEntity): def __init__(self, name, device, setting): """Initialize the Amcrest switch.""" self._name = '{} {}'.format(name, SWITCHES[setting][0]) + self._signal_name = name self._api = device.api self._setting = setting self._state = False self._icon = SWITCHES[setting][1] + self._unsub_dispatcher = None @property def name(self): @@ -52,30 +60,63 @@ def is_on(self): def turn_on(self, **kwargs): """Turn setting on.""" - if self._setting == 'motion_detection': - self._api.motion_detection = 'true' - elif self._setting == 'motion_recording': - self._api.motion_recording = 'true' + if not self.available: + return + try: + if self._setting == MOTION_DETECTION: + self._api.motion_detection = 'true' + elif self._setting == MOTION_RECORDING: + self._api.motion_recording = 'true' + except AmcrestError as error: + log_update_error(_LOGGER, 'turn on', self.name, 'switch', error) def turn_off(self, **kwargs): """Turn setting off.""" - if self._setting == 'motion_detection': - self._api.motion_detection = 'false' - elif self._setting == 'motion_recording': - self._api.motion_recording = 'false' + if not self.available: + return + try: + if self._setting == MOTION_DETECTION: + self._api.motion_detection = 'false' + elif self._setting == MOTION_RECORDING: + self._api.motion_recording = 'false' + except AmcrestError as error: + log_update_error(_LOGGER, 'turn off', self.name, 'switch', error) + + @property + def available(self): + """Return True if entity is available.""" + return self._api.available def update(self): """Update setting state.""" - _LOGGER.debug("Polling state for setting: %s ", self._name) - - if self._setting == 'motion_detection': - detection = self._api.is_motion_detector_on() - elif self._setting == 'motion_recording': - detection = self._api.is_record_on_motion_detection() - - self._state = detection + if not self.available: + return + _LOGGER.debug("Updating %s switch", self._name) + + try: + if self._setting == MOTION_DETECTION: + detection = self._api.is_motion_detector_on() + elif self._setting == MOTION_RECORDING: + detection = self._api.is_record_on_motion_detection() + self._state = detection + except AmcrestError as error: + log_update_error(_LOGGER, 'update', self.name, 'switch', error) @property def icon(self): """Return the icon for the switch.""" return self._icon + + async def async_on_demand_update(self): + """Update state.""" + self.async_schedule_update_ha_state(True) + + async def async_added_to_hass(self): + """Subscribe to update signal.""" + self._unsub_dispatcher = async_dispatcher_connect( + self.hass, service_signal(SERVICE_UPDATE, self._signal_name), + self.async_on_demand_update) + + async def async_will_remove_from_hass(self): + """Disconnect from update signal.""" + self._unsub_dispatcher() diff --git a/homeassistant/components/androidtv/manifest.json b/homeassistant/components/androidtv/manifest.json index 841ad299785825..7e23d8e7d59406 100644 --- a/homeassistant/components/androidtv/manifest.json +++ b/homeassistant/components/androidtv/manifest.json @@ -3,7 +3,7 @@ "name": "Androidtv", "documentation": "https://www.home-assistant.io/components/androidtv", "requirements": [ - "androidtv==0.0.15" + "androidtv==0.0.16" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/aprs/__init__.py b/homeassistant/components/aprs/__init__.py new file mode 100644 index 00000000000000..20a023166aeaed --- /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 00000000000000..3bde7021d7c45e --- /dev/null +++ b/homeassistant/components/aprs/device_tracker.py @@ -0,0 +1,187 @@ +"""Support for APRS device tracking.""" + +import logging +import threading + +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.""" + import geopy.distance + + 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 = "APRS position ambiguity must be 0-4, not '{0}'.".format( + 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__() + + import aprslib + + 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) + from aprslib import ConnectionError as AprsConnectionError + from aprslib import LoginError + + try: + _LOGGER.info("Opening connection to %s with callsign %s.", + self.host, self.callsign) + self.ais.connect() + self.start_complete( + True, + "Connected to {0} with callsign {1}.".format( + self.host, 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 00000000000000..fbe13ca85782c9 --- /dev/null +++ b/homeassistant/components/aprs/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "aprs", + "name": "APRS", + "documentation": "https://www.home-assistant.io/components/aprs", + "dependencies": [], + "codeowners": ["@PhilRW"], + "requirements": [ + "aprslib==0.6.46", + "geopy==1.19.0" + ] +} diff --git a/homeassistant/components/asuswrt/device_tracker.py b/homeassistant/components/asuswrt/device_tracker.py index 68641f670aa267..a7b13abbc053c4 100644 --- a/homeassistant/components/asuswrt/device_tracker.py +++ b/homeassistant/components/asuswrt/device_tracker.py @@ -47,6 +47,6 @@ async def async_update_info(self): Return boolean if scanning successful. """ - _LOGGER.info('Checking Devices') + _LOGGER.debug('Checking Devices') self.last_results = await self.connection.async_get_connected_devices() diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 90b5857b13c563..5238a423181d1d 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -50,7 +50,7 @@ def _platform_validator(config): - """Validate it is a valid platform.""" + """Validate it is a valid platform.""" try: platform = importlib.import_module('.{}'.format(config[CONF_PLATFORM]), __name__) @@ -223,23 +223,25 @@ def is_on(self) -> bool: 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 + self._last_triggered = state.attributes.get('last_triggered') + _LOGGER.debug("Loaded automation %s with state %s from state " + " storage last state %s", self.entity_id, + enable_automation, state) + else: + enable_automation = DEFAULT_INITIAL_STATE + _LOGGER.debug("Automation %s not in state storage, state %s from " + "default is used.", self.entity_id, + enable_automation) + if self._initial_state is not None: enable_automation = self._initial_state - _LOGGER.debug("Automation %s initial state %s from config " - "initial_state", self.entity_id, enable_automation) - else: - state = await self.async_get_last_state() - if state: - enable_automation = state.state == STATE_ON - self._last_triggered = state.attributes.get('last_triggered') - _LOGGER.debug("Automation %s initial state %s from recorder " - "last state %s", self.entity_id, - enable_automation, state) - else: - enable_automation = DEFAULT_INITIAL_STATE - _LOGGER.debug("Automation %s initial state %s from default " - "initial state", self.entity_id, - enable_automation) + _LOGGER.debug("Automation %s initial state %s overridden from " + "config initial_state", self.entity_id, + enable_automation) if enable_automation: await self.async_enable() diff --git a/homeassistant/components/automation/device.py b/homeassistant/components/automation/device.py new file mode 100644 index 00000000000000..4e59018b41c8ef --- /dev/null +++ b/homeassistant/components/automation/device.py @@ -0,0 +1,18 @@ +"""Offer device oriented automation.""" +import voluptuous as vol + +from homeassistant.const import CONF_DOMAIN, CONF_PLATFORM +from homeassistant.loader import async_get_integration + + +TRIGGER_SCHEMA = vol.Schema({ + vol.Required(CONF_PLATFORM): 'device', + vol.Required(CONF_DOMAIN): str, +}, extra=vol.ALLOW_EXTRA) + + +async def async_trigger(hass, config, action, automation_info): + """Listen for trigger.""" + integration = await async_get_integration(hass, config[CONF_DOMAIN]) + platform = integration.get_platform('device_automation') + return await platform.async_trigger(hass, config, action, automation_info) diff --git a/homeassistant/components/automation/template.py b/homeassistant/components/automation/template.py index 6371be2802102d..96075e9bd1c195 100644 --- a/homeassistant/components/automation/template.py +++ b/homeassistant/components/automation/template.py @@ -4,8 +4,10 @@ import voluptuous as vol from homeassistant.core import callback -from homeassistant.const import CONF_VALUE_TEMPLATE, CONF_PLATFORM -from homeassistant.helpers.event import async_track_template +from homeassistant.const import CONF_VALUE_TEMPLATE, CONF_PLATFORM, CONF_FOR +from homeassistant.helpers import condition +from homeassistant.helpers.event import ( + async_track_same_state, async_track_template) import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -13,6 +15,7 @@ TRIGGER_SCHEMA = IF_ACTION_SCHEMA = vol.Schema({ vol.Required(CONF_PLATFORM): 'template', vol.Required(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_FOR): vol.All(cv.time_period, cv.positive_timedelta), }) @@ -20,17 +23,44 @@ async def async_trigger(hass, config, action, automation_info): """Listen for state changes based on configuration.""" value_template = config.get(CONF_VALUE_TEMPLATE) value_template.hass = hass + time_delta = config.get(CONF_FOR) + unsub_track_same = None @callback def template_listener(entity_id, from_s, to_s): """Listen for state changes and calls action.""" - hass.async_run_job(action({ - 'trigger': { - 'platform': 'template', - 'entity_id': entity_id, - 'from_state': from_s, - 'to_state': to_s, - }, - }, context=(to_s.context if to_s else None))) - - return async_track_template(hass, value_template, template_listener) + 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, + }, + }, context=(to_s.context if to_s else None))) + + if not time_delta: + call_action() + return + + unsub_track_same = async_track_same_state( + hass, time_delta, 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/awair/manifest.json b/homeassistant/components/awair/manifest.json index cba11e8be1ca00..dfa5bec3c00309 100644 --- a/homeassistant/components/awair/manifest.json +++ b/homeassistant/components/awair/manifest.json @@ -6,5 +6,7 @@ "python_awair==0.0.4" ], "dependencies": [], - "codeowners": [] + "codeowners": [ + "@danielsjf" + ] } diff --git a/homeassistant/components/awair/sensor.py b/homeassistant/components/awair/sensor.py index 85f18e87d13f86..71b74c7971e404 100644 --- a/homeassistant/components/awair/sensor.py +++ b/homeassistant/components/awair/sensor.py @@ -219,6 +219,6 @@ async def _async_update(self): # 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]] = sensor[ATTR_VALUE] + 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/axis/.translations/ca.json b/homeassistant/components/axis/.translations/ca.json index 5e98dbf34189d1..75dd89ef9c15b0 100644 --- a/homeassistant/components/axis/.translations/ca.json +++ b/homeassistant/components/axis/.translations/ca.json @@ -3,10 +3,12 @@ "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" + "link_local_address": "L'enlla\u00e7 d'adreces locals no est\u00e0 disponible", + "not_axis_device": "El dispositiu descobert no \u00e9s un dispositiu Axis" }, "error": { "already_configured": "El dispositiu ja est\u00e0 configurat", + "already_in_progress": "El flux de dades pel dispositiu ja est\u00e0 en curs.", "device_unavailable": "El dispositiu no est\u00e0 disponible", "faulty_credentials": "Credencials d'usuari incorrectes" }, diff --git a/homeassistant/components/axis/.translations/de.json b/homeassistant/components/axis/.translations/de.json index c979068b922229..05c1853d769222 100644 --- a/homeassistant/components/axis/.translations/de.json +++ b/homeassistant/components/axis/.translations/de.json @@ -3,10 +3,12 @@ "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" + "link_local_address": "Link-local Adressen werden nicht unterst\u00fctzt", + "not_axis_device": "Erkanntes Ger\u00e4t ist kein Axis-Ger\u00e4t" }, "error": { "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "already_in_progress": "Der Konfigurationsablauf f\u00fcr das Ger\u00e4t wird bereits ausgef\u00fchrt.", "device_unavailable": "Ger\u00e4t ist nicht verf\u00fcgbar", "faulty_credentials": "Ung\u00fcltige Anmeldeinformationen" }, diff --git a/homeassistant/components/axis/.translations/en.json b/homeassistant/components/axis/.translations/en.json index 6c5933dfd97263..5fd5d9be5655e9 100644 --- a/homeassistant/components/axis/.translations/en.json +++ b/homeassistant/components/axis/.translations/en.json @@ -3,10 +3,12 @@ "abort": { "already_configured": "Device is already configured", "bad_config_file": "Bad data from config file", - "link_local_address": "Link local addresses are not supported" + "link_local_address": "Link local addresses are not supported", + "not_axis_device": "Discovered device not an Axis device" }, "error": { "already_configured": "Device is already configured", + "already_in_progress": "Config flow for device is already in progress.", "device_unavailable": "Device is not available", "faulty_credentials": "Bad user credentials" }, diff --git a/homeassistant/components/axis/.translations/hu.json b/homeassistant/components/axis/.translations/hu.json index cbf055e2fba477..b0c8051e69f9fa 100644 --- a/homeassistant/components/axis/.translations/hu.json +++ b/homeassistant/components/axis/.translations/hu.json @@ -1,9 +1,17 @@ { "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" + "host": "Hoszt", + "password": "Jelsz\u00f3", + "port": "Port", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" } } } diff --git a/homeassistant/components/axis/.translations/it.json b/homeassistant/components/axis/.translations/it.json index 2141bf34942bc8..2498c28ec33aca 100644 --- a/homeassistant/components/axis/.translations/it.json +++ b/homeassistant/components/axis/.translations/it.json @@ -2,10 +2,12 @@ "config": { "abort": { "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", - "bad_config_file": "Dati errati dal file di configurazione" + "bad_config_file": "Dati errati dal file di configurazione", + "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" }, diff --git a/homeassistant/components/axis/.translations/ko.json b/homeassistant/components/axis/.translations/ko.json index aafa4fc18962e1..5ceaa0828103ee 100644 --- a/homeassistant/components/axis/.translations/ko.json +++ b/homeassistant/components/axis/.translations/ko.json @@ -3,10 +3,12 @@ "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" + "link_local_address": "\ub85c\uceec \uc8fc\uc18c \uc5f0\uacb0\uc740 \uc9c0\uc6d0\ub418\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4", + "not_axis_device": "\ubc1c\uacac\ub41c \uae30\uae30\ub294 Axis \uae30\uae30\uac00 \uc544\ub2d9\ub2c8\ub2e4" }, "error": { "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "already_in_progress": "\uae30\uae30 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589\uc911\uc785\ub2c8\ub2e4.", "device_unavailable": "\uae30\uae30\ub97c \uc0ac\uc6a9\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", "faulty_credentials": "\uc0ac\uc6a9\uc790 \uc774\ub984 \ud639\uc740 \ube44\ubc00\ubc88\ud638\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, diff --git a/homeassistant/components/axis/.translations/lb.json b/homeassistant/components/axis/.translations/lb.json index 6b0728f4030d86..281eaa7c8818ba 100644 --- a/homeassistant/components/axis/.translations/lb.json +++ b/homeassistant/components/axis/.translations/lb.json @@ -3,10 +3,12 @@ "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" + "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" }, diff --git a/homeassistant/components/axis/.translations/nl.json b/homeassistant/components/axis/.translations/nl.json index e46f35aa1f9a48..83395283404040 100644 --- a/homeassistant/components/axis/.translations/nl.json +++ b/homeassistant/components/axis/.translations/nl.json @@ -1,6 +1,14 @@ { "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" }, @@ -11,8 +19,10 @@ "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/no.json b/homeassistant/components/axis/.translations/no.json index 94b5a1680b7156..24cf845f9f0b57 100644 --- a/homeassistant/components/axis/.translations/no.json +++ b/homeassistant/components/axis/.translations/no.json @@ -7,6 +7,7 @@ }, "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" }, diff --git a/homeassistant/components/axis/.translations/pl.json b/homeassistant/components/axis/.translations/pl.json index 7903dc63bf8bca..88e803605363de 100644 --- a/homeassistant/components/axis/.translations/pl.json +++ b/homeassistant/components/axis/.translations/pl.json @@ -3,10 +3,12 @@ "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" + "link_local_address": "Po\u0142\u0105czenie lokalnego adresu nie jest obs\u0142ugiwane", + "not_axis_device": "Wykryte urz\u0105dzenie nie jest urz\u0105dzeniem Axis" }, "error": { "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", + "already_in_progress": "Konfigurowanie urz\u0105dzenia jest ju\u017c w toku.", "device_unavailable": "Urz\u0105dzenie jest niedost\u0119pne", "faulty_credentials": "B\u0142\u0119dne dane uwierzytelniaj\u0105ce" }, diff --git a/homeassistant/components/axis/.translations/pt-BR.json b/homeassistant/components/axis/.translations/pt-BR.json new file mode 100644 index 00000000000000..4126d99e2e21c8 --- /dev/null +++ b/homeassistant/components/axis/.translations/pt-BR.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "not_axis_device": "Dispositivo descoberto n\u00e3o \u00e9 um dispositivo Axis" + }, + "error": { + "already_in_progress": "O fluxo de configura\u00e7\u00e3o para o dispositivo j\u00e1 est\u00e1 em andamento.", + "faulty_credentials": "Credenciais do usu\u00e1rio inv\u00e1lidas" + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Senha", + "port": "Porta", + "username": "Nome de usu\u00e1rio" + } + } + }, + "title": "Dispositivo Axis" + } +} \ No newline at end of file diff --git a/homeassistant/components/axis/.translations/ru.json b/homeassistant/components/axis/.translations/ru.json index f303aa947ea8ba..67d720aa85f06a 100644 --- a/homeassistant/components/axis/.translations/ru.json +++ b/homeassistant/components/axis/.translations/ru.json @@ -3,10 +3,12 @@ "abort": { "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\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" + "link_local_address": "\u0421\u0441\u044b\u043b\u043a\u0430 \u043d\u0430 \u043b\u043e\u043a\u0430\u043b\u044c\u043d\u044b\u0435 \u0430\u0434\u0440\u0435\u0441\u0430 \u043d\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442\u0441\u044f", + "not_axis_device": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u044f\u0432\u043b\u044f\u0435\u0442\u0441\u044f \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c Axis" }, "error": { "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\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\u0435\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435" }, diff --git a/homeassistant/components/axis/.translations/sl.json b/homeassistant/components/axis/.translations/sl.json index 41d2994987333f..cf58ed345ce611 100644 --- a/homeassistant/components/axis/.translations/sl.json +++ b/homeassistant/components/axis/.translations/sl.json @@ -7,6 +7,7 @@ }, "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" }, diff --git a/homeassistant/components/axis/.translations/sv.json b/homeassistant/components/axis/.translations/sv.json index 2f75a9dcfffa6d..d7f014c7800ba9 100644 --- a/homeassistant/components/axis/.translations/sv.json +++ b/homeassistant/components/axis/.translations/sv.json @@ -7,6 +7,7 @@ }, "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" }, diff --git a/homeassistant/components/axis/.translations/zh-Hant.json b/homeassistant/components/axis/.translations/zh-Hant.json index ac9f3ceb2b696f..1457db2b600276 100644 --- a/homeassistant/components/axis/.translations/zh-Hant.json +++ b/homeassistant/components/axis/.translations/zh-Hant.json @@ -3,10 +3,12 @@ "abort": { "already_configured": "\u88dd\u7f6e\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" + "link_local_address": "\u4e0d\u652f\u63f4\u9023\u7d50\u672c\u5730\u7aef\u4f4d\u5740", + "not_axis_device": "\u6240\u767c\u73fe\u7684\u88dd\u7f6e\u4e26\u975e Axis \u88dd\u7f6e" }, "error": { "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "already_in_progress": "\u88dd\u7f6e\u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d\u3002", "device_unavailable": "\u88dd\u7f6e\u7121\u6cd5\u4f7f\u7528", "faulty_credentials": "\u4f7f\u7528\u8005\u6191\u8b49\u7121\u6548" }, diff --git a/homeassistant/components/blink/manifest.json b/homeassistant/components/blink/manifest.json index abce8a4a0d1c63..98c609731c6d31 100644 --- a/homeassistant/components/blink/manifest.json +++ b/homeassistant/components/blink/manifest.json @@ -3,7 +3,7 @@ "name": "Blink", "documentation": "https://www.home-assistant.io/components/blink", "requirements": [ - "blinkpy==0.14.0" + "blinkpy==0.14.1" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/broadlink/manifest.json b/homeassistant/components/broadlink/manifest.json index 125a3a83d21b22..45ed2003026fd2 100644 --- a/homeassistant/components/broadlink/manifest.json +++ b/homeassistant/components/broadlink/manifest.json @@ -3,7 +3,7 @@ "name": "Broadlink", "documentation": "https://www.home-assistant.io/components/broadlink", "requirements": [ - "broadlink==0.10.0" + "broadlink==0.11.1" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/broadlink/switch.py b/homeassistant/components/broadlink/switch.py index 96a45322114152..5c67e9dbc2867a 100644 --- a/homeassistant/components/broadlink/switch.py +++ b/homeassistant/components/broadlink/switch.py @@ -322,6 +322,8 @@ def __init__(self, device): def get_outlet_status(self, slot): """Get status of outlet from cached status list.""" + if self._states is None: + return None return self._states['s{}'.format(slot)] @Throttle(TIME_BETWEEN_UPDATES) diff --git a/homeassistant/components/buienradar/camera.py b/homeassistant/components/buienradar/camera.py new file mode 100644 index 00000000000000..b390a86d622da5 --- /dev/null +++ b/homeassistant/components/buienradar/camera.py @@ -0,0 +1,178 @@ +"""Provide animated GIF loops of Buienradar imagery.""" +import asyncio +import logging +from datetime import datetime, timedelta +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 = None # type: Optional[bytes] + # value of the last seen last modified header + self._last_modified = None # type: Optional[str] + # loading status + self._loading = False + # deadline for image refresh - self.delta after last successful load + self._deadline = None # type: Optional[datetime] + + @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/manifest.json b/homeassistant/components/buienradar/manifest.json index 98fc5fbdeac458..1ed313348f7100 100644 --- a/homeassistant/components/buienradar/manifest.json +++ b/homeassistant/components/buienradar/manifest.json @@ -6,5 +6,5 @@ "buienradar==0.91" ], "dependencies": [], - "codeowners": [] + "codeowners": ["@ties"] } diff --git a/homeassistant/components/buienradar/weather.py b/homeassistant/components/buienradar/weather.py index 7d77bec7cca05a..a8e4f9d424d752 100644 --- a/homeassistant/components/buienradar/weather.py +++ b/homeassistant/components/buienradar/weather.py @@ -5,7 +5,8 @@ from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, ATTR_FORECAST_TEMP, ATTR_FORECAST_TEMP_LOW, - ATTR_FORECAST_TIME, PLATFORM_SCHEMA, WeatherEntity) + ATTR_FORECAST_TIME, PLATFORM_SCHEMA, WeatherEntity, + ATTR_FORECAST_PRECIPITATION) from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, TEMP_CELSIUS) from homeassistant.helpers import config_validation as cv @@ -149,7 +150,7 @@ def temperature_unit(self): @property def forecast(self): """Return the forecast array.""" - from buienradar.buienradar import (CONDITION, CONDCODE, DATETIME, + from buienradar.buienradar import (CONDITION, CONDCODE, RAIN, DATETIME, MIN_TEMP, MAX_TEMP) if self._forecast: @@ -166,6 +167,7 @@ def forecast(self): data_out[ATTR_FORECAST_CONDITION] = cond[condcode] data_out[ATTR_FORECAST_TEMP_LOW] = data_in.get(MIN_TEMP) data_out[ATTR_FORECAST_TEMP] = data_in.get(MAX_TEMP) + data_out[ATTR_FORECAST_PRECIPITATION] = data_in.get(RAIN) fcdata_out.append(data_out) diff --git a/homeassistant/components/cast/manifest.json b/homeassistant/components/cast/manifest.json index 2d310cdda8f2e6..ff9e8907ec5aeb 100644 --- a/homeassistant/components/cast/manifest.json +++ b/homeassistant/components/cast/manifest.json @@ -4,8 +4,9 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/components/cast", "requirements": [ - "pychromecast==3.2.1" + "pychromecast==3.2.2" ], "dependencies": [], + "zeroconf": ["_googlecast._tcp.local."], "codeowners": [] } diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index d4d443a692d51a..bb539a270acd3a 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -4,7 +4,7 @@ import voluptuous as vol from homeassistant.auth.const import GROUP_ID_ADMIN -from homeassistant.components.alexa import smart_home as alexa_sh +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, @@ -21,7 +21,8 @@ CONF_CLOUDHOOK_CREATE_URL, CONF_COGNITO_CLIENT_ID, CONF_ENTITY_CONFIG, CONF_FILTER, CONF_GOOGLE_ACTIONS, CONF_GOOGLE_ACTIONS_SYNC_URL, CONF_RELAYER, CONF_REMOTE_API_URL, CONF_SUBSCRIPTION_INFO_URL, - CONF_USER_POOL_ID, DOMAIN, MODE_DEV, MODE_PROD) + CONF_USER_POOL_ID, DOMAIN, MODE_DEV, MODE_PROD, CONF_ALEXA_ACCESS_TOKEN_URL +) from .prefs import CloudPreferences _LOGGER = logging.getLogger(__name__) @@ -33,9 +34,9 @@ ALEXA_ENTITY_SCHEMA = vol.Schema({ - vol.Optional(alexa_sh.CONF_DESCRIPTION): cv.string, - vol.Optional(alexa_sh.CONF_DISPLAY_CATEGORIES): cv.string, - vol.Optional(alexa_sh.CONF_NAME): cv.string, + 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({ @@ -61,7 +62,6 @@ DOMAIN: vol.Schema({ vol.Optional(CONF_MODE, default=DEFAULT_MODE): vol.In([MODE_DEV, MODE_PROD]), - # Change to optional when we include real servers vol.Optional(CONF_COGNITO_CLIENT_ID): str, vol.Optional(CONF_USER_POOL_ID): str, vol.Optional(CONF_REGION): str, @@ -73,6 +73,7 @@ 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): str, }), }, extra=vol.ALLOW_EXTRA) @@ -192,8 +193,16 @@ async def _service_handler(service): hass.helpers.service.async_register_admin_service( DOMAIN, SERVICE_REMOTE_DISCONNECT, _service_handler) + loaded_binary_sensor = False + async def _on_connect(): """Discover RemoteUI binary sensor.""" + nonlocal loaded_binary_sensor + + if loaded_binary_sensor: + return + + loaded_binary_sensor = True hass.async_create_task(hass.helpers.discovery.async_load_platform( 'binary_sensor', DOMAIN, {}, config)) diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py new file mode 100644 index 00000000000000..aae48df9884cfe --- /dev/null +++ b/homeassistant/components/cloud/alexa_config.py @@ -0,0 +1,259 @@ +"""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.const import CLOUD_NEVER_EXPOSED_ENTITIES +from homeassistant.helpers import entity_registry +from homeassistant.helpers.event import async_call_later +from homeassistant.util.dt import utcnow +from homeassistant.components.alexa import ( + config as alexa_config, + errors as alexa_errors, + entities as alexa_entities, + state_report as alexa_state_report, +) + + +from .const import ( + CONF_ENTITY_CONFIG, CONF_FILTER, PREF_SHOULD_EXPOSE, DEFAULT_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, {}) + + 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) + + 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() + + # 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.""" + 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/client.py b/homeassistant/components/cloud/client.py index eadb1731bd021a..d22e5bf37ba1cb 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -2,42 +2,45 @@ import asyncio from pathlib import Path from typing import Any, Dict +import logging import aiohttp from hass_nabucasa.client import CloudClient as Interface from homeassistant.core import callback -from homeassistant.components.alexa import smart_home as alexa_sh -from homeassistant.components.google_assistant import ( - helpers as ga_h, smart_home as ga) -from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES +from homeassistant.components.google_assistant import smart_home as ga from homeassistant.helpers.typing import HomeAssistantType from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.util.aiohttp import MockRequest +from homeassistant.components.alexa import ( + smart_home as alexa_sh, + errors as alexa_errors, +) -from . import utils -from .const import ( - CONF_ENTITY_CONFIG, CONF_FILTER, DOMAIN, DISPATCHER_REMOTE_UPDATE, - PREF_SHOULD_EXPOSE, DEFAULT_SHOULD_EXPOSE, - PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA) +from . import utils, alexa_config, google_config +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_config: Dict[str, Any], google_config: Dict[str, Any]): + 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._alexa_user_config = alexa_config - self._google_user_config = google_config - + self.google_user_config = google_user_config + self.alexa_user_config = alexa_user_config self._alexa_config = None self._google_config = None + self.cloud = None @property def base_path(self) -> Path: @@ -75,70 +78,40 @@ def remote_autostart(self) -> bool: return self._prefs.remote_enabled @property - def alexa_config(self) -> alexa_sh.Config: + def alexa_config(self) -> alexa_config.AlexaConfig: """Return Alexa config.""" - if not self._alexa_config: - alexa_conf = self._alexa_user_config - - self._alexa_config = alexa_sh.Config( - endpoint=None, - async_get_access_token=None, - should_expose=alexa_conf[CONF_FILTER], - entity_config=alexa_conf.get(CONF_ENTITY_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 @property - def google_config(self) -> ga_h.Config: + def google_config(self) -> google_config.CloudGoogleConfig: """Return Google config.""" if not self._google_config: - google_conf = self._google_user_config - - def should_expose(entity): - """If an entity should be exposed.""" - if entity.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: - return False - - if not google_conf['filter'].empty_filter: - return google_conf['filter'](entity.entity_id) - - entity_configs = self.prefs.google_entity_configs - entity_config = entity_configs.get(entity.entity_id, {}) - return entity_config.get( - PREF_SHOULD_EXPOSE, DEFAULT_SHOULD_EXPOSE) - - def should_2fa(entity): - """If an entity should be checked for 2FA.""" - entity_configs = self.prefs.google_entity_configs - entity_config = entity_configs.get(entity.entity_id, {}) - return not entity_config.get( - PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA) - - username = self._hass.data[DOMAIN].claims["cognito:username"] - - self._google_config = ga_h.Config( - should_expose=should_expose, - should_2fa=should_2fa, - secure_devices_pin=self._prefs.google_secure_devices_pin, - entity_config=google_conf.get(CONF_ENTITY_CONFIG), - agent_user_id=username, - ) - - # Set it to the latest. - self._google_config.secure_devices_pin = \ - self._prefs.google_secure_devices_pin + assert self.cloud is not None + self._google_config = google_config.CloudGoogleConfig( + self.google_user_config, self._prefs, self.cloud) return self._google_config - @property - def google_user_config(self) -> Dict[str, Any]: - """Return google action user config.""" - return self._google_user_config + async def async_initialize(self, cloud) -> None: + """Initialize the client.""" + self.cloud = cloud + + if (not self.alexa_config.should_report_state or + not self.cloud.is_logged_in): + return + + try: + await self.alexa_config.async_enable_proactive_mode() + except alexa_errors.NoTokenAvailable: + pass async def cleanups(self) -> None: """Cleanup some stuff after logout.""" - self._alexa_config = None self._google_config = None @callback diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 65062213a630d2..fdb36723fdbb19 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -9,12 +9,15 @@ 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_OVERRIDE_NAME = 'override_name' PREF_DISABLE_2FA = 'disable_2fa' PREF_ALIASES = 'aliases' PREF_SHOULD_EXPOSE = 'should_expose' DEFAULT_SHOULD_EXPOSE = True DEFAULT_DISABLE_2FA = False +DEFAULT_ALEXA_REPORT_STATE = False CONF_ALEXA = 'alexa' CONF_ALIASES = 'aliases' @@ -29,6 +32,7 @@ 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' MODE_DEV = "development" MODE_PROD = "production" @@ -42,3 +46,7 @@ class InvalidTrustedNetworks(Exception): 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 00000000000000..b047d25ee4976d --- /dev/null +++ b/homeassistant/components/cloud/google_config.py @@ -0,0 +1,52 @@ +"""Google config for Cloud.""" +from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES +from homeassistant.components.google_assistant.helpers import AbstractConfig + +from .const import ( + PREF_SHOULD_EXPOSE, DEFAULT_SHOULD_EXPOSE, CONF_ENTITY_CONFIG, + PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA) + + +class CloudGoogleConfig(AbstractConfig): + """HA Cloud Configuration for Google Assistant.""" + + def __init__(self, config, prefs, cloud): + """Initialize the Alexa config.""" + self._config = config + self._prefs = prefs + self._cloud = cloud + + @property + def agent_user_id(self): + """Return Agent User Id to use for query responses.""" + return self._cloud.claims["cognito:username"] + + @property + def entity_config(self): + """Return entity config.""" + return self._config.get(CONF_ENTITY_CONFIG) + + @property + def secure_devices_pin(self): + """Return entity config.""" + return self._prefs.google_secure_devices_pin + + def should_expose(self, state): + """If an entity should be exposed.""" + if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + return False + + if not self._config['filter'].empty_filter: + return self._config['filter'](state.entity_id) + + entity_configs = self._prefs.google_entity_configs + entity_config = entity_configs.get(state.entity_id, {}) + return entity_config.get( + PREF_SHOULD_EXPOSE, DEFAULT_SHOULD_EXPOSE) + + def should_2fa(self, state): + """If an entity should be checked for 2FA.""" + entity_configs = self._prefs.google_entity_configs + entity_config = entity_configs.get(state.entity_id, {}) + return not entity_config.get( + PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA) diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 9908268b252556..0cd08dd3d5f91f 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -13,13 +13,17 @@ from homeassistant.components.http.data_validator import ( RequestDataValidator) from homeassistant.components import websocket_api -from homeassistant.components.alexa import smart_home as alexa_sh +from homeassistant.components.websocket_api import const as ws_const +from homeassistant.components.alexa import ( + entities as alexa_entities, + errors as alexa_errors, +) from homeassistant.components.google_assistant import helpers as google_helpers from .const import ( DOMAIN, REQUEST_TIMEOUT, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, PREF_GOOGLE_SECURE_DEVICES_PIN, InvalidTrustedNetworks, - InvalidTrustedProxies) + InvalidTrustedProxies, PREF_ALEXA_REPORT_STATE) _LOGGER = logging.getLogger(__name__) @@ -90,6 +94,10 @@ async def async_setup(hass): hass.components.websocket_api.async_register_command( google_assistant_update) + hass.components.websocket_api.async_register_command(alexa_list) + hass.components.websocket_api.async_register_command(alexa_update) + hass.components.websocket_api.async_register_command(alexa_sync) + hass.http.register_view(GoogleActionsSyncView) hass.http.register_view(CloudLoginView) hass.http.register_view(CloudLogoutView) @@ -360,6 +368,7 @@ async def websocket_subscription(hass, connection, msg): 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_SECURE_DEVICES_PIN): vol.Any(None, str), }) async def websocket_update_prefs(hass, connection, msg): @@ -369,6 +378,24 @@ async def websocket_update_prefs(hass, connection, msg): 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: + 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'])) @@ -420,8 +447,7 @@ def _account_data(cloud): 'cloud': cloud.iot.state, 'prefs': client.prefs.as_dict(), 'google_entities': client.google_user_config['filter'].config, - 'alexa_entities': client.alexa_config.should_expose.config, - 'alexa_domains': list(alexa_sh.ENTITY_ADAPTERS), + 'alexa_entities': client.alexa_user_config['filter'].config, 'remote_domain': remote.instance_domain, 'remote_connected': remote.is_connected, 'remote_certificate': certificate, @@ -497,7 +523,7 @@ async def google_assistant_list(hass, connection, msg): vol.Optional('disable_2fa'): bool, }) async def google_assistant_update(hass, connection, msg): - """List all google assistant entities.""" + """Update google assistant config.""" cloud = hass.data[DOMAIN] changes = dict(msg) changes.pop('type') @@ -508,3 +534,80 @@ async def google_assistant_update(hass, connection, msg): 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') diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 1a4511c8c88632..e848f54425b3d4 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -3,7 +3,7 @@ "name": "Cloud", "documentation": "https://www.home-assistant.io/components/cloud", "requirements": [ - "hass-nabucasa==0.14" + "hass-nabucasa==0.15" ], "dependencies": [ "http", diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index 9f2579134e506a..a01a6dd4cb57e0 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -1,11 +1,15 @@ """Preference management for cloud.""" from ipaddress import ip_address +from homeassistant.core import callback +from homeassistant.util.logging import async_create_catching_coro + from .const import ( DOMAIN, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, PREF_ENABLE_REMOTE, PREF_GOOGLE_SECURE_DEVICES_PIN, PREF_CLOUDHOOKS, PREF_CLOUD_USER, PREF_GOOGLE_ENTITY_CONFIGS, PREF_OVERRIDE_NAME, PREF_DISABLE_2FA, - PREF_ALIASES, PREF_SHOULD_EXPOSE, + PREF_ALIASES, PREF_SHOULD_EXPOSE, PREF_ALEXA_ENTITY_CONFIGS, + PREF_ALEXA_REPORT_STATE, DEFAULT_ALEXA_REPORT_STATE, InvalidTrustedNetworks, InvalidTrustedProxies) STORAGE_KEY = DOMAIN @@ -21,6 +25,7 @@ def __init__(self, hass): 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.""" @@ -33,16 +38,24 @@ async def async_initialize(self): PREF_ENABLE_REMOTE: False, PREF_GOOGLE_SECURE_DEVICES_PIN: None, PREF_GOOGLE_ENTITY_CONFIGS: {}, + PREF_ALEXA_ENTITY_CONFIGS: {}, PREF_CLOUDHOOKS: {}, PREF_CLOUD_USER: None, } self._prefs = prefs + @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): + cloud_user=_UNDEF, google_entity_configs=_UNDEF, + alexa_entity_configs=_UNDEF, + alexa_report_state=_UNDEF): """Update user preferences.""" for key, value in ( (PREF_ENABLE_GOOGLE, google_enabled), @@ -52,18 +65,27 @@ async def async_update(self, *, google_enabled=_UNDEF, (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), ): if value is not _UNDEF: self._prefs[key] = value if remote_enabled is True and self._has_local_trusted_network: + self._prefs[PREF_ENABLE_REMOTE] = False raise InvalidTrustedNetworks if remote_enabled is True and self._has_local_trusted_proxies: + self._prefs[PREF_ENABLE_REMOTE] = False raise InvalidTrustedProxies await self._store.async_save(self._prefs) + for listener in self._listeners: + self._hass.async_create_task( + async_create_catching_coro(listener(self)) + ) + async def async_update_google_entity_config( self, *, entity_id, override_name=_UNDEF, disable_2fa=_UNDEF, aliases=_UNDEF, should_expose=_UNDEF): @@ -95,6 +117,33 @@ async def async_update_google_entity_config( } 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) + def as_dict(self): """Return dictionary version.""" return { @@ -103,6 +152,8 @@ def as_dict(self): 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_CLOUDHOOKS: self.cloudhooks, PREF_CLOUD_USER: self.cloud_user, } @@ -125,6 +176,12 @@ 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.""" @@ -140,6 +197,11 @@ def google_entity_configs(self): """Return Google Entity configurations.""" return self._prefs.get(PREF_GOOGLE_ENTITY_CONFIGS, {}) + @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.""" diff --git a/homeassistant/components/config/device_registry.py b/homeassistant/components/config/device_registry.py index d9e55bbe67e73c..61b00bf6726753 100644 --- a/homeassistant/components/config/device_registry.py +++ b/homeassistant/components/config/device_registry.py @@ -69,7 +69,7 @@ def _entry_dict(entry): 'name': entry.name, 'sw_version': entry.sw_version, 'id': entry.id, - 'hub_device_id': entry.hub_device_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/cover/__init__.py b/homeassistant/components/cover/__init__.py index 8609d3c9cf6402..4e90a7bd186d6a 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -15,9 +15,10 @@ from homeassistant.helpers import intent from homeassistant.const import ( SERVICE_OPEN_COVER, SERVICE_CLOSE_COVER, SERVICE_SET_COVER_POSITION, - SERVICE_STOP_COVER, SERVICE_OPEN_COVER_TILT, SERVICE_CLOSE_COVER_TILT, - SERVICE_STOP_COVER_TILT, SERVICE_SET_COVER_TILT_POSITION, STATE_OPEN, - STATE_CLOSED, STATE_OPENING, STATE_CLOSING, ATTR_ENTITY_ID) + SERVICE_STOP_COVER, SERVICE_TOGGLE, SERVICE_OPEN_COVER_TILT, + SERVICE_CLOSE_COVER_TILT, SERVICE_STOP_COVER_TILT, + SERVICE_SET_COVER_TILT_POSITION, SERVICE_TOGGLE_COVER_TILT, + STATE_OPEN, STATE_CLOSED, STATE_OPENING, STATE_CLOSING, ATTR_ENTITY_ID) _LOGGER = logging.getLogger(__name__) @@ -118,6 +119,11 @@ async def async_setup(hass, config): 'async_stop_cover' ) + component.async_register_entity_service( + SERVICE_TOGGLE, COVER_SERVICE_SCHEMA, + 'async_toggle' + ) + component.async_register_entity_service( SERVICE_OPEN_COVER_TILT, COVER_SERVICE_SCHEMA, 'async_open_cover_tilt' @@ -138,6 +144,11 @@ async def async_setup(hass, config): 'async_set_cover_tilt_position' ) + component.async_register_entity_service( + SERVICE_TOGGLE_COVER_TILT, COVER_SERVICE_SCHEMA, + 'async_toggle_tilt' + ) + hass.helpers.intent.async_register(intent.ServiceIntentHandler( INTENT_OPEN_COVER, DOMAIN, SERVICE_OPEN_COVER, "Opened {}")) @@ -159,7 +170,7 @@ async def async_unload_entry(hass, entry): class CoverDevice(Entity): - """Representation a cover.""" + """Representation of a cover.""" @property def current_cover_position(self): @@ -259,6 +270,22 @@ def async_close_cover(self, **kwargs): """ return self.hass.async_add_job(ft.partial(self.close_cover, **kwargs)) + def toggle(self, **kwargs) -> 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 @@ -329,3 +356,19 @@ def async_stop_cover_tilt(self, **kwargs): """ return self.hass.async_add_job( ft.partial(self.stop_cover_tilt, **kwargs)) + + def toggle_tilt(self, **kwargs) -> 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/services.yaml b/homeassistant/components/cover/services.yaml index 79f00180a8946d..64534e409742e8 100644 --- a/homeassistant/components/cover/services.yaml +++ b/homeassistant/components/cover/services.yaml @@ -14,6 +14,13 @@ close_cover: description: Name(s) of cover(s) to close. example: 'cover.living_room' +toggle: + description: Toggles a cover open/closed. + fields: + entity_id: + description: Name(s) of cover(s) to toggle. + example: 'cover.garage_door' + set_cover_position: description: Move to specific position all or specified cover. fields: @@ -36,21 +43,28 @@ open_cover_tilt: fields: entity_id: description: Name(s) of cover(s) tilt to open. - example: 'cover.living_room' + example: 'cover.living_room_blinds' close_cover_tilt: description: Close all or specified cover tilt. fields: entity_id: description: Name(s) of cover(s) to close tilt. - example: 'cover.living_room' + example: 'cover.living_room_blinds' + +toggle_cover_tilt: + description: Toggles a cover tilt open/closed. + fields: + entity_id: + description: Name(s) of cover(s) to toggle tilt. + example: 'cover.living_room_blinds' set_cover_tilt_position: description: Move to specific position all or specified cover tilt. fields: entity_id: description: Name(s) of cover(s) to set cover tilt position. - example: 'cover.living_room' + example: 'cover.living_room_blinds' tilt_position: description: Tilt position of the cover (0 to 100). example: 30 @@ -60,4 +74,4 @@ stop_cover_tilt: fields: entity_id: description: Name(s) of cover(s) to stop. - example: 'cover.living_room' + example: 'cover.living_room_blinds' diff --git a/homeassistant/components/ddwrt/device_tracker.py b/homeassistant/components/ddwrt/device_tracker.py index a97fe340f927ed..e412e33fa17ad9 100644 --- a/homeassistant/components/ddwrt/device_tracker.py +++ b/homeassistant/components/ddwrt/device_tracker.py @@ -18,6 +18,8 @@ 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, @@ -25,6 +27,7 @@ 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 }) @@ -46,6 +49,7 @@ def __init__(self, config): 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 = {} @@ -103,8 +107,9 @@ def _update_info(self): """ _LOGGER.info("Checking ARP") - url = '{}://{}/Status_Wireless.live.asp'.format( - self.protocol, self.host) + endpoint = 'Wireless' if self.wireless_only else 'Lan' + url = '{}://{}/Status_{}.live.asp'.format( + self.protocol, self.host, endpoint) data = self.get_ddwrt_data(url) if not data: @@ -112,7 +117,10 @@ def _update_info(self): self.last_results = [] - active_clients = data.get('active_wireless', None) + 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 diff --git a/homeassistant/components/deconz/.translations/ca.json b/homeassistant/components/deconz/.translations/ca.json index 5f1ae46b48e882..7b69b7477f59c2 100644 --- a/homeassistant/components/deconz/.translations/ca.json +++ b/homeassistant/components/deconz/.translations/ca.json @@ -2,7 +2,9 @@ "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" }, @@ -15,7 +17,7 @@ "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 per a que es connecti amb la passarel\u00b7la deCONZ proporcionada per l\u2019add-on {addon} de hass.io?", + "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": { diff --git a/homeassistant/components/deconz/.translations/en.json b/homeassistant/components/deconz/.translations/en.json index 981f579f09f44c..dd8f1cc4026edb 100644 --- a/homeassistant/components/deconz/.translations/en.json +++ b/homeassistant/components/deconz/.translations/en.json @@ -2,7 +2,9 @@ "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" }, diff --git a/homeassistant/components/deconz/.translations/it.json b/homeassistant/components/deconz/.translations/it.json index dfff5743df7aa2..f15a2ddf26536a 100644 --- a/homeassistant/components/deconz/.translations/it.json +++ b/homeassistant/components/deconz/.translations/it.json @@ -3,7 +3,8 @@ "abort": { "already_configured": "Il Bridge \u00e8 gi\u00e0 configurato", "no_bridges": "Nessun bridge deCONZ rilevato", - "one_instance_only": "Il componente supporto solo un'istanza di 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" diff --git a/homeassistant/components/deconz/.translations/ko.json b/homeassistant/components/deconz/.translations/ko.json index f68b4dc10e9a51..4bf845d50e5fd2 100644 --- a/homeassistant/components/deconz/.translations/ko.json +++ b/homeassistant/components/deconz/.translations/ko.json @@ -2,7 +2,9 @@ "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" }, @@ -15,7 +17,7 @@ "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 \ubd80\uac00\uae30\ub2a5 {addon} \ub85c(\uc73c\ub85c) deCONZ \uac8c\uc774\ud2b8\uc6e8\uc774\uc5d0 \uc5f0\uacb0\ud558\ub3c4\ub85d Home Assistant \ub97c \uad6c\uc131 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "description": "Hass.io {addon} \uc560\ub4dc\uc628\uc73c\ub85c deCONZ \uac8c\uc774\ud2b8\uc6e8\uc774\uc5d0 \uc5f0\uacb0\ud558\ub3c4\ub85d Home Assistant \ub97c \uad6c\uc131 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", "title": "Hass.io \uc560\ub4dc\uc628\uc758 deCONZ Zigbee \uac8c\uc774\ud2b8\uc6e8\uc774" }, "init": { diff --git a/homeassistant/components/deconz/.translations/lb.json b/homeassistant/components/deconz/.translations/lb.json index 3308a557d5dfb1..60a27304d78437 100644 --- a/homeassistant/components/deconz/.translations/lb.json +++ b/homeassistant/components/deconz/.translations/lb.json @@ -2,7 +2,9 @@ "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" }, diff --git a/homeassistant/components/deconz/.translations/nl.json b/homeassistant/components/deconz/.translations/nl.json index d4b65f16552a8d..19477bbed3f33c 100644 --- a/homeassistant/components/deconz/.translations/nl.json +++ b/homeassistant/components/deconz/.translations/nl.json @@ -2,13 +2,24 @@ "config": { "abort": { "already_configured": "Bridge is al geconfigureerd", + "already_in_progress": "Configuratiestroom voor bridge wordt al ingesteld.", "no_bridges": "Geen deCONZ bruggen ontdekt", - "one_instance_only": "Component ondersteunt slechts \u00e9\u00e9n deCONZ instance" + "not_deconz_bridge": "Dit is geen deCONZ bridge", + "one_instance_only": "Component ondersteunt slechts \u00e9\u00e9n deCONZ instance", + "updated_instance": "DeCONZ-instantie bijgewerkt met nieuw host-adres" }, "error": { "no_key": "Kon geen API-sleutel ophalen" }, "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", diff --git a/homeassistant/components/deconz/.translations/no.json b/homeassistant/components/deconz/.translations/no.json index 7934d20ec53275..7c674c71022fc0 100644 --- a/homeassistant/components/deconz/.translations/no.json +++ b/homeassistant/components/deconz/.translations/no.json @@ -2,7 +2,9 @@ "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" }, diff --git a/homeassistant/components/deconz/.translations/pl.json b/homeassistant/components/deconz/.translations/pl.json index c3eded43341165..a17835f79a3020 100644 --- a/homeassistant/components/deconz/.translations/pl.json +++ b/homeassistant/components/deconz/.translations/pl.json @@ -2,7 +2,9 @@ "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" }, @@ -15,7 +17,7 @@ "allow_clip_sensor": "Zezwalaj na importowanie wirtualnych sensor\u00f3w", "allow_deconz_groups": "Zezw\u00f3l na importowanie grup deCONZ" }, - "description": "Czy chcesz skonfigurowa\u0107 Home Assistant'a, aby po\u0142\u0105czy\u0142 si\u0119 z bramk\u0105 deCONZ dostarczon\u0105 przez dodatek hass.io {addon}?", + "description": "Czy chcesz skonfigurowa\u0107 Home Assistant'a, 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": { diff --git a/homeassistant/components/deconz/.translations/pt-BR.json b/homeassistant/components/deconz/.translations/pt-BR.json index be79e7e461ae0b..dc7e682cafbcef 100644 --- a/homeassistant/components/deconz/.translations/pt-BR.json +++ b/homeassistant/components/deconz/.translations/pt-BR.json @@ -2,8 +2,11 @@ "config": { "abort": { "already_configured": "A ponte j\u00e1 est\u00e1 configurada", + "already_in_progress": "Fluxo de configura\u00e7\u00e3o para ponte j\u00e1 est\u00e1 em andamento.", "no_bridges": "N\u00e3o h\u00e1 pontes de deCONZ descobertas", - "one_instance_only": "Componente suporta apenas uma inst\u00e2ncia deCONZ" + "not_deconz_bridge": "N\u00e3o \u00e9 uma ponte deCONZ", + "one_instance_only": "Componente suporta apenas uma inst\u00e2ncia deCONZ", + "updated_instance": "Atualiza\u00e7\u00e3o da inst\u00e2ncia deCONZ com novo endere\u00e7o de host" }, "error": { "no_key": "N\u00e3o foi poss\u00edvel obter uma chave de API" diff --git a/homeassistant/components/deconz/.translations/ru.json b/homeassistant/components/deconz/.translations/ru.json index c4f2b2c4fab99a..ea701b3f759434 100644 --- a/homeassistant/components/deconz/.translations/ru.json +++ b/homeassistant/components/deconz/.translations/ru.json @@ -2,7 +2,9 @@ "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", + "already_in_progress": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0448\u043b\u044e\u0437\u0430 \u0443\u0436\u0435 \u043d\u0430\u0447\u0430\u0442\u0430.", "no_bridges": "\u0428\u043b\u044e\u0437\u044b deCONZ \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b", + "not_deconz_bridge": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u044f\u0432\u043b\u044f\u0435\u0442\u0441\u044f \u0448\u043b\u044e\u0437\u043e\u043c deCONZ", "one_instance_only": "\u041a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442 \u0442\u043e\u043b\u044c\u043a\u043e \u043e\u0434\u0438\u043d \u044d\u043a\u0437\u0435\u043c\u043f\u043b\u044f\u0440 deCONZ", "updated_instance": "\u0410\u0434\u0440\u0435\u0441 \u0445\u043e\u0441\u0442\u0430 deCONZ \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d" }, diff --git a/homeassistant/components/deconz/.translations/sl.json b/homeassistant/components/deconz/.translations/sl.json index 1a8550ca08fba6..58ecde32a8484d 100644 --- a/homeassistant/components/deconz/.translations/sl.json +++ b/homeassistant/components/deconz/.translations/sl.json @@ -2,7 +2,9 @@ "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" }, diff --git a/homeassistant/components/deconz/.translations/sv.json b/homeassistant/components/deconz/.translations/sv.json index 17367c49f5bcac..a7b5160e8a3c50 100644 --- a/homeassistant/components/deconz/.translations/sv.json +++ b/homeassistant/components/deconz/.translations/sv.json @@ -2,7 +2,9 @@ "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" }, diff --git a/homeassistant/components/deconz/.translations/zh-Hant.json b/homeassistant/components/deconz/.translations/zh-Hant.json index 06b174f27f53b4..0c9efd8992b873 100644 --- a/homeassistant/components/deconz/.translations/zh-Hant.json +++ b/homeassistant/components/deconz/.translations/zh-Hant.json @@ -2,7 +2,9 @@ "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 \u88dd\u7f6e", "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 \u5be6\u4f8b" }, diff --git a/homeassistant/components/deconz/deconz_device.py b/homeassistant/components/deconz/deconz_device.py index 90a5c8a3ddebef..8745cb2141a1a8 100644 --- a/homeassistant/components/deconz/deconz_device.py +++ b/homeassistant/components/deconz/deconz_device.py @@ -72,5 +72,5 @@ def device_info(self): 'model': self._device.modelid, 'name': self._device.name, 'sw_version': self._device.swversion, - 'via_hub': (DECONZ_DOMAIN, bridgeid), + 'via_device': (DECONZ_DOMAIN, bridgeid), } diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py index efdb8ad80919b2..cb60998137fcaf 100644 --- a/homeassistant/components/deconz/sensor.py +++ b/homeassistant/components/deconz/sensor.py @@ -100,7 +100,7 @@ def device_state_attributes(self): self._device.dark is not None: attr[ATTR_DARK] = self._device.dark - if self.unit_of_measurement == 'Watts': + if self.unit_of_measurement == 'W': attr[ATTR_CURRENT] = self._device.current attr[ATTR_VOLTAGE] = self._device.voltage diff --git a/homeassistant/components/default_config/manifest.json b/homeassistant/components/default_config/manifest.json index 992cb71c07c57c..6969d9bba7e9a4 100644 --- a/homeassistant/components/default_config/manifest.json +++ b/homeassistant/components/default_config/manifest.json @@ -7,7 +7,6 @@ "automation", "cloud", "config", - "conversation", "frontend", "history", "logbook", diff --git a/homeassistant/components/deluge/manifest.json b/homeassistant/components/deluge/manifest.json index 2b3c6d4c05505f..d33a140cedb914 100644 --- a/homeassistant/components/deluge/manifest.json +++ b/homeassistant/components/deluge/manifest.json @@ -3,7 +3,7 @@ "name": "Deluge", "documentation": "https://www.home-assistant.io/components/deluge", "requirements": [ - "deluge-client==1.4.0" + "deluge-client==1.7.1" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/demo/alarm_control_panel.py b/homeassistant/components/demo/alarm_control_panel.py index 3cf5aaca57e797..a960848eee7c80 100644 --- a/homeassistant/components/demo/alarm_control_panel.py +++ b/homeassistant/components/demo/alarm_control_panel.py @@ -12,7 +12,7 @@ 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, False, { + 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), diff --git a/homeassistant/components/denonavr/manifest.json b/homeassistant/components/denonavr/manifest.json index df7d58169e056a..5e40dbb89da102 100644 --- a/homeassistant/components/denonavr/manifest.json +++ b/homeassistant/components/denonavr/manifest.json @@ -3,7 +3,7 @@ "name": "Denonavr", "documentation": "https://www.home-assistant.io/components/denonavr", "requirements": [ - "denonavr==0.7.8" + "denonavr==0.7.9" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/device_automation/__init__.py b/homeassistant/components/device_automation/__init__.py new file mode 100644 index 00000000000000..67ad51210dfec5 --- /dev/null +++ b/homeassistant/components/device_automation/__init__.py @@ -0,0 +1,80 @@ +"""Helpers for device automations.""" +import asyncio +import logging + +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.core import split_entity_id +from homeassistant.helpers.entity_registry import async_entries_for_device +from homeassistant.loader import async_get_integration, IntegrationNotFound + +DOMAIN = 'device_automation' + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass, config): + """Set up device automation.""" + hass.components.websocket_api.async_register_command( + websocket_device_automation_list_triggers) + return True + + +async def _async_get_device_automation_triggers(hass, domain, device_id): + """List device triggers.""" + integration = None + try: + integration = await async_get_integration(hass, domain) + except IntegrationNotFound: + _LOGGER.warning('Integration %s not found', domain) + return None + + try: + platform = integration.get_platform('device_automation') + except ImportError: + # The domain does not have device automations + return None + + if hasattr(platform, 'async_get_triggers'): + return await platform.async_get_triggers(hass, device_id) + + +async def async_get_device_automation_triggers(hass, device_id): + """List device triggers.""" + device_registry, entity_registry = await asyncio.gather( + hass.helpers.device_registry.async_get_registry(), + hass.helpers.entity_registry.async_get_registry()) + + domains = set() + triggers = [] + 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) + + entities = async_entries_for_device(entity_registry, device_id) + for entity in entities: + domains.add(split_entity_id(entity.entity_id)[0]) + + device_triggers = await asyncio.gather(*[ + _async_get_device_automation_triggers(hass, domain, device_id) + for domain in domains + ]) + for device_trigger in device_triggers: + if device_trigger is not None: + triggers.extend(device_trigger) + + return triggers + + +@websocket_api.async_response +@websocket_api.websocket_command({ + vol.Required('type'): 'device_automation/list_triggers', + vol.Required('device_id'): str, +}) +async def websocket_device_automation_list_triggers(hass, connection, msg): + """Handle request for device triggers.""" + device_id = msg['device_id'] + triggers = await async_get_device_automation_triggers(hass, device_id) + connection.send_result(msg['id'], {'triggers': triggers}) diff --git a/homeassistant/components/device_automation/manifest.json b/homeassistant/components/device_automation/manifest.json new file mode 100644 index 00000000000000..a95e9c4f68fbb1 --- /dev/null +++ b/homeassistant/components/device_automation/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "device_automation", + "name": "Device automation", + "documentation": "https://www.home-assistant.io/components/device_automation", + "requirements": [], + "dependencies": [ + "webhook" + ], + "codeowners": [ + "@home-assistant/core" + ] +} diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py index 1fdd807772801a..1a2e7c854e50e1 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -14,6 +14,7 @@ from homeassistant.config import load_yaml_config_file, async_log_exception 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 from homeassistant import util @@ -115,6 +116,7 @@ async def async_see( 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: @@ -134,6 +136,14 @@ async def async_see( 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( diff --git a/homeassistant/components/dialogflow/.translations/ca.json b/homeassistant/components/dialogflow/.translations/ca.json index 0967b1c158e7d4..f6dfc9399c2807 100644 --- a/homeassistant/components/dialogflow/.translations/ca.json +++ b/homeassistant/components/dialogflow/.translations/ca.json @@ -10,7 +10,7 @@ "step": { "user": { "description": "Est\u00e0s segur que vols configurar Dialogflow?", - "title": "Configuraci\u00f3 del Webhook Dialogflow" + "title": "Configuraci\u00f3 del Webhook de Dialogflow" } }, "title": "Dialogflow" diff --git a/homeassistant/components/dialogflow/.translations/fr.json b/homeassistant/components/dialogflow/.translations/fr.json index 53edb21b8e82c3..e9eabeff6381d9 100644 --- a/homeassistant/components/dialogflow/.translations/fr.json +++ b/homeassistant/components/dialogflow/.translations/fr.json @@ -5,7 +5,7 @@ "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 Mailgun] ( {mailgun_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." + "default": "Pour envoyer des \u00e9v\u00e9nements \u00e0 Home Assistant, vous devez configurer [Webhooks avec Mailgun] ( {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": { diff --git a/homeassistant/components/discord/manifest.json b/homeassistant/components/discord/manifest.json index 05b2a3c8e06215..fd496b3402bcc9 100644 --- a/homeassistant/components/discord/manifest.json +++ b/homeassistant/components/discord/manifest.json @@ -3,7 +3,7 @@ "name": "Discord", "documentation": "https://www.home-assistant.io/components/discord", "requirements": [ - "discord.py==1.0.1" + "discord.py==1.1.1" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py index a7c306ad241147..229e64ad682179 100644 --- a/homeassistant/components/discovery/__init__.py +++ b/homeassistant/components/discovery/__init__.py @@ -47,10 +47,7 @@ CONFIG_ENTRY_HANDLERS = { SERVICE_DAIKIN: 'daikin', - 'google_cast': 'cast', - SERVICE_HEOS: 'heos', SERVICE_TELLDUSLIVE: 'tellduslive', - 'sonos': 'sonos', SERVICE_IGD: 'upnp', } @@ -97,9 +94,12 @@ 'axis', 'deconz', 'esphome', - 'ikea_tradfri', + 'google_cast', + SERVICE_HEOS, 'homekit', + 'ikea_tradfri', 'philips_hue', + 'sonos', SERVICE_WEMO, ] diff --git a/homeassistant/components/dlib_face_identify/image_processing.py b/homeassistant/components/dlib_face_identify/image_processing.py index 569b1ecece2bcf..1dd7b6e46265da 100644 --- a/homeassistant/components/dlib_face_identify/image_processing.py +++ b/homeassistant/components/dlib_face_identify/image_processing.py @@ -7,7 +7,7 @@ from homeassistant.core import split_entity_id from homeassistant.components.image_processing import ( ImageProcessingFaceEntity, PLATFORM_SCHEMA, CONF_SOURCE, CONF_ENTITY_ID, - CONF_NAME) + CONF_NAME, CONF_CONFIDENCE) import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -17,6 +17,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_FACES): {cv.string: cv.isfile}, + vol.Optional(CONF_CONFIDENCE, default=0.6): vol.Coerce(float), }) @@ -25,7 +26,8 @@ def setup_platform(hass, config, add_entities, discovery_info=None): entities = [] for camera in config[CONF_SOURCE]: entities.append(DlibFaceIdentifyEntity( - camera[CONF_ENTITY_ID], config[CONF_FACES], camera.get(CONF_NAME) + camera[CONF_ENTITY_ID], config[CONF_FACES], camera.get(CONF_NAME), + config[CONF_CONFIDENCE] )) add_entities(entities) @@ -34,7 +36,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class DlibFaceIdentifyEntity(ImageProcessingFaceEntity): """Dlib Face API entity for identify.""" - def __init__(self, camera_entity, faces, name=None): + def __init__(self, camera_entity, faces, name, tolerance): """Initialize Dlib face identify entry.""" # pylint: disable=import-error import face_recognition @@ -57,6 +59,8 @@ def __init__(self, camera_entity, faces, name=None): 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.""" @@ -82,7 +86,10 @@ def process_image(self, image): found = [] for unknown_face in unknowns: for name, face in self._faces.items(): - result = face_recognition.compare_faces([face], unknown_face) + result = face_recognition.compare_faces( + [face], + unknown_face, + tolerance=self._tolerance) if result[0]: found.append({ ATTR_NAME: name diff --git a/homeassistant/components/ebusd/.translations/pt-BR.json b/homeassistant/components/ebusd/.translations/pt-BR.json new file mode 100644 index 00000000000000..9925fdfab9cc34 --- /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/__init__.py b/homeassistant/components/ebusd/__init__.py index 15ff523f4fbf90..e662e661afb3a4 100644 --- a/homeassistant/components/ebusd/__init__.py +++ b/homeassistant/components/ebusd/__init__.py @@ -23,15 +23,29 @@ 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( + "Condition '" + condition + "' not in '" + circuit + "'.") + return config + + CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - 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=[]): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES['700'])]) - }) + 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) diff --git a/homeassistant/components/elv/__init__.py b/homeassistant/components/elv/__init__.py new file mode 100644 index 00000000000000..13ade253ff659a --- /dev/null +++ b/homeassistant/components/elv/__init__.py @@ -0,0 +1 @@ +"""The Elv integration.""" diff --git a/homeassistant/components/elv/manifest.json b/homeassistant/components/elv/manifest.json new file mode 100644 index 00000000000000..4c9ed56352ecd8 --- /dev/null +++ b/homeassistant/components/elv/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "elv", + "name": "ELV PCA", + "documentation": "https://www.home-assistant.io/components/pca", + "dependencies": [], + "codeowners": ["@majuss"], + "requirements": ["pypca==0.0.4"] + } \ No newline at end of file diff --git a/homeassistant/components/elv/switch.py b/homeassistant/components/elv/switch.py new file mode 100644 index 00000000000000..bd97d10cecf4dc --- /dev/null +++ b/homeassistant/components/elv/switch.py @@ -0,0 +1,103 @@ +"""Support for PCA 301 smart switch.""" +import logging + +import voluptuous as vol + +from homeassistant.components.switch import ( + SwitchDevice, PLATFORM_SCHEMA, ATTR_CURRENT_POWER_W) +from homeassistant.const import ( + CONF_NAME, CONF_DEVICE, EVENT_HOMEASSISTANT_STOP) +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +ATTR_TOTAL_ENERGY_KWH = 'total_energy_kwh' + +DEFAULT_NAME = 'PCA 301' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_DEVICE): cv.string +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the PCA switch platform.""" + import pypca + from serial import SerialException + + name = config[CONF_NAME] + usb_device = config[CONF_DEVICE] + + try: + pca = pypca.PCA(usb_device) + pca.open() + entities = [SmartPlugSwitch(pca, device, name) + 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, name): + """Initialize the switch.""" + self._device_id = device_id + self._name = name + 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/environment_canada/__init__.py b/homeassistant/components/environment_canada/__init__.py new file mode 100644 index 00000000000000..356e18fe23fd4d --- /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 100755 index 00000000000000..18a88129e1dd01 --- /dev/null +++ b/homeassistant/components/environment_canada/camera.py @@ -0,0 +1,101 @@ +""" +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 + +import voluptuous as vol + +from homeassistant.components.camera import ( + PLATFORM_SCHEMA, Camera) +from homeassistant.const import ( + CONF_NAME, CONF_LATITUDE, CONF_LONGITUDE, ATTR_ATTRIBUTION) +from homeassistant.util import Throttle +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +ATTR_STATION = 'station' +ATTR_LOCATION = 'location' + +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.string, + 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.""" + from env_canada import ECRadar + + if config.get(CONF_STATION): + radar_object = ECRadar(station_id=config[CONF_STATION], + precip_type=config.get(CONF_PRECIP_TYPE)) + elif config.get(CONF_LATITUDE) and config.get(CONF_LONGITUDE): + radar_object = ECRadar(coordinates=(config[CONF_LATITUDE], + config[CONF_LONGITUDE]), + precip_type=config.get(CONF_PRECIP_TYPE)) + else: + radar_object = ECRadar(coordinates=(hass.config.latitude, + hass.config.longitude), + precip_type=config.get(CONF_PRECIP_TYPE)) + + 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 + + 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 + } + + 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() diff --git a/homeassistant/components/environment_canada/manifest.json b/homeassistant/components/environment_canada/manifest.json new file mode 100644 index 00000000000000..ea809238499199 --- /dev/null +++ b/homeassistant/components/environment_canada/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "environment_canada", + "name": "Environment Canada", + "documentation": "https://www.home-assistant.io/components/environment_canada", + "requirements": [ + "env_canada==0.0.10" + ], + "dependencies": [], + "codeowners": [ + "@michaeldavie" + ] +} diff --git a/homeassistant/components/environment_canada/sensor.py b/homeassistant/components/environment_canada/sensor.py new file mode 100755 index 00000000000000..c0b78cd4f3509b --- /dev/null +++ b/homeassistant/components/environment_canada/sensor.py @@ -0,0 +1,178 @@ +""" +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/ +""" +import datetime +import logging +import re + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_MONITORED_CONDITIONS, TEMP_CELSIUS, CONF_NAME, CONF_LATITUDE, + CONF_LONGITUDE, ATTR_ATTRIBUTION, ATTR_LOCATION, ATTR_HIDDEN) +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle +import homeassistant.util.dt as dt +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +ATTR_UPDATED = 'updated' +ATTR_STATION = 'station' +ATTR_DETAIL = 'alert detail' +ATTR_TIME = 'alert time' + +CONF_ATTRIBUTION = "Data provided by Environment Canada" +CONF_STATION = 'station' + +MIN_TIME_BETWEEN_UPDATES = datetime.timedelta(minutes=10) + +SENSOR_TYPES = { + 'temperature': {'name': 'Temperature', + 'unit': TEMP_CELSIUS}, + 'dewpoint': {'name': 'Dew Point', + 'unit': TEMP_CELSIUS}, + 'wind_chill': {'name': 'Wind Chill', + 'unit': TEMP_CELSIUS}, + 'humidex': {'name': 'Humidex', + 'unit': TEMP_CELSIUS}, + 'pressure': {'name': 'Pressure', + 'unit': 'kPa'}, + 'tendency': {'name': 'Tendency'}, + 'humidity': {'name': 'Humidity', + 'unit': '%'}, + 'visibility': {'name': 'Visibility', + 'unit': 'km'}, + 'condition': {'name': 'Condition'}, + 'wind_speed': {'name': 'Wind Speed', + 'unit': 'km/h'}, + 'wind_gust': {'name': 'Wind Gust', + 'unit': 'km/h'}, + 'wind_dir': {'name': 'Wind Direction'}, + 'high_temp': {'name': 'High Temperature', + 'unit': TEMP_CELSIUS}, + 'low_temp': {'name': 'Low Temperature', + 'unit': TEMP_CELSIUS}, + 'pop': {'name': 'Chance of Precip.', + 'unit': '%'}, + 'warnings': {'name': 'Warnings'}, + 'watches': {'name': 'Watches'}, + 'advisories': {'name': 'Advisories'}, + 'statements': {'name': 'Statements'}, + 'endings': {'name': 'Ended'} +} + + +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_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), + 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, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Environment Canada sensor.""" + from env_canada import ECData + + if config.get(CONF_STATION): + ec_data = ECData(station_id=config[CONF_STATION]) + elif config.get(CONF_LATITUDE) and config.get(CONF_LONGITUDE): + ec_data = ECData(coordinates=(config[CONF_LATITUDE], + config[CONF_LONGITUDE])) + else: + ec_data = ECData(coordinates=(hass.config.latitude, + hass.config.longitude)) + + add_devices([ECSensor(sensor_type, ec_data, config.get(CONF_NAME)) + for sensor_type in config[CONF_MONITORED_CONDITIONS]], + True) + + +class ECSensor(Entity): + """Implementation of an Environment Canada sensor.""" + + def __init__(self, sensor_type, ec_data, platform_name): + """Initialize the sensor.""" + self.sensor_type = sensor_type + self.ec_data = ec_data + self.platform_name = platform_name + self._state = None + self._attr = None + + @property + def name(self): + """Return the name of the sensor.""" + if self.platform_name is None: + return SENSOR_TYPES[self.sensor_type]['name'] + + return ' '.join([self.platform_name, + SENSOR_TYPES[self.sensor_type]['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 SENSOR_TYPES[self.sensor_type].get('unit') + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Update current conditions.""" + self.ec_data.update() + self.ec_data.conditions.update(self.ec_data.alerts) + + self._attr = {} + + sensor_data = self.ec_data.conditions.get(self.sensor_type) + if isinstance(sensor_data, list): + self._state = ' | '.join([str(s.get('title')) + for s in sensor_data]) + self._attr.update({ + ATTR_DETAIL: ' | '.join([str(s.get('detail')) + for s in sensor_data]), + ATTR_TIME: ' | '.join([str(s.get('date')) + for s in sensor_data]) + }) + else: + self._state = sensor_data + + timestamp = self.ec_data.conditions.get('timestamp') + if timestamp: + updated_utc = datetime.datetime.strptime(timestamp, '%Y%m%d%H%M%S') + updated_local = dt.as_local(updated_utc).isoformat() + else: + updated_local = None + + hidden = bool(self._state is None or self._state == '') + + self._attr.update({ + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + ATTR_UPDATED: updated_local, + ATTR_LOCATION: self.ec_data.conditions.get('location'), + ATTR_STATION: self.ec_data.conditions.get('station'), + ATTR_HIDDEN: hidden + }) diff --git a/homeassistant/components/environment_canada/weather.py b/homeassistant/components/environment_canada/weather.py new file mode 100644 index 00000000000000..0589a23445ec5f --- /dev/null +++ b/homeassistant/components/environment_canada/weather.py @@ -0,0 +1,219 @@ +""" +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) +from homeassistant.util import Throttle +import homeassistant.util.dt as dt +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +CONF_FORECAST = 'forecast' +CONF_ATTRIBUTION = "Data provided by Environment Canada" +CONF_STATION = 'station' + +MIN_TIME_BETWEEN_UPDATES = datetime.timedelta(minutes=10) + + +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]) + elif config.get(CONF_LATITUDE) and config.get(CONF_LONGITUDE): + ec_data = ECData(coordinates=(config[CONF_LATITUDE], + config[CONF_LONGITUDE])) + else: + ec_data = ECData(coordinates=(hass.config.latitude, + hass.config.longitude)) + + 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.conditions['location'] + + @property + def temperature(self): + """Return the temperature.""" + if self.ec_data.conditions.get('temperature'): + return float(self.ec_data.conditions['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'): + return float(self.ec_data.conditions['humidity']) + return None + + @property + def wind_speed(self): + """Return the wind speed.""" + if self.ec_data.conditions.get('wind_speed'): + return float(self.ec_data.conditions['wind_speed']) + return None + + @property + def wind_bearing(self): + """Return the wind bearing.""" + if self.ec_data.conditions.get('wind_bearing'): + return float(self.ec_data.conditions['wind_bearing']) + return None + + @property + def pressure(self): + """Return the pressure.""" + if self.ec_data.conditions.get('pressure'): + return 10 * float(self.ec_data.conditions['pressure']) + return None + + @property + def visibility(self): + """Return the visibility.""" + if self.ec_data.conditions.get('visibility'): + return float(self.ec_data.conditions['visibility']) + return None + + @property + def condition(self): + """Return the weather condition.""" + icon_code = self.ec_data.conditions.get('icon_code') + if icon_code: + return icon_code_to_condition(int(icon_code)) + condition = self.ec_data.conditions.get('condition') + if condition: + return condition + return 'Condition not observed' + + @property + def forecast(self): + """Return the forecast array.""" + return get_forecast(self.ec_data, self.forecast_type) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + 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/esphome/.translations/de.json b/homeassistant/components/esphome/.translations/de.json index 30cbf09525f884..80111f34984cbd 100644 --- a/homeassistant/components/esphome/.translations/de.json +++ b/homeassistant/components/esphome/.translations/de.json @@ -8,6 +8,7 @@ "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": { diff --git a/homeassistant/components/esphome/.translations/hu.json b/homeassistant/components/esphome/.translations/hu.json index c665637ba05248..628983fec03704 100644 --- a/homeassistant/components/esphome/.translations/hu.json +++ b/homeassistant/components/esphome/.translations/hu.json @@ -8,6 +8,7 @@ "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": { diff --git a/homeassistant/components/esphome/.translations/it.json b/homeassistant/components/esphome/.translations/it.json index 47047a95560596..b9088c2eadc34e 100644 --- a/homeassistant/components/esphome/.translations/it.json +++ b/homeassistant/components/esphome/.translations/it.json @@ -8,6 +8,7 @@ "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": { @@ -16,6 +17,10 @@ "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", diff --git a/homeassistant/components/esphome/.translations/ko.json b/homeassistant/components/esphome/.translations/ko.json index f58d43f9df9ae6..b6bcf3cd1b3377 100644 --- a/homeassistant/components/esphome/.translations/ko.json +++ b/homeassistant/components/esphome/.translations/ko.json @@ -8,6 +8,7 @@ "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": { diff --git a/homeassistant/components/esphome/.translations/lb.json b/homeassistant/components/esphome/.translations/lb.json index a240debfaf5af6..955b050bc5b19d 100644 --- a/homeassistant/components/esphome/.translations/lb.json +++ b/homeassistant/components/esphome/.translations/lb.json @@ -8,6 +8,7 @@ "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": { diff --git a/homeassistant/components/esphome/.translations/nl.json b/homeassistant/components/esphome/.translations/nl.json index aba738f4e0f6fb..a56130b2263980 100644 --- a/homeassistant/components/esphome/.translations/nl.json +++ b/homeassistant/components/esphome/.translations/nl.json @@ -8,6 +8,7 @@ "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": { diff --git a/homeassistant/components/esphome/.translations/no.json b/homeassistant/components/esphome/.translations/no.json index c71424b6f00e57..f7dac2a9d568dd 100644 --- a/homeassistant/components/esphome/.translations/no.json +++ b/homeassistant/components/esphome/.translations/no.json @@ -8,6 +8,7 @@ "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": { diff --git a/homeassistant/components/esphome/.translations/pl.json b/homeassistant/components/esphome/.translations/pl.json index 5693efde9a8d50..c8e6012ea94582 100644 --- a/homeassistant/components/esphome/.translations/pl.json +++ b/homeassistant/components/esphome/.translations/pl.json @@ -8,12 +8,13 @@ "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 {nazwa}.", + "description": "Wprowad\u017a has\u0142o ustawione w konfiguracji dla {name}.", "title": "Wprowad\u017a has\u0142o" }, "discovery_confirm": { diff --git a/homeassistant/components/esphome/.translations/pt-BR.json b/homeassistant/components/esphome/.translations/pt-BR.json index 87adc69021c699..77a98a875ba34d 100644 --- a/homeassistant/components/esphome/.translations/pt-BR.json +++ b/homeassistant/components/esphome/.translations/pt-BR.json @@ -8,6 +8,7 @@ "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": { @@ -16,6 +17,9 @@ "description": "Por favor, digite a senha que voc\u00ea definiu em sua configura\u00e7\u00e3o.", "title": "Digite a senha" }, + "discovery_confirm": { + "title": "N\u00f3 ESPHome descoberto" + }, "user": { "data": { "port": "Porta" diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 395c145e5df241..db5aeea2aa1fae 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -6,7 +6,7 @@ from aioesphomeapi import ( APIClient, APIConnectionError, DeviceInfo, EntityInfo, EntityState, - ServiceCall, UserService, UserServiceArgType) + HomeassistantServiceCall, UserService, UserServiceArgType) import voluptuous as vol from homeassistant import const @@ -39,18 +39,6 @@ STORAGE_KEY = 'esphome.{}' STORAGE_VERSION = 1 -# The HA component types this integration supports -HA_COMPONENTS = [ - 'binary_sensor', - 'camera', - 'climate', - 'cover', - 'fan', - 'light', - 'sensor', - 'switch', -] - # No config schema - only configuration entry CONFIG_SCHEMA = vol.Schema({}, extra=vol.ALLOW_EXTRA) @@ -98,7 +86,7 @@ def async_on_state(state: EntityState) -> None: entry_data.async_update_state(hass, state) @callback - def async_on_service_call(service: ServiceCall) -> None: + 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 @@ -114,8 +102,17 @@ def async_on_service_call(service: ServiceCall) -> None: _LOGGER.error('Error rendering data template: %s', ex) return - hass.async_create_task(hass.services.async_call( - domain, service_name, service_data, blocking=True)) + 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: @@ -144,7 +141,8 @@ async def on_login() -> None: entry_data.async_update_device_state(hass) entity_infos, services = await cli.list_entities_services() - entry_data.async_update_static_infos(hass, entity_infos) + 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) @@ -162,14 +160,8 @@ async def on_login() -> None: async def complete_setup() -> None: """Complete the config entry setup.""" - tasks = [] - for component in HA_COMPONENTS: - tasks.append(hass.config_entries.async_forward_entry_setup( - entry, component)) - await asyncio.wait(tasks) - infos, services = await entry_data.async_load_from_store() - entry_data.async_update_static_infos(hass, infos) + 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 @@ -239,7 +231,7 @@ 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_core_version + sw_version = device_info.esphome_version if device_info.compilation_time: sw_version += ' ({})'.format(device_info.compilation_time) device_registry = await dr.async_get_registry(hass) @@ -266,6 +258,10 @@ async def _register_service(hass: HomeAssistantType, 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): @@ -308,7 +304,7 @@ async def _setup_services(hass: HomeAssistantType, async def _cleanup_instance(hass: HomeAssistantType, - entry: ConfigEntry) -> None: + entry: ConfigEntry) -> RuntimeEntryData: """Cleanup the esphome client if it exists.""" data = hass.data[DATA_KEY].pop(entry.entry_id) # type: RuntimeEntryData if data.reconnect_task is not None: @@ -318,19 +314,19 @@ async def _cleanup_instance(hass: HomeAssistantType, 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.""" - await _cleanup_instance(hass, entry) - + entry_data = await _cleanup_instance(hass, entry) tasks = [] - for component in HA_COMPONENTS: + for platform in entry_data.loaded_platforms: tasks.append(hass.config_entries.async_forward_entry_unload( - entry, component)) - await asyncio.wait(tasks) - + entry, platform)) + if tasks: + await asyncio.wait(tasks) return True diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index b2a96ed53f3a77..2ce749d6ae9f6f 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -22,7 +22,6 @@ def __init__(self): self._host = None # type: Optional[str] self._port = None # type: Optional[int] self._password = None # type: Optional[str] - self._name = None # type: Optional[str] async def async_step_user(self, user_input: Optional[ConfigType] = None, error: Optional[str] = None): @@ -44,34 +43,41 @@ async def async_step_user(self, user_input: Optional[ConfigType] = None, errors=errors ) - async def _async_authenticate_or_add(self, user_input, - from_discovery=False): + @property + def _name(self): + return self.context.get('name') + + @_name.setter + def _name(self, value): + # pylint: disable=unsupported-assignment-operation + 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 - # pylint: disable=unsupported-assignment-operation - self.context['title_placeholders'] = { - 'name': self._name - } - self.context['name'] = self._name # Only show authentication step if device uses password if device_info.uses_password: return await self.async_step_authenticate() - if from_discovery: - # If from discovery, do not create entry immediately, - # First present user with message - return await self.async_step_discovery_confirm() 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 self._async_get_entry() + return await self._async_authenticate_or_add(None) return self.async_show_form( step_id='discovery_confirm', description_placeholders={'name': self._name}, @@ -101,14 +107,16 @@ async def async_step_zeroconf(self, user_input: ConfigType): 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']['name'] == node_name: + if flow['context'].get('name') == node_name: return self.async_abort(reason='already_configured') - return await self._async_authenticate_or_add(user_input={ - 'host': address, - 'port': user_input['port'], - }, from_discovery=True) + return await self.async_step_discovery_confirm() def _async_get_entry(self): return self.async_create_entry( diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 47cadc00653103..4e78718b760b78 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -1,11 +1,15 @@ """Runtime entry data for ESPHome stored in hass.data.""" import asyncio -from typing import Any, Callable, Dict, List, Optional, Tuple +from typing import Any, Callable, Dict, List, Optional, Tuple, Set from aioesphomeapi import ( - COMPONENT_TYPE_TO_INFO, DeviceInfo, EntityInfo, EntityState, UserService) + COMPONENT_TYPE_TO_INFO, DeviceInfo, EntityInfo, EntityState, UserService, + BinarySensorInfo, + CameraInfo, ClimateInfo, CoverInfo, FanInfo, LightInfo, SensorInfo, + SwitchInfo, TextSensorInfo) 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 @@ -17,6 +21,19 @@ 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: @@ -33,6 +50,8 @@ class RuntimeEntryData: 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: @@ -48,9 +67,33 @@ def async_remove_entity(self, hass: HomeAssistantType, component_key: str, entry_id=self.entry_id, component_key=component_key, key=key) async_dispatcher_send(hass, signal) - def async_update_static_infos(self, hass: HomeAssistantType, - infos: List[EntityInfo]) -> None: + 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) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index a986a8641897b6..43987cce2c9789 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/components/esphome", "requirements": [ - "aioesphomeapi==2.1.0" + "aioesphomeapi==2.2.0" ], "dependencies": [], "zeroconf": ["_esphomelib._tcp.local."], diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index a18ed6eb3d1d2e..b295c94ec31701 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -24,6 +24,8 @@ 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' @@ -55,6 +57,8 @@ 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' @@ -71,6 +75,10 @@ }), 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, @@ -184,6 +192,15 @@ def add_extra_html_url(hass, url, es5=False): 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 @@ -249,6 +266,18 @@ async def async_setup(hass, config): 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 @@ -396,6 +425,8 @@ async def get(self, request: web.Request): 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' ) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 0d517aa6560523..d4bd24f8ab7316 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/components/frontend", "requirements": [ - "home-assistant-frontend==20190604.0" + "home-assistant-frontend==20190626.0" ], "dependencies": [ "api", diff --git a/homeassistant/components/geniushub/binary_sensor.py b/homeassistant/components/geniushub/binary_sensor.py index cbea4147e73dfb..c0f0d90028dffd 100644 --- a/homeassistant/components/geniushub/binary_sensor.py +++ b/homeassistant/components/geniushub/binary_sensor.py @@ -18,8 +18,9 @@ async def async_setup_platform(hass, config, async_add_entities, """Set up the Genius Hub sensor entities.""" client = hass.data[DOMAIN]['client'] + devices = [d for d in client.hub.device_objs if d.type is not None] switches = [GeniusBinarySensor(client, d) - for d in client.hub.device_objs if d.type[:21] in GH_IS_SWITCH] + for d in devices if d.type[:21] in GH_IS_SWITCH] async_add_entities(switches) diff --git a/homeassistant/components/geniushub/manifest.json b/homeassistant/components/geniushub/manifest.json index b2c7286a2d53c2..7c82ceeca44e7b 100644 --- a/homeassistant/components/geniushub/manifest.json +++ b/homeassistant/components/geniushub/manifest.json @@ -3,7 +3,7 @@ "name": "Genius Hub", "documentation": "https://www.home-assistant.io/components/geniushub", "requirements": [ - "geniushub-client==0.4.11" + "geniushub-client==0.4.12" ], "dependencies": [], "codeowners": ["@zxdavb"] diff --git a/homeassistant/components/geniushub/water_heater.py b/homeassistant/components/geniushub/water_heater.py index 6efbed514ee666..3b40bafa69913e 100644 --- a/homeassistant/components/geniushub/water_heater.py +++ b/homeassistant/components/geniushub/water_heater.py @@ -94,7 +94,10 @@ def should_poll(self) -> bool: @property def current_temperature(self): """Return the current temperature.""" - return self._boiler.temperature + try: + return self._boiler.temperature + except AttributeError: + return None @property def target_temperature(self): diff --git a/homeassistant/components/geofency/.translations/ca.json b/homeassistant/components/geofency/.translations/ca.json index 125ca51399a2de..44377ce3021413 100644 --- a/homeassistant/components/geofency/.translations/ca.json +++ b/homeassistant/components/geofency/.translations/ca.json @@ -9,10 +9,10 @@ }, "step": { "user": { - "description": "Est\u00e0s segur que vols configurar el Webhook Geofency?", - "title": "Configuraci\u00f3 del Webhook Geofency" + "description": "Est\u00e0s segur que vols configurar el Webhook de Geofency?", + "title": "Configuraci\u00f3 del Webhook de Geofency" } }, - "title": "Webhook Geofency" + "title": "Webhook de Geofency" } } \ No newline at end of file diff --git a/homeassistant/components/gitlab_ci/sensor.py b/homeassistant/components/gitlab_ci/sensor.py index 54cbf34fdfc2c8..1d59a5e4f21a5e 100644 --- a/homeassistant/components/gitlab_ci/sensor.py +++ b/homeassistant/components/gitlab_ci/sensor.py @@ -30,7 +30,7 @@ ICON_HAPPY = 'mdi:emoticon-happy' ICON_OTHER = 'mdi:git' -ICON_SAD = 'mdi:emoticon-happy' +ICON_SAD = 'mdi:emoticon-sad' SCAN_INTERVAL = timedelta(seconds=300) diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py index 534b4c5cd59c5d..2b35e35669e764 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -26,13 +26,13 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) SENSOR_TYPES = { - 'disk_use_percent': ['Disk used', '%', 'mdi:harddisk'], + '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', '%', 'mdi:memory'], + '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', '%', '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'], diff --git a/homeassistant/components/google/__init__.py b/homeassistant/components/google/__init__.py index e9bbf3f96cdd9f..027a6b2f56863a 100644 --- a/homeassistant/components/google/__init__.py +++ b/homeassistant/components/google/__init__.py @@ -1,4 +1,5 @@ """Support for Google - Calendar Event Devices.""" +from datetime import timedelta, datetime import logging import os import yaml @@ -35,17 +36,32 @@ 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' +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 = '{}_calendars.yaml'.format(DOMAIN) -SCOPES = 'https://www.googleapis.com/auth/calendar.readonly' +SCOPES = 'https://www.googleapis.com/auth/calendar' TOKEN_FILE = '.{}.token'.format(DOMAIN) @@ -73,6 +89,27 @@ 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. @@ -87,10 +124,9 @@ def do_authentication(hass, hass_config, config): oauth = OAuth2WebServerFlow( client_id=config[CONF_CLIENT_ID], client_secret=config[CONF_CLIENT_SECRET], - scope='https://www.googleapis.com/auth/calendar.readonly', + 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: @@ -155,8 +191,20 @@ def setup(hass, config): if not os.path.isfile(token_file): do_authentication(hass, config, conf) else: - do_setup(hass, config, conf) + 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 @@ -195,6 +243,61 @@ def _scan_for_calendars(service): 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 diff --git a/homeassistant/components/google/services.yaml b/homeassistant/components/google/services.yaml index 34eecb33fd5e47..048e886dc4e565 100644 --- a/homeassistant/components/google/services.yaml +++ b/homeassistant/components/google/services.yaml @@ -2,3 +2,30 @@ 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/helpers.py b/homeassistant/components/google_assistant/helpers.py index 770a502ad5dbdb..87c4fb78f3a533 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -17,24 +17,32 @@ from .error import SmartHomeError -class Config: +class AbstractConfig: """Hold the configuration for Google Assistant.""" - def __init__(self, should_expose, - entity_config=None, secure_devices_pin=None, - agent_user_id=None, should_2fa=None): - """Initialize the configuration.""" - self.should_expose = should_expose - self.entity_config = entity_config or {} - self.secure_devices_pin = secure_devices_pin - self._should_2fa = should_2fa + @property + def agent_user_id(self): + """Return Agent User Id to use for query responses.""" + return None + + @property + def entity_config(self): + """Return entity config.""" + return {} + + @property + def secure_devices_pin(self): + """Return entity config.""" + return None - # Agent User Id to use for query responses - self.agent_user_id = agent_user_id + def should_expose(self, state) -> bool: + """Return if entity should be exposed.""" + raise NotImplementedError def should_2fa(self, state): """If an entity should have 2FA checked.""" - return self._should_2fa is None or self._should_2fa(state) + # pylint: disable=no-self-use + return True class RequestData: diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index d385d742c7d180..95528eea3cae2e 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -17,33 +17,50 @@ CONF_SECURE_DEVICES_PIN, ) from .smart_home import async_handle_message -from .helpers import Config +from .helpers import AbstractConfig _LOGGER = logging.getLogger(__name__) -@callback -def async_register_http(hass, cfg): - """Register HTTP views for Google Assistant.""" - expose_by_default = cfg.get(CONF_EXPOSE_BY_DEFAULT) - exposed_domains = cfg.get(CONF_EXPOSED_DOMAINS) - entity_config = cfg.get(CONF_ENTITY_CONFIG) or {} - secure_devices_pin = cfg.get(CONF_SECURE_DEVICES_PIN) - - def is_exposed(entity) -> bool: - """Determine if an entity should be exposed to Google Assistant.""" - if entity.attributes.get('view') is not None: +class GoogleConfig(AbstractConfig): + """Config for manual setup of Google.""" + + def __init__(self, config): + """Initialize the config.""" + self._config = config + + @property + def agent_user_id(self): + """Return Agent User Id to use for query responses.""" + return None + + @property + def entity_config(self): + """Return entity config.""" + return self._config.get(CONF_ENTITY_CONFIG, {}) + + @property + def secure_devices_pin(self): + """Return entity config.""" + return self._config.get(CONF_SECURE_DEVICES_PIN) + + 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 entity.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: return False explicit_expose = \ - entity_config.get(entity.entity_id, {}).get(CONF_EXPOSE) + self.entity_config.get(state.entity_id, {}).get(CONF_EXPOSE) domain_exposed_by_default = \ - expose_by_default and entity.domain in exposed_domains + 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 @@ -53,13 +70,15 @@ def is_exposed(entity) -> bool: return is_default_exposed or explicit_expose - config = Config( - should_expose=is_exposed, - entity_config=entity_config, - secure_devices_pin=secure_devices_pin - ) + def should_2fa(self, state): + """If an entity should have 2FA checked.""" + return True - hass.http.register_view(GoogleAssistantView(config)) + +@callback +def async_register_http(hass, cfg): + """Register HTTP views for Google Assistant.""" + hass.http.register_view(GoogleAssistantView(GoogleConfig(cfg))) class GoogleAssistantView(HomeAssistantView): diff --git a/homeassistant/components/google_cloud/__init__.py b/homeassistant/components/google_cloud/__init__.py new file mode 100644 index 00000000000000..97b669245d2de8 --- /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 00000000000000..c8ac0d2e81e588 --- /dev/null +++ b/homeassistant/components/google_cloud/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "google_cloud", + "name": "Google Cloud Platform", + "documentation": "https://www.home-assistant.io/components/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 00000000000000..4f0c2c20914b24 --- /dev/null +++ b/homeassistant/components/google_cloud/tts.py @@ -0,0 +1,253 @@ +"""Support for the Google Cloud TTS service.""" +import logging +import os + +import asyncio +import async_timeout +import voluptuous as vol +from google.cloud import texttospeech + +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 = [ + 'da-DK', 'de-DE', 'en-AU', 'en-GB', 'en-US', 'es-ES', 'fr-CA', 'fr-FR', + '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', +] +DEFAULT_LANG = 'en-US' + +DEFAULT_GENDER = 'NEUTRAL' + +VOICE_REGEX = r'[a-z]{2}-[A-Z]{2}-(Standard|Wavenet)-[A-Z]|' +DEFAULT_VOICE = '' + +DEFAULT_ENCODING = 'OGG_OPUS' + +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): + """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 occured during Google Cloud TTS call: %s", ex + ) + + return None, None diff --git a/homeassistant/components/gpslogger/.translations/ca.json b/homeassistant/components/gpslogger/.translations/ca.json index 2d3b08d236ee7c..296159f2e5ae0e 100644 --- a/homeassistant/components/gpslogger/.translations/ca.json +++ b/homeassistant/components/gpslogger/.translations/ca.json @@ -9,10 +9,10 @@ }, "step": { "user": { - "description": "Est\u00e0s segur que vols configurar el Webhook GPSLogger?", - "title": "Configuraci\u00f3 del Webhook GPSLogger" + "description": "Est\u00e0s segur que vols configurar el Webhook de GPSLogger?", + "title": "Configuraci\u00f3 del Webhook de GPSLogger" } }, - "title": "Webhook GPSLogger" + "title": "Webhook de GPSLogger" } } \ No newline at end of file diff --git a/homeassistant/components/gpslogger/.translations/nl.json b/homeassistant/components/gpslogger/.translations/nl.json index 4956cf52f267d0..f34a21b7897928 100644 --- a/homeassistant/components/gpslogger/.translations/nl.json +++ b/homeassistant/components/gpslogger/.translations/nl.json @@ -1,5 +1,12 @@ { "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?", diff --git a/homeassistant/components/hangouts/.translations/it.json b/homeassistant/components/hangouts/.translations/it.json index 76a9adcb40efe4..ad8dafd17ec74a 100644 --- a/homeassistant/components/hangouts/.translations/it.json +++ b/homeassistant/components/hangouts/.translations/it.json @@ -18,6 +18,7 @@ }, "user": { "data": { + "authorization_code": "Codice di autorizzazione (necessario per l'autenticazione manuale)", "email": "Indirizzo email", "password": "Password" }, diff --git a/homeassistant/components/hangouts/.translations/nl.json b/homeassistant/components/hangouts/.translations/nl.json index da9bc9edd7b217..9f9b121a7c2a85 100644 --- a/homeassistant/components/hangouts/.translations/nl.json +++ b/homeassistant/components/hangouts/.translations/nl.json @@ -19,6 +19,7 @@ }, "user": { "data": { + "authorization_code": "Autorisatiecode (vereist voor handmatige authenticatie)", "email": "E-mailadres", "password": "Wachtwoord" }, diff --git a/homeassistant/components/heos/.translations/hu.json b/homeassistant/components/heos/.translations/hu.json index 8cd10b3c2466ff..20ae78ae3161f7 100644 --- a/homeassistant/components/heos/.translations/hu.json +++ b/homeassistant/components/heos/.translations/hu.json @@ -7,6 +7,7 @@ "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 index 32667d0dbe8e41..20a4060add4ba5 100644 --- a/homeassistant/components/heos/.translations/it.json +++ b/homeassistant/components/heos/.translations/it.json @@ -1,12 +1,16 @@ { "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" + "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" diff --git a/homeassistant/components/heos/.translations/nl.json b/homeassistant/components/heos/.translations/nl.json index d3c91af2c1649d..3e7105e8cb358d 100644 --- a/homeassistant/components/heos/.translations/nl.json +++ b/homeassistant/components/heos/.translations/nl.json @@ -1,10 +1,18 @@ { "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" + "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" } }, diff --git a/homeassistant/components/heos/.translations/pt-BR.json b/homeassistant/components/heos/.translations/pt-BR.json new file mode 100644 index 00000000000000..ac860059b5df3f --- /dev/null +++ b/homeassistant/components/heos/.translations/pt-BR.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "user": { + "data": { + "access_token": "Host", + "host": "Host" + }, + "title": "Conecte-se a Heos" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/heos/config_flow.py b/homeassistant/components/heos/config_flow.py index 064813a86a7781..8207d40be11b7a 100644 --- a/homeassistant/components/heos/config_flow.py +++ b/homeassistant/components/heos/config_flow.py @@ -22,7 +22,7 @@ class HeosFlowHandler(config_entries.ConfigFlow): VERSION = 1 CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH - async def async_step_discovery(self, discovery_info): + async def async_step_ssdp(self, discovery_info): """Handle a discovered Heos device.""" # Store discovered host friendly_name = "{} ({})".format( diff --git a/homeassistant/components/heos/manifest.json b/homeassistant/components/heos/manifest.json index a1fc803031824c..09833bb729b416 100644 --- a/homeassistant/components/heos/manifest.json +++ b/homeassistant/components/heos/manifest.json @@ -6,6 +6,11 @@ "requirements": [ "pyheos==0.5.2" ], + "ssdp": { + "st": [ + "urn:schemas-denon-com:device:ACT-Denon:1" + ] + }, "dependencies": [], "codeowners": [ "@andrewsayre" diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index 93a197969ca721..2bcacb48bd1fed 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -14,7 +14,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE, SERVICE_HOMEASSISTANT_STOP, SERVICE_HOMEASSISTANT_RESTART, - RESTART_EXIT_CODE) + RESTART_EXIT_CODE, ATTR_LATITUDE, ATTR_LONGITUDE) from homeassistant.helpers import config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -22,6 +22,7 @@ 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 }) @@ -131,7 +132,22 @@ async def async_handle_reload_config(call): await conf_util.async_process_ha_core_config( hass, conf.get(ha.DOMAIN) or {}) - hass.services.async_register( - ha.DOMAIN, SERVICE_RELOAD_CORE_CONFIG, async_handle_reload_config) + 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/homekit_controller/.translations/ca.json b/homeassistant/components/homekit_controller/.translations/ca.json index 8765a859418f59..f2ed4bd0c21596 100644 --- a/homeassistant/components/homekit_controller/.translations/ca.json +++ b/homeassistant/components/homekit_controller/.translations/ca.json @@ -3,6 +3,7 @@ "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.", diff --git a/homeassistant/components/homekit_controller/.translations/de.json b/homeassistant/components/homekit_controller/.translations/de.json index d13d2bb7e2a760..22420b79661e58 100644 --- a/homeassistant/components/homekit_controller/.translations/de.json +++ b/homeassistant/components/homekit_controller/.translations/de.json @@ -3,6 +3,7 @@ "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.", diff --git a/homeassistant/components/homekit_controller/.translations/fr.json b/homeassistant/components/homekit_controller/.translations/fr.json index 955e11d12b0696..15e50a4012701c 100644 --- a/homeassistant/components/homekit_controller/.translations/fr.json +++ b/homeassistant/components/homekit_controller/.translations/fr.json @@ -3,6 +3,7 @@ "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.", diff --git a/homeassistant/components/homekit_controller/.translations/hu.json b/homeassistant/components/homekit_controller/.translations/hu.json new file mode 100644 index 00000000000000..60bd173dc8ecc2 --- /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 index 6ec1c28344845c..a1d460d12dcfa8 100644 --- a/homeassistant/components/homekit_controller/.translations/it.json +++ b/homeassistant/components/homekit_controller/.translations/it.json @@ -1,13 +1,19 @@ { "config": { "abort": { - "already_configured": "L'accessorio \u00e8 gi\u00e0 configurato con questo controller." + "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.", "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": { diff --git a/homeassistant/components/homekit_controller/.translations/ko.json b/homeassistant/components/homekit_controller/.translations/ko.json index 5ee62ad62b4268..8837e501a8aae7 100644 --- a/homeassistant/components/homekit_controller/.translations/ko.json +++ b/homeassistant/components/homekit_controller/.translations/ko.json @@ -3,10 +3,11 @@ "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\ub418\uc9c0 \uc54a\uc740 \uae30\uae30\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4" + "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.", diff --git a/homeassistant/components/homekit_controller/.translations/lb.json b/homeassistant/components/homekit_controller/.translations/lb.json index 882a1d3bc3af43..97efd428a0469e 100644 --- a/homeassistant/components/homekit_controller/.translations/lb.json +++ b/homeassistant/components/homekit_controller/.translations/lb.json @@ -3,6 +3,7 @@ "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.", diff --git a/homeassistant/components/homekit_controller/.translations/nl.json b/homeassistant/components/homekit_controller/.translations/nl.json index a714934372b77c..30494295f0ea0f 100644 --- a/homeassistant/components/homekit_controller/.translations/nl.json +++ b/homeassistant/components/homekit_controller/.translations/nl.json @@ -1,20 +1,30 @@ { "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": { diff --git a/homeassistant/components/homekit_controller/.translations/no.json b/homeassistant/components/homekit_controller/.translations/no.json index e7ec6c279fa1f6..8dd293dc7c8c8c 100644 --- a/homeassistant/components/homekit_controller/.translations/no.json +++ b/homeassistant/components/homekit_controller/.translations/no.json @@ -3,6 +3,7 @@ "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.", diff --git a/homeassistant/components/homekit_controller/.translations/pl.json b/homeassistant/components/homekit_controller/.translations/pl.json index a0489aa083a3a5..031a7440ed0129 100644 --- a/homeassistant/components/homekit_controller/.translations/pl.json +++ b/homeassistant/components/homekit_controller/.translations/pl.json @@ -3,6 +3,7 @@ "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.", 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 00000000000000..58f12bf595cf00 --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/pt-BR.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "accessory_not_found_error": "N\u00e3o \u00e9 poss\u00edvel adicionar o emparelhamento, pois o dispositivo n\u00e3o pode mais ser encontrado.", + "already_in_progress": "O fluxo de configura\u00e7\u00e3o para o dispositivo j\u00e1 est\u00e1 em andamento." + }, + "error": { + "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." + }, + "flow_title": "Acess\u00f3rio HomeKit: {name}" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/ru.json b/homeassistant/components/homekit_controller/.translations/ru.json index 44b4faf455f941..c7770c6a064b34 100644 --- a/homeassistant/components/homekit_controller/.translations/ru.json +++ b/homeassistant/components/homekit_controller/.translations/ru.json @@ -3,6 +3,7 @@ "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.", diff --git a/homeassistant/components/homekit_controller/.translations/sl.json b/homeassistant/components/homekit_controller/.translations/sl.json index 0404dd7beb5434..2af8a2a7ab5c5a 100644 --- a/homeassistant/components/homekit_controller/.translations/sl.json +++ b/homeassistant/components/homekit_controller/.translations/sl.json @@ -3,6 +3,7 @@ "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.", diff --git a/homeassistant/components/homekit_controller/.translations/sv.json b/homeassistant/components/homekit_controller/.translations/sv.json index 264fca2de504dc..b4b721b7ff91f1 100644 --- a/homeassistant/components/homekit_controller/.translations/sv.json +++ b/homeassistant/components/homekit_controller/.translations/sv.json @@ -3,6 +3,7 @@ "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.", @@ -17,7 +18,7 @@ "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: {namn}", + "flow_title": "HomeKit-tillbeh\u00f6r: {name}", "step": { "pair": { "data": { diff --git a/homeassistant/components/homekit_controller/.translations/zh-Hant.json b/homeassistant/components/homekit_controller/.translations/zh-Hant.json index 25ca625d7df19c..aaa2c9eda8f7de 100644 --- a/homeassistant/components/homekit_controller/.translations/zh-Hant.json +++ b/homeassistant/components/homekit_controller/.translations/zh-Hant.json @@ -3,6 +3,7 @@ "abort": { "accessory_not_found_error": "\u627e\u4e0d\u5230\u88dd\u7f6e\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": "\u88dd\u7f6e\u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d\u3002", "already_paired": "\u914d\u4ef6\u5df2\u7d93\u8207\u5176\u4ed6\u88dd\u7f6e\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": "\u88dd\u7f6e\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", diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index f1ddf1faacfc77..9651e497ccc7f6 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -156,11 +156,12 @@ def device_info(self): 'sw_version': self._accessory_info.get('firmware.revision', ''), } - # Some devices only have a single accessory - we don't add a via_hub - # otherwise it would be self referential. + # 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_hub'] = (DOMAIN, 'serial-number', bridge_serial) + device_info['via_device'] = ( + DOMAIN, 'serial-number', bridge_serial) return device_info diff --git a/homeassistant/components/homematic/cover.py b/homeassistant/components/homematic/cover.py index 387eb26f433d06..28e66f39a508a6 100644 --- a/homeassistant/components/homematic/cover.py +++ b/homeassistant/components/homematic/cover.py @@ -48,6 +48,7 @@ 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.""" diff --git a/homeassistant/components/homematic/manifest.json b/homeassistant/components/homematic/manifest.json index 7c80806cae585b..ea012ceeb27def 100644 --- a/homeassistant/components/homematic/manifest.json +++ b/homeassistant/components/homematic/manifest.json @@ -3,7 +3,7 @@ "name": "Homematic", "documentation": "https://www.home-assistant.io/components/homematic", "requirements": [ - "pyhomematic==0.1.58" + "pyhomematic==0.1.59" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/homematicip_cloud/alarm_control_panel.py b/homeassistant/components/homematicip_cloud/alarm_control_panel.py index 1e072c6784c1fa..ccd19f26d68707 100644 --- a/homeassistant/components/homematicip_cloud/alarm_control_panel.py +++ b/homeassistant/components/homematicip_cloud/alarm_control_panel.py @@ -12,7 +12,7 @@ STATE_ALARM_TRIGGERED) from homeassistant.core import HomeAssistant -from . import DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID, HomematicipGenericDevice +from . import DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID _LOGGER = logging.getLogger(__name__) @@ -34,12 +34,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, for group in home.groups: if isinstance(group, AsyncSecurityZoneGroup): security_zones.append(group) - # To be removed in a later release. - devices.append(HomematicipSecurityZone(home, group)) - _LOGGER.warning("Homematic IP: alarm_control_panel.%s is " - "deprecated. Please switch to " - "alarm_control_panel.*hmip_alarm_control_panel.", - group.label) + if security_zones: devices.append(HomematicipAlarmControlPanel(home, security_zones)) @@ -47,45 +42,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities(devices) -class HomematicipSecurityZone(HomematicipGenericDevice, AlarmControlPanel): - """Representation of an HomematicIP Cloud security zone group.""" - - def __init__(self, home: AsyncHome, device) -> None: - """Initialize the security zone group.""" - device.modelType = 'Group-SecurityZone' - device.windowState = None - super().__init__(home, device) - - @property - def state(self) -> str: - """Return the state of the device.""" - if self._device.active: - if (self._device.sabotage or self._device.motionDetected or - self._device.windowState == WindowState.OPEN or - self._device.windowState == WindowState.TILTED): - return STATE_ALARM_TRIGGERED - - active = self._home.get_security_zones_activation() - if active == (True, True): - return STATE_ALARM_ARMED_AWAY - if active == (False, True): - return STATE_ALARM_ARMED_HOME - - return STATE_ALARM_DISARMED - - async def async_alarm_disarm(self, code=None): - """Send disarm command.""" - await self._home.set_security_zones_activation(False, False) - - async def async_alarm_arm_home(self, code=None): - """Send arm home command.""" - await self._home.set_security_zones_activation(False, True) - - async def async_alarm_arm_away(self, code=None): - """Send arm away command.""" - await self._home.set_security_zones_activation(True, True) - - class HomematicipAlarmControlPanel(AlarmControlPanel): """Representation of an alarm control panel.""" diff --git a/homeassistant/components/homematicip_cloud/binary_sensor.py b/homeassistant/components/homematicip_cloud/binary_sensor.py index b006ec8068654a..ba30591dc6da9c 100644 --- a/homeassistant/components/homematicip_cloud/binary_sensor.py +++ b/homeassistant/components/homematicip_cloud/binary_sensor.py @@ -23,6 +23,7 @@ _LOGGER = logging.getLogger(__name__) +ATTR_LOW_BATTERY = 'low_battery' ATTR_MOTIONDETECTED = 'motion detected' ATTR_PRESENCEDETECTED = 'presence detected' ATTR_POWERMAINSFAILURE = 'power mains failure' @@ -312,7 +313,8 @@ def device_state_attributes(self): attr[ATTR_MOISTUREDETECTED] = True if self._device.waterlevelDetected: attr[ATTR_WATERLEVELDETECTED] = True - + if self._device.lowBat: + attr[ATTR_LOW_BATTERY] = True if self._device.smokeDetectorAlarmType is not None and \ self._device.smokeDetectorAlarmType != \ SmokeDetectorAlarmType.IDLE_OFF: diff --git a/homeassistant/components/homematicip_cloud/device.py b/homeassistant/components/homematicip_cloud/device.py index 2c77d225263ca2..3cd84791c6778a 100644 --- a/homeassistant/components/homematicip_cloud/device.py +++ b/homeassistant/components/homematicip_cloud/device.py @@ -10,7 +10,6 @@ _LOGGER = logging.getLogger(__name__) -ATTR_LOW_BATTERY = 'low_battery' ATTR_MODEL_TYPE = 'model_type' # RSSI HAP -> Device ATTR_RSSI_DEVICE = 'rssi_device' @@ -45,7 +44,8 @@ def device_info(self): 'manufacturer': self._device.oem, 'model': self._device.modelType, 'sw_version': self._device.firmwareVersion, - 'via_hub': (homematicip_cloud.DOMAIN, self._device.homeId), + 'via_device': ( + homematicip_cloud.DOMAIN, self._device.homeId), } return None @@ -96,8 +96,6 @@ def icon(self) -> Optional[str]: def device_state_attributes(self): """Return the state attributes of the generic device.""" attr = {ATTR_MODEL_TYPE: self._device.modelType} - if hasattr(self._device, 'lowBat') and self._device.lowBat: - attr[ATTR_LOW_BATTERY] = self._device.lowBat if hasattr(self._device, 'sabotage') and self._device.sabotage: attr[ATTR_SABOTAGE] = self._device.sabotage if hasattr(self._device, 'rssiDeviceValue') and \ diff --git a/homeassistant/components/honeywell/__init__.py b/homeassistant/components/honeywell/__init__.py index 59a90711e5752f..53259dcf275c65 100644 --- a/homeassistant/components/honeywell/__init__.py +++ b/homeassistant/components/honeywell/__init__.py @@ -1 +1 @@ -"""Support for Honeywell Round Connected and Honeywell Evohome thermostats.""" +"""Support for Honeywell Total Connect Comfort climate systems.""" diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index 75bbb2ca5d8178..3ebb2a9bb85b87 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -1,4 +1,4 @@ -"""Support for Honeywell Round Connected and Honeywell Evohome thermostats.""" +"""Support for Honeywell Total Connect Comfort climate systems.""" import logging import datetime @@ -25,9 +25,9 @@ CONF_COOL_AWAY_TEMPERATURE = 'away_cool_temperature' CONF_HEAT_AWAY_TEMPERATURE = 'away_heat_temperature' -DEFAULT_AWAY_TEMPERATURE = 16 -DEFAULT_COOL_AWAY_TEMPERATURE = 30 -DEFAULT_HEAT_AWAY_TEMPERATURE = 16 +DEFAULT_AWAY_TEMPERATURE = 16 # in C, for eu regions, the others are F/us +DEFAULT_COOL_AWAY_TEMPERATURE = 88 +DEFAULT_HEAT_AWAY_TEMPERATURE = 61 DEFAULT_REGION = 'eu' REGIONS = ['eu', 'us'] @@ -37,9 +37,9 @@ vol.Optional(CONF_AWAY_TEMPERATURE, default=DEFAULT_AWAY_TEMPERATURE): vol.Coerce(float), vol.Optional(CONF_COOL_AWAY_TEMPERATURE, - default=DEFAULT_COOL_AWAY_TEMPERATURE): vol.Coerce(float), + default=DEFAULT_COOL_AWAY_TEMPERATURE): vol.Coerce(int), vol.Optional(CONF_HEAT_AWAY_TEMPERATURE, - default=DEFAULT_HEAT_AWAY_TEMPERATURE): vol.Coerce(float), + default=DEFAULT_HEAT_AWAY_TEMPERATURE): vol.Coerce(int), vol.Optional(CONF_REGION, default=DEFAULT_REGION): vol.In(REGIONS), }) diff --git a/homeassistant/components/honeywell/manifest.json b/homeassistant/components/honeywell/manifest.json index c3d76703e91dd9..ba75950452904d 100644 --- a/homeassistant/components/honeywell/manifest.json +++ b/homeassistant/components/honeywell/manifest.json @@ -7,5 +7,5 @@ "somecomfort==0.5.2" ], "dependencies": [], - "codeowners": [] + "codeowners": ["@zxdavb"] } diff --git a/homeassistant/components/hue/.translations/ca.json b/homeassistant/components/hue/.translations/ca.json index 078c4e753770d6..471ce2181fb828 100644 --- a/homeassistant/components/hue/.translations/ca.json +++ b/homeassistant/components/hue/.translations/ca.json @@ -7,6 +7,7 @@ "cannot_connect": "No s'ha pogut connectar amb l'enlla\u00e7", "discover_timeout": "No s'han pogut descobrir enlla\u00e7os Hue", "no_bridges": "No s'han trobat enlla\u00e7os Philips Hue", + "not_hue_bridge": "No \u00e9s un enlla\u00e7 Hue", "unknown": "S'ha produ\u00eft un error desconegut" }, "error": { diff --git a/homeassistant/components/hue/.translations/de.json b/homeassistant/components/hue/.translations/de.json index a0bd50d8514dfc..bb78566a12be09 100644 --- a/homeassistant/components/hue/.translations/de.json +++ b/homeassistant/components/hue/.translations/de.json @@ -3,6 +3,7 @@ "abort": { "all_configured": "Alle Philips Hue Bridges sind bereits konfiguriert", "already_configured": "Bridge ist bereits konfiguriert", + "already_in_progress": "Der Konfigurationsablauf f\u00fcr die Bridge wird bereits ausgef\u00fchrt.", "cannot_connect": "Verbindung zur Bridge nicht m\u00f6glich", "discover_timeout": "Nicht in der Lage Hue Bridges zu entdecken", "no_bridges": "Keine Philips Hue Bridges entdeckt", diff --git a/homeassistant/components/hue/.translations/en.json b/homeassistant/components/hue/.translations/en.json index 744efb1b15eec5..350360285af414 100644 --- a/homeassistant/components/hue/.translations/en.json +++ b/homeassistant/components/hue/.translations/en.json @@ -7,6 +7,7 @@ "cannot_connect": "Unable to connect to the bridge", "discover_timeout": "Unable to discover Hue bridges", "no_bridges": "No Philips Hue bridges discovered", + "not_hue_bridge": "Not a Hue bridge", "unknown": "Unknown error occurred" }, "error": { diff --git a/homeassistant/components/hue/.translations/ko.json b/homeassistant/components/hue/.translations/ko.json index a4a8051663e5bb..99319f07ce46e6 100644 --- a/homeassistant/components/hue/.translations/ko.json +++ b/homeassistant/components/hue/.translations/ko.json @@ -3,9 +3,11 @@ "abort": { "all_configured": "\ubaa8\ub4e0 \ud544\ub9bd\uc2a4 Hue \ube0c\ub9bf\uc9c0\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "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.", "cannot_connect": "\ube0c\ub9ac\uc9c0\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", "discover_timeout": "Hue \ube0c\ub9bf\uc9c0\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", "no_bridges": "\ubc1c\uacac\ub41c \ud544\ub9bd\uc2a4 Hue \ube0c\ub9bf\uc9c0\uac00 \uc5c6\uc2b5\ub2c8\ub2e4", + "not_hue_bridge": "Hue \ube0c\ub9bf\uc9c0\uac00 \uc544\ub2d9\ub2c8\ub2e4", "unknown": "\uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, "error": { diff --git a/homeassistant/components/hue/.translations/lb.json b/homeassistant/components/hue/.translations/lb.json index 9b245a2a875674..ac83609ff02591 100644 --- a/homeassistant/components/hue/.translations/lb.json +++ b/homeassistant/components/hue/.translations/lb.json @@ -3,9 +3,11 @@ "abort": { "all_configured": "All Philips Hue Bridge si scho\u00a0konfigur\u00e9iert", "already_configured": "Bridge ass scho konfigur\u00e9iert", + "already_in_progress": "Konfiguratioun fir d\u00ebsen Apparat ass schonn am gaang.", "cannot_connect": "Keng Verbindung mat der bridge m\u00e9iglech", "discover_timeout": "Keng Hue bridge fonnt", "no_bridges": "Keng Philips Hue Bridge fonnt", + "not_hue_bridge": "Keng Hue Bridge", "unknown": "Onbekannten Feeler opgetrueden" }, "error": { diff --git a/homeassistant/components/hue/.translations/nl.json b/homeassistant/components/hue/.translations/nl.json index bd065bb7506b19..9b84b4a7afce66 100644 --- a/homeassistant/components/hue/.translations/nl.json +++ b/homeassistant/components/hue/.translations/nl.json @@ -3,9 +3,11 @@ "abort": { "all_configured": "Alle Philips Hue bridges zijn al geconfigureerd", "already_configured": "Bridge is al geconfigureerd", + "already_in_progress": "De configuratiestroom voor het apparaat is al in volle gang.", "cannot_connect": "Kan geen verbinding maken met bridge", "discover_timeout": "Hue bridges kunnen niet worden gevonden", "no_bridges": "Geen Philips Hue bridges ontdekt", + "not_hue_bridge": "Dit is geen Hue bridge", "unknown": "Onbekende fout opgetreden" }, "error": { diff --git a/homeassistant/components/hue/.translations/no.json b/homeassistant/components/hue/.translations/no.json index 02dd6ef7128c84..e8718fe778b8ee 100644 --- a/homeassistant/components/hue/.translations/no.json +++ b/homeassistant/components/hue/.translations/no.json @@ -3,9 +3,11 @@ "abort": { "all_configured": "Alle Philips Hue Bridger er allerede konfigurert", "already_configured": "Bridge er allerede konfigurert", + "already_in_progress": "Konfigurasjonsflyt for bro p\u00e5g\u00e5r allerede.", "cannot_connect": "Kan ikke koble til Bridge", "discover_timeout": "Kunne ikke oppdage Hue Bridger", "no_bridges": "Ingen Philips Hue Bridger oppdaget", + "not_hue_bridge": "Ikke en Hue bro", "unknown": "Ukjent feil oppstod" }, "error": { diff --git a/homeassistant/components/hue/.translations/pl.json b/homeassistant/components/hue/.translations/pl.json index 63cbbe016a21ee..9062e427a27c25 100644 --- a/homeassistant/components/hue/.translations/pl.json +++ b/homeassistant/components/hue/.translations/pl.json @@ -3,9 +3,11 @@ "abort": { "all_configured": "Wszystkie mostki Hue s\u0105 ju\u017c skonfigurowane", "already_configured": "Mostek jest ju\u017c skonfigurowany", + "already_in_progress": "Konfigurowanie mostka jest ju\u017c w toku.", "cannot_connect": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z mostkiem", "discover_timeout": "Nie mo\u017cna wykry\u0107 \u017cadnych mostk\u00f3w Hue", "no_bridges": "Nie wykryto \u017cadnych mostk\u00f3w Hue", + "not_hue_bridge": "To nie jest mostek Hue", "unknown": "Wyst\u0105pi\u0142 nieznany b\u0142\u0105d" }, "error": { diff --git a/homeassistant/components/hue/.translations/pt-BR.json b/homeassistant/components/hue/.translations/pt-BR.json index b30764c92393ff..2b78d2f127825a 100644 --- a/homeassistant/components/hue/.translations/pt-BR.json +++ b/homeassistant/components/hue/.translations/pt-BR.json @@ -6,6 +6,7 @@ "cannot_connect": "N\u00e3o \u00e9 poss\u00edvel conectar-se \u00e0 ponte", "discover_timeout": "Incapaz de descobrir pontes Hue", "no_bridges": "N\u00e3o h\u00e1 pontes Philips Hue descobertas", + "not_hue_bridge": "N\u00e3o \u00e9 uma ponte Hue", "unknown": "Ocorreu um erro desconhecido" }, "error": { diff --git a/homeassistant/components/hue/.translations/ru.json b/homeassistant/components/hue/.translations/ru.json index 713e86f49b7605..be5d2b7159d40b 100644 --- a/homeassistant/components/hue/.translations/ru.json +++ b/homeassistant/components/hue/.translations/ru.json @@ -7,6 +7,7 @@ "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0448\u043b\u044e\u0437\u0443", "discover_timeout": "\u0428\u043b\u044e\u0437 Philips Hue \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d", "no_bridges": "\u0428\u043b\u044e\u0437\u044b Philips Hue \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b", + "not_hue_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 Hue", "unknown": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430" }, "error": { diff --git a/homeassistant/components/hue/.translations/sl.json b/homeassistant/components/hue/.translations/sl.json index fc3142ba8201a1..29fc66488eb9ca 100644 --- a/homeassistant/components/hue/.translations/sl.json +++ b/homeassistant/components/hue/.translations/sl.json @@ -7,6 +7,7 @@ "cannot_connect": "Ni mogo\u010de vzpostaviti povezave z mostom", "discover_timeout": "Ni bilo mogo\u010de odkriti Hue mostov", "no_bridges": "Ni odkritih mostov Philips Hue", + "not_hue_bridge": "Ni Hue most", "unknown": "Pri\u0161lo je do neznane napake" }, "error": { diff --git a/homeassistant/components/hue/.translations/sv.json b/homeassistant/components/hue/.translations/sv.json index b0b8ea3cbfa500..7e5b7c52dd55d9 100644 --- a/homeassistant/components/hue/.translations/sv.json +++ b/homeassistant/components/hue/.translations/sv.json @@ -7,6 +7,7 @@ "cannot_connect": "Det gick inte att ansluta till bryggan", "discover_timeout": "Det gick inte att uppt\u00e4cka n\u00e5gra Hue-bryggor", "no_bridges": "Inga Philips Hue-bryggor uppt\u00e4cktes", + "not_hue_bridge": "Inte en Hue-brygga", "unknown": "Ett ok\u00e4nt fel intr\u00e4ffade" }, "error": { diff --git a/homeassistant/components/hue/.translations/zh-Hant.json b/homeassistant/components/hue/.translations/zh-Hant.json index a585cfd38c36d3..3d03aba03a6e94 100644 --- a/homeassistant/components/hue/.translations/zh-Hant.json +++ b/homeassistant/components/hue/.translations/zh-Hant.json @@ -7,6 +7,7 @@ "cannot_connect": "\u7121\u6cd5\u9023\u7dda\u81f3 Bridge", "discover_timeout": "\u7121\u6cd5\u641c\u5c0b\u5230 Hue Bridge", "no_bridges": "\u672a\u641c\u5c0b\u5230 Philips Hue Bridge", + "not_hue_bridge": "\u975e Hue Bridge \u88dd\u7f6e", "unknown": "\u767c\u751f\u672a\u77e5\u932f\u8aa4" }, "error": { diff --git a/homeassistant/components/hue/binary_sensor.py b/homeassistant/components/hue/binary_sensor.py index b9921a9a01fbf5..68f0405856603a 100644 --- a/homeassistant/components/hue/binary_sensor.py +++ b/homeassistant/components/hue/binary_sensor.py @@ -26,3 +26,14 @@ async def _async_update_ha_state(self, *args, **kwargs): def is_on(self): """Return true if the binary sensor is on.""" return self.sensor.presence + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + attributes = super().device_state_attributes + if 'sensitivity' in self.sensor.config: + attributes['sensitivity'] = self.sensor.config['sensitivity'] + if 'sensitivitymax' in self.sensor.config: + attributes['sensitivity_max'] = \ + self.sensor.config['sensitivitymax'] + return attributes diff --git a/homeassistant/components/hue/light.py b/homeassistant/components/hue/light.py index c517184b62a8e8..100b26b0b78819 100644 --- a/homeassistant/components/hue/light.py +++ b/homeassistant/components/hue/light.py @@ -334,7 +334,7 @@ def device_info(self): 'model': self.light.productname or self.light.modelid, # Not yet exposed as properties in aiohue 'sw_version': self.light.raw['swversion'], - 'via_hub': (hue.DOMAIN, self.bridge.api.config.bridgeid), + 'via_device': (hue.DOMAIN, self.bridge.api.config.bridgeid), } async def async_turn_on(self, **kwargs): diff --git a/homeassistant/components/hue/sensor.py b/homeassistant/components/hue/sensor.py index 7664bd38d97229..cdc86d2d2800e7 100644 --- a/homeassistant/components/hue/sensor.py +++ b/homeassistant/components/hue/sensor.py @@ -40,13 +40,16 @@ def state(self): # scale used because the human eye adjusts to light levels and small # changes at low lux levels are more noticeable than at high lux # levels. - return 10 ** ((self.sensor.lightlevel - 1) / 10000) + return round(float(10 ** ((self.sensor.lightlevel - 1) / 10000)), 2) @property def device_state_attributes(self): """Return the device state attributes.""" attributes = super().device_state_attributes attributes.update({ + "lightlevel": self.sensor.lightlevel, + "daylight": self.sensor.daylight, + "dark": self.sensor.dark, "threshold_dark": self.sensor.tholddark, "threshold_offset": self.sensor.tholdoffset, }) diff --git a/homeassistant/components/hue/sensor_base.py b/homeassistant/components/hue/sensor_base.py index 9dca6e31b1df82..60ddfac1a95ab9 100644 --- a/homeassistant/components/hue/sensor_base.py +++ b/homeassistant/components/hue/sensor_base.py @@ -269,7 +269,7 @@ def device_info(self): self.primary_sensor.productname or self.primary_sensor.modelid), 'sw_version': self.primary_sensor.swversion, - 'via_hub': (hue.DOMAIN, self.bridge.api.config.bridgeid), + 'via_device': (hue.DOMAIN, self.bridge.api.config.bridgeid), } diff --git a/homeassistant/components/ifttt/.translations/ca.json b/homeassistant/components/ifttt/.translations/ca.json index ff4cf67c23b26f..597328a2ee4003 100644 --- a/homeassistant/components/ifttt/.translations/ca.json +++ b/homeassistant/components/ifttt/.translations/ca.json @@ -10,7 +10,7 @@ "step": { "user": { "description": "Est\u00e0s segur que vols configurar IFTTT?", - "title": "Configuraci\u00f3 de la miniaplicaci\u00f3 Webhook IFTTT" + "title": "Configuraci\u00f3 de la miniaplicaci\u00f3 Webhook de IFTTT" } }, "title": "IFTTT" diff --git a/homeassistant/components/incomfort/__init__.py b/homeassistant/components/incomfort/__init__.py index 8aaa8e7e19db32..024875e38c1302 100644 --- a/homeassistant/components/incomfort/__init__.py +++ b/homeassistant/components/incomfort/__init__.py @@ -1,6 +1,7 @@ """Support for an Intergas boiler via an InComfort/Intouch Lan2RF gateway.""" import logging +from aiohttp import ClientResponseError import voluptuous as vol from incomfortclient import Gateway as InComfortGateway @@ -30,21 +31,20 @@ async def async_setup(hass, hass_config): credentials = dict(hass_config[DOMAIN]) hostname = credentials.pop(CONF_HOST) - try: - client = incomfort_data['client'] = InComfortGateway( - hostname, **credentials, session=async_get_clientsession(hass) - ) + client = incomfort_data['client'] = InComfortGateway( + hostname, **credentials, session=async_get_clientsession(hass) + ) + try: heater = incomfort_data['heater'] = list(await client.heaters)[0] - await heater.update() - - except AssertionError: # assert response.status == HTTP_OK + except ClientResponseError as err: _LOGGER.warning( - "Setup failed, check your configuration.", - exc_info=True) + "Setup failed, check your configuration, message is: %s", err) return False - for platform in ['water_heater', 'climate']: + await heater.update() + + for platform in ['water_heater', 'binary_sensor', 'sensor', 'climate']: hass.async_create_task(async_load_platform( hass, platform, DOMAIN, {}, hass_config)) diff --git a/homeassistant/components/incomfort/binary_sensor.py b/homeassistant/components/incomfort/binary_sensor.py new file mode 100644 index 00000000000000..87ca5d5385ffdf --- /dev/null +++ b/homeassistant/components/incomfort/binary_sensor.py @@ -0,0 +1,52 @@ +"""Support for an Intergas boiler via an InComfort/InTouch Lan2RF gateway.""" +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from . import DOMAIN + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up an InComfort/InTouch binary_sensor device.""" + async_add_entities([ + IncomfortFailed(hass.data[DOMAIN]['client'], + hass.data[DOMAIN]['heater']) + ]) + + +class IncomfortFailed(BinarySensorDevice): + """Representation of an InComfort Failed sensor.""" + + def __init__(self, client, boiler): + """Initialize the binary sensor.""" + self._client = client + self._boiler = boiler + + async def async_added_to_hass(self): + """Set up a listener when this entity is added to HA.""" + async_dispatcher_connect(self.hass, DOMAIN, self._refresh) + + @callback + def _refresh(self): + self.async_schedule_update_ha_state(force_refresh=True) + + @property + def name(self): + """Return the name of the sensor.""" + return 'Fault state' + + @property + def is_on(self): + """Return the status of the sensor.""" + return self._boiler.status['is_failed'] + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + return {'fault_code': self._boiler.status['fault_code']} + + @property + def should_poll(self) -> bool: + """Return False as this device should never be polled.""" + return False diff --git a/homeassistant/components/incomfort/climate.py b/homeassistant/components/incomfort/climate.py index fa42ced32c28eb..9be7541e922996 100644 --- a/homeassistant/components/incomfort/climate.py +++ b/homeassistant/components/incomfort/climate.py @@ -19,10 +19,7 @@ async def async_setup_platform(hass, hass_config, async_add_entities, client = hass.data[DOMAIN]['client'] heater = hass.data[DOMAIN]['heater'] - rooms = [InComfortClimate(client, r) - for r in heater.rooms if not r.room_temp] - if rooms: - async_add_entities(rooms) + async_add_entities([InComfortClimate(client, r) for r in heater.rooms]) class InComfortClimate(ClimateDevice): diff --git a/homeassistant/components/incomfort/manifest.json b/homeassistant/components/incomfort/manifest.json index 1731c8c942f466..13c77cd33fffc8 100644 --- a/homeassistant/components/incomfort/manifest.json +++ b/homeassistant/components/incomfort/manifest.json @@ -3,7 +3,7 @@ "name": "Intergas InComfort/Intouch Lan2RF gateway", "documentation": "https://www.home-assistant.io/components/incomfort", "requirements": [ - "incomfort-client==0.2.9" + "incomfort-client==0.3.0" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/incomfort/sensor.py b/homeassistant/components/incomfort/sensor.py new file mode 100644 index 00000000000000..1d4ddff37b9d44 --- /dev/null +++ b/homeassistant/components/incomfort/sensor.py @@ -0,0 +1,110 @@ +"""Support for an Intergas boiler via an InComfort/InTouch Lan2RF gateway.""" +from homeassistant.const import ( + PRESSURE_BAR, TEMP_CELSIUS, DEVICE_CLASS_TEMPERATURE) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from . import DOMAIN + +INTOUCH_HEATER_TEMP = 'CV Temp' +INTOUCH_PRESSURE = 'CV Pressure' +INTOUCH_TAP_TEMP = 'Tap Temp' + +INTOUCH_MAP_ATTRS = { + INTOUCH_HEATER_TEMP: ['heater_temp', 'is_pumping'], + INTOUCH_TAP_TEMP: ['tap_temp', 'is_tapping'], +} + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up an InComfort/InTouch sensor device.""" + client = hass.data[DOMAIN]['client'] + heater = hass.data[DOMAIN]['heater'] + + async_add_entities([ + IncomfortPressure(client, heater, INTOUCH_PRESSURE), + IncomfortTemperature(client, heater, INTOUCH_HEATER_TEMP), + IncomfortTemperature(client, heater, INTOUCH_TAP_TEMP) + ]) + + +class IncomfortSensor(Entity): + """Representation of an InComfort/InTouch sensor device.""" + + def __init__(self, client, boiler): + """Initialize the sensor.""" + self._client = client + self._boiler = boiler + + self._name = None + self._device_class = None + self._unit_of_measurement = None + + async def async_added_to_hass(self): + """Set up a listener when this entity is added to HA.""" + async_dispatcher_connect(self.hass, DOMAIN, self._refresh) + + @callback + def _refresh(self): + self.async_schedule_update_ha_state(force_refresh=True) + + @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 unit_of_measurement(self): + """Return the unit of measurement of the sensor.""" + return self._unit_of_measurement + + @property + def should_poll(self) -> bool: + """Return False as this device should never be polled.""" + return False + + +class IncomfortPressure(IncomfortSensor): + """Representation of an InTouch CV Pressure sensor.""" + + def __init__(self, client, boiler, name): + """Initialize the sensor.""" + super().__init__(client, boiler) + + self._name = name + self._unit_of_measurement = PRESSURE_BAR + + @property + def state(self): + """Return the state/value of the sensor.""" + return self._boiler.status['pressure'] + + +class IncomfortTemperature(IncomfortSensor): + """Representation of an InTouch Temperature sensor.""" + + def __init__(self, client, boiler, name): + """Initialize the signal strength sensor.""" + super().__init__(client, boiler) + + self._name = name + self._device_class = DEVICE_CLASS_TEMPERATURE + self._unit_of_measurement = TEMP_CELSIUS + + @property + def state(self): + """Return the state of the sensor.""" + return self._boiler.status[INTOUCH_MAP_ATTRS[self._name][0]] + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + key = INTOUCH_MAP_ATTRS[self._name][1] + return {key: self._boiler.status[key]} diff --git a/homeassistant/components/incomfort/water_heater.py b/homeassistant/components/incomfort/water_heater.py index 9223902f5a3a87..535d55df193222 100644 --- a/homeassistant/components/incomfort/water_heater.py +++ b/homeassistant/components/incomfort/water_heater.py @@ -2,8 +2,10 @@ import asyncio import logging +from aiohttp import ClientResponseError from homeassistant.components.water_heater import WaterHeaterDevice from homeassistant.const import TEMP_CELSIUS +from homeassistant.helpers.dispatcher import async_dispatcher_send from . import DOMAIN @@ -16,8 +18,8 @@ HEATER_NAME = 'Boiler' HEATER_ATTRS = [ - 'display_code', 'display_text', 'fault_code', 'is_burning', 'is_failed', - 'is_pumping', 'is_tapping', 'heater_temp', 'tap_temp', 'pressure'] + 'display_code', 'display_text', 'is_burning', + 'rf_message_rssi', 'nodenr', 'rfstatus_cntr'] async def async_setup_platform(hass, hass_config, async_add_entities, @@ -43,6 +45,11 @@ def name(self): """Return the name of the water_heater device.""" return HEATER_NAME + @property + def icon(self): + """Return the icon of the water_heater device.""" + return "mdi:oil-temperature" + @property def device_state_attributes(self): """Return the device state attributes.""" @@ -55,7 +62,9 @@ def current_temperature(self): """Return the current temperature.""" if self._heater.is_tapping: return self._heater.tap_temp - return self._heater.heater_temp + if self._heater.is_pumping: + return self._heater.heater_temp + return max(self._heater.heater_temp, self._heater.tap_temp) @property def min_temp(self): @@ -81,7 +90,7 @@ def supported_features(self): def current_operation(self): """Return the current operation mode.""" if self._heater.is_failed: - return "Failed ({})".format(self._heater.fault_code) + return "Fault code: {}".format(self._heater.fault_code) return self._heater.display_text @@ -90,5 +99,7 @@ async def async_update(self): try: await self._heater.update() - except (AssertionError, asyncio.TimeoutError) as err: - _LOGGER.warning("Update failed, message: %s", err) + except (ClientResponseError, asyncio.TimeoutError) as err: + _LOGGER.warning("Update failed, message is: %s", err) + + async_dispatcher_send(self.hass, DOMAIN) diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index 3a72c81fa11ac0..6aa0f5ad5f2a0a 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -40,7 +40,7 @@ 'h': 60*60, 'd': 24*60*60} -ICON = 'mdi:char-histogram' +ICON = 'mdi:chart-histogram' DEFAULT_ROUND = 3 diff --git a/homeassistant/components/ipma/.translations/it.json b/homeassistant/components/ipma/.translations/it.json index d751d8a317f2be..954ff6e9ee1e9e 100644 --- a/homeassistant/components/ipma/.translations/it.json +++ b/homeassistant/components/ipma/.translations/it.json @@ -7,7 +7,7 @@ "user": { "data": { "latitude": "Latitudine", - "longitude": "Logitudine", + "longitude": "Longitudine", "name": "Nome" }, "description": "Instituto Portugu\u00eas do Mar e Atmosfera", diff --git a/homeassistant/components/ipma/.translations/pt-BR.json b/homeassistant/components/ipma/.translations/pt-BR.json new file mode 100644 index 00000000000000..4a0d8e0b01bf7a --- /dev/null +++ b/homeassistant/components/ipma/.translations/pt-BR.json @@ -0,0 +1,18 @@ +{ + "config": { + "error": { + "name_exists": "O nome j\u00e1 existe" + }, + "step": { + "user": { + "data": { + "latitude": "Latitude", + "longitude": "Longitude", + "name": "Nome" + }, + "description": "Instituto Portugu\u00eas do Mar e Atmosfera", + "title": "Localiza\u00e7\u00e3o" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ipma/.translations/ru.json b/homeassistant/components/ipma/.translations/ru.json index f49852d5c0c0bb..a260efa5bd9dc2 100644 --- a/homeassistant/components/ipma/.translations/ru.json +++ b/homeassistant/components/ipma/.translations/ru.json @@ -11,7 +11,7 @@ "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435" }, "description": "\u041f\u043e\u0440\u0442\u0443\u0433\u0430\u043b\u044c\u0441\u043a\u0438\u0439 \u0438\u043d\u0441\u0442\u0438\u0442\u0443\u0442 \u043c\u043e\u0440\u044f \u0438 \u0430\u0442\u043c\u043e\u0441\u0444\u0435\u0440\u044b", - "title": "\u041c\u0435\u0441\u0442\u043e\u043d\u0430\u0445\u043e\u0436\u0434\u0435\u043d\u0438\u0435" + "title": "\u041c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435" } }, "title": "\u041c\u0435\u0442\u0435\u043e\u0440\u043e\u043b\u043e\u0433\u0438\u0447\u0435\u0441\u043a\u0430\u044f \u0441\u043b\u0443\u0436\u0431\u0430 \u041f\u043e\u0440\u0442\u0443\u0433\u0430\u043b\u0438\u0438 (IPMA)" diff --git a/homeassistant/components/iqvia/.translations/it.json b/homeassistant/components/iqvia/.translations/it.json new file mode 100644 index 00000000000000..37079cf571dbbf --- /dev/null +++ b/homeassistant/components/iqvia/.translations/it.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "identifier_exists": "Il CAP \u00e8 gi\u00e0 registrato", + "invalid_zip_code": "Il CAP non \u00e8 valido" + }, + "step": { + "user": { + "data": { + "zip_code": "CAP" + }, + "title": "IQVIA" + } + }, + "title": "IQVIA" + } +} \ No newline at end of file diff --git a/homeassistant/components/iqvia/.translations/nl.json b/homeassistant/components/iqvia/.translations/nl.json index dccb7348a016f3..e0b3b667de396d 100644 --- a/homeassistant/components/iqvia/.translations/nl.json +++ b/homeassistant/components/iqvia/.translations/nl.json @@ -1,7 +1,7 @@ { "config": { "error": { - "identifier_exists": "Postcode reeds geregistreerd", + "identifier_exists": "Postcode al geregistreerd", "invalid_zip_code": "Postcode is ongeldig" }, "step": { @@ -9,6 +9,7 @@ "data": { "zip_code": "Postcode" }, + "description": "Vul uw Amerikaanse of Canadese postcode in.", "title": "IQVIA" } }, diff --git a/homeassistant/components/iqvia/.translations/pt-BR.json b/homeassistant/components/iqvia/.translations/pt-BR.json new file mode 100644 index 00000000000000..b9f716e8d3eae7 --- /dev/null +++ b/homeassistant/components/iqvia/.translations/pt-BR.json @@ -0,0 +1,18 @@ +{ + "config": { + "error": { + "identifier_exists": "C\u00f3digo postal j\u00e1 registado", + "invalid_zip_code": "C\u00f3digo postal inv\u00e1lido" + }, + "step": { + "user": { + "data": { + "zip_code": "C\u00f3digo postal" + }, + "description": "Preencha o seu CEP dos EUA ou Canad\u00e1.", + "title": "IQVIA" + } + }, + "title": "IQVIA" + } +} \ No newline at end of file diff --git a/homeassistant/components/itunes/media_player.py b/homeassistant/components/itunes/media_player.py index 8451d751954cc4..04e4c3f09e63f2 100644 --- a/homeassistant/components/itunes/media_player.py +++ b/homeassistant/components/itunes/media_player.py @@ -10,7 +10,7 @@ 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_MUTE, - SUPPORT_VOLUME_SET) + SUPPORT_VOLUME_SET, SUPPORT_SHUFFLE_SET) from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_PORT, CONF_SSL, STATE_IDLE, STATE_OFF, STATE_ON, STATE_PAUSED, STATE_PLAYING) @@ -26,7 +26,7 @@ SUPPORT_ITUNES = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_SEEK | \ - SUPPORT_PLAY_MEDIA | SUPPORT_PLAY | SUPPORT_TURN_OFF + SUPPORT_PLAY_MEDIA | SUPPORT_PLAY | SUPPORT_TURN_OFF | SUPPORT_SHUFFLE_SET SUPPORT_AIRPLAY = SUPPORT_VOLUME_SET | SUPPORT_TURN_ON | SUPPORT_TURN_OFF @@ -96,6 +96,11 @@ def set_muted(self, muted): """Mute and returns the current state, muted True or False.""" return self._request('PUT', '/mute', {'muted': muted}) + def set_shuffle(self, shuffle): + """Set the shuffle mode, shuffle True or False.""" + return self._request('PUT', '/shuffle', + {'mode': ('songs' if shuffle else 'off')}) + def play(self): """Set playback to play and returns the current state.""" return self._command('play') @@ -183,6 +188,7 @@ def __init__(self, name, host, port, use_ssl, add_entities): self.current_volume = None self.muted = None + self.shuffled = None self.current_title = None self.current_album = None self.current_artist = None @@ -207,6 +213,9 @@ def update_state(self, state_hash): self.current_playlist = state_hash.get('playlist', None) self.content_id = state_hash.get('id', None) + _shuffle = state_hash.get('shuffle', None) + self.shuffled = (_shuffle == 'songs') + @property def name(self): """Return the name of the device.""" @@ -306,6 +315,11 @@ def media_playlist(self): """Title of the currently playing playlist.""" return self.current_playlist + @property + def shuffle(self): + """Boolean if shuffle is enabled.""" + return self.shuffled + @property def supported_features(self): """Flag media player features that are supported.""" @@ -321,6 +335,11 @@ def mute_volume(self, mute): response = self.client.set_muted(mute) self.update_state(response) + def set_shuffle(self, shuffle): + """Shuffle (true) or no shuffle (false) media player.""" + response = self.client.set_shuffle(shuffle) + self.update_state(response) + def media_play(self): """Send media_play command to media player.""" response = self.client.play() diff --git a/homeassistant/components/keenetic_ndms2/manifest.json b/homeassistant/components/keenetic_ndms2/manifest.json index 91c0c69a4fa56a..42d8d89a021152 100644 --- a/homeassistant/components/keenetic_ndms2/manifest.json +++ b/homeassistant/components/keenetic_ndms2/manifest.json @@ -3,7 +3,7 @@ "name": "Keenetic ndms2", "documentation": "https://www.home-assistant.io/components/keenetic_ndms2", "requirements": [ - "ndms2_client==0.0.7" + "ndms2_client==0.0.8" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/kira/__init__.py b/homeassistant/components/kira/__init__.py index 7cf27d342f51c9..723a13e426a1e3 100644 --- a/homeassistant/components/kira/__init__.py +++ b/homeassistant/components/kira/__init__.py @@ -60,7 +60,7 @@ def load_codes(path): codes = [] if os.path.exists(path): with open(path) as code_file: - data = yaml.load(code_file) or [] + data = yaml.safe_load(code_file) or [] for code in data: try: codes.append(CODE_SCHEMA(code)) diff --git a/homeassistant/components/lcn/__init__.py b/homeassistant/components/lcn/__init__.py index 4a421274a18704..cf21f705b31844 100644 --- a/homeassistant/components/lcn/__init__.py +++ b/homeassistant/components/lcn/__init__.py @@ -16,10 +16,11 @@ from .const import ( BINSENSOR_PORTS, CONF_CLIMATES, CONF_CONNECTIONS, CONF_DIM_MODE, CONF_DIMMABLE, CONF_LOCKABLE, CONF_MAX_TEMP, CONF_MIN_TEMP, CONF_MOTOR, - CONF_OUTPUT, CONF_SETPOINT, CONF_SK_NUM_TRIES, CONF_SOURCE, - CONF_TRANSITION, DATA_LCN, DIM_MODES, DOMAIN, KEYS, LED_PORTS, - LOGICOP_PORTS, MOTOR_PORTS, OUTPUT_PORTS, RELAY_PORTS, S0_INPUTS, - SETPOINTS, THRESHOLDS, VAR_UNITS, VARIABLES) + CONF_OUTPUT, CONF_OUTPUTS, CONF_REGISTER, CONF_SCENE, CONF_SCENES, + CONF_SETPOINT, CONF_SK_NUM_TRIES, CONF_SOURCE, CONF_TRANSITION, DATA_LCN, + DIM_MODES, DOMAIN, KEYS, LED_PORTS, LOGICOP_PORTS, MOTOR_PORTS, + OUTPUT_PORTS, RELAY_PORTS, S0_INPUTS, SETPOINTS, THRESHOLDS, VAR_UNITS, + VARIABLES) from .helpers import has_unique_connection_names, is_address from .services import ( DynText, Led, LockKeys, LockRegulator, OutputAbs, OutputRel, OutputToggle, @@ -64,6 +65,20 @@ lambda value: value * 1000), }) +SCENES_SCHEMA = vol.Schema({ + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_ADDRESS): is_address, + vol.Required(CONF_REGISTER): vol.All(vol.Coerce(int), vol.Range(0, 9)), + vol.Required(CONF_SCENE): vol.All(vol.Coerce(int), vol.Range(0, 9)), + vol.Optional(CONF_OUTPUTS): vol.All( + cv.ensure_list, [vol.All(vol.Upper, + vol.In(OUTPUT_PORTS + RELAY_PORTS))]), + vol.Optional(CONF_TRANSITION, default=None): + vol.Any(vol.All(vol.Coerce(int), vol.Range(min=0., max=486.), + lambda value: value * 1000), + None) +}) + SENSORS_SCHEMA = vol.Schema({ vol.Required(CONF_NAME): cv.string, vol.Required(CONF_ADDRESS): is_address, @@ -105,6 +120,8 @@ cv.ensure_list, [COVERS_SCHEMA]), vol.Optional(CONF_LIGHTS): vol.All( cv.ensure_list, [LIGHTS_SCHEMA]), + vol.Optional(CONF_SCENES): vol.All( + cv.ensure_list, [SCENES_SCHEMA]), vol.Optional(CONF_SENSORS): vol.All( cv.ensure_list, [SENSORS_SCHEMA]), vol.Optional(CONF_SWITCHES): vol.All( @@ -152,6 +169,7 @@ async def async_setup(hass, config): ('climate', CONF_CLIMATES), ('cover', CONF_COVERS), ('light', CONF_LIGHTS), + ('scene', CONF_SCENES), ('sensor', CONF_SENSORS), ('switch', CONF_SWITCHES)): if conf_key in config[DOMAIN]: diff --git a/homeassistant/components/lcn/climate.py b/homeassistant/components/lcn/climate.py index 67ba6d90c53178..7cf4f700b41974 100644 --- a/homeassistant/components/lcn/climate.py +++ b/homeassistant/components/lcn/climate.py @@ -51,7 +51,7 @@ def __init__(self, config, address_connection): self._current_temperature = None self._target_temperature = None - self._is_on = True + self._is_on = None self.support = const.SUPPORT_TARGET_TEMPERATURE if self.is_lockable: @@ -130,10 +130,12 @@ def input_received(self, input_obj): return if input_obj.get_var() == self.variable: - self._current_temperature = ( - input_obj.get_value().to_var_unit(self.unit)) - elif self._is_on and input_obj.get_var() == self.setpoint: - self._target_temperature = ( - input_obj.get_value().to_var_unit(self.unit)) + self._current_temperature = \ + input_obj.get_value().to_var_unit(self.unit) + elif input_obj.get_var() == self.setpoint: + self._is_on = not input_obj.get_value().is_locked_regulator() + if self.is_on: + self._target_temperature = \ + input_obj.get_value().to_var_unit(self.unit) self.async_schedule_update_ha_state() diff --git a/homeassistant/components/lcn/const.py b/homeassistant/components/lcn/const.py index 9307fb4d706b12..1cf88851456e04 100644 --- a/homeassistant/components/lcn/const.py +++ b/homeassistant/components/lcn/const.py @@ -32,6 +32,10 @@ CONF_CLIMATES = 'climates' CONF_MAX_TEMP = 'max_temp' CONF_MIN_TEMP = 'min_temp' +CONF_SCENES = 'scenes' +CONF_REGISTER = 'register' +CONF_SCENE = 'scene' +CONF_OUTPUTS = 'outputs' DIM_MODES = ['STEPS50', 'STEPS200'] diff --git a/homeassistant/components/lcn/manifest.json b/homeassistant/components/lcn/manifest.json index 5f0d1052741116..c5ec117a53e887 100644 --- a/homeassistant/components/lcn/manifest.json +++ b/homeassistant/components/lcn/manifest.json @@ -3,7 +3,7 @@ "name": "Lcn", "documentation": "https://www.home-assistant.io/components/lcn", "requirements": [ - "pypck==0.6.0" + "pypck==0.6.1" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/lcn/scene.py b/homeassistant/components/lcn/scene.py new file mode 100755 index 00000000000000..09f0292758a026 --- /dev/null +++ b/homeassistant/components/lcn/scene.py @@ -0,0 +1,66 @@ +"""Support for LCN scenes.""" +import pypck + +from homeassistant.components.scene import Scene +from homeassistant.const import CONF_ADDRESS + +from . import LcnDevice +from .const import ( + CONF_CONNECTIONS, CONF_OUTPUTS, CONF_REGISTER, CONF_SCENE, CONF_TRANSITION, + DATA_LCN, OUTPUT_PORTS) +from .helpers import get_connection + + +async def async_setup_platform(hass, hass_config, async_add_entities, + discovery_info=None): + """Set up the LCN scene platform.""" + if discovery_info is None: + return + + devices = [] + for config in discovery_info: + address, connection_id = config[CONF_ADDRESS] + addr = pypck.lcn_addr.LcnAddr(*address) + connections = hass.data[DATA_LCN][CONF_CONNECTIONS] + connection = get_connection(connections, connection_id) + address_connection = connection.get_address_conn(addr) + + devices.append(LcnScene(config, address_connection)) + + async_add_entities(devices) + + +class LcnScene(LcnDevice, Scene): + """Representation of a LCN scene.""" + + def __init__(self, config, address_connection): + """Initialize the LCN scene.""" + super().__init__(config, address_connection) + + self.register_id = config[CONF_REGISTER] + self.scene_id = config[CONF_SCENE] + self.output_ports = [] + self.relay_ports = [] + + for port in config[CONF_OUTPUTS]: + if port in OUTPUT_PORTS: + self.output_ports.append(pypck.lcn_defs.OutputPort[port]) + else: # in RELEAY_PORTS + self.relay_ports.append(pypck.lcn_defs.RelayPort[port]) + + if config[CONF_TRANSITION] is None: + self.transition = None + else: + self.transition = pypck.lcn_defs.time_to_ramp_value( + config[CONF_TRANSITION]) + + async def async_added_to_hass(self): + """Run when entity about to be added to hass.""" + + async def async_activate(self): + """Activate scene.""" + self.address_connection.activate_scene(self.register_id, + self.scene_id, + self.output_ports, + self.relay_ports, + self.transition) diff --git a/homeassistant/components/lcn/services.py b/homeassistant/components/lcn/services.py index 78a887a80c101e..e89608a23b702e 100755 --- a/homeassistant/components/lcn/services.py +++ b/homeassistant/components/lcn/services.py @@ -2,9 +2,9 @@ import pypck import voluptuous as vol +import homeassistant.helpers.config_validation as cv from homeassistant.const import ( CONF_ADDRESS, CONF_BRIGHTNESS, CONF_STATE, CONF_UNIT_OF_MEASUREMENT) -import homeassistant.helpers.config_validation as cv from .const import ( CONF_CONNECTIONS, CONF_KEYS, CONF_LED, CONF_OUTPUT, CONF_PCK, @@ -22,7 +22,7 @@ class LcnServiceCall(): schema = vol.Schema({ vol.Required(CONF_ADDRESS): is_address - }) + }) def __init__(self, hass): """Initialize service call.""" @@ -49,7 +49,7 @@ class OutputAbs(LcnServiceCall): vol.All(vol.Coerce(int), vol.Range(min=0, max=100)), vol.Optional(CONF_TRANSITION, default=0): vol.All(vol.Coerce(float), vol.Range(min=0., max=486.)) - }) + }) def __call__(self, call): """Execute service call.""" @@ -69,7 +69,7 @@ class OutputRel(LcnServiceCall): vol.Required(CONF_OUTPUT): vol.All(vol.Upper, vol.In(OUTPUT_PORTS)), vol.Required(CONF_BRIGHTNESS): vol.All(vol.Coerce(int), vol.Range(min=-100, max=100)) - }) + }) def __call__(self, call): """Execute service call.""" @@ -87,7 +87,7 @@ class OutputToggle(LcnServiceCall): vol.Required(CONF_OUTPUT): vol.All(vol.Upper, vol.In(OUTPUT_PORTS)), vol.Optional(CONF_TRANSITION, default=0): vol.All(vol.Coerce(float), vol.Range(min=0., max=486.)) - }) + }) def __call__(self, call): """Execute service call.""" @@ -145,7 +145,7 @@ class VarAbs(LcnServiceCall): vol.All(vol.Coerce(int), vol.Range(min=0)), vol.Optional(CONF_UNIT_OF_MEASUREMENT, default='native'): vol.All(vol.Upper, vol.In(VAR_UNITS)) - }) + }) def __call__(self, call): """Execute service call.""" @@ -164,7 +164,7 @@ class VarReset(LcnServiceCall): schema = LcnServiceCall.schema.extend({ vol.Required(CONF_VARIABLE): vol.All(vol.Upper, vol.In(VARIABLES + SETPOINTS)) - }) + }) def __call__(self, call): """Execute service call.""" @@ -185,7 +185,7 @@ class VarRel(LcnServiceCall): vol.All(vol.Upper, vol.In(VAR_UNITS)), vol.Optional(CONF_RELVARREF, default='current'): vol.All(vol.Upper, vol.In(RELVARREF)) - }) + }) def __call__(self, call): """Execute service call.""" @@ -206,7 +206,7 @@ class LockRegulator(LcnServiceCall): schema = LcnServiceCall.schema.extend({ vol.Required(CONF_SETPOINT): vol.All(vol.Upper, vol.In(SETPOINTS)), vol.Optional(CONF_STATE, default=False): bool, - }) + }) def __call__(self, call): """Execute service call.""" @@ -222,13 +222,14 @@ class SendKeys(LcnServiceCall): """Sends keys (which executes bound commands).""" schema = LcnServiceCall.schema.extend({ - vol.Required(CONF_KEYS): cv.matches_regex(r'^([a-dA-D][1-8])+$'), + vol.Required(CONF_KEYS): vol.All( + vol.Upper, cv.matches_regex(r'^([A-D][1-8])+$')), vol.Optional(CONF_STATE, default='hit'): vol.All(vol.Upper, vol.In(SENDKEYCOMMANDS)), vol.Optional(CONF_TIME, default=0): vol.All(int, vol.Range(min=0)), vol.Optional(CONF_TIME_UNIT, default='s'): vol.All(vol.Upper, vol.In(TIME_UNITS)) - }) + }) def __call__(self, call): """Execute service call.""" @@ -265,12 +266,13 @@ class LockKeys(LcnServiceCall): """Lock keys.""" schema = LcnServiceCall.schema.extend({ - vol.Optional(CONF_TABLE, default='a'): cv.matches_regex(r'^[a-dA-D]$'), + vol.Optional(CONF_TABLE, default='a'): vol.All( + vol.Upper, cv.matches_regex(r'^[A-D]$')), vol.Required(CONF_STATE): is_key_lock_states_string, vol.Optional(CONF_TIME, default=0): vol.All(int, vol.Range(min=0)), vol.Optional(CONF_TIME_UNIT, default='s'): vol.All(vol.Upper, vol.In(TIME_UNITS)) - }) + }) def __call__(self, call): """Execute service call.""" @@ -301,7 +303,7 @@ class DynText(LcnServiceCall): schema = LcnServiceCall.schema.extend({ vol.Required(CONF_ROW): vol.All(int, vol.Range(min=1, max=4)), vol.Required(CONF_TEXT): vol.All(str, vol.Length(max=60)) - }) + }) def __call__(self, call): """Execute service call.""" @@ -317,7 +319,7 @@ class Pck(LcnServiceCall): schema = LcnServiceCall.schema.extend({ vol.Required(CONF_PCK): str - }) + }) def __call__(self, call): """Execute service call.""" diff --git a/homeassistant/components/life360/.translations/ca.json b/homeassistant/components/life360/.translations/ca.json new file mode 100644 index 00000000000000..a7189d69185220 --- /dev/null +++ b/homeassistant/components/life360/.translations/ca.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "invalid_credentials": "Credencials inv\u00e0lides", + "user_already_configured": "El compte ja ha estat configurat" + }, + "create_entry": { + "default": "Per configurar les opcions avan\u00e7ades mira la [documentaci\u00f3 de Life360]({docs_url})." + }, + "error": { + "invalid_credentials": "Credencials inv\u00e0lides", + "invalid_username": "Nom d'usuari incorrecte", + "user_already_configured": "El compte ja ha estat configurat" + }, + "step": { + "user": { + "data": { + "password": "Contrasenya", + "username": "Nom d'usuari" + }, + "description": "Per configurar les opcions avan\u00e7ades mira la [documentaci\u00f3 de Life360]({docs_url}). Pot ser que ho hagis de fer abans d\u2019afegir cap compte.", + "title": "Informaci\u00f3 del compte Life360" + } + }, + "title": "Life360" + } +} \ No newline at end of file diff --git a/homeassistant/components/life360/.translations/de.json b/homeassistant/components/life360/.translations/de.json new file mode 100644 index 00000000000000..9833a0c9959a7f --- /dev/null +++ b/homeassistant/components/life360/.translations/de.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "user_already_configured": "Konto wurde bereits konfiguriert" + }, + "create_entry": { + "default": "M\u00f6gliche erweiterte Einstellungen finden sich unter [Life360-Dokumentation]({docs_url})." + }, + "error": { + "invalid_credentials": "Ung\u00fcltige Anmeldeinformationen", + "invalid_username": "Ung\u00fcltiger Benutzername", + "user_already_configured": "Konto wurde bereits konfiguriert" + }, + "step": { + "user": { + "data": { + "password": "Passwort", + "username": "Benutzername" + }, + "description": "Erweiterte Optionen sind in der [Life360-Dokumentation]({docs_url}) zu finden.\nDies sollte vor dem Hinzuf\u00fcgen von Kontoinformationen getan werden.", + "title": "Life360-Kontoinformationen" + } + }, + "title": "Life360" + } +} \ No newline at end of file diff --git a/homeassistant/components/life360/.translations/en.json b/homeassistant/components/life360/.translations/en.json new file mode 100644 index 00000000000000..2c187ba0470730 --- /dev/null +++ b/homeassistant/components/life360/.translations/en.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "invalid_credentials": "Invalid credentials", + "user_already_configured": "Account has already been configured" + }, + "create_entry": { + "default": "To set advanced options, see [Life360 documentation]({docs_url})." + }, + "error": { + "invalid_credentials": "Invalid credentials", + "invalid_username": "Invalid username", + "user_already_configured": "Account has already been configured" + }, + "step": { + "user": { + "data": { + "password": "Password", + "username": "Username" + }, + "description": "To set advanced options, see [Life360 documentation]({docs_url}).\nYou may want to do that before adding accounts.", + "title": "Life360 Account Info" + } + }, + "title": "Life360" + } +} \ No newline at end of file diff --git a/homeassistant/components/life360/.translations/it.json b/homeassistant/components/life360/.translations/it.json new file mode 100644 index 00000000000000..9c4cb1cc4cb15f --- /dev/null +++ b/homeassistant/components/life360/.translations/it.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "invalid_credentials": "Credenziali non valide", + "user_already_configured": "L'account \u00e8 gi\u00e0 stato configurato" + }, + "create_entry": { + "default": "Per impostare le opzioni avanzate, consultare la [Documentazione Life360] ( {docs_url} )." + }, + "error": { + "invalid_credentials": "Credenziali non valide", + "invalid_username": "Nome utente non valido", + "user_already_configured": "L'account \u00e8 gi\u00e0 stato configurato" + }, + "step": { + "user": { + "data": { + "password": "Password", + "username": "Nome utente" + }, + "title": "Informazioni sull'account Life360" + } + }, + "title": "Life360" + } +} \ No newline at end of file diff --git a/homeassistant/components/life360/.translations/ko.json b/homeassistant/components/life360/.translations/ko.json new file mode 100644 index 00000000000000..b81a6fd059f5ce --- /dev/null +++ b/homeassistant/components/life360/.translations/ko.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "invalid_credentials": "\uc0ac\uc6a9\uc790 \uc774\ub984 \ud639\uc740 \ube44\ubc00\ubc88\ud638\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "user_already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + }, + "create_entry": { + "default": "\uace0\uae09 \uc635\uc158\uc744 \uc124\uc815\ud558\ub824\uba74 [Life360 \uc124\uba85\uc11c]({docs_url}) \ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694." + }, + "error": { + "invalid_credentials": "\ube44\ubc00\ubc88\ud638\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "invalid_username": "\uc0ac\uc6a9\uc790 \uc774\ub984\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "user_already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + }, + "step": { + "user": { + "data": { + "password": "\ube44\ubc00\ubc88\ud638", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" + }, + "description": "\uace0\uae09 \uc635\uc158\uc744 \uc124\uc815\ud558\ub824\uba74 [Life360 \uc124\uba85\uc11c]({docs_url}) \ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694. \uacc4\uc815\uc744 \ucd94\uac00\ud558\uc2dc\uae30 \uc804\uc5d0 \uc77d\uc5b4\ubcf4\uc2dc\ub294\uac83\uc744 \ucd94\ucc9c\ub4dc\ub9bd\ub2c8\ub2e4.", + "title": "Life360 \uacc4\uc815 \uc815\ubcf4" + } + }, + "title": "Life360" + } +} \ No newline at end of file diff --git a/homeassistant/components/life360/.translations/lb.json b/homeassistant/components/life360/.translations/lb.json new file mode 100644 index 00000000000000..bfed5937e24bea --- /dev/null +++ b/homeassistant/components/life360/.translations/lb.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "invalid_credentials": "Ong\u00eblteg Login Informatioune", + "user_already_configured": "Kont ass scho konfigur\u00e9iert" + }, + "create_entry": { + "default": "Fir erweidert Optiounen anzestellen, kuckt [Life360 Dokumentatioun]({docs_url})." + }, + "error": { + "invalid_credentials": "Ong\u00eblteg Login Informatioune", + "invalid_username": "Ong\u00ebltege Benotzernumm", + "user_already_configured": "Kont ass scho konfigur\u00e9iert" + }, + "step": { + "user": { + "data": { + "password": "Passwuert", + "username": "Benotzernumm" + }, + "description": "Fir erweidert Optiounen anzestellen, kuckt [Life360 Dokumentatioun]({docs_url}).\nMaacht dat am beschten ier dir Konte b\u00e4isetzt.", + "title": "Life360 Kont Informatiounen" + } + }, + "title": "Life360" + } +} \ No newline at end of file diff --git a/homeassistant/components/life360/.translations/nl.json b/homeassistant/components/life360/.translations/nl.json new file mode 100644 index 00000000000000..ec7a53329503a0 --- /dev/null +++ b/homeassistant/components/life360/.translations/nl.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "invalid_credentials": "Ongeldige gebruikersgegevens", + "user_already_configured": "Account is al geconfigureerd" + }, + "create_entry": { + "default": "Om geavanceerde opties in te stellen, zie [Life360 documentatie]({docs_url})." + }, + "error": { + "invalid_credentials": "Ongeldige gebruikersgegevens", + "invalid_username": "Ongeldige gebruikersnaam", + "user_already_configured": "Account is al geconfigureerd" + }, + "step": { + "user": { + "data": { + "password": "Wachtwoord", + "username": "Gebruikersnaam" + }, + "description": "Om geavanceerde opties in te stellen, zie [Life360 documentatie]({docs_url}).\nMisschien wilt u dat doen voordat u accounts toevoegt.", + "title": "Life360-accountgegevens" + } + }, + "title": "Life360" + } +} \ No newline at end of file diff --git a/homeassistant/components/life360/.translations/pl.json b/homeassistant/components/life360/.translations/pl.json new file mode 100644 index 00000000000000..b1523da188ce88 --- /dev/null +++ b/homeassistant/components/life360/.translations/pl.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "invalid_credentials": "B\u0142\u0119dne dane uwierzytelniaj\u0105ce", + "user_already_configured": "Konto jest ju\u017c skonfigurowane." + }, + "create_entry": { + "default": "Aby skonfigurowa\u0107 zaawansowane ustawienia, zapoznaj si\u0119 z [dokumentacj\u0105 Life360]({docs_url})." + }, + "error": { + "invalid_credentials": "B\u0142\u0119dne dane uwierzytelniaj\u0105ce", + "invalid_username": "Nieprawid\u0142owa nazwa u\u017cytkownika", + "user_already_configured": "Konto jest ju\u017c skonfigurowane." + }, + "step": { + "user": { + "data": { + "password": "Has\u0142o", + "username": "Nazwa u\u017cytkownika" + }, + "description": "Aby skonfigurowa\u0107 zaawansowane ustawienia, zapoznaj si\u0119 z [dokumentacj\u0105 Life360]({docs_url}). Mo\u017cesz to zrobi\u0107 przed dodaniem kont.", + "title": "Informacje o koncie Life360" + } + }, + "title": "Life360" + } +} \ No newline at end of file diff --git a/homeassistant/components/life360/.translations/pt-BR.json b/homeassistant/components/life360/.translations/pt-BR.json new file mode 100644 index 00000000000000..ca4cee896b37ac --- /dev/null +++ b/homeassistant/components/life360/.translations/pt-BR.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "invalid_credentials": "Credenciais inv\u00e1lidas", + "user_already_configured": "A conta j\u00e1 foi configurada" + }, + "create_entry": { + "default": "Para definir op\u00e7\u00f5es avan\u00e7adas, consulte [Documenta\u00e7\u00e3o da Life360] ({docs_url})." + }, + "error": { + "invalid_credentials": "Credenciais inv\u00e1lidas", + "invalid_username": "Nome de usu\u00e1rio Inv\u00e1lido", + "user_already_configured": "A conta j\u00e1 foi configurada" + }, + "step": { + "user": { + "data": { + "password": "Senha", + "username": "Nome de usu\u00e1rio" + }, + "description": "Para definir op\u00e7\u00f5es avan\u00e7adas, consulte [Documenta\u00e7\u00e3o da Life360] ({docs_url}). \n Voc\u00ea pode querer fazer isso antes de adicionar contas.", + "title": "Informa\u00e7\u00f5es da conta Life360" + } + }, + "title": "Life360" + } +} \ No newline at end of file diff --git a/homeassistant/components/life360/.translations/ru.json b/homeassistant/components/life360/.translations/ru.json new file mode 100644 index 00000000000000..0f698457bf799a --- /dev/null +++ b/homeassistant/components/life360/.translations/ru.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "invalid_credentials": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0435 \u0443\u0447\u0435\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435", + "user_already_configured": "\u0423\u0447\u0435\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430" + }, + "create_entry": { + "default": "\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 Life360]({docs_url}) \u0434\u043b\u044f \u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u043d\u043e\u0439 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438." + }, + "error": { + "invalid_credentials": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0435 \u0443\u0447\u0435\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435", + "invalid_username": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043b\u043e\u0433\u0438\u043d", + "user_already_configured": "\u0423\u0447\u0435\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430" + }, + "step": { + "user": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "username": "\u041b\u043e\u0433\u0438\u043d" + }, + "description": "\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 Life360]({docs_url}) \u0434\u043b\u044f \u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u043d\u043e\u0439 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438. \u0412\u044b \u043c\u043e\u0436\u0435\u0442\u0435 \u0441\u0434\u0435\u043b\u0430\u0442\u044c \u044d\u0442\u043e \u043f\u0435\u0440\u0435\u0434 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u043e\u0439 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430.", + "title": "Life360" + } + }, + "title": "Life360" + } +} \ No newline at end of file diff --git a/homeassistant/components/life360/.translations/sl.json b/homeassistant/components/life360/.translations/sl.json new file mode 100644 index 00000000000000..36e4917256bc91 --- /dev/null +++ b/homeassistant/components/life360/.translations/sl.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "invalid_credentials": "Napa\u010dno geslo", + "user_already_configured": "Ra\u010dun \u017ee nastavljen" + }, + "create_entry": { + "default": "\u010ce \u017eelite nastaviti napredne mo\u017enosti, glejte [Life360 dokumentacija]({docs_url})." + }, + "error": { + "invalid_credentials": "Napa\u010dno geslo", + "invalid_username": "Napa\u010dno uporabni\u0161ko ime", + "user_already_configured": "Ra\u010dun \u017ee nastavljen" + }, + "step": { + "user": { + "data": { + "password": "Geslo", + "username": "Uporabni\u0161ko ime" + }, + "description": "\u010ce \u017eelite nastaviti napredne mo\u017enosti, glejte [Life360 dokumentacija]({docs_url}). \n To lahko storite pred dodajanjem ra\u010dunov.", + "title": "Podatki ra\u010duna Life360" + } + }, + "title": "Life360" + } +} \ No newline at end of file diff --git a/homeassistant/components/life360/.translations/zh-Hant.json b/homeassistant/components/life360/.translations/zh-Hant.json new file mode 100644 index 00000000000000..8ab5dcf536979e --- /dev/null +++ b/homeassistant/components/life360/.translations/zh-Hant.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "invalid_credentials": "\u6191\u8b49\u7121\u6548", + "user_already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "create_entry": { + "default": "\u6b32\u8a2d\u5b9a\u9032\u968e\u9078\u9805\uff0c\u8acb\u53c3\u95b1 [Life360 \u6587\u4ef6]({docs_url})\u3002" + }, + "error": { + "invalid_credentials": "\u6191\u8b49\u7121\u6548", + "invalid_username": "\u4f7f\u7528\u8005\u540d\u7a31\u7121\u6548", + "user_already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "step": { + "user": { + "data": { + "password": "\u5bc6\u78bc", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + }, + "description": "\u6b32\u8a2d\u5b9a\u9032\u968e\u9078\u9805\uff0c\u8acb\u53c3\u95b1 [Life360 \u6587\u4ef6]({docs_url})\u3002\n\u5efa\u8b70\u65bc\u65b0\u589e\u5e33\u865f\u524d\uff0c\u5148\u9032\u884c\u4e86\u89e3\u3002", + "title": "Life360 \u5e33\u865f\u8cc7\u8a0a" + } + }, + "title": "Life360" + } +} \ No newline at end of file diff --git a/homeassistant/components/life360/__init__.py b/homeassistant/components/life360/__init__.py new file mode 100644 index 00000000000000..b59ace1d1ffa2e --- /dev/null +++ b/homeassistant/components/life360/__init__.py @@ -0,0 +1,178 @@ +"""Life360 integration.""" +import logging + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.device_tracker import ( + CONF_SCAN_INTERVAL, DOMAIN as DEVICE_TRACKER) +from homeassistant.components.device_tracker.const import ( + SCAN_INTERVAL as DEFAULT_SCAN_INTERVAL) +from homeassistant.const import ( + CONF_EXCLUDE, CONF_INCLUDE, CONF_PASSWORD, CONF_PREFIX, CONF_USERNAME) +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv + +from .const import ( + CONF_AUTHORIZATION, CONF_CIRCLES, CONF_DRIVING_SPEED, CONF_ERROR_THRESHOLD, + CONF_MAX_GPS_ACCURACY, CONF_MAX_UPDATE_WAIT, CONF_MEMBERS, + CONF_SHOW_AS_STATE, CONF_WARNING_THRESHOLD, DOMAIN, SHOW_DRIVING, + SHOW_MOVING) +from .helpers import get_api + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_PREFIX = DOMAIN + +CONF_ACCOUNTS = 'accounts' + +SHOW_AS_STATE_OPTS = [SHOW_DRIVING, SHOW_MOVING] + + +def _excl_incl_list_to_filter_dict(value): + return { + 'include': CONF_INCLUDE in value, + 'list': value.get(CONF_EXCLUDE) or value.get(CONF_INCLUDE) + } + + +def _prefix(value): + if not value: + return '' + if not value.endswith('_'): + return value + '_' + return value + + +def _thresholds(config): + error_threshold = config.get(CONF_ERROR_THRESHOLD) + warning_threshold = config.get(CONF_WARNING_THRESHOLD) + if error_threshold and warning_threshold: + if error_threshold <= warning_threshold: + raise vol.Invalid('{} must be larger than {}'.format( + CONF_ERROR_THRESHOLD, CONF_WARNING_THRESHOLD)) + elif not error_threshold and warning_threshold: + config[CONF_ERROR_THRESHOLD] = warning_threshold + 1 + elif error_threshold and not warning_threshold: + # Make them the same which effectively prevents warnings. + config[CONF_WARNING_THRESHOLD] = error_threshold + else: + # Log all errors as errors. + config[CONF_ERROR_THRESHOLD] = 1 + config[CONF_WARNING_THRESHOLD] = 1 + return config + + +ACCOUNT_SCHEMA = vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, +}) + +_SLUG_LIST = vol.All( + cv.ensure_list, [cv.slugify], + vol.Length(min=1, msg='List cannot be empty')) + +_LOWER_STRING_LIST = vol.All( + cv.ensure_list, [vol.All(cv.string, vol.Lower)], + vol.Length(min=1, msg='List cannot be empty')) + +_EXCL_INCL_SLUG_LIST = vol.All( + vol.Schema({ + vol.Exclusive(CONF_EXCLUDE, 'incl_excl'): _SLUG_LIST, + vol.Exclusive(CONF_INCLUDE, 'incl_excl'): _SLUG_LIST, + }), + cv.has_at_least_one_key(CONF_EXCLUDE, CONF_INCLUDE), + _excl_incl_list_to_filter_dict, +) + +_EXCL_INCL_LOWER_STRING_LIST = vol.All( + vol.Schema({ + vol.Exclusive(CONF_EXCLUDE, 'incl_excl'): _LOWER_STRING_LIST, + vol.Exclusive(CONF_INCLUDE, 'incl_excl'): _LOWER_STRING_LIST, + }), + cv.has_at_least_one_key(CONF_EXCLUDE, CONF_INCLUDE), + _excl_incl_list_to_filter_dict +) + +_THRESHOLD = vol.All(vol.Coerce(int), vol.Range(min=1)) + +LIFE360_SCHEMA = vol.All( + vol.Schema({ + vol.Optional(CONF_ACCOUNTS): vol.All( + cv.ensure_list, [ACCOUNT_SCHEMA], vol.Length(min=1)), + vol.Optional(CONF_CIRCLES): _EXCL_INCL_LOWER_STRING_LIST, + vol.Optional(CONF_DRIVING_SPEED): vol.Coerce(float), + vol.Optional(CONF_ERROR_THRESHOLD): _THRESHOLD, + vol.Optional(CONF_MAX_GPS_ACCURACY): vol.Coerce(float), + vol.Optional(CONF_MAX_UPDATE_WAIT): vol.All( + cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_MEMBERS): _EXCL_INCL_SLUG_LIST, + vol.Optional(CONF_PREFIX, default=DEFAULT_PREFIX): + vol.All(vol.Any(None, cv.string), _prefix), + vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): + cv.time_period, + vol.Optional(CONF_SHOW_AS_STATE, default=[]): vol.All( + cv.ensure_list, [vol.In(SHOW_AS_STATE_OPTS)]), + vol.Optional(CONF_WARNING_THRESHOLD): _THRESHOLD, + }), + _thresholds +) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: LIFE360_SCHEMA +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up integration.""" + conf = config.get(DOMAIN, LIFE360_SCHEMA({})) + hass.data[DOMAIN] = {'config': conf, 'apis': {}} + discovery.load_platform(hass, DEVICE_TRACKER, DOMAIN, None, config) + + if CONF_ACCOUNTS not in conf: + return True + + # Check existing config entries. For any that correspond to an entry in + # configuration.yaml, and whose password has not changed, nothing needs to + # be done with that config entry or that account from configuration.yaml. + # But if the config entry was created by import and the account no longer + # exists in configuration.yaml, or if the password has changed, then delete + # that out-of-date config entry. + already_configured = [] + for entry in hass.config_entries.async_entries(DOMAIN): + # Find corresponding configuration.yaml entry and its password. + password = None + for account in conf[CONF_ACCOUNTS]: + if account[CONF_USERNAME] == entry.data[CONF_USERNAME]: + password = account[CONF_PASSWORD] + if password == entry.data[CONF_PASSWORD]: + already_configured.append(entry.data[CONF_USERNAME]) + continue + if (not password and entry.source == config_entries.SOURCE_IMPORT + or password and password != entry.data[CONF_PASSWORD]): + hass.async_create_task(hass.config_entries.async_remove( + entry.entry_id)) + + # Create config entries for accounts listed in configuration. + for account in conf[CONF_ACCOUNTS]: + if account[CONF_USERNAME] not in already_configured: + hass.async_create_task(hass.config_entries.flow.async_init( + DOMAIN, context={'source': config_entries.SOURCE_IMPORT}, + data=account)) + return True + + +async def async_setup_entry(hass, entry): + """Set up config entry.""" + hass.data[DOMAIN]['apis'][entry.data[CONF_USERNAME]] = get_api( + entry.data[CONF_AUTHORIZATION]) + return True + + +async def async_unload_entry(hass, entry): + """Unload config entry.""" + try: + hass.data[DOMAIN]['apis'].pop(entry.data[CONF_USERNAME]) + return True + except KeyError: + return False diff --git a/homeassistant/components/life360/config_flow.py b/homeassistant/components/life360/config_flow.py new file mode 100644 index 00000000000000..4f536b0f60efe5 --- /dev/null +++ b/homeassistant/components/life360/config_flow.py @@ -0,0 +1,97 @@ +"""Config flow to configure Life360 integration.""" +from collections import OrderedDict +import logging + +from life360 import LoginError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from .const import CONF_AUTHORIZATION, DOMAIN +from .helpers import get_api + +_LOGGER = logging.getLogger(__name__) + +DOCS_URL = 'https://www.home-assistant.io/components/life360' + + +@config_entries.HANDLERS.register(DOMAIN) +class Life360ConfigFlow(config_entries.ConfigFlow): + """Life360 integration config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Initialize.""" + self._api = get_api() + self._username = vol.UNDEFINED + self._password = vol.UNDEFINED + + @property + def configured_usernames(self): + """Return tuple of configured usernames.""" + entries = self.hass.config_entries.async_entries(DOMAIN) + if entries: + return (entry.data[CONF_USERNAME] for entry in entries) + return () + + async def async_step_user(self, user_input=None): + """Handle a user initiated config flow.""" + errors = {} + + if user_input is not None: + self._username = user_input[CONF_USERNAME] + self._password = user_input[CONF_PASSWORD] + try: + # pylint: disable=no-value-for-parameter + vol.Email()(self._username) + authorization = self._api.get_authorization( + self._username, self._password) + except vol.Invalid: + errors[CONF_USERNAME] = 'invalid_username' + except LoginError: + errors['base'] = 'invalid_credentials' + else: + if self._username in self.configured_usernames: + errors['base'] = 'user_already_configured' + else: + return self.async_create_entry( + title=self._username, + data={ + CONF_USERNAME: self._username, + CONF_PASSWORD: self._password, + CONF_AUTHORIZATION: authorization + }, + description_placeholders={'docs_url': DOCS_URL} + ) + + data_schema = OrderedDict() + data_schema[vol.Required(CONF_USERNAME, default=self._username)] = str + data_schema[vol.Required(CONF_PASSWORD, default=self._password)] = str + + return self.async_show_form( + step_id='user', + data_schema=vol.Schema(data_schema), + errors=errors, + description_placeholders={'docs_url': DOCS_URL} + ) + + async def async_step_import(self, user_input): + """Import a config flow from configuration.""" + username = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + try: + authorization = self._api.get_authorization(username, password) + except LoginError: + _LOGGER.error('Invalid credentials for %s', username) + return self.async_abort(reason='invalid_credentials') + return self.async_create_entry( + title='{} (from configuration)'.format(username), + data={ + CONF_USERNAME: username, + CONF_PASSWORD: password, + CONF_AUTHORIZATION: authorization + } + ) diff --git a/homeassistant/components/life360/const.py b/homeassistant/components/life360/const.py new file mode 100644 index 00000000000000..602c5ee48468c5 --- /dev/null +++ b/homeassistant/components/life360/const.py @@ -0,0 +1,15 @@ +"""Constants for Life360 integration.""" +DOMAIN = 'life360' + +CONF_AUTHORIZATION = 'authorization' +CONF_CIRCLES = 'circles' +CONF_DRIVING_SPEED = 'driving_speed' +CONF_ERROR_THRESHOLD = 'error_threshold' +CONF_MAX_GPS_ACCURACY = 'max_gps_accuracy' +CONF_MAX_UPDATE_WAIT = 'max_update_wait' +CONF_MEMBERS = 'members' +CONF_SHOW_AS_STATE = 'show_as_state' +CONF_WARNING_THRESHOLD = 'warning_threshold' + +SHOW_DRIVING = 'driving' +SHOW_MOVING = 'moving' diff --git a/homeassistant/components/life360/device_tracker.py b/homeassistant/components/life360/device_tracker.py new file mode 100644 index 00000000000000..cf69d8b656a243 --- /dev/null +++ b/homeassistant/components/life360/device_tracker.py @@ -0,0 +1,370 @@ +"""Support for Life360 device tracking.""" +from datetime import timedelta +import logging + +from life360 import Life360Error +import voluptuous as vol + +from homeassistant.components.device_tracker import CONF_SCAN_INTERVAL +from homeassistant.components.device_tracker.const import ( + ENTITY_ID_FORMAT as DT_ENTITY_ID_FORMAT) +from homeassistant.components.zone import async_active_zone +from homeassistant.const import ( + ATTR_BATTERY_CHARGING, ATTR_ENTITY_ID, CONF_PREFIX, LENGTH_FEET, + LENGTH_KILOMETERS, LENGTH_METERS, LENGTH_MILES, STATE_UNKNOWN) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import track_time_interval +from homeassistant.util.async_ import run_callback_threadsafe +from homeassistant.util.distance import convert +import homeassistant.util.dt as dt_util + +from .const import ( + CONF_CIRCLES, CONF_DRIVING_SPEED, CONF_ERROR_THRESHOLD, + CONF_MAX_GPS_ACCURACY, CONF_MAX_UPDATE_WAIT, CONF_MEMBERS, + CONF_SHOW_AS_STATE, CONF_WARNING_THRESHOLD, DOMAIN, SHOW_DRIVING, + SHOW_MOVING) + +_LOGGER = logging.getLogger(__name__) + +SPEED_FACTOR_MPH = 2.25 +EVENT_DELAY = timedelta(seconds=30) + +ATTR_ADDRESS = 'address' +ATTR_AT_LOC_SINCE = 'at_loc_since' +ATTR_DRIVING = 'driving' +ATTR_LAST_SEEN = 'last_seen' +ATTR_MOVING = 'moving' +ATTR_PLACE = 'place' +ATTR_RAW_SPEED = 'raw_speed' +ATTR_SPEED = 'speed' +ATTR_WAIT = 'wait' +ATTR_WIFI_ON = 'wifi_on' + +EVENT_UPDATE_OVERDUE = 'life360_update_overdue' +EVENT_UPDATE_RESTORED = 'life360_update_restored' + + +def _include_name(filter_dict, name): + if not name: + return False + if not filter_dict: + return True + name = name.lower() + if filter_dict['include']: + return name in filter_dict['list'] + return name not in filter_dict['list'] + + +def _exc_msg(exc): + return '{}: {}'.format(exc.__class__.__name__, str(exc)) + + +def _dump_filter(filter_dict, desc, func=lambda x: x): + if not filter_dict: + return + _LOGGER.debug( + '%scluding %s: %s', + 'In' if filter_dict['include'] else 'Ex', desc, + ', '.join([func(name) for name in filter_dict['list']])) + + +def setup_scanner(hass, config, see, discovery_info=None): + """Set up device scanner.""" + config = hass.data[DOMAIN]['config'] + apis = hass.data[DOMAIN]['apis'] + Life360Scanner(hass, config, see, apis) + return True + + +def _utc_from_ts(val): + try: + return dt_util.utc_from_timestamp(float(val)) + except (TypeError, ValueError): + return None + + +def _dt_attr_from_ts(timestamp): + utc = _utc_from_ts(timestamp) + if utc: + return utc + return STATE_UNKNOWN + + +def _bool_attr_from_int(val): + try: + return bool(int(val)) + except (TypeError, ValueError): + return STATE_UNKNOWN + + +class Life360Scanner: + """Life360 device scanner.""" + + def __init__(self, hass, config, see, apis): + """Initialize Life360Scanner.""" + self._hass = hass + self._see = see + self._max_gps_accuracy = config.get(CONF_MAX_GPS_ACCURACY) + self._max_update_wait = config.get(CONF_MAX_UPDATE_WAIT) + self._prefix = config[CONF_PREFIX] + self._circles_filter = config.get(CONF_CIRCLES) + self._members_filter = config.get(CONF_MEMBERS) + self._driving_speed = config.get(CONF_DRIVING_SPEED) + self._show_as_state = config[CONF_SHOW_AS_STATE] + self._apis = apis + self._errs = {} + self._error_threshold = config[CONF_ERROR_THRESHOLD] + self._warning_threshold = config[CONF_WARNING_THRESHOLD] + self._max_errs = self._error_threshold + 1 + self._dev_data = {} + self._circles_logged = set() + self._members_logged = set() + + _dump_filter(self._circles_filter, 'Circles') + _dump_filter(self._members_filter, 'device IDs', self._dev_id) + + self._started = dt_util.utcnow() + self._update_life360() + track_time_interval( + self._hass, self._update_life360, config[CONF_SCAN_INTERVAL]) + + def _dev_id(self, name): + return self._prefix + name + + def _ok(self, key): + if self._errs.get(key, 0) >= self._max_errs: + _LOGGER.error('%s: OK again', key) + self._errs[key] = 0 + + def _err(self, key, err_msg): + _errs = self._errs.get(key, 0) + if _errs < self._max_errs: + self._errs[key] = _errs = _errs + 1 + msg = '{}: {}'.format(key, err_msg) + if _errs >= self._error_threshold: + if _errs == self._max_errs: + msg = 'Suppressing further errors until OK: ' + msg + _LOGGER.error(msg) + elif _errs >= self._warning_threshold: + _LOGGER.warning(msg) + + def _exc(self, key, exc): + self._err(key, _exc_msg(exc)) + + def _prev_seen(self, dev_id, last_seen): + prev_seen, reported = self._dev_data.get(dev_id, (None, False)) + + if self._max_update_wait: + now = dt_util.utcnow() + most_recent_update = last_seen or prev_seen or self._started + overdue = now - most_recent_update > self._max_update_wait + if overdue and not reported and now - self._started > EVENT_DELAY: + self._hass.bus.fire( + EVENT_UPDATE_OVERDUE, + {ATTR_ENTITY_ID: DT_ENTITY_ID_FORMAT.format(dev_id)}) + reported = True + elif not overdue and reported: + self._hass.bus.fire( + EVENT_UPDATE_RESTORED, { + ATTR_ENTITY_ID: DT_ENTITY_ID_FORMAT.format(dev_id), + ATTR_WAIT: + str(last_seen - (prev_seen or self._started)) + .split('.')[0]}) + reported = False + + self._dev_data[dev_id] = last_seen or prev_seen, reported + + return prev_seen + + def _update_member(self, member, dev_id): + loc = member.get('location', {}) + last_seen = _utc_from_ts(loc.get('timestamp')) + prev_seen = self._prev_seen(dev_id, last_seen) + + if not loc: + err_msg = member['issues']['title'] + if err_msg: + if member['issues']['dialog']: + err_msg += ': ' + member['issues']['dialog'] + else: + err_msg = 'Location information missing' + self._err(dev_id, err_msg) + return + + # Only update when we truly have an update. + if not last_seen or prev_seen and last_seen <= prev_seen: + return + + lat = loc.get('latitude') + lon = loc.get('longitude') + gps_accuracy = loc.get('accuracy') + try: + lat = float(lat) + lon = float(lon) + # Life360 reports accuracy in feet, but Device Tracker expects + # gps_accuracy in meters. + gps_accuracy = round( + convert(float(gps_accuracy), LENGTH_FEET, LENGTH_METERS)) + except (TypeError, ValueError): + self._err(dev_id, 'GPS data invalid: {}, {}, {}'.format( + lat, lon, gps_accuracy)) + return + + self._ok(dev_id) + + msg = 'Updating {}'.format(dev_id) + if prev_seen: + msg += '; Time since last update: {}'.format(last_seen - prev_seen) + _LOGGER.debug(msg) + + if (self._max_gps_accuracy is not None + and gps_accuracy > self._max_gps_accuracy): + _LOGGER.warning( + '%s: Ignoring update because expected GPS ' + 'accuracy (%.0f) is not met: %.0f', + dev_id, self._max_gps_accuracy, gps_accuracy) + return + + # Get raw attribute data, converting empty strings to None. + place = loc.get('name') or None + address1 = loc.get('address1') or None + address2 = loc.get('address2') or None + if address1 and address2: + address = ', '.join([address1, address2]) + else: + address = address1 or address2 + raw_speed = loc.get('speed') or None + driving = _bool_attr_from_int(loc.get('isDriving')) + moving = _bool_attr_from_int(loc.get('inTransit')) + try: + battery = int(float(loc.get('battery'))) + except (TypeError, ValueError): + battery = None + + # Try to convert raw speed into real speed. + try: + speed = float(raw_speed) * SPEED_FACTOR_MPH + if self._hass.config.units.is_metric: + speed = convert(speed, LENGTH_MILES, LENGTH_KILOMETERS) + speed = max(0, round(speed)) + except (TypeError, ValueError): + speed = STATE_UNKNOWN + + # Make driving attribute True if it isn't and we can derive that it + # should be True from other data. + if (driving in (STATE_UNKNOWN, False) + and self._driving_speed is not None + and speed != STATE_UNKNOWN): + driving = speed >= self._driving_speed + + attrs = { + ATTR_ADDRESS: address, + ATTR_AT_LOC_SINCE: _dt_attr_from_ts(loc.get('since')), + ATTR_BATTERY_CHARGING: _bool_attr_from_int(loc.get('charge')), + ATTR_DRIVING: driving, + ATTR_LAST_SEEN: last_seen, + ATTR_MOVING: moving, + ATTR_PLACE: place, + ATTR_RAW_SPEED: raw_speed, + ATTR_SPEED: speed, + ATTR_WIFI_ON: _bool_attr_from_int(loc.get('wifiState')), + } + + # If user wants driving or moving to be shown as state, and current + # location is not in a HA zone, then set location name accordingly. + loc_name = None + active_zone = run_callback_threadsafe( + self._hass.loop, async_active_zone, self._hass, lat, lon, + gps_accuracy).result() + if not active_zone: + if SHOW_DRIVING in self._show_as_state and driving is True: + loc_name = SHOW_DRIVING + elif SHOW_MOVING in self._show_as_state and moving is True: + loc_name = SHOW_MOVING + + self._see(dev_id=dev_id, location_name=loc_name, gps=(lat, lon), + gps_accuracy=gps_accuracy, battery=battery, attributes=attrs, + picture=member.get('avatar')) + + def _update_members(self, members, members_updated): + for member in members: + member_id = member['id'] + if member_id in members_updated: + continue + members_updated.append(member_id) + err_key = 'Member data' + try: + first = member.get('firstName') + last = member.get('lastName') + if first and last: + full_name = ' '.join([first, last]) + else: + full_name = first or last + slug_name = cv.slugify(full_name) + include_member = _include_name(self._members_filter, slug_name) + dev_id = self._dev_id(slug_name) + if member_id not in self._members_logged: + self._members_logged.add(member_id) + _LOGGER.debug( + '%s -> %s: will%s be tracked, id=%s', full_name, + dev_id, '' if include_member else ' NOT', member_id) + sharing = bool(int(member['features']['shareLocation'])) + except (KeyError, TypeError, ValueError, vol.Invalid): + self._err(err_key, member) + continue + self._ok(err_key) + + if include_member and sharing: + self._update_member(member, dev_id) + + def _update_life360(self, now=None): + circles_updated = [] + members_updated = [] + + for api in self._apis.values(): + err_key = 'get_circles' + try: + circles = api.get_circles() + except Life360Error as exc: + self._exc(err_key, exc) + continue + self._ok(err_key) + + for circle in circles: + circle_id = circle['id'] + if circle_id in circles_updated: + continue + circles_updated.append(circle_id) + circle_name = circle['name'] + incl_circle = _include_name(self._circles_filter, circle_name) + if circle_id not in self._circles_logged: + self._circles_logged.add(circle_id) + _LOGGER.debug( + '%s Circle: will%s be included, id=%s', circle_name, + '' if incl_circle else ' NOT', circle_id) + try: + places = api.get_circle_places(circle_id) + place_data = "Circle's Places:" + for place in places: + place_data += '\n- name: {}'.format(place['name']) + place_data += '\n latitude: {}'.format( + place['latitude']) + place_data += '\n longitude: {}'.format( + place['longitude']) + place_data += '\n radius: {}'.format( + place['radius']) + if not places: + place_data += ' None' + _LOGGER.debug(place_data) + except (Life360Error, KeyError): + pass + if incl_circle: + err_key = 'get_circle_members "{}"'.format(circle_name) + try: + members = api.get_circle_members(circle_id) + except Life360Error as exc: + self._exc(err_key, exc) + continue + self._ok(err_key) + + self._update_members(members, members_updated) diff --git a/homeassistant/components/life360/helpers.py b/homeassistant/components/life360/helpers.py new file mode 100644 index 00000000000000..0eb215743df3ce --- /dev/null +++ b/homeassistant/components/life360/helpers.py @@ -0,0 +1,7 @@ +"""Life360 integration helpers.""" +from life360 import Life360 + + +def get_api(authorization=None): + """Create Life360 api object.""" + return Life360(timeout=3.05, max_retries=2, authorization=authorization) diff --git a/homeassistant/components/life360/manifest.json b/homeassistant/components/life360/manifest.json new file mode 100644 index 00000000000000..27d1b1f4c93b99 --- /dev/null +++ b/homeassistant/components/life360/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "life360", + "name": "Life360", + "config_flow": true, + "documentation": "https://www.home-assistant.io/components/life360", + "dependencies": [], + "codeowners": [ + "@pnbruckner" + ], + "requirements": [ + "life360==4.0.0" + ] +} diff --git a/homeassistant/components/life360/strings.json b/homeassistant/components/life360/strings.json new file mode 100644 index 00000000000000..cff3f39e5d5850 --- /dev/null +++ b/homeassistant/components/life360/strings.json @@ -0,0 +1,27 @@ +{ + "config": { + "title": "Life360", + "step": { + "user": { + "title": "Life360 Account Info", + "data": { + "username": "Username", + "password": "Password" + }, + "description": "To set advanced options, see [Life360 documentation]({docs_url}).\nYou may want to do that before adding accounts." + } + }, + "error": { + "invalid_username": "Invalid username", + "invalid_credentials": "Invalid credentials", + "user_already_configured": "Account has already been configured" + }, + "create_entry": { + "default": "To set advanced options, see [Life360 documentation]({docs_url})." + }, + "abort": { + "invalid_credentials": "Invalid credentials", + "user_already_configured": "Account has already been configured" + } + } +} diff --git a/homeassistant/components/lifx/.translations/pl.json b/homeassistant/components/lifx/.translations/pl.json index f13c0b54bbdb84..d6a06ea9fac539 100644 --- a/homeassistant/components/lifx/.translations/pl.json +++ b/homeassistant/components/lifx/.translations/pl.json @@ -2,7 +2,7 @@ "config": { "abort": { "no_devices_found": "Nie znaleziono w sieci urz\u0105dze\u0144 LIFX.", - "single_instance_allowed": "Wymagana jest tylko jedna konfiguracja LIFX." + "single_instance_allowed": "Dozwolona jest tylko jedna konfiguracja LIFX." }, "step": { "confirm": { diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py index 5f462941062480..42d9ecd8c9f545 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -484,7 +484,8 @@ def supported_features(self): @property def brightness(self): """Return the brightness of this light between 0..255.""" - return convert_16_to_8(self.bulb.color[2]) + fade = self.bulb.power_level / 65535 + return convert_16_to_8(int(fade * self.bulb.color[2])) @property def color_temp(self): diff --git a/homeassistant/components/light/device_automation.py b/homeassistant/components/light/device_automation.py new file mode 100644 index 00000000000000..44a9d9887e69b7 --- /dev/null +++ b/homeassistant/components/light/device_automation.py @@ -0,0 +1,80 @@ +"""Provides device automations for lights.""" +import voluptuous as vol + +import homeassistant.components.automation.state as state +from homeassistant.core import split_entity_id +from homeassistant.const import ( + CONF_DEVICE_ID, CONF_DOMAIN, CONF_ENTITY_ID, CONF_PLATFORM, CONF_TYPE) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_registry import async_entries_for_device +from . import DOMAIN + +CONF_TURN_OFF = 'turn_off' +CONF_TURN_ON = 'turn_on' + +ENTITY_TRIGGERS = [ + { + # Trigger when light is turned on + CONF_PLATFORM: 'device', + CONF_DOMAIN: DOMAIN, + CONF_TYPE: CONF_TURN_OFF, + }, + { + # Trigger when light is turned off + CONF_PLATFORM: 'device', + CONF_DOMAIN: DOMAIN, + CONF_TYPE: CONF_TURN_ON, + }, +] + +TRIGGER_SCHEMA = vol.All(vol.Schema({ + vol.Required(CONF_PLATFORM): 'device', + vol.Optional(CONF_DEVICE_ID): str, + vol.Required(CONF_DOMAIN): DOMAIN, + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TYPE): str, +})) + + +def _is_domain(entity, domain): + return split_entity_id(entity.entity_id)[0] == domain + + +async def async_attach_trigger(hass, config, action, automation_info): + """Listen for state changes based on configuration.""" + trigger_type = config.get(CONF_TYPE) + if trigger_type == CONF_TURN_ON: + from_state = 'off' + to_state = 'on' + else: + from_state = 'on' + to_state = 'off' + state_config = { + state.CONF_ENTITY_ID: config[CONF_ENTITY_ID], + state.CONF_FROM: from_state, + state.CONF_TO: to_state + } + + return await state.async_trigger(hass, state_config, action, + automation_info) + + +async def async_trigger(hass, config, action, automation_info): + """Temporary so existing automation framework can be used for testing.""" + return await async_attach_trigger(hass, config, action, automation_info) + + +async def async_get_triggers(hass, device_id): + """List device triggers.""" + triggers = [] + entity_registry = await hass.helpers.entity_registry.async_get_registry() + + entities = async_entries_for_device(entity_registry, device_id) + domain_entities = [x for x in entities if _is_domain(x, DOMAIN)] + for entity in domain_entities: + for trigger in ENTITY_TRIGGERS: + trigger = dict(trigger) + trigger.update(device_id=device_id, entity_id=entity.entity_id) + triggers.append(trigger) + + return triggers diff --git a/homeassistant/components/linky/manifest.json b/homeassistant/components/linky/manifest.json index 706962b5c4d368..cd4ac4665e2801 100644 --- a/homeassistant/components/linky/manifest.json +++ b/homeassistant/components/linky/manifest.json @@ -6,5 +6,8 @@ "pylinky==0.3.3" ], "dependencies": [], - "codeowners": [] + "codeowners": [ + "@tiste", + "@Quentame" + ] } diff --git a/homeassistant/components/linky/sensor.py b/homeassistant/components/linky/sensor.py index 63f7aaf5423382..263395ab9e77db 100644 --- a/homeassistant/components/linky/sensor.py +++ b/homeassistant/components/linky/sensor.py @@ -1,21 +1,41 @@ """Support for Linky.""" -import logging -import json from datetime import timedelta +import json +import logging +from pylinky.client import DAILY, MONTHLY, YEARLY, LinkyClient, PyLinkyError import voluptuous as vol -from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD, CONF_TIMEOUT, - ENERGY_KILO_WATT_HOUR) from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.helpers.entity import Entity -from homeassistant.util import Throttle +from homeassistant.const import ( + ATTR_ATTRIBUTION, CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME, + ENERGY_KILO_WATT_HOUR) import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import track_time_interval +import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(minutes=10) +SCAN_INTERVAL = timedelta(hours=4) +ICON_ENERGY = "mdi:flash" +CONSUMPTION = "conso" +TIME = "time" +INDEX_CURRENT = -1 +INDEX_LAST = -2 +ATTRIBUTION = "Data provided by Enedis" + DEFAULT_TIMEOUT = 10 +SENSORS = { + "yesterday": ("Linky yesterday", DAILY, INDEX_LAST), + "current_month": ("Linky current month", MONTHLY, INDEX_CURRENT), + "last_month": ("Linky last month", MONTHLY, INDEX_LAST), + "current_year": ("Linky current year", YEARLY, INDEX_CURRENT), + "last_year": ("Linky last year", YEARLY, INDEX_LAST) +} +SENSORS_INDEX_LABEL = 0 +SENSORS_INDEX_SCALE = 1 +SENSORS_INDEX_WHEN = 2 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_USERNAME): cv.string, @@ -30,28 +50,73 @@ def setup_platform(hass, config, add_entities, discovery_info=None): password = config[CONF_PASSWORD] timeout = config[CONF_TIMEOUT] - from pylinky.client import LinkyClient, PyLinkyError - client = LinkyClient(username, password, None, timeout) - try: - client.login() - client.fetch_data() - except PyLinkyError as exp: - _LOGGER.error(exp) - client.close_session() - return + account = LinkyAccount(hass, add_entities, username, password, timeout) + add_entities(account.sensors, True) + - devices = [LinkySensor('Linky', client)] - add_entities(devices, True) +class LinkyAccount: + """Representation of a Linky account.""" + + def __init__(self, hass, add_entities, username, password, timeout): + """Initialise the Linky account.""" + self._username = username + self.__password = password + self._timeout = timeout + self._data = None + self.sensors = [] + + self.update_linky_data(dt_util.utcnow()) + + self.sensors.append( + LinkySensor("Linky yesterday", self, DAILY, INDEX_LAST)) + self.sensors.append( + LinkySensor("Linky current month", self, MONTHLY, INDEX_CURRENT)) + self.sensors.append( + LinkySensor("Linky last month", self, MONTHLY, INDEX_LAST)) + self.sensors.append( + LinkySensor("Linky current year", self, YEARLY, INDEX_CURRENT)) + self.sensors.append( + LinkySensor("Linky last year", self, YEARLY, INDEX_LAST)) + + track_time_interval(hass, self.update_linky_data, SCAN_INTERVAL) + + def update_linky_data(self, event_time): + """Fetch new state data for the sensor.""" + client = LinkyClient(self._username, self.__password, None, + self._timeout) + try: + client.login() + client.fetch_data() + self._data = client.get_data() + _LOGGER.debug(json.dumps(self._data, indent=2)) + except PyLinkyError as exp: + _LOGGER.error(exp) + finally: + client.close_session() + + @property + def username(self): + """Return the username.""" + return self._username + + @property + def data(self): + """Return the data.""" + return self._data class LinkySensor(Entity): """Representation of a sensor entity for Linky.""" - def __init__(self, name, client): + def __init__(self, name, account: LinkyAccount, scale, when): """Initialize the sensor.""" self._name = name - self._client = client - self._state = None + self.__account = account + self._scale = scale + self.__when = when + self._username = account.username + self.__time = None + self.__consumption = None @property def name(self): @@ -61,28 +126,35 @@ def name(self): @property def state(self): """Return the state of the sensor.""" - return self._state + return self.__consumption @property def unit_of_measurement(self): """Return the unit of measurement.""" return ENERGY_KILO_WATT_HOUR - @Throttle(SCAN_INTERVAL) - def update(self): - """Fetch new state data for the sensor.""" - from pylinky.client import PyLinkyError - try: - self._client.fetch_data() - except PyLinkyError as exp: - _LOGGER.error(exp) - self._client.close_session() - return + @property + def icon(self): + """Return the icon of the sensor.""" + return ICON_ENERGY - _LOGGER.debug(json.dumps(self._client.get_data(), indent=2)) + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + return { + ATTR_ATTRIBUTION: ATTRIBUTION, + 'time': self.__time, + CONF_USERNAME: self._username + } - if self._client.get_data(): - # get the last past day data - self._state = self._client.get_data()['daily'][-2]['conso'] - else: - self._state = None + def update(self): + """Retreive the new data for the sensor.""" + data = self.__account.data[self._scale][self.__when] + self.__consumption = data[CONSUMPTION] + self.__time = data[TIME] + + if self._scale is not YEARLY: + year_index = INDEX_CURRENT + if self.__time.endswith("Dec"): + year_index = INDEX_LAST + self.__time += ' ' + self.__account.data[YEARLY][year_index][TIME] diff --git a/homeassistant/components/locative/.translations/ca.json b/homeassistant/components/locative/.translations/ca.json index a08907a51ef922..ff3c150886decf 100644 --- a/homeassistant/components/locative/.translations/ca.json +++ b/homeassistant/components/locative/.translations/ca.json @@ -9,10 +9,10 @@ }, "step": { "user": { - "description": "Est\u00e0s segur que vols configurar el Webhook Locative?", - "title": "Configuraci\u00f3 del Webhook Locative" + "description": "Est\u00e0s segur que vols configurar el Webhook de Locative?", + "title": "Configuraci\u00f3 del Webhook de Locative" } }, - "title": "Webhook Locative" + "title": "Webhook de Locative" } } \ No newline at end of file diff --git a/homeassistant/components/locative/device_tracker.py b/homeassistant/components/locative/device_tracker.py index 6f86519c47c2b3..38efab7e8c015b 100644 --- a/homeassistant/components/locative/device_tracker.py +++ b/homeassistant/components/locative/device_tracker.py @@ -85,6 +85,8 @@ async def async_will_remove_from_hass(self): @callback def _async_receive_data(self, device, location, location_name): """Update device data.""" + if device != self._name: + return self._location_name = location_name self._location = location self.async_write_ha_state() diff --git a/homeassistant/components/logi_circle/.translations/it.json b/homeassistant/components/logi_circle/.translations/it.json new file mode 100644 index 00000000000000..568bf79a40d207 --- /dev/null +++ b/homeassistant/components/logi_circle/.translations/it.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_setup": "\u00c8 possibile configurare solo un singolo account Logi Circle.", + "external_error": "Si \u00e8 verificata un'eccezione da un altro flusso.", + "external_setup": "Logi Circle configurato con successo da un altro flusso.", + "no_flows": "Devi configurare Logi Circle prima di poter eseguire l'autenticazione. [Si prega di leggere le istruzioni] (https://www.home-assistant.io/components/logi_circle/)." + }, + "create_entry": { + "default": "Autenticato con successo con Logi Circle." + }, + "error": { + "auth_error": "Autorizzazione API fallita.", + "auth_timeout": "Timeout dell'autorizzazione durante la richiesta del token di accesso.", + "follow_link": "Segui il link e autenticati prima di premere Invio" + }, + "step": { + "auth": { + "title": "Autenticarsi con Logi Circle" + }, + "user": { + "data": { + "flow_impl": "Provider" + }, + "description": "Scegli tramite quale provider di autenticazione vuoi autenticarti con Logi Circle.", + "title": "Provider di autenticazione" + } + }, + "title": "Logi Circle" + } +} \ No newline at end of file diff --git a/homeassistant/components/logi_circle/.translations/nl.json b/homeassistant/components/logi_circle/.translations/nl.json index 84af68e1384dd9..822447f353dbf1 100644 --- a/homeassistant/components/logi_circle/.translations/nl.json +++ b/homeassistant/components/logi_circle/.translations/nl.json @@ -1,13 +1,31 @@ { "config": { "abort": { - "external_error": "Uitzondering opgetreden uit een andere stroom." + "already_setup": "U kunt slechts \u00e9\u00e9n Logi Circle-account configureren.", + "external_error": "Uitzondering opgetreden uit een andere stroom.", + "external_setup": "Logi Circle is met succes geconfigureerd vanuit een andere stroom.", + "no_flows": "U moet Logi Circle configureren voordat u ermee kunt authenticeren. [Lees de instructies] (https://www.home-assistant.io/components/logi_circle/)." }, "create_entry": { "default": "Succesvol geverifieerd met Logi Circle." }, "error": { - "auth_error": "API-autorisatie mislukt." + "auth_error": "API-autorisatie mislukt.", + "auth_timeout": "Er is een time-out opgetreden bij autorisatie bij het aanvragen van toegangstoken.", + "follow_link": "Volg de link en authenticeer voordat u op Verzenden drukt." + }, + "step": { + "auth": { + "description": "Volg de onderstaande link en Accepteer toegang tot uw Logi Circle-account, kom dan terug en druk hieronder op Verzenden . \n\n [Link] ({authorization_url})", + "title": "Authenticeren met Logi Circle" + }, + "user": { + "data": { + "flow_impl": "Provider" + }, + "description": "Kies met welke authenticatieprovider u wilt authenticeren met Logi Circle.", + "title": "Authenticatieprovider" + } }, "title": "Logi Circle" } diff --git a/homeassistant/components/logi_circle/.translations/pt-BR.json b/homeassistant/components/logi_circle/.translations/pt-BR.json new file mode 100644 index 00000000000000..babdba4f9bf190 --- /dev/null +++ b/homeassistant/components/logi_circle/.translations/pt-BR.json @@ -0,0 +1,23 @@ +{ + "config": { + "create_entry": { + "default": "Autenticado com sucesso com o Logi Circle." + }, + "error": { + "auth_error": "Falha na autoriza\u00e7\u00e3o da API." + }, + "step": { + "auth": { + "title": "Autenticar com o Logi Circle" + }, + "user": { + "data": { + "flow_impl": "Provedor" + }, + "description": "Escolha atrav\u00e9s de qual provedor de autentica\u00e7\u00e3o voc\u00ea deseja autenticar com o Logi Circle.", + "title": "Provedor de Autentica\u00e7\u00e3o" + } + }, + "title": "C\u00edrculo Logi" + } +} \ No newline at end of file diff --git a/homeassistant/components/logi_circle/__init__.py b/homeassistant/components/logi_circle/__init__.py index 4e5ad0c5aebb75..2f34366aafa630 100644 --- a/homeassistant/components/logi_circle/__init__.py +++ b/homeassistant/components/logi_circle/__init__.py @@ -105,7 +105,7 @@ async def async_setup_entry(hass, entry): client_secret=entry.data[CONF_CLIENT_SECRET], api_key=entry.data[CONF_API_KEY], redirect_uri=entry.data[CONF_REDIRECT_URI], - cache_file=DEFAULT_CACHEDB + cache_file=hass.config.path(DEFAULT_CACHEDB) ) if not logi_circle.authorized: diff --git a/homeassistant/components/logi_circle/config_flow.py b/homeassistant/components/logi_circle/config_flow.py index 728ca27ba51511..7f1f085bbac246 100644 --- a/homeassistant/components/logi_circle/config_flow.py +++ b/homeassistant/components/logi_circle/config_flow.py @@ -157,7 +157,7 @@ async def _async_create_session(self, code): client_secret=client_secret, api_key=api_key, redirect_uri=redirect_uri, - cache_file=DEFAULT_CACHEDB) + cache_file=self.hass.config.path(DEFAULT_CACHEDB)) try: with async_timeout.timeout(_TIMEOUT): diff --git a/homeassistant/components/mailgun/.translations/ca.json b/homeassistant/components/mailgun/.translations/ca.json index 8815360f7b4df6..f43467de7d9da7 100644 --- a/homeassistant/components/mailgun/.translations/ca.json +++ b/homeassistant/components/mailgun/.translations/ca.json @@ -10,7 +10,7 @@ "step": { "user": { "description": "Est\u00e0s segur que vols configurar Mailgun?", - "title": "Configuraci\u00f3 del Webhook Mailgun" + "title": "Configuraci\u00f3 del Webhook de Mailgun" } }, "title": "Mailgun" diff --git a/homeassistant/components/mailgun/.translations/nl.json b/homeassistant/components/mailgun/.translations/nl.json index d71c311b7f8ab0..6a1ff24ef2c273 100644 --- a/homeassistant/components/mailgun/.translations/nl.json +++ b/homeassistant/components/mailgun/.translations/nl.json @@ -4,6 +4,9 @@ "not_internet_accessible": "Uw Home Assistant instantie moet toegankelijk zijn vanaf het internet om Mailgun-berichten te ontvangen.", "one_instance_allowed": "Slechts \u00e9\u00e9n enkele instantie is nodig." }, + "create_entry": { + "default": "Om evenementen naar Home Assistant te verzenden, moet u [Webhooks with Mailgun] instellen ( {mailgun_url} ). \n\n Vul de volgende info in: \n\n - URL: ` {webhook_url} ` \n - Methode: POST \n - Inhoudstype: application/json \n\n Zie [de documentatie] ( {docs_url} ) voor informatie over het configureren van automatiseringen om binnenkomende gegevens te verwerken." + }, "step": { "user": { "description": "Weet u zeker dat u Mailgun wilt instellen?", diff --git a/homeassistant/components/manual/alarm_control_panel.py b/homeassistant/components/manual/alarm_control_panel.py index 14934db41c291f..da0f7035d5cdd2 100644 --- a/homeassistant/components/manual/alarm_control_panel.py +++ b/homeassistant/components/manual/alarm_control_panel.py @@ -21,6 +21,7 @@ _LOGGER = logging.getLogger(__name__) CONF_CODE_TEMPLATE = 'code_template' +CONF_CODE_ARM_REQUIRED = 'code_arm_required' DEFAULT_ALARM_NAME = 'HA Alarm' DEFAULT_DELAY_TIME = datetime.timedelta(seconds=0) @@ -76,6 +77,7 @@ def _state_schema(state): vol.Optional(CONF_NAME, default=DEFAULT_ALARM_NAME): cv.string, vol.Exclusive(CONF_CODE, 'code validation'): cv.string, vol.Exclusive(CONF_CODE_TEMPLATE, 'code validation'): cv.template, + vol.Optional(CONF_CODE_ARM_REQUIRED, default=True): cv.boolean, vol.Optional(CONF_DELAY_TIME, default=DEFAULT_DELAY_TIME): vol.All(cv.time_period, cv.positive_timedelta), vol.Optional(CONF_PENDING_TIME, default=DEFAULT_PENDING_TIME): @@ -106,6 +108,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): config[CONF_NAME], config.get(CONF_CODE), config.get(CONF_CODE_TEMPLATE), + config.get(CONF_CODE_ARM_REQUIRED), config.get(CONF_DISARM_AFTER_TRIGGER, DEFAULT_DISARM_AFTER_TRIGGER), config )]) @@ -124,7 +127,7 @@ class ManualAlarm(alarm.AlarmControlPanel, RestoreEntity): """ def __init__(self, hass, name, code, code_template, - disarm_after_trigger, config): + code_arm_required, disarm_after_trigger, config): """Init the manual alarm panel.""" self._state = STATE_ALARM_DISARMED self._hass = hass @@ -134,6 +137,7 @@ def __init__(self, hass, name, code, code_template, self._code.hass = hass else: self._code = code or None + self._code_arm_required = code_arm_required self._disarm_after_trigger = disarm_after_trigger self._previous_state = self._state self._state_ts = None @@ -205,6 +209,11 @@ def code_format(self): return alarm.FORMAT_NUMBER return alarm.FORMAT_TEXT + @property + def code_arm_required(self): + """Whether the code is required for arm actions.""" + return self._code_arm_required + def alarm_disarm(self, code=None): """Send disarm command.""" if not self._validate_code(code, STATE_ALARM_DISARMED): @@ -216,28 +225,32 @@ def alarm_disarm(self, code=None): def alarm_arm_home(self, code=None): """Send arm home command.""" - if not self._validate_code(code, STATE_ALARM_ARMED_HOME): + if self._code_arm_required and not \ + self._validate_code(code, STATE_ALARM_ARMED_HOME): return self._update_state(STATE_ALARM_ARMED_HOME) def alarm_arm_away(self, code=None): """Send arm away command.""" - if not self._validate_code(code, STATE_ALARM_ARMED_AWAY): + if self._code_arm_required and not \ + self._validate_code(code, STATE_ALARM_ARMED_AWAY): return self._update_state(STATE_ALARM_ARMED_AWAY) def alarm_arm_night(self, code=None): """Send arm night command.""" - if not self._validate_code(code, STATE_ALARM_ARMED_NIGHT): + if self._code_arm_required and not \ + self._validate_code(code, STATE_ALARM_ARMED_NIGHT): return self._update_state(STATE_ALARM_ARMED_NIGHT) def alarm_arm_custom_bypass(self, code=None): """Send arm custom bypass command.""" - if not self._validate_code(code, STATE_ALARM_ARMED_CUSTOM_BYPASS): + if self._code_arm_required and not \ + self._validate_code(code, STATE_ALARM_ARMED_CUSTOM_BYPASS): return self._update_state(STATE_ALARM_ARMED_CUSTOM_BYPASS) diff --git a/homeassistant/components/manual_mqtt/alarm_control_panel.py b/homeassistant/components/manual_mqtt/alarm_control_panel.py index d952dd68ebb2f6..c051ce47173e5a 100644 --- a/homeassistant/components/manual_mqtt/alarm_control_panel.py +++ b/homeassistant/components/manual_mqtt/alarm_control_panel.py @@ -24,6 +24,7 @@ _LOGGER = logging.getLogger(__name__) CONF_CODE_TEMPLATE = 'code_template' +CONF_CODE_ARM_REQUIRED = 'code_arm_required' CONF_PAYLOAD_DISARM = 'payload_disarm' CONF_PAYLOAD_ARM_HOME = 'payload_arm_home' @@ -108,6 +109,7 @@ def _state_schema(state): _state_schema(STATE_ALARM_TRIGGERED), vol.Required(mqtt.CONF_COMMAND_TOPIC): mqtt.valid_publish_topic, vol.Required(mqtt.CONF_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_CODE_ARM_REQUIRED, default=True): cv.boolean, vol.Optional(CONF_PAYLOAD_ARM_AWAY, default=DEFAULT_ARM_AWAY): cv.string, vol.Optional(CONF_PAYLOAD_ARM_HOME, default=DEFAULT_ARM_HOME): cv.string, vol.Optional(CONF_PAYLOAD_ARM_NIGHT, default=DEFAULT_ARM_NIGHT): cv.string, @@ -126,6 +128,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): config.get(mqtt.CONF_STATE_TOPIC), config.get(mqtt.CONF_COMMAND_TOPIC), config.get(mqtt.CONF_QOS), + config.get(CONF_CODE_ARM_REQUIRED), config.get(CONF_PAYLOAD_DISARM), config.get(CONF_PAYLOAD_ARM_HOME), config.get(CONF_PAYLOAD_ARM_AWAY), @@ -146,9 +149,9 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel): """ def __init__(self, hass, name, code, code_template, disarm_after_trigger, - state_topic, command_topic, qos, payload_disarm, - payload_arm_home, payload_arm_away, payload_arm_night, - config): + state_topic, command_topic, qos, code_arm_required, + payload_disarm, payload_arm_home, payload_arm_away, + payload_arm_night, config): """Init the manual MQTT alarm panel.""" self._state = STATE_ALARM_DISARMED self._hass = hass @@ -175,6 +178,7 @@ def __init__(self, hass, name, code, code_template, disarm_after_trigger, self._state_topic = state_topic self._command_topic = command_topic self._qos = qos + self._code_arm_required = code_arm_required self._payload_disarm = payload_disarm self._payload_arm_home = payload_arm_home self._payload_arm_away = payload_arm_away @@ -237,6 +241,11 @@ def code_format(self): return alarm.FORMAT_NUMBER return alarm.FORMAT_TEXT + @property + def code_arm_required(self): + """Whether the code is required for arm actions.""" + return self._code_arm_required + def alarm_disarm(self, code=None): """Send disarm command.""" if not self._validate_code(code, STATE_ALARM_DISARMED): @@ -248,21 +257,24 @@ def alarm_disarm(self, code=None): def alarm_arm_home(self, code=None): """Send arm home command.""" - if not self._validate_code(code, STATE_ALARM_ARMED_HOME): + if self._code_arm_required and not \ + self._validate_code(code, STATE_ALARM_ARMED_HOME): return self._update_state(STATE_ALARM_ARMED_HOME) def alarm_arm_away(self, code=None): """Send arm away command.""" - if not self._validate_code(code, STATE_ALARM_ARMED_AWAY): + if self._code_arm_required and not \ + self._validate_code(code, STATE_ALARM_ARMED_AWAY): return self._update_state(STATE_ALARM_ARMED_AWAY) def alarm_arm_night(self, code=None): """Send arm night command.""" - if not self._validate_code(code, STATE_ALARM_ARMED_NIGHT): + if self._code_arm_required and not \ + self._validate_code(code, STATE_ALARM_ARMED_NIGHT): return self._update_state(STATE_ALARM_ARMED_NIGHT) diff --git a/homeassistant/components/mastodon/manifest.json b/homeassistant/components/mastodon/manifest.json index 6db3791c519b2c..b49aa735b05396 100644 --- a/homeassistant/components/mastodon/manifest.json +++ b/homeassistant/components/mastodon/manifest.json @@ -3,7 +3,7 @@ "name": "Mastodon", "documentation": "https://www.home-assistant.io/components/mastodon", "requirements": [ - "Mastodon.py==1.4.2" + "Mastodon.py==1.4.3" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index dbdb64b8421564..7d57cbf1ab96bc 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -3,7 +3,7 @@ "name": "Media extractor", "documentation": "https://www.home-assistant.io/components/media_extractor", "requirements": [ - "youtube_dl==2019.05.11" + "youtube_dl==2019.05.20" ], "dependencies": [ "media_player" diff --git a/homeassistant/components/met/.translations/ca.json b/homeassistant/components/met/.translations/ca.json new file mode 100644 index 00000000000000..5335bfd48ea923 --- /dev/null +++ b/homeassistant/components/met/.translations/ca.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "name_exists": "El nom ja existeix" + }, + "step": { + "user": { + "data": { + "elevation": "Altitud", + "latitude": "Latitud", + "longitude": "Longitud", + "name": "Nom" + }, + "description": "Meteorologisk institutt", + "title": "Ubicaci\u00f3" + } + }, + "title": "Met.no" + } +} \ No newline at end of file diff --git a/homeassistant/components/met/.translations/de.json b/homeassistant/components/met/.translations/de.json new file mode 100644 index 00000000000000..b70d3f12a838ce --- /dev/null +++ b/homeassistant/components/met/.translations/de.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "name_exists": "Name existiert bereits" + }, + "step": { + "user": { + "data": { + "elevation": "H\u00f6he", + "latitude": "Breitengrad", + "longitude": "L\u00e4ngengrad", + "name": "Name" + }, + "description": "Meteorologisches Institut", + "title": "Standort" + } + }, + "title": "Met.no" + } +} \ No newline at end of file diff --git a/homeassistant/components/met/.translations/en.json b/homeassistant/components/met/.translations/en.json new file mode 100644 index 00000000000000..21ae7cb78fa4f9 --- /dev/null +++ b/homeassistant/components/met/.translations/en.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "name_exists": "Name already exists" + }, + "step": { + "user": { + "data": { + "elevation": "Elevation", + "latitude": "Latitude", + "longitude": "Longitude", + "name": "Name" + }, + "description": "Meteorologisk institutt", + "title": "Location" + } + }, + "title": "Met.no" + } +} \ No newline at end of file diff --git a/homeassistant/components/met/.translations/ko.json b/homeassistant/components/met/.translations/ko.json new file mode 100644 index 00000000000000..3cb6fd6694348d --- /dev/null +++ b/homeassistant/components/met/.translations/ko.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "name_exists": "\uc774\ub984\uc774 \uc774\ubbf8 \uc874\uc7ac\ud569\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "elevation": "\uace0\ub3c4", + "latitude": "\uc704\ub3c4", + "longitude": "\uacbd\ub3c4", + "name": "\uc774\ub984" + }, + "description": "\ub178\ub974\uc6e8\uc774 \uae30\uc0c1 \uc5f0\uad6c\uc18c", + "title": "\uc704\uce58" + } + }, + "title": "Met.no" + } +} \ No newline at end of file diff --git a/homeassistant/components/met/.translations/ru.json b/homeassistant/components/met/.translations/ru.json new file mode 100644 index 00000000000000..d298b1e3b07a5c --- /dev/null +++ b/homeassistant/components/met/.translations/ru.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "name_exists": "\u0418\u043c\u044f \u0443\u0436\u0435 \u0441\u0443\u0449\u0435\u0441\u0442\u0432\u0443\u0435\u0442" + }, + "step": { + "user": { + "data": { + "elevation": "\u0412\u044b\u0441\u043e\u0442\u0430", + "latitude": "\u0428\u0438\u0440\u043e\u0442\u0430", + "longitude": "\u0414\u043e\u043b\u0433\u043e\u0442\u0430", + "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435" + }, + "description": "\u041c\u0435\u0442\u0435\u043e\u0440\u043e\u043b\u043e\u0433\u0438\u0447\u0435\u0441\u043a\u0438\u0439 \u0438\u043d\u0441\u0442\u0438\u0442\u0443\u0442", + "title": "\u041c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435" + } + }, + "title": "Met.no" + } +} \ No newline at end of file diff --git a/homeassistant/components/met/.translations/vi.json b/homeassistant/components/met/.translations/vi.json new file mode 100644 index 00000000000000..e2bfbeb8a41242 --- /dev/null +++ b/homeassistant/components/met/.translations/vi.json @@ -0,0 +1,14 @@ +{ + "config": { + "step": { + "user": { + "data": { + "latitude": "V\u0129 \u0111\u1ed9", + "longitude": "Kinh \u0111\u1ed9", + "name": "T\u00ean" + }, + "title": "V\u1ecb tr\u00ed" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/met/__init__.py b/homeassistant/components/met/__init__.py index 67bd64f3e1665a..aa284ad02e2308 100644 --- a/homeassistant/components/met/__init__.py +++ b/homeassistant/components/met/__init__.py @@ -1 +1,23 @@ """The met component.""" +from homeassistant.core import Config, HomeAssistant +from .config_flow import MetFlowHandler # noqa +from .const import DOMAIN # noqa + + +async def async_setup(hass: HomeAssistant, config: Config) -> bool: + """Set up configured Met.""" + return True + + +async def async_setup_entry(hass, config_entry): + """Set up Met as config entry.""" + hass.async_create_task(hass.config_entries.async_forward_entry_setup( + config_entry, 'weather')) + return True + + +async def async_unload_entry(hass, config_entry): + """Unload a config entry.""" + await hass.config_entries.async_forward_entry_unload( + config_entry, 'weather') + return True diff --git a/homeassistant/components/met/config_flow.py b/homeassistant/components/met/config_flow.py new file mode 100644 index 00000000000000..2480b5f29b810d --- /dev/null +++ b/homeassistant/components/met/config_flow.py @@ -0,0 +1,72 @@ +"""Config flow to configure Met component.""" +import voluptuous as vol + +from homeassistant import config_entries, data_entry_flow +from homeassistant.const import ( + CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME) +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv + +from .const import DOMAIN, HOME_LOCATION_NAME, CONF_TRACK_HOME + + +@callback +def configured_instances(hass): + """Return a set of configured SimpliSafe instances.""" + return set( + entry.data[CONF_NAME] + for entry in hass.config_entries.async_entries(DOMAIN)) + + +@config_entries.HANDLERS.register(DOMAIN) +class MetFlowHandler(data_entry_flow.FlowHandler): + """Config flow for Met component.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Init MetFlowHandler.""" + self._errors = {} + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + self._errors = {} + + if user_input is not None: + if user_input[CONF_NAME] not in configured_instances(self.hass): + return self.async_create_entry( + title=user_input[CONF_NAME], + data=user_input, + ) + + self._errors[CONF_NAME] = 'name_exists' + + return await self._show_config_form( + name=HOME_LOCATION_NAME, + latitude=self.hass.config.latitude, + longitude=self.hass.config.longitude, + elevation=self.hass.config.elevation) + + async def _show_config_form(self, name=None, latitude=None, + longitude=None, elevation=None): + """Show the configuration form to edit location data.""" + return self.async_show_form( + step_id='user', + data_schema=vol.Schema({ + vol.Required(CONF_NAME, default=name): str, + vol.Required(CONF_LATITUDE, default=latitude): cv.latitude, + vol.Required(CONF_LONGITUDE, default=longitude): cv.longitude, + vol.Required(CONF_ELEVATION, default=elevation): int + }), + errors=self._errors, + ) + + async def async_step_onboarding(self, data=None): + """Handle a flow initialized by onboarding.""" + return self.async_create_entry( + title=HOME_LOCATION_NAME, + data={ + CONF_TRACK_HOME: True + } + ) diff --git a/homeassistant/components/met/const.py b/homeassistant/components/met/const.py new file mode 100644 index 00000000000000..5d61ecadfa366b --- /dev/null +++ b/homeassistant/components/met/const.py @@ -0,0 +1,16 @@ +"""Constants for Met component.""" +import logging + +from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN + +DOMAIN = 'met' + +HOME_LOCATION_NAME = 'Home' + +CONF_TRACK_HOME = 'track_home' + +ENTITY_ID_SENSOR_FORMAT = WEATHER_DOMAIN + ".met_{}" +ENTITY_ID_SENSOR_FORMAT_HOME = ENTITY_ID_SENSOR_FORMAT.format( + HOME_LOCATION_NAME) + +_LOGGER = logging.getLogger('.') diff --git a/homeassistant/components/met/manifest.json b/homeassistant/components/met/manifest.json index b2ef166be50194..426d0faf8608b7 100644 --- a/homeassistant/components/met/manifest.json +++ b/homeassistant/components/met/manifest.json @@ -1,6 +1,7 @@ { "domain": "met", "name": "Met", + "config_flow": true, "documentation": "https://www.home-assistant.io/components/met", "requirements": [ "pyMetno==0.4.6" diff --git a/homeassistant/components/met/strings.json b/homeassistant/components/met/strings.json new file mode 100644 index 00000000000000..f5c49bac3c4420 --- /dev/null +++ b/homeassistant/components/met/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "title": "Met.no", + "step": { + "user": { + "title": "Location", + "description": "Meteorologisk institutt", + "data": { + "name": "Name", + "latitude": "Latitude", + "longitude": "Longitude", + "elevation": "Elevation" + } + } + }, + "error": { + "name_exists": "Name already exists" + } + } +} diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py index d9824e203c5462..e97918ceba1bc4 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -2,17 +2,21 @@ import logging from random import randrange +import metno import voluptuous as vol +from homeassistant.core import callback from homeassistant.components.weather import PLATFORM_SCHEMA, WeatherEntity from homeassistant.const import ( - CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, TEMP_CELSIUS) + CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, TEMP_CELSIUS, + EVENT_CORE_CONFIG_UPDATE) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.event import ( - async_call_later, async_track_utc_time_change) +from homeassistant.helpers.event import async_call_later import homeassistant.util.dt as dt_util +from .const import CONF_TRACK_HOME + _LOGGER = logging.getLogger(__name__) ATTRIBUTION = "Weather forecast from met.no, delivered by the Norwegian " \ @@ -27,48 +31,91 @@ 'Latitude and longitude must exist together'): cv.latitude, vol.Inclusive(CONF_LONGITUDE, 'coordinates', 'Latitude and longitude must exist together'): cv.longitude, + vol.Optional(CONF_ELEVATION): int, }) async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the Met.no weather platform.""" - elevation = config.get(CONF_ELEVATION, hass.config.elevation or 0) - latitude = config.get(CONF_LATITUDE, hass.config.latitude) - longitude = config.get(CONF_LONGITUDE, hass.config.longitude) - name = config.get(CONF_NAME) - - if None in (latitude, longitude): - _LOGGER.error("Latitude or longitude not set in Home Assistant config") - return - - coordinates = { - 'lat': str(latitude), - 'lon': str(longitude), - 'msl': str(elevation), + _LOGGER.warning("Loading Met.no via platform config is deprecated") + + # Add defaults. + config = { + CONF_ELEVATION: hass.config.elevation, + **config, } - async_add_entities([MetWeather( - name, coordinates, async_get_clientsession(hass))]) + if config.get(CONF_LATITUDE) is None: + config[CONF_TRACK_HOME] = True + + async_add_entities([MetWeather(config)]) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Add a weather entity from a config_entry.""" + async_add_entities([MetWeather(config_entry.data)]) class MetWeather(WeatherEntity): """Implementation of a Met.no weather condition.""" - def __init__(self, name, coordinates, clientsession): + def __init__(self, config): """Initialise the platform with a data instance and site.""" - import metno - self._name = name - self._weather_data = metno.MetWeatherData( - coordinates, clientsession, URL) + self._config = config + self._unsub_track_home = None + self._unsub_fetch_data = None + self._weather_data = None self._current_weather_data = {} self._forecast_data = None async def async_added_to_hass(self): """Start fetching data.""" + self._init_data() + await self._fetch_data() + if self._config.get(CONF_TRACK_HOME): + self._unsub_track_home = self.hass.bus.async_listen( + EVENT_CORE_CONFIG_UPDATE, self._core_config_updated) + + @callback + def _init_data(self): + """Initialize a data object.""" + conf = self._config + + if self.track_home: + latitude = self.hass.config.latitude + longitude = self.hass.config.longitude + elevation = self.hass.config.elevation + else: + latitude = conf[CONF_LATITUDE] + longitude = conf[CONF_LONGITUDE] + elevation = conf[CONF_ELEVATION] + + coordinates = { + 'lat': str(latitude), + 'lon': str(longitude), + 'msl': str(elevation), + } + self._weather_data = metno.MetWeatherData( + coordinates, async_get_clientsession(self.hass), URL) + + async def _core_config_updated(self, _event): + """Handle core config updated.""" + self._init_data() + if self._unsub_fetch_data: + self._unsub_fetch_data() + self._unsub_fetch_data = None await self._fetch_data() - async_track_utc_time_change( - self.hass, self._update, minute=31, second=0) + + async def will_remove_from_hass(self): + """Handle entity will be removed from hass.""" + if self._unsub_track_home: + self._unsub_track_home() + self._unsub_track_home = None + + if self._unsub_fetch_data: + self._unsub_fetch_data() + self._unsub_fetch_data = None async def _fetch_data(self, *_): """Get the latest data from met.no.""" @@ -76,28 +123,55 @@ async def _fetch_data(self, *_): # Retry in 15 to 20 minutes. minutes = 15 + randrange(6) _LOGGER.error("Retrying in %i minutes", minutes) - async_call_later(self.hass, minutes*60, self._fetch_data) + self._unsub_fetch_data = async_call_later( + self.hass, minutes*60, self._fetch_data) return - async_call_later(self.hass, 60*60, self._fetch_data) - await self._update() + # Wait between 55-65 minutes. If people update HA on the hour, this + # will make sure it will spread it out. - @property - def should_poll(self): - """No polling needed.""" - return False + self._unsub_fetch_data = async_call_later( + self.hass, randrange(55, 65)*60, self._fetch_data) + self._update() - async def _update(self, *_): + def _update(self, *_): """Get the latest data from Met.no.""" self._current_weather_data = self._weather_data.get_current_weather() time_zone = dt_util.DEFAULT_TIME_ZONE self._forecast_data = self._weather_data.get_forecast(time_zone) - self.async_schedule_update_ha_state() + self.async_write_ha_state() + + @property + def track_home(self): + """Return if we are tracking home.""" + return self._config.get(CONF_TRACK_HOME, False) + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def unique_id(self): + """Return unique ID.""" + if self.track_home: + return 'home' + + return '{}-{}'.format( + self._config[CONF_LATITUDE], self._config[CONF_LONGITUDE]) @property def name(self): """Return the name of the sensor.""" - return self._name + name = self._config.get(CONF_NAME) + + if name is not None: + return name + + if self.track_home: + return self.hass.config.location_name + + return DEFAULT_NAME @property def condition(self): diff --git a/homeassistant/components/meteo_france/__init__.py b/homeassistant/components/meteo_france/__init__.py index df0292ec407db5..3e9f0cf75f3c1c 100644 --- a/homeassistant/components/meteo_france/__init__.py +++ b/homeassistant/components/meteo_france/__init__.py @@ -31,6 +31,7 @@ 'next_rain': ['Next rain', 'min'], 'temperature': ['Temperature', TEMP_CELSIUS], 'uv': ['UV', None], + 'weather_alert': ['Weather Alert', None], } CONDITION_CLASSES = { @@ -77,6 +78,28 @@ def setup(hass, config): """Set up the Meteo-France component.""" hass.data[DATA_METEO_FRANCE] = {} + # Check if at least weather alert have to be monitored for one location. + need_weather_alert_watcher = False + for location in config[DOMAIN]: + if CONF_MONITORED_CONDITIONS in location \ + and 'weather_alert' in location[CONF_MONITORED_CONDITIONS]: + need_weather_alert_watcher = True + + # If weather alert monitoring is expected initiate a client to be used by + # all weather_alert entities. + if need_weather_alert_watcher: + from vigilancemeteo import VigilanceMeteoFranceProxy, \ + VigilanceMeteoError + + weather_alert_client = VigilanceMeteoFranceProxy() + try: + weather_alert_client.update_data() + except VigilanceMeteoError as exp: + _LOGGER.error(exp) + else: + weather_alert_client = None + hass.data[DATA_METEO_FRANCE]['weather_alert_client'] = weather_alert_client + for location in config[DOMAIN]: city = location[CONF_CITY] @@ -98,6 +121,7 @@ def setup(hass, config): if CONF_MONITORED_CONDITIONS in location: monitored_conditions = location[CONF_MONITORED_CONDITIONS] + _LOGGER.debug("meteo_france sensor platfrom loaded for %s", city) load_platform( hass, 'sensor', DOMAIN, { CONF_CITY: city, diff --git a/homeassistant/components/meteo_france/manifest.json b/homeassistant/components/meteo_france/manifest.json index 20ad5e46fe628b..b485458be409e2 100644 --- a/homeassistant/components/meteo_france/manifest.json +++ b/homeassistant/components/meteo_france/manifest.json @@ -3,8 +3,12 @@ "name": "Meteo france", "documentation": "https://www.home-assistant.io/components/meteo_france", "requirements": [ - "meteofrance==0.3.4" + "meteofrance==0.3.7", + "vigilancemeteo==3.0.0" ], "dependencies": [], - "codeowners": [] -} + "codeowners": [ + "@victorcerutti", + "@oncleben31" + ] +} \ No newline at end of file diff --git a/homeassistant/components/meteo_france/sensor.py b/homeassistant/components/meteo_france/sensor.py index 122b91cae44d63..d30b58bdd5a283 100644 --- a/homeassistant/components/meteo_france/sensor.py +++ b/homeassistant/components/meteo_france/sensor.py @@ -9,6 +9,7 @@ _LOGGER = logging.getLogger(__name__) STATE_ATTR_FORECAST = '1h rain forecast' +STATE_ATTR_BULLETIN_TIME = 'Bulletin date' def setup_platform(hass, config, add_entities, discovery_info=None): @@ -19,18 +20,44 @@ def setup_platform(hass, config, add_entities, discovery_info=None): city = discovery_info[CONF_CITY] monitored_conditions = discovery_info[CONF_MONITORED_CONDITIONS] client = hass.data[DATA_METEO_FRANCE][city] - - add_entities([MeteoFranceSensor(variable, client) + weather_alert_client = hass.data[DATA_METEO_FRANCE]['weather_alert_client'] + + from vigilancemeteo import DepartmentWeatherAlert + + alert_watcher = None + if 'weather_alert' in monitored_conditions: + datas = hass.data[DATA_METEO_FRANCE][city].get_data() + # Check if a department code is available for this city. + if "dept" in datas: + try: + # If yes create the watcher DepartmentWeatherAlert object. + alert_watcher = DepartmentWeatherAlert(datas["dept"], + weather_alert_client) + except ValueError as exp: + _LOGGER.error(exp) + alert_watcher = None + else: + _LOGGER.info("weather alert watcher added for %s" + "in department %s", + city, datas["dept"]) + else: + _LOGGER.warning("No dept key found for '%s'. So weather alert " + "information won't be available", city) + # Exit and don't create the sensor if no department code available. + return + + add_entities([MeteoFranceSensor(variable, client, alert_watcher) for variable in monitored_conditions], True) class MeteoFranceSensor(Entity): """Representation of a Meteo-France sensor.""" - def __init__(self, condition, client): + def __init__(self, condition, client, alert_watcher): """Initialize the Meteo-France sensor.""" self._condition = condition self._client = client + self._alert_watcher = alert_watcher self._state = None self._data = {} @@ -48,12 +75,25 @@ def state(self): @property def device_state_attributes(self): """Return the state attributes of the sensor.""" + # Attributes for next_rain sensor. if self._condition == 'next_rain' and 'rain_forecast' in self._data: return { **{STATE_ATTR_FORECAST: self._data['rain_forecast']}, ** self._data['next_rain_intervals'], **{ATTR_ATTRIBUTION: ATTRIBUTION} } + + # Attributes for weather_alert sensor. + if self._condition == 'weather_alert' \ + and self._alert_watcher is not None: + return { + **{STATE_ATTR_BULLETIN_TIME: + self._alert_watcher.bulletin_date}, + ** self._alert_watcher.alerts_list, + ATTR_ATTRIBUTION: ATTRIBUTION + } + + # Attributes for all other sensors. return {ATTR_ATTRIBUTION: ATTRIBUTION} @property @@ -66,7 +106,19 @@ def update(self): try: self._client.update() self._data = self._client.get_data() - self._state = self._data[self._condition] + + if self._condition == 'weather_alert': + if self._alert_watcher is not None: + self._alert_watcher.update_department_status() + self._state = self._alert_watcher.department_color + _LOGGER.debug("weather alert watcher for %s updated. Proxy" + " have the status: %s", self._data['name'], + self._alert_watcher.proxy.status) + else: + _LOGGER.warning("No weather alert data for location %s", + self._data['name']) + else: + self._state = self._data[self._condition] except KeyError: _LOGGER.error("No condition %s for location %s", self._condition, self._data['name']) diff --git a/homeassistant/components/meteo_france/weather.py b/homeassistant/components/meteo_france/weather.py index b2b94c7622e464..05b760e49f14a1 100644 --- a/homeassistant/components/meteo_france/weather.py +++ b/homeassistant/components/meteo_france/weather.py @@ -102,3 +102,11 @@ def format_condition(condition): if condition in value: return key return condition + + @property + def device_state_attributes(self): + """Return the state attributes.""" + data = dict() + if self._data and "next_rain" in self._data: + data["next_rain"] = self._data["next_rain"] + return data diff --git a/homeassistant/components/meteoalarm/binary_sensor.py b/homeassistant/components/meteoalarm/binary_sensor.py index 8af43d3b087883..e1ffbe1d9ad0ff 100644 --- a/homeassistant/components/meteoalarm/binary_sensor.py +++ b/homeassistant/components/meteoalarm/binary_sensor.py @@ -6,26 +6,22 @@ from homeassistant.components.binary_sensor import ( PLATFORM_SCHEMA, BinarySensorDevice) -from homeassistant.const import ( - ATTR_ATTRIBUTION, CONF_NAME) +from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) +ATTRIBUTION = "Information provided by MeteoAlarm" + CONF_COUNTRY = 'country' -CONF_PROVINCE = 'province' CONF_LANGUAGE = 'language' +CONF_PROVINCE = 'province' -ATTRIBUTION = "Information provided by MeteoAlarm." - -DEFAULT_NAME = 'meteoalarm' DEFAULT_DEVICE_CLASS = 'safety' - -ICON = 'mdi:alert' +DEFAULT_NAME = 'meteoalarm' SCAN_INTERVAL = timedelta(minutes=30) - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_COUNTRY): cv.string, vol.Required(CONF_PROVINCE): cv.string, @@ -46,7 +42,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): try: api = Meteoalert(country, province, language) except KeyError(): - _LOGGER.error("Wrong country digits, or province name") + _LOGGER.error("Wrong country digits or province name") return add_entities([MeteoAlertBinarySensor(api, name)], True) @@ -78,14 +74,9 @@ def device_state_attributes(self): self._attributes[ATTR_ATTRIBUTION] = ATTRIBUTION return self._attributes - @property - def icon(self): - """Icon to use in the frontend.""" - return ICON - @property def device_class(self): - """Return the class of this binary sensor.""" + """Return the device class of this binary sensor.""" return DEFAULT_DEVICE_CLASS def update(self): diff --git a/homeassistant/components/meteoalarm/manifest.json b/homeassistant/components/meteoalarm/manifest.json index d84749547ae6c0..2148375621307c 100644 --- a/homeassistant/components/meteoalarm/manifest.json +++ b/homeassistant/components/meteoalarm/manifest.json @@ -3,7 +3,7 @@ "name": "meteoalarm", "documentation": "https://www.home-assistant.io/components/meteoalarm", "requirements": [ - "meteoalertapi==0.0.8" + "meteoalertapi==0.1.5" ], "dependencies": [], "codeowners": ["@rolfberkenbosch"] diff --git a/homeassistant/components/mobile_app/.translations/it.json b/homeassistant/components/mobile_app/.translations/it.json index 8c083fad17ef07..049e551d19bdfe 100644 --- a/homeassistant/components/mobile_app/.translations/it.json +++ b/homeassistant/components/mobile_app/.translations/it.json @@ -1,7 +1,11 @@ { "config": { + "abort": { + "install_app": "Apri l'app per dispositivi mobili per configurare l'integrazione con Home Assistant. Vedi [i documenti] ( {apps_url} ) per un elenco di app compatibili." + }, "step": { "confirm": { + "description": "Vuoi configurare il componente Mobile App?", "title": "App per dispositivi mobili" } }, diff --git a/homeassistant/components/mobile_app/.translations/nl.json b/homeassistant/components/mobile_app/.translations/nl.json new file mode 100644 index 00000000000000..8140e7df7dc240 --- /dev/null +++ b/homeassistant/components/mobile_app/.translations/nl.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "install_app": "Open de mobiele app om de integratie met de Home Assistant op te zetten. Zie [de docs]({apps_url}) voor een lijst met compatibele apps." + }, + "step": { + "confirm": { + "description": "Wilt u de Mobile App component instellen?", + "title": "Mobiele app" + } + }, + "title": "Mobiele app" + } +} \ No newline at end of file diff --git a/homeassistant/components/moon/.translations/sensor.nl.json b/homeassistant/components/moon/.translations/sensor.nl.json index 5e78d429b9f079..3eaf470e509b2a 100644 --- a/homeassistant/components/moon/.translations/sensor.nl.json +++ b/homeassistant/components/moon/.translations/sensor.nl.json @@ -7,6 +7,6 @@ "waning_crescent": "Krimpende, sikkelvormige maan", "waning_gibbous": "Krimpende, vooruitspringende maan", "waxing_crescent": "Wassende, sikkelvormige maan", - "waxing_gibbous": "Wassende, vooruitspringende maan" + "waxing_gibbous": "Wassende, sikkelvormige maan" } } \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/ca.json b/homeassistant/components/mqtt/.translations/ca.json index 1fc3ea628bb866..47dc4d344bcee4 100644 --- a/homeassistant/components/mqtt/.translations/ca.json +++ b/homeassistant/components/mqtt/.translations/ca.json @@ -22,7 +22,7 @@ "data": { "discovery": "Habilitar descobriment autom\u00e0tic" }, - "description": "Vols configurar Home Assistant perqu\u00e8 es connecti amb el broker MQTT proporcionat pel complement de hass.io {addon}?", + "description": "Vols configurar Home Assistant perqu\u00e8 es connecti amb el broker MQTT proporcionat pel complement de Hass.io: {addon}?", "title": "Broker MQTT a trav\u00e9s del complement de Hass.io" } }, diff --git a/homeassistant/components/mqtt/.translations/ko.json b/homeassistant/components/mqtt/.translations/ko.json index e2a1ef6456e0e5..1d50d5bd3c31c4 100644 --- a/homeassistant/components/mqtt/.translations/ko.json +++ b/homeassistant/components/mqtt/.translations/ko.json @@ -22,7 +22,7 @@ "data": { "discovery": "\uae30\uae30 \uac80\uc0c9 \ud65c\uc131\ud654" }, - "description": "Hass.io \uc560\ub4dc\uc628 {addon} \ub85c(\uc73c\ub85c) MQTT \ube0c\ub85c\ucee4\uc5d0 \uc5f0\uacb0\ud558\ub3c4\ub85d Home Assistant \ub97c \uad6c\uc131 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "description": "Hass.io {addon} \uc560\ub4dc\uc628\uc73c\ub85c MQTT \ube0c\ub85c\ucee4\uc5d0 \uc5f0\uacb0\ud558\ub3c4\ub85d Home Assistant \ub97c \uad6c\uc131 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", "title": "Hass.io \uc560\ub4dc\uc628\uc758 MQTT \ube0c\ub85c\ucee4" } }, diff --git a/homeassistant/components/mqtt/.translations/pl.json b/homeassistant/components/mqtt/.translations/pl.json index 33c33c5c09585b..24cdeb0f12e4d0 100644 --- a/homeassistant/components/mqtt/.translations/pl.json +++ b/homeassistant/components/mqtt/.translations/pl.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "single_instance_allowed": "Wymagana jest tylko jedna konfiguracja MQTT." + "single_instance_allowed": "Dozwolona jest tylko jedna konfiguracja MQTT." }, "error": { "cannot_connect": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z po\u015brednikiem." diff --git a/homeassistant/components/mqtt/.translations/ru.json b/homeassistant/components/mqtt/.translations/ru.json index aacac084b198d6..ac27652cbdd6b6 100644 --- a/homeassistant/components/mqtt/.translations/ru.json +++ b/homeassistant/components/mqtt/.translations/ru.json @@ -13,7 +13,7 @@ "discovery": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u044c \u0430\u0432\u0442\u043e\u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0438\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432", "password": "\u041f\u0430\u0440\u043e\u043b\u044c", "port": "\u041f\u043e\u0440\u0442", - "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" + "username": "\u041b\u043e\u0433\u0438\u043d" }, "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e \u043e \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0438 \u043a \u0412\u0430\u0448\u0435\u043c\u0443 \u0431\u0440\u043e\u043a\u0435\u0440\u0443 MQTT.", "title": "MQTT" diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 4ba8f1a5cc5273..d31ea150acac88 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -79,7 +79,8 @@ CONF_MANUFACTURER = 'manufacturer' CONF_MODEL = 'model' CONF_SW_VERSION = 'sw_version' -CONF_VIA_HUB = 'via_hub' +CONF_VIA_DEVICE = 'via_device' +CONF_DEPRECATED_VIA_HUB = 'via_hub' PROTOCOL_31 = '3.1' PROTOCOL_311 = '3.1.1' @@ -229,17 +230,20 @@ def embedded_broker_deprecated(value): default=DEFAULT_PAYLOAD_NOT_AVAILABLE): cv.string, }) -MQTT_ENTITY_DEVICE_INFO_SCHEMA = vol.All(vol.Schema({ - vol.Optional(CONF_IDENTIFIERS, default=list): - vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_CONNECTIONS, default=list): - vol.All(cv.ensure_list, [vol.All(vol.Length(2), [cv.string])]), - vol.Optional(CONF_MANUFACTURER): cv.string, - vol.Optional(CONF_MODEL): cv.string, - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_SW_VERSION): cv.string, - vol.Optional(CONF_VIA_HUB): cv.string, -}), validate_device_has_at_least_one_identifier) +MQTT_ENTITY_DEVICE_INFO_SCHEMA = vol.All( + cv.deprecated(CONF_DEPRECATED_VIA_HUB, CONF_VIA_DEVICE), + vol.Schema({ + vol.Optional(CONF_IDENTIFIERS, default=list): + vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_CONNECTIONS, default=list): + vol.All(cv.ensure_list, [vol.All(vol.Length(2), [cv.string])]), + vol.Optional(CONF_MANUFACTURER): cv.string, + vol.Optional(CONF_MODEL): cv.string, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_SW_VERSION): cv.string, + vol.Optional(CONF_VIA_DEVICE): cv.string, + }), + validate_device_has_at_least_one_identifier) MQTT_JSON_ATTRS_SCHEMA = vol.Schema({ vol.Optional(CONF_JSON_ATTRS_TOPIC): valid_subscribe_topic, @@ -1098,8 +1102,8 @@ def device_info(self): if CONF_SW_VERSION in self._device_config: info['sw_version'] = self._device_config[CONF_SW_VERSION] - if CONF_VIA_HUB in self._device_config: - info['via_hub'] = (DOMAIN, self._device_config[CONF_VIA_HUB]) + if CONF_VIA_DEVICE in self._device_config: + info['via_device'] = (DOMAIN, self._device_config[CONF_VIA_DEVICE]) return info diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index da3e2faf224229..9827c7c4df9d4e 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -192,6 +192,12 @@ def code_format(self): return alarm.FORMAT_NUMBER return alarm.FORMAT_TEXT + @property + def code_arm_required(self): + """Whether the code is required for arm actions.""" + code_required = self._config.get(CONF_CODE_ARM_REQUIRED) + return code_required + async def async_alarm_disarm(self, code=None): """Send disarm command. diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index e1ad21564b5bbd..0c62f230032bd2 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -30,6 +30,7 @@ CONF_SET_POSITION_TOPIC = 'set_position_topic' CONF_TILT_COMMAND_TOPIC = 'tilt_command_topic' CONF_TILT_STATUS_TOPIC = 'tilt_status_topic' +CONF_TILT_STATUS_TEMPLATE = 'tilt_status_template' CONF_PAYLOAD_CLOSE = 'payload_close' CONF_PAYLOAD_OPEN = 'payload_open' @@ -110,6 +111,7 @@ def validate_options(value): vol.Optional(CONF_TILT_STATE_OPTIMISTIC, default=DEFAULT_TILT_OPTIMISTIC): cv.boolean, vol.Optional(CONF_TILT_STATUS_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_TILT_STATUS_TEMPLATE): cv.template, vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, }).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema).extend( @@ -203,17 +205,26 @@ async def _subscribe_topics(self): set_position_template = self._config.get(CONF_SET_POSITION_TEMPLATE) if set_position_template is not None: set_position_template.hass = self.hass + tilt_status_template = self._config.get(CONF_TILT_STATUS_TEMPLATE) + if tilt_status_template is not None: + tilt_status_template.hass = self.hass topics = {} @callback def tilt_updated(msg): """Handle tilt updates.""" - if (msg.payload.isnumeric() and - (self._config[CONF_TILT_MIN] <= int(msg.payload) <= + payload = msg.payload + if tilt_status_template is not None: + payload = \ + tilt_status_template.async_render_with_possible_json_value( + payload) + + if (payload.isnumeric() and + (self._config[CONF_TILT_MIN] <= int(payload) <= self._config[CONF_TILT_MAX])): - level = self.find_percentage_in_range(float(msg.payload)) + level = self.find_percentage_in_range(float(payload)) self._tilt_value = level self.async_write_ha_state() @@ -271,7 +282,6 @@ def position_message_received(msg): if self._config.get(CONF_TILT_STATUS_TOPIC) is None: self._tilt_optimistic = True else: - self._tilt_optimistic = False self._tilt_value = STATE_UNKNOWN topics['tilt_status_topic'] = { 'topic': self._config.get(CONF_TILT_STATUS_TOPIC), @@ -394,7 +404,8 @@ async def async_open_cover_tilt(self, **kwargs): self._config[CONF_QOS], self._config[CONF_RETAIN]) if self._tilt_optimistic: - self._tilt_value = self._config[CONF_TILT_OPEN_POSITION] + self._tilt_value = self.find_percentage_in_range( + float(self._config[CONF_TILT_OPEN_POSITION])) self.async_write_ha_state() async def async_close_cover_tilt(self, **kwargs): @@ -405,7 +416,8 @@ async def async_close_cover_tilt(self, **kwargs): self._config[CONF_QOS], self._config[CONF_RETAIN]) if self._tilt_optimistic: - self._tilt_value = self._config[CONF_TILT_CLOSED_POSITION] + self._tilt_value = self.find_percentage_in_range( + float(self._config[CONF_TILT_CLOSED_POSITION])) self.async_write_ha_state() async def async_set_cover_tilt_position(self, **kwargs): @@ -453,6 +465,19 @@ async def async_set_cover_position(self, **kwargs): self._position = percentage_position self.async_write_ha_state() + async def async_toggle_tilt(self, **kwargs): + """Toggle the entity.""" + if self.is_tilt_closed(): + await self.async_open_cover_tilt(**kwargs) + else: + await self.async_close_cover_tilt(**kwargs) + + def is_tilt_closed(self): + """Return if the cover is tilted closed.""" + return self._tilt_value == \ + self.find_percentage_in_range( + float(self._config[CONF_TILT_CLOSED_POSITION])) + def find_percentage_in_range(self, position, range_type=TILT_PAYLOAD): """Find the 0-100% value within the specified range.""" # the range of motion as defined by the min max values diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index c99c73018ea08f..fb9626ac6e2f47 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -78,9 +78,11 @@ 'away_mode_cmd_t': 'away_mode_command_topic', 'away_mode_stat_tpl': 'away_mode_state_template', 'away_mode_stat_t': 'away_mode_state_topic', + 'b_tpl': 'blue_template', 'bri_cmd_t': 'brightness_command_topic', 'bri_scl': 'brightness_scale', 'bri_stat_t': 'brightness_state_topic', + 'bri_tpl': 'brightness_template', 'bri_val_tpl': 'brightness_value_template', 'clr_temp_cmd_tpl': 'color_temp_command_template', 'bat_lev_t': 'battery_level_topic', @@ -92,6 +94,8 @@ 'clr_temp_val_tpl': 'color_temp_value_template', 'cln_t': 'cleaning_topic', 'cln_tpl': 'cleaning_template', + 'cmd_off_tpl': 'command_off_template', + 'cmd_on_tpl': 'command_on_template', 'cmd_t': 'command_topic', 'curr_temp_t': 'current_temperature_topic', 'curr_temp_tpl': 'current_temperature_template', @@ -107,12 +111,14 @@ 'fx_cmd_t': 'effect_command_topic', 'fx_list': 'effect_list', 'fx_stat_t': 'effect_state_topic', + 'fx_tpl': 'effect_template', 'fx_val_tpl': 'effect_value_template', 'exp_aft': 'expire_after', 'fan_mode_cmd_t': 'fan_mode_command_topic', 'fan_mode_stat_tpl': 'fan_mode_state_template', 'fan_mode_stat_t': 'fan_mode_state_topic', 'frc_upd': 'force_update', + 'g_tpl': 'green_template', 'hold_cmd_t': 'hold_command_topic', 'hold_stat_tpl': 'hold_state_template', 'hold_stat_t': 'hold_state_topic', @@ -149,6 +155,7 @@ 'pl_stop': 'payload_stop', 'pl_unlk': 'payload_unlock', 'pow_cmd_t': 'power_command_topic', + 'r_tpl': 'red_template', 'ret': 'retain', 'rgb_cmd_tpl': 'rgb_command_template', 'rgb_cmd_t': 'rgb_command_topic', @@ -168,6 +175,7 @@ 'stat_on': 'state_on', 'stat_open': 'state_open', 'stat_t': 'state_topic', + 'stat_tpl': 'state_template', 'stat_val_tpl': 'state_value_template', 'sup_feat': 'supported_features', 'swing_mode_cmd_t': 'swing_mode_command_topic', diff --git a/homeassistant/components/nest/.translations/lb.json b/homeassistant/components/nest/.translations/lb.json index 197cc8206d0510..9fdf442c5ff22d 100644 --- a/homeassistant/components/nest/.translations/lb.json +++ b/homeassistant/components/nest/.translations/lb.json @@ -1,15 +1,15 @@ { "config": { "abort": { - "already_setup": "Dir k\u00ebnnt n\u00ebmmen een eenzegen\u00a0Nest Kont\u00a0konfigur\u00e9ieren.", + "already_setup": "Dir k\u00ebnnt n\u00ebmmen een eenzegen Nest Kont konfigur\u00e9ieren.", "authorize_url_fail": "Onbekannte Feeler beim gener\u00e9ieren vun der Autorisatiouns URL.", - "authorize_url_timeout": "Z\u00e4it Iwwerschreidung\u00a0beim gener\u00e9ieren\u00a0vun der Autorisatiouns\u00a0URL.", - "no_flows": "Dir musst Nest konfigur\u00e9ieren, ier Dir d\u00ebs Authentifiz\u00e9ierung\u00a0k\u00ebnnt benotzen.[Liest w.e.g. d'Instruktioune](https://www.home-assistant.io/components/nest/)." + "authorize_url_timeout": "Z\u00e4it Iwwerschreidung beim gener\u00e9ieren vun der Autorisatiouns URL.", + "no_flows": "Dir musst Nest konfigur\u00e9ieren, ier Dir d\u00ebs Authentifiz\u00e9ierung k\u00ebnnt benotzen.[Liest w.e.g. d'Instruktioune](https://www.home-assistant.io/components/nest/)." }, "error": { "internal_error": "Interne Feeler beim valid\u00e9ieren vum Code", "invalid_code": "Ong\u00ebltege Code", - "timeout": "Z\u00e4it Iwwerschreidung\u00a0beim valid\u00e9ieren vum Code", + "timeout": "Z\u00e4it Iwwerschreidung beim valid\u00e9ieren vum Code", "unknown": "Onbekannte Feeler beim valid\u00e9ieren vum Code" }, "step": { diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index 33ad34b25ff3af..ec8d8275b1b9ca 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -2,6 +2,7 @@ import logging from datetime import timedelta +import requests import voluptuous as vol import homeassistant.helpers.config_validation as cv @@ -344,8 +345,8 @@ def get_room_ids(self): """Return all module available on the API as a list.""" if not self.setup(): return [] - for key in self.homestatus.rooms: - self.room_ids.append(key) + for room in self.homestatus.rooms: + self.room_ids.append(room) return self.room_ids def setup(self): @@ -365,47 +366,63 @@ def setup(self): def update(self): """Call the NetAtmo API to update the data.""" import pyatmo + try: self.homestatus = pyatmo.HomeStatus(self.auth, home=self.home) except TypeError: _LOGGER.error("Error when getting homestatus.") return + except requests.exceptions.Timeout: + _LOGGER.warning("Timed out when connecting to Netatmo server.") + return _LOGGER.debug("Following is the debugging output for homestatus:") _LOGGER.debug(self.homestatus.rawData) - for key in self.homestatus.rooms: - roomstatus = {} - homestatus_room = self.homestatus.rooms[key] - homedata_room = self.homedata.rooms[self.home][key] - roomstatus['roomID'] = homestatus_room['id'] - roomstatus['roomname'] = homedata_room['name'] - roomstatus['target_temperature'] = \ - homestatus_room['therm_setpoint_temperature'] - roomstatus['setpoint_mode'] = \ - homestatus_room['therm_setpoint_mode'] - roomstatus['current_temperature'] = \ - homestatus_room['therm_measured_temperature'] - roomstatus['module_type'] = \ - self.homestatus.thermostatType(self.home, key) - roomstatus['module_id'] = None - roomstatus['heating_status'] = None - roomstatus['heating_power_request'] = None - for module_id in homedata_room['module_ids']: - if self.homedata.modules[self.home][module_id]['type'] == \ - NA_THERM or roomstatus['module_id'] is None: - roomstatus['module_id'] = module_id - if roomstatus['module_type'] == NA_THERM: - self.boilerstatus = self.homestatus.boilerStatus( - rid=roomstatus['module_id']) - roomstatus['heating_status'] = self.boilerstatus - elif roomstatus['module_type'] == NA_VALVE: - roomstatus['heating_power_request'] = \ - homestatus_room['heating_power_request'] - roomstatus['heating_status'] = \ - roomstatus['heating_power_request'] > 0 - if self.boilerstatus is not None: - roomstatus['heating_status'] = \ - self.boilerstatus and roomstatus['heating_status'] - self.room_status[key] = roomstatus + for room in self.homestatus.rooms: + try: + roomstatus = {} + homestatus_room = self.homestatus.rooms[room] + homedata_room = self.homedata.rooms[self.home][room] + roomstatus["roomID"] = homestatus_room["id"] + roomstatus["roomname"] = homedata_room["name"] + roomstatus["target_temperature"] = homestatus_room[ + "therm_setpoint_temperature" + ] + roomstatus["setpoint_mode"] = homestatus_room[ + "therm_setpoint_mode" + ] + roomstatus["current_temperature"] = homestatus_room[ + "therm_measured_temperature" + ] + roomstatus["module_type"] = self.homestatus.thermostatType( + self.home, room + ) + roomstatus["module_id"] = None + roomstatus["heating_status"] = None + roomstatus["heating_power_request"] = None + for module_id in homedata_room["module_ids"]: + if (self.homedata.modules[self.home][module_id]["type"] + == NA_THERM + or roomstatus["module_id"] is None): + roomstatus["module_id"] = module_id + if roomstatus["module_type"] == NA_THERM: + self.boilerstatus = self.homestatus.boilerStatus( + rid=roomstatus["module_id"] + ) + roomstatus["heating_status"] = self.boilerstatus + elif roomstatus["module_type"] == NA_VALVE: + roomstatus["heating_power_request"] = homestatus_room[ + "heating_power_request" + ] + roomstatus["heating_status"] = ( + roomstatus["heating_power_request"] > 0 + ) + if self.boilerstatus is not None: + roomstatus["heating_status"] = ( + self.boilerstatus and roomstatus["heating_status"] + ) + self.room_status[room] = roomstatus + except KeyError as err: + _LOGGER.error("Update of room %s failed. Error: %s", room, err) self.away_temperature = self.homestatus.getAwaytemp(self.home) self.hg_temperature = self.homestatus.getHgtemp(self.home) self.setpoint_duration = self.homedata.setpoint_duration[self.home] diff --git a/homeassistant/components/netatmo/manifest.json b/homeassistant/components/netatmo/manifest.json index 91e96e48b5c959..a8a8c28f2376f0 100644 --- a/homeassistant/components/netatmo/manifest.json +++ b/homeassistant/components/netatmo/manifest.json @@ -3,7 +3,7 @@ "name": "Netatmo", "documentation": "https://www.home-assistant.io/components/netatmo", "requirements": [ - "pyatmo==1.12" + "pyatmo==2.1.0" ], "dependencies": [ "webhook" diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index dabfb827aea0e7..48d82eca2f0580 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -1,20 +1,21 @@ """Support for the Netatmo Weather Service.""" -from datetime import timedelta import logging -from time import time import threading +from datetime import timedelta +from time import time +import requests import voluptuous as vol +import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( CONF_NAME, CONF_MODE, CONF_MONITORED_CONDITIONS, TEMP_CELSIUS, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_BATTERY) from homeassistant.helpers.entity import Entity -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import call_later from homeassistant.util import Throttle - from .const import DATA_NETATMO_AUTH _LOGGER = logging.getLogger(__name__) @@ -101,7 +102,6 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the available Netatmo weather sensors.""" dev = [] - not_handled = {} auth = hass.data[DATA_NETATMO_AUTH] if config.get(CONF_AREAS) is not None: @@ -121,45 +121,55 @@ def setup_platform(hass, config, add_entities, discovery_info=None): area[CONF_MODE] )) else: - for data_class in all_product_classes(): + def _retry(_data): + try: + _dev = find_devices(_data) + except requests.exceptions.Timeout: + return call_later(hass, NETATMO_UPDATE_INTERVAL, + lambda _: _retry(_data)) + if _dev: + add_entities(_dev, True) + + import pyatmo + for data_class in [pyatmo.WeatherStationData, pyatmo.HomeCoachData]: data = NetatmoData(auth, data_class, config.get(CONF_STATION)) - module_items = [] # Test if manually configured if CONF_MODULES in config: module_items = config[CONF_MODULES].items() - else: - # otherwise add all modules and conditions - for module_name in data.get_module_names(): - monitored_conditions = \ - data.station_data.monitoredConditions(module_name) - module_items.append( - (module_name, monitored_conditions)) - - for module_name, monitored_conditions in module_items: - # Test if module exists - if module_name not in data.get_module_names(): - not_handled[module_name] = \ - not_handled[module_name]+1 \ - if module_name in not_handled else 1 - else: - # Only create sensors for monitored properties + for module_name, monitored_conditions in module_items: for condition in monitored_conditions: dev.append(NetatmoSensor( data, module_name, condition.lower(), config.get(CONF_STATION))) + continue - for module_name, _ in not_handled.items(): - _LOGGER.error('Module name: "%s" not found', module_name) + # otherwise add all modules and conditions + try: + dev.extend(find_devices(data)) + except requests.exceptions.Timeout: + call_later(hass, NETATMO_UPDATE_INTERVAL, + lambda _: _retry(data)) if dev: add_entities(dev, True) -def all_product_classes(): - """Provide all handled Netatmo product classes.""" - import pyatmo +def find_devices(data): + """Find all devices.""" + dev = [] + not_handled = [] + for module_name in data.get_module_names(): + if (module_name not in data.get_module_names() + and module_name not in not_handled): + not_handled.append(not_handled) + continue + for condition in data.station_data.monitoredConditions(module_name): + dev.append(NetatmoSensor( + data, module_name, condition.lower(), data.station)) - return [pyatmo.WeatherStationData, pyatmo.HomeCoachData] + for module_name in not_handled: + _LOGGER.error('Module name: "%s" not found', module_name) + return dev class NetatmoSensor(Entity): @@ -515,22 +525,6 @@ def get_module_names(self): self.update() return self.data.keys() - def _detect_platform_type(self): - """Return the XXXData object corresponding to the specified platform. - - The return can be a WeatherStationData or a HomeCoachData. - """ - from pyatmo import NoDevice - try: - station_data = self.data_class(self.auth) - _LOGGER.debug("%s detected!", str(self.data_class.__name__)) - return station_data - except NoDevice: - _LOGGER.warning("No Weather or HomeCoach devices found for %s", - str(self.station) - ) - raise - def update(self): """Call the Netatmo API to update the data. @@ -541,14 +535,20 @@ def update(self): if time() < self._next_update or \ not self._update_in_progress.acquire(False): return - - from pyatmo import NoDevice try: - self.station_data = self._detect_platform_type() - except NoDevice: - return + from pyatmo import NoDevice + try: + self.station_data = self.data_class(self.auth) + _LOGGER.debug("%s detected!", str(self.data_class.__name__)) + except NoDevice: + _LOGGER.warning("No Weather or HomeCoach devices found for %s", + str(self.station) + ) + return + except requests.exceptions.Timeout: + _LOGGER.warning("Timed out when connecting to Netatmo server.") + return - try: if self.station is not None: data = self.station_data.lastData( station=self.station, exclude=3600) diff --git a/homeassistant/components/nissan_leaf/__init__.py b/homeassistant/components/nissan_leaf/__init__.py index f9e7cd7f2d1879..80e5543946cafe 100644 --- a/homeassistant/components/nissan_leaf/__init__.py +++ b/homeassistant/components/nissan_leaf/__init__.py @@ -301,6 +301,9 @@ async def async_refresh_data(self, now): self.data[DATA_PLUGGED_IN] = ( server_response.is_connected ) + self.data[DATA_CHARGING] = ( + server_response.is_charging + ) async_dispatcher_send(self.hass, SIGNAL_UPDATE_LEAF) self.last_battery_response = utcnow() diff --git a/homeassistant/components/onboarding/.translations/fr.json b/homeassistant/components/onboarding/.translations/fr.json index 8a8ff47a48a417..d8ae0b34033b60 100644 --- a/homeassistant/components/onboarding/.translations/fr.json +++ b/homeassistant/components/onboarding/.translations/fr.json @@ -2,6 +2,6 @@ "area": { "bedroom": "Chambre", "kitchen": "Cuisine", - "living_room": "Salle De S\u00e9jour" + "living_room": "Salon" } } \ No newline at end of file diff --git a/homeassistant/components/onboarding/.translations/hu.json b/homeassistant/components/onboarding/.translations/hu.json new file mode 100644 index 00000000000000..262fca71470885 --- /dev/null +++ b/homeassistant/components/onboarding/.translations/hu.json @@ -0,0 +1,7 @@ +{ + "area": { + "bedroom": "H\u00e1l\u00f3szoba", + "kitchen": "Konyha", + "living_room": "Nappali" + } +} \ No newline at end of file diff --git a/homeassistant/components/onboarding/.translations/it.json b/homeassistant/components/onboarding/.translations/it.json new file mode 100644 index 00000000000000..1832c80cfcff30 --- /dev/null +++ b/homeassistant/components/onboarding/.translations/it.json @@ -0,0 +1,7 @@ +{ + "area": { + "bedroom": "Camera da letto", + "kitchen": "Cucina", + "living_room": "Soggiorno" + } +} \ No newline at end of file diff --git a/homeassistant/components/onboarding/.translations/pt-BR.json b/homeassistant/components/onboarding/.translations/pt-BR.json new file mode 100644 index 00000000000000..d5a09a0b24002e --- /dev/null +++ b/homeassistant/components/onboarding/.translations/pt-BR.json @@ -0,0 +1,7 @@ +{ + "area": { + "bedroom": "Quarto", + "kitchen": "Cozinha", + "living_room": "Sala de estar" + } +} \ No newline at end of file diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index c8060891fd4f1e..90217016d60a0e 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -148,6 +148,10 @@ async def post(self, request): await self._async_mark_done(hass) + await hass.config_entries.flow.async_init('met', context={ + 'source': 'onboarding' + }) + return self.json({}) diff --git a/homeassistant/components/opencv/manifest.json b/homeassistant/components/opencv/manifest.json index dfc493f1c96f49..9892e51ba0fb7e 100644 --- a/homeassistant/components/opencv/manifest.json +++ b/homeassistant/components/opencv/manifest.json @@ -3,7 +3,8 @@ "name": "Opencv", "documentation": "https://www.home-assistant.io/components/opencv", "requirements": [ - "numpy==1.16.3" + "numpy==1.16.3", + "opencv-python-headless==4.1.0.25" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/openuv/.translations/it.json b/homeassistant/components/openuv/.translations/it.json index 82dfd63184ab12..1a231d680e693b 100644 --- a/homeassistant/components/openuv/.translations/it.json +++ b/homeassistant/components/openuv/.translations/it.json @@ -10,7 +10,7 @@ "api_key": "API Key di OpenUV", "elevation": "Altitudine", "latitude": "Latitudine", - "longitude": "Logitudine" + "longitude": "Longitudine" }, "title": "Inserisci i tuoi dati" } diff --git a/homeassistant/components/plaato/.translations/ca.json b/homeassistant/components/plaato/.translations/ca.json new file mode 100644 index 00000000000000..481450cbc5fce4 --- /dev/null +++ b/homeassistant/components/plaato/.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 Plaato Airlock.", + "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 Plaato Airlock.\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 Plaato Airlock?", + "title": "Configuraci\u00f3 del Webhook de Plaato" + } + }, + "title": "Plaato Airlock" + } +} \ No newline at end of file diff --git a/homeassistant/components/plaato/.translations/de.json b/homeassistant/components/plaato/.translations/de.json new file mode 100644 index 00000000000000..92dafa1c3232ba --- /dev/null +++ b/homeassistant/components/plaato/.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 Plaato Airlock 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 Plaato Airlock 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": "Soll Plaato Airlock wirklich eingerichtet werden?", + "title": "Plaato Webhook einrichten" + } + }, + "title": "Plaato Airlock" + } +} \ No newline at end of file diff --git a/homeassistant/components/plaato/.translations/en.json b/homeassistant/components/plaato/.translations/en.json new file mode 100644 index 00000000000000..6d3aa2c59c43eb --- /dev/null +++ b/homeassistant/components/plaato/.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 Plaato Airlock.", + "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 Plaato Airlock.\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 Plaato Airlock?", + "title": "Set up the Plaato Webhook" + } + }, + "title": "Plaato Airlock" + } +} \ No newline at end of file diff --git a/homeassistant/components/plaato/.translations/ko.json b/homeassistant/components/plaato/.translations/ko.json new file mode 100644 index 00000000000000..34432f6b108f0b --- /dev/null +++ b/homeassistant/components/plaato/.translations/ko.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Plaato Airlock \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 Plaato Airlock \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": "Plaato Airlock \uc744 \uc124\uc815 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "title": "Plaato Webhook \uc124\uc815" + } + }, + "title": "Plaato Airlock" + } +} \ No newline at end of file diff --git a/homeassistant/components/plaato/.translations/ru.json b/homeassistant/components/plaato/.translations/ru.json new file mode 100644 index 00000000000000..59964fdedd63a4 --- /dev/null +++ b/homeassistant/components/plaato/.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 Plaato Airlock.", + "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 \u0432\u044b \u0434\u043e\u043b\u0436\u043d\u044b \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c webhooks \u0434\u043b\u044f Plaato Airlock\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 [\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u0435\u0439]({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 Plaato Airlock?", + "title": "Plaato Airlock" + } + }, + "title": "Plaato Airlock" + } +} \ No newline at end of file diff --git a/homeassistant/components/plaato/.translations/zh-Hant.json b/homeassistant/components/plaato/.translations/zh-Hant.json new file mode 100644 index 00000000000000..20cdb405f4ee10 --- /dev/null +++ b/homeassistant/components/plaato/.translations/zh-Hant.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Home Assistant \u5be6\u4f8b\u5fc5\u9808\u80fd\u5920\u7531\u7db2\u969b\u7db2\u8def\u5b58\u53d6\uff0c\u65b9\u80fd\u63a5\u53d7 Plaato Airlock \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 Plaato Airlock \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 Plaato Airlock\uff1f", + "title": "\u8a2d\u5b9a Plaato Webhook" + } + }, + "title": "Plaato Airlock" + } +} \ No newline at end of file diff --git a/homeassistant/components/plaato/__init__.py b/homeassistant/components/plaato/__init__.py new file mode 100644 index 00000000000000..9857ef47b1ce8c --- /dev/null +++ b/homeassistant/components/plaato/__init__.py @@ -0,0 +1,126 @@ +"""Support for Plaato Airlock.""" +import logging + +from aiohttp import web +import voluptuous as vol + +from homeassistant.components.sensor import DOMAIN as SENSOR +from homeassistant.const import ( + CONF_WEBHOOK_ID, HTTP_OK, + TEMP_CELSIUS, TEMP_FAHRENHEIT, VOLUME_GALLONS, VOLUME_LITERS) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['webhook'] + +PLAATO_DEVICE_SENSORS = 'sensors' +PLAATO_DEVICE_ATTRS = 'attrs' + +ATTR_DEVICE_ID = 'device_id' +ATTR_DEVICE_NAME = 'device_name' +ATTR_TEMP_UNIT = 'temp_unit' +ATTR_VOLUME_UNIT = 'volume_unit' +ATTR_BPM = 'bpm' +ATTR_TEMP = 'temp' +ATTR_SG = 'sg' +ATTR_OG = 'og' +ATTR_BUBBLES = 'bubbles' +ATTR_ABV = 'abv' +ATTR_CO2_VOLUME = 'co2_volume' +ATTR_BATCH_VOLUME = 'batch_volume' + +SENSOR_UPDATE = '{}_sensor_update'.format(DOMAIN) +SENSOR_DATA_KEY = '{}.{}'.format(DOMAIN, SENSOR) + +WEBHOOK_SCHEMA = vol.Schema({ + vol.Required(ATTR_DEVICE_NAME): cv.string, + vol.Required(ATTR_DEVICE_ID): cv.positive_int, + vol.Required(ATTR_TEMP_UNIT): vol.Any(TEMP_CELSIUS, TEMP_FAHRENHEIT), + vol.Required(ATTR_VOLUME_UNIT): vol.Any(VOLUME_LITERS, VOLUME_GALLONS), + vol.Required(ATTR_BPM): cv.positive_int, + vol.Required(ATTR_TEMP): vol.Coerce(float), + vol.Required(ATTR_SG): vol.Coerce(float), + vol.Required(ATTR_OG): vol.Coerce(float), + vol.Required(ATTR_ABV): vol.Coerce(float), + vol.Required(ATTR_CO2_VOLUME): vol.Coerce(float), + vol.Required(ATTR_BATCH_VOLUME): vol.Coerce(float), + vol.Required(ATTR_BUBBLES): cv.positive_int, +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, hass_config): + """Set up the Plaato component.""" + return True + + +async def async_setup_entry(hass, entry): + """Configure based on config entry.""" + if DOMAIN not in hass.data: + hass.data[DOMAIN] = {} + + webhook_id = entry.data[CONF_WEBHOOK_ID] + hass.components.webhook.async_register( + DOMAIN, 'Plaato', webhook_id, handle_webhook) + + 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.""" + hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID]) + hass.data[SENSOR_DATA_KEY]() + + await hass.config_entries.async_forward_entry_unload(entry, SENSOR) + return True + + +async def handle_webhook(hass, webhook_id, request): + """Handle incoming webhook from Plaato.""" + try: + data = WEBHOOK_SCHEMA(await request.json()) + except vol.MultipleInvalid as error: + _LOGGER.warning("An error occurred when parsing webhook data <%s>", + error) + return + + device_id = _device_id(data) + + attrs = { + ATTR_DEVICE_NAME: data.get(ATTR_DEVICE_NAME), + ATTR_DEVICE_ID: data.get(ATTR_DEVICE_ID), + ATTR_TEMP_UNIT: data.get(ATTR_TEMP_UNIT), + ATTR_VOLUME_UNIT: data.get(ATTR_VOLUME_UNIT) + } + + sensors = { + ATTR_TEMP: data.get(ATTR_TEMP), + ATTR_BPM: data.get(ATTR_BPM), + ATTR_SG: data.get(ATTR_SG), + ATTR_OG: data.get(ATTR_OG), + ATTR_ABV: data.get(ATTR_ABV), + ATTR_CO2_VOLUME: data.get(ATTR_CO2_VOLUME), + ATTR_BATCH_VOLUME: data.get(ATTR_BATCH_VOLUME), + ATTR_BUBBLES: data.get(ATTR_BUBBLES) + } + + hass.data[DOMAIN][device_id] = { + PLAATO_DEVICE_ATTRS: attrs, + PLAATO_DEVICE_SENSORS: sensors + } + + async_dispatcher_send(hass, SENSOR_UPDATE, device_id) + + return web.Response( + text="Saving status for {}".format(device_id), status=HTTP_OK) + + +def _device_id(data): + """Return name of device sensor.""" + return "{}_{}".format(data.get(ATTR_DEVICE_NAME), data.get(ATTR_DEVICE_ID)) diff --git a/homeassistant/components/plaato/config_flow.py b/homeassistant/components/plaato/config_flow.py new file mode 100644 index 00000000000000..c3f9279df05ffc --- /dev/null +++ b/homeassistant/components/plaato/config_flow.py @@ -0,0 +1,11 @@ +"""Config flow for GPSLogger.""" +from homeassistant.helpers import config_entry_flow +from .const import DOMAIN + +config_entry_flow.register_webhook_flow( + DOMAIN, + 'Webhook', + { + 'docs_url': 'https://www.home-assistant.io/components/plaato/' + } +) diff --git a/homeassistant/components/plaato/const.py b/homeassistant/components/plaato/const.py new file mode 100644 index 00000000000000..f683ddb664ce8d --- /dev/null +++ b/homeassistant/components/plaato/const.py @@ -0,0 +1,3 @@ +"""Const for GPSLogger.""" + +DOMAIN = 'plaato' diff --git a/homeassistant/components/plaato/manifest.json b/homeassistant/components/plaato/manifest.json new file mode 100644 index 00000000000000..cd6111ba9da789 --- /dev/null +++ b/homeassistant/components/plaato/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "plaato", + "name": "Plaato Airlock", + "config_flow": true, + "documentation": "https://www.home-assistant.io/components/plaato", + "dependencies": ["webhook"], + "codeowners": ["@JohNan"], + "requirements": [] +} \ No newline at end of file diff --git a/homeassistant/components/plaato/sensor.py b/homeassistant/components/plaato/sensor.py new file mode 100644 index 00000000000000..4362accee240a3 --- /dev/null +++ b/homeassistant/components/plaato/sensor.py @@ -0,0 +1,147 @@ +"""Support for Plaato Airlock sensors.""" + +import logging + +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.entity import Entity + +from . import ( + ATTR_ABV, ATTR_BATCH_VOLUME, ATTR_BPM, ATTR_CO2_VOLUME, ATTR_TEMP, + ATTR_TEMP_UNIT, ATTR_VOLUME_UNIT, DOMAIN as PLAATO_DOMAIN, + PLAATO_DEVICE_ATTRS, PLAATO_DEVICE_SENSORS, SENSOR_DATA_KEY, SENSOR_UPDATE) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the Plaato sensor.""" + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Plaato from a config entry.""" + devices = {} + + def get_device(device_id): + """Get a device.""" + return hass.data[PLAATO_DOMAIN].get(device_id, False) + + def get_device_sensors(device_id): + """Get device sensors.""" + return hass.data[PLAATO_DOMAIN].get(device_id)\ + .get(PLAATO_DEVICE_SENSORS) + + async def _update_sensor(device_id): + """Update/Create the sensors.""" + if device_id not in devices and get_device(device_id): + entities = [] + sensors = get_device_sensors(device_id) + + for sensor_type in sensors: + entities.append(PlaatoSensor(device_id, sensor_type)) + + devices[device_id] = entities + + async_add_entities(entities, True) + else: + for entity in devices[device_id]: + async_dispatcher_send(hass, "{}_{}".format(PLAATO_DOMAIN, + entity.unique_id)) + + hass.data[SENSOR_DATA_KEY] = async_dispatcher_connect( + hass, SENSOR_UPDATE, _update_sensor + ) + + return True + + +class PlaatoSensor(Entity): + """Representation of a Sensor.""" + + def __init__(self, device_id, sensor_type): + """Initialize the sensor.""" + self._device_id = device_id + self._type = sensor_type + self._state = 0 + self._name = "{} {}".format(device_id, sensor_type) + self._attributes = None + + @property + def name(self): + """Return the name of the sensor.""" + return "{} {}".format(PLAATO_DOMAIN, self._name) + + @property + def unique_id(self): + """Return the unique ID of this sensor.""" + return "{}_{}".format(self._device_id, self._type) + + @property + def device_info(self): + """Get device info.""" + return { + 'identifiers': { + (PLAATO_DOMAIN, self._device_id) + }, + 'name': self._device_id, + 'manufacturer': 'Plaato', + 'model': 'Airlock' + } + + def get_sensors(self): + """Get device sensors.""" + return self.hass.data[PLAATO_DOMAIN].get(self._device_id)\ + .get(PLAATO_DEVICE_SENSORS, False) + + def get_sensors_unit_of_measurement(self, sensor_type): + """Get unit of measurement for sensor of type.""" + return self.hass.data[PLAATO_DOMAIN].get(self._device_id)\ + .get(PLAATO_DEVICE_ATTRS, []).get(sensor_type, '') + + @property + def state(self): + """Return the state of the sensor.""" + sensors = self.get_sensors() + if sensors is False: + _LOGGER.debug("Device with name %s has no sensors.", self.name) + return 0 + + if self._type == ATTR_ABV: + return round(sensors.get(self._type), 2) + if self._type == ATTR_TEMP: + return round(sensors.get(self._type), 1) + if self._type == ATTR_CO2_VOLUME: + return round(sensors.get(self._type), 2) + return sensors.get(self._type) + + @property + def device_state_attributes(self): + """Return the state attributes of the monitored installation.""" + if self._attributes is not None: + return self._attributes + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + if self._type == ATTR_TEMP: + return self.get_sensors_unit_of_measurement(ATTR_TEMP_UNIT) + if self._type == ATTR_BATCH_VOLUME or self._type == ATTR_CO2_VOLUME: + return self.get_sensors_unit_of_measurement(ATTR_VOLUME_UNIT) + if self._type == ATTR_BPM: + return 'bpm' + if self._type == ATTR_ABV: + return '%' + + return '' + + @property + def should_poll(self): + """Return the polling state.""" + return False + + async def async_added_to_hass(self): + """Register callbacks.""" + self.hass.helpers.dispatcher.async_dispatcher_connect( + "{}_{}".format(PLAATO_DOMAIN, self.unique_id), + self.async_schedule_update_ha_state) diff --git a/homeassistant/components/plaato/strings.json b/homeassistant/components/plaato/strings.json new file mode 100644 index 00000000000000..ee99da0c8b1cd7 --- /dev/null +++ b/homeassistant/components/plaato/strings.json @@ -0,0 +1,18 @@ +{ + "config": { + "title": "Plaato Airlock", + "step": { + "user": { + "title": "Set up the Plaato Webhook", + "description": "Are you sure you want to set up the Plaato Airlock?" + } + }, + "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 Plaato Airlock." + }, + "create_entry": { + "default": "To send events to Home Assistant, you will need to setup the webhook feature in Plaato Airlock.\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/point/.translations/nl.json b/homeassistant/components/point/.translations/nl.json index 50d9c7d45bbf53..1ca54237fd55a0 100644 --- a/homeassistant/components/point/.translations/nl.json +++ b/homeassistant/components/point/.translations/nl.json @@ -23,6 +23,7 @@ "data": { "flow_impl": "Leverancier" }, + "description": "Kies met welke authenticatieprovider u wilt authenticeren met Point.", "title": "Authenticatieleverancier" } }, diff --git a/homeassistant/components/point/__init__.py b/homeassistant/components/point/__init__.py index 2ed83fe1d9b06b..ac5a5a4ec918cf 100644 --- a/homeassistant/components/point/__init__.py +++ b/homeassistant/components/point/__init__.py @@ -300,7 +300,7 @@ def device_info(self): 'model': 'Point v{}'.format(device['hardware_version']), 'name': device['description'], 'sw_version': device['firmware']['installed'], - 'via_hub': (DOMAIN, device['home']), + 'via_device': (DOMAIN, device['home']), } @property diff --git a/homeassistant/components/ps4/.translations/hu.json b/homeassistant/components/ps4/.translations/hu.json index 6a0008957232e8..77b13f33a51c3d 100644 --- a/homeassistant/components/ps4/.translations/hu.json +++ b/homeassistant/components/ps4/.translations/hu.json @@ -1,6 +1,16 @@ { "config": { "step": { + "creds": { + "title": "PlayStation 4" + }, + "link": { + "data": { + "ip_address": "IP-c\u00edm", + "name": "N\u00e9v", + "region": "R\u00e9gi\u00f3" + } + }, "mode": { "data": { "mode": "Konfigur\u00e1ci\u00f3s m\u00f3d" diff --git a/homeassistant/components/ps4/.translations/it.json b/homeassistant/components/ps4/.translations/it.json index 635fbd7b479cca..afa32056757ccb 100644 --- a/homeassistant/components/ps4/.translations/it.json +++ b/homeassistant/components/ps4/.translations/it.json @@ -29,8 +29,10 @@ }, "mode": { "data": { + "ip_address": "Indirizzo IP (lasciare vuoto se si utilizza la funzione Auto Discovery).", "mode": "Modalit\u00e0 di configurazione" }, + "description": "Seleziona la modalit\u00e0 per la configurazione. L'indirizzo IP pu\u00f2 essere lasciato vuoto se si seleziona Auto Discovery, poich\u00e9 i dispositivi verranno rilevati automaticamente.", "title": "PlayStation 4" } }, diff --git a/homeassistant/components/ps4/.translations/nl.json b/homeassistant/components/ps4/.translations/nl.json index c3cdf03355fc30..8eaa20d76cf8c2 100644 --- a/homeassistant/components/ps4/.translations/nl.json +++ b/homeassistant/components/ps4/.translations/nl.json @@ -4,10 +4,11 @@ "credential_error": "Fout bij ophalen van inloggegevens.", "devices_configured": "Alle gevonden apparaten zijn al geconfigureerd.", "no_devices_found": "Geen PlayStation 4 apparaten gevonden op het netwerk.", - "port_987_bind_error": "Kan niet binden aan poort 987.", - "port_997_bind_error": "Kan niet binden aan poort 997." + "port_987_bind_error": "Kon niet binden aan poort 987. Raadpleeg de [documentatie] (https://www.home-assistant.io/components/ps4/) voor meer informatie.", + "port_997_bind_error": "Kon niet binden aan poort 997. Raadpleeg de [documentatie] (https://www.home-assistant.io/components/ps4/) voor aanvullende informatie." }, "error": { + "credential_timeout": "Time-out van inlog service. Druk op Submit om opnieuw te starten.", "login_failed": "Kan niet koppelen met PlayStation 4. Controleer of de pincode juist is.", "no_ipaddress": "Voer het IP-adres in van de PlayStation 4 die je wilt configureren.", "not_ready": "PlayStation 4 staat niet aan of is niet verbonden met een netwerk." @@ -24,13 +25,15 @@ "name": "Naam", "region": "Regio" }, - "description": "Voer je PlayStation 4 informatie in. Voor 'PIN', blader naar 'Instellingen' op je PlayStation 4. Blader dan naar 'Mobiele App verbindingsinstellingen' en kies 'Apparaat toevoegen'. Voer de weergegeven PIN-code in.", + "description": "Voer je PlayStation 4-informatie in. Ga voor 'PIN' naar 'Instellingen' op je PlayStation 4-console. Navigeer vervolgens naar 'Verbindingsinstellingen mobiele app' en selecteer 'Apparaat toevoegen'. Voer de pincode in die wordt weergegeven. Raadpleeg de [documentatie] (https://www.home-assistant.io/components/ps4/) voor meer informatie.", "title": "PlayStation 4" }, "mode": { "data": { - "ip_address": "IP-adres (leeg laten als u Auto Discovery gebruikt)." + "ip_address": "IP-adres (leeg laten als u Auto Discovery gebruikt).", + "mode": "Configuratiemodus" }, + "description": "Selecteer modus voor configuratie. Het veld IP-adres kan leeg blijven als Auto Discovery wordt geselecteerd, omdat apparaten automatisch worden gedetecteerd.", "title": "PlayStation 4" } }, diff --git a/homeassistant/components/ps4/.translations/pt-BR.json b/homeassistant/components/ps4/.translations/pt-BR.json new file mode 100644 index 00000000000000..7746ed5d9f41cb --- /dev/null +++ b/homeassistant/components/ps4/.translations/pt-BR.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nenhum dispositivo PlayStation 4 encontrado na rede." + }, + "error": { + "credential_timeout": "Servi\u00e7o de credencial expirou. Pressione Submit para reiniciar.", + "not_ready": "O PlayStation 4 n\u00e3o est\u00e1 ligado ou conectado \u00e0 rede." + }, + "step": { + "creds": { + "title": "Playstation 4" + }, + "link": { + "data": { + "ip_address": "Endere\u00e7o IP", + "name": "Nome", + "region": "Regi\u00e3o" + }, + "title": "Playstation 4" + }, + "mode": { + "data": { + "mode": "Modo de configura\u00e7\u00e3o" + } + } + }, + "title": "Playstation 4" + } +} \ No newline at end of file diff --git a/homeassistant/components/ps4/__init__.py b/homeassistant/components/ps4/__init__.py index b91e6b239e74be..7f7561304d2ba4 100644 --- a/homeassistant/components/ps4/__init__.py +++ b/homeassistant/components/ps4/__init__.py @@ -7,13 +7,29 @@ from homeassistant.util import location from .config_flow import PlayStation4FlowHandler # noqa: pylint: disable=unused-import -from .const import DOMAIN # noqa: pylint: disable=unused-import +from .const import DOMAIN, PS4_DATA # noqa: pylint: disable=unused-import _LOGGER = logging.getLogger(__name__) +class PS4Data(): + """Init Data Class.""" + + def __init__(self): + """Init Class.""" + self.devices = [] + self.protocol = None + + async def async_setup(hass, config): """Set up the PS4 Component.""" + from pyps4_homeassistant.ddp import async_create_ddp_endpoint + + hass.data[PS4_DATA] = PS4Data() + + transport, protocol = await async_create_ddp_endpoint() + hass.data[PS4_DATA].protocol = protocol + _LOGGER.debug("PS4 DDP endpoint created: %s, %s", transport, protocol) return True diff --git a/homeassistant/components/ps4/config_flow.py b/homeassistant/components/ps4/config_flow.py index b31ba44fbe3dc2..8ef98e12a8fead 100644 --- a/homeassistant/components/ps4/config_flow.py +++ b/homeassistant/components/ps4/config_flow.py @@ -9,7 +9,7 @@ CONF_CODE, CONF_HOST, CONF_IP_ADDRESS, CONF_NAME, CONF_REGION, CONF_TOKEN) from homeassistant.util import location -from .const import DEFAULT_NAME, DOMAIN +from .const import DEFAULT_ALIAS, DEFAULT_NAME, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -61,7 +61,7 @@ async def async_step_creds(self, user_input=None): if user_input is not None: try: self.creds = await self.hass.async_add_executor_job( - self.helper.get_creds) + self.helper.get_creds, DEFAULT_ALIAS) if self.creds is not None: return await self.async_step_mode() return self.async_abort(reason='credential_error') @@ -143,7 +143,8 @@ async def async_step_link(self, user_input=None): self.host = user_input[CONF_IP_ADDRESS] is_ready, is_login = await self.hass.async_add_executor_job( - self.helper.link, self.host, self.creds, self.pin) + self.helper.link, self.host, + self.creds, self.pin, DEFAULT_ALIAS) if is_ready is False: errors['base'] = 'not_ready' diff --git a/homeassistant/components/ps4/const.py b/homeassistant/components/ps4/const.py index bbf654530b0081..3c0dad6119fbc2 100644 --- a/homeassistant/components/ps4/const.py +++ b/homeassistant/components/ps4/const.py @@ -1,7 +1,9 @@ """Constants for PlayStation 4.""" DEFAULT_NAME = "PlayStation 4" DEFAULT_REGION = "United States" +DEFAULT_ALIAS = 'Home-Assistant' DOMAIN = 'ps4' +PS4_DATA = 'ps4_data' # Deprecated used for logger/backwards compatibility from 0.89 REGIONS = ['R1', 'R2', 'R3', 'R4', 'R5'] diff --git a/homeassistant/components/ps4/manifest.json b/homeassistant/components/ps4/manifest.json index 1cf613bf9b9464..aab01d0eda2bf7 100644 --- a/homeassistant/components/ps4/manifest.json +++ b/homeassistant/components/ps4/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/components/ps4", "requirements": [ - "pyps4-homeassistant==0.7.3" + "pyps4-homeassistant==0.8.3" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/ps4/media_player.py b/homeassistant/components/ps4/media_player.py index f5360f491dbc2d..f1d78564674d4e 100644 --- a/homeassistant/components/ps4/media_player.py +++ b/homeassistant/components/ps4/media_player.py @@ -1,29 +1,31 @@ """Support for PlayStation 4 consoles.""" import logging -import socket +import asyncio import voluptuous as vol +from homeassistant.core import callback from homeassistant.components.media_player import ( ENTITY_IMAGE_URL, MediaPlayerDevice) from homeassistant.components.media_player.const import ( MEDIA_TYPE_GAME, MEDIA_TYPE_APP, SUPPORT_SELECT_SOURCE, - SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON) + SUPPORT_PAUSE, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON) from homeassistant.components.ps4 import format_unique_id from homeassistant.const import ( ATTR_COMMAND, ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, CONF_REGION, CONF_TOKEN, STATE_IDLE, STATE_OFF, STATE_PLAYING) import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import device_registry, entity_registry from homeassistant.util.json import load_json, save_json -from .const import DOMAIN as PS4_DOMAIN, REGIONS as deprecated_regions +from .const import (DEFAULT_ALIAS, DOMAIN as PS4_DOMAIN, PS4_DATA, + REGIONS as deprecated_regions) _LOGGER = logging.getLogger(__name__) SUPPORT_PS4 = SUPPORT_TURN_OFF | SUPPORT_TURN_ON | \ - SUPPORT_STOP | SUPPORT_SELECT_SOURCE + SUPPORT_PAUSE | SUPPORT_STOP | SUPPORT_SELECT_SOURCE -PS4_DATA = 'ps4_data' ICON = 'mdi:playstation' GAMES_FILE = '.ps4-games.json' MEDIA_IMAGE_DEFAULT = None @@ -50,35 +52,29 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Set up PS4 from a config entry.""" config = config_entry - - def add_entities(entities, update_before_add=False): - """Sync version of async add devices.""" - hass.add_job(async_add_entities, entities, update_before_add) - - await hass.async_add_executor_job( - setup_platform, hass, config, - add_entities, None) + await async_setup_platform( + hass, config, async_add_entities, discovery_info=None) async def async_service_handle(hass): """Handle for services.""" - def service_command(call): + async def async_service_command(call): entity_ids = call.data[ATTR_ENTITY_ID] command = call.data[ATTR_COMMAND] for device in hass.data[PS4_DATA].devices: if device.entity_id in entity_ids: - device.send_command(command) + await device.async_send_command(command) hass.services.async_register( - PS4_DOMAIN, SERVICE_COMMAND, service_command, + PS4_DOMAIN, SERVICE_COMMAND, async_service_command, schema=PS4_COMMAND_SCHEMA) await async_service_handle(hass) -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): """Set up PS4 Platform.""" - import pyps4_homeassistant as pyps4 - hass.data[PS4_DATA] = PS4Data() + import pyps4_homeassistant.ps4 as pyps4 games_file = hass.config.path(GAMES_FILE) creds = config.data[CONF_TOKEN] device_list = [] @@ -86,25 +82,18 @@ def setup_platform(hass, config, add_entities, discovery_info=None): host = device[CONF_HOST] region = device[CONF_REGION] name = device[CONF_NAME] - ps4 = pyps4.Ps4(host, creds) + ps4 = pyps4.Ps4Async(host, creds, device_name=DEFAULT_ALIAS) device_list.append(PS4Device( - name, host, region, ps4, creds, games_file)) - add_entities(device_list, True) - - -class PS4Data(): - """Init Data Class.""" - - def __init__(self): - """Init Class.""" - self.devices = [] + config, name, host, region, ps4, creds, games_file)) + async_add_entities(device_list, update_before_add=True) class PS4Device(MediaPlayerDevice): """Representation of a PS4.""" - def __init__(self, name, host, region, ps4, creds, games_file): + def __init__(self, config, name, host, region, ps4, creds, games_file): """Initialize the ps4 device.""" + self._entry_id = config.entry_id self._ps4 = ps4 self._host = host self._name = name @@ -123,56 +112,93 @@ def __init__(self, name, host, region, ps4, creds, games_file): self._disconnected = False self._info = None self._unique_id = None - self._power_on = False + + @callback + def status_callback(self): + """Handle status callback. Parse status.""" + self._parse_status() + + @callback + def schedule_update(self): + """Schedules update with HA.""" + self.async_schedule_update_ha_state() + + @callback + def subscribe_to_protocol(self): + """Notify protocol to callback with update changes.""" + self.hass.data[PS4_DATA].protocol.add_callback( + self._ps4, self.status_callback) + + @callback + def unsubscribe_to_protocol(self): + """Notify protocol to remove callback.""" + self.hass.data[PS4_DATA].protocol.remove_callback( + self._ps4, self.status_callback) + + def check_region(self): + """Display logger msg if region is deprecated.""" + # Non-Breaking although data returned may be inaccurate. + if self._region in deprecated_regions: + _LOGGER.info("""Region: %s has been deprecated. + Please remove PS4 integration + and Re-configure again to utilize + current regions""", self._region) async def async_added_to_hass(self): """Subscribe PS4 events.""" self.hass.data[PS4_DATA].devices.append(self) + self.check_region() - def update(self): + async def async_update(self): """Retrieve the latest data.""" - try: - status = self._ps4.get_status() - if self._info is None: - # Add entity to registry - self.get_device_info(status) - self._games = self.load_games() - if self._games is not None: - self._source_list = list(sorted(self._games.values())) - # Non-Breaking although data returned may be inaccurate. - if self._region in deprecated_regions: - _LOGGER.info("""Region: %s has been deprecated. - Please remove PS4 integration - and Re-configure again to utilize - current regions""", self._region) - except socket.timeout: - status = None + if self._ps4.ddp_protocol is not None: + # Request Status with asyncio transport. + self._ps4.get_status() + if not self._ps4.connected and not self._ps4.is_standby: + await self._ps4.async_connect() + + # Try to ensure correct status is set on startup for device info. + if self._ps4.ddp_protocol is None: + # Use socket.socket. + await self.hass.async_add_executor_job(self._ps4.get_status) + self._ps4.ddp_protocol = self.hass.data[PS4_DATA].protocol + self.subscribe_to_protocol() + + if self._ps4.status is not None: + if self._info is None: + # Add entity to registry. + await self.async_get_device_info(self._ps4.status) + self._parse_status() + + def _parse_status(self): + """Parse status.""" + status = self._ps4.status + if status is not None: + self._games = self.load_games() + if self._games is not None: + self._source_list = list(sorted(self._games.values())) self._retry = 0 self._disconnected = False if status.get('status') == 'Ok': - # Check if only 1 device in Hass. - if len(self.hass.data[PS4_DATA].devices) == 1: - # Enable keep alive feature for PS4 Connection. - # Only 1 device is supported, Since have to use port 997. - self._ps4.keep_alive = True - else: - self._ps4.keep_alive = False - if self._power_on: - # Auto Login after Turn On. - self._ps4.open() - self._power_on = False title_id = status.get('running-app-titleid') name = status.get('running-app-name') if title_id and name is not None: self._state = STATE_PLAYING if self._media_content_id != title_id: self._media_content_id = title_id - self.get_title_data(title_id, name) + self._media_title = name + self._source = self._media_title + self._media_type = None + asyncio.ensure_future( + self.async_get_title_data(title_id, name)) else: - self.idle() + if self._state != STATE_IDLE: + self.idle() else: - self.state_off() + if self._state != STATE_OFF: + self.state_off() + elif self._retry > 5: self.state_unknown() else: @@ -182,11 +208,13 @@ def idle(self): """Set states for state idle.""" self.reset_title() self._state = STATE_IDLE + self.schedule_update() def state_off(self): """Set states for state off.""" self.reset_title() self._state = STATE_OFF + self.schedule_update() def state_unknown(self): """Set states for state unknown.""" @@ -201,32 +229,47 @@ def reset_title(self): """Update if there is no title.""" self._media_title = None self._media_content_id = None + self._media_type = None self._source = None - def get_title_data(self, title_id, name): + async def async_get_title_data(self, title_id, name): """Get PS Store Data.""" from pyps4_homeassistant.errors import PSDataIncomplete app_name = None art = None + media_type = None try: - title = self._ps4.get_ps_store_data( + title = await self._ps4.async_get_ps_store_data( name, title_id, self._region) + except PSDataIncomplete: - _LOGGER.error( - "Could not find data in region: %s for PS ID: %s", - self._region, title_id) + title = None + except asyncio.TimeoutError: + title = None + _LOGGER.error("PS Store Search Timed out") + else: - app_name = title.name - art = title.cover_art + if title is not None: + app_name = title.name + art = title.cover_art + # Assume media type is game if not app. + if title.game_type != 'App': + media_type = MEDIA_TYPE_GAME + else: + media_type = MEDIA_TYPE_APP + else: + _LOGGER.error( + "Could not find data in region: %s for PS ID: %s", + self._region, title_id) + finally: self._media_title = app_name or name self._source = self._media_title - self._media_image = art - if title.game_type == 'App': - self._media_type = MEDIA_TYPE_APP - else: - self._media_type = MEDIA_TYPE_GAME + self._media_image = art or None + self._media_type = media_type + self.update_list() + self.schedule_update() def update_list(self): """Update Game List, Correct data if different.""" @@ -234,9 +277,11 @@ def update_list(self): store = self._games[self._media_content_id] if store != self._media_title: self._games.pop(self._media_content_id) + if self._media_content_id not in self._games: self.add_games(self._media_content_id, self._media_title) self._games = self.load_games() + self._source_list = list(sorted(self._games.values())) def load_games(self): @@ -271,28 +316,50 @@ def add_games(self, title_id, app_name): games.update(game) self.save_games(games) - def get_device_info(self, status): + async def async_get_device_info(self, status): """Set device info for registry.""" - _sw_version = status['system-version'] - _sw_version = _sw_version[1:4] - sw_version = "{}.{}".format(_sw_version[0], _sw_version[1:]) - self._info = { - 'name': status['host-name'], - 'model': 'PlayStation 4', - 'identifiers': { - (PS4_DOMAIN, status['host-id']) - }, - 'manufacturer': 'Sony Interactive Entertainment Inc.', - 'sw_version': sw_version - } - - self._unique_id = format_unique_id(self._creds, status['host-id']) + # If cannot get status on startup, assume info from registry. + if status is None: + _LOGGER.info("Assuming status from registry") + e_registry = await entity_registry.async_get_registry(self.hass) + d_registry = await device_registry.async_get_registry(self.hass) + for entity_id, entry in e_registry.entities.items(): + if entry.config_entry_id == self._entry_id: + self._unique_id = entry.unique_id + self.entity_id = entity_id + break + for device in d_registry.devices.values(): + if self._entry_id in device.config_entries: + self._info = { + 'name': device.name, + 'model': device.model, + 'identifiers': device.identifiers, + 'manufacturer': device.manufacturer, + 'sw_version': device.sw_version + } + break + + else: + _sw_version = status['system-version'] + _sw_version = _sw_version[1:4] + sw_version = "{}.{}".format(_sw_version[0], _sw_version[1:]) + self._info = { + 'name': status['host-name'], + 'model': 'PlayStation 4', + 'identifiers': { + (PS4_DOMAIN, status['host-id']) + }, + 'manufacturer': 'Sony Interactive Entertainment Inc.', + 'sw_version': sw_version + } + + self._unique_id = format_unique_id(self._creds, status['host-id']) async def async_will_remove_from_hass(self): """Remove Entity from Hass.""" - # Close TCP Socket + # Close TCP Transport. if self._ps4.connected: - await self.hass.async_add_executor_job(self._ps4.close) + await self._ps4.close() self.hass.data[PS4_DATA].devices.remove(self) @property @@ -367,43 +434,44 @@ def source_list(self): """List of available input sources.""" return self._source_list - def turn_off(self): + async def async_turn_off(self): """Turn off media player.""" - self._ps4.standby() + await self._ps4.standby() - def turn_on(self): + async def async_turn_on(self): """Turn on the media player.""" - self._power_on = True self._ps4.wakeup() - def media_pause(self): + async def async_media_pause(self): """Send keypress ps to return to menu.""" - self.send_remote_control('ps') + await self.async_send_remote_control('ps') - def media_stop(self): + async def async_media_stop(self): """Send keypress ps to return to menu.""" - self.send_remote_control('ps') + await self.async_send_remote_control('ps') - def select_source(self, source): + async def async_select_source(self, source): """Select input source.""" for title_id, game in self._games.items(): if source.lower().encode(encoding='utf-8') == \ game.lower().encode(encoding='utf-8') \ or source == title_id: + _LOGGER.debug( "Starting PS4 game %s (%s) using source %s", game, title_id, source) - self._ps4.start_title( - title_id, running_id=self._media_content_id) + + await self._ps4.start_title(title_id, self._media_content_id) return + _LOGGER.warning( "Could not start title. '%s' is not in source list", source) return - def send_command(self, command): + async def async_send_command(self, command): """Send Button Command.""" - self.send_remote_control(command) + await self.async_send_remote_control(command) - def send_remote_control(self, command): + async def async_send_remote_control(self, command): """Send RC command.""" - self._ps4.remote_control(command) + await self._ps4.remote_control(command) diff --git a/homeassistant/components/qld_bushfire/__init__.py b/homeassistant/components/qld_bushfire/__init__.py new file mode 100644 index 00000000000000..893ae2ce7999d4 --- /dev/null +++ b/homeassistant/components/qld_bushfire/__init__.py @@ -0,0 +1 @@ +"""The qld_bushfire component.""" diff --git a/homeassistant/components/qld_bushfire/geo_location.py b/homeassistant/components/qld_bushfire/geo_location.py new file mode 100644 index 00000000000000..eff0f11019af2b --- /dev/null +++ b/homeassistant/components/qld_bushfire/geo_location.py @@ -0,0 +1,228 @@ +"""Support for Queensland Bushfire Alert Feeds.""" +from datetime import timedelta +import logging +from typing import Optional + +from georss_qld_bushfire_alert_client import QldBushfireAlertFeedManager +import voluptuous as vol + +from homeassistant.components.geo_location import ( + PLATFORM_SCHEMA, GeolocationEvent) +from homeassistant.const import ( + ATTR_ATTRIBUTION, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS, + CONF_SCAN_INTERVAL, 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_CATEGORY = 'category' +ATTR_EXTERNAL_ID = 'external_id' +ATTR_PUBLICATION_DATE = 'publication_date' +ATTR_STATUS = 'status' +ATTR_UPDATED_DATE = 'updated_date' + +CONF_CATEGORIES = 'categories' + +DEFAULT_RADIUS_IN_KM = 20.0 +DEFAULT_UNIT_OF_MEASUREMENT = 'km' + +SCAN_INTERVAL = timedelta(minutes=5) + +SIGNAL_DELETE_ENTITY = 'qld_bushfire_delete_{}' +SIGNAL_UPDATE_ENTITY = 'qld_bushfire_update_{}' + +SOURCE = 'qld_bushfire' + +VALID_CATEGORIES = [ + 'Emergency Warning', + 'Watch and Act', + 'Advice', + 'Notification', + 'Information', +] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + 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_CATEGORIES, default=[]): + vol.All(cv.ensure_list, [vol.In(VALID_CATEGORIES)]), +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Queensland Bushfire Alert Feed platform.""" + 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] + categories = config[CONF_CATEGORIES] + # Initialize the entity manager. + feed = QldBushfireFeedEntityManager( + hass, add_entities, scan_interval, coordinates, radius_in_km, + categories) + + def start_feed_manager(event): + """Start feed manager.""" + feed.startup() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_feed_manager) + + +class QldBushfireFeedEntityManager: + """Feed Entity Manager for Qld Bushfire Alert GeoRSS feed.""" + + def __init__(self, hass, add_entities, scan_interval, coordinates, + radius_in_km, categories): + """Initialize the Feed Entity Manager.""" + self._hass = hass + self._feed_manager = QldBushfireAlertFeedManager( + self._generate_entity, self._update_entity, self._remove_entity, + coordinates, filter_radius=radius_in_km, + filter_categories=categories) + 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 = QldBushfireLocationEvent(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 QldBushfireLocationEvent(GeolocationEvent): + """This represents an external event with Qld Bushfire feed 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._attribution = None + self._category = None + self._publication_date = None + self._updated_date = None + self._status = 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 Qld Bushfire Alert 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._name = feed_entry.title + 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._category = feed_entry.category + self._publication_date = feed_entry.published + self._updated_date = feed_entry.updated + self._status = feed_entry.status + + @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 = {} + for key, value in ( + (ATTR_EXTERNAL_ID, self._external_id), + (ATTR_CATEGORY, self._category), + (ATTR_ATTRIBUTION, self._attribution), + (ATTR_PUBLICATION_DATE, self._publication_date), + (ATTR_UPDATED_DATE, self._updated_date), + (ATTR_STATUS, self._status) + ): + if value or isinstance(value, bool): + attributes[key] = value + return attributes diff --git a/homeassistant/components/qld_bushfire/manifest.json b/homeassistant/components/qld_bushfire/manifest.json new file mode 100644 index 00000000000000..47a4a4b5f85ce1 --- /dev/null +++ b/homeassistant/components/qld_bushfire/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "qld_bushfire", + "name": "Queensland Bushfire Alert", + "documentation": "https://www.home-assistant.io/components/qld_bushfire", + "requirements": [ + "georss_qld_bushfire_alert_client==0.3" + ], + "dependencies": [], + "codeowners": [ + "@exxamalte" + ] +} \ No newline at end of file diff --git a/homeassistant/components/rainmachine/manifest.json b/homeassistant/components/rainmachine/manifest.json index b99798bb4b6cae..25b36c798c593d 100644 --- a/homeassistant/components/rainmachine/manifest.json +++ b/homeassistant/components/rainmachine/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/components/rainmachine", "requirements": [ - "regenmaschine==1.4.0" + "regenmaschine==1.5.1" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 528f6f4a8a3b43..bad6d5ca0f44eb 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -62,7 +62,7 @@ }) CONFIG_SCHEMA = vol.Schema({ - DOMAIN: FILTER_SCHEMA.extend({ + vol.Optional(DOMAIN, default=dict): FILTER_SCHEMA.extend({ vol.Optional(CONF_PURGE_KEEP_DAYS, default=10): vol.All(vol.Coerce(int), vol.Range(min=1)), vol.Optional(CONF_PURGE_INTERVAL, default=1): @@ -95,7 +95,7 @@ def run_information(hass, point_in_time: Optional[datetime] = None): async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the recorder.""" - conf = config.get(DOMAIN, {}) + conf = config[DOMAIN] keep_days = conf.get(CONF_PURGE_KEEP_DAYS) purge_interval = conf.get(CONF_PURGE_INTERVAL) diff --git a/homeassistant/components/remote/__init__.py b/homeassistant/components/remote/__init__.py index f08abf5fd4a4cd..568ea8ece325f9 100644 --- a/homeassistant/components/remote/__init__.py +++ b/homeassistant/components/remote/__init__.py @@ -24,6 +24,8 @@ ATTR_NUM_REPEATS = 'num_repeats' ATTR_DELAY_SECS = 'delay_secs' ATTR_HOLD_SECS = 'hold_secs' +ATTR_ALTERNATIVE = 'alternative' +ATTR_TIMEOUT = 'timeout' DOMAIN = 'remote' SCAN_INTERVAL = timedelta(seconds=30) @@ -36,12 +38,15 @@ MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) SERVICE_SEND_COMMAND = 'send_command' +SERVICE_LEARN_COMMAND = 'learn_command' SERVICE_SYNC = 'sync' DEFAULT_NUM_REPEATS = 1 DEFAULT_DELAY_SECS = 0.4 DEFAULT_HOLD_SECS = 0 +SUPPORT_LEARN_COMMAND = 1 + REMOTE_SERVICE_SCHEMA = vol.Schema({ vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids, }) @@ -59,6 +64,13 @@ vol.Optional(ATTR_HOLD_SECS, default=DEFAULT_HOLD_SECS): vol.Coerce(float), }) +REMOTE_SERVICE_LEARN_COMMAND_SCHEMA = REMOTE_SERVICE_SCHEMA.extend({ + vol.Optional(ATTR_DEVICE): cv.string, + vol.Optional(ATTR_COMMAND): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(ATTR_ALTERNATIVE): cv.boolean, + vol.Optional(ATTR_TIMEOUT): cv.positive_int +}) + @bind_hass def is_on(hass, entity_id=None): @@ -93,12 +105,22 @@ async def async_setup(hass, config): 'async_send_command' ) + component.async_register_entity_service( + SERVICE_LEARN_COMMAND, REMOTE_SERVICE_LEARN_COMMAND_SCHEMA, + 'async_learn_command' + ) + return True class RemoteDevice(ToggleEntity): """Representation of a remote.""" + @property + def supported_features(self): + """Flag supported features.""" + return 0 + def send_command(self, command, **kwargs): """Send a command to a device.""" raise NotImplementedError() @@ -108,5 +130,17 @@ def async_send_command(self, command, **kwargs): This method must be run in the event loop and returns a coroutine. """ - return self.hass.async_add_job(ft.partial( - self.send_command, command, **kwargs)) + return self.hass.async_add_executor_job( + ft.partial(self.send_command, command, **kwargs)) + + def learn_command(self, **kwargs): + """Learn a command from a device.""" + raise NotImplementedError() + + def async_learn_command(self, **kwargs): + """Learn a command from a device. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_executor_job( + ft.partial(self.learn_command, **kwargs)) diff --git a/homeassistant/components/remote/services.yaml b/homeassistant/components/remote/services.yaml index 62615f28714c76..a551ba18ed4587 100644 --- a/homeassistant/components/remote/services.yaml +++ b/homeassistant/components/remote/services.yaml @@ -25,7 +25,7 @@ turn_off: example: 'remote.family_room' send_command: - description: Sends a single command to a single device. + description: Sends a command or a list of commands to a device. fields: entity_id: description: Name(s) of entities to send command from. @@ -46,6 +46,25 @@ send_command: description: An optional value that specifies that number of seconds you want to have it held before the release is send. If not specified, the release will be send immediately after the press. example: '2.5' +learn_command: + description: Learns a command or a list of commands from a device. + fields: + entity_id: + description: Name(s) of entities to learn command from. + example: 'remote.bedroom' + device: + description: Device ID to learn command from. + example: 'television' + command: + description: A single command or a list of commands to learn. + example: 'Turn on' + alternative: + description: If code must be stored as alternative (useful for discrete remotes). + example: 'True' + timeout: + description: Timeout, in seconds, for the command to be learned. + example: '30' + harmony_sync: description: Syncs the remote's configuration. diff --git a/homeassistant/components/rflink/manifest.json b/homeassistant/components/rflink/manifest.json index a3b81f39c55f71..bbdb49ad401240 100644 --- a/homeassistant/components/rflink/manifest.json +++ b/homeassistant/components/rflink/manifest.json @@ -3,7 +3,7 @@ "name": "Rflink", "documentation": "https://www.home-assistant.io/components/rflink", "requirements": [ - "rflink==0.0.37" + "rflink==0.0.46" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/rtorrent/sensor.py b/homeassistant/components/rtorrent/sensor.py index 8ec6a45b639ca5..d7912e0d6be874 100644 --- a/homeassistant/components/rtorrent/sensor.py +++ b/homeassistant/components/rtorrent/sensor.py @@ -99,8 +99,9 @@ def update(self): try: self.data = multicall() self._available = True - except (xmlrpc.client.ProtocolError, ConnectionRefusedError): - _LOGGER.error("Connection to rtorrent lost") + except (xmlrpc.client.ProtocolError, + ConnectionRefusedError, OSError) as ex: + _LOGGER.error("Connection to rtorrent failed (%s)", ex) self._available = False return diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index 05921d7e84b07f..6f928e830dc3d6 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -10,8 +10,9 @@ MediaPlayerDevice, PLATFORM_SCHEMA) 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, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP) + SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, + SUPPORT_SELECT_SOURCE, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, + SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP) from homeassistant.const import ( CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT, CONF_TIMEOUT, STATE_OFF, STATE_ON) @@ -26,9 +27,13 @@ KEY_PRESS_TIMEOUT = 1.2 KNOWN_DEVICES_KEY = 'samsungtv_known_devices' +SOURCES = { + 'TV': 'KEY_DTV', + 'HDMI': 'KEY_HDMI' +} SUPPORT_SAMSUNGTV = SUPPORT_PAUSE | SUPPORT_VOLUME_STEP | \ - SUPPORT_VOLUME_MUTE | SUPPORT_PREVIOUS_TRACK | \ + SUPPORT_VOLUME_MUTE | SUPPORT_PREVIOUS_TRACK | SUPPORT_SELECT_SOURCE | \ SUPPORT_NEXT_TRACK | SUPPORT_TURN_OFF | SUPPORT_PLAY | SUPPORT_PLAY_MEDIA PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -115,7 +120,7 @@ def __init__(self, host, port, name, timeout, mac, uuid): 'timeout': timeout, } - if self._config['port'] == 8001: + if self._config['port'] in (8001, 8002): self._config['method'] = 'websocket' else: self._config['method'] = 'legacy' @@ -187,6 +192,11 @@ def is_volume_muted(self): """Boolean if volume is currently muted.""" return self._muted + @property + def source_list(self): + """List of available input sources.""" + return list(SOURCES) + @property def supported_features(self): """Flag media player features that are supported.""" @@ -262,6 +272,7 @@ async def async_play_media(self, media_type, media_id, **kwargs): for digit in media_id: await self.hass.async_add_job(self.send_key, 'KEY_' + digit) await asyncio.sleep(KEY_PRESS_TIMEOUT, self.hass.loop) + await self.hass.async_add_job(self.send_key, 'KEY_ENTER') def turn_on(self): """Turn the media player on.""" @@ -269,3 +280,11 @@ def turn_on(self): self._wol.send_magic_packet(self._mac) else: self.send_key('KEY_POWERON') + + async def async_select_source(self, source): + """Select input source.""" + if source not in SOURCES: + _LOGGER.error('Unsupported source') + return + + await self.hass.async_add_job(self.send_key, SOURCES[source]) diff --git a/homeassistant/components/sense/__init__.py b/homeassistant/components/sense/__init__.py index 7266b2fb1e5ab2..85d5cd90e08c44 100644 --- a/homeassistant/components/sense/__init__.py +++ b/homeassistant/components/sense/__init__.py @@ -1,11 +1,14 @@ """Support for monitoring a Sense energy sensor.""" import logging +from datetime import timedelta import voluptuous as vol from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_TIMEOUT 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.event import async_track_time_interval _LOGGER = logging.getLogger(__name__) @@ -15,6 +18,7 @@ DOMAIN = 'sense' SENSE_DATA = 'sense_data' +SENSE_DEVICE_UPDATE = 'sense_devices_update' CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ @@ -27,7 +31,9 @@ async def async_setup(hass, config): """Set up the Sense sensor.""" - from sense_energy import ASyncSenseable, SenseAuthenticationException + from sense_energy import ( + ASyncSenseable, SenseAuthenticationException, + SenseAPITimeoutException) username = config[DOMAIN][CONF_EMAIL] password = config[DOMAIN][CONF_PASSWORD] @@ -45,4 +51,15 @@ async def async_setup(hass, config): async_load_platform(hass, 'sensor', DOMAIN, {}, config)) hass.async_create_task( async_load_platform(hass, 'binary_sensor', DOMAIN, {}, config)) + + async def async_sense_update(now): + """Retrieve latest state.""" + try: + await hass.data[SENSE_DATA].update_realtime() + async_dispatcher_send(hass, SENSE_DEVICE_UPDATE) + except SenseAPITimeoutException: + _LOGGER.error("Timeout retrieving data") + + async_track_time_interval(hass, async_sense_update, + timedelta(seconds=ACTIVE_UPDATE_RATE)) return True diff --git a/homeassistant/components/sense/binary_sensor.py b/homeassistant/components/sense/binary_sensor.py index a0f65ac555a585..43a2dc79a89b2b 100644 --- a/homeassistant/components/sense/binary_sensor.py +++ b/homeassistant/components/sense/binary_sensor.py @@ -2,8 +2,10 @@ import logging from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.core import callback -from . import SENSE_DATA +from . import SENSE_DATA, SENSE_DEVICE_UPDATE _LOGGER = logging.getLogger(__name__) @@ -75,12 +77,12 @@ def __init__(self, data, device): self._id = device['id'] self._icon = sense_to_mdi(device['icon']) self._data = data - self._state = False + self._undo_dispatch_subscription = None @property def is_on(self): """Return true if the binary sensor is on.""" - return self._state + return self._name in self._data.active_devices @property def name(self): @@ -102,12 +104,22 @@ def device_class(self): """Return the device class of the binary sensor.""" return BIN_SENSOR_CLASS - async def async_update(self): - """Retrieve latest state.""" - from sense_energy.sense_api import SenseAPITimeoutException - try: - await self._data.update_realtime() - except SenseAPITimeoutException: - _LOGGER.error("Timeout retrieving data") - return - self._state = self._name in self._data.active_devices + @property + def should_poll(self): + """Return the deviceshould not poll for updates.""" + return False + + async def async_added_to_hass(self): + """Register callbacks.""" + @callback + def update(): + """Update the state.""" + self.async_schedule_update_ha_state(True) + + self._undo_dispatch_subscription = async_dispatcher_connect( + self.hass, SENSE_DEVICE_UPDATE, update) + + async def async_will_remove_from_hass(self): + """Undo subscription.""" + if self._undo_dispatch_subscription: + self._undo_dispatch_subscription() diff --git a/homeassistant/components/sense/manifest.json b/homeassistant/components/sense/manifest.json index 272a4a58f33835..8763234c5ed64d 100644 --- a/homeassistant/components/sense/manifest.json +++ b/homeassistant/components/sense/manifest.json @@ -6,5 +6,5 @@ "sense_energy==0.7.0" ], "dependencies": [], - "codeowners": [] + "codeowners": ["@kbickar"] } diff --git a/homeassistant/components/simplisafe/manifest.json b/homeassistant/components/simplisafe/manifest.json index b6bb1285daac75..ac94f5801195fc 100644 --- a/homeassistant/components/simplisafe/manifest.json +++ b/homeassistant/components/simplisafe/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/components/simplisafe", "requirements": [ - "simplisafe-python==3.4.1" + "simplisafe-python==3.4.2" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/sisyphus/__init__.py b/homeassistant/components/sisyphus/__init__.py index 7cc8e3efd33a53..bfe1cb7bebb2a5 100644 --- a/homeassistant/components/sisyphus/__init__.py +++ b/homeassistant/components/sisyphus/__init__.py @@ -35,26 +35,31 @@ async def async_setup(hass, config): """Set up the sisyphus component.""" from sisyphus_control import Table + + class SocketIONoiseFilter(logging.Filter): + """Filters out excessively verbose logs from SocketIO.""" + + def filter(self, record): + if record.msg.contains('waiting for connection'): + return False + return True + + logging.getLogger('socketIO-client').addFilter(SocketIONoiseFilter()) tables = hass.data.setdefault(DATA_SISYPHUS, {}) table_configs = config.get(DOMAIN) session = async_get_clientsession(hass) async def add_table(host, name=None): """Add platforms for a single table with the given hostname.""" - table = await Table.connect(host, session) - if name is None: - name = table.name - tables[name] = table - _LOGGER.debug("Connected to %s at %s", name, host) + tables[host] = TableHolder(hass, session, host, name) hass.async_create_task(async_load_platform( hass, 'light', DOMAIN, { - CONF_NAME: name, + CONF_HOST: host, }, config )) hass.async_create_task(async_load_platform( hass, 'media_player', DOMAIN, { - CONF_NAME: name, CONF_HOST: host, }, config )) @@ -75,3 +80,51 @@ async def close_tables(*args): hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, close_tables) return True + + +class TableHolder: + """Holds table objects and makes them available to platforms.""" + + def __init__(self, hass, session, host, name): + """Initialize the table holder.""" + self._hass = hass + self._session = session + self._host = host + self._name = name + self._table = None + self._table_task = None + + @property + def available(self): + """Return true if the table is responding to heartbeats.""" + if self._table_task and self._table_task.done(): + return self._table_task.result().is_connected + return False + + @property + def name(self): + """Return the name of the table.""" + return self._name + + async def get_table(self): + """Return the Table held by this holder, connecting to it if needed.""" + if not self._table_task: + self._table_task = self._hass.async_create_task( + self._connect_table()) + + return await self._table_task + + async def _connect_table(self): + from sisyphus_control import Table + self._table = await Table.connect(self._host, self._session) + if self._name is None: + self._name = self._table.name + _LOGGER.debug("Connected to %s at %s", self._name, self._host) + return self._table + + async def close(self): + """Close the table held by this holder, if any.""" + if self._table: + await self._table.close() + self._table = None + self._table_task = None diff --git a/homeassistant/components/sisyphus/light.py b/homeassistant/components/sisyphus/light.py index 8d882925796ad6..9ad36df6118e55 100644 --- a/homeassistant/components/sisyphus/light.py +++ b/homeassistant/components/sisyphus/light.py @@ -1,8 +1,11 @@ """Support for the light on the Sisyphus Kinetic Art Table.""" import logging +import aiohttp + from homeassistant.components.light import SUPPORT_BRIGHTNESS, Light -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_HOST +from homeassistant.exceptions import PlatformNotReady from . import DATA_SISYPHUS @@ -11,11 +14,18 @@ SUPPORTED_FEATURES = SUPPORT_BRIGHTNESS -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform(hass, config, add_entities, + discovery_info=None): """Set up a single Sisyphus table.""" - name = discovery_info[CONF_NAME] + host = discovery_info[CONF_HOST] + try: + table_holder = hass.data[DATA_SISYPHUS][host] + table = await table_holder.get_table() + except aiohttp.ClientError: + raise PlatformNotReady() + add_entities( - [SisyphusLight(name, hass.data[DATA_SISYPHUS][name])], + [SisyphusLight(table_holder.name, table)], update_before_add=True) @@ -32,6 +42,16 @@ async def async_added_to_hass(self): self._table.add_listener( lambda: self.async_schedule_update_ha_state(False)) + @property + def available(self): + """Return true if the table is responding to heartbeats.""" + return self._table.is_connected + + @property + def unique_id(self): + """Return the UUID of the table.""" + return self._table.id + @property def name(self): """Return the ame of the table.""" diff --git a/homeassistant/components/sisyphus/media_player.py b/homeassistant/components/sisyphus/media_player.py index 65f5cb48e59b78..46ff00283b4052 100644 --- a/homeassistant/components/sisyphus/media_player.py +++ b/homeassistant/components/sisyphus/media_player.py @@ -1,13 +1,16 @@ """Support for track controls on the Sisyphus Kinetic Art Table.""" import logging +import aiohttp + from homeassistant.components.media_player import MediaPlayerDevice from homeassistant.components.media_player.const import ( SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_PREVIOUS_TRACK, SUPPORT_SHUFFLE_SET, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET) from homeassistant.const import ( - CONF_HOST, CONF_NAME, STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING) + CONF_HOST, STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING) +from homeassistant.exceptions import PlatformNotReady from . import DATA_SISYPHUS @@ -27,12 +30,18 @@ # pylint: disable=unused-argument -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform(hass, config, add_entities, + discovery_info=None): """Set up a media player entity for a Sisyphus table.""" - name = discovery_info[CONF_NAME] host = discovery_info[CONF_HOST] + try: + table_holder = hass.data[DATA_SISYPHUS][host] + table = await table_holder.get_table() + except aiohttp.ClientError: + raise PlatformNotReady() + add_entities( - [SisyphusPlayer(name, host, hass.data[DATA_SISYPHUS][name])], True) + [SisyphusPlayer(table_holder.name, host, table)], True) class SisyphusPlayer(MediaPlayerDevice): @@ -49,6 +58,16 @@ async def async_added_to_hass(self): self._table.add_listener( lambda: self.async_schedule_update_ha_state(False)) + @property + def unique_id(self): + """Return the UUID of the table.""" + return self._table.id + + @property + def available(self): + """Return true if the table is responding to heartbeats.""" + return self._table.is_connected + @property def name(self): """Return the name of the table.""" diff --git a/homeassistant/components/sma/sensor.py b/homeassistant/components/sma/sensor.py index 9f33e2361868ca..0ef2926eb2d6af 100644 --- a/homeassistant/components/sma/sensor.py +++ b/homeassistant/components/sma/sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_SSL, CONF_VERIFY_SSL, - EVENT_HOMEASSISTANT_STOP) + EVENT_HOMEASSISTANT_STOP, CONF_PATH) from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -52,6 +52,7 @@ def _check_sensor_schema(conf): vol.All(cv.string, vol.Length(min=13, max=15)), vol.Required(CONF_UNIT): cv.string, vol.Optional(CONF_FACTOR, default=1): vol.Coerce(float), + vol.Optional(CONF_PATH): vol.All(cv.ensure_list, [str]), }) PLATFORM_SCHEMA = vol.All(PLATFORM_SCHEMA.extend({ @@ -79,7 +80,8 @@ async def async_setup_platform( sensor_def = pysma.Sensors() # Sensor from the custom config - sensor_def.add([pysma.Sensor(o[CONF_KEY], n, o[CONF_UNIT], o[CONF_FACTOR]) + sensor_def.add([pysma.Sensor(o[CONF_KEY], n, o[CONF_UNIT], o[CONF_FACTOR], + o.get(CONF_PATH)) for n, o in config[CONF_CUSTOM].items()]) # Use all sensors by default diff --git a/homeassistant/components/smartthings/.translations/pt-BR.json b/homeassistant/components/smartthings/.translations/pt-BR.json new file mode 100644 index 00000000000000..eee67c4e16fbde --- /dev/null +++ b/homeassistant/components/smartthings/.translations/pt-BR.json @@ -0,0 +1,10 @@ +{ + "config": { + "step": { + "wait_install": { + "title": "Instalar o SmartApp" + } + }, + "title": "SmartThings" + } +} \ No newline at end of file diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 75b113354ff74e..621da91f4f8c5f 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -5,7 +5,7 @@ "documentation": "https://www.home-assistant.io/components/smartthings", "requirements": [ "pysmartapp==0.3.2", - "pysmartthings==0.6.8" + "pysmartthings==0.6.9" ], "dependencies": [ "webhook" diff --git a/homeassistant/components/smartthings/smartapp.py b/homeassistant/components/smartthings/smartapp.py index 9aa44d26f2dd7e..68999914d71cce 100644 --- a/homeassistant/components/smartthings/smartapp.py +++ b/homeassistant/components/smartthings/smartapp.py @@ -282,9 +282,9 @@ async def create_subscription(target: str): await api.create_subscription(sub) _LOGGER.debug("Created subscription for '%s' under app '%s'", target, installed_app_id) - except Exception: # pylint:disable=broad-except - _LOGGER.exception("Failed to create subscription for '%s' under " - "app '%s'", target, installed_app_id) + except Exception as error: # pylint:disable=broad-except + _LOGGER.error("Failed to create subscription for '%s' under app " + "'%s': %s", target, installed_app_id, error) async def delete_subscription(sub: SubscriptionEntity): try: @@ -293,9 +293,9 @@ async def delete_subscription(sub: SubscriptionEntity): _LOGGER.debug("Removed subscription for '%s' under app '%s' " "because it was no longer needed", sub.capability, installed_app_id) - except Exception: # pylint:disable=broad-except - _LOGGER.exception("Failed to remove subscription for '%s' under " - "app '%s'", sub.capability, installed_app_id) + except Exception as error: # pylint:disable=broad-except + _LOGGER.error("Failed to remove subscription for '%s' under app " + "'%s': %s", sub.capability, installed_app_id, error) # Build set of capabilities and prune unsupported ones capabilities = set() diff --git a/homeassistant/components/smarty/__init__.py b/homeassistant/components/smarty/__init__.py new file mode 100644 index 00000000000000..d66c06de3eb31a --- /dev/null +++ b/homeassistant/components/smarty/__init__.py @@ -0,0 +1,71 @@ +"""Support to control a Salda Smarty XP/XV ventilation unit.""" + +from datetime import timedelta + +import ipaddress +import logging +import voluptuous as vol + +from homeassistant.const import (CONF_NAME, CONF_HOST) +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import dispatcher_send +from homeassistant.helpers.event import track_time_interval + +DOMAIN = 'smarty' +DATA_SMARTY = 'smarty' +SMARTY_NAME = 'Smarty' + +_LOGGER = logging.getLogger(__name__) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema({ + vol.Required(CONF_HOST): vol.All(ipaddress.ip_address, cv.string), + vol.Optional(CONF_NAME, default=SMARTY_NAME): cv.string + }), + }, + extra=vol.ALLOW_EXTRA) + +RPM = 'rpm' +SIGNAL_UPDATE_SMARTY = 'smarty_update' + + +def setup(hass, config): + """Set up the smarty environment.""" + from pysmarty import (Smarty) + conf = config[DOMAIN] + + host = conf[CONF_HOST] + name = conf[CONF_NAME] + + _LOGGER.debug("Name: %s, host: %s", name, host) + + smarty = Smarty(host=host) + + hass.data[DOMAIN] = { + 'api': smarty, + 'name': name + } + + # Initial update + smarty.update() + + # Load platforms + discovery.load_platform(hass, 'fan', DOMAIN, {}, config) + discovery.load_platform(hass, 'sensor', DOMAIN, {}, config) + discovery.load_platform(hass, 'binary_sensor', DOMAIN, {}, config) + + def poll_device_update(event_time): + """Update Smarty device.""" + _LOGGER.debug("Updating Smarty device...") + if smarty.update(): + _LOGGER.debug("Update success...") + dispatcher_send(hass, SIGNAL_UPDATE_SMARTY) + else: + _LOGGER.debug("Update failed...") + + track_time_interval(hass, poll_device_update, + timedelta(seconds=30)) + + return True diff --git a/homeassistant/components/smarty/binary_sensor.py b/homeassistant/components/smarty/binary_sensor.py new file mode 100644 index 00000000000000..a17e8fa85dc7dd --- /dev/null +++ b/homeassistant/components/smarty/binary_sensor.py @@ -0,0 +1,110 @@ +"""Support for Salda Smarty XP/XV Ventilation Unit Binary Sensors.""" + +import logging + +from homeassistant.core import callback +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from . import (DOMAIN, SIGNAL_UPDATE_SMARTY) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the Smarty Binary Sensor Platform.""" + smarty = hass.data[DOMAIN]['api'] + name = hass.data[DOMAIN]['name'] + + sensors = [AlarmSensor(name, smarty), + WarningSensor(name, smarty), + BoostSensor(name, smarty)] + + async_add_entities(sensors, True) + + +class SmartyBinarySensor(BinarySensorDevice): + """Representation of a Smarty Binary Sensor.""" + + def __init__(self, name, device_class, smarty): + """Initialize the entity.""" + self._name = name + self._state = None + self._sensor_type = device_class + self._smarty = smarty + + @property + def device_class(self): + """Return the class of the sensor.""" + return self._sensor_type + + @property + def should_poll(self) -> bool: + """Do not poll.""" + return False + + @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 + + async def async_added_to_hass(self): + """Call to update.""" + async_dispatcher_connect(self.hass, + SIGNAL_UPDATE_SMARTY, + self._update_callback) + + @callback + def _update_callback(self): + """Call update method.""" + self.async_schedule_update_ha_state(True) + + +class BoostSensor(SmartyBinarySensor): + """Boost State Binary Sensor.""" + + def __init__(self, name, smarty): + """Alarm Sensor Init.""" + super().__init__(name='{} Boost State'.format(name), + device_class=None, + smarty=smarty) + + def update(self) -> None: + """Update state.""" + _LOGGER.debug('Updating sensor %s', self._name) + self._state = self._smarty.boost + + +class AlarmSensor(SmartyBinarySensor): + """Alarm Binary Sensor.""" + + def __init__(self, name, smarty): + """Alarm Sensor Init.""" + super().__init__(name='{} Alarm'.format(name), + device_class='problem', + smarty=smarty) + + def update(self) -> None: + """Update state.""" + _LOGGER.debug('Updating sensor %s', self._name) + self._state = self._smarty.alarm + + +class WarningSensor(SmartyBinarySensor): + """Warning Sensor.""" + + def __init__(self, name, smarty): + """Warning Sensor Init.""" + super().__init__(name='{} Warning'.format(name), + device_class='problem', + smarty=smarty) + + def update(self) -> None: + """Update state.""" + _LOGGER.debug('Updating sensor %s', self._name) + self._state = self._smarty.warning diff --git a/homeassistant/components/smarty/fan.py b/homeassistant/components/smarty/fan.py new file mode 100644 index 00000000000000..64a1e89ea889af --- /dev/null +++ b/homeassistant/components/smarty/fan.py @@ -0,0 +1,121 @@ +"""Platform to control a Salda Smarty XP/XV ventilation unit.""" + +import logging + +from homeassistant.core import callback +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_UPDATE_SMARTY) + +_LOGGER = logging.getLogger(__name__) + +SPEED_LIST = [SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] + +SPEED_MAPPING = { + 1: SPEED_LOW, + 2: SPEED_MEDIUM, + 3: SPEED_HIGH +} +SPEED_TO_MODE = {v: k for k, v in SPEED_MAPPING.items()} + + +async def async_setup_platform(hass, config, + async_add_entities, discovery_info=None): + """Set up the Smarty Fan Platform.""" + smarty = hass.data[DOMAIN]['api'] + name = hass.data[DOMAIN]['name'] + + async_add_entities([SmartyFan(name, smarty)], True) + + +class SmartyFan(FanEntity): + """Representation of a Smarty Fan.""" + + def __init__(self, name, smarty): + """Initialize the entity.""" + self._name = name + self._speed = SPEED_OFF + self._state = None + self._smarty = smarty + + @property + def should_poll(self): + """Do not poll.""" + return False + + @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): + """Return the list of supported features.""" + return SUPPORT_SET_SPEED + + @property + def speed_list(self): + """List of available fan modes.""" + return SPEED_LIST + + @property + def is_on(self): + """Return state of the fan.""" + return self._state + + @property + def speed(self) -> str: + """Return speed of the fan.""" + return self._speed + + def turn_on(self, speed=None, **kwargs): + """Turn on the fan.""" + _LOGGER.debug('Turning on fan. Speed is %s', speed) + if speed is None: + if self._smarty.turn_on(SPEED_TO_MODE.get(self._speed)): + self._state = True + self._speed = SPEED_MEDIUM + else: + if self._smarty.set_fan_speed(SPEED_TO_MODE.get(speed)): + self._speed = speed + self._state = True + + self.schedule_update_ha_state() + + def turn_off(self, **kwargs): + """Turn off the fan.""" + _LOGGER.debug('Turning off fan') + if self._smarty.turn_off(): + self._state = False + + self.schedule_update_ha_state() + + async def async_added_to_hass(self): + """Call to update fan.""" + async_dispatcher_connect(self.hass, + SIGNAL_UPDATE_SMARTY, + self._update_callback) + + @callback + def _update_callback(self): + """Call update method.""" + self.async_schedule_update_ha_state(True) + + def update(self): + """Update state.""" + _LOGGER.debug('Updating state') + result = self._smarty.fan_speed + if result: + self._speed = SPEED_MAPPING[result] + _LOGGER.debug('Speed is %s, Mode is %s', self._speed, result) + self._state = True + else: + self._state = False diff --git a/homeassistant/components/smarty/manifest.json b/homeassistant/components/smarty/manifest.json new file mode 100644 index 00000000000000..b2e3deb4008c5f --- /dev/null +++ b/homeassistant/components/smarty/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "smarty", + "name": "smarty", + "documentation": "https://www.home-assistant.io/components/smarty", + "requirements": [ + "pysmarty==0.8" + ], + "dependencies": [], + "codeowners": [ + "@z0mbieprocess" + ] +} + diff --git a/homeassistant/components/smarty/sensor.py b/homeassistant/components/smarty/sensor.py new file mode 100644 index 00000000000000..5b33c9393b905e --- /dev/null +++ b/homeassistant/components/smarty/sensor.py @@ -0,0 +1,180 @@ +"""Support for Salda Smarty XP/XV Ventilation Unit Sensors.""" + +import datetime as dt +import logging + +from homeassistant.core import callback +from homeassistant.const import ( + TEMP_CELSIUS, DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_TIMESTAMP) +import homeassistant.util.dt as dt_util +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity +from . import (DOMAIN, SIGNAL_UPDATE_SMARTY) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the Smarty Sensor Platform.""" + smarty = hass.data[DOMAIN]['api'] + name = hass.data[DOMAIN]['name'] + + sensors = [SupplyAirTemperatureSensor(name, smarty), + ExtractAirTemperatureSensor(name, smarty), + OutdoorAirTemperatureSensor(name, smarty), + SupplyFanSpeedSensor(name, smarty), + ExtractFanSpeedSensor(name, smarty), + FilterDaysLeftSensor(name, smarty)] + + async_add_entities(sensors, True) + + +class SmartySensor(Entity): + """Representation of a Smarty Sensor.""" + + def __init__(self, name: str, device_class: str, + smarty, unit_of_measurement: str = ''): + """Initialize the entity.""" + self._name = name + self._state = None + self._sensor_type = device_class + self._unit_of_measurement = unit_of_measurement + self._smarty = smarty + + @property + def should_poll(self) -> bool: + """Do not poll.""" + return False + + @property + def device_class(self): + """Return the device class of the sensor.""" + return self._sensor_type + + @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 + + async def async_added_to_hass(self): + """Call to update.""" + async_dispatcher_connect(self.hass, + SIGNAL_UPDATE_SMARTY, + self._update_callback) + + @callback + def _update_callback(self): + """Call update method.""" + self.async_schedule_update_ha_state(True) + + +class SupplyAirTemperatureSensor(SmartySensor): + """Supply Air Temperature Sensor.""" + + def __init__(self, name, smarty): + """Supply Air Temperature Init.""" + super().__init__(name='{} Supply Air Temperature'.format(name), + device_class=DEVICE_CLASS_TEMPERATURE, + unit_of_measurement=TEMP_CELSIUS, + smarty=smarty) + + def update(self) -> None: + """Update state.""" + _LOGGER.debug('Updating sensor %s', self._name) + self._state = self._smarty.supply_air_temperature + + +class ExtractAirTemperatureSensor(SmartySensor): + """Extract Air Temperature Sensor.""" + + def __init__(self, name, smarty): + """Supply Air Temperature Init.""" + super().__init__(name='{} Extract Air Temperature'.format(name), + device_class=DEVICE_CLASS_TEMPERATURE, + unit_of_measurement=TEMP_CELSIUS, + smarty=smarty) + + def update(self) -> None: + """Update state.""" + _LOGGER.debug('Updating sensor %s', self._name) + self._state = self._smarty.extract_air_temperature + + +class OutdoorAirTemperatureSensor(SmartySensor): + """Extract Air Temperature Sensor.""" + + def __init__(self, name, smarty): + """Outdoor Air Temperature Init.""" + super().__init__(name='{} Outdoor Air Temperature'.format(name), + device_class=DEVICE_CLASS_TEMPERATURE, + unit_of_measurement=TEMP_CELSIUS, + smarty=smarty) + + def update(self) -> None: + """Update state.""" + _LOGGER.debug('Updating sensor %s', self._name) + self._state = self._smarty.outdoor_air_temperature + + +class SupplyFanSpeedSensor(SmartySensor): + """Supply Fan Speed RPM.""" + + def __init__(self, name, smarty): + """Supply Fan Speed RPM Init.""" + super().__init__(name='{} Supply Fan Speed'.format(name), + device_class=None, + unit_of_measurement=None, + smarty=smarty) + + def update(self) -> None: + """Update state.""" + _LOGGER.debug('Updating sensor %s', self._name) + self._state = self._smarty.supply_fan_speed + + +class ExtractFanSpeedSensor(SmartySensor): + """Extract Fan Speed RPM.""" + + def __init__(self, name, smarty): + """Extract Fan Speed RPM Init.""" + super().__init__(name='{} Extract Fan Speed'.format(name), + device_class=None, + unit_of_measurement=None, + smarty=smarty) + + def update(self) -> None: + """Update state.""" + _LOGGER.debug('Updating sensor %s', self._name) + self._state = self._smarty.extract_fan_speed + + +class FilterDaysLeftSensor(SmartySensor): + """Filter Days Left.""" + + def __init__(self, name, smarty): + """Filter Days Left Init.""" + super().__init__(name='{} Filter Days Left'.format(name), + device_class=DEVICE_CLASS_TIMESTAMP, + unit_of_measurement=None, + smarty=smarty) + self._days_left = 91 + + def update(self) -> None: + """Update state.""" + _LOGGER.debug('Updating sensor %s', self._name) + days_left = self._smarty.filter_timer + if days_left is not None and days_left != self._days_left: + self._state = dt_util.now() + dt.timedelta(days=days_left) + self._days_left = days_left diff --git a/homeassistant/components/smhi/.translations/it.json b/homeassistant/components/smhi/.translations/it.json index b8c228f7e9eb1a..1c886e4f20eb80 100644 --- a/homeassistant/components/smhi/.translations/it.json +++ b/homeassistant/components/smhi/.translations/it.json @@ -8,7 +8,7 @@ "user": { "data": { "latitude": "Latitudine", - "longitude": "Logitudine", + "longitude": "Longitudine", "name": "Nome" }, "title": "Localit\u00e0 in Svezia" diff --git a/homeassistant/components/smhi/.translations/ru.json b/homeassistant/components/smhi/.translations/ru.json index 3496d19f5f4c55..88ea988ff1bb9a 100644 --- a/homeassistant/components/smhi/.translations/ru.json +++ b/homeassistant/components/smhi/.translations/ru.json @@ -11,7 +11,7 @@ "longitude": "\u0414\u043e\u043b\u0433\u043e\u0442\u0430", "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435" }, - "title": "\u041c\u0435\u0441\u0442\u043e\u043d\u0430\u0445\u043e\u0436\u0434\u0435\u043d\u0438\u0435 \u0432 \u0428\u0432\u0435\u0446\u0438\u0438" + "title": "\u041c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u0432 \u0428\u0432\u0435\u0446\u0438\u0438" } }, "title": "\u041c\u0435\u0442\u0435\u043e\u0440\u043e\u043b\u043e\u0433\u0438\u0447\u0435\u0441\u043a\u0430\u044f \u0441\u043b\u0443\u0436\u0431\u0430 \u0428\u0432\u0435\u0446\u0438\u0438 (SMHI)" diff --git a/homeassistant/components/solaredge_local/__init__.py b/homeassistant/components/solaredge_local/__init__.py new file mode 100644 index 00000000000000..bf9d724dd545e3 --- /dev/null +++ b/homeassistant/components/solaredge_local/__init__.py @@ -0,0 +1 @@ +"""The SolarEdge Local Integration.""" diff --git a/homeassistant/components/solaredge_local/manifest.json b/homeassistant/components/solaredge_local/manifest.json new file mode 100644 index 00000000000000..5fb07011983edd --- /dev/null +++ b/homeassistant/components/solaredge_local/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "solaredge_local", + "name": "Solar Edge Local", + "documentation": "", + "dependencies": [], + "codeowners": ["@drobtravels"], + "requirements": ["solaredge-local==0.1.4"] + } \ No newline at end of file diff --git a/homeassistant/components/solaredge_local/sensor.py b/homeassistant/components/solaredge_local/sensor.py new file mode 100644 index 00000000000000..8be4ceda7c7e8a --- /dev/null +++ b/homeassistant/components/solaredge_local/sensor.py @@ -0,0 +1,159 @@ +""" +Support for SolarEdge Monitoring API. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.solaredge_local/ +""" +import logging +from datetime import timedelta + +from requests.exceptions import HTTPError, ConnectTimeout +from solaredge_local import SolarEdge +import voluptuous as vol + + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_IP_ADDRESS, CONF_NAME, POWER_WATT, + ENERGY_WATT_HOUR) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +DOMAIN = 'solaredge_local' +UPDATE_DELAY = timedelta(seconds=10) + +# Supported sensor types: +# Key: ['json_key', 'name', unit, icon] +SENSOR_TYPES = { + 'lifetime_energy': ['energyTotal', "Lifetime energy", + ENERGY_WATT_HOUR, 'mdi:solar-power'], + 'energy_this_year': ['energyThisYear', "Energy this year", + ENERGY_WATT_HOUR, 'mdi:solar-power'], + 'energy_this_month': ['energyThisMonth', "Energy this month", + ENERGY_WATT_HOUR, 'mdi:solar-power'], + 'energy_today': ['energyToday', "Energy today", + ENERGY_WATT_HOUR, 'mdi:solar-power'], + 'current_power': ['currentPower', "Current Power", + POWER_WATT, 'mdi:solar-power'] +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_IP_ADDRESS): cv.string, + vol.Optional(CONF_NAME, default='SolarEdge'): cv.string, +}) + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Create the SolarEdge Monitoring API sensor.""" + ip_address = config[CONF_IP_ADDRESS] + platform_name = config[CONF_NAME] + + # Create new SolarEdge object to retrieve data + api = SolarEdge("http://{}/".format(ip_address)) + + # Check if api can be reached and site is active + try: + status = api.get_status() + + status.energy # pylint: disable=pointless-statement + _LOGGER.debug("Credentials correct and site is active") + except AttributeError: + _LOGGER.error("Missing details data in solaredge response") + _LOGGER.debug("Response is: %s", status) + return + except (ConnectTimeout, HTTPError): + _LOGGER.error("Could not retrieve details from SolarEdge API") + return + + # Create solaredge data service which will retrieve and update the data. + data = SolarEdgeData(hass, api) + + # Create a new sensor for each sensor type. + entities = [] + for sensor_key in SENSOR_TYPES: + sensor = SolarEdgeSensor(platform_name, sensor_key, data) + entities.append(sensor) + + add_entities(entities, True) + + +class SolarEdgeSensor(Entity): + """Representation of an SolarEdge Monitoring API sensor.""" + + def __init__(self, platform_name, sensor_key, data): + """Initialize the sensor.""" + self.platform_name = platform_name + self.sensor_key = sensor_key + self.data = data + self._state = None + + self._json_key = SENSOR_TYPES[self.sensor_key][0] + self._unit_of_measurement = SENSOR_TYPES[self.sensor_key][2] + + @property + def name(self): + """Return the name.""" + return "{} ({})".format(self.platform_name, + SENSOR_TYPES[self.sensor_key][1]) + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit_of_measurement + + @property + def icon(self): + """Return the sensor icon.""" + return SENSOR_TYPES[self.sensor_key][3] + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + def update(self): + """Get the latest data from the sensor and update the state.""" + self.data.update() + self._state = self.data.data[self._json_key] + + +class SolarEdgeData: + """Get and update the latest data.""" + + def __init__(self, hass, api): + """Initialize the data object.""" + self.hass = hass + self.api = api + self.data = {} + + @Throttle(UPDATE_DELAY) + def update(self): + """Update the data from the SolarEdge Monitoring API.""" + try: + response = self.api.get_status() + _LOGGER.debug("response from SolarEdge: %s", response) + + self.data["energyTotal"] = response.energy.total + self.data["energyThisYear"] = response.energy.thisYear + self.data["energyThisMonth"] = response.energy.thisMonth + self.data["energyToday"] = response.energy.today + self.data["currentPower"] = response.powerWatt + + _LOGGER.debug("Updated SolarEdge overview data: %s", self.data) + except AttributeError: + _LOGGER.error("Missing details data in solaredge response") + _LOGGER.debug("Response is: %s", response) + return + except (ConnectTimeout, HTTPError): + _LOGGER.error("Could not retrieve data, skipping update") + return + + self.data["energyTotal"] = response.energy.total + self.data["energyThisYear"] = response.energy.thisYear + self.data["energyThisMonth"] = response.energy.thisMonth + self.data["energyToday"] = response.energy.today + self.data["currentPower"] = response.powerWatt + _LOGGER.debug("Updated SolarEdge overview data: %s", self.data) diff --git a/homeassistant/components/somfy/.translations/ca.json b/homeassistant/components/somfy/.translations/ca.json new file mode 100644 index 00000000000000..14f707ac046734 --- /dev/null +++ b/homeassistant/components/somfy/.translations/ca.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "already_setup": "Nom\u00e9s pots configurar un compte de Somfy.", + "authorize_url_timeout": "S'ha acabat el temps d'espera mentre \u00e9s generava l'URL d'autoritzaci\u00f3.", + "missing_configuration": "El component Somfy no est\u00e0 configurat. Mira'n la documentaci\u00f3." + }, + "create_entry": { + "default": "Autenticaci\u00f3 exitosa amb Somfy." + }, + "title": "Somfy" + } +} \ No newline at end of file diff --git a/homeassistant/components/somfy/.translations/de.json b/homeassistant/components/somfy/.translations/de.json new file mode 100644 index 00000000000000..1dd1b7b4448223 --- /dev/null +++ b/homeassistant/components/somfy/.translations/de.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "already_setup": "Es kann nur ein Somfy-Account konfiguriert werden.", + "authorize_url_timeout": "Zeit\u00fcberschreitung beim Erstellen der Authorisierungs-URL.", + "missing_configuration": "Die Somfy-Komponente ist nicht konfiguriert. Folge bitte der Dokumentation." + }, + "create_entry": { + "default": "Erfolgreich mit Somfy authentifiziert." + }, + "title": "Somfy" + } +} \ No newline at end of file diff --git a/homeassistant/components/somfy/.translations/en.json b/homeassistant/components/somfy/.translations/en.json new file mode 100644 index 00000000000000..d4155915636c96 --- /dev/null +++ b/homeassistant/components/somfy/.translations/en.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "already_setup": "You can only configure one Somfy account.", + "authorize_url_timeout": "Timeout generating authorize url.", + "missing_configuration": "The Somfy component is not configured. Please follow the documentation." + }, + "create_entry": { + "default": "Successfully authenticated with Somfy." + }, + "title": "Somfy" + } +} \ No newline at end of file diff --git a/homeassistant/components/somfy/.translations/fr.json b/homeassistant/components/somfy/.translations/fr.json new file mode 100644 index 00000000000000..6367e41155298d --- /dev/null +++ b/homeassistant/components/somfy/.translations/fr.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "already_setup": "Vous ne pouvez configurer qu'un seul compte Somfy.", + "authorize_url_timeout": "Durée expirée pour la génération de l'url d'autorisation.", + "missing_configuration": "Le composant Somfy n'est pas configuré. Merci de suivre la documentation." + }, + "create_entry": { + "default": "Authentification réussie avec Somfy." + }, + "title": "Somfy" + } +} \ No newline at end of file diff --git a/homeassistant/components/somfy/.translations/ko.json b/homeassistant/components/somfy/.translations/ko.json new file mode 100644 index 00000000000000..72b234cd98b60e --- /dev/null +++ b/homeassistant/components/somfy/.translations/ko.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "already_setup": "\ud558\ub098\uc758 Somfy \uacc4\uc815\ub9cc \uad6c\uc131 \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.", + "authorize_url_timeout": "\uc778\uc99d url \uc0dd\uc131 \uc2dc\uac04\uc774 \ucd08\uacfc\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", + "missing_configuration": "Somfy \uad6c\uc131\uc694\uc18c\uac00 \uad6c\uc131\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. \uc124\uba85\uc11c\ub97c \ucc38\uace0\ud574\uc8fc\uc138\uc694." + }, + "create_entry": { + "default": "Somfy \ub85c \uc131\uacf5\uc801\uc73c\ub85c \uc778\uc99d\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + }, + "title": "Somfy" + } +} \ No newline at end of file diff --git a/homeassistant/components/somfy/.translations/lb.json b/homeassistant/components/somfy/.translations/lb.json new file mode 100644 index 00000000000000..62f588292416fc --- /dev/null +++ b/homeassistant/components/somfy/.translations/lb.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "already_setup": "Dir k\u00ebnnt n\u00ebmmen een eenzegen Somfy Kont konfigur\u00e9ieren.", + "authorize_url_timeout": "Z\u00e4it Iwwerschreidung beim gener\u00e9ieren vun der Autorisatiouns URL.", + "missing_configuration": "D'Somfy Komponent ass nach net konfigur\u00e9iert. Follegt w.e.g der Dokumentatioun." + }, + "create_entry": { + "default": "Erfollegr\u00e4ich mat Somfy authentifiz\u00e9iert." + }, + "title": "Somfy" + } +} \ No newline at end of file diff --git a/homeassistant/components/somfy/.translations/nl.json b/homeassistant/components/somfy/.translations/nl.json new file mode 100644 index 00000000000000..be50b280c17ad6 --- /dev/null +++ b/homeassistant/components/somfy/.translations/nl.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "already_setup": "U kunt slechts \u00e9\u00e9n Somfy-account configureren.", + "authorize_url_timeout": "Time-out tijdens genereren autorisatie url.", + "missing_configuration": "Het Somfy-component is niet geconfigureerd. Gelieve de documentatie te volgen." + }, + "create_entry": { + "default": "Succesvol geverifieerd met Somfy." + }, + "title": "Somfy" + } +} \ No newline at end of file diff --git a/homeassistant/components/somfy/.translations/pl.json b/homeassistant/components/somfy/.translations/pl.json new file mode 100644 index 00000000000000..cb19fcb793ae1b --- /dev/null +++ b/homeassistant/components/somfy/.translations/pl.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "already_setup": "Mo\u017cesz skonfigurowa\u0107 tylko jedno konto Somfy.", + "authorize_url_timeout": "Min\u0105\u0142 limit czasu generowania url autoryzacji.", + "missing_configuration": "Komponent Somfy nie jest skonfigurowany. Post\u0119puj zgodnie z dokumentacj\u0105." + }, + "create_entry": { + "default": "Pomy\u015blnie uwierzytelniono z Somfy" + }, + "title": "Somfy" + } +} \ No newline at end of file diff --git a/homeassistant/components/somfy/.translations/ru.json b/homeassistant/components/somfy/.translations/ru.json new file mode 100644 index 00000000000000..7251bc990e9f46 --- /dev/null +++ b/homeassistant/components/somfy/.translations/ru.json @@ -0,0 +1,13 @@ +{ + "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.", + "authorize_url_timeout": "\u0418\u0441\u0442\u0435\u043a\u043b\u043e \u0432\u0440\u0435\u043c\u044f \u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u0438 \u0441\u0441\u044b\u043b\u043a\u0438 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438.", + "missing_configuration": "\u041a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442 Somfy \u043d\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u0435\u0439." + }, + "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." + }, + "title": "Somfy" + } +} \ No newline at end of file diff --git a/homeassistant/components/somfy/.translations/zh-Hant.json b/homeassistant/components/somfy/.translations/zh-Hant.json new file mode 100644 index 00000000000000..6b53e943304383 --- /dev/null +++ b/homeassistant/components/somfy/.translations/zh-Hant.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "already_setup": "\u50c5\u80fd\u8a2d\u5b9a\u4e00\u7d44 Somfy \u5e33\u865f\u3002", + "authorize_url_timeout": "\u7522\u751f\u8a8d\u8b49 URL \u6642\u903e\u6642", + "missing_configuration": "Somfy \u5143\u4ef6\u5c1a\u672a\u8a2d\u7f6e\uff0c\u8acb\u53c3\u95b1\u6587\u4ef6\u8aaa\u660e\u3002" + }, + "create_entry": { + "default": "\u5df2\u6210\u529f\u8a8d\u8b49 Somfy \u88dd\u7f6e\u3002" + }, + "title": "Somfy" + } +} \ No newline at end of file diff --git a/homeassistant/components/somfy/__init__.py b/homeassistant/components/somfy/__init__.py new file mode 100644 index 00000000000000..c725bb47815dc6 --- /dev/null +++ b/homeassistant/components/somfy/__init__.py @@ -0,0 +1,160 @@ +""" +Support for Somfy hubs. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/somfy/ +""" +import logging +from datetime import timedelta +from functools import partial + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant import config_entries +from homeassistant.components.somfy import config_flow +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TOKEN +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.util import Throttle + +API = 'api' + +DEVICES = 'devices' + +_LOGGER = logging.getLogger(__name__) + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10) + +DOMAIN = 'somfy' + +CONF_CLIENT_ID = 'client_id' +CONF_CLIENT_SECRET = 'client_secret' + +SOMFY_AUTH_CALLBACK_PATH = '/auth/somfy/callback' +SOMFY_AUTH_START = '/auth/somfy' + +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) + +SOMFY_COMPONENTS = ['cover'] + + +async def async_setup(hass, config): + """Set up the Somfy component.""" + if DOMAIN not in config: + return True + + hass.data[DOMAIN] = {} + + config_flow.register_flow_implementation( + hass, config[DOMAIN][CONF_CLIENT_ID], + config[DOMAIN][CONF_CLIENT_SECRET]) + + 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: HomeAssistantType, entry: ConfigEntry): + """Set up Somfy from a config entry.""" + def token_saver(token): + _LOGGER.debug('Saving updated token') + entry.data[CONF_TOKEN] = token + update_entry = partial( + hass.config_entries.async_update_entry, + data={**entry.data} + ) + hass.add_job(update_entry, entry) + + # Force token update. + from pymfy.api.somfy_api import SomfyApi + hass.data[DOMAIN][API] = SomfyApi( + entry.data['refresh_args']['client_id'], + entry.data['refresh_args']['client_secret'], + token=entry.data[CONF_TOKEN], + token_updater=token_saver + ) + + await update_all_devices(hass) + + for component in SOMFY_COMPONENTS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component)) + + return True + + +async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry): + """Unload a config entry.""" + hass.data[DOMAIN].pop(API, None) + return True + + +class SomfyEntity(Entity): + """Representation of a generic Somfy device.""" + + def __init__(self, device, api): + """Initialize the Somfy device.""" + self.device = device + self.api = api + + @property + def unique_id(self): + """Return the unique id base on the id returned by Somfy.""" + return self.device.id + + @property + def name(self): + """Return the name of the device.""" + return self.device.name + + @property + def device_info(self): + """Return device specific attributes. + + Implemented by platform classes. + """ + return { + 'identifiers': {(DOMAIN, self.unique_id)}, + 'name': self.name, + 'model': self.device.type, + 'via_hub': (DOMAIN, self.device.site_id), + # For the moment, Somfy only returns their own device. + 'manufacturer': 'Somfy' + } + + async def async_update(self): + """Update the device with the latest data.""" + await update_all_devices(self.hass) + devices = self.hass.data[DOMAIN][DEVICES] + self.device = next((d for d in devices if d.id == self.device.id), + self.device) + + def has_capability(self, capability): + """Test if device has a capability.""" + capabilities = self.device.capabilities + return bool([c for c in capabilities if c.name == capability]) + + +@Throttle(MIN_TIME_BETWEEN_UPDATES) +async def update_all_devices(hass): + """Update all the devices.""" + from requests import HTTPError + try: + data = hass.data[DOMAIN] + data[DEVICES] = await hass.async_add_executor_job( + data[API].get_devices) + except HTTPError: + _LOGGER.warning("Cannot update devices") + return False + return True diff --git a/homeassistant/components/somfy/config_flow.py b/homeassistant/components/somfy/config_flow.py new file mode 100644 index 00000000000000..0c29c037ba3dee --- /dev/null +++ b/homeassistant/components/somfy/config_flow.py @@ -0,0 +1,146 @@ +"""Config flow for Somfy.""" +import asyncio +import logging + +import async_timeout + +from homeassistant import config_entries +from homeassistant.components.http import HomeAssistantView +from homeassistant.core import callback +from .const import CLIENT_ID, CLIENT_SECRET, DOMAIN + +AUTH_CALLBACK_PATH = '/auth/somfy/callback' +AUTH_CALLBACK_NAME = 'auth:somfy:callback' + +_LOGGER = logging.getLogger(__name__) + + +@callback +def register_flow_implementation(hass, client_id, client_secret): + """Register a flow implementation. + + client_id: Client id. + client_secret: Client secret. + """ + hass.data[DOMAIN][CLIENT_ID] = client_id + hass.data[DOMAIN][CLIENT_SECRET] = client_secret + + +@config_entries.HANDLERS.register('somfy') +class SomfyFlowHandler(config_entries.ConfigFlow): + """Handle a config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Instantiate config flow.""" + self.code = None + + async def async_step_import(self, user_input=None): + """Handle external yaml configuration.""" + if self.hass.config_entries.async_entries(DOMAIN): + return self.async_abort(reason='already_setup') + return await self.async_step_auth() + + async def async_step_user(self, user_input=None): + """Handle a flow start.""" + if self.hass.config_entries.async_entries(DOMAIN): + return self.async_abort(reason='already_setup') + + if DOMAIN not in self.hass.data: + return self.async_abort(reason='missing_configuration') + + return await self.async_step_auth() + + async def async_step_auth(self, user_input=None): + """Create an entry for auth.""" + # Flow has been triggered from Somfy website + if user_input: + return await self.async_step_code(user_input) + + try: + with async_timeout.timeout(10): + url, _ = await self._get_authorization_url() + except asyncio.TimeoutError: + return self.async_abort(reason='authorize_url_timeout') + + return self.async_external_step( + step_id='auth', + url=url + ) + + async def _get_authorization_url(self): + """Get Somfy authorization url.""" + from pymfy.api.somfy_api import SomfyApi + client_id = self.hass.data[DOMAIN][CLIENT_ID] + client_secret = self.hass.data[DOMAIN][CLIENT_SECRET] + redirect_uri = '{}{}'.format( + self.hass.config.api.base_url, AUTH_CALLBACK_PATH) + api = SomfyApi(client_id, client_secret, redirect_uri) + + self.hass.http.register_view(SomfyAuthCallbackView()) + # Thanks to the state, we can forward the flow id to Somfy that will + # add it in the callback. + return await self.hass.async_add_executor_job( + api.get_authorization_url, self.flow_id) + + async def async_step_code(self, code): + """Received code for authentication.""" + self.code = code + return self.async_external_step_done(next_step_id="creation") + + async def async_step_creation(self, user_input=None): + """Create Somfy api and entries.""" + client_id = self.hass.data[DOMAIN][CLIENT_ID] + client_secret = self.hass.data[DOMAIN][CLIENT_SECRET] + code = self.code + from pymfy.api.somfy_api import SomfyApi + redirect_uri = '{}{}'.format( + self.hass.config.api.base_url, AUTH_CALLBACK_PATH) + api = SomfyApi(client_id, client_secret, redirect_uri) + token = await self.hass.async_add_executor_job(api.request_token, None, + code) + _LOGGER.info('Successfully authenticated Somfy') + return self.async_create_entry( + title='Somfy', + data={ + 'token': token, + 'refresh_args': { + 'client_id': client_id, + 'client_secret': client_secret + } + }, + ) + + +class SomfyAuthCallbackView(HomeAssistantView): + """Somfy Authorization Callback View.""" + + requires_auth = False + url = AUTH_CALLBACK_PATH + name = AUTH_CALLBACK_NAME + + @staticmethod + async def get(request): + """Receive authorization code.""" + from aiohttp import web_response + + if 'code' not in request.query or 'state' not in request.query: + return web_response.Response( + text="Missing code or state parameter in " + request.url + ) + + hass = request.app['hass'] + hass.async_create_task( + hass.config_entries.flow.async_configure( + flow_id=request.query['state'], + user_input=request.query['code'], + )) + + return web_response.Response( + headers={ + 'content-type': 'text/html' + }, + text="" + ) diff --git a/homeassistant/components/somfy/const.py b/homeassistant/components/somfy/const.py new file mode 100644 index 00000000000000..3d7029d56f69a1 --- /dev/null +++ b/homeassistant/components/somfy/const.py @@ -0,0 +1,5 @@ +"""Define constants for the Somfy component.""" + +DOMAIN = 'somfy' +CLIENT_ID = 'client_id' +CLIENT_SECRET = 'client_secret' diff --git a/homeassistant/components/somfy/cover.py b/homeassistant/components/somfy/cover.py new file mode 100644 index 00000000000000..7b4e53f63a79cc --- /dev/null +++ b/homeassistant/components/somfy/cover.py @@ -0,0 +1,114 @@ +""" +Support for Somfy Covers. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/cover.somfy/ +""" + +from homeassistant.components.cover import CoverDevice, ATTR_POSITION, \ + ATTR_TILT_POSITION +from homeassistant.components.somfy import DOMAIN, SomfyEntity, DEVICES, API + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Somfy cover platform.""" + def get_covers(): + """Retrieve covers.""" + from pymfy.api.devices.category import Category + + categories = {Category.ROLLER_SHUTTER.value, + Category.INTERIOR_BLIND.value, + Category.EXTERIOR_BLIND.value} + + devices = hass.data[DOMAIN][DEVICES] + + return [SomfyCover(cover, hass.data[DOMAIN][API]) for cover in + devices if + categories & set(cover.categories)] + + async_add_entities(await hass.async_add_executor_job(get_covers), True) + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Old way of setting up 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 + + +class SomfyCover(SomfyEntity, CoverDevice): + """Representation of a Somfy cover device.""" + + def __init__(self, device, api): + """Initialize the Somfy device.""" + from pymfy.api.devices.blind import Blind + super().__init__(device, api) + self.cover = Blind(self.device, self.api) + + async def async_update(self): + """Update the device with the latest data.""" + from pymfy.api.devices.blind import Blind + await super().async_update() + self.cover = Blind(self.device, self.api) + + def close_cover(self, **kwargs): + """Close the cover.""" + self.cover.close() + + def open_cover(self, **kwargs): + """Open the cover.""" + self.cover.open() + + def stop_cover(self, **kwargs): + """Stop the cover.""" + self.cover.stop() + + def set_cover_position(self, **kwargs): + """Move the cover shutter to a specific position.""" + self.cover.set_position(100 - kwargs[ATTR_POSITION]) + + @property + def current_cover_position(self): + """Return the current position of cover shutter.""" + position = None + if self.has_capability('position'): + position = 100 - self.cover.get_position() + return position + + @property + def is_closed(self): + """Return if the cover is closed.""" + is_closed = None + if self.has_capability('position'): + is_closed = self.cover.is_closed() + return is_closed + + @property + def current_cover_tilt_position(self): + """Return current position of cover tilt. + + None is unknown, 0 is closed, 100 is fully open. + """ + orientation = None + if self.has_capability('rotation'): + orientation = 100 - self.cover.orientation + return orientation + + def set_cover_tilt_position(self, **kwargs): + """Move the cover tilt to a specific position.""" + self.cover.orientation = kwargs[ATTR_TILT_POSITION] + + def open_cover_tilt(self, **kwargs): + """Open the cover tilt.""" + self.cover.orientation = 100 + + def close_cover_tilt(self, **kwargs): + """Close the cover tilt.""" + self.cover.orientation = 0 + + def stop_cover_tilt(self, **kwargs): + """Stop the cover.""" + self.cover.stop() diff --git a/homeassistant/components/somfy/manifest.json b/homeassistant/components/somfy/manifest.json new file mode 100644 index 00000000000000..02eab03c8bb31d --- /dev/null +++ b/homeassistant/components/somfy/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "somfy", + "name": "Somfy Open API", + "config_flow": true, + "documentation": "https://www.home-assistant.io/components/somfy", + "dependencies": [], + "codeowners": [ + "@tetienne" + ], + "requirements": [ + "pymfy==0.5.2" + ] +} \ No newline at end of file diff --git a/homeassistant/components/somfy/strings.json b/homeassistant/components/somfy/strings.json new file mode 100644 index 00000000000000..d4155915636c96 --- /dev/null +++ b/homeassistant/components/somfy/strings.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "already_setup": "You can only configure one Somfy account.", + "authorize_url_timeout": "Timeout generating authorize url.", + "missing_configuration": "The Somfy component is not configured. Please follow the documentation." + }, + "create_entry": { + "default": "Successfully authenticated with Somfy." + }, + "title": "Somfy" + } +} \ No newline at end of file diff --git a/homeassistant/components/somfy_mylink/manifest.json b/homeassistant/components/somfy_mylink/manifest.json index 5a3cec0def8bc2..d4e799c2cf1ab6 100644 --- a/homeassistant/components/somfy_mylink/manifest.json +++ b/homeassistant/components/somfy_mylink/manifest.json @@ -3,7 +3,7 @@ "name": "Somfy MyLink", "documentation": "https://www.home-assistant.io/components/somfy_mylink", "requirements": [ - "somfy-mylink-synergy==1.0.4" + "somfy-mylink-synergy==1.0.6" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index 58fa7b49f884c6..98f5784a028a2b 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -4,9 +4,14 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/components/sonos", "requirements": [ - "pysonos==0.0.12" + "pysonos==0.0.17" ], "dependencies": [], + "ssdp": { + "st": [ + "urn:schemas-upnp-org:device:ZonePlayer:1" + ] + }, "codeowners": [ "@amelchio" ] diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index e8004ec84285b0..6a4016c11f0965 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -34,8 +34,7 @@ _LOGGER = logging.getLogger(__name__) -PARALLEL_UPDATES = 0 - +SCAN_INTERVAL = 10 DISCOVERY_INTERVAL = 60 # Quiet down pysonos logging to just actual problems. @@ -227,10 +226,10 @@ def _timespan_secs(timespan): def _is_radio_uri(uri): - """Return whether the URI is a radio stream.""" + """Return whether the URI is a stream (not a playlist).""" radio_schemes = ( 'x-rincon-mp3radio:', 'x-sonosapi-stream:', 'x-sonosapi-radio:', - 'x-sonosapi-hls:', 'hls-radio:') + 'x-sonosapi-hls:', 'hls-radio:', 'x-rincon-stream:') return uri.startswith(radio_schemes) @@ -241,7 +240,7 @@ def __init__(self, player): """Initialize the Sonos entity.""" self._seen = None self._subscriptions = [] - self._receives_events = False + self._poll_timer = None self._volume_increment = 2 self._unique_id = player.uid self._player = player @@ -347,6 +346,10 @@ def check_unseen(self): if self._seen < time.monotonic() - 2*DISCOVERY_INTERVAL: self._available = False + if self._poll_timer: + self._poll_timer() + self._poll_timer = None + def _unsub(subscriptions): for subscription in subscriptions: subscription.unsubscribe() @@ -374,7 +377,9 @@ def _set_basic_information(self): def _set_favorites(self): """Set available favorites.""" - self._favorites = self.soco.music_library.get_sonos_favorites() + favorites = self.soco.music_library.get_sonos_favorites() + # Exclude favorites that are non-playable due to no linked resources + self._favorites = [f for f in favorites if f.reference.resources] def _radio_artwork(self, url): """Return the private URL with artwork for a radio stream.""" @@ -391,7 +396,8 @@ def _radio_artwork(self, url): def _subscribe_to_player_events(self): """Add event subscriptions.""" - self._receives_events = False + self._poll_timer = self.hass.helpers.event.track_time_interval( + self.update, datetime.timedelta(seconds=SCAN_INTERVAL)) # New player available, build the current group topology for entity in self.hass.data[DATA_SONOS].entities: @@ -410,16 +416,20 @@ def subscribe(service, action): subscribe(player.zoneGroupTopology, self.update_groups) subscribe(player.contentDirectory, self.update_content) - def update(self): + @property + def should_poll(self): + """Return that we should not be polled (we handle that internally).""" + return False + + def update(self, now=None): """Retrieve latest state.""" - if self._available and not self._receives_events: - try: - self.update_groups() - self.update_volume() - if self.is_coordinator: - self.update_media() - except SoCoException: - pass + try: + self.update_groups() + self.update_volume() + if self.is_coordinator: + self.update_media() + except SoCoException: + pass def update_media(self, event=None): """Update information about currently playing media.""" @@ -651,7 +661,10 @@ async def _async_handle_group_event(event): self.hass.data[DATA_SONOS].topology_condition.notify_all() if event: - self._receives_events = True + # Cancel poll timer since we do receive events + if self._poll_timer: + self._poll_timer() + self._poll_timer = None if not hasattr(event, 'zone_player_uui_ds_in_group'): return diff --git a/homeassistant/components/streamlabswater/__init__.py b/homeassistant/components/streamlabswater/__init__.py new file mode 100644 index 00000000000000..7e4fdd855c3408 --- /dev/null +++ b/homeassistant/components/streamlabswater/__init__.py @@ -0,0 +1,84 @@ +"""Support for Streamlabs Water Monitor devices.""" +import logging + +import voluptuous as vol + +from homeassistant.const import CONF_API_KEY +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv + +DOMAIN = 'streamlabswater' + +_LOGGER = logging.getLogger(__name__) + +ATTR_AWAY_MODE = 'away_mode' +SERVICE_SET_AWAY_MODE = 'set_away_mode' +AWAY_MODE_AWAY = 'away' +AWAY_MODE_HOME = 'home' + +STREAMLABSWATER_COMPONENTS = [ + 'sensor', 'binary_sensor' +] + +CONF_LOCATION_ID = "location_id" + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_API_KEY): cv.string, + vol.Optional(CONF_LOCATION_ID): cv.string + }) +}, extra=vol.ALLOW_EXTRA) + +SET_AWAY_MODE_SCHEMA = vol.Schema({ + vol.Required(ATTR_AWAY_MODE): vol.In([AWAY_MODE_AWAY, AWAY_MODE_HOME]) +}) + + +def setup(hass, config): + """Set up the streamlabs water component.""" + from streamlabswater import streamlabswater + + conf = config[DOMAIN] + api_key = conf.get(CONF_API_KEY) + location_id = conf.get(CONF_LOCATION_ID) + + client = streamlabswater.StreamlabsClient(api_key) + locations = client.get_locations().get('locations') + + if locations is None: + _LOGGER.error("Unable to retrieve locations. Verify API key") + return False + + if location_id is None: + location = locations[0] + location_id = location['locationId'] + _LOGGER.info("Streamlabs Water Monitor auto-detected location_id=%s", + location_id) + else: + location = next(( + l for l in locations if location_id == l['locationId']), None) + if location is None: + _LOGGER.error("Supplied location_id is invalid") + return False + + location_name = location['name'] + + hass.data[DOMAIN] = { + 'client': client, + 'location_id': location_id, + 'location_name': location_name + } + + for component in STREAMLABSWATER_COMPONENTS: + discovery.load_platform(hass, component, DOMAIN, {}, config) + + def set_away_mode(service): + """Set the StreamLabsWater Away Mode.""" + away_mode = service.data.get(ATTR_AWAY_MODE) + client.update_location(location_id, away_mode) + + hass.services.register( + DOMAIN, SERVICE_SET_AWAY_MODE, set_away_mode, + schema=SET_AWAY_MODE_SCHEMA) + + return True diff --git a/homeassistant/components/streamlabswater/binary_sensor.py b/homeassistant/components/streamlabswater/binary_sensor.py new file mode 100644 index 00000000000000..d6351cc2dc6bda --- /dev/null +++ b/homeassistant/components/streamlabswater/binary_sensor.py @@ -0,0 +1,73 @@ +"""Support for Streamlabs Water Monitor Away Mode.""" + +from datetime import timedelta + +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.streamlabswater import ( + DOMAIN as STREAMLABSWATER_DOMAIN) +from homeassistant.util import Throttle + +DEPENDS = ['streamlabswater'] + +MIN_TIME_BETWEEN_LOCATION_UPDATES = timedelta(seconds=60) + +ATTR_LOCATION_ID = "location_id" +NAME_AWAY_MODE = "Water Away Mode" + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the StreamLabsWater mode sensor.""" + client = hass.data[STREAMLABSWATER_DOMAIN]['client'] + location_id = hass.data[STREAMLABSWATER_DOMAIN]['location_id'] + location_name = hass.data[STREAMLABSWATER_DOMAIN]['location_name'] + + streamlabs_location_data = StreamlabsLocationData(location_id, client) + streamlabs_location_data.update() + + add_devices([ + StreamlabsAwayMode(location_name, streamlabs_location_data) + ]) + + +class StreamlabsLocationData: + """Track and query location data.""" + + def __init__(self, location_id, client): + """Initialize the location data.""" + self._location_id = location_id + self._client = client + self._is_away = None + + @Throttle(MIN_TIME_BETWEEN_LOCATION_UPDATES) + def update(self): + """Query and store location data.""" + location = self._client.get_location(self._location_id) + self._is_away = location['homeAway'] == 'away' + + def is_away(self): + """Return whether away more is enabled.""" + return self._is_away + + +class StreamlabsAwayMode(BinarySensorDevice): + """Monitor the away mode state.""" + + def __init__(self, location_name, streamlabs_location_data): + """Initialize the away mode device.""" + self._location_name = location_name + self._streamlabs_location_data = streamlabs_location_data + self._is_away = None + + @property + def name(self): + """Return the name for away mode.""" + return "{} {}".format(self._location_name, NAME_AWAY_MODE) + + @property + def is_on(self): + """Return if away mode is on.""" + return self._streamlabs_location_data.is_away() + + def update(self): + """Retrieve the latest location data and away mode state.""" + self._streamlabs_location_data.update() diff --git a/homeassistant/components/streamlabswater/manifest.json b/homeassistant/components/streamlabswater/manifest.json new file mode 100644 index 00000000000000..b4173ebf0e9297 --- /dev/null +++ b/homeassistant/components/streamlabswater/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "streamlabswater", + "name": "Streamlabs Water", + "documentation": "https://www.home-assistant.io/components/streamlabswater", + "requirements": [ + "streamlabswater==1.0.1" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/streamlabswater/sensor.py b/homeassistant/components/streamlabswater/sensor.py new file mode 100644 index 00000000000000..9d55b4931ad598 --- /dev/null +++ b/homeassistant/components/streamlabswater/sensor.py @@ -0,0 +1,128 @@ +"""Support for Streamlabs Water Monitor Usage.""" + +from datetime import timedelta + +from homeassistant.components.streamlabswater import ( + DOMAIN as STREAMLABSWATER_DOMAIN) +from homeassistant.const import VOLUME_GALLONS +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +DEPENDENCIES = ['streamlabswater'] + +WATER_ICON = 'mdi:water' +MIN_TIME_BETWEEN_USAGE_UPDATES = timedelta(seconds=60) + +NAME_DAILY_USAGE = "Daily Water" +NAME_MONTHLY_USAGE = "Monthly Water" +NAME_YEARLY_USAGE = "Yearly Water" + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up water usage sensors.""" + client = hass.data[STREAMLABSWATER_DOMAIN]['client'] + location_id = hass.data[STREAMLABSWATER_DOMAIN]['location_id'] + location_name = hass.data[STREAMLABSWATER_DOMAIN]['location_name'] + + streamlabs_usage_data = StreamlabsUsageData(location_id, client) + streamlabs_usage_data.update() + + add_devices([ + StreamLabsDailyUsage(location_name, streamlabs_usage_data), + StreamLabsMonthlyUsage(location_name, streamlabs_usage_data), + StreamLabsYearlyUsage(location_name, streamlabs_usage_data) + ]) + + +class StreamlabsUsageData: + """Track and query usage data.""" + + def __init__(self, location_id, client): + """Initialize the usage data.""" + self._location_id = location_id + self._client = client + self._today = None + self._this_month = None + self._this_year = None + + @Throttle(MIN_TIME_BETWEEN_USAGE_UPDATES) + def update(self): + """Query and store usage data.""" + water_usage = self._client.get_water_usage_summary(self._location_id) + self._today = round(water_usage['today'], 1) + self._this_month = round(water_usage['thisMonth'], 1) + self._this_year = round(water_usage['thisYear'], 1) + + def get_daily_usage(self): + """Return the day's usage.""" + return self._today + + def get_monthly_usage(self): + """Return the month's usage.""" + return self._this_month + + def get_yearly_usage(self): + """Return the year's usage.""" + return self._this_year + + +class StreamLabsDailyUsage(Entity): + """Monitors the daily water usage.""" + + def __init__(self, location_name, streamlabs_usage_data): + """Initialize the daily water usage device.""" + self._location_name = location_name + self._streamlabs_usage_data = streamlabs_usage_data + self._state = None + + @property + def name(self): + """Return the name for daily usage.""" + return "{} {}".format(self._location_name, NAME_DAILY_USAGE) + + @property + def icon(self): + """Return the daily usage icon.""" + return WATER_ICON + + @property + def state(self): + """Return the current daily usage.""" + return self._streamlabs_usage_data.get_daily_usage() + + @property + def unit_of_measurement(self): + """Return gallons as the unit measurement for water.""" + return VOLUME_GALLONS + + def update(self): + """Retrieve the latest daily usage.""" + self._streamlabs_usage_data.update() + + +class StreamLabsMonthlyUsage(StreamLabsDailyUsage): + """Monitors the monthly water usage.""" + + @property + def name(self): + """Return the name for monthly usage.""" + return "{} {}".format(self._location_name, NAME_MONTHLY_USAGE) + + @property + def state(self): + """Return the current monthly usage.""" + return self._streamlabs_usage_data.get_monthly_usage() + + +class StreamLabsYearlyUsage(StreamLabsDailyUsage): + """Monitors the yearly water usage.""" + + @property + def name(self): + """Return the name for yearly usage.""" + return "{} {}".format(self._location_name, NAME_YEARLY_USAGE) + + @property + def state(self): + """Return the current yearly usage.""" + return self._streamlabs_usage_data.get_yearly_usage() diff --git a/homeassistant/components/streamlabswater/services.yaml b/homeassistant/components/streamlabswater/services.yaml new file mode 100644 index 00000000000000..fa2a04c95864ed --- /dev/null +++ b/homeassistant/components/streamlabswater/services.yaml @@ -0,0 +1,4 @@ +set_away_mode: + description: 'Set the home/away mode for a Streamlabs Water Monitor.' + fields: + away_mode: {description: home or away, example: 'home'} \ No newline at end of file diff --git a/homeassistant/components/switcher_kis/__init__.py b/homeassistant/components/switcher_kis/__init__.py index 8f959369b7b4a6..4fd66a20085589 100644 --- a/homeassistant/components/switcher_kis/__init__.py +++ b/homeassistant/components/switcher_kis/__init__.py @@ -7,19 +7,25 @@ import voluptuous as vol +from homeassistant.auth.permissions.const import POLICY_EDIT from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.const import EVENT_HOMEASSISTANT_STOP -from homeassistant.core import callback +from homeassistant.const import CONF_ENTITY_ID, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import callback, split_entity_id +from homeassistant.exceptions import Unauthorized, UnknownUser from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.discovery import async_load_platform +from homeassistant.helpers.discovery import (async_listen_platform, + async_load_platform) from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.typing import EventType, HomeAssistantType +from homeassistant.helpers.typing import (ContextType, EventType, + HomeAssistantType, ServiceCallType) +from homeassistant.loader import bind_hass _LOGGER = getLogger(__name__) DOMAIN = 'switcher_kis' +CONF_AUTO_OFF = 'auto_off' CONF_DEVICE_ID = 'device_id' CONF_DEVICE_PASSWORD = 'device_password' CONF_PHONE_ID = 'phone_id' @@ -40,6 +46,32 @@ }) }, extra=vol.ALLOW_EXTRA) +SERVICE_SET_AUTO_OFF_NAME = 'set_auto_off' +SERVICE_SET_AUTO_OFF_SCHEMA = vol.Schema({ + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_AUTO_OFF): cv.time_period_str +}) + + +@bind_hass +async def _validate_edit_permission( + hass: HomeAssistantType, context: ContextType, + entity_id: str) -> None: + """Use for validating user control permissions.""" + splited = split_entity_id(entity_id) + if splited[0] != SWITCH_DOMAIN or not splited[1].startswith(DOMAIN): + raise Unauthorized( + context=context, entity_id=entity_id, permission=(POLICY_EDIT, )) + + user = await hass.auth.async_get_user(context.user_id) + if user is None: + raise UnknownUser( + context=context, entity_id=entity_id, permission=(POLICY_EDIT, )) + + if not user.permissions.check_entity(entity_id, POLICY_EDIT): + raise Unauthorized( + context=context, entity_id=entity_id, permission=(POLICY_EDIT, )) + async def async_setup(hass: HomeAssistantType, config: Dict) -> bool: """Set up the switcher component.""" @@ -58,13 +90,12 @@ async def async_stop_bridge(event: EventType) -> None: """On homeassistant stop, gracefully stop the bridge if running.""" await v2bridge.stop() - hass.async_add_job(hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, async_stop_bridge)) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop_bridge) try: device_data = await wait_for(v2bridge.queue.get(), timeout=10.0) except (Asyncio_TimeoutError, RuntimeError): - _LOGGER.exception("failed to get response from device") + _LOGGER.exception("Failed to get response from device") await v2bridge.stop() return False @@ -72,8 +103,33 @@ async def async_stop_bridge(event: EventType) -> None: DATA_DEVICE: device_data } + async def async_switch_platform_discovered( + platform: str, discovery_info: Optional[Dict]) -> None: + """Use for registering services after switch platform is discoverd.""" + if platform != DOMAIN: + return + + async def async_set_auto_off_service(service: ServiceCallType) -> None: + """Use for handling setting device auto-off service calls.""" + from aioswitcher.api import SwitcherV2Api + + await _validate_edit_permission( + hass, service.context, service.data[CONF_ENTITY_ID]) + + async with SwitcherV2Api(hass.loop, device_data.ip_addr, phone_id, + device_id, device_password) as swapi: + await swapi.set_auto_shutdown(service.data[CONF_AUTO_OFF]) + + hass.services.async_register( + DOMAIN, SERVICE_SET_AUTO_OFF_NAME, + async_set_auto_off_service, + schema=SERVICE_SET_AUTO_OFF_SCHEMA) + + async_listen_platform( + hass, SWITCH_DOMAIN, async_switch_platform_discovered) + hass.async_create_task(async_load_platform( - hass, SWITCH_DOMAIN, DOMAIN, None, config)) + hass, SWITCH_DOMAIN, DOMAIN, {}, config)) @callback def device_updates(timestamp: Optional[datetime]) -> None: diff --git a/homeassistant/components/switcher_kis/manifest.json b/homeassistant/components/switcher_kis/manifest.json index 140caf51936b76..2f3b3b6e84a5bc 100644 --- a/homeassistant/components/switcher_kis/manifest.json +++ b/homeassistant/components/switcher_kis/manifest.json @@ -6,7 +6,7 @@ "@tomerfi" ], "requirements": [ - "aioswitcher==2019.3.21" + "aioswitcher==2019.4.26" ], "dependencies": [] } diff --git a/homeassistant/components/switcher_kis/services.yaml b/homeassistant/components/switcher_kis/services.yaml new file mode 100644 index 00000000000000..5408a20499025e --- /dev/null +++ b/homeassistant/components/switcher_kis/services.yaml @@ -0,0 +1,9 @@ +set_auto_off: + description: 'Update Switcher device auto off setting.' + fields: + entity_id: + description: "Name of the entity id associated with the integration, used for permission validation." + example: "switch.switcher_kis_boiler" + auto_off: + description: 'Time period string containing hours and minutes.' + example: '"02:30"' diff --git a/homeassistant/components/switcher_kis/switch.py b/homeassistant/components/switcher_kis/switch.py index c66c6b52e0c3d4..a6da7aad4b9c96 100644 --- a/homeassistant/components/switcher_kis/switch.py +++ b/homeassistant/components/switcher_kis/switch.py @@ -30,7 +30,8 @@ async def async_setup_platform(hass: HomeAssistantType, config: Dict, async_add_entities: Callable, discovery_info: Dict) -> None: """Set up the switcher platform for the switch component.""" - assert DOMAIN in hass.data + if discovery_info is None: + return async_add_entities([SwitcherControl(hass.data[DOMAIN][DATA_DEVICE])]) diff --git a/homeassistant/components/synologydsm/sensor.py b/homeassistant/components/synologydsm/sensor.py index 2d12dbfe763662..6589c402431e15 100644 --- a/homeassistant/components/synologydsm/sensor.py +++ b/homeassistant/components/synologydsm/sensor.py @@ -7,7 +7,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_HOST, CONF_USERNAME, CONF_PASSWORD, CONF_PORT, CONF_SSL, + CONF_NAME, CONF_HOST, CONF_USERNAME, CONF_PASSWORD, CONF_PORT, CONF_SSL, ATTR_ATTRIBUTION, TEMP_CELSIUS, CONF_MONITORED_CONDITIONS, EVENT_HOMEASSISTANT_START, CONF_DISKS) from homeassistant.helpers.entity import Entity @@ -68,6 +68,7 @@ list(_STORAGE_DSK_MON_COND.keys()) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_SSL, default=True): cv.boolean, @@ -88,6 +89,7 @@ def run_setup(event): Delay the setup until Home Assistant is fully initialized. This allows any entities to be created already """ + name = config.get(CONF_NAME) host = config.get(CONF_HOST) port = config.get(CONF_PORT) username = config.get(CONF_USERNAME) @@ -99,21 +101,21 @@ def run_setup(event): api = SynoApi(host, port, username, password, unit, use_ssl) sensors = [SynoNasUtilSensor( - api, variable, _UTILISATION_MON_COND[variable]) + api, name, variable, _UTILISATION_MON_COND[variable]) for variable in monitored_conditions if variable in _UTILISATION_MON_COND] # Handle all volumes for volume in config.get(CONF_VOLUMES, api.storage.volumes): sensors += [SynoNasStorageSensor( - api, variable, _STORAGE_VOL_MON_COND[variable], volume) + api, name, variable, _STORAGE_VOL_MON_COND[variable], volume) for variable in monitored_conditions if variable in _STORAGE_VOL_MON_COND] # Handle all disks for disk in config.get(CONF_DISKS, api.storage.disks): sensors += [SynoNasStorageSensor( - api, variable, _STORAGE_DSK_MON_COND[variable], disk) + api, name, variable, _STORAGE_DSK_MON_COND[variable], disk) for variable in monitored_conditions if variable in _STORAGE_DSK_MON_COND] @@ -150,10 +152,11 @@ def update(self): class SynoNasSensor(Entity): """Representation of a Synology NAS Sensor.""" - def __init__(self, api, variable, variable_info, monitor_device=None): + def __init__(self, api, name, variable, variable_info, + monitor_device=None): """Initialize the sensor.""" self.var_id = variable - self.var_name = variable_info[0] + self.var_name = "{} {}".format(name, variable_info[0]) self.var_units = variable_info[1] self.var_icon = variable_info[2] self.monitor_device = monitor_device diff --git a/homeassistant/components/tahoma/cover.py b/homeassistant/components/tahoma/cover.py index eeacf7c83b216c..fdeb77dd9901d7 100644 --- a/homeassistant/components/tahoma/cover.py +++ b/homeassistant/components/tahoma/cover.py @@ -2,7 +2,10 @@ from datetime import timedelta import logging -from homeassistant.components.cover import ATTR_POSITION, CoverDevice +from homeassistant.components.cover import ( + ATTR_POSITION, DEVICE_CLASS_AWNING, DEVICE_CLASS_BLIND, + DEVICE_CLASS_CURTAIN, DEVICE_CLASS_GARAGE, DEVICE_CLASS_SHUTTER, + DEVICE_CLASS_WINDOW, CoverDevice) from homeassistant.util.dt import utcnow from . import DOMAIN as TAHOMA_DOMAIN, TahomaDevice @@ -16,6 +19,24 @@ ATTR_LOCK_LEVEL = 'lock_level' ATTR_LOCK_ORIG = 'lock_originator' +TAHOMA_DEVICE_CLASSES = { + 'io:ExteriorVenetianBlindIOComponent': DEVICE_CLASS_BLIND, + 'io:HorizontalAwningIOComponent': DEVICE_CLASS_AWNING, + 'io:RollerShutterGenericIOComponent': DEVICE_CLASS_SHUTTER, + 'io:RollerShutterUnoIOComponent': DEVICE_CLASS_SHUTTER, + 'io:RollerShutterVeluxIOComponent': DEVICE_CLASS_SHUTTER, + 'io:RollerShutterWithLowSpeedManagementIOComponent': DEVICE_CLASS_SHUTTER, + 'io:VerticalExteriorAwningIOComponent': DEVICE_CLASS_AWNING, + 'io:WindowOpenerVeluxIOComponent': DEVICE_CLASS_WINDOW, + 'io:GarageOpenerIOComponent': DEVICE_CLASS_GARAGE, + 'rts:BlindRTSComponent': DEVICE_CLASS_BLIND, + 'rts:CurtainRTSComponent': DEVICE_CLASS_CURTAIN, + 'rts:DualCurtainRTSComponent': DEVICE_CLASS_CURTAIN, + 'rts:ExteriorVenetianBlindRTSComponent': DEVICE_CLASS_BLIND, + 'rts:RollerShutterRTSComponent': DEVICE_CLASS_SHUTTER, + 'rts:VenetianBlindRTSComponent': DEVICE_CLASS_BLIND +} + def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Tahoma covers.""" @@ -109,12 +130,18 @@ def update(self): # _position: 0 is closed, 100 is fully open. # 'core:ClosureState': 100 is closed, 0 is fully open. if self._closure is not None: - self._position = 100 - self._closure + if self.tahoma_device.type == 'io:HorizontalAwningIOComponent': + self._position = self._closure + else: + self._position = 100 - self._closure if self._position <= 5: self._position = 0 if self._position >= 95: self._position = 100 - self._closed = self._position == 0 + if self.tahoma_device.type == 'io:HorizontalAwningIOComponent': + self._closed = self._position == 0 + else: + self._closed = self._position == 100 else: self._position = None if 'core:OpenClosedState' in self.tahoma_device.active_states: @@ -133,7 +160,11 @@ def current_cover_position(self): def set_cover_position(self, **kwargs): """Move the cover to a specific position.""" - self.apply_action('setPosition', 100 - kwargs.get(ATTR_POSITION)) + if self.tahoma_device.type == 'io:HorizontalAwningIOComponent': + self.apply_action('setPosition', kwargs.get(ATTR_POSITION, 0)) + else: + self.apply_action('setPosition', + 100 - kwargs.get(ATTR_POSITION, 0)) @property def is_closed(self): @@ -143,9 +174,7 @@ def is_closed(self): @property def device_class(self): """Return the class of the device.""" - if self.tahoma_device.type == 'io:WindowOpenerVeluxIOComponent': - return 'window' - return None + return TAHOMA_DEVICE_CLASSES.get(self.tahoma_device.type) @property def device_state_attributes(self): diff --git a/homeassistant/components/tellduslive/.translations/nl.json b/homeassistant/components/tellduslive/.translations/nl.json index 609ac51c4defec..a1029d991fe471 100644 --- a/homeassistant/components/tellduslive/.translations/nl.json +++ b/homeassistant/components/tellduslive/.translations/nl.json @@ -2,10 +2,14 @@ "config": { "abort": { "all_configured": "TelldusLive is al geconfigureerd", + "already_setup": "TelldusLive is al geconfigureerd", "authorize_url_fail": "Onbekende fout bij het genereren van een autorisatie url.", "authorize_url_timeout": "Time-out tijdens genereren autorisatie url.", "unknown": "Onbekende fout opgetreden" }, + "error": { + "auth_error": "Authenticatiefout, probeer het opnieuw." + }, "step": { "auth": { "description": "Om uw TelldusLive-account te linken: \n 1. Klik op de onderstaande link \n 2. Log in op Telldus Live \n 3. Autoriseer ** {app_name} ** (klik op ** Ja **). \n 4. Kom hier terug en klik op ** VERSTUREN **. \n\n [Link TelldusLive account]({auth_url})", diff --git a/homeassistant/components/tellduslive/entry.py b/homeassistant/components/tellduslive/entry.py index 9255f9da6458b7..c35a484b09d52d 100644 --- a/homeassistant/components/tellduslive/entry.py +++ b/homeassistant/components/tellduslive/entry.py @@ -128,5 +128,5 @@ def device_info(self): device_info['manufacturer'] = protocol.title() client = device.get('client') if client is not None: - device_info['via_hub'] = ('tellduslive', client) + device_info['via_device'] = ('tellduslive', client) return device_info diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index 6562fa49cde0b5..922362de1d9ccb 100644 --- a/homeassistant/components/tibber/manifest.json +++ b/homeassistant/components/tibber/manifest.json @@ -3,7 +3,7 @@ "name": "Tibber", "documentation": "https://www.home-assistant.io/components/tibber", "requirements": [ - "pyTibber==0.10.3" + "pyTibber==0.11.5" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/toon/.translations/hu.json b/homeassistant/components/toon/.translations/hu.json new file mode 100644 index 00000000000000..740e4bd381da5e --- /dev/null +++ b/homeassistant/components/toon/.translations/hu.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "authenticate": { + "data": { + "password": "Jelsz\u00f3", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/toon/.translations/it.json b/homeassistant/components/toon/.translations/it.json new file mode 100644 index 00000000000000..696c770f130952 --- /dev/null +++ b/homeassistant/components/toon/.translations/it.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "client_id": "L'ID client dalla configurazione non \u00e8 valido.", + "no_agreements": "Questo account non ha display Toon.", + "no_app": "\u00c8 necessario configurare Toon prima di poter eseguire l'autenticazione con esso. [Si prega di leggere le istruzioni] (https://www.home-assistant.io/components/toon/).", + "unknown_auth_fail": "Si \u00e8 verificato un errore imprevisto durante l'autenticazione." + }, + "error": { + "credentials": "Le credenziali fornite non sono valide.", + "display_exists": "Il display selezionato \u00e8 gi\u00e0 configurato." + }, + "step": { + "authenticate": { + "data": { + "password": "Password", + "username": "Nome utente" + }, + "description": "Autenticati con il tuo account Eneco Toon (non l'account sviluppatore).", + "title": "Collega il tuo account Toon" + }, + "display": { + "data": { + "display": "Seleziona il display" + }, + "description": "Seleziona il display Toon con cui connettersi.", + "title": "Seleziona il display" + } + }, + "title": "Toon" + } +} \ No newline at end of file diff --git a/homeassistant/components/toon/.translations/pt-BR.json b/homeassistant/components/toon/.translations/pt-BR.json new file mode 100644 index 00000000000000..cb6ef7c41c4394 --- /dev/null +++ b/homeassistant/components/toon/.translations/pt-BR.json @@ -0,0 +1,11 @@ +{ + "config": { + "abort": { + "client_secret": "O segredo do cliente da configura\u00e7\u00e3o \u00e9 inv\u00e1lido.", + "no_agreements": "Esta conta n\u00e3o possui exibi\u00e7\u00f5es Toon." + }, + "error": { + "credentials": "As credenciais fornecidas s\u00e3o inv\u00e1lidas." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/toon/__init__.py b/homeassistant/components/toon/__init__.py index da47285934cca9..ba39462941f0fd 100644 --- a/homeassistant/components/toon/__init__.py +++ b/homeassistant/components/toon/__init__.py @@ -64,7 +64,7 @@ async def async_setup_entry(hass: HomeAssistantType, }, manufacturer='Eneco', name="Meter Adapter", - via_hub=(DOMAIN, toon.agreement.id) + via_device=(DOMAIN, toon.agreement.id) ) for component in 'binary_sensor', 'climate', 'sensor': @@ -126,7 +126,7 @@ def device_info(self) -> Dict[str, Any]: 'identifiers': { (DOMAIN, self.toon.agreement.id, 'electricity'), }, - 'via_hub': (DOMAIN, self.toon.agreement.id, 'meter_adapter'), + 'via_device': (DOMAIN, self.toon.agreement.id, 'meter_adapter'), } @@ -136,16 +136,16 @@ class ToonGasMeterDeviceEntity(ToonEntity): @property def device_info(self) -> Dict[str, Any]: """Return device information about this entity.""" - via_hub = 'meter_adapter' + via_device = 'meter_adapter' if self.toon.gas.is_smart: - via_hub = 'electricity' + via_device = 'electricity' return { 'name': 'Gas Meter', 'identifiers': { (DOMAIN, self.toon.agreement.id, 'gas'), }, - 'via_hub': (DOMAIN, self.toon.agreement.id, via_hub), + 'via_device': (DOMAIN, self.toon.agreement.id, via_device), } @@ -160,7 +160,7 @@ def device_info(self) -> Dict[str, Any]: 'identifiers': { (DOMAIN, self.toon.agreement.id, 'solar'), }, - 'via_hub': (DOMAIN, self.toon.agreement.id, 'meter_adapter'), + 'via_device': (DOMAIN, self.toon.agreement.id, 'meter_adapter'), } @@ -176,7 +176,7 @@ def device_info(self) -> Dict[str, Any]: 'identifiers': { (DOMAIN, self.toon.agreement.id, 'boiler_module'), }, - 'via_hub': (DOMAIN, self.toon.agreement.id), + 'via_device': (DOMAIN, self.toon.agreement.id), } @@ -191,5 +191,5 @@ def device_info(self) -> Dict[str, Any]: 'identifiers': { (DOMAIN, self.toon.agreement.id, 'boiler'), }, - 'via_hub': (DOMAIN, self.toon.agreement.id, 'boiler_module'), + 'via_device': (DOMAIN, self.toon.agreement.id, 'boiler_module'), } diff --git a/homeassistant/components/toon/manifest.json b/homeassistant/components/toon/manifest.json index eccaf7df9bcccf..3fd00e88a0c16f 100644 --- a/homeassistant/components/toon/manifest.json +++ b/homeassistant/components/toon/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/components/toon", "requirements": [ - "toonapilib==3.2.2" + "toonapilib==3.2.4" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/totalconnect/alarm_control_panel.py b/homeassistant/components/totalconnect/alarm_control_panel.py index 848202d6ce1445..6d4c7a9671a87e 100644 --- a/homeassistant/components/totalconnect/alarm_control_panel.py +++ b/homeassistant/components/totalconnect/alarm_control_panel.py @@ -9,9 +9,8 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_USERNAME, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, STATE_ALARM_DISARMED, - STATE_ALARM_ARMING, STATE_ALARM_DISARMING, CONF_NAME, - STATE_ALARM_ARMED_CUSTOM_BYPASS) - + STATE_ALARM_ARMING, STATE_ALARM_DISARMING, STATE_ALARM_TRIGGERED, + CONF_NAME, STATE_ALARM_ARMED_CUSTOM_BYPASS) _LOGGER = logging.getLogger(__name__) @@ -46,6 +45,7 @@ def __init__(self, name, username, password): self._username = username self._password = password self._state = None + self._device_state_attributes = {} self._client = TotalConnectClient.TotalConnectClient( username, password) @@ -59,9 +59,15 @@ def state(self): """Return the state of the device.""" return self._state + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + return self._device_state_attributes + def update(self): """Return the state of the device.""" status = self._client.get_armed_status() + attr = {'triggered_source': None, 'triggered_zone': None} if status == self._client.DISARMED: state = STATE_ALARM_DISARMED @@ -77,10 +83,22 @@ def update(self): state = STATE_ALARM_ARMING elif status == self._client.DISARMING: state = STATE_ALARM_DISARMING + elif status == self._client.ALARMING: + state = STATE_ALARM_TRIGGERED + attr['triggered_source'] = 'Police/Medical' + elif status == self._client.ALARMING_FIRE_SMOKE: + state = STATE_ALARM_TRIGGERED + attr['triggered_source'] = 'Fire/Smoke' + elif status == self._client.ALARMING_CARBON_MONOXIDE: + state = STATE_ALARM_TRIGGERED + attr['triggered_source'] = 'Carbon Monoxide' else: + logging.info("Total Connect Client returned unknown " + "status code: %s", status) state = None self._state = state + self._device_state_attributes = attr def alarm_disarm(self, code=None): """Send disarm command.""" diff --git a/homeassistant/components/totalconnect/manifest.json b/homeassistant/components/totalconnect/manifest.json index adb60599ae533c..3ff3b5c5b46436 100644 --- a/homeassistant/components/totalconnect/manifest.json +++ b/homeassistant/components/totalconnect/manifest.json @@ -3,7 +3,7 @@ "name": "Totalconnect", "documentation": "https://www.home-assistant.io/components/totalconnect", "requirements": [ - "total_connect_client==0.25" + "total_connect_client==0.27" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/tplink/.translations/it.json b/homeassistant/components/tplink/.translations/it.json new file mode 100644 index 00000000000000..4931e2293dd8f8 --- /dev/null +++ b/homeassistant/components/tplink/.translations/it.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nessun dispositivo TP-Link trovato in rete.", + "single_instance_allowed": "\u00c8 necessaria una sola configurazione." + }, + "step": { + "confirm": { + "description": "Vuoi configurare i dispositivi intelligenti TP-Link?", + "title": "TP-Link Smart Home" + } + }, + "title": "TP-Link Smart Home" + } +} \ No newline at end of file diff --git a/homeassistant/components/tplink/.translations/pt-BR.json b/homeassistant/components/tplink/.translations/pt-BR.json new file mode 100644 index 00000000000000..cb74920ff92e82 --- /dev/null +++ b/homeassistant/components/tplink/.translations/pt-BR.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "confirm": { + "description": "Deseja configurar dispositivos inteligentes TP-Link?", + "title": "TP-Link Smart Home" + } + }, + "title": "TP-Link Smart Home" + } +} \ No newline at end of file diff --git a/homeassistant/components/traccar/const.py b/homeassistant/components/traccar/const.py new file mode 100644 index 00000000000000..755b3dec77b9a1 --- /dev/null +++ b/homeassistant/components/traccar/const.py @@ -0,0 +1,31 @@ +"""Constants for Traccar integration.""" + +CONF_MAX_ACCURACY = 'max_accuracy' +CONF_SKIP_ACCURACY_ON = 'skip_accuracy_filter_on' + +ATTR_ADDRESS = 'address' +ATTR_CATEGORY = 'category' +ATTR_GEOFENCE = 'geofence' +ATTR_MOTION = 'motion' +ATTR_SPEED = 'speed' +ATTR_TRACKER = 'tracker' +ATTR_TRACCAR_ID = 'traccar_id' +ATTR_STATUS = 'status' + +EVENT_DEVICE_MOVING = 'device_moving' +EVENT_COMMAND_RESULT = 'command_result' +EVENT_DEVICE_FUEL_DROP = 'device_fuel_drop' +EVENT_GEOFENCE_ENTER = 'geofence_enter' +EVENT_DEVICE_OFFLINE = 'device_offline' +EVENT_DRIVER_CHANGED = 'driver_changed' +EVENT_GEOFENCE_EXIT = 'geofence_exit' +EVENT_DEVICE_OVERSPEED = 'device_overspeed' +EVENT_DEVICE_ONLINE = 'device_online' +EVENT_DEVICE_STOPPED = 'device_stopped' +EVENT_MAINTENANCE = 'maintenance' +EVENT_ALARM = 'alarm' +EVENT_TEXT_MESSAGE = 'text_message' +EVENT_DEVICE_UNKNOWN = 'device_unknown' +EVENT_IGNITION_OFF = 'ignition_off' +EVENT_IGNITION_ON = 'ignition_on' +EVENT_ALL_EVENTS = 'all_events' diff --git a/homeassistant/components/traccar/device_tracker.py b/homeassistant/components/traccar/device_tracker.py index d2990e178ab642..08604027273f0a 100644 --- a/homeassistant/components/traccar/device_tracker.py +++ b/homeassistant/components/traccar/device_tracker.py @@ -7,44 +7,25 @@ from homeassistant.components.device_tracker import PLATFORM_SCHEMA from homeassistant.const import ( CONF_HOST, CONF_PORT, CONF_SSL, CONF_VERIFY_SSL, - CONF_PASSWORD, CONF_USERNAME, ATTR_BATTERY_LEVEL, - CONF_SCAN_INTERVAL, CONF_MONITORED_CONDITIONS, - CONF_EVENT) + CONF_PASSWORD, CONF_USERNAME, CONF_SCAN_INTERVAL, + CONF_MONITORED_CONDITIONS, CONF_EVENT) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.event import async_track_time_interval from homeassistant.util import slugify +from .const import ( + ATTR_ADDRESS, ATTR_CATEGORY, ATTR_GEOFENCE, + ATTR_MOTION, ATTR_SPEED, ATTR_TRACKER, ATTR_TRACCAR_ID, ATTR_STATUS, + EVENT_DEVICE_MOVING, EVENT_COMMAND_RESULT, EVENT_DEVICE_FUEL_DROP, + EVENT_GEOFENCE_ENTER, EVENT_DEVICE_OFFLINE, EVENT_DRIVER_CHANGED, + EVENT_GEOFENCE_EXIT, EVENT_DEVICE_OVERSPEED, EVENT_DEVICE_ONLINE, + EVENT_DEVICE_STOPPED, EVENT_MAINTENANCE, EVENT_ALARM, EVENT_TEXT_MESSAGE, + EVENT_DEVICE_UNKNOWN, EVENT_IGNITION_OFF, EVENT_IGNITION_ON, + EVENT_ALL_EVENTS, CONF_MAX_ACCURACY, CONF_SKIP_ACCURACY_ON) _LOGGER = logging.getLogger(__name__) -ATTR_ADDRESS = 'address' -ATTR_CATEGORY = 'category' -ATTR_GEOFENCE = 'geofence' -ATTR_MOTION = 'motion' -ATTR_SPEED = 'speed' -ATTR_TRACKER = 'tracker' -ATTR_TRACCAR_ID = 'traccar_id' -ATTR_STATUS = 'status' - -EVENT_DEVICE_MOVING = 'device_moving' -EVENT_COMMAND_RESULT = 'command_result' -EVENT_DEVICE_FUEL_DROP = 'device_fuel_drop' -EVENT_GEOFENCE_ENTER = 'geofence_enter' -EVENT_DEVICE_OFFLINE = 'device_offline' -EVENT_DRIVER_CHANGED = 'driver_changed' -EVENT_GEOFENCE_EXIT = 'geofence_exit' -EVENT_DEVICE_OVERSPEED = 'device_overspeed' -EVENT_DEVICE_ONLINE = 'device_online' -EVENT_DEVICE_STOPPED = 'device_stopped' -EVENT_MAINTENANCE = 'maintenance' -EVENT_ALARM = 'alarm' -EVENT_TEXT_MESSAGE = 'text_message' -EVENT_DEVICE_UNKNOWN = 'device_unknown' -EVENT_IGNITION_OFF = 'ignition_off' -EVENT_IGNITION_ON = 'ignition_on' -EVENT_ALL_EVENTS = 'all_events' - DEFAULT_SCAN_INTERVAL = timedelta(seconds=30) SCAN_INTERVAL = DEFAULT_SCAN_INTERVAL @@ -55,6 +36,10 @@ vol.Optional(CONF_PORT, default=8082): cv.port, vol.Optional(CONF_SSL, default=False): cv.boolean, vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, + vol.Required(CONF_MAX_ACCURACY, default=0): vol.All(vol.Coerce(int), + vol.Range(min=0)), + vol.Optional(CONF_SKIP_ACCURACY_ON, + default=[]): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_MONITORED_CONDITIONS, default=[]): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_EVENT, @@ -91,6 +76,7 @@ async def async_setup_scanner(hass, config, async_see, discovery_info=None): scanner = TraccarScanner( api, hass, async_see, config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL), + config[CONF_MAX_ACCURACY], config[CONF_SKIP_ACCURACY_ON], config[CONF_MONITORED_CONDITIONS], config[CONF_EVENT]) return await scanner.async_init() @@ -99,9 +85,8 @@ async def async_setup_scanner(hass, config, async_see, discovery_info=None): class TraccarScanner: """Define an object to retrieve Traccar data.""" - def __init__(self, api, hass, async_see, scan_interval, - custom_attributes, - event_types): + def __init__(self, api, hass, async_see, scan_interval, max_accuracy, + skip_accuracy_on, custom_attributes, event_types): """Initialize.""" from stringcase import camelcase self._event_types = {camelcase(evt): evt for evt in event_types} @@ -111,6 +96,8 @@ def __init__(self, api, hass, async_see, scan_interval, self._api = api self.connected = False self._hass = hass + self._max_accuracy = max_accuracy + self._skip_accuracy_on = skip_accuracy_on async def async_init(self): """Further initialize connection to Traccar.""" @@ -148,6 +135,8 @@ async def import_device_data(self): device_info = self._api.device_info[device_unique_id] device = None attr = {} + skip_accuracy_filter = False + attr[ATTR_TRACKER] = 'traccar' if device_info.get('address') is not None: attr[ATTR_ADDRESS] = device_info['address'] @@ -157,8 +146,6 @@ async def import_device_data(self): attr[ATTR_CATEGORY] = device_info['category'] if device_info.get('speed') is not None: attr[ATTR_SPEED] = device_info['speed'] - if device_info.get('battery') is not None: - attr[ATTR_BATTERY_LEVEL] = device_info['battery'] if device_info.get('motion') is not None: attr[ATTR_MOTION] = device_info['motion'] if device_info.get('traccar_id') is not None: @@ -172,11 +159,24 @@ async def import_device_data(self): for custom_attr in self._custom_attributes: if device_info.get(custom_attr) is not None: attr[custom_attr] = device_info[custom_attr] + if custom_attr in self._skip_accuracy_on: + skip_accuracy_filter = True + + accuracy = 0.0 + if device_info.get('accuracy') is not None: + accuracy = device_info['accuracy'] + if (not skip_accuracy_filter and self._max_accuracy > 0 and + accuracy > self._max_accuracy): + _LOGGER.debug('Excluded position by accuracy filter: %f (%s)', + accuracy, attr[ATTR_TRACCAR_ID]) + continue + await self._async_see( dev_id=slugify(device_info['device_id']), gps=(device_info.get('latitude'), device_info.get('longitude')), - gps_accuracy=(device_info.get('accuracy')), + gps_accuracy=accuracy, + battery=device_info.get('battery'), attributes=attr) async def import_events(self): diff --git a/homeassistant/components/tradfri/config_flow.py b/homeassistant/components/tradfri/config_flow.py index bfabf4fd12a933..7cdf4b9de6c5a3 100644 --- a/homeassistant/components/tradfri/config_flow.py +++ b/homeassistant/components/tradfri/config_flow.py @@ -78,13 +78,22 @@ async def async_step_auth(self, user_input=None): async def async_step_zeroconf(self, user_input): """Handle zeroconf discovery.""" + host = user_input['host'] + + # pylint: disable=unsupported-assignment-operation + self.context['host'] = host + + if any(host == flow['context']['host'] + for flow in self._async_in_progress()): + return self.async_abort(reason='already_in_progress') + for entry in self._async_current_entries(): - if entry.data[CONF_HOST] == user_input['host']: + if entry.data[CONF_HOST] == host: return self.async_abort( reason='already_configured' ) - self._host = user_input['host'] + self._host = host return await self.async_step_auth() async_step_homekit = async_step_zeroconf diff --git a/homeassistant/components/tradfri/light.py b/homeassistant/components/tradfri/light.py index a2b2cdc7c49bd7..06530f6bad4faf 100644 --- a/homeassistant/components/tradfri/light.py +++ b/homeassistant/components/tradfri/light.py @@ -175,7 +175,7 @@ def device_info(self): 'manufacturer': info.manufacturer, 'model': info.model_number, 'sw_version': info.firmware_version, - 'via_hub': (TRADFRI_DOMAIN, self._gateway_id), + 'via_device': (TRADFRI_DOMAIN, self._gateway_id), } @property diff --git a/homeassistant/components/tradfri/strings.json b/homeassistant/components/tradfri/strings.json index 38c58486a6a738..868fbbed550c65 100644 --- a/homeassistant/components/tradfri/strings.json +++ b/homeassistant/components/tradfri/strings.json @@ -17,7 +17,8 @@ "timeout": "Timeout validating the code." }, "abort": { - "already_configured": "Bridge is already configured" + "already_configured": "Bridge is already configured.", + "already_in_progress": "Bridge configuration is already in progress." } } } diff --git a/homeassistant/components/tradfri/switch.py b/homeassistant/components/tradfri/switch.py index b7826624f525c0..6b1372c8d98525 100644 --- a/homeassistant/components/tradfri/switch.py +++ b/homeassistant/components/tradfri/switch.py @@ -61,7 +61,7 @@ def device_info(self): 'manufacturer': info.manufacturer, 'model': info.model_number, 'sw_version': info.firmware_version, - 'via_hub': (TRADFRI_DOMAIN, self._gateway_id), + 'via_device': (TRADFRI_DOMAIN, self._gateway_id), } async def async_added_to_hass(self): diff --git a/homeassistant/components/twilio/.translations/ca.json b/homeassistant/components/twilio/.translations/ca.json index 796f71ee7e7d7f..324ab0dd69aa56 100644 --- a/homeassistant/components/twilio/.translations/ca.json +++ b/homeassistant/components/twilio/.translations/ca.json @@ -10,7 +10,7 @@ "step": { "user": { "description": "Est\u00e0s segur que vols configurar Twilio?", - "title": "Configuraci\u00f3 del Webhook Twilio" + "title": "Configuraci\u00f3 del Webhook de Twilio" } }, "title": "Twilio" diff --git a/homeassistant/components/twilio/.translations/nl.json b/homeassistant/components/twilio/.translations/nl.json index fc8b5c08261234..842307c666bbdb 100644 --- a/homeassistant/components/twilio/.translations/nl.json +++ b/homeassistant/components/twilio/.translations/nl.json @@ -4,6 +4,9 @@ "not_internet_accessible": "Uw Home Assistant instantie moet toegankelijk zijn vanaf het internet om Twillo-berichten te ontvangen.", "one_instance_allowed": "Slechts \u00e9\u00e9n exemplaar is nodig." }, + "create_entry": { + "default": "Om evenementen naar de Home Assistant te verzenden, moet u [Webhooks with Twilio] ( {twilio_url} ) instellen. \n\n Vul de volgende info in: \n\n - URL: ` {webhook_url} ` \n - Methode: POST \n - Inhoudstype: application / x-www-form-urlencoded \n\n Zie [de documentatie] ( {docs_url} ) voor informatie over het configureren van automatiseringen om binnenkomende gegevens te verwerken." + }, "step": { "user": { "description": "Weet u zeker dat u Twilio wilt instellen?", diff --git a/homeassistant/components/ubee/device_tracker.py b/homeassistant/components/ubee/device_tracker.py index b81a2320b5e87a..c31e3f040aa5a6 100644 --- a/homeassistant/components/ubee/device_tracker.py +++ b/homeassistant/components/ubee/device_tracker.py @@ -22,6 +22,7 @@ vol.Any( 'EVW32C-0N', 'EVW320B', + 'EVW321B', 'EVW3200-Wifi', 'EVW3226@UPC', ), diff --git a/homeassistant/components/ubee/manifest.json b/homeassistant/components/ubee/manifest.json index f9f17e41546a4a..39ffe7686579f6 100644 --- a/homeassistant/components/ubee/manifest.json +++ b/homeassistant/components/ubee/manifest.json @@ -3,7 +3,7 @@ "name": "Ubee", "documentation": "https://www.home-assistant.io/components/ubee", "requirements": [ - "pyubee==0.6" + "pyubee==0.7" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/uber/__init__.py b/homeassistant/components/uber/__init__.py deleted file mode 100644 index b555f83fed90e2..00000000000000 --- a/homeassistant/components/uber/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The uber component.""" diff --git a/homeassistant/components/uber/manifest.json b/homeassistant/components/uber/manifest.json deleted file mode 100644 index a7db237ab91444..00000000000000 --- a/homeassistant/components/uber/manifest.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "domain": "uber", - "name": "Uber", - "documentation": "https://www.home-assistant.io/components/uber", - "requirements": [ - "uber_rides==0.6.0" - ], - "dependencies": [], - "codeowners": [ - "@robbiet480" - ] -} diff --git a/homeassistant/components/uber/sensor.py b/homeassistant/components/uber/sensor.py deleted file mode 100644 index 324124ca960bfb..00000000000000 --- a/homeassistant/components/uber/sensor.py +++ /dev/null @@ -1,231 +0,0 @@ -"""Support for the Uber API.""" -import logging -from datetime import timedelta - -import voluptuous as vol - -import homeassistant.helpers.config_validation as cv -from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.helpers.entity import Entity -from homeassistant.util import Throttle - -_LOGGER = logging.getLogger(__name__) - -CONF_END_LATITUDE = 'end_latitude' -CONF_END_LONGITUDE = 'end_longitude' -CONF_PRODUCT_IDS = 'product_ids' -CONF_SERVER_TOKEN = 'server_token' -CONF_START_LATITUDE = 'start_latitude' -CONF_START_LONGITUDE = 'start_longitude' - -ICON = 'mdi:taxi' - -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_SERVER_TOKEN): cv.string, - vol.Optional(CONF_START_LATITUDE): cv.latitude, - vol.Optional(CONF_START_LONGITUDE): cv.longitude, - vol.Optional(CONF_END_LATITUDE): cv.latitude, - vol.Optional(CONF_END_LONGITUDE): cv.longitude, - vol.Optional(CONF_PRODUCT_IDS): vol.All(cv.ensure_list, [cv.string]), -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Uber sensor.""" - from uber_rides.session import Session - - session = Session(server_token=config.get(CONF_SERVER_TOKEN)) - start_latitude = config.get(CONF_START_LATITUDE, hass.config.latitude) - start_longitude = config.get(CONF_START_LONGITUDE, hass.config.longitude) - end_latitude = config.get(CONF_END_LATITUDE) - end_longitude = config.get(CONF_END_LONGITUDE) - wanted_product_ids = config.get(CONF_PRODUCT_IDS) - - dev = [] - timeandpriceest = UberEstimate( - session, start_latitude, start_longitude, end_latitude, end_longitude) - - for product_id, product in timeandpriceest.products.items(): - if (wanted_product_ids is not None) and \ - (product_id not in wanted_product_ids): - continue - dev.append(UberSensor('time', timeandpriceest, product_id, product)) - - if product.get('price_details') is not None \ - and product['display_name'] != 'TAXI': - dev.append(UberSensor( - 'price', timeandpriceest, product_id, product)) - - add_entities(dev, True) - - -class UberSensor(Entity): - """Implementation of an Uber sensor.""" - - def __init__(self, sensorType, products, product_id, product): - """Initialize the Uber sensor.""" - self.data = products - self._product_id = product_id - self._product = product - self._sensortype = sensorType - self._name = '{} {}'.format( - self._product['display_name'], self._sensortype) - if self._sensortype == 'time': - self._unit_of_measurement = 'min' - time_estimate = self._product.get('time_estimate_seconds', 0) - self._state = int(time_estimate / 60) - elif self._sensortype == 'price': - if self._product.get('price_details') is not None: - price_details = self._product['price_details'] - self._unit_of_measurement = price_details.get('currency_code') - try: - if price_details.get('low_estimate') is not None: - statekey = 'minimum' - else: - statekey = 'low_estimate' - self._state = int(price_details.get(statekey)) - except TypeError: - self._state = 0 - else: - self._state = 0 - - @property - def name(self): - """Return the name of the sensor.""" - if 'uber' not in self._name.lower(): - self._name = 'Uber{}'.format(self._name) - 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.""" - time_estimate = self._product.get('time_estimate_seconds') - params = { - 'Product ID': self._product['product_id'], - 'Product short description': self._product['short_description'], - 'Product display name': self._product['display_name'], - 'Product description': self._product['description'], - 'Pickup time estimate (in seconds)': time_estimate, - 'Trip duration (in seconds)': self._product.get('duration'), - 'Vehicle Capacity': self._product['capacity'] - } - - if self._product.get('price_details') is not None: - price_details = self._product['price_details'] - dunit = price_details.get('distance_unit') - distance_key = 'Trip distance (in {}s)'.format(dunit) - distance_val = self._product.get('distance') - params['Cost per minute'] = price_details.get('cost_per_minute') - params['Distance units'] = price_details.get('distance_unit') - params['Cancellation fee'] = price_details.get('cancellation_fee') - cpd = price_details.get('cost_per_distance') - params['Cost per distance'] = cpd - params['Base price'] = price_details.get('base') - params['Minimum price'] = price_details.get('minimum') - params['Price estimate'] = price_details.get('estimate') - params['Price currency code'] = price_details.get('currency_code') - params['High price estimate'] = price_details.get('high_estimate') - params['Low price estimate'] = price_details.get('low_estimate') - params['Surge multiplier'] = price_details.get('surge_multiplier') - else: - distance_key = 'Trip distance (in miles)' - distance_val = self._product.get('distance') - - params[distance_key] = distance_val - - return {k: v for k, v in params.items() if v is not None} - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - - def update(self): - """Get the latest data from the Uber API and update the states.""" - self.data.update() - self._product = self.data.products[self._product_id] - if self._sensortype == 'time': - time_estimate = self._product.get('time_estimate_seconds', 0) - self._state = int(time_estimate / 60) - elif self._sensortype == 'price': - price_details = self._product.get('price_details') - if price_details is not None: - min_price = price_details.get('minimum') - self._state = int(price_details.get('low_estimate', min_price)) - else: - self._state = 0 - - -class UberEstimate: - """The class for handling the time and price estimate.""" - - def __init__(self, session, start_latitude, start_longitude, - end_latitude=None, end_longitude=None): - """Initialize the UberEstimate object.""" - self._session = session - self.start_latitude = start_latitude - self.start_longitude = start_longitude - self.end_latitude = end_latitude - self.end_longitude = end_longitude - self.products = None - self.update() - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): - """Get the latest product info and estimates from the Uber API.""" - from uber_rides.client import UberRidesClient - client = UberRidesClient(self._session) - - self.products = {} - - products_response = client.get_products( - self.start_latitude, self.start_longitude) - - products = products_response.json.get('products') - - for product in products: - self.products[product['product_id']] = product - - if self.end_latitude is not None and self.end_longitude is not None: - price_response = client.get_price_estimates( - self.start_latitude, self.start_longitude, - self.end_latitude, self.end_longitude) - - prices = price_response.json.get('prices', []) - - for price in prices: - product = self.products[price['product_id']] - product['duration'] = price.get('duration', '0') - product['distance'] = price.get('distance', '0') - price_details = product.get('price_details') - if product.get('price_details') is None: - price_details = {} - price_details['estimate'] = price.get('estimate', '0') - price_details['high_estimate'] = price.get( - 'high_estimate', '0') - price_details['low_estimate'] = price.get('low_estimate', '0') - price_details['currency_code'] = price.get('currency_code') - surge_multiplier = price.get('surge_multiplier', '0') - price_details['surge_multiplier'] = surge_multiplier - product['price_details'] = price_details - - estimate_response = client.get_pickup_time_estimates( - self.start_latitude, self.start_longitude) - - estimates = estimate_response.json.get('times') - - for estimate in estimates: - self.products[estimate['product_id']][ - 'time_estimate_seconds'] = estimate.get('estimate', '0') diff --git a/homeassistant/components/unifi/.translations/ru.json b/homeassistant/components/unifi/.translations/ru.json index c061ab36e7bd55..f4d86300acaed2 100644 --- a/homeassistant/components/unifi/.translations/ru.json +++ b/homeassistant/components/unifi/.translations/ru.json @@ -15,7 +15,7 @@ "password": "\u041f\u0430\u0440\u043e\u043b\u044c", "port": "\u041f\u043e\u0440\u0442", "site": "ID \u0441\u0430\u0439\u0442\u0430", - "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f", + "username": "\u041b\u043e\u0433\u0438\u043d", "verify_ssl": "\u041a\u043e\u043d\u0442\u0440\u043e\u043b\u043b\u0435\u0440 \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" }, "title": "UniFi Controller" diff --git a/homeassistant/components/unifi/config_flow.py b/homeassistant/components/unifi/config_flow.py index b784aaa705ad9d..1ffb8d942100d2 100644 --- a/homeassistant/components/unifi/config_flow.py +++ b/homeassistant/components/unifi/config_flow.py @@ -5,8 +5,7 @@ from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME, CONF_VERIFY_SSL) -from .const import (CONF_CONTROLLER, CONF_POE_CONTROL, CONF_SITE_ID, - DOMAIN, LOGGER) +from .const import CONF_CONTROLLER, CONF_SITE_ID, DOMAIN, LOGGER from .controller import get_controller from .errors import ( AlreadyConfigured, AuthenticationRequired, CannotConnect, UserLevel) @@ -84,6 +83,7 @@ async def async_step_site(self, user_input=None): try: desc = user_input.get(CONF_SITE_ID, self.desc) + print(self.sites) for site in self.sites.values(): if desc == site['desc']: if site['role'] != 'admin': @@ -98,8 +98,7 @@ async def async_step_site(self, user_input=None): raise AlreadyConfigured data = { - CONF_CONTROLLER: self.config, - CONF_POE_CONTROL: True + CONF_CONTROLLER: self.config } return self.async_create_entry( diff --git a/homeassistant/components/unifi/const.py b/homeassistant/components/unifi/const.py index 4d65a0d223a397..7353a9d302b2c3 100644 --- a/homeassistant/components/unifi/const.py +++ b/homeassistant/components/unifi/const.py @@ -7,5 +7,4 @@ CONTROLLER_ID = '{host}-{site}' CONF_CONTROLLER = 'controller' -CONF_POE_CONTROL = 'poe_control' CONF_SITE_ID = 'site' diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index 2b9aa89fef24a7..d0600315c013b5 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -1,14 +1,18 @@ """UniFi Controller abstraction.""" import asyncio +import ssl import async_timeout from aiohttp import CookieJar +import aiounifi + from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.const import CONF_HOST from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.dispatcher import async_dispatcher_send -from .const import CONF_CONTROLLER, CONF_POE_CONTROL, LOGGER +from .const import CONF_CONTROLLER, CONF_SITE_ID, CONTROLLER_ID, LOGGER from .errors import AuthenticationRequired, CannotConnect @@ -36,7 +40,57 @@ def mac(self): return client.mac return None - async def async_setup(self, tries=0): + @property + def event_update(self): + """Event specific per UniFi entry to signal new data.""" + return 'unifi-update-{}'.format( + CONTROLLER_ID.format( + host=self.host, + site=self.config_entry.data[CONF_CONTROLLER][CONF_SITE_ID])) + + async def request_update(self): + """Request an update.""" + if self.progress is not None: + return await self.progress + + self.progress = self.hass.async_create_task(self.async_update()) + await self.progress + + self.progress = None + + async def async_update(self): + """Update UniFi controller information.""" + failed = False + + try: + with async_timeout.timeout(4): + await self.api.clients.update() + await self.api.devices.update() + + except aiounifi.LoginRequired: + try: + with async_timeout.timeout(5): + await self.api.login() + + except (asyncio.TimeoutError, aiounifi.AiounifiException): + failed = True + if self.available: + LOGGER.error('Unable to reach controller %s', self.host) + self.available = False + + except (asyncio.TimeoutError, aiounifi.AiounifiException): + failed = True + if self.available: + LOGGER.error('Unable to reach controller %s', self.host) + self.available = False + + if not failed and not self.available: + LOGGER.info('Reconnected to controller %s', self.host) + self.available = True + + async_dispatcher_send(self.hass, self.event_update) + + async def async_setup(self): """Set up a UniFi controller.""" hass = self.hass @@ -53,10 +107,9 @@ async def async_setup(self, tries=0): 'Unknown error connecting with UniFi controller.') return False - if self.config_entry.data[CONF_POE_CONTROL]: - hass.async_create_task( - hass.config_entries.async_forward_entry_setup( - self.config_entry, 'switch')) + hass.async_create_task( + hass.config_entries.async_forward_entry_setup( + self.config_entry, 'switch')) return True @@ -70,26 +123,26 @@ async def async_reset(self): if self.api is None: return True - if self.config_entry.data[CONF_POE_CONTROL]: - return await self.hass.config_entries.async_forward_entry_unload( - self.config_entry, 'switch') - return True + return await self.hass.config_entries.async_forward_entry_unload( + self.config_entry, 'switch') async def get_controller( hass, host, username, password, port, site, verify_ssl): """Create a controller object and verify authentication.""" - import aiounifi + sslcontext = None if verify_ssl: session = aiohttp_client.async_get_clientsession(hass) + if isinstance(verify_ssl, str): + sslcontext = ssl.create_default_context(cafile=verify_ssl) else: session = aiohttp_client.async_create_clientsession( hass, verify_ssl=verify_ssl, cookie_jar=CookieJar(unsafe=True)) controller = aiounifi.Controller( host, username=username, password=password, port=port, site=site, - websession=session + websession=session, sslcontext=sslcontext ) try: diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index 8bf384eef14f72..30754273254a46 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -1,8 +1,13 @@ """Support for Unifi WAP controllers.""" +import asyncio import logging from datetime import timedelta import voluptuous as vol +import async_timeout + +import aiounifi + import homeassistant.helpers.config_validation as cv from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) @@ -10,6 +15,9 @@ from homeassistant.const import CONF_VERIFY_SSL, CONF_MONITORED_CONDITIONS import homeassistant.util.dt as dt_util +from .controller import get_controller +from .errors import AuthenticationRequired, CannotConnect + _LOGGER = logging.getLogger(__name__) CONF_PORT = 'port' CONF_SITE_ID = 'site_id' @@ -54,10 +62,8 @@ }) -def get_scanner(hass, config): +async def async_get_scanner(hass, config): """Set up the Unifi device_tracker.""" - from pyunifi.controller import Controller, APIError - host = config[DOMAIN].get(CONF_HOST) username = config[DOMAIN].get(CONF_USERNAME) password = config[DOMAIN].get(CONF_PASSWORD) @@ -69,9 +75,11 @@ def get_scanner(hass, config): ssid_filter = config[DOMAIN].get(CONF_SSID_FILTER) try: - ctrl = Controller(host, username, password, port, version='v4', - site_id=site_id, ssl_verify=verify_ssl) - except APIError as ex: + controller = await get_controller( + hass, host, username, password, port, site_id, verify_ssl) + await controller.initialize() + + except (AuthenticationRequired, CannotConnect) as ex: _LOGGER.error("Failed to connect to Unifi: %s", ex) hass.components.persistent_notification.create( 'Failed to connect to Unifi. ' @@ -82,8 +90,8 @@ def get_scanner(hass, config): notification_id=NOTIFICATION_ID) return False - return UnifiScanner(ctrl, detection_time, ssid_filter, - monitored_conditions) + return UnifiScanner( + controller, detection_time, ssid_filter, monitored_conditions) class UnifiScanner(DeviceScanner): @@ -92,36 +100,45 @@ class UnifiScanner(DeviceScanner): def __init__(self, controller, detection_time: timedelta, ssid_filter, monitored_conditions) -> None: """Initialize the scanner.""" + self.controller = controller self._detection_time = detection_time - self._controller = controller self._ssid_filter = ssid_filter self._monitored_conditions = monitored_conditions - self._update() + self._clients = {} - def _update(self): + async def async_update(self): """Get the clients from the device.""" - from pyunifi.controller import APIError try: - clients = self._controller.get_clients() - except APIError as ex: - _LOGGER.error("Failed to scan clients: %s", ex) + await self.controller.clients.update() + clients = self.controller.clients.values() + + except aiounifi.LoginRequired: + try: + with async_timeout.timeout(5): + await self.controller.login() + except (asyncio.TimeoutError, aiounifi.AiounifiException): + clients = [] + + except aiounifi.AiounifiException: clients = [] # Filter clients to provided SSID list if self._ssid_filter: - clients = [client for client in clients - if 'essid' in client and - client['essid'] in self._ssid_filter] + clients = [ + client for client in clients + if client.essid in self._ssid_filter + ] self._clients = { - client['mac']: client + client.raw['mac']: client.raw for client in clients if (dt_util.utcnow() - dt_util.utc_from_timestamp(float( - client['last_seen']))) < self._detection_time} + client.last_seen))) < self._detection_time + } - def scan_devices(self): + async def async_scan_devices(self): """Scan for devices.""" - self._update() + await self.async_update() return self._clients.keys() def get_device_name(self, device): diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index 22ece5addafb6a..64119bae2fecb5 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -4,8 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/components/unifi", "requirements": [ - "aiounifi==4", - "pyunifi==2.16" + "aiounifi==6" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index 5f33a9c08d35fb..dd6fc1ff1a20d5 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -1,15 +1,13 @@ """Support for devices connected to UniFi POE.""" -import asyncio from datetime import timedelta import logging -import async_timeout - from homeassistant.components import unifi from homeassistant.components.switch import SwitchDevice from homeassistant.const import CONF_HOST from homeassistant.core import callback from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.dispatcher import async_dispatcher_connect from .const import CONF_CONTROLLER, CONF_SITE_ID, CONTROLLER_ID @@ -36,79 +34,23 @@ async def async_setup_entry(hass, config_entry, async_add_entities): controller = hass.data[unifi.DOMAIN][controller_id] switches = {} - progress = None - update_progress = set() - - async def request_update(object_id): - """Request an update.""" - nonlocal progress - update_progress.add(object_id) - - if progress is not None: - return await progress - - progress = asyncio.ensure_future(update_controller()) - result = await progress - progress = None - update_progress.clear() - return result - - async def update_controller(): + @callback + def update_controller(): """Update the values of the controller.""" - tasks = [async_update_items( - controller, async_add_entities, request_update, - switches, update_progress - )] - await asyncio.wait(tasks) + update_items(controller, async_add_entities, switches) - await update_controller() + async_dispatcher_connect(hass, controller.event_update, update_controller) + update_controller() -async def async_update_items(controller, async_add_entities, - request_controller_update, switches, - progress_waiting): - """Update POE port state from the controller.""" - import aiounifi - - @callback - def update_switch_state(): - """Tell switches to reload state.""" - for client_id, client in switches.items(): - if client_id not in progress_waiting: - client.async_schedule_update_ha_state() - - try: - with async_timeout.timeout(4): - await controller.api.clients.update() - await controller.api.devices.update() - - except aiounifi.LoginRequired: - try: - with async_timeout.timeout(5): - await controller.api.login() - except (asyncio.TimeoutError, aiounifi.AiounifiException): - if controller.available: - controller.available = False - update_switch_state() - return - - except (asyncio.TimeoutError, aiounifi.AiounifiException): - if controller.available: - LOGGER.error('Unable to reach controller %s', controller.host) - controller.available = False - update_switch_state() - return - - if not controller.available: - LOGGER.info('Reconnected to controller %s', controller.host) - controller.available = True +@callback +def update_items(controller, async_add_entities, switches): + """Update POE port state from the controller.""" new_switches = [] devices = controller.api.devices - for client_id in controller.api.clients: - if client_id in progress_waiting: - continue + for client_id in controller.api.clients: if client_id in switches: LOGGER.debug("Updating UniFi switch %s (%s)", @@ -137,8 +79,7 @@ def update_switch_state(): if multi_clients_on_port: continue - switches[client_id] = UniFiSwitch( - client, controller, request_controller_update) + switches[client_id] = UniFiSwitch(client, controller) new_switches.append(switches[client_id]) LOGGER.debug("New UniFi switch %s (%s)", client.hostname, client.mac) @@ -149,18 +90,17 @@ def update_switch_state(): class UniFiSwitch(SwitchDevice): """Representation of a client that uses POE.""" - def __init__(self, client, controller, request_controller_update): + def __init__(self, client, controller): """Set up switch.""" self.client = client self.controller = controller self.poe_mode = None if self.port.poe_mode != 'off': self.poe_mode = self.port.poe_mode - self.async_request_controller_update = request_controller_update async def async_update(self): """Synchronize state with controller.""" - await self.async_request_controller_update(self.client.mac) + await self.controller.request_update() @property def name(self): diff --git a/homeassistant/components/upnp/.translations/nl.json b/homeassistant/components/upnp/.translations/nl.json index 5d426f2edafee9..a94471bb6102df 100644 --- a/homeassistant/components/upnp/.translations/nl.json +++ b/homeassistant/components/upnp/.translations/nl.json @@ -8,6 +8,10 @@ "no_sensors_or_port_mapping": "Schakel ten minste sensoren of poorttoewijzing in", "single_instance_allowed": "Er is slechts \u00e9\u00e9n configuratie van UPnP/IGD nodig." }, + "error": { + "one": "Een", + "other": "Ander" + }, "step": { "confirm": { "description": "Wilt u UPnP/IGD instellen?", diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index ff32000d5f0729..c432a2695ff568 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -3,7 +3,7 @@ "name": "Velbus", "documentation": "https://www.home-assistant.io/components/velbus", "requirements": [ - "python-velbus==2.0.24" + "python-velbus==2.0.26" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/velux/cover.py b/homeassistant/components/velux/cover.py index 3c1b6ecb1eb0ff..68e25f7a61fe9c 100644 --- a/homeassistant/components/velux/cover.py +++ b/homeassistant/components/velux/cover.py @@ -60,7 +60,16 @@ def current_cover_position(self): @property def device_class(self): - """Define this cover as a window.""" + """Define this cover as either window/blind/awning/shutter.""" + from pyvlx.opening_device import Blind, RollerShutter, Window, Awning + if isinstance(self.node, Window): + return 'window' + if isinstance(self.node, Blind): + return 'blind' + if isinstance(self.node, RollerShutter): + return 'shutter' + if isinstance(self.node, Awning): + return 'awning' return 'window' @property diff --git a/homeassistant/components/vera/manifest.json b/homeassistant/components/vera/manifest.json index 7b475c437c3cab..99492753edb960 100644 --- a/homeassistant/components/vera/manifest.json +++ b/homeassistant/components/vera/manifest.json @@ -3,7 +3,7 @@ "name": "Vera", "documentation": "https://www.home-assistant.io/components/vera", "requirements": [ - "pyvera==0.2.45" + "pyvera==0.3.1" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/vlc_telnet/__init__.py b/homeassistant/components/vlc_telnet/__init__.py new file mode 100644 index 00000000000000..91a3eb35444b94 --- /dev/null +++ b/homeassistant/components/vlc_telnet/__init__.py @@ -0,0 +1 @@ +"""The vlc component.""" diff --git a/homeassistant/components/vlc_telnet/manifest.json b/homeassistant/components/vlc_telnet/manifest.json new file mode 100644 index 00000000000000..1e0f1c71df5061 --- /dev/null +++ b/homeassistant/components/vlc_telnet/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "vlc_telnet", + "name": "VLC telnet", + "documentation": "https://www.home-assistant.io/components/vlc-telnet", + "requirements": [ + "python-telnet-vlc==1.0.4" + ], + "dependencies": [], + "codeowners": ["@rodripf"] +} diff --git a/homeassistant/components/vlc_telnet/media_player.py b/homeassistant/components/vlc_telnet/media_player.py new file mode 100644 index 00000000000000..096afcc1044fca --- /dev/null +++ b/homeassistant/components/vlc_telnet/media_player.py @@ -0,0 +1,233 @@ +"""Provide functionality to interact with the vlc telnet interface.""" +import logging +import voluptuous as vol + +from python_telnet_vlc import VLCTelnet, ConnectionError as ConnErr + +from homeassistant.components.media_player import ( + MediaPlayerDevice, PLATFORM_SCHEMA) +from homeassistant.components.media_player.const import ( + MEDIA_TYPE_MUSIC, SUPPORT_PAUSE, SUPPORT_PLAY, + SUPPORT_PLAY_MEDIA, SUPPORT_STOP, SUPPORT_VOLUME_MUTE, + SUPPORT_VOLUME_SET, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, + SUPPORT_NEXT_TRACK, SUPPORT_CLEAR_PLAYLIST, SUPPORT_SHUFFLE_SET) +from homeassistant.const import ( + CONF_NAME, STATE_IDLE, STATE_PAUSED, STATE_PLAYING, STATE_UNAVAILABLE, + CONF_HOST, CONF_PORT, CONF_PASSWORD) +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'vlc_telnet' + +DEFAULT_NAME = 'VLC-TELNET' +DEFAULT_PORT = 4212 + +SUPPORT_VLC = SUPPORT_PAUSE | SUPPORT_SEEK | SUPPORT_VOLUME_SET \ + | SUPPORT_VOLUME_MUTE | SUPPORT_PREVIOUS_TRACK \ + | SUPPORT_NEXT_TRACK | SUPPORT_PLAY_MEDIA | SUPPORT_STOP \ + | SUPPORT_CLEAR_PLAYLIST | SUPPORT_PLAY \ + | SUPPORT_SHUFFLE_SET +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.positive_int, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the vlc platform.""" + add_entities([VlcDevice(config.get(CONF_NAME), + config.get(CONF_HOST), + config.get(CONF_PORT), + config.get(CONF_PASSWORD))], True) + + +class VlcDevice(MediaPlayerDevice): + """Representation of a vlc player.""" + + def __init__(self, name, host, port, passwd): + """Initialize the vlc device.""" + self._instance = None + self._name = name + self._volume = None + self._muted = None + self._state = STATE_UNAVAILABLE + self._media_position_updated_at = None + self._media_position = None + self._media_duration = None + self._host = host + self._port = port + self._password = passwd + self._vlc = None + self._available = False + self._volume_bkp = 0 + self._media_artist = "" + self._media_title = "" + + def update(self): + """Get the latest details from the device.""" + if self._vlc is None: + try: + self._vlc = VLCTelnet(self._host, self._password, self._port) + self._state = STATE_IDLE + self._available = True + except (ConnErr, EOFError): + self._available = False + self._vlc = None + else: + try: + status = self._vlc.status() + if status: + if 'volume' in status: + self._volume = int(status['volume']) / 500.0 + else: + self._volume = None + if 'state' in status: + state = status["state"] + if state == "playing": + self._state = STATE_PLAYING + elif state == "paused": + self._state = STATE_PAUSED + else: + self._state = STATE_IDLE + else: + self._state = STATE_IDLE + + self._media_duration = self._vlc.get_length() + self._media_position = self._vlc.get_time() + + info = self._vlc.info() + if info: + self._media_artist = info[0].get('artist') + self._media_title = info[0].get('title') + + except (ConnErr, EOFError): + self._available = False + self._vlc = None + + 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.""" + return self._state + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._available + + @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_VLC + + @property + def media_content_type(self): + """Content type of current playing media.""" + return MEDIA_TYPE_MUSIC + + @property + def media_duration(self): + """Duration of current playing media in seconds.""" + return self._media_duration + + @property + def media_position(self): + """Position of current playing media in seconds.""" + return self._media_position + + @property + def media_position_updated_at(self): + """When was the position of the current playing media valid.""" + return self._media_position_updated_at + + @property + def media_title(self): + """Title of current playing media.""" + return self._media_title + + @property + def media_artist(self): + """Artist of current playing media, music track only.""" + return self._media_artist + + def media_seek(self, position): + """Seek the media to a specific location.""" + track_length = self._vlc.get_length() / 1000 + self._vlc.seek(position / track_length) + + def mute_volume(self, mute): + """Mute the volume.""" + if mute: + self._volume_bkp = self._volume + self._volume = 0 + self._vlc.set_volume("0") + else: + self._vlc.set_volume(str(self._volume_bkp)) + self._volume = self._volume_bkp + + self._muted = mute + + def set_volume_level(self, volume): + """Set volume level, range 0..1.""" + self._vlc.set_volume(str(volume * 500)) + self._volume = volume + + def media_play(self): + """Send play command.""" + self._vlc.play() + self._state = STATE_PLAYING + + def media_pause(self): + """Send pause command.""" + self._vlc.pause() + self._state = STATE_PAUSED + + def media_stop(self): + """Send stop command.""" + self._vlc.stop() + self._state = STATE_IDLE + + def play_media(self, media_type, media_id, **kwargs): + """Play media from a URL or file.""" + if media_type != MEDIA_TYPE_MUSIC: + _LOGGER.error( + "Invalid media type %s. Only %s is supported", + media_type, MEDIA_TYPE_MUSIC) + return + self._vlc.add(media_id) + self._state = STATE_PLAYING + + def media_previous_track(self): + """Send previous track command.""" + self._vlc.prev() + + def media_next_track(self): + """Send next track command.""" + self._vlc.next() + + def clear_playlist(self): + """Clear players playlist.""" + self._vlc.clear() + + def set_shuffle(self, shuffle): + """Enable/disable shuffle mode.""" + self._vlc.random(shuffle) diff --git a/homeassistant/components/waze_travel_time/manifest.json b/homeassistant/components/waze_travel_time/manifest.json index 64b384356ce7ca..09ae4f812d7ade 100644 --- a/homeassistant/components/waze_travel_time/manifest.json +++ b/homeassistant/components/waze_travel_time/manifest.json @@ -3,7 +3,7 @@ "name": "Waze travel time", "documentation": "https://www.home-assistant.io/components/waze_travel_time", "requirements": [ - "WazeRouteCalculator==0.9" + "WazeRouteCalculator==0.10" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/waze_travel_time/sensor.py b/homeassistant/components/waze_travel_time/sensor.py index 282637b15076b7..af0014d24b3835 100644 --- a/homeassistant/components/waze_travel_time/sensor.py +++ b/homeassistant/components/waze_travel_time/sensor.py @@ -1,17 +1,18 @@ """Support for Waze travel time sensor.""" from datetime import timedelta import logging +import re import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( ATTR_ATTRIBUTION, CONF_NAME, CONF_REGION, EVENT_HOMEASSISTANT_START, - ATTR_LATITUDE, ATTR_LONGITUDE) + ATTR_LATITUDE, ATTR_LONGITUDE, CONF_UNIT_SYSTEM_METRIC, + CONF_UNIT_SYSTEM_IMPERIAL) import homeassistant.helpers.config_validation as cv from homeassistant.helpers import location from homeassistant.helpers.entity import Entity -from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) @@ -26,18 +27,21 @@ CONF_INCL_FILTER = 'incl_filter' CONF_EXCL_FILTER = 'excl_filter' CONF_REALTIME = 'realtime' +CONF_UNITS = 'units' +CONF_VEHICLE_TYPE = 'vehicle_type' DEFAULT_NAME = 'Waze Travel Time' DEFAULT_REALTIME = True +DEFAULT_VEHICLE_TYPE = 'car' ICON = 'mdi:car' +UNITS = [CONF_UNIT_SYSTEM_METRIC, CONF_UNIT_SYSTEM_IMPERIAL] + REGIONS = ['US', 'NA', 'EU', 'IL', 'AU'] +VEHICLE_TYPES = ['car', 'taxi', 'motorcycle'] SCAN_INTERVAL = timedelta(minutes=5) -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) - -TRACKABLE_DOMAINS = ['device_tracker', 'sensor', 'zone', 'person'] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_ORIGIN): cv.string, @@ -47,6 +51,9 @@ vol.Optional(CONF_INCL_FILTER): cv.string, vol.Optional(CONF_EXCL_FILTER): cv.string, vol.Optional(CONF_REALTIME, default=DEFAULT_REALTIME): cv.boolean, + vol.Optional(CONF_VEHICLE_TYPE, + default=DEFAULT_VEHICLE_TYPE): vol.In(VEHICLE_TYPES), + vol.Optional(CONF_UNITS): vol.In(UNITS) }) @@ -59,9 +66,14 @@ def setup_platform(hass, config, add_entities, discovery_info=None): incl_filter = config.get(CONF_INCL_FILTER) excl_filter = config.get(CONF_EXCL_FILTER) realtime = config.get(CONF_REALTIME) + vehicle_type = config.get(CONF_VEHICLE_TYPE) + units = config.get(CONF_UNITS, hass.config.units.name) + + data = WazeTravelTimeData(None, None, region, incl_filter, + excl_filter, realtime, units, + vehicle_type) - sensor = WazeTravelTime(name, origin, destination, region, - incl_filter, excl_filter, realtime) + sensor = WazeTravelTime(name, origin, destination, data) add_entities([sensor]) @@ -79,27 +91,28 @@ def _get_location_from_attributes(state): class WazeTravelTime(Entity): """Representation of a Waze travel time sensor.""" - def __init__(self, name, origin, destination, region, - incl_filter, excl_filter, realtime): + def __init__(self, name, origin, destination, waze_data): """Initialize the Waze travel time sensor.""" self._name = name - self._region = region - self._incl_filter = incl_filter - self._excl_filter = excl_filter - self._realtime = realtime + self._waze_data = waze_data self._state = None self._origin_entity_id = None self._destination_entity_id = None - if origin.split('.', 1)[0] in TRACKABLE_DOMAINS: + # Attempt to find entity_id without finding address with period. + pattern = "(? bool: """Return true / false if nightlight is currently enabled.""" if self.bulb is None: return False - return self.bulb.last_properties.get('active_mode') == '1' + return self._active_mode == ACTIVE_MODE_NIGHTLIGHT @property def is_nightlight_supported(self) -> bool: """Return true / false if nightlight is supported.""" - return self.bulb.get_model_specs().get('night_light', False) + if self.model: + return self.bulb.get_model_specs().get('night_light', False) + + return self._active_mode is not None + + @property + def _active_mode(self): + return self.bulb.last_properties.get('active_mode') @property - def is_ambilight_supported(self) -> bool: - """Return true / false if ambilight is supported.""" - return self.bulb.get_model_specs().get('background_light', False) + def type(self): + """Return bulb type.""" + if not self._device_type: + self._device_type = self.bulb.bulb_type + + return self._device_type def turn_on(self, duration=DEFAULT_TRANSITION, light_type=None): """Turn on device.""" @@ -242,17 +254,16 @@ def turn_on(self, duration=DEFAULT_TRANSITION, light_type=None): self.bulb.turn_on(duration=duration, light_type=light_type) except BulbException as ex: _LOGGER.error("Unable to turn the bulb on: %s", ex) - return def turn_off(self, duration=DEFAULT_TRANSITION, light_type=None): """Turn off device.""" try: self.bulb.turn_off(duration=duration, light_type=light_type) except BulbException as ex: - _LOGGER.error("Unable to turn the bulb off: %s", ex) - return + _LOGGER.error("Unable to turn the bulb off: %s, %s: %s", + self.ipaddr, self.name, ex) - def update(self): + def _update_properties(self): """Read new properties from the device.""" if not self.bulb: return @@ -260,9 +271,29 @@ def update(self): try: self.bulb.get_properties(UPDATE_REQUEST_PROPERTIES) self._available = True + if not self._initialized: + self._initialize_device() except BulbException as ex: if self._available: # just inform once - _LOGGER.error("Unable to update bulb status: %s", ex) + _LOGGER.error("Unable to update device %s, %s: %s", + self.ipaddr, self.name, ex) self._available = False + return self._available + + def _initialize_device(self): + self._initialized = True + dispatcher_send(self._hass, DEVICE_INITIALIZED, self.ipaddr) + + def update(self): + """Update device properties and send data updated signal.""" + self._update_properties() dispatcher_send(self._hass, DATA_UPDATED.format(self._ipaddr)) + + def setup(self): + """Fetch initial device properties.""" + initial_update = self._update_properties() + + # We can build correct class anyway. + if not initial_update and self.model: + self._initialize_device() diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py index 33116d973e990d..1abb05e784ff02 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -28,15 +28,14 @@ SUPPORT_YEELIGHT = (SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION | - SUPPORT_FLASH) + SUPPORT_FLASH | + SUPPORT_EFFECT) SUPPORT_YEELIGHT_WHITE_TEMP = (SUPPORT_YEELIGHT | SUPPORT_COLOR_TEMP) -SUPPORT_YEELIGHT_RGB = (SUPPORT_YEELIGHT | - SUPPORT_COLOR | - SUPPORT_EFFECT | - SUPPORT_COLOR_TEMP) +SUPPORT_YEELIGHT_RGB = (SUPPORT_YEELIGHT_WHITE_TEMP | + SUPPORT_COLOR) ATTR_MODE = 'mode' @@ -61,24 +60,46 @@ EFFECT_TWITTER = "Twitter" EFFECT_STOP = "Stop" -YEELIGHT_EFFECT_LIST = [ - EFFECT_DISCO, +YEELIGHT_TEMP_ONLY_EFFECT_LIST = [ EFFECT_TEMP, + EFFECT_STOP, +] + +YEELIGHT_MONO_EFFECT_LIST = [ + EFFECT_DISCO, EFFECT_STROBE, - EFFECT_STROBE_COLOR, EFFECT_ALARM, - EFFECT_POLICE, EFFECT_POLICE2, + EFFECT_WHATSAPP, + EFFECT_FACEBOOK, + EFFECT_TWITTER, + *YEELIGHT_TEMP_ONLY_EFFECT_LIST +] + +YEELIGHT_COLOR_EFFECT_LIST = [ + EFFECT_STROBE_COLOR, + EFFECT_POLICE, EFFECT_CHRISTMAS, EFFECT_RGB, EFFECT_RANDOM_LOOP, EFFECT_FAST_RANDOM_LOOP, EFFECT_LSD, EFFECT_SLOWDOWN, - EFFECT_WHATSAPP, - EFFECT_FACEBOOK, - EFFECT_TWITTER, - EFFECT_STOP] + *YEELIGHT_MONO_EFFECT_LIST +] + +MODEL_TO_DEVICE_TYPE = { + 'mono': BulbType.White, + 'mono1': BulbType.White, + 'color': BulbType.Color, + 'color1': BulbType.Color, + 'color2': BulbType.Color, + 'strip1': BulbType.Color, + 'bslamp1': BulbType.Color, + 'ceiling1': BulbType.WhiteTemp, + 'ceiling2': BulbType.WhiteTemp, + 'ceiling3': BulbType.WhiteTemp, + 'ceiling4': BulbType.WhiteTempMood} def _transitions_config_parser(transitions): @@ -137,11 +158,28 @@ def setup_platform(hass, config, add_entities, discovery_info=None): custom_effects = _parse_custom_effects(discovery_info[CONF_CUSTOM_EFFECTS]) - lights = [YeelightLight(device, custom_effects=custom_effects)] - - if device.is_ambilight_supported: - lights.append( - YeelightAmbientLight(device, custom_effects=custom_effects)) + lights = [] + + if device.model: + device_type = MODEL_TO_DEVICE_TYPE.get(device.model, None) + else: + device_type = device.type + + def _lights_setup_helper(klass): + lights.append(klass(device, custom_effects=custom_effects)) + + if device_type == BulbType.White: + _lights_setup_helper(YeelightGenericLight) + elif device_type == BulbType.Color: + _lights_setup_helper(YeelightColorLight) + elif device_type == BulbType.WhiteTemp: + _lights_setup_helper(YeelightWhiteTempLight) + elif device_type == BulbType.WhiteTempMood: + _lights_setup_helper(YeelightWithAmbientLight) + _lights_setup_helper(YeelightAmbientLight) + else: + _LOGGER.error("Cannot determine device type for %s, %s", + device.ipaddr, device.name) hass.data[data_key] += lights add_entities(lights, True) @@ -179,23 +217,21 @@ def service_handler(service): schema=service_schema_start_flow) -class YeelightLight(Light): - """Representation of a Yeelight light.""" +class YeelightGenericLight(Light): + """Representation of a Yeelight generic light.""" def __init__(self, device, custom_effects=None): """Initialize the Yeelight light.""" self.config = device.config self._device = device - self._supported_features = SUPPORT_YEELIGHT - self._brightness = None self._color_temp = None - self._is_on = None self._hs = None - self._min_mireds = None - self._max_mireds = None + model_specs = self._bulb.get_model_specs() + self._min_mireds = kelvin_to_mired(model_specs['color_temp']['max']) + self._max_mireds = kelvin_to_mired(model_specs['color_temp']['min']) self._light_type = LightType.Main @@ -229,17 +265,20 @@ def available(self) -> bool: @property def supported_features(self) -> int: """Flag supported features.""" - return self._supported_features + return SUPPORT_YEELIGHT @property def effect_list(self): """Return the list of supported effects.""" - return YEELIGHT_EFFECT_LIST + self.custom_effects_names + return self._predefined_effects + self.custom_effects_names @property def color_temp(self) -> int: """Return the color temperature.""" - return self._color_temp + temp = self._get_property('ct') + if temp: + self._color_temp = temp + return kelvin_to_mired(int(self._color_temp)) @property def name(self) -> str: @@ -249,12 +288,15 @@ def name(self) -> str: @property def is_on(self) -> bool: """Return true if device is on.""" - return self._is_on + return self._get_property(self._power_property) == 'on' @property def brightness(self) -> int: """Return the brightness of this light between 1..255.""" - return self._brightness + temp = self._get_property(self._brightness_property) + if temp: + self._brightness = temp + return round(255 * (int(self._brightness) / 100)) @property def min_mireds(self): @@ -281,6 +323,46 @@ def light_type(self): """Return light type.""" return self._light_type + @property + def hs_color(self) -> tuple: + """Return the color property.""" + return self._hs + + # F821: https://github.com/PyCQA/pyflakes/issues/373 + @property + def _bulb(self) -> 'Bulb': # noqa: F821 + return self.device.bulb + + @property + def _properties(self) -> dict: + if self._bulb is None: + return {} + return self._bulb.last_properties + + def _get_property(self, prop, default=None): + return self._properties.get(prop, default) + + @property + def _brightness_property(self): + return 'bright' + + @property + def _power_property(self): + return 'power' + + @property + def _predefined_effects(self): + return YEELIGHT_MONO_EFFECT_LIST + + @property + def device(self): + """Return yeelight device.""" + return self._device + + def update(self): + """Update light properties.""" + self._hs = self._get_hs_from_properties() + def _get_hs_from_properties(self): rgb = self._get_property('rgb') color_mode = self._get_property('color_mode') @@ -290,7 +372,7 @@ def _get_hs_from_properties(self): color_mode = int(color_mode) if color_mode == 2: # color temperature - temp_in_k = mired_to_kelvin(self._color_temp) + temp_in_k = mired_to_kelvin(self.color_temp) return color_util.color_temperature_to_hs(temp_in_k) if color_mode == 3: # hsv hue = int(self._get_property('hue')) @@ -305,34 +387,6 @@ def _get_hs_from_properties(self): return color_util.color_RGB_to_hs(red, green, blue) - @property - def hs_color(self) -> tuple: - """Return the color property.""" - return self._hs - - @property - def _properties(self) -> dict: - if self._bulb is None: - return {} - return self._bulb.last_properties - - def _get_property(self, prop, default=None): - return self._properties.get(prop, default) - - @property - def device(self): - """Return yeelight device.""" - return self._device - - @property - def _is_nightlight_enabled(self): - return self.device.is_nightlight_enabled - - # F821: https://github.com/PyCQA/pyflakes/issues/373 - @property - def _bulb(self) -> 'yeelight.Bulb': # noqa: F821 - return self.device.bulb - def set_music_mode(self, mode) -> None: """Set the music mode on or off.""" if mode: @@ -340,47 +394,6 @@ def set_music_mode(self, mode) -> None: else: self._bulb.stop_music() - def update(self) -> None: - """Update properties from the bulb.""" - bulb_type = self._bulb.bulb_type - - if bulb_type == BulbType.Color: - self._supported_features = SUPPORT_YEELIGHT_RGB - elif self.light_type == LightType.Ambient: - self._supported_features = SUPPORT_YEELIGHT_RGB - elif bulb_type in (BulbType.WhiteTemp, BulbType.WhiteTempMood): - if self._is_nightlight_enabled: - self._supported_features = SUPPORT_YEELIGHT - else: - self._supported_features = SUPPORT_YEELIGHT_WHITE_TEMP - - if self.min_mireds is None: - model_specs = self._bulb.get_model_specs() - self._min_mireds = \ - kelvin_to_mired(model_specs['color_temp']['max']) - self._max_mireds = \ - kelvin_to_mired(model_specs['color_temp']['min']) - - if bulb_type == BulbType.WhiteTempMood: - self._is_on = self._get_property('main_power') == 'on' - else: - self._is_on = self._get_property('power') == 'on' - - if self._is_nightlight_enabled: - bright = self._get_property('nl_br') - else: - bright = self._get_property('bright') - - if bright: - self._brightness = round(255 * (int(bright) / 100)) - - temp_in_k = self._get_property('ct') - - if temp_in_k: - self._color_temp = kelvin_to_mired(int(temp_in_k)) - - self._hs = self._get_hs_from_properties() - @_cmd def set_brightness(self, brightness, duration) -> None: """Set bulb brightness.""" @@ -566,12 +579,49 @@ def start_flow(self, transitions, count=0, action=ACTION_RECOVER): _LOGGER.error("Unable to set effect: %s", ex) -class YeelightAmbientLight(YeelightLight): +class YeelightColorLight(YeelightGenericLight): + """Representation of a Color Yeelight light.""" + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return SUPPORT_YEELIGHT_RGB + + @property + def _predefined_effects(self): + return YEELIGHT_COLOR_EFFECT_LIST + + +class YeelightWhiteTempLight(YeelightGenericLight): + """Representation of a Color Yeelight light.""" + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return SUPPORT_YEELIGHT_WHITE_TEMP + + @property + def _brightness_property(self): + return 'current_brightness' + + @property + def _predefined_effects(self): + return YEELIGHT_TEMP_ONLY_EFFECT_LIST + + +class YeelightWithAmbientLight(YeelightWhiteTempLight): + """Representation of a Yeelight which has ambilight support.""" + + @ property + def _power_property(self): + return 'main_power' + + +class YeelightAmbientLight(YeelightColorLight): """Representation of a Yeelight ambient light.""" PROPERTIES_MAPPING = { "color_mode": "bg_lmode", - "main_power": "bg_power", } def __init__(self, *args, **kwargs): @@ -587,14 +637,10 @@ def name(self) -> str: """Return the name of the device if any.""" return "{} ambilight".format(self.device.name) - @property - def _is_nightlight_enabled(self): - return False - def _get_property(self, prop, default=None): bg_prop = self.PROPERTIES_MAPPING.get(prop) if not bg_prop: bg_prop = "bg_" + prop - return self._properties.get(bg_prop, default) + return super()._get_property(bg_prop, default) diff --git a/homeassistant/components/yr/manifest.json b/homeassistant/components/yr/manifest.json index 88daadd35aa1c6..7f06ddddcb5700 100644 --- a/homeassistant/components/yr/manifest.json +++ b/homeassistant/components/yr/manifest.json @@ -6,5 +6,7 @@ "xmltodict==0.12.0" ], "dependencies": [], - "codeowners": [] + "codeowners": [ + "@danielhiversen" + ] } diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index e9fa25c2577897..c3e6208d824971 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -1,27 +1,31 @@ """Binary sensors on Zigbee Home Automation networks.""" import logging -from homeassistant.components.binary_sensor import DOMAIN, BinarySensorDevice +from homeassistant.components.binary_sensor import ( + DOMAIN, BinarySensorDevice, DEVICE_CLASS_MOVING, DEVICE_CLASS_MOTION, + DEVICE_CLASS_OPENING, DEVICE_CLASS_MOISTURE, DEVICE_CLASS_SMOKE, + DEVICE_CLASS_GAS, DEVICE_CLASS_VIBRATION, DEVICE_CLASS_OCCUPANCY +) from homeassistant.const import STATE_ON from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from .core.const import ( DATA_ZHA, DATA_ZHA_DISPATCHERS, ZHA_DISCOVERY_NEW, ON_OFF_CHANNEL, - LEVEL_CHANNEL, ZONE_CHANNEL, SIGNAL_ATTR_UPDATED, SIGNAL_MOVE_LEVEL, - SIGNAL_SET_LEVEL, ATTRIBUTE_CHANNEL, UNKNOWN, OPENING, ZONE, OCCUPANCY, - ATTR_LEVEL, SENSOR_TYPE, ACCELERATION) + ZONE_CHANNEL, SIGNAL_ATTR_UPDATED, ATTRIBUTE_CHANNEL, UNKNOWN, OPENING, + ZONE, OCCUPANCY, SENSOR_TYPE, ACCELERATION +) from .entity import ZhaEntity _LOGGER = logging.getLogger(__name__) # Zigbee Cluster Library Zone Type to Home Assistant device class CLASS_MAPPING = { - 0x000d: 'motion', - 0x0015: 'opening', - 0x0028: 'smoke', - 0x002a: 'moisture', - 0x002b: 'gas', - 0x002d: 'vibration', + 0x000d: DEVICE_CLASS_MOTION, + 0x0015: DEVICE_CLASS_OPENING, + 0x0028: DEVICE_CLASS_SMOKE, + 0x002a: DEVICE_CLASS_MOISTURE, + 0x002b: DEVICE_CLASS_GAS, + 0x002d: DEVICE_CLASS_VIBRATION, } @@ -33,10 +37,10 @@ async def get_ias_device_class(channel): DEVICE_CLASS_REGISTRY = { UNKNOWN: None, - OPENING: OPENING, + OPENING: DEVICE_CLASS_OPENING, ZONE: get_ias_device_class, - OCCUPANCY: OCCUPANCY, - ACCELERATION: 'moving', + OCCUPANCY: DEVICE_CLASS_OCCUPANCY, + ACCELERATION: DEVICE_CLASS_MOVING, } @@ -85,10 +89,8 @@ def __init__(self, **kwargs): self._device_state_attributes = {} self._zone_channel = self.cluster_channels.get(ZONE_CHANNEL) self._on_off_channel = self.cluster_channels.get(ON_OFF_CHANNEL) - self._level_channel = self.cluster_channels.get(LEVEL_CHANNEL) self._attr_channel = self.cluster_channels.get(ATTRIBUTE_CHANNEL) self._zha_sensor_type = kwargs[SENSOR_TYPE] - self._level = None async def _determine_device_class(self): """Determine the device class for this binary sensor.""" @@ -105,11 +107,6 @@ async def async_added_to_hass(self): """Run when about to be added to hass.""" self._device_class = await self._determine_device_class() await super().async_added_to_hass() - if self._level_channel: - await self.async_accept_signal( - self._level_channel, SIGNAL_SET_LEVEL, self.set_level) - await self.async_accept_signal( - self._level_channel, SIGNAL_MOVE_LEVEL, self.move_level) if self._on_off_channel: await self.async_accept_signal( self._on_off_channel, SIGNAL_ATTR_UPDATED, @@ -126,8 +123,6 @@ def async_restore_last_state(self, last_state): """Restore previous state.""" super().async_restore_last_state(last_state) self._state = last_state.state == STATE_ON - if 'level' in last_state.attributes: - self._level = last_state.attributes['level'] @property def is_on(self) -> bool: @@ -146,36 +141,9 @@ def async_set_state(self, state): self._state = bool(state) self.async_schedule_update_ha_state() - def move_level(self, change): - """Increment the level, setting state if appropriate.""" - level = self._level or 0 - if not self._state and change > 0: - level = 0 - self._level = min(254, max(0, level + change)) - self._state = bool(self._level) - self.async_schedule_update_ha_state() - - def set_level(self, level): - """Set the level, setting state if appropriate.""" - self._level = level - self._state = bool(level) - self.async_schedule_update_ha_state() - - @property - def device_state_attributes(self): - """Return the device state attributes.""" - if self._level_channel is not None: - self._device_state_attributes.update({ - ATTR_LEVEL: self._state and self._level or 0 - }) - return self._device_state_attributes - async def async_update(self): """Attempt to retrieve on off state from the binary sensor.""" await super().async_update() - if self._level_channel: - self._level = await self._level_channel.get_attribute_value( - 'current_level') if self._on_off_channel: self._state = await self._on_off_channel.get_attribute_value( 'on_off') diff --git a/homeassistant/components/zha/const.py b/homeassistant/components/zha/const.py deleted file mode 100644 index 1ccc3e0ea25357..00000000000000 --- a/homeassistant/components/zha/const.py +++ /dev/null @@ -1,4 +0,0 @@ -"""Backwards compatible constants bridge.""" -# pylint: disable=W0614,W0401 -from .core.const import * # noqa: F401,F403 -from .core.registries import * # noqa: F401,F403 diff --git a/homeassistant/components/zha/core/channels/__init__.py b/homeassistant/components/zha/core/channels/__init__.py index 1845ae8e999203..162ef5a59e4d05 100644 --- a/homeassistant/components/zha/core/channels/__init__.py +++ b/homeassistant/components/zha/core/channels/__init__.py @@ -14,7 +14,7 @@ from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_send from ..helpers import ( - bind_configure_reporting, construct_unique_id, + configure_reporting, construct_unique_id, safe_read, get_attr_id_by_name, bind_cluster) from ..const import ( REPORT_CONFIG_DEFAULT, SIGNAL_ATTR_UPDATED, ATTRIBUTE_CHANNEL, @@ -22,10 +22,6 @@ ) from ..registries import CLUSTER_REPORT_CONFIGS -NODE_DESCRIPTOR_REQUEST = 0x0002 -MAINS_POWERED = 1 -BATTERY_OR_UNKNOWN = 0 - ZIGBEE_CHANNEL_REGISTRY = {} _LOGGER = logging.getLogger(__name__) @@ -48,7 +44,6 @@ def decorate_command(channel, command): """Wrap a cluster command to make it safe.""" @wraps(command) async def wrapper(*args, **kwds): - from zigpy.zcl.foundation import Status from zigpy.exceptions import DeliveryError try: result = await command(*args, **kwds) @@ -58,9 +53,8 @@ async def wrapper(*args, **kwds): "{}: {}".format("with args", args), "{}: {}".format("with kwargs", kwds), "{}: {}".format("and result", result)) - if isinstance(result, bool): - return result - return result[1] is Status.SUCCESS + return result + except (DeliveryError, Timeout) as ex: _LOGGER.debug( "%s: command failed: %s exception: %s", @@ -68,7 +62,7 @@ async def wrapper(*args, **kwds): command.__name__, str(ex) ) - return False + return ex return wrapper @@ -139,26 +133,25 @@ async def async_configure(self): """Set cluster binding and attribute reporting.""" manufacturer = None manufacturer_code = self._zha_device.manufacturer_code - if self.cluster.cluster_id >= 0xfc00 and manufacturer_code: - manufacturer = manufacturer_code - if self.cluster.bind_only: + # Xiaomi devices don't need this and it disrupts pairing + if self._zha_device.manufacturer != 'LUMI': + if self.cluster.cluster_id >= 0xfc00 and manufacturer_code: + manufacturer = manufacturer_code await bind_cluster(self._unique_id, self.cluster) - else: - skip_bind = False # bind cluster only for the 1st configured attr - for report_config in self._report_config: - attr = report_config.get('attr') - min_report_interval, max_report_interval, change = \ - report_config.get('config') - await bind_configure_reporting( - self._unique_id, self.cluster, attr, - min_report=min_report_interval, - max_report=max_report_interval, - reportable_change=change, - skip_bind=skip_bind, - manufacturer=manufacturer - ) - skip_bind = True - await asyncio.sleep(uniform(0.1, 0.5)) + if not self.cluster.bind_only: + for report_config in self._report_config: + attr = report_config.get('attr') + min_report_interval, max_report_interval, change = \ + report_config.get('config') + await configure_reporting( + self._unique_id, self.cluster, attr, + min_report=min_report_interval, + max_report=max_report_interval, + reportable_change=change, + manufacturer=manufacturer + ) + await asyncio.sleep(uniform(0.1, 0.5)) + _LOGGER.debug( "%s: finished channel configuration", self._unique_id @@ -268,11 +261,6 @@ async def async_initialize(self, from_cache): class ZDOChannel: """Channel for ZDO events.""" - POWER_SOURCES = { - MAINS_POWERED: 'Mains', - BATTERY_OR_UNKNOWN: 'Battery or Unknown' - } - def __init__(self, cluster, device): """Initialize ZDOChannel.""" self.name = ZDO_CHANNEL @@ -281,8 +269,6 @@ def __init__(self, cluster, device): self._status = ChannelStatus.CREATED self._unique_id = "{}_ZDO".format(device.name) self._cluster.add_listener(self) - self.power_source = None - self.manufacturer_code = None @property def unique_id(self): @@ -314,49 +300,10 @@ async def async_initialize(self, from_cache): entry = self._zha_device.gateway.zha_storage.async_get_or_create( self._zha_device) _LOGGER.debug("entry loaded from storage: %s", entry) - if entry is not None: - self.power_source = entry.power_source - self.manufacturer_code = entry.manufacturer_code - - if self.power_source is None: - self.power_source = BATTERY_OR_UNKNOWN - - if self.manufacturer_code is None and not from_cache: - # this should always be set. This is from us not doing - # this previously so lets set it up so users don't have - # to reconfigure every device. - await self.async_get_node_descriptor(False) - entry = self._zha_device.gateway.zha_storage.async_update( - self._zha_device) - _LOGGER.debug("entry after getting node desc in init: %s", entry) self._status = ChannelStatus.INITIALIZED - async def async_get_node_descriptor(self, from_cache): - """Request the node descriptor from the device.""" - from zigpy.zdo.types import Status - - if from_cache: - return - - node_descriptor = await self._cluster.request( - NODE_DESCRIPTOR_REQUEST, - self._cluster.device.nwk, tries=3, delay=2) - - def get_bit(byteval, idx): - return int(((byteval & (1 << idx)) != 0)) - - if node_descriptor is not None and\ - node_descriptor[0] == Status.SUCCESS: - mac_capability_flags = node_descriptor[2].mac_capability_flags - - self.power_source = get_bit(mac_capability_flags, 2) - self.manufacturer_code = node_descriptor[2].manufacturer_code - - _LOGGER.debug("node descriptor: %s", node_descriptor) - async def async_configure(self): """Configure channel.""" - await self.async_get_node_descriptor(False) self._status = ChannelStatus.CONFIGURED diff --git a/homeassistant/components/zha/core/channels/closures.py b/homeassistant/components/zha/core/channels/closures.py index ba3b6b2e71617f..f2f8d07fde9299 100644 --- a/homeassistant/components/zha/core/channels/closures.py +++ b/homeassistant/components/zha/core/channels/closures.py @@ -5,5 +5,44 @@ https://home-assistant.io/components/zha/ """ import logging +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_send +from . import ZigbeeChannel +from ..const import SIGNAL_ATTR_UPDATED _LOGGER = logging.getLogger(__name__) + + +class DoorLockChannel(ZigbeeChannel): + """Door lock channel.""" + + _value_attribute = 0 + + async def async_update(self): + """Retrieve latest state.""" + result = await self.get_attribute_value('lock_state', from_cache=True) + + async_dispatcher_send( + self._zha_device.hass, + "{}_{}".format(self.unique_id, SIGNAL_ATTR_UPDATED), + result + ) + + @callback + def attribute_updated(self, attrid, value): + """Handle attribute update from lock cluster.""" + attr_name = self.cluster.attributes.get(attrid, [attrid])[0] + _LOGGER.debug("%s: Attribute report '%s'[%s] = %s", + self.unique_id, self.cluster.name, attr_name, value) + if attrid == self._value_attribute: + async_dispatcher_send( + self._zha_device.hass, + "{}_{}".format(self.unique_id, SIGNAL_ATTR_UPDATED), + value + ) + + async def async_initialize(self, from_cache): + """Initialize channel.""" + await self.get_attribute_value( + self._value_attribute, from_cache=from_cache) + await super().async_initialize(from_cache) diff --git a/homeassistant/components/zha/core/channels/general.py b/homeassistant/components/zha/core/channels/general.py index 470cd6b38cffc6..3f08a738a13e97 100644 --- a/homeassistant/components/zha/core/channels/general.py +++ b/homeassistant/components/zha/core/channels/general.py @@ -8,7 +8,7 @@ from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later -from . import ZigbeeChannel, parse_and_log_command, MAINS_POWERED +from . import ZigbeeChannel, parse_and_log_command from ..helpers import get_attr_id_by_name from ..const import ( SIGNAL_ATTR_UPDATED, SIGNAL_MOVE_LEVEL, SIGNAL_SET_LEVEL, @@ -87,7 +87,7 @@ async def async_initialize(self, from_cache): async def async_update(self): """Initialize channel.""" - from_cache = not self.device.power_source == MAINS_POWERED + from_cache = not self.device.is_mains_powered _LOGGER.debug( "%s is attempting to update onoff state - from cache: %s", self._unique_id, diff --git a/homeassistant/components/zha/core/channels/registry.py b/homeassistant/components/zha/core/channels/registry.py index 8f7335d82a9cd8..8b50ff4149731c 100644 --- a/homeassistant/components/zha/core/channels/registry.py +++ b/homeassistant/components/zha/core/channels/registry.py @@ -5,6 +5,8 @@ https://home-assistant.io/components/zha/ """ from . import ZigbeeChannel + +from .closures import DoorLockChannel from .general import ( OnOffChannel, LevelControlChannel, PowerConfigurationChannel, BasicChannel ) @@ -13,7 +15,6 @@ from .lighting import ColorChannel from .security import IASZoneChannel - ZIGBEE_CHANNEL_REGISTRY = {} @@ -44,4 +45,5 @@ def populate_channel_registry(): zcl.clusters.security.IasZone.cluster_id: IASZoneChannel, zcl.clusters.hvac.Fan.cluster_id: FanChannel, zcl.clusters.lightlink.LightLink.cluster_id: ZigbeeChannel, + zcl.clusters.closures.DoorLock.cluster_id: DoorLockChannel, }) diff --git a/homeassistant/components/zha/core/channels/security.py b/homeassistant/components/zha/core/channels/security.py index 03b50b7c7ba803..a69ab692da554d 100644 --- a/homeassistant/components/zha/core/channels/security.py +++ b/homeassistant/components/zha/core/channels/security.py @@ -35,6 +35,9 @@ def cluster_command(self, tsn, command_id, args): async def async_configure(self): """Configure IAS device.""" + # Xiaomi devices don't need this and it disrupts pairing + if self._zha_device.manufacturer == 'LUMI': + return from zigpy.exceptions import DeliveryError _LOGGER.debug("%s: started IASZoneChannel configuration", self._unique_id) diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index 193780c9124728..97e2364619aa5c 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -5,6 +5,7 @@ from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR from homeassistant.components.fan import DOMAIN as FAN from homeassistant.components.light import DOMAIN as LIGHT +from homeassistant.components.lock import DOMAIN as LOCK from homeassistant.components.sensor import DOMAIN as SENSOR from homeassistant.components.switch import DOMAIN as SWITCH @@ -27,6 +28,7 @@ BINARY_SENSOR, FAN, LIGHT, + LOCK, SENSOR, SWITCH, ) @@ -92,6 +94,7 @@ ELECTRICAL_MEASUREMENT_CHANNEL = 'electrical_measurement' POWER_CONFIGURATION_CHANNEL = 'power' EVENT_RELAY_CHANNEL = 'event_relay' +DOORLOCK_CHANNEL = 'door_lock' SIGNAL_ATTR_UPDATED = 'attribute_updated' SIGNAL_MOVE_LEVEL = "move_level" @@ -104,6 +107,8 @@ QUIRK_CLASS = 'quirk_class' MANUFACTURER_CODE = 'manufacturer_code' POWER_SOURCE = 'power_source' +MAINS_POWERED = 'Mains' +BATTERY_OR_UNKNOWN = 'Battery or Unknown' BELLOWS = 'bellows' ZHA = 'homeassistant.components.zha' diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 1a619dff981836..dcb4fe7ca0e050 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -17,9 +17,10 @@ ATTR_CLUSTER_ID, ATTR_ATTRIBUTE, ATTR_VALUE, ATTR_COMMAND, SERVER, ATTR_COMMAND_TYPE, ATTR_ARGS, CLIENT_COMMANDS, SERVER_COMMANDS, ATTR_ENDPOINT_ID, IEEE, MODEL, NAME, UNKNOWN, QUIRK_APPLIED, - QUIRK_CLASS, ZDO_CHANNEL, MANUFACTURER_CODE, POWER_SOURCE + QUIRK_CLASS, ZDO_CHANNEL, MANUFACTURER_CODE, POWER_SOURCE, MAINS_POWERED, + BATTERY_OR_UNKNOWN, NWK ) -from .channels import EventRelayChannel, ZDOChannel +from .channels import EventRelayChannel _LOGGER = logging.getLogger(__name__) @@ -68,7 +69,6 @@ def __init__(self, hass, zigpy_device, zha_gateway): self._zigpy_device.__class__.__module__, self._zigpy_device.__class__.__name__ ) - self._power_source = None self.status = DeviceStatus.CREATED @property @@ -91,6 +91,13 @@ def model(self): """Return model for device.""" return self._model + @property + def manufacturer_code(self): + """Return the manufacturer code for the device.""" + if self._zigpy_device.node_desc.is_valid: + return self._zigpy_device.node_desc.manufacturer_code + return None + @property def nwk(self): """Return nwk for device.""" @@ -112,20 +119,29 @@ def last_seen(self): return self._zigpy_device.last_seen @property - def manufacturer_code(self): - """Return manufacturer code for device.""" - if ZDO_CHANNEL in self.cluster_channels: - return self.cluster_channels.get(ZDO_CHANNEL).manufacturer_code - return None + def is_mains_powered(self): + """Return true if device is mains powered.""" + return self._zigpy_device.node_desc.is_mains_powered @property def power_source(self): """Return the power source for the device.""" - if self._power_source is not None: - return self._power_source - if ZDO_CHANNEL in self.cluster_channels: - return self.cluster_channels.get(ZDO_CHANNEL).power_source - return None + return MAINS_POWERED if self.is_mains_powered else BATTERY_OR_UNKNOWN + + @property + def is_router(self): + """Return true if this is a routing capable device.""" + return self._zigpy_device.node_desc.is_router + + @property + def is_coordinator(self): + """Return true if this device represents the coordinator.""" + return self._zigpy_device.node_desc.is_coordinator + + @property + def is_end_device(self): + """Return true if this device is an end device.""" + return self._zigpy_device.node_desc.is_end_device @property def gateway(self): @@ -151,10 +167,6 @@ def set_available(self, available): """Set availability from restore and prevent signals.""" self._available = available - def set_power_source(self, power_source): - """Set the power source.""" - self._power_source = power_source - def update_available(self, available): """Set sensor availability.""" if self._available != available and available: @@ -177,13 +189,14 @@ def device_info(self): ieee = str(self.ieee) return { IEEE: ieee, + NWK: self.nwk, ATTR_MANUFACTURER: self.manufacturer, MODEL: self.model, NAME: self.name or ieee, QUIRK_APPLIED: self.quirk_applied, QUIRK_CLASS: self.quirk_class, MANUFACTURER_CODE: self.manufacturer_code, - POWER_SOURCE: ZDOChannel.POWER_SOURCES.get(self.power_source) + POWER_SOURCE: self.power_source } def add_cluster_channel(self, cluster_channel): @@ -256,7 +269,7 @@ async def async_initialize(self, from_cache=False): _LOGGER.debug( '%s: power source: %s', self.name, - ZDOChannel.POWER_SOURCES.get(self.power_source) + self.power_source ) self.status = DeviceStatus.INITIALIZED _LOGGER.debug('%s: completed initialization', self.name) @@ -378,7 +391,7 @@ async def write_zigbee_attribute(self, endpoint_id, cluster_id, manufacturer=manufacturer ) _LOGGER.debug( - 'set: %s for attr: %s to cluster: %s for entity: %s - res: %s', + 'set: %s for attr: %s to cluster: %s for ept: %s - res: %s', value, attribute, cluster_id, diff --git a/homeassistant/components/zha/core/discovery.py b/homeassistant/components/zha/core/discovery.py index e81fa53020da8c..8901726ff88570 100644 --- a/homeassistant/components/zha/core/discovery.py +++ b/homeassistant/components/zha/core/discovery.py @@ -21,9 +21,10 @@ SENSOR_TYPE, UNKNOWN, GENERIC, POWER_CONFIGURATION_CHANNEL ) from .registries import ( - BINARY_SENSOR_TYPES, NO_SENSOR_CLUSTERS, EVENT_RELAY_CLUSTERS, + BINARY_SENSOR_TYPES, CHANNEL_ONLY_CLUSTERS, EVENT_RELAY_CLUSTERS, SENSOR_TYPES, DEVICE_CLASS, COMPONENT_CLUSTERS, - SINGLE_INPUT_CLUSTER_DEVICE_CLASS, SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS + SINGLE_INPUT_CLUSTER_DEVICE_CLASS, SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS, + OUTPUT_CHANNEL_ONLY_CLUSTERS, REMOTE_DEVICE_TYPES ) from ..device_entity import ZhaDeviceEntity @@ -87,6 +88,12 @@ def async_process_endpoint( def _async_create_cluster_channel(cluster, zha_device, is_new_join, channels=None, channel_class=None): """Create a cluster channel and attach it to a device.""" + # really ugly hack to deal with xiaomi using the door lock cluster + # incorrectly. + if hasattr(cluster, 'ep_attribute') and \ + cluster.ep_attribute == 'multistate_input': + channel_class = AttributeListeningChannel + # end of ugly hack if channel_class is None: channel_class = ZIGBEE_CHANNEL_REGISTRY.get(cluster.cluster_id, AttributeListeningChannel) @@ -161,17 +168,18 @@ def _async_handle_single_cluster_matches(hass, endpoint, zha_device, profile_clusters, device_key, is_new_join): """Dispatch single cluster matches to HA components.""" + from zigpy.zcl.clusters.general import OnOff cluster_matches = [] cluster_match_results = [] for cluster in endpoint.in_clusters.values(): - # don't let profiles prevent these channels from being created - if cluster.cluster_id in NO_SENSOR_CLUSTERS: + if cluster.cluster_id in CHANNEL_ONLY_CLUSTERS: cluster_match_results.append( _async_handle_channel_only_cluster_match( zha_device, cluster, is_new_join, )) + continue if cluster.cluster_id not in profile_clusters: cluster_match_results.append(_async_handle_single_cluster_match( @@ -184,15 +192,33 @@ def _async_handle_single_cluster_matches(hass, endpoint, zha_device, )) for cluster in endpoint.out_clusters.values(): + if cluster.cluster_id in OUTPUT_CHANNEL_ONLY_CLUSTERS: + cluster_match_results.append( + _async_handle_channel_only_cluster_match( + zha_device, + cluster, + is_new_join, + )) + continue + + device_type = cluster.endpoint.device_type + profile_id = cluster.endpoint.profile_id + if cluster.cluster_id not in profile_clusters: - cluster_match_results.append(_async_handle_single_cluster_match( - hass, - zha_device, - cluster, - device_key, - SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS, - is_new_join, - )) + # prevent remotes and controllers from getting entities + if not (cluster.cluster_id == OnOff.cluster_id and profile_id in + REMOTE_DEVICE_TYPES and device_type in + REMOTE_DEVICE_TYPES[profile_id]): + cluster_match_results.append( + _async_handle_single_cluster_match( + hass, + zha_device, + cluster, + device_key, + SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS, + is_new_join, + ) + ) if cluster.cluster_id in EVENT_RELAY_CLUSTERS: _async_create_cluster_channel( diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index daf14297ec18c9..d1ccaf8265c2da 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -18,7 +18,6 @@ from homeassistant.helpers.entity_component import EntityComponent from ..api import async_get_device_info -from .channels import MAINS_POWERED, ZDOChannel from .const import ( ADD_DEVICE_RELAY_LOGGERS, ATTR_MANUFACTURER, BELLOWS, CONF_BAUDRATE, CONF_DATABASE, CONF_RADIO_TYPE, CONF_USB_PATH, CONTROLLER, CURRENT, @@ -33,7 +32,7 @@ async_create_device_entity, async_dispatch_discovery_info, async_process_endpoint) from .patches import apply_application_controller_patch -from .registries import RADIO_TYPES +from .registries import RADIO_TYPES, INPUT_BIND_ONLY_CLUSTERS from .store import async_get_registry _LOGGER = logging.getLogger(__name__) @@ -116,6 +115,8 @@ def device_joined(self, device): def raw_device_initialized(self, device): """Handle a device initialization without quirks loaded.""" + if device.nwk == 0x0000: + return endpoint_ids = device.endpoints.keys() ept_id = next((ept_id for ept_id in endpoint_ids if ept_id != 0), None) manufacturer = 'Unknown' @@ -232,7 +233,6 @@ def _async_get_or_create_device(self, zigpy_device, is_new_join): if not is_new_join: entry = self.zha_storage.async_get_or_create(zha_device) zha_device.async_update_last_seen(entry.last_seen) - zha_device.set_power_source(entry.power_source) return zha_device @callback @@ -259,6 +259,9 @@ async def async_update_device_storage(self): async def async_device_initialized(self, device, is_new_join): """Handle device joined and basic information discovered (async).""" + if device.nwk == 0x0000: + return + zha_device = self._async_get_or_create_device(device, is_new_join) is_rejoin = False @@ -271,8 +274,10 @@ async def async_device_initialized(self, device, is_new_join): ) if endpoint_id != 0: for cluster in endpoint.in_clusters.values(): - cluster.bind_only = False + cluster.bind_only = \ + cluster.cluster_id in INPUT_BIND_ONLY_CLUSTERS for cluster in endpoint.out_clusters.values(): + # output clusters are always bind only cluster.bind_only = True else: is_rejoin = is_new_join is True @@ -285,16 +290,13 @@ async def async_device_initialized(self, device, is_new_join): # configure the device await zha_device.async_configure() zha_device.update_available(True) - elif zha_device.power_source is not None\ - and zha_device.power_source == MAINS_POWERED: + elif zha_device.is_mains_powered: # the device isn't a battery powered device so we should be able # to update it now _LOGGER.debug( "attempting to request fresh state for %s %s", zha_device.name, - "with power source: {}".format( - ZDOChannel.POWER_SOURCES.get(zha_device.power_source) - ) + "with power source: {}".format(zha_device.power_source) ) await zha_device.async_initialize(from_cache=False) else: diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index b585ce5f48a679..8a6832caed6de1 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -8,6 +8,7 @@ from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR from homeassistant.components.fan import DOMAIN as FAN from homeassistant.components.light import DOMAIN as LIGHT +from homeassistant.components.lock import DOMAIN as LOCK from homeassistant.components.sensor import DOMAIN as SENSOR from homeassistant.components.switch import DOMAIN as SWITCH @@ -29,11 +30,14 @@ SENSOR_TYPES = {} RADIO_TYPES = {} BINARY_SENSOR_TYPES = {} +REMOTE_DEVICE_TYPES = {} CLUSTER_REPORT_CONFIGS = {} CUSTOM_CLUSTER_MAPPINGS = {} EVENT_RELAY_CLUSTERS = [] -NO_SENSOR_CLUSTERS = [] +CHANNEL_ONLY_CLUSTERS = [] +OUTPUT_CHANNEL_ONLY_CLUSTERS = [] BINDABLE_CLUSTERS = [] +INPUT_BIND_ONLY_CLUSTERS = [] BINARY_SENSOR_CLUSTERS = set() LIGHT_CLUSTERS = set() SWITCH_CLUSTERS = set() @@ -58,6 +62,11 @@ def establish_device_mappings(): if zll.PROFILE_ID not in DEVICE_CLASS: DEVICE_CLASS[zll.PROFILE_ID] = {} + if zha.PROFILE_ID not in REMOTE_DEVICE_TYPES: + REMOTE_DEVICE_TYPES[zha.PROFILE_ID] = [] + if zll.PROFILE_ID not in REMOTE_DEVICE_TYPES: + REMOTE_DEVICE_TYPES[zll.PROFILE_ID] = [] + def get_ezsp_radio(): import bellows.ezsp from bellows.zigbee.application import ControllerApplication @@ -100,27 +109,34 @@ def get_deconz_radio(): EVENT_RELAY_CLUSTERS.append(zcl.clusters.general.LevelControl.cluster_id) EVENT_RELAY_CLUSTERS.append(zcl.clusters.general.OnOff.cluster_id) - NO_SENSOR_CLUSTERS.append(zcl.clusters.general.Basic.cluster_id) - NO_SENSOR_CLUSTERS.append( + CHANNEL_ONLY_CLUSTERS.append(zcl.clusters.general.Basic.cluster_id) + CHANNEL_ONLY_CLUSTERS.append( zcl.clusters.general.PowerConfiguration.cluster_id) - NO_SENSOR_CLUSTERS.append(zcl.clusters.lightlink.LightLink.cluster_id) + CHANNEL_ONLY_CLUSTERS.append(zcl.clusters.lightlink.LightLink.cluster_id) + + OUTPUT_CHANNEL_ONLY_CLUSTERS.append(zcl.clusters.general.Scenes.cluster_id) BINDABLE_CLUSTERS.append(zcl.clusters.general.LevelControl.cluster_id) BINDABLE_CLUSTERS.append(zcl.clusters.general.OnOff.cluster_id) BINDABLE_CLUSTERS.append(zcl.clusters.lighting.Color.cluster_id) + INPUT_BIND_ONLY_CLUSTERS.append( + zcl.clusters.lightlink.LightLink.cluster_id + ) + DEVICE_CLASS[zha.PROFILE_ID].update({ - zha.DeviceType.ON_OFF_SWITCH: BINARY_SENSOR, - zha.DeviceType.LEVEL_CONTROL_SWITCH: BINARY_SENSOR, - zha.DeviceType.REMOTE_CONTROL: BINARY_SENSOR, zha.DeviceType.SMART_PLUG: SWITCH, zha.DeviceType.LEVEL_CONTROLLABLE_OUTPUT: LIGHT, zha.DeviceType.ON_OFF_LIGHT: LIGHT, zha.DeviceType.DIMMABLE_LIGHT: LIGHT, zha.DeviceType.COLOR_DIMMABLE_LIGHT: LIGHT, - zha.DeviceType.ON_OFF_LIGHT_SWITCH: BINARY_SENSOR, - zha.DeviceType.DIMMER_SWITCH: BINARY_SENSOR, - zha.DeviceType.COLOR_DIMMER_SWITCH: BINARY_SENSOR, + zha.DeviceType.ON_OFF_LIGHT_SWITCH: SWITCH, + zha.DeviceType.ON_OFF_BALLAST: SWITCH, + zha.DeviceType.DIMMABLE_BALLAST: LIGHT, + zha.DeviceType.ON_OFF_PLUG_IN_UNIT: SWITCH, + zha.DeviceType.DIMMABLE_PLUG_IN_UNIT: LIGHT, + zha.DeviceType.COLOR_TEMPERATURE_LIGHT: LIGHT, + zha.DeviceType.EXTENDED_COLOR_LIGHT: LIGHT }) DEVICE_CLASS[zll.PROFILE_ID].update({ @@ -130,12 +146,7 @@ def get_deconz_radio(): zll.DeviceType.DIMMABLE_PLUGIN_UNIT: LIGHT, zll.DeviceType.COLOR_LIGHT: LIGHT, zll.DeviceType.EXTENDED_COLOR_LIGHT: LIGHT, - zll.DeviceType.COLOR_TEMPERATURE_LIGHT: LIGHT, - zll.DeviceType.COLOR_CONTROLLER: BINARY_SENSOR, - zll.DeviceType.COLOR_SCENE_CONTROLLER: BINARY_SENSOR, - zll.DeviceType.CONTROLLER: BINARY_SENSOR, - zll.DeviceType.SCENE_CONTROLLER: BINARY_SENSOR, - zll.DeviceType.ON_OFF_SENSOR: BINARY_SENSOR, + zll.DeviceType.COLOR_TEMPERATURE_LIGHT: LIGHT }) SINGLE_INPUT_CLUSTER_DEVICE_CLASS.update({ @@ -154,7 +165,8 @@ def get_deconz_radio(): zcl.clusters.hvac.Fan: FAN, SMARTTHINGS_ACCELERATION_CLUSTER: BINARY_SENSOR, zcl.clusters.general.MultistateInput.cluster_id: SENSOR, - zcl.clusters.general.AnalogInput.cluster_id: SENSOR + zcl.clusters.general.AnalogInput.cluster_id: SENSOR, + zcl.clusters.closures.DoorLock: LOCK }) SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS.update({ @@ -181,6 +193,23 @@ def get_deconz_radio(): SMARTTHINGS_ACCELERATION_CLUSTER: ACCELERATION, }) + zhap = zha.PROFILE_ID + REMOTE_DEVICE_TYPES[zhap].append(zha.DeviceType.NON_COLOR_SCENE_CONTROLLER) + REMOTE_DEVICE_TYPES[zhap].append(zha.DeviceType.NON_COLOR_CONTROLLER) + REMOTE_DEVICE_TYPES[zhap].append(zha.DeviceType.COLOR_SCENE_CONTROLLER) + REMOTE_DEVICE_TYPES[zhap].append(zha.DeviceType.COLOR_CONTROLLER) + REMOTE_DEVICE_TYPES[zhap].append(zha.DeviceType.REMOTE_CONTROL) + REMOTE_DEVICE_TYPES[zhap].append(zha.DeviceType.SCENE_SELECTOR) + REMOTE_DEVICE_TYPES[zhap].append(zha.DeviceType.DIMMER_SWITCH) + REMOTE_DEVICE_TYPES[zhap].append(zha.DeviceType.COLOR_DIMMER_SWITCH) + + zllp = zll.PROFILE_ID + REMOTE_DEVICE_TYPES[zllp].append(zll.DeviceType.COLOR_CONTROLLER) + REMOTE_DEVICE_TYPES[zllp].append(zll.DeviceType.COLOR_SCENE_CONTROLLER) + REMOTE_DEVICE_TYPES[zllp].append(zll.DeviceType.CONTROLLER) + REMOTE_DEVICE_TYPES[zllp].append(zll.DeviceType.SCENE_CONTROLLER) + REMOTE_DEVICE_TYPES[zllp].append(zll.DeviceType.CONTROL_BRIDGE) + CLUSTER_REPORT_CONFIGS.update({ zcl.clusters.general.Alarms.cluster_id: [], zcl.clusters.general.Basic.cluster_id: [], @@ -282,10 +311,13 @@ def get_deconz_radio(): 'attr': 'fan_mode', 'config': REPORT_CONFIG_OP }], + zcl.clusters.closures.DoorLock.cluster_id: [{ + 'attr': 'lock_state', + 'config': REPORT_CONFIG_IMMEDIATE + }], }) BINARY_SENSOR_CLUSTERS.add(zcl.clusters.general.OnOff.cluster_id) - BINARY_SENSOR_CLUSTERS.add(zcl.clusters.general.LevelControl.cluster_id) BINARY_SENSOR_CLUSTERS.add(zcl.clusters.security.IasZone.cluster_id) BINARY_SENSOR_CLUSTERS.add( zcl.clusters.measurement.OccupancySensing.cluster_id) diff --git a/homeassistant/components/zha/core/store.py b/homeassistant/components/zha/core/store.py index f3547cea8a4154..c14345e89dd56a 100644 --- a/homeassistant/components/zha/core/store.py +++ b/homeassistant/components/zha/core/store.py @@ -26,8 +26,6 @@ class ZhaDeviceEntry: name = attr.ib(type=str, default=None) ieee = attr.ib(type=str, default=None) - power_source = attr.ib(type=int, default=None) - manufacturer_code = attr.ib(type=int, default=None) last_seen = attr.ib(type=float, default=None) @@ -46,8 +44,6 @@ def async_create(self, device) -> ZhaDeviceEntry: device_entry = ZhaDeviceEntry( name=device.name, ieee=str(device.ieee), - power_source=device.power_source, - manufacturer_code=device.manufacturer_code, last_seen=device.last_seen ) @@ -85,13 +81,6 @@ def async_update(self, device) -> ZhaDeviceEntry: old = self.devices[ieee_str] changes = {} - - if device.power_source != old.power_source: - changes['power_source'] = device.power_source - - if device.manufacturer_code != old.manufacturer_code: - changes['manufacturer_code'] = device.manufacturer_code - changes['last_seen'] = device.last_seen new = self.devices[ieee_str] = attr.evolve(old, **changes) @@ -109,8 +98,6 @@ async def async_load(self) -> None: devices[device['ieee']] = ZhaDeviceEntry( name=device['name'], ieee=device['ieee'], - power_source=device['power_source'], - manufacturer_code=device['manufacturer_code'], last_seen=device['last_seen'] if 'last_seen' in device else None ) @@ -135,8 +122,6 @@ def _data_to_save(self) -> dict: { 'name': entry.name, 'ieee': entry.ieee, - 'power_source': entry.power_source, - 'manufacturer_code': entry.manufacturer_code, 'last_seen': entry.last_seen } for entry in self.devices.values() ] diff --git a/homeassistant/components/zha/device_entity.py b/homeassistant/components/zha/device_entity.py index 94fe598b6ec231..c61c0347704b53 100644 --- a/homeassistant/components/zha/device_entity.py +++ b/homeassistant/components/zha/device_entity.py @@ -1,12 +1,13 @@ """Device entity for Zigbee Home Automation.""" import logging +import numbers import time from homeassistant.core import callback from homeassistant.util import slugify from .entity import ZhaEntity -from .const import POWER_CONFIGURATION_CHANNEL, SIGNAL_STATE_ATTR +from .core.const import POWER_CONFIGURATION_CHANNEL, SIGNAL_STATE_ATTR _LOGGER = logging.getLogger(__name__) @@ -101,6 +102,18 @@ async def async_added_to_hass(self): # only do this on add to HA because it is static await self._async_init_battery_values() + def async_update_state_attribute(self, key, value): + """Update a single device state attribute.""" + if key == 'battery_level': + if not isinstance(value, numbers.Number) or value == -1: + return + value = value / 2 + value = int(round(value)) + self._device_state_attributes.update({ + key: value + }) + self.async_schedule_update_ha_state() + async def async_update(self): """Handle polling.""" if self._zha_device.last_seen is None: diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index d894ef5d7a37c4..338a9db278deb0 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -14,7 +14,6 @@ DOMAIN, ATTR_MANUFACTURER, DATA_ZHA, DATA_ZHA_BRIDGE_ID, MODEL, NAME, SIGNAL_REMOVE ) -from .core.channels import MAINS_POWERED _LOGGER = logging.getLogger(__name__) @@ -109,7 +108,8 @@ def device_info(self): ATTR_MANUFACTURER: zha_device_info[ATTR_MANUFACTURER], MODEL: zha_device_info[MODEL], NAME: zha_device_info[NAME], - 'via_hub': (DOMAIN, self.hass.data[DATA_ZHA][DATA_ZHA_BRIDGE_ID]), + 'via_device': ( + DOMAIN, self.hass.data[DATA_ZHA][DATA_ZHA_BRIDGE_ID]), } @property @@ -157,7 +157,7 @@ async def async_check_recently_seen(self): time.time() - self._zha_device.last_seen < RESTART_GRACE_PERIOD): self.async_set_available(True) - if self.zha_device.power_source != MAINS_POWERED: + if not self.zha_device.is_mains_powered: # mains powered devices will get real time state self.async_restore_last_state(last_state) self._zha_device.set_available(True) diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index c3aa0e50f44228..9e0f2739290acc 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -2,19 +2,19 @@ from datetime import timedelta import logging +from zigpy.zcl.foundation import Status from homeassistant.components import light from homeassistant.const import STATE_ON from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.event import async_track_time_interval import homeassistant.util.color as color_util -from .const import ( +from .core.const import ( DATA_ZHA, DATA_ZHA_DISPATCHERS, ZHA_DISCOVERY_NEW, COLOR_CHANNEL, ON_OFF_CHANNEL, LEVEL_CHANNEL, SIGNAL_ATTR_UPDATED, SIGNAL_SET_LEVEL ) from .entity import ZhaEntity - _LOGGER = logging.getLogger(__name__) DEFAULT_DURATION = 5 @@ -173,12 +173,12 @@ async def async_turn_on(self, **kwargs): level = min(254, brightness) else: level = self._brightness or 254 - success = await self._level_channel.move_to_level_with_on_off( + result = await self._level_channel.move_to_level_with_on_off( level, duration ) - t_log['move_to_level_with_on_off'] = success - if not success: + t_log['move_to_level_with_on_off'] = result + if not isinstance(result, list) or result[1] is not Status.SUCCESS: self.debug("turned on: %s", t_log) return self._state = bool(level) @@ -186,9 +186,9 @@ async def async_turn_on(self, **kwargs): self._brightness = level if brightness is None or brightness: - success = await self._on_off_channel.on() - t_log['on_off'] = success - if not success: + result = await self._on_off_channel.on() + t_log['on_off'] = result + if not isinstance(result, list) or result[1] is not Status.SUCCESS: self.debug("turned on: %s", t_log) return self._state = True @@ -196,10 +196,10 @@ async def async_turn_on(self, **kwargs): if light.ATTR_COLOR_TEMP in kwargs and \ self.supported_features & light.SUPPORT_COLOR_TEMP: temperature = kwargs[light.ATTR_COLOR_TEMP] - success = await self._color_channel.move_to_color_temp( + result = await self._color_channel.move_to_color_temp( temperature, duration) - t_log['move_to_color_temp'] = success - if not success: + t_log['move_to_color_temp'] = result + if not isinstance(result, list) or result[1] is not Status.SUCCESS: self.debug("turned on: %s", t_log) return self._color_temp = temperature @@ -208,13 +208,13 @@ async def async_turn_on(self, **kwargs): self.supported_features & light.SUPPORT_COLOR: hs_color = kwargs[light.ATTR_HS_COLOR] xy_color = color_util.color_hs_to_xy(*hs_color) - success = await self._color_channel.move_to_color( + result = await self._color_channel.move_to_color( int(xy_color[0] * 65535), int(xy_color[1] * 65535), duration, ) - t_log['move_to_color'] = success - if not success: + t_log['move_to_color'] = result + if not isinstance(result, list) or result[1] is not Status.SUCCESS: self.debug("turned on: %s", t_log) return self._hs_color = hs_color @@ -227,14 +227,14 @@ async def async_turn_off(self, **kwargs): duration = kwargs.get(light.ATTR_TRANSITION) supports_level = self.supported_features & light.SUPPORT_BRIGHTNESS if duration and supports_level: - success = await self._level_channel.move_to_level_with_on_off( + result = await self._level_channel.move_to_level_with_on_off( 0, duration*10 ) else: - success = await self._on_off_channel.off() - self.debug("turned off: %s", success) - if not success: + result = await self._on_off_channel.off() + self.debug("turned off: %s", result) + if not isinstance(result, list) or result[1] is not Status.SUCCESS: return self._state = False self.async_schedule_update_ha_state() diff --git a/homeassistant/components/zha/lock.py b/homeassistant/components/zha/lock.py new file mode 100644 index 00000000000000..5ac4a0c2e30825 --- /dev/null +++ b/homeassistant/components/zha/lock.py @@ -0,0 +1,134 @@ +"""Locks on Zigbee Home Automation networks.""" +import logging + +from zigpy.zcl.foundation import Status +from homeassistant.core import callback +from homeassistant.components.lock import ( + DOMAIN, STATE_UNLOCKED, STATE_LOCKED, LockDevice) +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from .core.const import ( + DATA_ZHA, DATA_ZHA_DISPATCHERS, ZHA_DISCOVERY_NEW, DOORLOCK_CHANNEL, + SIGNAL_ATTR_UPDATED +) +from .entity import ZhaEntity + +_LOGGER = logging.getLogger(__name__) + +""" The first state is Zigbee 'Not fully locked' """ + +STATE_LIST = [ + STATE_UNLOCKED, + STATE_LOCKED, + STATE_UNLOCKED +] + +VALUE_TO_STATE = {i: state for i, state in enumerate(STATE_LIST)} + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Old way of setting up Zigbee Home Automation locks.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Zigbee Home Automation Door Lock from config entry.""" + async def async_discover(discovery_info): + await _async_setup_entities(hass, config_entry, async_add_entities, + [discovery_info]) + + unsub = async_dispatcher_connect( + hass, ZHA_DISCOVERY_NEW.format(DOMAIN), async_discover) + hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub) + + locks = hass.data.get(DATA_ZHA, {}).get(DOMAIN) + if locks is not None: + await _async_setup_entities(hass, config_entry, async_add_entities, + locks.values()) + del hass.data[DATA_ZHA][DOMAIN] + + +async def _async_setup_entities(hass, config_entry, async_add_entities, + discovery_infos): + """Set up the ZHA locks.""" + entities = [] + for discovery_info in discovery_infos: + entities.append(ZhaDoorLock(**discovery_info)) + + async_add_entities(entities, update_before_add=True) + + +class ZhaDoorLock(ZhaEntity, LockDevice): + """Representation of a ZHA lock.""" + + _domain = DOMAIN + + def __init__(self, unique_id, zha_device, channels, **kwargs): + """Init this sensor.""" + super().__init__(unique_id, zha_device, channels, **kwargs) + self._doorlock_channel = self.cluster_channels.get(DOORLOCK_CHANNEL) + + async def async_added_to_hass(self): + """Run when about to be added to hass.""" + await super().async_added_to_hass() + await self.async_accept_signal( + self._doorlock_channel, SIGNAL_ATTR_UPDATED, self.async_set_state) + + @callback + def async_restore_last_state(self, last_state): + """Restore previous state.""" + self._state = VALUE_TO_STATE.get(last_state.state, last_state.state) + + @property + def is_locked(self) -> bool: + """Return true if entity is locked.""" + if self._state is None: + return False + return self._state == STATE_LOCKED + + @property + def device_state_attributes(self): + """Return state attributes.""" + return self.state_attributes + + async def async_lock(self, **kwargs): + """Lock the lock.""" + result = await self._doorlock_channel.lock_door() + if not isinstance(result, list) or result[0] is not Status.SUCCESS: + _LOGGER.error("Error with lock_door: %s", result) + return + self.async_schedule_update_ha_state() + + async def async_unlock(self, **kwargs): + """Unlock the lock.""" + result = await self._doorlock_channel.unlock_door() + if not isinstance(result, list) or result[0] is not Status.SUCCESS: + _LOGGER.error("Error with unlock_door: %s", result) + return + self.async_schedule_update_ha_state() + + async def async_update(self): + """Attempt to retrieve state from the lock.""" + await super().async_update() + await self.async_get_state() + + def async_set_state(self, state): + """Handle state update from channel.""" + self._state = VALUE_TO_STATE.get(state, self._state) + self.async_schedule_update_ha_state() + + async def async_get_state(self, from_cache=True): + """Attempt to retrieve state from the lock.""" + if self._doorlock_channel: + state = await self._doorlock_channel.get_attribute_value( + 'lock_state', from_cache=from_cache) + if state is not None: + self._state = VALUE_TO_STATE.get(state, self._state) + + async def refresh(self, time): + """Call async_get_state at an interval.""" + await self.async_get_state(from_cache=False) + + def debug(self, msg, *args): + """Log debug message.""" + _LOGGER.debug('%s: ' + msg, self.entity_id, *args) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 610498e62370c3..15fcf38100fb11 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -4,11 +4,11 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/components/zha", "requirements": [ - "bellows-homeassistant==0.7.3", - "zha-quirks==0.0.13", - "zigpy-deconz==0.1.4", - "zigpy-homeassistant==0.3.3", - "zigpy-xbee-homeassistant==0.2.1" + "bellows-homeassistant==0.8.2", + "zha-quirks==0.0.15", + "zigpy-deconz==0.1.6", + "zigpy-homeassistant==0.6.1", + "zigpy-xbee-homeassistant==0.3.0" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index b2d246c30959f0..15ef922bd98664 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -2,7 +2,10 @@ import logging from homeassistant.core import callback -from homeassistant.components.sensor import DOMAIN +from homeassistant.components.sensor import ( + DOMAIN, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_PRESSURE, DEVICE_CLASS_POWER +) from homeassistant.const import ( TEMP_CELSIUS, POWER_WATT, ATTR_UNIT_OF_MEASUREMENT ) @@ -11,7 +14,7 @@ DATA_ZHA, DATA_ZHA_DISPATCHERS, ZHA_DISCOVERY_NEW, HUMIDITY, TEMPERATURE, ILLUMINANCE, PRESSURE, METERING, ELECTRICAL_MEASUREMENT, GENERIC, SENSOR_TYPE, ATTRIBUTE_CHANNEL, ELECTRICAL_MEASUREMENT_CHANNEL, - SIGNAL_ATTR_UPDATED, SIGNAL_STATE_ATTR) + SIGNAL_ATTR_UPDATED, SIGNAL_STATE_ATTR, UNKNOWN) from .entity import ZhaEntity PARALLEL_UPDATES = 5 @@ -91,6 +94,16 @@ def pressure_formatter(value): ELECTRICAL_MEASUREMENT: False } +DEVICE_CLASS_REGISTRY = { + UNKNOWN: None, + HUMIDITY: DEVICE_CLASS_HUMIDITY, + TEMPERATURE: DEVICE_CLASS_TEMPERATURE, + PRESSURE: DEVICE_CLASS_PRESSURE, + ILLUMINANCE: DEVICE_CLASS_ILLUMINANCE, + METERING: DEVICE_CLASS_POWER, + ELECTRICAL_MEASUREMENT: DEVICE_CLASS_POWER +} + async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): @@ -155,6 +168,10 @@ def __init__(self, unique_id, zha_device, channels, **kwargs): self._channel = self.cluster_channels.get( CHANNEL_REGISTRY.get(self._sensor_type, ATTRIBUTE_CHANNEL) ) + self._device_class = DEVICE_CLASS_REGISTRY.get( + self._sensor_type, + None + ) async def async_added_to_hass(self): """Run when about to be added to hass.""" @@ -165,6 +182,11 @@ async def async_added_to_hass(self): self._channel, SIGNAL_STATE_ATTR, self.async_update_state_attribute) + @property + def device_class(self) -> str: + """Return device class from component DEVICE_CLASSES.""" + return self._device_class + @property def unit_of_measurement(self): """Return the unit of measurement of this entity.""" diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index 7efcbabd74e1be..89452f00d9f2f7 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -1,6 +1,7 @@ """Switches on Zigbee Home Automation networks.""" import logging +from zigpy.zcl.foundation import Status from homeassistant.components.switch import DOMAIN, SwitchDevice from homeassistant.const import STATE_ON from homeassistant.core import callback @@ -66,16 +67,16 @@ def is_on(self) -> bool: async def async_turn_on(self, **kwargs): """Turn the entity on.""" - success = await self._on_off_channel.on() - if not success: + result = await self._on_off_channel.on() + if not isinstance(result, list) or result[1] is not Status.SUCCESS: return self._state = True self.async_schedule_update_ha_state() async def async_turn_off(self, **kwargs): """Turn the entity off.""" - success = await self._on_off_channel.off() - if not success: + result = await self._on_off_channel.off() + if not isinstance(result, list) or result[1] is not Status.SUCCESS: return self._state = False self.async_schedule_update_ha_state() diff --git a/homeassistant/components/zone/.translations/it.json b/homeassistant/components/zone/.translations/it.json index 4490124510fa4d..24de27a8bbb79c 100644 --- a/homeassistant/components/zone/.translations/it.json +++ b/homeassistant/components/zone/.translations/it.json @@ -8,7 +8,7 @@ "data": { "icon": "Icona", "latitude": "Latitudine", - "longitude": "Logitudine", + "longitude": "Longitudine", "name": "Nome", "passive": "Passiva", "radius": "Raggio" diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index 51e956e33144ad..a5a460d129e29b 100644 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -11,7 +11,8 @@ from homeassistant.core import callback, CoreState from homeassistant.helpers import discovery from homeassistant.helpers.entity import generate_entity_id -from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.entity_component import DEFAULT_SCAN_INTERVAL +from homeassistant.helpers.entity_platform import EntityPlatform from homeassistant.helpers.entity_registry import async_get_registry from homeassistant.const import ( ATTR_ENTITY_ID, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) @@ -46,6 +47,7 @@ CONF_POLLING_INTENSITY = 'polling_intensity' CONF_IGNORED = 'ignored' CONF_INVERT_OPENCLOSE_BUTTONS = 'invert_openclose_buttons' +CONF_INVERT_PERCENT = 'invert_percent' CONF_REFRESH_VALUE = 'refresh_value' CONF_REFRESH_DELAY = 'delay' CONF_DEVICE_CONFIG = 'device_config' @@ -56,6 +58,7 @@ DEFAULT_CONF_IGNORED = False DEFAULT_CONF_INVERT_OPENCLOSE_BUTTONS = False +DEFAULT_CONF_INVERT_PERCENT = False DEFAULT_CONF_REFRESH_VALUE = False DEFAULT_CONF_REFRESH_DELAY = 5 @@ -145,6 +148,8 @@ vol.Optional(CONF_IGNORED, default=DEFAULT_CONF_IGNORED): cv.boolean, vol.Optional(CONF_INVERT_OPENCLOSE_BUTTONS, default=DEFAULT_CONF_INVERT_OPENCLOSE_BUTTONS): cv.boolean, + vol.Optional(CONF_INVERT_PERCENT, + default=DEFAULT_CONF_INVERT_PERCENT): cv.boolean, vol.Optional(CONF_REFRESH_VALUE, default=DEFAULT_CONF_REFRESH_VALUE): cv.boolean, vol.Optional(CONF_REFRESH_DELAY, default=DEFAULT_CONF_REFRESH_DELAY): @@ -291,6 +296,8 @@ async def async_setup_entry(hass, config_entry): hass.data[DATA_DEVICES] = {} hass.data[DATA_ENTITY_VALUES] = [] + registry = await async_get_registry(hass) + if use_debug: # pragma: no cover def log_all(signal, value=None): """Log all the signals.""" @@ -332,14 +339,23 @@ def value_added(node, value): new_values = hass.data[DATA_ENTITY_VALUES] + [values] hass.data[DATA_ENTITY_VALUES] = new_values - component = EntityComponent(_LOGGER, DOMAIN, hass) - registry = await async_get_registry(hass) + platform = EntityPlatform( + hass=hass, + logger=_LOGGER, + domain=DOMAIN, + platform_name=DOMAIN, + platform=None, + scan_interval=DEFAULT_SCAN_INTERVAL, + entity_namespace=None, + async_entities_added_callback=lambda: None, + ) + platform.config_entry = config_entry def node_added(node): """Handle a new node on the network.""" entity = ZWaveNodeEntity(node, network) - def _add_node_to_component(): + async def _add_node_to_component(): if hass.data[DATA_DEVICES].get(entity.unique_id): return @@ -353,10 +369,10 @@ def _add_node_to_component(): return hass.data[DATA_DEVICES][entity.unique_id] = entity - component.add_entities([entity]) + await platform.async_add_entities([entity]) if entity.unique_id: - _add_node_to_component() + hass.async_add_job(_add_node_to_component()) return @callback @@ -1057,14 +1073,25 @@ def unique_id(self): @property def device_info(self): """Return device information.""" - return { - 'identifiers': { - (DOMAIN, self.node_id) - }, + info = { 'manufacturer': self.node.manufacturer_name, 'model': self.node.product_name, - 'name': node_name(self.node), } + if self.values.primary.instance > 1: + info['name'] = '{} ({})'.format( + node_name(self.node), self.values.primary.instance) + info['identifiers'] = { + (DOMAIN, self.node_id, self.values.primary.instance, ), + } + info['via_device'] = (DOMAIN, self.node_id, ) + else: + info['name'] = node_name(self.node) + info['identifiers'] = { + (DOMAIN, self.node_id), + } + if self.node_id > 1: + info['via_device'] = (DOMAIN, 1, ) + return info @property def name(self): diff --git a/homeassistant/components/zwave/cover.py b/homeassistant/components/zwave/cover.py index a3cd7269b99337..1ab643bde11add 100644 --- a/homeassistant/components/zwave/cover.py +++ b/homeassistant/components/zwave/cover.py @@ -6,7 +6,8 @@ from homeassistant.components.cover import CoverDevice from homeassistant.helpers.dispatcher import async_dispatcher_connect from . import ( - ZWaveDeviceEntity, CONF_INVERT_OPENCLOSE_BUTTONS, workaround) + ZWaveDeviceEntity, CONF_INVERT_OPENCLOSE_BUTTONS, CONF_INVERT_PERCENT, + workaround) from .const import ( COMMAND_CLASS_SWITCH_MULTILEVEL, COMMAND_CLASS_SWITCH_BINARY, COMMAND_CLASS_BARRIER_OPERATOR, DATA_NETWORK) @@ -35,10 +36,11 @@ def async_add_cover(cover): def get_device(hass, values, node_config, **kwargs): """Create Z-Wave entity device.""" invert_buttons = node_config.get(CONF_INVERT_OPENCLOSE_BUTTONS) + invert_percent = node_config.get(CONF_INVERT_PERCENT) if (values.primary.command_class == COMMAND_CLASS_SWITCH_MULTILEVEL and values.primary.index == 0): - return ZwaveRollershutter(hass, values, invert_buttons) + return ZwaveRollershutter(hass, values, invert_buttons, invert_percent) if values.primary.command_class == COMMAND_CLASS_SWITCH_BINARY: return ZwaveGarageDoorSwitch(values) if values.primary.command_class == \ @@ -50,7 +52,7 @@ def get_device(hass, values, node_config, **kwargs): class ZwaveRollershutter(ZWaveDeviceEntity, CoverDevice): """Representation of an Z-Wave cover.""" - def __init__(self, hass, values, invert_buttons): + def __init__(self, hass, values, invert_buttons, invert_percent): """Initialize the Z-Wave rollershutter.""" ZWaveDeviceEntity.__init__(self, values, DOMAIN) self._network = hass.data[DATA_NETWORK] @@ -58,6 +60,7 @@ def __init__(self, hass, values, invert_buttons): self._close_id = None self._current_position = None self._invert_buttons = invert_buttons + self._invert_percent = invert_percent self._workaround = workaround.get_device_mapping(values.primary) if self._workaround: @@ -92,12 +95,14 @@ def current_cover_position(self): """Return the current position of Zwave roller shutter.""" if self._workaround == workaround.WORKAROUND_NO_POSITION: return None + if self._current_position is not None: if self._current_position <= 5: - return 0 + return 100 if self._invert_percent else 0 if self._current_position >= 95: - return 100 - return self._current_position + return 0 if self._invert_percent else 100 + return 100 - self._current_position if self._invert_percent \ + else self._current_position def open_cover(self, **kwargs): """Move the roller shutter up.""" @@ -110,7 +115,9 @@ def close_cover(self, **kwargs): def set_cover_position(self, **kwargs): """Move the roller shutter to a specific position.""" self.node.set_dimmer(self.values.primary.value_id, - kwargs.get(ATTR_POSITION)) + (100 - kwargs.get(ATTR_POSITION)) + if self._invert_percent + else kwargs.get(ATTR_POSITION)) def stop_cover(self, **kwargs): """Stop the roller shutter.""" diff --git a/homeassistant/components/zwave/node_entity.py b/homeassistant/components/zwave/node_entity.py index 0a24f888c20e1d..3bba18f5c02058 100644 --- a/homeassistant/components/zwave/node_entity.py +++ b/homeassistant/components/zwave/node_entity.py @@ -124,7 +124,7 @@ def unique_id(self): @property def device_info(self): """Return device information.""" - return { + info = { 'identifiers': { (DOMAIN, self.node_id) }, @@ -132,6 +132,9 @@ def device_info(self): 'model': self.node.product_name, 'name': node_name(self.node) } + if self.node_id > 1: + info['via_device'] = (DOMAIN, 1) + return info def network_node_changed(self, node=None, value=None, args=None): """Handle a changed node on the network.""" diff --git a/homeassistant/config.py b/homeassistant/config.py index 7e8bcec08a51a5..ae5d2ce24fd6cc 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -1,7 +1,7 @@ """Module to help with parsing and generating configuration files.""" from collections import OrderedDict # pylint: disable=no-name-in-module -from distutils.version import StrictVersion # pylint: disable=import-error +from distutils.version import LooseVersion # pylint: disable=import-error import logging import os import re @@ -60,11 +60,6 @@ # http: # base_url: example.duckdns.org:8123 -# Sensors -sensor: - # Weather prediction - - platform: yr - # Text to speech tts: - platform: google_translate @@ -334,15 +329,15 @@ def process_ha_config_upgrade(hass: HomeAssistant) -> None: _LOGGER.info("Upgrading configuration directory from %s to %s", conf_version, __version__) - version_obj = StrictVersion(conf_version) + version_obj = LooseVersion(conf_version) - if version_obj < StrictVersion('0.50'): + if version_obj < LooseVersion('0.50'): # 0.50 introduced persistent deps dir. lib_path = hass.config.path('deps') if os.path.isdir(lib_path): shutil.rmtree(lib_path) - if version_obj < StrictVersion('0.92'): + if version_obj < LooseVersion('0.92'): # 0.92 moved google/tts.py to google_translate/tts.py config_path = find_config_file(hass.config.config_dir) assert config_path is not None @@ -360,7 +355,7 @@ def process_ha_config_upgrade(hass: HomeAssistant) -> None: _LOGGER.exception("Migrating to google_translate tts failed") pass - if version_obj < StrictVersion('0.94.0b6') and is_docker_env(): + if version_obj < LooseVersion('0.94') and is_docker_env(): # In 0.94 we no longer install packages inside the deps folder when # running inside a Docker container. lib_path = hass.config.path('deps') diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 299bfe9b407453..bfd8c0f2df7661 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1,124 +1,4 @@ -"""The Config Manager is responsible for managing configuration for components. - -The Config Manager allows for creating config entries to be consumed by -components. Each entry is created via a Config Flow Handler, as defined by each -component. - -During startup, Home Assistant will setup the entries during the normal setup -of a component. It will first call the normal setup and then call the method -`async_setup_entry(hass, entry)` for each entry. The same method is called when -Home Assistant is running while a config entry is created. If the version of -the config entry does not match that of the flow handler, setup will -call the method `async_migrate_entry(hass, entry)` with the expectation that -the entry be brought to the current version. Return `True` to indicate -migration was successful, otherwise `False`. - -## Config Flows - -A component needs to define a Config Handler to allow the user to create config -entries for that component. A config flow will manage the creation of entries -from user input, discovery or other sources (like hassio). - -When a config flow is started for a domain, the handler will be instantiated -and receives a unique id. The instance of this handler will be reused for every -interaction of the user with this flow. This makes it possible to store -instance variables on the handler. - -Before instantiating the handler, Home Assistant will make sure to load all -dependencies and install the requirements of the component. - -At a minimum, each config flow will have to define a version number and the -'user' step. - - @config_entries.HANDLERS.register(DOMAIN) - class ExampleConfigFlow(config_entries.ConfigFlow): - - VERSION = 1 - CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH - - async def async_step_user(self, user_input=None): - … - -The 'user' step is the first step of a flow and is called when a user -starts a new flow. Each step has three different possible results: "Show Form", -"Abort" and "Create Entry". - -> Note: prior 0.76, the default step is 'init' step, some config flows still -keep 'init' step to avoid break localization. All new config flow should use -'user' step. - -### Show Form - -This will show a form to the user to fill in. You define the current step, -a title, a description and the schema of the data that needs to be returned. - - async def async_step_init(self, user_input=None): - # Use OrderedDict to guarantee order of the form shown to the user - data_schema = OrderedDict() - data_schema[vol.Required('username')] = str - data_schema[vol.Required('password')] = str - - return self.async_show_form( - step_id='user', - title='Account Info', - data_schema=vol.Schema(data_schema) - ) - -After the user has filled in the form, the step method will be called again and -the user input is passed in. If the validation of the user input fails , you -can return a dictionary with errors. Each key in the dictionary refers to a -field name that contains the error. Use the key 'base' if you want to show a -generic error. - - async def async_step_init(self, user_input=None): - errors = None - if user_input is not None: - # Validate user input - if valid: - return self.create_entry(…) - - errors['base'] = 'Unable to reach authentication server.' - - return self.async_show_form(…) - -If the user input passes validation, you can again return one of the three -return values. If you want to navigate the user to the next step, return the -return value of that step: - - return await self.async_step_account() - -### Abort - -When the result is "Abort", a message will be shown to the user and the -configuration flow is finished. - - return self.async_abort( - reason='This device is not supported by Home Assistant.' - ) - -### Create Entry - -When the result is "Create Entry", an entry will be created and stored in Home -Assistant, a success message is shown to the user and the flow is finished. - -## Initializing a config flow from an external source - -You might want to initialize a config flow programmatically. For example, if -we discover a device on the network that requires user interaction to finish -setup. To do so, pass a source parameter and optional user input to the init -method: - - await hass.config_entries.flow.async_init( - 'hue', context={'source': 'discovery'}, data=discovery_info) - -The config flow handler will need to add a step to support the source. The step -should follow the same return values as a normal step. - - async def async_step_discovery(info): - -If the result of the step is to show a form, the user will be able to continue -the flow from the config panel. -""" +"""Manage config entries in Home Assistant.""" import asyncio import logging import functools @@ -673,6 +553,14 @@ async def _async_create_flow(self, handler_key, *, context, data): _LOGGER.error('Cannot find integration %s', handler_key) raise data_entry_flow.UnknownHandler + # Our config flow list is based on built-in integrations. If overriden, + # we should not load it's config flow. + if not integration.is_built_in: + _LOGGER.error( + 'Config flow is not supported for custom integration %s', + handler_key) + raise data_entry_flow.UnknownHandler + # Make sure requirements and dependencies of component are resolved await async_process_deps_reqs( self.hass, self._hass_config, integration) diff --git a/homeassistant/const.py b/homeassistant/const.py index 39cb680ac667d6..6cf77275f6e111 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,8 +1,8 @@ # coding: utf-8 """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 -MINOR_VERSION = 94 -PATCH_VERSION = '4' +MINOR_VERSION = 95 +PATCH_VERSION = '0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) @@ -59,6 +59,7 @@ CONF_DELAY_TIME = 'delay_time' CONF_DEVICE = 'device' CONF_DEVICE_CLASS = 'device_class' +CONF_DEVICE_ID = 'device_id' CONF_DEVICES = 'devices' CONF_DISARM_AFTER_TRIGGER = 'disarm_after_trigger' CONF_DISCOVERY = 'discovery' @@ -345,6 +346,7 @@ # Pressure units PRESSURE_PA = 'Pa' # type: str PRESSURE_HPA = 'hPa' # type: str +PRESSURE_BAR = 'bar' # type: str PRESSURE_MBAR = 'mbar' # type: str PRESSURE_INHG = 'inHg' # type: str PRESSURE_PSI = 'psi' # type: str @@ -410,6 +412,7 @@ SERVICE_SET_COVER_TILT_POSITION = 'set_cover_tilt_position' SERVICE_STOP_COVER = 'stop_cover' SERVICE_STOP_COVER_TILT = 'stop_cover_tilt' +SERVICE_TOGGLE_COVER_TILT = 'toggle_cover_tilt' SERVICE_SELECT_OPTION = 'select_option' diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 87da17434b9d7d..926023f4a75134 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -1,10 +1,11 @@ """Automatically generated by hassfest. -To update, run python3 -m hassfest +To update, run python3 -m script.hassfest """ FLOWS = [ + "adguard", "ambiclimate", "ambient_station", "axis", @@ -25,22 +26,26 @@ "ios", "ipma", "iqvia", + "life360", "lifx", "locative", "logi_circle", "luftdaten", "mailgun", + "met", "mobile_app", "mqtt", "nest", "openuv", "owntracks", + "plaato", "point", "ps4", "rainmachine", "simplisafe", "smartthings", "smhi", + "somfy", "sonos", "tellduslive", "toon", diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index 4da9f41a20354c..28df05a872cfb0 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -1,6 +1,6 @@ """Automatically generated by hassfest. -To update, run python3 -m hassfest +To update, run python3 -m script.hassfest """ @@ -15,5 +15,12 @@ "hue" ] }, - "st": {} + "st": { + "urn:schemas-denon-com:device:ACT-Denon:1": [ + "heos" + ], + "urn:schemas-upnp-org:device:ZonePlayer:1": [ + "sonos" + ] + } } diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 4e46e9dd366f5f..09c1712c061d52 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -1,6 +1,6 @@ """Automatically generated by hassfest. -To update, run python3 -m hassfest +To update, run python3 -m script.hassfest """ @@ -14,6 +14,9 @@ "_esphomelib._tcp.local.": [ "esphome" ], + "_googlecast._tcp.local.": [ + "cast" + ], "_hap._tcp.local.": [ "homekit_controller" ] diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 9282770de1a85c..bd5d85230c59cf 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -1,12 +1,12 @@ """Helpers for config validation using voluptuous.""" import inspect -import json import logging import os import re from datetime import (timedelta, datetime as datetime_sys, time as time_sys, date as date_sys) from socket import _GLOBAL_DEFAULT_TIMEOUT +from numbers import Number from typing import Any, Union, TypeVar, Callable, Sequence, Dict, Optional from urllib.parse import urlparse from uuid import UUID @@ -17,7 +17,7 @@ import homeassistant.util.dt as dt_util from homeassistant.const import ( CONF_ABOVE, CONF_ALIAS, CONF_BELOW, CONF_CONDITION, CONF_ENTITY_ID, - CONF_ENTITY_NAMESPACE, CONF_NAME, CONF_PLATFORM, CONF_SCAN_INTERVAL, + CONF_ENTITY_NAMESPACE, CONF_PLATFORM, CONF_SCAN_INTERVAL, CONF_UNIT_SYSTEM_IMPERIAL, CONF_UNIT_SYSTEM_METRIC, CONF_VALUE_TEMPLATE, CONF_TIMEOUT, ENTITY_MATCH_ALL, SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET, TEMP_CELSIUS, TEMP_FAHRENHEIT, WEEKDAYS, __version__) @@ -29,13 +29,6 @@ # pylint: disable=invalid-name TIME_PERIOD_ERROR = "offset {} should be format 'HH:MM' or 'HH:MM:SS'" -OLD_SLUG_VALIDATION = r'^[a-z0-9_]+$' -OLD_ENTITY_ID_VALIDATION = r"^(\w+)\.(\w+)$" -# Keep track of invalid slugs and entity ids found so we can create a -# persistent notification. Rare temporary exception to use a global. -INVALID_SLUGS_FOUND = {} -INVALID_ENTITY_IDS_FOUND = {} -INVALID_EXTRA_KEYS_FOUND = [] # Home Assistant types @@ -89,14 +82,17 @@ def validate(obj: Dict) -> Dict: def boolean(value: Any) -> bool: """Validate and coerce a boolean value.""" + if isinstance(value, bool): + return value if isinstance(value, str): - value = value.lower() + value = value.lower().strip() if value in ('1', 'true', 'yes', 'on', 'enable'): return True if value in ('0', 'false', 'no', 'off', 'disable'): return False - raise vol.Invalid('invalid boolean value {}'.format(value)) - return bool(value) + elif isinstance(value, Number): + return value != 0 + raise vol.Invalid('invalid boolean value {}'.format(value)) def isdevice(value): @@ -176,17 +172,6 @@ def entity_id(value: Any) -> str: value = string(value).lower() if valid_entity_id(value): return value - if re.match(OLD_ENTITY_ID_VALIDATION, value): - # To ease the breaking change, we allow old slugs for now - # Remove after 0.94 or 1.0 - fixed = '.'.join(util_slugify(part) for part in value.split('.', 1)) - INVALID_ENTITY_IDS_FOUND[value] = fixed - logging.getLogger(__name__).warning( - "Found invalid entity_id %s, please update with %s. This " - "will become a breaking change.", - value, fixed - ) - return value raise vol.Invalid('Entity ID {} is an invalid entity id'.format(value)) @@ -377,21 +362,7 @@ def verify(value: Dict) -> Dict: raise vol.Invalid('expected dictionary') for key in value.keys(): - try: - slug(key) - except vol.Invalid: - # To ease the breaking change, we allow old slugs for now - # Remove after 0.94 or 1.0 - if re.match(OLD_SLUG_VALIDATION, key): - fixed = util_slugify(key) - INVALID_SLUGS_FOUND[key] = fixed - logging.getLogger(__name__).warning( - "Found invalid slug %s, please update with %s. This " - "will be come a breaking change.", - key, fixed - ) - else: - raise + slug(key) return schema(value) return verify @@ -656,88 +627,7 @@ def validator(value): # Schemas -class HASchema(vol.Schema): - """Schema class that allows us to mark PREVENT_EXTRA errors as warnings.""" - - def __call__(self, data): - """Override __call__ to mark PREVENT_EXTRA as warning.""" - try: - return super().__call__(data) - except vol.Invalid as orig_err: - if self.extra != vol.PREVENT_EXTRA: - raise - - # orig_error is of type vol.MultipleInvalid (see super __call__) - assert isinstance(orig_err, vol.MultipleInvalid) - # pylint: disable=no-member - # If it fails with PREVENT_EXTRA, try with ALLOW_EXTRA - self.extra = vol.ALLOW_EXTRA - # In case it still fails the following will raise - try: - validated = super().__call__(data) - finally: - self.extra = vol.PREVENT_EXTRA - - # This is a legacy config, print warning - extra_key_errs = [err.path[-1] for err in orig_err.errors - if err.error_message == 'extra keys not allowed'] - - if not extra_key_errs: - # This should not happen (all errors should be extra key - # errors). Let's raise the original error anyway. - raise orig_err - - WHITELIST = [ - re.compile(CONF_NAME), - re.compile(CONF_PLATFORM), - re.compile('.*_topic'), - ] - - msg = "Your configuration contains extra keys " \ - "that the platform does not support.\n" \ - "Please remove " - submsg = ', '.join('[{}]'.format(err) for err in - extra_key_errs) - submsg += '. ' - - # Add file+line information, if available - if hasattr(data, '__config_file__'): - submsg += " (See {}, line {}). ".format( - data.__config_file__, data.__line__) - - # Add configuration source information, if available - if hasattr(data, '__configuration_source__'): - submsg += "\nConfiguration source: {}. ".format( - data.__configuration_source__) - redacted_data = {} - - # Print configuration causing the error, but filter any potentially - # sensitive data - for k, v in data.items(): - if (any(regex.match(k) for regex in WHITELIST) or - k in extra_key_errs): - redacted_data[k] = v - else: - redacted_data[k] = '' - submsg += "\nOffending data: {}".format( - json.dumps(redacted_data)) - - msg += submsg - logging.getLogger(__name__).warning(msg) - INVALID_EXTRA_KEYS_FOUND.append(submsg) - - # Return legacy validated config - return validated - - def extend(self, schema, required=None, extra=None): - """Extend this schema and convert it to HASchema if necessary.""" - ret = super().extend(schema, required=required, extra=extra) - if extra is not None: - return ret - return HASchema(ret.schema, required=required, extra=self.extra) - - -PLATFORM_SCHEMA = HASchema({ +PLATFORM_SCHEMA = vol.Schema({ vol.Required(CONF_PLATFORM): string, vol.Optional(CONF_ENTITY_NAMESPACE): string, vol.Optional(CONF_SCAN_INTERVAL): time_period diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index d090e571a8b970..13a013522fb3e3 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -38,7 +38,7 @@ class DeviceEntry: model = attr.ib(type=str, default=None) name = attr.ib(type=str, default=None) sw_version = attr.ib(type=str, default=None) - hub_device_id = attr.ib(type=str, default=None) + via_device_id = attr.ib(type=str, default=None) area_id = attr.ib(type=str, default=None) name_by_user = attr.ib(type=str, default=None) id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex)) @@ -93,7 +93,7 @@ def async_get_device(self, identifiers: set, connections: set): def async_get_or_create(self, *, config_entry_id, connections=None, identifiers=None, manufacturer=_UNDEF, model=_UNDEF, name=_UNDEF, sw_version=_UNDEF, - via_hub=None): + via_device=None): """Get device. Create if it doesn't exist.""" if not identifiers and not connections: return None @@ -116,16 +116,16 @@ def async_get_or_create(self, *, config_entry_id, connections=None, device = DeviceEntry(is_new=True) self.devices[device.id] = device - if via_hub is not None: - hub_device = self.async_get_device({via_hub}, set()) - hub_device_id = hub_device.id if hub_device else _UNDEF + if via_device is not None: + via = self.async_get_device({via_device}, set()) + via_device_id = via.id if via else _UNDEF else: - hub_device_id = _UNDEF + via_device_id = _UNDEF return self._async_update_device( device.id, add_config_entry_id=config_entry_id, - hub_device_id=hub_device_id, + via_device_id=via_device_id, merge_connections=connections or _UNDEF, merge_identifiers=identifiers or _UNDEF, manufacturer=manufacturer, @@ -153,7 +153,7 @@ def _async_update_device(self, device_id, *, add_config_entry_id=_UNDEF, model=_UNDEF, name=_UNDEF, sw_version=_UNDEF, - hub_device_id=_UNDEF, + via_device_id=_UNDEF, area_id=_UNDEF, name_by_user=_UNDEF): """Update device attributes.""" @@ -191,7 +191,7 @@ def _async_update_device(self, device_id, *, add_config_entry_id=_UNDEF, ('model', model), ('name', name), ('sw_version', sw_version), - ('hub_device_id', hub_device_id), + ('via_device_id', via_device_id), ): if value is not _UNDEF and value != getattr(old, attr_name): changes[attr_name] = value @@ -247,7 +247,10 @@ async def async_load(self): sw_version=device['sw_version'], id=device['id'], # Introduced in 0.79 - hub_device_id=device.get('hub_device_id'), + # renamed in 0.95 + via_device_id=( + device.get('via_device_id') + or device.get('hub_device_id')), # Introduced in 0.87 area_id=device.get('area_id'), name_by_user=device.get('name_by_user') @@ -275,7 +278,7 @@ def _data_to_save(self): 'name': entry.name, 'sw_version': entry.sw_version, 'id': entry.id, - 'hub_device_id': entry.hub_device_id, + 'via_device_id': entry.via_device_id, 'area_id': entry.area_id, 'name_by_user': entry.name_by_user } for entry in self.devices.values() diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 30868c33f9df60..8b1b850258696b 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -296,7 +296,7 @@ async def _async_add_entity(self, entity, update_before_add, 'model', 'name', 'sw_version', - 'via_hub', + 'via_device', ): if key in device_info: processed_dev_info[key] = device_info[key] diff --git a/homeassistant/helpers/typing.py b/homeassistant/helpers/typing.py index e9a8d0749b0a46..f084c5fddbe6a9 100644 --- a/homeassistant/helpers/typing.py +++ b/homeassistant/helpers/typing.py @@ -7,8 +7,10 @@ GPSType = Tuple[float, float] ConfigType = Dict[str, Any] +ContextType = homeassistant.core.Context EventType = homeassistant.core.Event HomeAssistantType = homeassistant.core.HomeAssistant +ServiceCallType = homeassistant.core.ServiceCall ServiceDataType = Dict[str, Any] TemplateVarsType = Optional[Dict[str, Any]] diff --git a/homeassistant/loader.py b/homeassistant/loader.py index fb2c1bae894106..70fbc3710279ba 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -123,6 +123,11 @@ def __init__(self, hass: 'HomeAssistant', pkg_path: str, self.requirements = manifest['requirements'] # type: List[str] _LOGGER.info("Loaded %s from %s", self.domain, pkg_path) + @property + def is_built_in(self) -> bool: + """Test if package is a built-in integration.""" + return self.pkg_path.startswith(PACKAGE_BUILTIN) + def get_component(self) -> ModuleType: """Return the component.""" cache = self.hass.data.setdefault(DATA_COMPONENTS, {}) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 8ae1023e1a9c5c..1f36e9f8fdd655 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,21 +1,29 @@ +PyJWT==1.7.1 +PyNaCl==1.3.0 aiohttp==3.5.4 +aiohttp_cors==0.7.0 astral==1.10.1 async_timeout==3.0.1 attrs==19.1.0 bcrypt==3.1.6 certifi>=2018.04.16 +cryptography==2.6.1 +distro==1.4.0 +hass-nabucasa==0.15 +home-assistant-frontend==20190626.0 importlib-metadata==0.15 jinja2>=2.10 -PyJWT==1.7.1 -cryptography==2.6.1 +netdisco==2.6.0 pip>=8.0.3 python-slugify==3.0.2 pytz>=2019.01 -pyyaml>=3.13,<4 +pyyaml==5.1 requests==2.22.0 -ruamel.yaml==0.15.94 -voluptuous==0.11.5 +ruamel.yaml==0.15.97 +sqlalchemy==1.3.3 voluptuous-serialize==2.1.0 +voluptuous==0.11.5 +zeroconf==0.23.0 pycryptodome>=3.6.6 @@ -27,7 +35,3 @@ pycrypto==1000000000.0.0 # Contains code to modify Home Assistant to work around our rules python-systemair-savecair==1000000000.0.0 - -# Newer version causes pylint to take forever -# https://github.com/timothycrosley/isort/issues/848 -isort==4.3.4 diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py index b3f7cdd434c14c..b0c803990649b6 100644 --- a/homeassistant/util/dt.py +++ b/homeassistant/util/dt.py @@ -221,7 +221,7 @@ def parse_time_expression(parameter: Any, min_value: int, max_value: int) \ if parameter is None or parameter == MATCH_ALL: res = [x for x in range(min_value, max_value + 1)] elif isinstance(parameter, str) and parameter.startswith('/'): - parameter = float(parameter[1:]) + parameter = int(parameter[1:]) res = [x for x in range(min_value, max_value + 1) if x % parameter == 0] elif not hasattr(parameter, '__iter__'): @@ -302,7 +302,7 @@ def _lower_bound(arr: List[int], cmp: int) -> Optional[int]: next_hour = _lower_bound(hours, result.hour) if next_hour != result.hour: # We're in the next hour. Seconds+minutes needs to be reset. - result.replace(second=seconds[0], minute=minutes[0]) + result = result.replace(second=seconds[0], minute=minutes[0]) if next_hour is None: # No minute to match in this day. Roll-over to next day. diff --git a/homeassistant/util/package.py b/homeassistant/util/package.py index 6f6d03d67b6491..bc2245fd208dad 100644 --- a/homeassistant/util/package.py +++ b/homeassistant/util/package.py @@ -66,7 +66,7 @@ def install_package(package: str, upgrade: bool = True, if constraints is not None: args += ['--constraint', constraints] if find_links is not None: - args += ['--find-links', find_links] + args += ['--find-links', find_links, '--prefer-binary'] if target: assert not is_virtual_env() # This only works if not running in venv diff --git a/requirements_all.txt b/requirements_all.txt index ba2b91f0acab35..fd6af461a0c26a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -12,9 +12,9 @@ cryptography==2.6.1 pip>=8.0.3 python-slugify==3.0.2 pytz>=2019.01 -pyyaml>=3.13,<4 +pyyaml==5.1 requests==2.22.0 -ruamel.yaml==0.15.94 +ruamel.yaml==0.15.97 voluptuous==0.11.5 voluptuous-serialize==2.1.0 @@ -37,7 +37,7 @@ Adafruit-SHT31==1.0.2 HAP-python==2.5.0 # homeassistant.components.mastodon -Mastodon.py==1.4.2 +Mastodon.py==1.4.3 # homeassistant.components.orangepi_gpio OPi.GPIO==0.3.6 @@ -93,7 +93,7 @@ TwitterAPI==2.5.9 # VL53L1X2==0.1.5 # homeassistant.components.waze_travel_time -WazeRouteCalculator==0.9 +WazeRouteCalculator==0.10 # homeassistant.components.yessssms YesssSMS==0.2.3 @@ -107,11 +107,14 @@ adafruit-blinka==1.2.1 # homeassistant.components.mcp23017 adafruit-circuitpython-mcp230xx==1.1.2 +# homeassistant.components.adguard +adguardhome==0.2.1 + # homeassistant.components.frontier_silicon afsapi==0.0.4 # homeassistant.components.ambient_station -aioambient==0.3.0 +aioambient==0.3.1 # homeassistant.components.asuswrt aioasuswrt==1.1.21 @@ -126,7 +129,7 @@ aiobotocore==0.10.2 aiodns==2.0.0 # homeassistant.components.esphome -aioesphomeapi==2.1.0 +aioesphomeapi==2.2.0 # homeassistant.components.freebox aiofreepybox==0.0.8 @@ -157,10 +160,10 @@ aiolifx_effects==0.2.2 aiopvapi==1.6.14 # homeassistant.components.switcher_kis -aioswitcher==2019.3.21 +aioswitcher==2019.4.26 # homeassistant.components.unifi -aiounifi==4 +aiounifi==6 # homeassistant.components.aladdin_connect aladdin_connect==0.3 @@ -172,13 +175,13 @@ alarmdecoder==1.13.2 alpha_vantage==2.1.0 # homeassistant.components.ambiclimate -ambiclimate==0.1.2 +ambiclimate==0.2.0 # homeassistant.components.amcrest -amcrest==1.4.0 +amcrest==1.5.3 # homeassistant.components.androidtv -androidtv==0.0.15 +androidtv==0.0.16 # homeassistant.components.anel_pwrctrl anel_pwrctrl-homeassistant==0.0.1.dev2 @@ -192,6 +195,9 @@ apcaccess==0.0.13 # homeassistant.components.apns apns2==0.3.0 +# homeassistant.components.aprs +aprslib==0.6.46 + # homeassistant.components.aqualogic aqualogic==1.0 @@ -235,7 +241,7 @@ batinfo==0.4.2 beautifulsoup4==4.7.1 # homeassistant.components.zha -bellows-homeassistant==0.7.3 +bellows-homeassistant==0.8.2 # homeassistant.components.bmw_connected_drive bimmer_connected==0.5.3 @@ -244,7 +250,7 @@ bimmer_connected==0.5.3 bizkaibus==0.1.1 # homeassistant.components.blink -blinkpy==0.14.0 +blinkpy==0.14.1 # homeassistant.components.blinksticklight blinkstick==1.1.8 @@ -272,7 +278,7 @@ boto3==1.9.16 braviarc-homeassistant==0.3.7.dev0 # homeassistant.components.broadlink -broadlink==0.10.0 +broadlink==0.11.1 # homeassistant.components.brottsplatskartan brottsplatskartan==0.0.1 @@ -349,10 +355,10 @@ datapoint==0.4.3 defusedxml==0.6.0 # homeassistant.components.deluge -deluge-client==1.4.0 +deluge-client==1.7.1 # homeassistant.components.denonavr -denonavr==0.7.8 +denonavr==0.7.9 # homeassistant.components.directv directpy==0.5 @@ -361,7 +367,7 @@ directpy==0.5 discogs_client==2.2.1 # homeassistant.components.discord -discord.py==1.0.1 +discord.py==1.1.1 # homeassistant.components.updater distro==1.4.0 @@ -408,6 +414,9 @@ enocean==0.50 # homeassistant.components.entur_public_transport enturclient==0.2.0 +# homeassistant.components.environment_canada +env_canada==0.0.10 + # homeassistant.components.envirophat # envirophat==0.0.6 @@ -485,19 +494,25 @@ gearbest_parser==1.0.7 geizhals==0.0.9 # homeassistant.components.geniushub -geniushub-client==0.4.11 +geniushub-client==0.4.12 # homeassistant.components.geo_json_events # homeassistant.components.nsw_rural_fire_service_feed # homeassistant.components.usgs_earthquakes_feed geojson_client==0.3 +# homeassistant.components.aprs +geopy==1.19.0 + # homeassistant.components.geo_rss_events georss_generic_client==0.2 # homeassistant.components.ign_sismologia georss_ign_sismologia_client==0.2 +# homeassistant.components.qld_bushfire +georss_qld_bushfire_alert_client==0.3 + # homeassistant.components.gitter gitterpy==0.1.7 @@ -513,6 +528,9 @@ google-api-python-client==1.6.4 # homeassistant.components.google_pubsub google-cloud-pubsub==0.39.1 +# homeassistant.components.google_cloud +google-cloud-texttospeech==0.4.0 + # homeassistant.components.googlehome googledevices==1.0.2 @@ -547,7 +565,7 @@ habitipy==0.2.0 hangups==0.4.9 # homeassistant.components.cloud -hass-nabucasa==0.14 +hass-nabucasa==0.15 # homeassistant.components.mqtt hbmqtt==0.9.4 @@ -577,7 +595,7 @@ hole==0.3.0 holidays==0.9.10 # homeassistant.components.frontend -home-assistant-frontend==20190604.0 +home-assistant-frontend==20190626.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.4 @@ -619,7 +637,7 @@ iglo==1.2.7 ihcsdk==2.3.0 # homeassistant.components.incomfort -incomfort-client==0.2.9 +incomfort-client==0.3.0 # homeassistant.components.influxdb influxdb==5.2.0 @@ -669,6 +687,9 @@ librouteros==2.2.0 # homeassistant.components.soundtouch libsoundtouch==0.7.2 +# homeassistant.components.life360 +life360==4.0.0 + # homeassistant.components.lifx_legacy liffylights==0.9.4 @@ -727,10 +748,10 @@ mbddns==0.1.2 messagebird==1.2.0 # homeassistant.components.meteoalarm -meteoalertapi==0.0.8 +meteoalertapi==0.1.5 # homeassistant.components.meteo_france -meteofrance==0.3.4 +meteofrance==0.3.7 # homeassistant.components.mfi mficlient==0.3.0 @@ -766,7 +787,7 @@ n26==0.2.7 nad_receiver==0.0.11 # homeassistant.components.keenetic_ndms2 -ndms2_client==0.0.7 +ndms2_client==0.0.8 # homeassistant.components.ness_alarm nessclient==0.9.15 @@ -817,6 +838,9 @@ onkyo-eiscp==1.2.4 # homeassistant.components.onvif onvif-zeep-async==0.2.0 +# homeassistant.components.opencv +# opencv-python-headless==4.1.0.25 + # homeassistant.components.openevse openevsewifi==0.4 @@ -964,7 +988,7 @@ pyRFXtrx==0.23 # pySwitchmate==0.4.5 # homeassistant.components.tibber -pyTibber==0.10.3 +pyTibber==0.11.5 # homeassistant.components.dlink pyW215==0.6.0 @@ -994,7 +1018,7 @@ pyalarmdotcom==0.3.2 pyarlo==0.2.3 # homeassistant.components.netatmo -pyatmo==1.12 +pyatmo==2.1.0 # homeassistant.components.apple_tv pyatv==0.3.12 @@ -1021,7 +1045,7 @@ pycfdns==0.0.1 pychannels==1.0.0 # homeassistant.components.cast -pychromecast==3.2.1 +pychromecast==3.2.2 # homeassistant.components.cmus pycmus==0.1.1 @@ -1127,7 +1151,7 @@ pyhik==0.2.2 pyhiveapi==0.2.17 # homeassistant.components.homematic -pyhomematic==0.1.58 +pyhomematic==0.1.59 # homeassistant.components.homeworks pyhomeworks==0.0.6 @@ -1198,6 +1222,9 @@ pymailgunner==1.4 # homeassistant.components.mediaroom pymediaroom==0.6.4 +# homeassistant.components.somfy +pymfy==0.5.2 + # homeassistant.components.xiaomi_tv pymitv==1.4.3 @@ -1263,8 +1290,11 @@ pyowlet==1.0.2 # homeassistant.components.openweathermap pyowm==2.10.0 +# homeassistant.components.elv +pypca==0.0.4 + # homeassistant.components.lcn -pypck==0.6.0 +pypck==0.6.1 # homeassistant.components.pjlink pypjlink2==1.2.0 @@ -1273,7 +1303,7 @@ pypjlink2==1.2.0 pypoint==1.1.1 # homeassistant.components.ps4 -pyps4-homeassistant==0.7.3 +pyps4-homeassistant==0.8.3 # homeassistant.components.qwikswitch pyqwikswitch==0.93 @@ -1321,13 +1351,16 @@ pysma==0.3.1 pysmartapp==0.3.2 # homeassistant.components.smartthings -pysmartthings==0.6.8 +pysmartthings==0.6.9 + +# homeassistant.components.smarty +pysmarty==0.8 # homeassistant.components.snmp pysnmp==4.4.9 # homeassistant.components.sonos -pysonos==0.0.12 +pysonos==0.0.17 # homeassistant.components.spc pyspcwebgw==0.4.0 @@ -1440,11 +1473,14 @@ python-tado==0.2.9 # homeassistant.components.telegram_bot python-telegram-bot==11.1.0 +# homeassistant.components.vlc_telnet +python-telnet-vlc==1.0.4 + # homeassistant.components.twitch python-twitch-client==0.6.0 # homeassistant.components.velbus -python-velbus==2.0.24 +python-velbus==2.0.26 # homeassistant.components.vlc python-vlc==1.1.2 @@ -1483,10 +1519,7 @@ pytradfri[async]==6.0.1 pytrafikverket==0.1.5.9 # homeassistant.components.ubee -pyubee==0.6 - -# homeassistant.components.unifi -pyunifi==2.16 +pyubee==0.7 # homeassistant.components.uptimerobot pyuptimerobot==0.0.5 @@ -1495,7 +1528,7 @@ pyuptimerobot==0.0.5 # pyuserinput==0.1.11 # homeassistant.components.vera -pyvera==0.2.45 +pyvera==0.3.1 # homeassistant.components.vesync pyvesync_v2==0.9.7 @@ -1546,7 +1579,7 @@ raspyrfm-client==1.2.8 recollect-waste==1.0.1 # homeassistant.components.rainmachine -regenmaschine==1.4.0 +regenmaschine==1.5.1 # homeassistant.components.python_script restrictedpython==4.0b8 @@ -1555,7 +1588,7 @@ restrictedpython==4.0b8 rfk101py==0.0.1 # homeassistant.components.rflink -rflink==0.0.37 +rflink==0.0.46 # homeassistant.components.ring ring_doorbell==0.2.3 @@ -1618,7 +1651,7 @@ shodan==1.13.0 simplepush==1.1.4 # homeassistant.components.simplisafe -simplisafe-python==3.4.1 +simplisafe-python==3.4.2 # homeassistant.components.sisyphus sisyphus-control==2.1 @@ -1658,6 +1691,9 @@ snapcast==2.0.9 # homeassistant.components.socialblade socialbladeclient==0.2 +# homeassistant.components.solaredge_local +solaredge-local==0.1.4 + # homeassistant.components.solaredge solaredge==0.0.2 @@ -1668,7 +1704,7 @@ solax==0.0.3 somecomfort==0.5.2 # homeassistant.components.somfy_mylink -somfy-mylink-synergy==1.0.4 +somfy-mylink-synergy==1.0.6 # homeassistant.components.speedtestdotnet speedtest-cli==2.1.1 @@ -1698,6 +1734,9 @@ statsd==3.2.1 # homeassistant.components.steam_online steamodd==4.21 +# homeassistant.components.streamlabswater +streamlabswater==1.0.1 + # homeassistant.components.solaredge # homeassistant.components.thermoworks_smoke # homeassistant.components.traccar @@ -1752,10 +1791,10 @@ tikteck==0.4 todoist-python==7.0.17 # homeassistant.components.toon -toonapilib==3.2.2 +toonapilib==3.2.4 # homeassistant.components.totalconnect -total_connect_client==0.25 +total_connect_client==0.27 # homeassistant.components.tplink_lte tp-connected==0.0.4 @@ -1772,9 +1811,6 @@ tuyapy==0.1.3 # homeassistant.components.twilio twilio==6.19.1 -# homeassistant.components.uber -uber_rides==0.6.0 - # homeassistant.components.upcloud upcloud-api==0.4.3 @@ -1790,6 +1826,9 @@ uvcclient==0.11.0 # homeassistant.components.venstar venstarcolortouch==0.7 +# homeassistant.components.meteo_france +vigilancemeteo==3.0.0 + # homeassistant.components.volkszaehler volkszaehler==0.1.2 @@ -1869,7 +1908,7 @@ yeelight==0.5.0 yeelightsunflower==0.0.10 # homeassistant.components.media_extractor -youtube_dl==2019.05.11 +youtube_dl==2019.05.20 # homeassistant.components.zengge zengge==0.2 @@ -1878,7 +1917,7 @@ zengge==0.2 zeroconf==0.23.0 # homeassistant.components.zha -zha-quirks==0.0.13 +zha-quirks==0.0.15 # homeassistant.components.zhong_hong zhong_hong_hvac==1.0.9 @@ -1887,13 +1926,13 @@ zhong_hong_hvac==1.0.9 ziggo-mediabox-xl==1.1.0 # homeassistant.components.zha -zigpy-deconz==0.1.4 +zigpy-deconz==0.1.6 # homeassistant.components.zha -zigpy-homeassistant==0.3.3 +zigpy-homeassistant==0.6.1 # homeassistant.components.zha -zigpy-xbee-homeassistant==0.2.1 +zigpy-xbee-homeassistant==0.3.0 # homeassistant.components.zoneminder zm-py==0.3.3 diff --git a/requirements_test.txt b/requirements_test.txt index ff4d86436bb7ae..7de1ad9ab1d447 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -11,8 +11,8 @@ mypy==0.701 pydocstyle==3.0.0 pylint==2.3.1 pytest-aiohttp==0.3.0 -pytest-cov==2.6.1 +pytest-cov==2.7.1 pytest-sugar==0.9.2 pytest-timeout==1.3.3 -pytest==4.4.1 +pytest==4.6.1 requests_mock==1.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f87f5890dd7e99..413d239690a095 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -12,10 +12,10 @@ mypy==0.701 pydocstyle==3.0.0 pylint==2.3.1 pytest-aiohttp==0.3.0 -pytest-cov==2.6.1 +pytest-cov==2.7.1 pytest-sugar==0.9.2 pytest-timeout==1.3.3 -pytest==4.4.1 +pytest==4.6.1 requests_mock==1.5.2 @@ -35,8 +35,11 @@ PyTransportNSW==0.1.1 # homeassistant.components.yessssms YesssSMS==0.2.3 +# homeassistant.components.adguard +adguardhome==0.2.1 + # homeassistant.components.ambient_station -aioambient==0.3.0 +aioambient==0.3.1 # homeassistant.components.automatic aioautomatic==0.6.5 @@ -45,7 +48,7 @@ aioautomatic==0.6.5 aiobotocore==0.10.2 # homeassistant.components.esphome -aioesphomeapi==2.1.0 +aioesphomeapi==2.2.0 # homeassistant.components.emulated_hue # homeassistant.components.http @@ -55,17 +58,20 @@ aiohttp_cors==0.7.0 aiohue==1.9.1 # homeassistant.components.switcher_kis -aioswitcher==2019.3.21 +aioswitcher==2019.4.26 # homeassistant.components.unifi -aiounifi==4 +aiounifi==6 # homeassistant.components.ambiclimate -ambiclimate==0.1.2 +ambiclimate==0.2.0 # homeassistant.components.apns apns2==0.3.0 +# homeassistant.components.aprs +aprslib==0.6.46 + # homeassistant.components.stream av==6.1.2 @@ -73,7 +79,7 @@ av==6.1.2 axis==25 # homeassistant.components.zha -bellows-homeassistant==0.7.3 +bellows-homeassistant==0.8.2 # homeassistant.components.caldav caldav==0.6.1 @@ -120,12 +126,18 @@ gTTS-token==1.1.3 # homeassistant.components.usgs_earthquakes_feed geojson_client==0.3 +# homeassistant.components.aprs +geopy==1.19.0 + # homeassistant.components.geo_rss_events georss_generic_client==0.2 # homeassistant.components.ign_sismologia georss_ign_sismologia_client==0.2 +# homeassistant.components.qld_bushfire +georss_qld_bushfire_alert_client==0.3 + # homeassistant.components.google google-api-python-client==1.6.4 @@ -136,7 +148,7 @@ ha-ffmpeg==2.0 hangups==0.4.9 # homeassistant.components.cloud -hass-nabucasa==0.14 +hass-nabucasa==0.15 # homeassistant.components.mqtt hbmqtt==0.9.4 @@ -148,7 +160,7 @@ hdate==0.8.7 holidays==0.9.10 # homeassistant.components.frontend -home-assistant-frontend==20190604.0 +home-assistant-frontend==20190626.0 # homeassistant.components.homekit_controller homekit[IP]==0.14.0 @@ -226,6 +238,10 @@ py-canary==0.5.0 # homeassistant.components.tplink pyHS100==0.3.5 +# homeassistant.components.met +# homeassistant.components.norway_air +pyMetno==0.4.6 + # homeassistant.components.blackbird pyblackbird==0.5 @@ -239,7 +255,7 @@ pydispatcher==2.0.5 pyheos==0.5.2 # homeassistant.components.homematic -pyhomematic==0.1.58 +pyhomematic==0.1.59 # homeassistant.components.iqvia pyiqvia==0.2.1 @@ -247,6 +263,9 @@ pyiqvia==0.2.1 # homeassistant.components.litejet pylitejet==0.1 +# homeassistant.components.somfy +pymfy==0.5.2 + # homeassistant.components.monoprice pymonoprice==0.3 @@ -262,7 +281,7 @@ pyopenuv==1.0.9 pyotp==2.2.7 # homeassistant.components.ps4 -pyps4-homeassistant==0.7.3 +pyps4-homeassistant==0.8.3 # homeassistant.components.qwikswitch pyqwikswitch==0.93 @@ -271,10 +290,10 @@ pyqwikswitch==0.93 pysmartapp==0.3.2 # homeassistant.components.smartthings -pysmartthings==0.6.8 +pysmartthings==0.6.9 # homeassistant.components.sonos -pysonos==0.0.12 +pysonos==0.0.17 # homeassistant.components.spc pyspcwebgw==0.4.0 @@ -291,20 +310,17 @@ python_awair==0.0.4 # homeassistant.components.tradfri pytradfri[async]==6.0.1 -# homeassistant.components.unifi -pyunifi==2.16 - # homeassistant.components.html5 pywebpush==1.9.2 # homeassistant.components.rainmachine -regenmaschine==1.4.0 +regenmaschine==1.5.1 # homeassistant.components.python_script restrictedpython==4.0b8 # homeassistant.components.rflink -rflink==0.0.37 +rflink==0.0.46 # homeassistant.components.ring ring_doorbell==0.2.3 @@ -313,7 +329,7 @@ ring_doorbell==0.2.3 rxv==0.6.0 # homeassistant.components.simplisafe -simplisafe-python==3.4.1 +simplisafe-python==3.4.2 # homeassistant.components.sleepiq sleepyq==0.6 @@ -335,7 +351,7 @@ srpenergy==1.0.6 statsd==3.2.1 # homeassistant.components.toon -toonapilib==3.2.2 +toonapilib==3.2.4 # homeassistant.components.uvc uvcclient==0.11.0 @@ -355,4 +371,4 @@ wakeonlan==1.1.6 zeroconf==0.23.0 # homeassistant.components.zha -zigpy-homeassistant==0.3.3 +zigpy-homeassistant==0.6.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index f85758e464ff02..a8df6f6323210f 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 """Generate an updated requirements_all.txt.""" -import fnmatch import importlib import os import pathlib @@ -25,7 +24,7 @@ 'face_recognition', 'fritzconnection', 'i2csense', - 'opencv-python', + 'opencv-python-headless', 'py_noaa', 'VL53L1X2', 'pybluez', @@ -42,6 +41,7 @@ ) TEST_REQUIREMENTS = ( + 'adguardhome', 'ambiclimate', 'aioambient', 'aioautomatic', @@ -52,6 +52,7 @@ 'aiounifi', 'aioswitcher', 'apns2', + 'aprslib', 'av', 'axis', 'caldav', @@ -66,8 +67,10 @@ 'feedparser-homeassistant', 'foobot_async', 'geojson_client', + 'geopy', 'georss_generic_client', 'georss_ign_sismologia_client', + 'georss_qld_bushfire_alert_client', 'google-api-python-client', 'gTTS-token', 'ha-ffmpeg', @@ -87,6 +90,7 @@ 'libpurecool', 'libsoundtouch', 'luftdaten', + 'pyMetno', 'mbddns', 'mficlient', 'netdisco', @@ -107,6 +111,7 @@ 'pyhomematic', 'pyiqvia', 'pylitejet', + 'pymfy', 'pymonoprice', 'pynx584', 'pyopenuv', @@ -154,13 +159,6 @@ 'bellows-homeassistant', ) -IGNORE_PACKAGES = ( - 'homeassistant.components.hangouts.hangups_utils', - 'homeassistant.components.cloud.client', - 'homeassistant.components.homekit.*', - 'homeassistant.components.recorder.models', -) - IGNORE_PIN = ('colorlog>2.1,<3', 'keyring>=9.3,<10.0', 'urllib3') IGNORE_REQ = ( @@ -184,10 +182,6 @@ # Contains code to modify Home Assistant to work around our rules python-systemair-savecair==1000000000.0.0 - -# Newer version causes pylint to take forever -# https://github.com/timothycrosley/isort/issues/848 -isort==4.3.4 """ @@ -217,6 +211,22 @@ def core_requirements(): return re.findall(r"'(.*?)'", reqs_raw) +def gather_recursive_requirements(domain, seen=None): + """Recursively gather requirements from a module.""" + if seen is None: + seen = set() + + seen.add(domain) + integration = Integration(pathlib.Path( + 'homeassistant/components/{}'.format(domain) + )) + integration.load_manifest() + reqs = set(integration.manifest['requirements']) + for dep_domain in integration.manifest['dependencies']: + reqs.update(gather_recursive_requirements(dep_domain, seen)) + return reqs + + def comment_requirement(req): """Comment out requirement. Some don't install on all systems.""" return any(ign in req for ign in COMMENT_REQUIREMENTS) @@ -273,12 +283,8 @@ def gather_requirements_from_modules(errors, reqs): try: module = importlib.import_module(package) except ImportError as err: - for pattern in IGNORE_PACKAGES: - if fnmatch.fnmatch(package, pattern): - break - else: - print("{}: {}".format(package.replace('.', '/') + '.py', err)) - errors.append(package) + print("{}: {}".format(package.replace('.', '/') + '.py', err)) + errors.append(package) continue if getattr(module, 'REQUIREMENTS', None): @@ -346,7 +352,8 @@ def requirements_test_output(reqs): def gather_constraints(): """Construct output for constraint file.""" - return '\n'.join(core_requirements() + ['']) + return '\n'.join(sorted(core_requirements() + list( + gather_recursive_requirements('default_config'))) + ['']) def write_requirements_file(data): diff --git a/script/hassfest/config_flow.py b/script/hassfest/config_flow.py index 2f204227f25578..dd3c07fefd294b 100644 --- a/script/hassfest/config_flow.py +++ b/script/hassfest/config_flow.py @@ -7,7 +7,7 @@ BASE = """ \"\"\"Automatically generated by hassfest. -To update, run python3 -m hassfest +To update, run python3 -m script.hassfest \"\"\" diff --git a/script/hassfest/ssdp.py b/script/hassfest/ssdp.py index 3c13da98a9b9b8..308491dfa35575 100644 --- a/script/hassfest/ssdp.py +++ b/script/hassfest/ssdp.py @@ -8,7 +8,7 @@ BASE = """ \"\"\"Automatically generated by hassfest. -To update, run python3 -m hassfest +To update, run python3 -m script.hassfest \"\"\" diff --git a/script/hassfest/zeroconf.py b/script/hassfest/zeroconf.py index f30899d5948437..ad2b5b4e295784 100644 --- a/script/hassfest/zeroconf.py +++ b/script/hassfest/zeroconf.py @@ -8,7 +8,7 @@ BASE = """ \"\"\"Automatically generated by hassfest. -To update, run python3 -m hassfest +To update, run python3 -m script.hassfest \"\"\" diff --git a/setup.py b/setup.py index 2ae5d8e8c3b553..3278ec197d4574 100755 --- a/setup.py +++ b/setup.py @@ -46,9 +46,9 @@ 'pip>=8.0.3', 'python-slugify==3.0.2', 'pytz>=2019.01', - 'pyyaml>=3.13,<4', + 'pyyaml==5.1', 'requests==2.22.0', - 'ruamel.yaml==0.15.94', + 'ruamel.yaml==0.15.97', 'voluptuous==0.11.5', 'voluptuous-serialize==2.1.0', ] diff --git a/tests/components/adguard/__init__.py b/tests/components/adguard/__init__.py new file mode 100644 index 00000000000000..318e881ef2f90b --- /dev/null +++ b/tests/components/adguard/__init__.py @@ -0,0 +1 @@ +"""Tests for the AdGuard Home component.""" diff --git a/tests/components/adguard/test_config_flow.py b/tests/components/adguard/test_config_flow.py new file mode 100644 index 00000000000000..41af02345a9ed5 --- /dev/null +++ b/tests/components/adguard/test_config_flow.py @@ -0,0 +1,242 @@ +"""Tests for the AdGuard Home config flow.""" +from unittest.mock import patch + +import aiohttp + +from homeassistant import data_entry_flow, config_entries +from homeassistant.components.adguard import config_flow +from homeassistant.components.adguard.const import DOMAIN +from homeassistant.const import ( + CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_SSL, CONF_USERNAME, + CONF_VERIFY_SSL) + +from tests.common import MockConfigEntry, mock_coro + +FIXTURE_USER_INPUT = { + CONF_HOST: '127.0.0.1', + CONF_PORT: 3000, + CONF_USERNAME: 'user', + CONF_PASSWORD: 'pass', + CONF_SSL: True, + CONF_VERIFY_SSL: True, +} + + +async def test_show_authenticate_form(hass): + """Test that the setup form is served.""" + flow = config_flow.AdGuardHomeFlowHandler() + flow.hass = hass + result = await flow.async_step_user(user_input=None) + + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'user' + + +async def test_connection_error(hass, aioclient_mock): + """Test we show user form on AdGuard Home connection error.""" + aioclient_mock.get( + "{}://{}:{}/control/status".format( + 'https' if FIXTURE_USER_INPUT[CONF_SSL] else 'http', + FIXTURE_USER_INPUT[CONF_HOST], + FIXTURE_USER_INPUT[CONF_PORT], + ), + exc=aiohttp.ClientError, + ) + + flow = config_flow.AdGuardHomeFlowHandler() + flow.hass = hass + result = await flow.async_step_user(user_input=FIXTURE_USER_INPUT) + + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'user' + assert result['errors'] == {'base': 'connection_error'} + + +async def test_full_flow_implementation(hass, aioclient_mock): + """Test registering an integration and finishing flow works.""" + aioclient_mock.get( + "{}://{}:{}/control/status".format( + 'https' if FIXTURE_USER_INPUT[CONF_SSL] else 'http', + FIXTURE_USER_INPUT[CONF_HOST], + FIXTURE_USER_INPUT[CONF_PORT], + ), + json={'version': '1.0'}, + headers={'Content-Type': 'application/json'}, + ) + + flow = config_flow.AdGuardHomeFlowHandler() + flow.hass = hass + result = await flow.async_step_user(user_input=None) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'user' + + result = await flow.async_step_user(user_input=FIXTURE_USER_INPUT) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['title'] == FIXTURE_USER_INPUT[CONF_HOST] + assert result['data'][CONF_HOST] == FIXTURE_USER_INPUT[CONF_HOST] + assert result['data'][CONF_PASSWORD] == FIXTURE_USER_INPUT[CONF_PASSWORD] + assert result['data'][CONF_PORT] == FIXTURE_USER_INPUT[CONF_PORT] + assert result['data'][CONF_SSL] == FIXTURE_USER_INPUT[CONF_SSL] + assert result['data'][CONF_USERNAME] == FIXTURE_USER_INPUT[CONF_USERNAME] + assert ( + result['data'][CONF_VERIFY_SSL] == FIXTURE_USER_INPUT[CONF_VERIFY_SSL] + ) + + +async def test_integration_already_exists(hass): + """Test we only allow a single config flow.""" + MockConfigEntry(domain=DOMAIN).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={'source': 'user'} + ) + assert result['type'] == 'abort' + assert result['reason'] == 'single_instance_allowed' + + +async def test_hassio_single_instance(hass): + """Test we only allow a single config flow.""" + MockConfigEntry(domain='adguard', data={ + 'host': 'mock-adguard', + 'port': '3000' + }).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + 'adguard', + data={ + 'addon': 'AdGuard Home Addon', + 'host': 'mock-adguard', + 'port': '3000', + }, + context={'source': 'hassio'} + ) + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'single_instance_allowed' + + +async def test_hassio_update_instance_not_running(hass): + """Test we only allow a single config flow.""" + entry = MockConfigEntry(domain='adguard', data={ + 'host': 'mock-adguard', + 'port': '3000' + }) + entry.add_to_hass(hass) + assert entry.state == config_entries.ENTRY_STATE_NOT_LOADED + + result = await hass.config_entries.flow.async_init( + 'adguard', + data={ + 'addon': 'AdGuard Home Addon', + 'host': 'mock-adguard-updated', + 'port': '3000', + }, + context={'source': 'hassio'} + ) + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'existing_instance_updated' + + +async def test_hassio_update_instance_running(hass): + """Test we only allow a single config flow.""" + entry = MockConfigEntry(domain='adguard', data={ + 'host': 'mock-adguard', + 'port': '3000', + 'verify_ssl': False, + 'username': None, + 'password': None, + 'ssl': False, + }) + entry.add_to_hass(hass) + + with patch.object( + hass.config_entries, 'async_forward_entry_setup', + side_effect=lambda *_: mock_coro(True) + ) as mock_load: + assert await hass.config_entries.async_setup(entry.entry_id) + assert entry.state == config_entries.ENTRY_STATE_LOADED + assert len(mock_load.mock_calls) == 2 + + with patch.object( + hass.config_entries, 'async_forward_entry_unload', + side_effect=lambda *_: mock_coro(True) + ) as mock_unload, patch.object( + hass.config_entries, 'async_forward_entry_setup', + side_effect=lambda *_: mock_coro(True) + ) as mock_load: + result = await hass.config_entries.flow.async_init( + 'adguard', + data={ + 'addon': 'AdGuard Home Addon', + 'host': 'mock-adguard-updated', + 'port': '3000', + }, + context={'source': 'hassio'} + ) + assert len(mock_unload.mock_calls) == 2 + assert len(mock_load.mock_calls) == 2 + + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'existing_instance_updated' + assert entry.data['host'] == 'mock-adguard-updated' + + +async def test_hassio_confirm(hass, aioclient_mock): + """Test we can finish a config flow.""" + aioclient_mock.get( + "http://mock-adguard:3000/control/status", + json={'version': '1.0'}, + headers={'Content-Type': 'application/json'}, + ) + + result = await hass.config_entries.flow.async_init( + 'adguard', + data={ + 'addon': 'AdGuard Home Addon', + 'host': 'mock-adguard', + 'port': 3000, + }, + context={'source': 'hassio'}, + ) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'hassio_confirm' + assert result['description_placeholders'] == { + 'addon': 'AdGuard Home Addon' + } + + result = await hass.config_entries.flow.async_configure( + result['flow_id'], {} + ) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['title'] == 'AdGuard Home Addon' + assert result['data'][CONF_HOST] == 'mock-adguard' + assert result['data'][CONF_PASSWORD] is None + assert result['data'][CONF_PORT] == 3000 + assert result['data'][CONF_SSL] is False + assert result['data'][CONF_USERNAME] is None + assert result['data'][CONF_VERIFY_SSL] + + +async def test_hassio_connection_error(hass, aioclient_mock): + """Test we show hassio confirm form on AdGuard Home connection error.""" + aioclient_mock.get( + "http://mock-adguard:3000/control/status", + exc=aiohttp.ClientError, + ) + + result = await hass.config_entries.flow.async_init( + 'adguard', + data={ + 'addon': 'AdGuard Home Addon', + 'host': 'mock-adguard', + 'port': 3000, + }, + context={'source': 'hassio'}, + ) + + result = await hass.config_entries.flow.async_configure( + result['flow_id'], {} + ) + + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'hassio_confirm' + assert result['errors'] == {'base': 'connection_error'} diff --git a/tests/components/alexa/__init__.py b/tests/components/alexa/__init__.py index 88ecc63d200198..9ac6688ae249a3 100644 --- a/tests/components/alexa/__init__.py +++ b/tests/components/alexa/__init__.py @@ -1 +1,199 @@ """Tests for the Alexa integration.""" +from uuid import uuid4 + +from homeassistant.core import Context +from homeassistant.components.alexa import config, smart_home + +from tests.common import async_mock_service + +TEST_URL = "https://api.amazonalexa.com/v3/events" +TEST_TOKEN_URL = "https://api.amazon.com/auth/o2/token" + + +class MockConfig(config.AbstractConfig): + """Mock Alexa config.""" + + entity_config = {} + + @property + def supports_auth(self): + """Return if config supports auth.""" + return True + + @property + def endpoint(self): + """Endpoint for report state.""" + return TEST_URL + + def should_expose(self, entity_id): + """If an entity should be exposed.""" + return True + + async def async_get_access_token(self): + """Get an access token.""" + return "thisisnotanacesstoken" + + async def async_accept_grant(self, code): + """Accept a grant.""" + pass + + +DEFAULT_CONFIG = MockConfig(None) + + +def get_new_request(namespace, name, endpoint=None): + """Generate a new API message.""" + raw_msg = { + 'directive': { + 'header': { + 'namespace': namespace, + 'name': name, + 'messageId': str(uuid4()), + 'correlationToken': str(uuid4()), + 'payloadVersion': '3', + }, + 'endpoint': { + 'scope': { + 'type': 'BearerToken', + 'token': str(uuid4()), + }, + 'endpointId': endpoint, + }, + 'payload': {}, + } + } + + if not endpoint: + raw_msg['directive'].pop('endpoint') + + return raw_msg + + +async def assert_request_calls_service( + namespace, + name, + endpoint, + service, + hass, + response_type='Response', + payload=None): + """Assert an API request calls a hass service.""" + context = Context() + request = get_new_request(namespace, name, endpoint) + if payload: + request['directive']['payload'] = payload + + domain, service_name = service.split('.') + calls = async_mock_service(hass, domain, service_name) + + msg = await smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request, context) + await hass.async_block_till_done() + + assert len(calls) == 1 + call = calls[0] + assert 'event' in msg + assert call.data['entity_id'] == endpoint.replace('#', '.') + assert msg['event']['header']['name'] == response_type + assert call.context == context + + return call, msg + + +async def assert_request_fails( + namespace, + name, + endpoint, + service_not_called, + hass, + payload=None): + """Assert an API request returns an ErrorResponse.""" + request = get_new_request(namespace, name, endpoint) + if payload: + request['directive']['payload'] = payload + + domain, service_name = service_not_called.split('.') + call = async_mock_service(hass, domain, service_name) + + msg = await smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) + await hass.async_block_till_done() + + assert not call + assert 'event' in msg + assert msg['event']['header']['name'] == 'ErrorResponse' + + return msg + + +async def assert_power_controller_works( + endpoint, + on_service, + off_service, + hass +): + """Assert PowerController API requests work.""" + await assert_request_calls_service( + 'Alexa.PowerController', 'TurnOn', endpoint, + on_service, hass) + + await assert_request_calls_service( + 'Alexa.PowerController', 'TurnOff', endpoint, + off_service, hass) + + +async def assert_scene_controller_works( + endpoint, + activate_service, + deactivate_service, + hass): + """Assert SceneController API requests work.""" + _, response = await assert_request_calls_service( + 'Alexa.SceneController', 'Activate', endpoint, + activate_service, hass, + response_type='ActivationStarted') + assert response['event']['payload']['cause']['type'] == 'VOICE_INTERACTION' + assert 'timestamp' in response['event']['payload'] + + if deactivate_service: + await assert_request_calls_service( + 'Alexa.SceneController', 'Deactivate', endpoint, + deactivate_service, hass, + response_type='DeactivationStarted') + cause_type = response['event']['payload']['cause']['type'] + assert cause_type == 'VOICE_INTERACTION' + assert 'timestamp' in response['event']['payload'] + + +async def reported_properties(hass, endpoint): + """Use ReportState to get properties and return them. + + The result is a ReportedProperties instance, which has methods to make + assertions about the properties. + """ + request = get_new_request('Alexa', 'ReportState', endpoint) + msg = await smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) + await hass.async_block_till_done() + return ReportedProperties(msg['context']['properties']) + + +class ReportedProperties: + """Class to help assert reported properties.""" + + def __init__(self, properties): + """Initialize class.""" + self.properties = properties + + def assert_equal(self, namespace, name, value): + """Assert a property is equal to a given value.""" + for prop in self.properties: + if prop['namespace'] == namespace and prop['name'] == name: + assert prop['value'] == value + return prop + + assert False, 'property %s:%s not in %r' % ( + namespace, + name, + self.properties, + ) diff --git a/tests/components/alexa/test_auth.py b/tests/components/alexa/test_auth.py new file mode 100644 index 00000000000000..aefb5e82225c26 --- /dev/null +++ b/tests/components/alexa/test_auth.py @@ -0,0 +1,67 @@ +"""Test Alexa auth endpoints.""" +from homeassistant.components.alexa.auth import Auth +from . import TEST_TOKEN_URL + + +async def run_auth_get_access_token(hass, aioclient_mock, expires_in, + client_id, client_secret, + accept_grant_code, refresh_token): + """Do auth and request a new token for tests.""" + aioclient_mock.post(TEST_TOKEN_URL, + json={'access_token': 'the_access_token', + 'refresh_token': refresh_token, + 'expires_in': expires_in}) + + auth = Auth(hass, client_id, client_secret) + await auth.async_do_auth(accept_grant_code) + await auth.async_get_access_token() + + +async def test_auth_get_access_token_expired(hass, aioclient_mock): + """Test the auth get access token function.""" + client_id = "client123" + client_secret = "shhhhh" + accept_grant_code = "abcdefg" + refresh_token = "refresher" + + await run_auth_get_access_token(hass, aioclient_mock, -5, + client_id, client_secret, + accept_grant_code, refresh_token) + + assert len(aioclient_mock.mock_calls) == 2 + calls = aioclient_mock.mock_calls + + auth_call_json = calls[0][2] + token_call_json = calls[1][2] + + assert auth_call_json["grant_type"] == "authorization_code" + assert auth_call_json["code"] == accept_grant_code + assert auth_call_json["client_id"] == client_id + assert auth_call_json["client_secret"] == client_secret + + assert token_call_json["grant_type"] == "refresh_token" + assert token_call_json["refresh_token"] == refresh_token + assert token_call_json["client_id"] == client_id + assert token_call_json["client_secret"] == client_secret + + +async def test_auth_get_access_token_not_expired(hass, aioclient_mock): + """Test the auth get access token function.""" + client_id = "client123" + client_secret = "shhhhh" + accept_grant_code = "abcdefg" + refresh_token = "refresher" + + await run_auth_get_access_token(hass, aioclient_mock, 555, + client_id, client_secret, + accept_grant_code, refresh_token) + + assert len(aioclient_mock.mock_calls) == 1 + call = aioclient_mock.mock_calls + + auth_call_json = call[0][2] + + assert auth_call_json["grant_type"] == "authorization_code" + assert auth_call_json["code"] == accept_grant_code + assert auth_call_json["client_id"] == client_id + assert auth_call_json["client_secret"] == client_secret diff --git a/tests/components/alexa/test_capabilities.py b/tests/components/alexa/test_capabilities.py new file mode 100644 index 00000000000000..c47dae6d3a3d6e --- /dev/null +++ b/tests/components/alexa/test_capabilities.py @@ -0,0 +1,340 @@ +"""Test Alexa capabilities.""" +import pytest + +from homeassistant.const import ( + STATE_LOCKED, + STATE_UNLOCKED, + STATE_UNKNOWN, +) +from homeassistant.components.alexa import smart_home +from tests.common import async_mock_service + +from . import ( + DEFAULT_CONFIG, + get_new_request, + assert_request_calls_service, + assert_request_fails, + reported_properties, +) + + +@pytest.mark.parametrize( + "result,adjust", [(25, '-5'), (35, '5'), (0, '-80')]) +async def test_api_adjust_brightness(hass, result, adjust): + """Test api adjust brightness process.""" + request = get_new_request( + 'Alexa.BrightnessController', 'AdjustBrightness', 'light#test') + + # add payload + request['directive']['payload']['brightnessDelta'] = adjust + + # setup test devices + hass.states.async_set( + 'light.test', 'off', { + 'friendly_name': "Test light", 'brightness': '77' + }) + + call_light = async_mock_service(hass, 'light', 'turn_on') + + msg = await smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) + await hass.async_block_till_done() + + assert 'event' in msg + msg = msg['event'] + + assert len(call_light) == 1 + assert call_light[0].data['entity_id'] == 'light.test' + assert call_light[0].data['brightness_pct'] == result + assert msg['header']['name'] == 'Response' + + +async def test_api_set_color_rgb(hass): + """Test api set color process.""" + request = get_new_request( + 'Alexa.ColorController', 'SetColor', 'light#test') + + # add payload + request['directive']['payload']['color'] = { + 'hue': '120', + 'saturation': '0.612', + 'brightness': '0.342', + } + + # setup test devices + hass.states.async_set( + 'light.test', 'off', { + 'friendly_name': "Test light", + 'supported_features': 16, + }) + + call_light = async_mock_service(hass, 'light', 'turn_on') + + msg = await smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) + await hass.async_block_till_done() + + assert 'event' in msg + msg = msg['event'] + + assert len(call_light) == 1 + assert call_light[0].data['entity_id'] == 'light.test' + assert call_light[0].data['rgb_color'] == (33, 87, 33) + assert msg['header']['name'] == 'Response' + + +async def test_api_set_color_temperature(hass): + """Test api set color temperature process.""" + request = get_new_request( + 'Alexa.ColorTemperatureController', 'SetColorTemperature', + 'light#test') + + # add payload + request['directive']['payload']['colorTemperatureInKelvin'] = '7500' + + # setup test devices + hass.states.async_set( + 'light.test', 'off', {'friendly_name': "Test light"}) + + call_light = async_mock_service(hass, 'light', 'turn_on') + + msg = await smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) + await hass.async_block_till_done() + + assert 'event' in msg + msg = msg['event'] + + assert len(call_light) == 1 + assert call_light[0].data['entity_id'] == 'light.test' + assert call_light[0].data['kelvin'] == 7500 + assert msg['header']['name'] == 'Response' + + +@pytest.mark.parametrize("result,initial", [(383, '333'), (500, '500')]) +async def test_api_decrease_color_temp(hass, result, initial): + """Test api decrease color temp process.""" + request = get_new_request( + 'Alexa.ColorTemperatureController', 'DecreaseColorTemperature', + 'light#test') + + # setup test devices + hass.states.async_set( + 'light.test', 'off', { + 'friendly_name': "Test light", 'color_temp': initial, + 'max_mireds': 500, + }) + + call_light = async_mock_service(hass, 'light', 'turn_on') + + msg = await smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) + await hass.async_block_till_done() + + assert 'event' in msg + msg = msg['event'] + + assert len(call_light) == 1 + assert call_light[0].data['entity_id'] == 'light.test' + assert call_light[0].data['color_temp'] == result + assert msg['header']['name'] == 'Response' + + +@pytest.mark.parametrize("result,initial", [(283, '333'), (142, '142')]) +async def test_api_increase_color_temp(hass, result, initial): + """Test api increase color temp process.""" + request = get_new_request( + 'Alexa.ColorTemperatureController', 'IncreaseColorTemperature', + 'light#test') + + # setup test devices + hass.states.async_set( + 'light.test', 'off', { + 'friendly_name': "Test light", 'color_temp': initial, + 'min_mireds': 142, + }) + + call_light = async_mock_service(hass, 'light', 'turn_on') + + msg = await smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) + await hass.async_block_till_done() + + assert 'event' in msg + msg = msg['event'] + + assert len(call_light) == 1 + assert call_light[0].data['entity_id'] == 'light.test' + assert call_light[0].data['color_temp'] == result + assert msg['header']['name'] == 'Response' + + +@pytest.mark.parametrize( + "domain,payload,source_list,idx", [ + ('media_player', 'GAME CONSOLE', ['tv', 'game console'], 1), + ('media_player', 'SATELLITE TV', ['satellite-tv', 'game console'], 0), + ('media_player', 'SATELLITE TV', ['satellite_tv', 'game console'], 0), + ('media_player', 'BAD DEVICE', ['satellite_tv', 'game console'], None), + ] +) +async def test_api_select_input(hass, domain, payload, source_list, idx): + """Test api set input process.""" + hass.states.async_set( + 'media_player.test', 'off', { + 'friendly_name': "Test media player", + 'source': 'unknown', + 'source_list': source_list, + }) + + # test where no source matches + if idx is None: + await assert_request_fails( + 'Alexa.InputController', 'SelectInput', 'media_player#test', + 'media_player.select_source', + hass, + payload={'input': payload}) + return + + call, _ = await assert_request_calls_service( + 'Alexa.InputController', 'SelectInput', 'media_player#test', + 'media_player.select_source', + hass, + payload={'input': payload}) + assert call.data['source'] == source_list[idx] + + +async def test_report_lock_state(hass): + """Test LockController implements lockState property.""" + hass.states.async_set( + 'lock.locked', STATE_LOCKED, {}) + hass.states.async_set( + 'lock.unlocked', STATE_UNLOCKED, {}) + hass.states.async_set( + 'lock.unknown', STATE_UNKNOWN, {}) + + properties = await reported_properties(hass, 'lock.locked') + properties.assert_equal('Alexa.LockController', 'lockState', 'LOCKED') + + properties = await reported_properties(hass, 'lock.unlocked') + properties.assert_equal('Alexa.LockController', 'lockState', 'UNLOCKED') + + properties = await reported_properties(hass, 'lock.unknown') + properties.assert_equal('Alexa.LockController', 'lockState', 'JAMMED') + + +async def test_report_dimmable_light_state(hass): + """Test BrightnessController reports brightness correctly.""" + hass.states.async_set( + 'light.test_on', 'on', {'friendly_name': "Test light On", + 'brightness': 128, 'supported_features': 1}) + hass.states.async_set( + 'light.test_off', 'off', {'friendly_name': "Test light Off", + 'supported_features': 1}) + + properties = await reported_properties(hass, 'light.test_on') + properties.assert_equal('Alexa.BrightnessController', 'brightness', 50) + + properties = await reported_properties(hass, 'light.test_off') + properties.assert_equal('Alexa.BrightnessController', 'brightness', 0) + + +async def test_report_colored_light_state(hass): + """Test ColorController reports color correctly.""" + hass.states.async_set( + 'light.test_on', 'on', {'friendly_name': "Test light On", + 'hs_color': (180, 75), + 'brightness': 128, + 'supported_features': 17}) + hass.states.async_set( + 'light.test_off', 'off', {'friendly_name': "Test light Off", + 'supported_features': 17}) + + properties = await reported_properties(hass, 'light.test_on') + properties.assert_equal('Alexa.ColorController', 'color', { + 'hue': 180, + 'saturation': 0.75, + 'brightness': 128 / 255.0, + }) + + properties = await reported_properties(hass, 'light.test_off') + properties.assert_equal('Alexa.ColorController', 'color', { + 'hue': 0, + 'saturation': 0, + 'brightness': 0, + }) + + +async def test_report_colored_temp_light_state(hass): + """Test ColorTemperatureController reports color temp correctly.""" + hass.states.async_set( + 'light.test_on', 'on', {'friendly_name': "Test light On", + 'color_temp': 240, + 'supported_features': 2}) + hass.states.async_set( + 'light.test_off', 'off', {'friendly_name': "Test light Off", + 'supported_features': 2}) + + properties = await reported_properties(hass, 'light.test_on') + properties.assert_equal('Alexa.ColorTemperatureController', + 'colorTemperatureInKelvin', 4166) + + properties = await reported_properties(hass, 'light.test_off') + properties.assert_equal('Alexa.ColorTemperatureController', + 'colorTemperatureInKelvin', 0) + + +async def test_report_fan_speed_state(hass): + """Test PercentageController reports fan speed correctly.""" + hass.states.async_set( + 'fan.off', 'off', {'friendly_name': "Off fan", + 'speed': "off", + 'supported_features': 1}) + hass.states.async_set( + 'fan.low_speed', 'on', {'friendly_name': "Low speed fan", + 'speed': "low", + 'supported_features': 1}) + hass.states.async_set( + 'fan.medium_speed', 'on', {'friendly_name': "Medium speed fan", + 'speed': "medium", + 'supported_features': 1}) + hass.states.async_set( + 'fan.high_speed', 'on', {'friendly_name': "High speed fan", + 'speed': "high", + 'supported_features': 1}) + + properties = await reported_properties(hass, 'fan.off') + properties.assert_equal('Alexa.PercentageController', 'percentage', 0) + + properties = await reported_properties(hass, 'fan.low_speed') + properties.assert_equal('Alexa.PercentageController', 'percentage', 33) + + properties = await reported_properties(hass, 'fan.medium_speed') + properties.assert_equal('Alexa.PercentageController', 'percentage', 66) + + properties = await reported_properties(hass, 'fan.high_speed') + properties.assert_equal('Alexa.PercentageController', 'percentage', 100) + + +async def test_report_cover_percentage_state(hass): + """Test PercentageController reports cover percentage correctly.""" + hass.states.async_set( + 'cover.fully_open', 'open', {'friendly_name': "Fully open cover", + 'current_position': 100, + 'supported_features': 15}) + hass.states.async_set( + 'cover.half_open', 'open', {'friendly_name': "Half open cover", + 'current_position': 50, + 'supported_features': 15}) + hass.states.async_set( + 'cover.closed', 'closed', {'friendly_name': "Closed cover", + 'current_position': 0, + 'supported_features': 15}) + + properties = await reported_properties(hass, 'cover.fully_open') + properties.assert_equal('Alexa.PercentageController', 'percentage', 100) + + properties = await reported_properties(hass, 'cover.half_open') + properties.assert_equal('Alexa.PercentageController', 'percentage', 50) + + properties = await reported_properties(hass, 'cover.closed') + properties.assert_equal('Alexa.PercentageController', 'percentage', 0) diff --git a/tests/components/alexa/test_entities.py b/tests/components/alexa/test_entities.py new file mode 100644 index 00000000000000..a2193b09019f83 --- /dev/null +++ b/tests/components/alexa/test_entities.py @@ -0,0 +1,19 @@ +"""Test Alexa entity representation.""" +from homeassistant.components.alexa import smart_home +from . import get_new_request, DEFAULT_CONFIG + + +async def test_unsupported_domain(hass): + """Discovery ignores entities of unknown domains.""" + request = get_new_request('Alexa.Discovery', 'Discover') + + hass.states.async_set( + 'woz.boop', 'on', {'friendly_name': "Boop Woz"}) + + msg = await smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) + + assert 'event' in msg + msg = msg['event'] + + assert not msg['payload']['endpoints'] diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 20b4495cd1a053..26c9e4bb8b66df 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -1,34 +1,27 @@ """Test for smart home alexa support.""" -import json -from uuid import uuid4 - import pytest from homeassistant.core import Context, callback -from homeassistant.const import ( - TEMP_CELSIUS, TEMP_FAHRENHEIT, STATE_LOCKED, - STATE_UNLOCKED, STATE_UNKNOWN) -from homeassistant.setup import async_setup_component -from homeassistant.components import alexa -from homeassistant.components.alexa import smart_home -from homeassistant.components.alexa.auth import Auth +from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.components.alexa import ( + smart_home, + messages, +) from homeassistant.helpers import entityfilter from tests.common import async_mock_service - -async def get_access_token(): - """Return a test access token.""" - return "thisisnotanacesstoken" - - -TEST_URL = "https://api.amazonalexa.com/v3/events" -TEST_TOKEN_URL = "https://api.amazon.com/auth/o2/token" - -DEFAULT_CONFIG = smart_home.Config( - endpoint=TEST_URL, - async_get_access_token=get_access_token, - should_expose=lambda entity_id: True) +from . import ( + get_new_request, + MockConfig, + DEFAULT_CONFIG, + assert_request_calls_service, + assert_request_fails, + ReportedProperties, + assert_power_controller_works, + assert_scene_controller_works, + reported_properties, +) @pytest.fixture @@ -42,39 +35,11 @@ def events(hass): yield events -def get_new_request(namespace, name, endpoint=None): - """Generate a new API message.""" - raw_msg = { - 'directive': { - 'header': { - 'namespace': namespace, - 'name': name, - 'messageId': str(uuid4()), - 'correlationToken': str(uuid4()), - 'payloadVersion': '3', - }, - 'endpoint': { - 'scope': { - 'type': 'BearerToken', - 'token': str(uuid4()), - }, - 'endpointId': endpoint, - }, - 'payload': {}, - } - } - - if not endpoint: - raw_msg['directive'].pop('endpoint') - - return raw_msg - - def test_create_api_message_defaults(hass): """Create a API message response of a request with defaults.""" request = get_new_request('Alexa.PowerController', 'TurnOn', 'switch#xy') directive_header = request['directive']['header'] - directive = smart_home._AlexaDirective(request) + directive = messages.AlexaDirective(request) msg = directive.response(payload={'test': 3})._response @@ -101,7 +66,7 @@ def test_create_api_message_special(): request = get_new_request('Alexa.PowerController', 'TurnOn') directive_header = request['directive']['header'] directive_header.pop('correlationToken') - directive = smart_home._AlexaDirective(request) + directive = messages.AlexaDirective(request) msg = directive.response('testName', 'testNameSpace')._response @@ -901,7 +866,7 @@ async def test_thermostat(hass): payload={'targetSetpoint': {'value': 69.0, 'scale': 'FAHRENHEIT'}} ) assert call.data['temperature'] == 69.0 - properties = _ReportedProperties(msg['context']['properties']) + properties = ReportedProperties(msg['context']['properties']) properties.assert_equal( 'Alexa.ThermostatController', 'targetSetpoint', {'value': 69.0, 'scale': 'FAHRENHEIT'}) @@ -927,7 +892,7 @@ async def test_thermostat(hass): assert call.data['temperature'] == 70.0 assert call.data['target_temp_low'] == 68.0 assert call.data['target_temp_high'] == 86.0 - properties = _ReportedProperties(msg['context']['properties']) + properties = ReportedProperties(msg['context']['properties']) properties.assert_equal( 'Alexa.ThermostatController', 'targetSetpoint', {'value': 70.0, 'scale': 'FAHRENHEIT'}) @@ -967,7 +932,7 @@ async def test_thermostat(hass): payload={'targetSetpointDelta': {'value': -10.0, 'scale': 'KELVIN'}} ) assert call.data['temperature'] == 52.0 - properties = _ReportedProperties(msg['context']['properties']) + properties = ReportedProperties(msg['context']['properties']) properties.assert_equal( 'Alexa.ThermostatController', 'targetSetpoint', {'value': 52.0, 'scale': 'FAHRENHEIT'}) @@ -988,7 +953,7 @@ async def test_thermostat(hass): payload={'thermostatMode': {'value': 'HEAT'}} ) assert call.data['operation_mode'] == 'heat' - properties = _ReportedProperties(msg['context']['properties']) + properties = ReportedProperties(msg['context']['properties']) properties.assert_equal( 'Alexa.ThermostatController', 'thermostatMode', 'HEAT') @@ -999,7 +964,7 @@ async def test_thermostat(hass): payload={'thermostatMode': {'value': 'COOL'}} ) assert call.data['operation_mode'] == 'cool' - properties = _ReportedProperties(msg['context']['properties']) + properties = ReportedProperties(msg['context']['properties']) properties.assert_equal( 'Alexa.ThermostatController', 'thermostatMode', 'COOL') @@ -1011,7 +976,7 @@ async def test_thermostat(hass): payload={'thermostatMode': 'HEAT'} ) assert call.data['operation_mode'] == 'heat' - properties = _ReportedProperties(msg['context']['properties']) + properties = ReportedProperties(msg['context']['properties']) properties.assert_equal( 'Alexa.ThermostatController', 'thermostatMode', 'HEAT') @@ -1047,17 +1012,15 @@ async def test_exclude_filters(hass): hass.states.async_set( 'cover.deny', 'off', {'friendly_name': "Blocked cover"}) - config = smart_home.Config( - endpoint=None, - async_get_access_token=None, - should_expose=entityfilter.generate_filter( - include_domains=[], - include_entities=[], - exclude_domains=['script'], - exclude_entities=['cover.deny'], - )) - - msg = await smart_home.async_handle_message(hass, config, request) + alexa_config = MockConfig(hass) + alexa_config.should_expose = entityfilter.generate_filter( + include_domains=[], + include_entities=[], + exclude_domains=['script'], + exclude_entities=['cover.deny'], + ) + + msg = await smart_home.async_handle_message(hass, alexa_config, request) await hass.async_block_till_done() msg = msg['event'] @@ -1082,17 +1045,15 @@ async def test_include_filters(hass): hass.states.async_set( 'group.allow', 'off', {'friendly_name': "Allowed group"}) - config = smart_home.Config( - endpoint=None, - async_get_access_token=None, - should_expose=entityfilter.generate_filter( - include_domains=['automation', 'group'], - include_entities=['script.deny'], - exclude_domains=[], - exclude_entities=[], - )) - - msg = await smart_home.async_handle_message(hass, config, request) + alexa_config = MockConfig(hass) + alexa_config.should_expose = entityfilter.generate_filter( + include_domains=['automation', 'group'], + include_entities=['script.deny'], + exclude_domains=[], + exclude_entities=[], + ) + + msg = await smart_home.async_handle_message(hass, alexa_config, request) await hass.async_block_till_done() msg = msg['event'] @@ -1111,17 +1072,15 @@ async def test_never_exposed_entities(hass): hass.states.async_set( 'group.allow', 'off', {'friendly_name': "Allowed group"}) - config = smart_home.Config( - endpoint=None, - async_get_access_token=None, - should_expose=entityfilter.generate_filter( - include_domains=['group'], - include_entities=[], - exclude_domains=[], - exclude_entities=[], - )) - - msg = await smart_home.async_handle_message(hass, config, request) + alexa_config = MockConfig(hass) + alexa_config.should_expose = entityfilter.generate_filter( + include_domains=['group'], + include_entities=[], + exclude_domains=[], + exclude_entities=[], + ) + + msg = await smart_home.async_handle_message(hass, alexa_config, request) await hass.async_block_till_done() msg = msg['event'] @@ -1162,267 +1121,20 @@ async def test_api_function_not_implemented(hass): assert msg['payload']['type'] == 'INTERNAL_ERROR' -async def assert_request_fails( - namespace, - name, - endpoint, - service_not_called, - hass, - payload=None): - """Assert an API request returns an ErrorResponse.""" - request = get_new_request(namespace, name, endpoint) - if payload: - request['directive']['payload'] = payload - - domain, service_name = service_not_called.split('.') - call = async_mock_service(hass, domain, service_name) - - msg = await smart_home.async_handle_message( - hass, DEFAULT_CONFIG, request) - await hass.async_block_till_done() - - assert not call - assert 'event' in msg - assert msg['event']['header']['name'] == 'ErrorResponse' - - return msg - - -async def assert_request_calls_service( - namespace, - name, - endpoint, - service, - hass, - response_type='Response', - payload=None): - """Assert an API request calls a hass service.""" - context = Context() - request = get_new_request(namespace, name, endpoint) - if payload: - request['directive']['payload'] = payload - - domain, service_name = service.split('.') - calls = async_mock_service(hass, domain, service_name) - - msg = await smart_home.async_handle_message( - hass, DEFAULT_CONFIG, request, context) - await hass.async_block_till_done() - - assert len(calls) == 1 - call = calls[0] - assert 'event' in msg - assert call.data['entity_id'] == endpoint.replace('#', '.') - assert msg['event']['header']['name'] == response_type - assert call.context == context - - return call, msg - - -async def assert_power_controller_works( - endpoint, - on_service, - off_service, - hass -): - """Assert PowerController API requests work.""" - await assert_request_calls_service( - 'Alexa.PowerController', 'TurnOn', endpoint, - on_service, hass) - - await assert_request_calls_service( - 'Alexa.PowerController', 'TurnOff', endpoint, - off_service, hass) - - -async def assert_scene_controller_works( - endpoint, - activate_service, - deactivate_service, - hass): - """Assert SceneController API requests work.""" - _, response = await assert_request_calls_service( - 'Alexa.SceneController', 'Activate', endpoint, - activate_service, hass, - response_type='ActivationStarted') - assert response['event']['payload']['cause']['type'] == 'VOICE_INTERACTION' - assert 'timestamp' in response['event']['payload'] - - if deactivate_service: - await assert_request_calls_service( - 'Alexa.SceneController', 'Deactivate', endpoint, - deactivate_service, hass, - response_type='DeactivationStarted') - cause_type = response['event']['payload']['cause']['type'] - assert cause_type == 'VOICE_INTERACTION' - assert 'timestamp' in response['event']['payload'] - - -@pytest.mark.parametrize( - "result,adjust", [(25, '-5'), (35, '5'), (0, '-80')]) -async def test_api_adjust_brightness(hass, result, adjust): - """Test api adjust brightness process.""" - request = get_new_request( - 'Alexa.BrightnessController', 'AdjustBrightness', 'light#test') - - # add payload - request['directive']['payload']['brightnessDelta'] = adjust - - # setup test devices - hass.states.async_set( - 'light.test', 'off', { - 'friendly_name': "Test light", 'brightness': '77' - }) - - call_light = async_mock_service(hass, 'light', 'turn_on') - - msg = await smart_home.async_handle_message( - hass, DEFAULT_CONFIG, request) - await hass.async_block_till_done() - - assert 'event' in msg - msg = msg['event'] - - assert len(call_light) == 1 - assert call_light[0].data['entity_id'] == 'light.test' - assert call_light[0].data['brightness_pct'] == result - assert msg['header']['name'] == 'Response' - - -async def test_api_set_color_rgb(hass): - """Test api set color process.""" - request = get_new_request( - 'Alexa.ColorController', 'SetColor', 'light#test') - - # add payload - request['directive']['payload']['color'] = { - 'hue': '120', - 'saturation': '0.612', - 'brightness': '0.342', - } - - # setup test devices - hass.states.async_set( - 'light.test', 'off', { - 'friendly_name': "Test light", - 'supported_features': 16, - }) - - call_light = async_mock_service(hass, 'light', 'turn_on') - - msg = await smart_home.async_handle_message( - hass, DEFAULT_CONFIG, request) - await hass.async_block_till_done() - - assert 'event' in msg - msg = msg['event'] - - assert len(call_light) == 1 - assert call_light[0].data['entity_id'] == 'light.test' - assert call_light[0].data['rgb_color'] == (33, 87, 33) - assert msg['header']['name'] == 'Response' - - -async def test_api_set_color_temperature(hass): - """Test api set color temperature process.""" - request = get_new_request( - 'Alexa.ColorTemperatureController', 'SetColorTemperature', - 'light#test') - - # add payload - request['directive']['payload']['colorTemperatureInKelvin'] = '7500' - - # setup test devices - hass.states.async_set( - 'light.test', 'off', {'friendly_name': "Test light"}) - - call_light = async_mock_service(hass, 'light', 'turn_on') - - msg = await smart_home.async_handle_message( - hass, DEFAULT_CONFIG, request) - await hass.async_block_till_done() - - assert 'event' in msg - msg = msg['event'] - - assert len(call_light) == 1 - assert call_light[0].data['entity_id'] == 'light.test' - assert call_light[0].data['kelvin'] == 7500 - assert msg['header']['name'] == 'Response' - - -@pytest.mark.parametrize("result,initial", [(383, '333'), (500, '500')]) -async def test_api_decrease_color_temp(hass, result, initial): - """Test api decrease color temp process.""" - request = get_new_request( - 'Alexa.ColorTemperatureController', 'DecreaseColorTemperature', - 'light#test') - - # setup test devices - hass.states.async_set( - 'light.test', 'off', { - 'friendly_name': "Test light", 'color_temp': initial, - 'max_mireds': 500, - }) - - call_light = async_mock_service(hass, 'light', 'turn_on') - - msg = await smart_home.async_handle_message( - hass, DEFAULT_CONFIG, request) - await hass.async_block_till_done() - - assert 'event' in msg - msg = msg['event'] - - assert len(call_light) == 1 - assert call_light[0].data['entity_id'] == 'light.test' - assert call_light[0].data['color_temp'] == result - assert msg['header']['name'] == 'Response' - - -@pytest.mark.parametrize("result,initial", [(283, '333'), (142, '142')]) -async def test_api_increase_color_temp(hass, result, initial): - """Test api increase color temp process.""" - request = get_new_request( - 'Alexa.ColorTemperatureController', 'IncreaseColorTemperature', - 'light#test') - - # setup test devices - hass.states.async_set( - 'light.test', 'off', { - 'friendly_name': "Test light", 'color_temp': initial, - 'min_mireds': 142, - }) - - call_light = async_mock_service(hass, 'light', 'turn_on') - - msg = await smart_home.async_handle_message( - hass, DEFAULT_CONFIG, request) - await hass.async_block_till_done() - - assert 'event' in msg - msg = msg['event'] - - assert len(call_light) == 1 - assert call_light[0].data['entity_id'] == 'light.test' - assert call_light[0].data['color_temp'] == result - assert msg['header']['name'] == 'Response' - - async def test_api_accept_grant(hass): """Test api AcceptGrant process.""" request = get_new_request("Alexa.Authorization", "AcceptGrant") # add payload request['directive']['payload'] = { - 'grant': { - 'type': 'OAuth2.AuthorizationCode', - 'code': 'VGhpcyBpcyBhbiBhdXRob3JpemF0aW9uIGNvZGUuIDotKQ==' - }, - 'grantee': { - 'type': 'BearerToken', - 'token': 'access-token-from-skill' - } + 'grant': { + 'type': 'OAuth2.AuthorizationCode', + 'code': 'VGhpcyBpcyBhbiBhdXRob3JpemF0aW9uIGNvZGUuIDotKQ==' + }, + 'grantee': { + 'type': 'BearerToken', + 'token': 'access-token-from-skill' + } } # setup test devices @@ -1436,174 +1148,6 @@ async def test_api_accept_grant(hass): assert msg['header']['name'] == 'AcceptGrant.Response' -async def test_report_lock_state(hass): - """Test LockController implements lockState property.""" - hass.states.async_set( - 'lock.locked', STATE_LOCKED, {}) - hass.states.async_set( - 'lock.unlocked', STATE_UNLOCKED, {}) - hass.states.async_set( - 'lock.unknown', STATE_UNKNOWN, {}) - - properties = await reported_properties(hass, 'lock.locked') - properties.assert_equal('Alexa.LockController', 'lockState', 'LOCKED') - - properties = await reported_properties(hass, 'lock.unlocked') - properties.assert_equal('Alexa.LockController', 'lockState', 'UNLOCKED') - - properties = await reported_properties(hass, 'lock.unknown') - properties.assert_equal('Alexa.LockController', 'lockState', 'JAMMED') - - -async def test_report_dimmable_light_state(hass): - """Test BrightnessController reports brightness correctly.""" - hass.states.async_set( - 'light.test_on', 'on', {'friendly_name': "Test light On", - 'brightness': 128, 'supported_features': 1}) - hass.states.async_set( - 'light.test_off', 'off', {'friendly_name': "Test light Off", - 'supported_features': 1}) - - properties = await reported_properties(hass, 'light.test_on') - properties.assert_equal('Alexa.BrightnessController', 'brightness', 50) - - properties = await reported_properties(hass, 'light.test_off') - properties.assert_equal('Alexa.BrightnessController', 'brightness', 0) - - -async def test_report_colored_light_state(hass): - """Test ColorController reports color correctly.""" - hass.states.async_set( - 'light.test_on', 'on', {'friendly_name': "Test light On", - 'hs_color': (180, 75), - 'brightness': 128, - 'supported_features': 17}) - hass.states.async_set( - 'light.test_off', 'off', {'friendly_name': "Test light Off", - 'supported_features': 17}) - - properties = await reported_properties(hass, 'light.test_on') - properties.assert_equal('Alexa.ColorController', 'color', { - 'hue': 180, - 'saturation': 0.75, - 'brightness': 128 / 255.0, - }) - - properties = await reported_properties(hass, 'light.test_off') - properties.assert_equal('Alexa.ColorController', 'color', { - 'hue': 0, - 'saturation': 0, - 'brightness': 0, - }) - - -async def test_report_colored_temp_light_state(hass): - """Test ColorTemperatureController reports color temp correctly.""" - hass.states.async_set( - 'light.test_on', 'on', {'friendly_name': "Test light On", - 'color_temp': 240, - 'supported_features': 2}) - hass.states.async_set( - 'light.test_off', 'off', {'friendly_name': "Test light Off", - 'supported_features': 2}) - - properties = await reported_properties(hass, 'light.test_on') - properties.assert_equal('Alexa.ColorTemperatureController', - 'colorTemperatureInKelvin', 4166) - - properties = await reported_properties(hass, 'light.test_off') - properties.assert_equal('Alexa.ColorTemperatureController', - 'colorTemperatureInKelvin', 0) - - -async def test_report_fan_speed_state(hass): - """Test PercentageController reports fan speed correctly.""" - hass.states.async_set( - 'fan.off', 'off', {'friendly_name': "Off fan", - 'speed': "off", - 'supported_features': 1}) - hass.states.async_set( - 'fan.low_speed', 'on', {'friendly_name': "Low speed fan", - 'speed': "low", - 'supported_features': 1}) - hass.states.async_set( - 'fan.medium_speed', 'on', {'friendly_name': "Medium speed fan", - 'speed': "medium", - 'supported_features': 1}) - hass.states.async_set( - 'fan.high_speed', 'on', {'friendly_name': "High speed fan", - 'speed': "high", - 'supported_features': 1}) - - properties = await reported_properties(hass, 'fan.off') - properties.assert_equal('Alexa.PercentageController', 'percentage', 0) - - properties = await reported_properties(hass, 'fan.low_speed') - properties.assert_equal('Alexa.PercentageController', 'percentage', 33) - - properties = await reported_properties(hass, 'fan.medium_speed') - properties.assert_equal('Alexa.PercentageController', 'percentage', 66) - - properties = await reported_properties(hass, 'fan.high_speed') - properties.assert_equal('Alexa.PercentageController', 'percentage', 100) - - -async def test_report_cover_percentage_state(hass): - """Test PercentageController reports cover percentage correctly.""" - hass.states.async_set( - 'cover.fully_open', 'open', {'friendly_name': "Fully open cover", - 'current_position': 100, - 'supported_features': 15}) - hass.states.async_set( - 'cover.half_open', 'open', {'friendly_name': "Half open cover", - 'current_position': 50, - 'supported_features': 15}) - hass.states.async_set( - 'cover.closed', 'closed', {'friendly_name': "Closed cover", - 'current_position': 0, - 'supported_features': 15}) - - properties = await reported_properties(hass, 'cover.fully_open') - properties.assert_equal('Alexa.PercentageController', 'percentage', 100) - - properties = await reported_properties(hass, 'cover.half_open') - properties.assert_equal('Alexa.PercentageController', 'percentage', 50) - - properties = await reported_properties(hass, 'cover.closed') - properties.assert_equal('Alexa.PercentageController', 'percentage', 0) - - -async def reported_properties(hass, endpoint): - """Use ReportState to get properties and return them. - - The result is a _ReportedProperties instance, which has methods to make - assertions about the properties. - """ - request = get_new_request('Alexa', 'ReportState', endpoint) - msg = await smart_home.async_handle_message( - hass, DEFAULT_CONFIG, request) - await hass.async_block_till_done() - return _ReportedProperties(msg['context']['properties']) - - -class _ReportedProperties: - def __init__(self, properties): - self.properties = properties - - def assert_equal(self, namespace, name, value): - """Assert a property is equal to a given value.""" - for prop in self.properties: - if prop['namespace'] == namespace and prop['name'] == name: - assert prop['value'] == value - return prop - - assert False, 'property %s:%s not in %r' % ( - namespace, - name, - self.properties, - ) - - async def test_entity_config(hass): """Test that we can configure things via entity config.""" request = get_new_request('Alexa.Discovery', 'Discover') @@ -1611,21 +1155,17 @@ async def test_entity_config(hass): hass.states.async_set( 'light.test_1', 'on', {'friendly_name': "Test light 1"}) - config = smart_home.Config( - endpoint=None, - async_get_access_token=None, - should_expose=lambda entity_id: True, - entity_config={ - 'light.test_1': { - 'name': 'Config name', - 'display_categories': 'SWITCH', - 'description': 'Config description' - } + alexa_config = MockConfig(hass) + alexa_config.entity_config = { + 'light.test_1': { + 'name': 'Config name', + 'display_categories': 'SWITCH', + 'description': 'Config description' } - ) + } msg = await smart_home.async_handle_message( - hass, config, request) + hass, alexa_config, request) assert 'event' in msg msg = msg['event'] @@ -1644,95 +1184,6 @@ async def test_entity_config(hass): ) -async def test_unsupported_domain(hass): - """Discovery ignores entities of unknown domains.""" - request = get_new_request('Alexa.Discovery', 'Discover') - - hass.states.async_set( - 'woz.boop', 'on', {'friendly_name': "Boop Woz"}) - - msg = await smart_home.async_handle_message( - hass, DEFAULT_CONFIG, request) - - assert 'event' in msg - msg = msg['event'] - - assert not msg['payload']['endpoints'] - - -async def do_http_discovery(config, hass, hass_client): - """Submit a request to the Smart Home HTTP API.""" - await async_setup_component(hass, alexa.DOMAIN, config) - http_client = await hass_client() - - request = get_new_request('Alexa.Discovery', 'Discover') - response = await http_client.post( - smart_home.SMART_HOME_HTTP_ENDPOINT, - data=json.dumps(request), - headers={'content-type': 'application/json'}) - return response - - -async def test_http_api(hass, hass_client): - """With `smart_home:` HTTP API is exposed.""" - config = { - 'alexa': { - 'smart_home': None - } - } - - response = await do_http_discovery(config, hass, hass_client) - response_data = await response.json() - - # Here we're testing just the HTTP view glue -- details of discovery are - # covered in other tests. - assert response_data['event']['header']['name'] == 'Discover.Response' - - -async def test_http_api_disabled(hass, hass_client): - """Without `smart_home:`, the HTTP API is disabled.""" - config = { - 'alexa': {} - } - response = await do_http_discovery(config, hass, hass_client) - - assert response.status == 404 - - -@pytest.mark.parametrize( - "domain,payload,source_list,idx", [ - ('media_player', 'GAME CONSOLE', ['tv', 'game console'], 1), - ('media_player', 'SATELLITE TV', ['satellite-tv', 'game console'], 0), - ('media_player', 'SATELLITE TV', ['satellite_tv', 'game console'], 0), - ('media_player', 'BAD DEVICE', ['satellite_tv', 'game console'], None), - ] -) -async def test_api_select_input(hass, domain, payload, source_list, idx): - """Test api set input process.""" - hass.states.async_set( - 'media_player.test', 'off', { - 'friendly_name': "Test media player", - 'source': 'unknown', - 'source_list': source_list, - }) - - # test where no source matches - if idx is None: - await assert_request_fails( - 'Alexa.InputController', 'SelectInput', 'media_player#test', - 'media_player.select_source', - hass, - payload={'input': payload}) - return - - call, _ = await assert_request_calls_service( - 'Alexa.InputController', 'SelectInput', 'media_player#test', - 'media_player.select_source', - hass, - payload={'input': payload}) - assert call.data['source'] == source_list[idx] - - async def test_logging_request(hass, events): """Test that we log requests.""" context = Context() @@ -1834,104 +1285,3 @@ async def test_endpoint_bad_health(hass): properties = await reported_properties(hass, 'binary_sensor#test_contact') properties.assert_equal('Alexa.EndpointHealth', 'connectivity', {'value': 'UNREACHABLE'}) - - -async def test_report_state(hass, aioclient_mock): - """Test proactive state reports.""" - aioclient_mock.post(TEST_URL, json={'data': 'is irrelevant'}) - - hass.states.async_set( - 'binary_sensor.test_contact', - 'on', - { - 'friendly_name': "Test Contact Sensor", - 'device_class': 'door', - } - ) - - await smart_home.async_enable_proactive_mode(hass, DEFAULT_CONFIG) - - hass.states.async_set( - 'binary_sensor.test_contact', - 'off', - { - 'friendly_name': "Test Contact Sensor", - 'device_class': 'door', - } - ) - - # To trigger event listener - await hass.async_block_till_done() - - assert len(aioclient_mock.mock_calls) == 1 - call = aioclient_mock.mock_calls - - call_json = call[0][2] - assert call_json["event"]["payload"]["change"]["properties"][0][ - "value"] == "NOT_DETECTED" - assert call_json["event"]["endpoint"][ - "endpointId"] == "binary_sensor#test_contact" - - -async def run_auth_get_access_token(hass, aioclient_mock, expires_in, - client_id, client_secret, - accept_grant_code, refresh_token): - """Do auth and request a new token for tests.""" - aioclient_mock.post(TEST_TOKEN_URL, - json={'access_token': 'the_access_token', - 'refresh_token': refresh_token, - 'expires_in': expires_in}) - - auth = Auth(hass, client_id, client_secret) - await auth.async_do_auth(accept_grant_code) - await auth.async_get_access_token() - - -async def test_auth_get_access_token_expired(hass, aioclient_mock): - """Test the auth get access token function.""" - client_id = "client123" - client_secret = "shhhhh" - accept_grant_code = "abcdefg" - refresh_token = "refresher" - - await run_auth_get_access_token(hass, aioclient_mock, -5, - client_id, client_secret, - accept_grant_code, refresh_token) - - assert len(aioclient_mock.mock_calls) == 2 - calls = aioclient_mock.mock_calls - - auth_call_json = calls[0][2] - token_call_json = calls[1][2] - - assert auth_call_json["grant_type"] == "authorization_code" - assert auth_call_json["code"] == accept_grant_code - assert auth_call_json["client_id"] == client_id - assert auth_call_json["client_secret"] == client_secret - - assert token_call_json["grant_type"] == "refresh_token" - assert token_call_json["refresh_token"] == refresh_token - assert token_call_json["client_id"] == client_id - assert token_call_json["client_secret"] == client_secret - - -async def test_auth_get_access_token_not_expired(hass, aioclient_mock): - """Test the auth get access token function.""" - client_id = "client123" - client_secret = "shhhhh" - accept_grant_code = "abcdefg" - refresh_token = "refresher" - - await run_auth_get_access_token(hass, aioclient_mock, 555, - client_id, client_secret, - accept_grant_code, refresh_token) - - assert len(aioclient_mock.mock_calls) == 1 - call = aioclient_mock.mock_calls - - auth_call_json = call[0][2] - - assert auth_call_json["grant_type"] == "authorization_code" - assert auth_call_json["code"] == accept_grant_code - assert auth_call_json["client_id"] == client_id - assert auth_call_json["client_secret"] == client_secret diff --git a/tests/components/alexa/test_smart_home_http.py b/tests/components/alexa/test_smart_home_http.py new file mode 100644 index 00000000000000..fb410e4c4d6204 --- /dev/null +++ b/tests/components/alexa/test_smart_home_http.py @@ -0,0 +1,46 @@ +"""Test Smart Home HTTP endpoints.""" +import json + +from homeassistant.setup import async_setup_component +from homeassistant.components.alexa import DOMAIN, smart_home_http + +from . import get_new_request + + +async def do_http_discovery(config, hass, hass_client): + """Submit a request to the Smart Home HTTP API.""" + await async_setup_component(hass, DOMAIN, config) + http_client = await hass_client() + + request = get_new_request('Alexa.Discovery', 'Discover') + response = await http_client.post( + smart_home_http.SMART_HOME_HTTP_ENDPOINT, + data=json.dumps(request), + headers={'content-type': 'application/json'}) + return response + + +async def test_http_api(hass, hass_client): + """With `smart_home:` HTTP API is exposed.""" + config = { + 'alexa': { + 'smart_home': None + } + } + + response = await do_http_discovery(config, hass, hass_client) + response_data = await response.json() + + # Here we're testing just the HTTP view glue -- details of discovery are + # covered in other tests. + assert response_data['event']['header']['name'] == 'Discover.Response' + + +async def test_http_api_disabled(hass, hass_client): + """Without `smart_home:`, the HTTP API is disabled.""" + config = { + 'alexa': {} + } + response = await do_http_discovery(config, hass, hass_client) + + assert response.status == 404 diff --git a/tests/components/alexa/test_state_report.py b/tests/components/alexa/test_state_report.py new file mode 100644 index 00000000000000..f954aa825bc575 --- /dev/null +++ b/tests/components/alexa/test_state_report.py @@ -0,0 +1,96 @@ +"""Test report state.""" +from homeassistant.components.alexa import state_report +from . import TEST_URL, DEFAULT_CONFIG + + +async def test_report_state(hass, aioclient_mock): + """Test proactive state reports.""" + aioclient_mock.post(TEST_URL, json={'data': 'is irrelevant'}) + + hass.states.async_set( + 'binary_sensor.test_contact', + 'on', + { + 'friendly_name': "Test Contact Sensor", + 'device_class': 'door', + } + ) + + await state_report.async_enable_proactive_mode(hass, DEFAULT_CONFIG) + + hass.states.async_set( + 'binary_sensor.test_contact', + 'off', + { + 'friendly_name': "Test Contact Sensor", + 'device_class': 'door', + } + ) + + # To trigger event listener + await hass.async_block_till_done() + + assert len(aioclient_mock.mock_calls) == 1 + call = aioclient_mock.mock_calls + + call_json = call[0][2] + assert call_json["event"]["header"]["namespace"] == "Alexa" + assert call_json["event"]["header"]["name"] == "ChangeReport" + assert call_json["event"]["payload"]["change"]["properties"][0]["value"] \ + == "NOT_DETECTED" + assert call_json["event"]["endpoint"]["endpointId"] \ + == "binary_sensor#test_contact" + + +async def test_send_add_or_update_message(hass, aioclient_mock): + """Test sending an AddOrUpdateReport message.""" + aioclient_mock.post(TEST_URL, json={'data': 'is irrelevant'}) + + hass.states.async_set( + 'binary_sensor.test_contact', + 'on', + { + 'friendly_name': "Test Contact Sensor", + 'device_class': 'door', + } + ) + + await state_report.async_send_add_or_update_message( + hass, DEFAULT_CONFIG, ['binary_sensor.test_contact']) + + assert len(aioclient_mock.mock_calls) == 1 + call = aioclient_mock.mock_calls + + call_json = call[0][2] + assert call_json["event"]["header"]["namespace"] == "Alexa.Discovery" + assert call_json["event"]["header"]["name"] == "AddOrUpdateReport" + assert len(call_json["event"]["payload"]["endpoints"]) == 1 + assert call_json["event"]["payload"]["endpoints"][0]["endpointId"] \ + == "binary_sensor#test_contact" + + +async def test_send_delete_message(hass, aioclient_mock): + """Test sending an AddOrUpdateReport message.""" + aioclient_mock.post(TEST_URL, json={'data': 'is irrelevant'}) + + hass.states.async_set( + 'binary_sensor.test_contact', + 'on', + { + 'friendly_name': "Test Contact Sensor", + 'device_class': 'door', + } + ) + + await state_report.async_send_delete_message( + hass, DEFAULT_CONFIG, ['binary_sensor.test_contact']) + + assert len(aioclient_mock.mock_calls) == 1 + call = aioclient_mock.mock_calls + + call_json = call[0][2] + assert call_json["event"]["header"]["namespace"] == "Alexa.Discovery" + assert call_json["event"]["header"]["name"] == "DeleteReport" + assert len(call_json["event"]["payload"]["endpoints"]) == 1 + assert call_json["event"]["payload"]["endpoints"][0]["endpointId"] \ + == "binary_sensor#test_contact" diff --git a/tests/components/apns/test_notify.py b/tests/components/apns/test_notify.py index 7303f4872e3a04..3f8d00f8f50a25 100644 --- a/tests/components/apns/test_notify.py +++ b/tests/components/apns/test_notify.py @@ -400,7 +400,7 @@ def test_write_device(): device = apns.ApnsDevice('123', 'name', 'track_id', True) apns._write_device(out, device) - data = yaml.load(out.getvalue()) + data = yaml.safe_load(out.getvalue()) assert data == { 123: { 'name': 'name', diff --git a/tests/components/aprs/__init__.py b/tests/components/aprs/__init__.py new file mode 100644 index 00000000000000..c3e9dddb37fc5a --- /dev/null +++ b/tests/components/aprs/__init__.py @@ -0,0 +1 @@ +"""Tests for the APRS component.""" diff --git a/tests/components/aprs/test_device_tracker.py b/tests/components/aprs/test_device_tracker.py new file mode 100644 index 00000000000000..a90f11a01bc8b7 --- /dev/null +++ b/tests/components/aprs/test_device_tracker.py @@ -0,0 +1,351 @@ +"""Test APRS device tracker.""" +from unittest.mock import Mock, patch + +import aprslib + +import homeassistant.components.aprs.device_tracker as device_tracker +from homeassistant.const import EVENT_HOMEASSISTANT_START + +from tests.common import get_test_home_assistant + +DEFAULT_PORT = 14580 + +TEST_CALLSIGN = 'testcall' +TEST_COORDS_NULL_ISLAND = (0, 0) +TEST_FILTER = 'testfilter' +TEST_HOST = 'testhost' +TEST_PASSWORD = 'testpass' + + +def test_make_filter(): + """Test filter.""" + callsigns = [ + 'CALLSIGN1', + 'callsign2' + ] + res = device_tracker.make_filter(callsigns) + assert res == "b/CALLSIGN1 b/CALLSIGN2" + + +def test_gps_accuracy_0(): + """Test GPS accuracy level 0.""" + acc = device_tracker.gps_accuracy(TEST_COORDS_NULL_ISLAND, 0) + assert acc == 0 + + +def test_gps_accuracy_1(): + """Test GPS accuracy level 1.""" + acc = device_tracker.gps_accuracy(TEST_COORDS_NULL_ISLAND, 1) + assert acc == 186 + + +def test_gps_accuracy_2(): + """Test GPS accuracy level 2.""" + acc = device_tracker.gps_accuracy(TEST_COORDS_NULL_ISLAND, 2) + assert acc == 1855 + + +def test_gps_accuracy_3(): + """Test GPS accuracy level 3.""" + acc = device_tracker.gps_accuracy(TEST_COORDS_NULL_ISLAND, 3) + assert acc == 18553 + + +def test_gps_accuracy_4(): + """Test GPS accuracy level 4.""" + acc = device_tracker.gps_accuracy(TEST_COORDS_NULL_ISLAND, 4) + assert acc == 111319 + + +def test_gps_accuracy_invalid_int(): + """Test GPS accuracy with invalid input.""" + level = 5 + + try: + device_tracker.gps_accuracy(TEST_COORDS_NULL_ISLAND, level) + assert False, "No exception." + except ValueError: + pass + + +def test_gps_accuracy_invalid_string(): + """Test GPS accuracy with invalid input.""" + level = "not an int" + + try: + device_tracker.gps_accuracy(TEST_COORDS_NULL_ISLAND, level) + assert False, "No exception." + except ValueError: + pass + + +def test_gps_accuracy_invalid_float(): + """Test GPS accuracy with invalid input.""" + level = 1.2 + + try: + device_tracker.gps_accuracy(TEST_COORDS_NULL_ISLAND, level) + assert False, "No exception." + except ValueError: + pass + + +def test_aprs_listener(): + """Test listener thread.""" + with patch('aprslib.IS') as mock_ais: + callsign = TEST_CALLSIGN + password = TEST_PASSWORD + host = TEST_HOST + server_filter = TEST_FILTER + port = DEFAULT_PORT + see = Mock() + + listener = device_tracker.AprsListenerThread( + callsign, password, host, server_filter, see) + listener.run() + + assert listener.callsign == callsign + assert listener.host == host + assert listener.server_filter == server_filter + assert listener.see == see + assert listener.start_event.is_set() + assert listener.start_success + assert listener.start_message == \ + "Connected to testhost with callsign testcall." + mock_ais.assert_called_with( + callsign, passwd=password, host=host, port=port) + + +def test_aprs_listener_start_fail(): + """Test listener thread start failure.""" + with patch('aprslib.IS.connect', + side_effect=aprslib.ConnectionError("Unable to connect.")): + callsign = TEST_CALLSIGN + password = TEST_PASSWORD + host = TEST_HOST + server_filter = TEST_FILTER + see = Mock() + + listener = device_tracker.AprsListenerThread( + callsign, password, host, server_filter, see) + listener.run() + + assert listener.callsign == callsign + assert listener.host == host + assert listener.server_filter == server_filter + assert listener.see == see + assert listener.start_event.is_set() + assert not listener.start_success + assert listener.start_message == "Unable to connect." + + +def test_aprs_listener_stop(): + """Test listener thread stop.""" + with patch('aprslib.IS'): + callsign = TEST_CALLSIGN + password = TEST_PASSWORD + host = TEST_HOST + server_filter = TEST_FILTER + see = Mock() + + listener = device_tracker.AprsListenerThread( + callsign, password, host, server_filter, see) + listener.ais.close = Mock() + listener.run() + listener.stop() + + assert listener.callsign == callsign + assert listener.host == host + assert listener.server_filter == server_filter + assert listener.see == see + assert listener.start_event.is_set() + assert listener.start_message == \ + "Connected to testhost with callsign testcall." + assert listener.start_success + listener.ais.close.assert_called_with() + + +def test_aprs_listener_rx_msg(): + """Test rx_msg.""" + with patch('aprslib.IS'): + callsign = TEST_CALLSIGN + password = TEST_PASSWORD + host = TEST_HOST + server_filter = TEST_FILTER + see = Mock() + + sample_msg = { + device_tracker.ATTR_FORMAT: "uncompressed", + device_tracker.ATTR_FROM: "ZZ0FOOBAR-1", + device_tracker.ATTR_LATITUDE: 0.0, + device_tracker.ATTR_LONGITUDE: 0.0, + device_tracker.ATTR_ALTITUDE: 0 + } + + listener = device_tracker.AprsListenerThread( + callsign, password, host, server_filter, see) + listener.run() + listener.rx_msg(sample_msg) + + assert listener.callsign == callsign + assert listener.host == host + assert listener.server_filter == server_filter + assert listener.see == see + assert listener.start_event.is_set() + assert listener.start_success + assert listener.start_message == \ + "Connected to testhost with callsign testcall." + see.assert_called_with( + dev_id=device_tracker.slugify("ZZ0FOOBAR-1"), + gps=(0.0, 0.0), + attributes={"altitude": 0}) + + +def test_aprs_listener_rx_msg_ambiguity(): + """Test rx_msg with posambiguity.""" + with patch('aprslib.IS'): + callsign = TEST_CALLSIGN + password = TEST_PASSWORD + host = TEST_HOST + server_filter = TEST_FILTER + see = Mock() + + sample_msg = { + device_tracker.ATTR_FORMAT: "uncompressed", + device_tracker.ATTR_FROM: "ZZ0FOOBAR-1", + device_tracker.ATTR_LATITUDE: 0.0, + device_tracker.ATTR_LONGITUDE: 0.0, + device_tracker.ATTR_POS_AMBIGUITY: 1 + } + + listener = device_tracker.AprsListenerThread( + callsign, password, host, server_filter, see) + listener.run() + listener.rx_msg(sample_msg) + + assert listener.callsign == callsign + assert listener.host == host + assert listener.server_filter == server_filter + assert listener.see == see + assert listener.start_event.is_set() + assert listener.start_success + assert listener.start_message == \ + "Connected to testhost with callsign testcall." + see.assert_called_with( + dev_id=device_tracker.slugify("ZZ0FOOBAR-1"), + gps=(0.0, 0.0), + attributes={device_tracker.ATTR_GPS_ACCURACY: 186}) + + +def test_aprs_listener_rx_msg_ambiguity_invalid(): + """Test rx_msg with invalid posambiguity.""" + with patch('aprslib.IS'): + callsign = TEST_CALLSIGN + password = TEST_PASSWORD + host = TEST_HOST + server_filter = TEST_FILTER + see = Mock() + + sample_msg = { + device_tracker.ATTR_FORMAT: "uncompressed", + device_tracker.ATTR_FROM: "ZZ0FOOBAR-1", + device_tracker.ATTR_LATITUDE: 0.0, + device_tracker.ATTR_LONGITUDE: 0.0, + device_tracker.ATTR_POS_AMBIGUITY: 5 + } + + listener = device_tracker.AprsListenerThread( + callsign, password, host, server_filter, see) + listener.run() + listener.rx_msg(sample_msg) + + assert listener.callsign == callsign + assert listener.host == host + assert listener.server_filter == server_filter + assert listener.see == see + assert listener.start_event.is_set() + assert listener.start_success + assert listener.start_message == \ + "Connected to testhost with callsign testcall." + see.assert_called_with( + dev_id=device_tracker.slugify("ZZ0FOOBAR-1"), + gps=(0.0, 0.0), + attributes={}) + + +def test_aprs_listener_rx_msg_no_position(): + """Test rx_msg with non-position report.""" + with patch('aprslib.IS'): + callsign = TEST_CALLSIGN + password = TEST_PASSWORD + host = TEST_HOST + server_filter = TEST_FILTER + see = Mock() + + sample_msg = { + device_tracker.ATTR_FORMAT: "invalid" + } + + listener = device_tracker.AprsListenerThread( + callsign, password, host, server_filter, see) + listener.run() + listener.rx_msg(sample_msg) + + assert listener.callsign == callsign + assert listener.host == host + assert listener.server_filter == server_filter + assert listener.see == see + assert listener.start_event.is_set() + assert listener.start_success + assert listener.start_message == \ + "Connected to testhost with callsign testcall." + see.assert_not_called() + + +def test_setup_scanner(): + """Test setup_scanner.""" + with patch('homeassistant.components.' + 'aprs.device_tracker.AprsListenerThread') as listener: + hass = get_test_home_assistant() + hass.start() + + config = { + 'username': TEST_CALLSIGN, + 'password': TEST_PASSWORD, + 'host': TEST_HOST, + 'callsigns': [ + 'XX0FOO*', + 'YY0BAR-1'] + } + + see = Mock() + res = device_tracker.setup_scanner(hass, config, see) + hass.bus.fire(EVENT_HOMEASSISTANT_START) + hass.stop() + + assert res + listener.assert_called_with( + TEST_CALLSIGN, TEST_PASSWORD, TEST_HOST, + 'b/XX0FOO* b/YY0BAR-1', see) + + +def test_setup_scanner_timeout(): + """Test setup_scanner failure from timeout.""" + hass = get_test_home_assistant() + hass.start() + + config = { + 'username': TEST_CALLSIGN, + 'password': TEST_PASSWORD, + 'host': "localhost", + 'timeout': 0.01, + 'callsigns': [ + 'XX0FOO*', + 'YY0BAR-1'] + } + + see = Mock() + try: + assert not device_tracker.setup_scanner(hass, config, see) + finally: + hass.stop() diff --git a/tests/components/automation/test_homeassistant.py b/tests/components/automation/test_homeassistant.py index 742a2aa857ca24..b8802501d5df78 100644 --- a/tests/components/automation/test_homeassistant.py +++ b/tests/components/automation/test_homeassistant.py @@ -1,5 +1,4 @@ """The tests for the Event automation.""" -import asyncio from unittest.mock import patch, Mock from homeassistant.core import CoreState @@ -9,8 +8,7 @@ from tests.common import async_mock_service, mock_coro -@asyncio.coroutine -def test_if_fires_on_hass_start(hass): +async def test_if_fires_on_hass_start(hass): """Test the firing when HASS starts.""" calls = async_mock_service(hass, 'test', 'automation') hass.state = CoreState.not_running @@ -27,31 +25,29 @@ def test_if_fires_on_hass_start(hass): } } - res = yield from async_setup_component(hass, automation.DOMAIN, config) - assert res + assert await async_setup_component(hass, automation.DOMAIN, config) assert automation.is_on(hass, 'automation.hello') assert len(calls) == 0 - yield from hass.async_start() + await hass.async_start() assert automation.is_on(hass, 'automation.hello') assert len(calls) == 1 with patch('homeassistant.config.async_hass_config_yaml', Mock(return_value=mock_coro(config))): - yield from hass.services.async_call( + await hass.services.async_call( automation.DOMAIN, automation.SERVICE_RELOAD, blocking=True) assert automation.is_on(hass, 'automation.hello') assert len(calls) == 1 -@asyncio.coroutine -def test_if_fires_on_hass_shutdown(hass): +async def test_if_fires_on_hass_shutdown(hass): """Test the firing when HASS starts.""" calls = async_mock_service(hass, 'test', 'automation') hass.state = CoreState.not_running - res = yield from async_setup_component(hass, automation.DOMAIN, { + assert await async_setup_component(hass, automation.DOMAIN, { automation.DOMAIN: { 'alias': 'hello', 'trigger': { @@ -63,22 +59,13 @@ def test_if_fires_on_hass_shutdown(hass): } } }) - assert res assert automation.is_on(hass, 'automation.hello') assert len(calls) == 0 - yield from hass.async_start() + await hass.async_start() assert automation.is_on(hass, 'automation.hello') assert len(calls) == 0 with patch.object(hass.loop, 'stop'): - yield from hass.async_stop() + await hass.async_stop() assert len(calls) == 1 - - # with patch('homeassistant.config.async_hass_config_yaml', - # Mock(return_value=mock_coro(config))): - # yield from hass.services.async_call( - # automation.DOMAIN, automation.SERVICE_RELOAD, blocking=True) - - # assert automation.is_on(hass, 'automation.hello') - # assert len(calls) == 1 diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 81d7a8b257f236..7fa658b0064913 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -1,5 +1,4 @@ """The tests for the automation component.""" -import asyncio from datetime import timedelta from unittest.mock import patch, Mock @@ -616,8 +615,7 @@ async def test_reload_config_handles_load_fails(hass, calls): assert len(calls) == 2 -@asyncio.coroutine -def test_automation_restore_state(hass): +async def test_automation_restore_state(hass): """Ensure states are restored on startup.""" time = dt_util.utcnow() @@ -642,39 +640,39 @@ def test_automation_restore_state(hass): 'action': {'service': 'test.automation'} }]} - assert (yield from async_setup_component(hass, automation.DOMAIN, config)) + assert await async_setup_component(hass, automation.DOMAIN, config) state = hass.states.get('automation.hello') assert state assert state.state == STATE_ON + assert state.attributes['last_triggered'] is None state = hass.states.get('automation.bye') assert state assert state.state == STATE_OFF - assert state.attributes.get('last_triggered') == time + assert state.attributes['last_triggered'] == time calls = async_mock_service(hass, 'test', 'automation') assert automation.is_on(hass, 'automation.bye') is False hass.bus.async_fire('test_event_bye') - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(calls) == 0 assert automation.is_on(hass, 'automation.hello') hass.bus.async_fire('test_event_hello') - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(calls) == 1 -@asyncio.coroutine -def test_initial_value_off(hass): +async def test_initial_value_off(hass): """Test initial value off.""" calls = async_mock_service(hass, 'test', 'automation') - res = yield from async_setup_component(hass, automation.DOMAIN, { + assert await async_setup_component(hass, automation.DOMAIN, { automation.DOMAIN: { 'alias': 'hello', 'initial_state': 'off', @@ -688,11 +686,10 @@ def test_initial_value_off(hass): } } }) - assert res assert not automation.is_on(hass, 'automation.hello') hass.bus.async_fire('test_event') - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(calls) == 0 @@ -753,15 +750,14 @@ async def test_initial_value_off_but_restore_on(hass): assert len(calls) == 0 -@asyncio.coroutine -def test_initial_value_on_but_restore_off(hass): +async def test_initial_value_on_but_restore_off(hass): """Test initial value on and restored state is turned off.""" calls = async_mock_service(hass, 'test', 'automation') mock_restore_cache(hass, ( State('automation.hello', STATE_OFF), )) - res = yield from async_setup_component(hass, automation.DOMAIN, { + assert await async_setup_component(hass, automation.DOMAIN, { automation.DOMAIN: { 'alias': 'hello', 'initial_state': 'on', @@ -775,23 +771,21 @@ def test_initial_value_on_but_restore_off(hass): } } }) - assert res assert automation.is_on(hass, 'automation.hello') hass.bus.async_fire('test_event') - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(calls) == 1 -@asyncio.coroutine -def test_no_initial_value_and_restore_off(hass): +async def test_no_initial_value_and_restore_off(hass): """Test initial value off and restored state is turned on.""" calls = async_mock_service(hass, 'test', 'automation') mock_restore_cache(hass, ( State('automation.hello', STATE_OFF), )) - res = yield from async_setup_component(hass, automation.DOMAIN, { + assert await async_setup_component(hass, automation.DOMAIN, { automation.DOMAIN: { 'alias': 'hello', 'trigger': { @@ -804,20 +798,18 @@ def test_no_initial_value_and_restore_off(hass): } } }) - assert res assert not automation.is_on(hass, 'automation.hello') hass.bus.async_fire('test_event') - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(calls) == 0 -@asyncio.coroutine -def test_automation_is_on_if_no_initial_state_or_restore(hass): +async def test_automation_is_on_if_no_initial_state_or_restore(hass): """Test initial value is on when no initial state or restored state.""" calls = async_mock_service(hass, 'test', 'automation') - res = yield from async_setup_component(hass, automation.DOMAIN, { + assert await async_setup_component(hass, automation.DOMAIN, { automation.DOMAIN: { 'alias': 'hello', 'trigger': { @@ -830,21 +822,19 @@ def test_automation_is_on_if_no_initial_state_or_restore(hass): } } }) - assert res assert automation.is_on(hass, 'automation.hello') hass.bus.async_fire('test_event') - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(calls) == 1 -@asyncio.coroutine -def test_automation_not_trigger_on_bootstrap(hass): +async def test_automation_not_trigger_on_bootstrap(hass): """Test if automation is not trigger on bootstrap.""" hass.state = CoreState.not_running calls = async_mock_service(hass, 'test', 'automation') - res = yield from async_setup_component(hass, automation.DOMAIN, { + assert await async_setup_component(hass, automation.DOMAIN, { automation.DOMAIN: { 'alias': 'hello', 'trigger': { @@ -857,19 +847,18 @@ def test_automation_not_trigger_on_bootstrap(hass): } } }) - assert res assert automation.is_on(hass, 'automation.hello') hass.bus.async_fire('test_event') - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(calls) == 0 hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert automation.is_on(hass, 'automation.hello') hass.bus.async_fire('test_event') - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(calls) == 1 assert ['hello.world'] == calls[0].data.get(ATTR_ENTITY_ID) @@ -894,3 +883,57 @@ async def test_automation_with_error_in_script(hass, caplog): hass.bus.async_fire('test_event') await hass.async_block_till_done() assert 'Service not found' in caplog.text + + +async def test_automation_restore_last_triggered_with_initial_state(hass): + """Ensure last_triggered is restored, even when initial state is set.""" + time = dt_util.utcnow() + + mock_restore_cache(hass, ( + State('automation.hello', STATE_ON), + State('automation.bye', STATE_ON, {'last_triggered': time}), + State('automation.solong', STATE_OFF, {'last_triggered': time}), + )) + + config = {automation.DOMAIN: [{ + 'alias': 'hello', + 'initial_state': 'off', + 'trigger': { + 'platform': 'event', + 'event_type': 'test_event', + }, + 'action': {'service': 'test.automation'} + }, { + 'alias': 'bye', + 'initial_state': 'off', + 'trigger': { + 'platform': 'event', + 'event_type': 'test_event', + }, + 'action': {'service': 'test.automation'} + }, { + 'alias': 'solong', + 'initial_state': 'on', + 'trigger': { + 'platform': 'event', + 'event_type': 'test_event', + }, + 'action': {'service': 'test.automation'} + }]} + + await async_setup_component(hass, automation.DOMAIN, config) + + state = hass.states.get('automation.hello') + assert state + assert state.state == STATE_OFF + assert state.attributes['last_triggered'] is None + + state = hass.states.get('automation.bye') + assert state + assert state.state == STATE_OFF + assert state.attributes['last_triggered'] == time + + state = hass.states.get('automation.solong') + assert state + assert state.state == STATE_ON + assert state.attributes['last_triggered'] == time diff --git a/tests/components/automation/test_template.py b/tests/components/automation/test_template.py index 25f32ac193942f..815c5e440b4a4c 100644 --- a/tests/components/automation/test_template.py +++ b/tests/components/automation/test_template.py @@ -1,11 +1,15 @@ """The tests for the Template automation.""" +from datetime import timedelta + import pytest from homeassistant.core import Context from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util import homeassistant.components.automation as automation -from tests.common import (assert_setup_component, mock_component) +from tests.common import ( + async_fire_time_changed, assert_setup_component, mock_component) from tests.components.automation import common from tests.common import async_mock_service @@ -434,3 +438,90 @@ async def test_wait_template_with_trigger(hass, calls): assert 1 == len(calls) assert 'template - test.entity - hello - world' == \ calls[0].data['some'] + + +async def test_if_fires_on_change_with_for(hass, calls): + """Test for firing on change with for.""" + assert await async_setup_component(hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'template', + 'value_template': "{{ is_state('test.entity', 'world') }}", + 'for': { + 'seconds': 5 + }, + }, + 'action': { + 'service': 'test.automation' + } + } + }) + + hass.states.async_set('test.entity', 'world') + await hass.async_block_till_done() + assert 0 == len(calls) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) + await hass.async_block_till_done() + assert 1 == len(calls) + + +async def test_if_not_fires_on_change_with_for(hass, calls): + """Test for firing on change with for.""" + assert await async_setup_component(hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'template', + 'value_template': "{{ is_state('test.entity', 'world') }}", + 'for': { + 'seconds': 5 + }, + }, + 'action': { + 'service': 'test.automation' + } + } + }) + + hass.states.async_set('test.entity', 'world') + await hass.async_block_till_done() + assert 0 == len(calls) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=4)) + await hass.async_block_till_done() + assert 0 == len(calls) + hass.states.async_set('test.entity', 'hello') + await hass.async_block_till_done() + assert 0 == len(calls) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=6)) + await hass.async_block_till_done() + assert 0 == len(calls) + + +async def test_if_not_fires_when_turned_off_with_for(hass, calls): + """Test for firing on change with for.""" + assert await async_setup_component(hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'template', + 'value_template': "{{ is_state('test.entity', 'world') }}", + 'for': { + 'seconds': 5 + }, + }, + 'action': { + 'service': 'test.automation' + } + } + }) + + hass.states.async_set('test.entity', 'world') + await hass.async_block_till_done() + assert 0 == len(calls) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=4)) + await hass.async_block_till_done() + assert 0 == len(calls) + await common.async_turn_off(hass) + await hass.async_block_till_done() + assert 0 == len(calls) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=6)) + await hass.async_block_till_done() + assert 0 == len(calls) diff --git a/tests/components/awair/test_sensor.py b/tests/components/awair/test_sensor.py index d251e8fdce8640..d5bb8236a1e1e0 100644 --- a/tests/components/awair/test_sensor.py +++ b/tests/components/awair/test_sensor.py @@ -178,7 +178,7 @@ async def test_awair_humid(hass): await setup_awair(hass) sensor = hass.states.get("sensor.awair_humidity") - assert sensor.state == "32.73" + assert sensor.state == "32.7" assert sensor.attributes["device_class"] == DEVICE_CLASS_HUMIDITY assert sensor.attributes["unit_of_measurement"] == "%" @@ -291,7 +291,7 @@ async def test_async_update(hass): assert score_sensor.state == "79" assert hass.states.get("sensor.awair_temperature").state == "23.4" - assert hass.states.get("sensor.awair_humidity").state == "33.73" + assert hass.states.get("sensor.awair_humidity").state == "33.7" assert hass.states.get("sensor.awair_co2").state == "613" assert hass.states.get("sensor.awair_voc").state == "1013" assert hass.states.get("sensor.awair_pm2_5").state == "7.2" diff --git a/tests/components/buienradar/test_camera.py b/tests/components/buienradar/test_camera.py new file mode 100644 index 00000000000000..a05cc9413e5fe1 --- /dev/null +++ b/tests/components/buienradar/test_camera.py @@ -0,0 +1,202 @@ +"""The tests for generic camera component.""" +import asyncio +from aiohttp.client_exceptions import ClientResponseError + +from homeassistant.util import dt as dt_util + +from homeassistant.setup import async_setup_component + +# An infinitesimally small time-delta. +EPSILON_DELTA = 0.0000000001 + + +def radar_map_url(dim: int = 512) -> str: + """Build map url, defaulting to 512 wide (as in component).""" + return ("https://api.buienradar.nl/" + "image/1.0/RadarMapNL?w={dim}&h={dim}").format(dim=dim) + + +async def test_fetching_url_and_caching(aioclient_mock, hass, hass_client): + """Test that it fetches the given url.""" + aioclient_mock.get(radar_map_url(), text='hello world') + + await async_setup_component(hass, 'camera', { + 'camera': { + 'name': 'config_test', + 'platform': 'buienradar', + }}) + + client = await hass_client() + + resp = await client.get('/api/camera_proxy/camera.config_test') + + assert resp.status == 200 + assert aioclient_mock.call_count == 1 + body = await resp.text() + assert body == 'hello world' + + # default delta is 600s -> should be the same when calling immediately + # afterwards. + + resp = await client.get('/api/camera_proxy/camera.config_test') + assert aioclient_mock.call_count == 1 + + +async def test_expire_delta(aioclient_mock, hass, hass_client): + """Test that the cache expires after delta.""" + aioclient_mock.get(radar_map_url(), text='hello world') + + await async_setup_component(hass, 'camera', { + 'camera': { + 'name': 'config_test', + 'platform': 'buienradar', + 'delta': EPSILON_DELTA, + }}) + + client = await hass_client() + + resp = await client.get('/api/camera_proxy/camera.config_test') + + assert resp.status == 200 + assert aioclient_mock.call_count == 1 + body = await resp.text() + assert body == 'hello world' + + await asyncio.sleep(EPSILON_DELTA) + # tiny delta has passed -> should immediately call again + resp = await client.get('/api/camera_proxy/camera.config_test') + assert aioclient_mock.call_count == 2 + + +async def test_only_one_fetch_at_a_time(aioclient_mock, hass, hass_client): + """Test that it fetches with only one request at the same time.""" + aioclient_mock.get(radar_map_url(), text='hello world') + + await async_setup_component(hass, 'camera', { + 'camera': { + 'name': 'config_test', + 'platform': 'buienradar', + }}) + + client = await hass_client() + + resp_1 = client.get('/api/camera_proxy/camera.config_test') + resp_2 = client.get('/api/camera_proxy/camera.config_test') + + resp = await resp_1 + resp_2 = await resp_2 + + assert (await resp.text()) == (await resp_2.text()) + + assert aioclient_mock.call_count == 1 + + +async def test_dimension(aioclient_mock, hass, hass_client): + """Test that it actually adheres to the dimension.""" + aioclient_mock.get(radar_map_url(700), text='hello world') + + await async_setup_component(hass, 'camera', { + 'camera': { + 'name': 'config_test', + 'platform': 'buienradar', + 'dimension': 700, + }}) + + client = await hass_client() + + await client.get('/api/camera_proxy/camera.config_test') + + assert aioclient_mock.call_count == 1 + + +async def test_failure_response_not_cached(aioclient_mock, hass, hass_client): + """Test that it does not cache a failure response.""" + aioclient_mock.get(radar_map_url(), text='hello world', status=401) + + await async_setup_component(hass, 'camera', { + 'camera': { + 'name': 'config_test', + 'platform': 'buienradar', + }}) + + client = await hass_client() + + await client.get('/api/camera_proxy/camera.config_test') + await client.get('/api/camera_proxy/camera.config_test') + + assert aioclient_mock.call_count == 2 + + +async def test_last_modified_updates(aioclient_mock, hass, hass_client): + """Test that it does respect HTTP not modified.""" + # Build Last-Modified header value + now = dt_util.utcnow() + last_modified = now.strftime("%a, %d %m %Y %H:%M:%S GMT") + + aioclient_mock.get(radar_map_url(), text='hello world', status=200, + headers={ + 'Last-Modified': last_modified, + }) + + await async_setup_component(hass, 'camera', { + 'camera': { + 'name': 'config_test', + 'platform': 'buienradar', + 'delta': EPSILON_DELTA, + }}) + + client = await hass_client() + + resp_1 = await client.get('/api/camera_proxy/camera.config_test') + # It is not possible to check if header was sent. + assert aioclient_mock.call_count == 1 + + await asyncio.sleep(EPSILON_DELTA) + + # Content has expired, change response to a 304 NOT MODIFIED, which has no + # text, i.e. old value should be kept + aioclient_mock.clear_requests() + # mock call count is now reset as well: + assert aioclient_mock.call_count == 0 + + aioclient_mock.get(radar_map_url(), text=None, status=304) + + resp_2 = await client.get('/api/camera_proxy/camera.config_test') + assert aioclient_mock.call_count == 1 + + assert (await resp_1.read()) == (await resp_2.read()) + + +async def test_retries_after_error(aioclient_mock, hass, hass_client): + """Test that it does retry after an error instead of caching.""" + await async_setup_component(hass, 'camera', { + 'camera': { + 'name': 'config_test', + 'platform': 'buienradar', + }}) + + client = await hass_client() + + aioclient_mock.get(radar_map_url(), text=None, status=500) + + # A 404 should not return data and throw: + try: + await client.get('/api/camera_proxy/camera.config_test') + except ClientResponseError: + pass + + assert aioclient_mock.call_count == 1 + + # Change the response to a 200 + aioclient_mock.clear_requests() + aioclient_mock.get(radar_map_url(), text="DEADBEEF") + + assert aioclient_mock.call_count == 0 + + # http error should not be cached, immediate retry. + resp_2 = await client.get('/api/camera_proxy/camera.config_test') + assert aioclient_mock.call_count == 1 + + # Binary text can not be added as body to `aioclient_mock.get(text=...)`, + # while `resp.read()` returns bytes, encode the value. + assert (await resp_2.read()) == b"DEADBEEF" diff --git a/tests/components/cloud/__init__.py b/tests/components/cloud/__init__.py index 08ab5324b970e1..3f2b8f034cd07b 100644 --- a/tests/components/cloud/__init__.py +++ b/tests/components/cloud/__init__.py @@ -1,24 +1,22 @@ """Tests for the cloud component.""" from unittest.mock import patch + from homeassistant.setup import async_setup_component from homeassistant.components import cloud from homeassistant.components.cloud import const -from jose import jwt - from tests.common import mock_coro -def mock_cloud(hass, config={}): +async def mock_cloud(hass, config=None): """Mock cloud.""" - with patch('hass_nabucasa.Cloud.start', return_value=mock_coro()): - assert hass.loop.run_until_complete(async_setup_component( - hass, cloud.DOMAIN, { - 'cloud': config - })) - - hass.data[cloud.DOMAIN]._decode_claims = \ - lambda token: jwt.get_unverified_claims(token) + assert await async_setup_component( + hass, cloud.DOMAIN, { + 'cloud': config or {} + }) + cloud_inst = hass.data['cloud'] + with patch('hass_nabucasa.Cloud.run_executor', return_value=mock_coro()): + await cloud_inst.start() def mock_cloud_prefs(hass, prefs={}): diff --git a/tests/components/cloud/conftest.py b/tests/components/cloud/conftest.py index 163754dd3e1689..87ef6809fddb15 100644 --- a/tests/components/cloud/conftest.py +++ b/tests/components/cloud/conftest.py @@ -3,6 +3,8 @@ from unittest.mock import patch +from homeassistant.components.cloud import prefs + from . import mock_cloud, mock_cloud_prefs @@ -16,5 +18,13 @@ def mock_user_data(): @pytest.fixture def mock_cloud_fixture(hass): """Fixture for cloud component.""" - mock_cloud(hass) + hass.loop.run_until_complete(mock_cloud(hass)) return mock_cloud_prefs(hass) + + +@pytest.fixture +async def cloud_prefs(hass): + """Fixture for cloud preferences.""" + cloud_prefs = prefs.CloudPreferences(hass) + await cloud_prefs.async_initialize() + return cloud_prefs diff --git a/tests/components/cloud/test_client.py b/tests/components/cloud/test_client.py index fa1d8cf8b9b5e7..fa42bda32db3d6 100644 --- a/tests/components/cloud/test_client.py +++ b/tests/components/cloud/test_client.py @@ -1,4 +1,5 @@ """Test the cloud.iot module.""" +import contextlib from unittest.mock import patch, MagicMock from aiohttp import web @@ -7,17 +8,20 @@ from homeassistant.core import State from homeassistant.setup import async_setup_component -from homeassistant.components.cloud import DOMAIN +from homeassistant.components.cloud import ( + DOMAIN, ALEXA_SCHEMA, alexa_config) from homeassistant.components.cloud.const import ( PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE) +from homeassistant.util.dt import utcnow +from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED from tests.components.alexa import test_smart_home as test_alexa -from tests.common import mock_coro +from tests.common import mock_coro, async_fire_time_changed -from . import mock_cloud_prefs +from . import mock_cloud_prefs, mock_cloud @pytest.fixture -def mock_cloud(): +def mock_cloud_inst(): """Mock cloud class.""" return MagicMock(subscription_expired=False) @@ -25,10 +29,7 @@ def mock_cloud(): @pytest.fixture async def mock_cloud_setup(hass): """Set up the cloud.""" - with patch('hass_nabucasa.Cloud.start', return_value=mock_coro()): - assert await async_setup_component(hass, 'cloud', { - 'cloud': {} - }) + await mock_cloud(hass) @pytest.fixture @@ -48,24 +49,20 @@ async def test_handler_alexa(hass): hass.states.async_set( 'switch.test2', 'on', {'friendly_name': "Test switch 2"}) - with patch('hass_nabucasa.Cloud.start', return_value=mock_coro()): - setup = await async_setup_component(hass, 'cloud', { - 'cloud': { - 'alexa': { - 'filter': { - 'exclude_entities': 'switch.test2' - }, - 'entity_config': { - 'switch.test': { - 'name': 'Config name', - 'description': 'Config description', - 'display_categories': 'LIGHT' - } - } + await mock_cloud(hass, { + 'alexa': { + 'filter': { + 'exclude_entities': 'switch.test2' + }, + 'entity_config': { + 'switch.test': { + 'name': 'Config name', + 'description': 'Config description', + 'display_categories': 'LIGHT' } } - }) - assert setup + } + }) mock_cloud_prefs(hass) cloud = hass.data['cloud'] @@ -106,24 +103,20 @@ async def test_handler_google_actions(hass): hass.states.async_set( 'group.all_locks', 'on', {'friendly_name': "Evil locks"}) - with patch('hass_nabucasa.Cloud.start', return_value=mock_coro()): - setup = await async_setup_component(hass, 'cloud', { - 'cloud': { - 'google_actions': { - 'filter': { - 'exclude_entities': 'switch.test2' - }, - 'entity_config': { - 'switch.test': { - 'name': 'Config name', - 'aliases': 'Config alias', - 'room': 'living room' - } - } + await mock_cloud(hass, { + 'google_actions': { + 'filter': { + 'exclude_entities': 'switch.test2' + }, + 'entity_config': { + 'switch.test': { + 'name': 'Config name', + 'aliases': 'Config alias', + 'room': 'living room' } } - }) - assert setup + } + }) mock_cloud_prefs(hass) cloud = hass.data['cloud'] @@ -251,3 +244,134 @@ async def test_google_config_should_2fa( ) assert not cloud_client.google_config.should_2fa(state) + + +async def test_alexa_config_expose_entity_prefs(hass, cloud_prefs): + """Test Alexa config should expose using prefs.""" + entity_conf = { + 'should_expose': False + } + await cloud_prefs.async_update(alexa_entity_configs={ + 'light.kitchen': entity_conf + }) + conf = alexa_config.AlexaConfig(hass, ALEXA_SCHEMA({}), cloud_prefs, None) + + assert not conf.should_expose('light.kitchen') + entity_conf['should_expose'] = True + assert conf.should_expose('light.kitchen') + + +async def test_alexa_config_report_state(hass, cloud_prefs): + """Test Alexa config should expose using prefs.""" + conf = alexa_config.AlexaConfig(hass, ALEXA_SCHEMA({}), cloud_prefs, None) + + assert cloud_prefs.alexa_report_state is False + assert conf.should_report_state is False + assert conf.is_reporting_states is False + + with patch.object(conf, 'async_get_access_token', + return_value=mock_coro("hello")): + await cloud_prefs.async_update(alexa_report_state=True) + await hass.async_block_till_done() + + assert cloud_prefs.alexa_report_state is True + assert conf.should_report_state is True + assert conf.is_reporting_states is True + + await cloud_prefs.async_update(alexa_report_state=False) + await hass.async_block_till_done() + + assert cloud_prefs.alexa_report_state is False + assert conf.should_report_state is False + assert conf.is_reporting_states is False + + +@contextlib.contextmanager +def patch_sync_helper(): + """Patch sync helper. + + In Py3.7 this would have been an async context manager. + """ + to_update = [] + to_remove = [] + + with patch( + 'homeassistant.components.cloud.alexa_config.SYNC_DELAY', 0 + ), patch( + 'homeassistant.components.cloud.alexa_config.AlexaConfig._sync_helper', + side_effect=mock_coro + ) as mock_helper: + yield to_update, to_remove + + actual_to_update, actual_to_remove = mock_helper.mock_calls[0][1] + to_update.extend(actual_to_update) + to_remove.extend(actual_to_remove) + + +async def test_alexa_update_expose_trigger_sync(hass, cloud_prefs): + """Test Alexa config responds to updating exposed entities.""" + alexa_config.AlexaConfig(hass, ALEXA_SCHEMA({}), cloud_prefs, None) + + with patch_sync_helper() as (to_update, to_remove): + await cloud_prefs.async_update_alexa_entity_config( + entity_id='light.kitchen', should_expose=True + ) + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow()) + await hass.async_block_till_done() + + assert to_update == ['light.kitchen'] + assert to_remove == [] + + with patch_sync_helper() as (to_update, to_remove): + await cloud_prefs.async_update_alexa_entity_config( + entity_id='light.kitchen', should_expose=False + ) + await cloud_prefs.async_update_alexa_entity_config( + entity_id='binary_sensor.door', should_expose=True + ) + await cloud_prefs.async_update_alexa_entity_config( + entity_id='sensor.temp', should_expose=True + ) + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow()) + await hass.async_block_till_done() + + assert sorted(to_update) == ['binary_sensor.door', 'sensor.temp'] + assert to_remove == ['light.kitchen'] + + +async def test_alexa_entity_registry_sync(hass, mock_cloud_login, cloud_prefs): + """Test Alexa config responds to entity registry.""" + alexa_config.AlexaConfig( + hass, ALEXA_SCHEMA({}), cloud_prefs, hass.data['cloud']) + + with patch_sync_helper() as (to_update, to_remove): + hass.bus.async_fire(EVENT_ENTITY_REGISTRY_UPDATED, { + 'action': 'create', + 'entity_id': 'light.kitchen', + }) + await hass.async_block_till_done() + + assert to_update == ['light.kitchen'] + assert to_remove == [] + + with patch_sync_helper() as (to_update, to_remove): + hass.bus.async_fire(EVENT_ENTITY_REGISTRY_UPDATED, { + 'action': 'remove', + 'entity_id': 'light.kitchen', + }) + await hass.async_block_till_done() + + assert to_update == [] + assert to_remove == ['light.kitchen'] + + with patch_sync_helper() as (to_update, to_remove): + hass.bus.async_fire(EVENT_ENTITY_REGISTRY_UPDATED, { + 'action': 'update', + 'entity_id': 'light.kitchen', + }) + await hass.async_block_till_done() + + assert to_update == [] + assert to_remove == [] diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 24bd647405a631..bc60568f0d4bc5 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -14,9 +14,12 @@ PREF_ENABLE_GOOGLE, PREF_ENABLE_ALEXA, PREF_GOOGLE_SECURE_DEVICES_PIN, DOMAIN) from homeassistant.components.google_assistant.helpers import ( - GoogleEntity, Config) + GoogleEntity) +from homeassistant.components.alexa.entities import LightCapabilities +from homeassistant.components.alexa import errors as alexa_errors from tests.common import mock_coro +from tests.components.google_assistant import MockConfig from . import mock_cloud, mock_cloud_prefs @@ -44,7 +47,7 @@ def mock_cloud_login(hass, setup_api): @pytest.fixture(autouse=True) def setup_api(hass, aioclient_mock): """Initialize HTTP API.""" - mock_cloud(hass, { + hass.loop.run_until_complete(mock_cloud(hass, { 'mode': 'development', 'cognito_client_id': 'cognito_client_id', 'user_pool_id': 'user_pool_id', @@ -62,7 +65,7 @@ def setup_api(hass, aioclient_mock): 'include_entities': ['light.kitchen', 'switch.ac'] } } - }) + })) return mock_cloud_prefs(hass) @@ -343,7 +346,7 @@ async def test_websocket_status(hass, hass_ws_client, mock_cloud_fixture, with patch.dict( 'homeassistant.components.google_assistant.const.' 'DOMAIN_TO_GOOGLE_TYPES', {'light': None}, clear=True - ), patch.dict('homeassistant.components.alexa.smart_home.ENTITY_ADAPTERS', + ), patch.dict('homeassistant.components.alexa.entities.ENTITY_ADAPTERS', {'switch': None}, clear=True): await client.send_json({ 'id': 5, @@ -361,6 +364,8 @@ async def test_websocket_status(hass, hass_ws_client, mock_cloud_fixture, 'google_enabled': True, 'google_entity_configs': {}, 'google_secure_devices_pin': None, + 'alexa_entity_configs': {}, + 'alexa_report_state': False, 'remote_enabled': False, }, 'alexa_entities': { @@ -369,7 +374,6 @@ async def test_websocket_status(hass, hass_ws_client, mock_cloud_fixture, 'exclude_domains': [], 'exclude_entities': [], }, - 'alexa_domains': ['switch'], 'google_entities': { 'include_domains': ['light'], 'include_entities': [], @@ -707,9 +711,10 @@ async def test_list_google_entities( hass, hass_ws_client, setup_api, mock_cloud_login): """Test that we can list Google entities.""" client = await hass_ws_client(hass) - entity = GoogleEntity(hass, Config(lambda *_: False), State( - 'light.kitchen', 'on' - )) + entity = GoogleEntity( + hass, MockConfig(should_expose=lambda *_: False), State( + 'light.kitchen', 'on' + )) with patch('homeassistant.components.google_assistant.helpers' '.async_get_entities', return_value=[entity]): await client.send_json({ @@ -800,3 +805,96 @@ async def test_enabling_remote_trusted_proxies_local6( 'Remote UI not compatible with 127.0.0.1/::1 as trusted proxies.' assert len(mock_connect.mock_calls) == 0 + + +async def test_list_alexa_entities( + hass, hass_ws_client, setup_api, mock_cloud_login): + """Test that we can list Alexa entities.""" + client = await hass_ws_client(hass) + entity = LightCapabilities(hass, MagicMock(entity_config={}), State( + 'light.kitchen', 'on' + )) + with patch('homeassistant.components.alexa.entities' + '.async_get_entities', return_value=[entity]): + await client.send_json({ + 'id': 5, + 'type': 'cloud/alexa/entities', + }) + response = await client.receive_json() + + assert response['success'] + assert len(response['result']) == 1 + assert response['result'][0] == { + 'entity_id': 'light.kitchen', + 'display_categories': ['LIGHT'], + 'interfaces': ['Alexa.PowerController', 'Alexa.EndpointHealth'], + } + + +async def test_update_alexa_entity( + hass, hass_ws_client, setup_api, mock_cloud_login): + """Test that we can update config of an Alexa entity.""" + client = await hass_ws_client(hass) + await client.send_json({ + 'id': 5, + 'type': 'cloud/alexa/entities/update', + 'entity_id': 'light.kitchen', + 'should_expose': False, + }) + response = await client.receive_json() + + assert response['success'] + prefs = hass.data[DOMAIN].client.prefs + assert prefs.alexa_entity_configs['light.kitchen'] == { + 'should_expose': False, + } + + +async def test_sync_alexa_entities_timeout( + hass, hass_ws_client, setup_api, mock_cloud_login): + """Test that timeout syncing Alexa entities.""" + client = await hass_ws_client(hass) + with patch('homeassistant.components.cloud.alexa_config.AlexaConfig' + '.async_sync_entities', side_effect=asyncio.TimeoutError): + await client.send_json({ + 'id': 5, + 'type': 'cloud/alexa/sync', + }) + response = await client.receive_json() + + assert not response['success'] + assert response['error']['code'] == 'timeout' + + +async def test_sync_alexa_entities_no_token( + hass, hass_ws_client, setup_api, mock_cloud_login): + """Test sync Alexa entities when we have no token.""" + client = await hass_ws_client(hass) + with patch('homeassistant.components.cloud.alexa_config.AlexaConfig' + '.async_sync_entities', + side_effect=alexa_errors.NoTokenAvailable): + await client.send_json({ + 'id': 5, + 'type': 'cloud/alexa/sync', + }) + response = await client.receive_json() + + assert not response['success'] + assert response['error']['code'] == 'alexa_relink' + + +async def test_enable_alexa_state_report_fail( + hass, hass_ws_client, setup_api, mock_cloud_login): + """Test enable Alexa entities state reporting when no token available.""" + client = await hass_ws_client(hass) + with patch('homeassistant.components.cloud.alexa_config.AlexaConfig' + '.async_sync_entities', + side_effect=alexa_errors.NoTokenAvailable): + await client.send_json({ + 'id': 5, + 'type': 'cloud/alexa/sync', + }) + response = await client.receive_json() + + assert not response['success'] + assert response['error']['code'] == 'alexa_relink' diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index ea611c29df1c0b..c938a404964d71 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -154,3 +154,25 @@ async def test_setup_setup_cloud_user(hass, hass_storage): assert cloud_user assert cloud_user.groups[0].id == GROUP_ID_ADMIN + + +async def test_on_connect(hass, mock_cloud_fixture): + """Test cloud on connect triggers.""" + cl = hass.data['cloud'] + + assert len(cl.iot._on_connect) == 4 + + assert len(hass.states.async_entity_ids('binary_sensor')) == 0 + + assert 'async_setup' in str(cl.iot._on_connect[-1]) + await cl.iot._on_connect[-1]() + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids('binary_sensor')) == 1 + + with patch('homeassistant.helpers.discovery.async_load_platform', + side_effect=mock_coro) as mock_load: + await cl.iot._on_connect[-1]() + await hass.async_block_till_done() + + assert len(mock_load.mock_calls) == 0 diff --git a/tests/components/config/test_device_registry.py b/tests/components/config/test_device_registry.py index de603707ae2071..9f346343f72ba2 100644 --- a/tests/components/config/test_device_registry.py +++ b/tests/components/config/test_device_registry.py @@ -29,7 +29,7 @@ async def test_list_devices(hass, client, registry): config_entry_id='1234', identifiers={('bridgeid', '1234')}, manufacturer='manufacturer', model='model', - via_hub=('bridgeid', '0123')) + via_device=('bridgeid', '0123')) await client.send_json({ 'id': 5, @@ -47,7 +47,7 @@ async def test_list_devices(hass, client, registry): 'model': 'model', 'name': None, 'sw_version': None, - 'hub_device_id': None, + 'via_device_id': None, 'area_id': None, 'name_by_user': None, }, @@ -58,7 +58,7 @@ async def test_list_devices(hass, client, registry): 'model': 'model', 'name': None, 'sw_version': None, - 'hub_device_id': dev1, + 'via_device_id': dev1, 'area_id': None, 'name_by_user': None, } diff --git a/tests/components/demo/test_climate.py b/tests/components/demo/test_climate.py index 3166b2d3158643..444b053fc1958e 100644 --- a/tests/components/demo/test_climate.py +++ b/tests/components/demo/test_climate.py @@ -203,7 +203,8 @@ def test_set_away_mode_bad_attr(self): """Test setting the away mode without required attribute.""" state = self.hass.states.get(ENTITY_CLIMATE) assert 'on' == state.attributes.get('away_mode') - common.set_away_mode(self.hass, None, ENTITY_CLIMATE) + with pytest.raises(vol.Invalid): + common.set_away_mode(self.hass, None, ENTITY_CLIMATE) self.hass.block_till_done() assert 'on' == state.attributes.get('away_mode') @@ -246,7 +247,8 @@ def test_set_aux_heat_bad_attr(self): """Test setting the auxiliary heater without required attribute.""" state = self.hass.states.get(ENTITY_CLIMATE) assert 'off' == state.attributes.get('aux_heat') - common.set_aux_heat(self.hass, None, ENTITY_CLIMATE) + with pytest.raises(vol.Invalid): + common.set_aux_heat(self.hass, None, ENTITY_CLIMATE) self.hass.block_till_done() assert 'off' == state.attributes.get('aux_heat') diff --git a/tests/components/demo/test_cover.py b/tests/components/demo/test_cover.py index 011928f851a129..1477afc44d2dff 100644 --- a/tests/components/demo/test_cover.py +++ b/tests/components/demo/test_cover.py @@ -4,9 +4,12 @@ import pytest from homeassistant.components.cover import ( - ATTR_POSITION, ATTR_TILT_POSITION, DOMAIN) + ATTR_POSITION, ATTR_CURRENT_POSITION, ATTR_CURRENT_TILT_POSITION, + ATTR_TILT_POSITION, DOMAIN) from homeassistant.const import ( - ATTR_ENTITY_ID, SERVICE_CLOSE_COVER, SERVICE_CLOSE_COVER_TILT, + ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, + STATE_OPEN, STATE_OPENING, STATE_CLOSED, STATE_CLOSING, SERVICE_TOGGLE, + SERVICE_CLOSE_COVER, SERVICE_CLOSE_COVER_TILT, SERVICE_TOGGLE_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) @@ -29,60 +32,100 @@ async def setup_comp(hass): async def test_supported_features(hass, setup_comp): """Test cover supported features.""" state = hass.states.get('cover.garage_door') - assert 3 == state.attributes.get('supported_features') + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 3 state = hass.states.get('cover.kitchen_window') - assert 11 == state.attributes.get('supported_features') + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 11 state = hass.states.get('cover.hall_window') - assert 15 == state.attributes.get('supported_features') + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 15 state = hass.states.get('cover.living_room_window') - assert 255 == state.attributes.get('supported_features') + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 255 async def test_close_cover(hass, setup_comp): """Test closing the cover.""" state = hass.states.get(ENTITY_COVER) - assert state.state == 'open' - assert 70 == state.attributes.get('current_position') + assert state.state == STATE_OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 70 await hass.services.async_call( DOMAIN, SERVICE_CLOSE_COVER, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) state = hass.states.get(ENTITY_COVER) - assert state.state == 'closing' + assert state.state == STATE_CLOSING for _ in range(7): future = dt_util.utcnow() + timedelta(seconds=1) async_fire_time_changed(hass, future) await hass.async_block_till_done() state = hass.states.get(ENTITY_COVER) - assert state.state == 'closed' - assert 0 == state.attributes.get('current_position') + assert state.state == STATE_CLOSED + assert state.attributes[ATTR_CURRENT_POSITION] == 0 async def test_open_cover(hass, setup_comp): """Test opening the cover.""" state = hass.states.get(ENTITY_COVER) - assert state.state == 'open' - assert 70 == state.attributes.get('current_position') + assert state.state == STATE_OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 70 await hass.services.async_call( DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) state = hass.states.get(ENTITY_COVER) - assert state.state == 'opening' + assert state.state == STATE_OPENING for _ in range(7): future = dt_util.utcnow() + timedelta(seconds=1) async_fire_time_changed(hass, future) await hass.async_block_till_done() state = hass.states.get(ENTITY_COVER) - assert state.state == 'open' - assert 100 == state.attributes.get('current_position') + assert state.state == STATE_OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 100 + + +async def test_toggle_cover(hass, setup_comp): + """Test toggling the cover.""" + # Start open + await hass.services.async_call( + DOMAIN, SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) + for _ in range(7): + future = dt_util.utcnow() + timedelta(seconds=1) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_COVER) + assert state.state == STATE_OPEN + assert state.attributes['current_position'] == 100 + # Toggle closed + await hass.services.async_call( + DOMAIN, SERVICE_TOGGLE, + {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) + for _ in range(10): + future = dt_util.utcnow() + timedelta(seconds=1) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_COVER) + assert state.state == STATE_CLOSED + assert state.attributes[ATTR_CURRENT_POSITION] == 0 + # Toggle open + await hass.services.async_call( + DOMAIN, SERVICE_TOGGLE, + {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) + for _ in range(10): + future = dt_util.utcnow() + timedelta(seconds=1) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_COVER) + assert state.state == STATE_OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 100 async def test_set_cover_position(hass, setup_comp): """Test moving the cover to a specific position.""" state = hass.states.get(ENTITY_COVER) - assert 70 == state.attributes.get('current_position') + assert state.attributes[ATTR_CURRENT_POSITION] == 70 await hass.services.async_call( DOMAIN, SERVICE_SET_COVER_POSITION, {ATTR_ENTITY_ID: ENTITY_COVER, ATTR_POSITION: 10}, blocking=True) @@ -92,13 +135,13 @@ async def test_set_cover_position(hass, setup_comp): await hass.async_block_till_done() state = hass.states.get(ENTITY_COVER) - assert 10 == state.attributes.get('current_position') + assert state.attributes[ATTR_CURRENT_POSITION] == 10 async def test_stop_cover(hass, setup_comp): """Test stopping the cover.""" state = hass.states.get(ENTITY_COVER) - assert 70 == state.attributes.get('current_position') + assert state.attributes[ATTR_CURRENT_POSITION] == 70 await hass.services.async_call( DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) @@ -111,13 +154,13 @@ async def test_stop_cover(hass, setup_comp): async_fire_time_changed(hass, future) await hass.async_block_till_done() state = hass.states.get(ENTITY_COVER) - assert 80 == state.attributes.get('current_position') + assert state.attributes[ATTR_CURRENT_POSITION] == 80 async def test_close_cover_tilt(hass, setup_comp): """Test closing the cover tilt.""" state = hass.states.get(ENTITY_COVER) - assert 50 == state.attributes.get('current_tilt_position') + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 50 await hass.services.async_call( DOMAIN, SERVICE_CLOSE_COVER_TILT, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) @@ -127,13 +170,13 @@ async def test_close_cover_tilt(hass, setup_comp): await hass.async_block_till_done() state = hass.states.get(ENTITY_COVER) - assert 0 == state.attributes.get('current_tilt_position') + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 0 async def test_open_cover_tilt(hass, setup_comp): """Test opening the cover tilt.""" state = hass.states.get(ENTITY_COVER) - assert 50 == state.attributes.get('current_tilt_position') + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 50 await hass.services.async_call( DOMAIN, SERVICE_OPEN_COVER_TILT, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) @@ -143,29 +186,67 @@ async def test_open_cover_tilt(hass, setup_comp): await hass.async_block_till_done() state = hass.states.get(ENTITY_COVER) - assert 100 == state.attributes.get('current_tilt_position') + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 100 + + +async def test_toggle_cover_tilt(hass, setup_comp): + """Test toggling the cover tilt.""" + # Start open + await hass.services.async_call( + DOMAIN, SERVICE_OPEN_COVER_TILT, + {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) + for _ in range(7): + future = dt_util.utcnow() + timedelta(seconds=1) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_COVER) + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 100 + # Toggle closed + await hass.services.async_call( + DOMAIN, SERVICE_TOGGLE_COVER_TILT, + {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) + for _ in range(10): + future = dt_util.utcnow() + timedelta(seconds=1) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_COVER) + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 0 + # Toggle Open + await hass.services.async_call( + DOMAIN, SERVICE_TOGGLE_COVER_TILT, + {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) + for _ in range(10): + future = dt_util.utcnow() + timedelta(seconds=1) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_COVER) + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 100 async def test_set_cover_tilt_position(hass, setup_comp): """Test moving the cover til to a specific position.""" state = hass.states.get(ENTITY_COVER) - assert 50 == state.attributes.get('current_tilt_position') + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 50 await hass.services.async_call( DOMAIN, SERVICE_SET_COVER_TILT_POSITION, - {ATTR_ENTITY_ID: ENTITY_COVER, ATTR_TILT_POSITION: 90}, blocking=True) + {ATTR_ENTITY_ID: ENTITY_COVER, ATTR_TILT_POSITION: 90}, + blocking=True) for _ in range(7): future = dt_util.utcnow() + timedelta(seconds=1) async_fire_time_changed(hass, future) await hass.async_block_till_done() state = hass.states.get(ENTITY_COVER) - assert 90 == state.attributes.get('current_tilt_position') + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 90 async def test_stop_cover_tilt(hass, setup_comp): """Test stopping the cover tilt.""" state = hass.states.get(ENTITY_COVER) - assert 50 == state.attributes.get('current_tilt_position') + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 50 await hass.services.async_call( DOMAIN, SERVICE_CLOSE_COVER_TILT, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) @@ -178,4 +259,4 @@ async def test_stop_cover_tilt(hass, setup_comp): async_fire_time_changed(hass, future) await hass.async_block_till_done() state = hass.states.get(ENTITY_COVER) - assert 40 == state.attributes.get('current_tilt_position') + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 40 diff --git a/tests/components/demo/test_media_player.py b/tests/components/demo/test_media_player.py index 808e3ee2102a4b..fae4215f954e99 100644 --- a/tests/components/demo/test_media_player.py +++ b/tests/components/demo/test_media_player.py @@ -90,7 +90,8 @@ def test_volume_services(self): assert False is state.attributes.get('is_volume_muted') - common.mute_volume(self.hass, None, entity_id) + with pytest.raises(vol.Invalid): + common.mute_volume(self.hass, None, entity_id) self.hass.block_till_done() state = self.hass.states.get(entity_id) assert False is state.attributes.get('is_volume_muted') diff --git a/tests/components/demo/test_remote.py b/tests/components/demo/test_remote.py index c68e34ddc18e5d..e5db98c381b6c0 100644 --- a/tests/components/demo/test_remote.py +++ b/tests/components/demo/test_remote.py @@ -48,5 +48,8 @@ def test_methods(self): common.send_command(self.hass, 'test', entity_id=ENTITY_ID) self.hass.block_till_done() state = self.hass.states.get(ENTITY_ID) - assert state.attributes == \ - {'friendly_name': 'Remote One', 'last_command_sent': 'test'} + assert state.attributes == { + 'friendly_name': 'Remote One', + 'last_command_sent': 'test', + 'supported_features': 0 + } diff --git a/tests/components/demo/test_water_heater.py b/tests/components/demo/test_water_heater.py index d8c9c71935b05d..e336e879f91553 100644 --- a/tests/components/demo/test_water_heater.py +++ b/tests/components/demo/test_water_heater.py @@ -95,7 +95,8 @@ def test_set_away_mode_bad_attr(self): """Test setting the away mode without required attribute.""" state = self.hass.states.get(ENTITY_WATER_HEATER) assert 'off' == state.attributes.get('away_mode') - common.set_away_mode(self.hass, None, ENTITY_WATER_HEATER) + with pytest.raises(vol.Invalid): + common.set_away_mode(self.hass, None, ENTITY_WATER_HEATER) self.hass.block_till_done() assert 'off' == state.attributes.get('away_mode') diff --git a/tests/components/device_automation/test_init.py b/tests/components/device_automation/test_init.py new file mode 100644 index 00000000000000..64b1a7574ae623 --- /dev/null +++ b/tests/components/device_automation/test_init.py @@ -0,0 +1,67 @@ +"""The test for light device automation.""" +import pytest + +from homeassistant.setup import async_setup_component +from homeassistant.components.websocket_api.const import TYPE_RESULT +from homeassistant.helpers import device_registry + + +from tests.common import ( + MockConfigEntry, mock_device_registry, mock_registry) + + +@pytest.fixture +def device_reg(hass): + """Return an empty, loaded, registry.""" + return mock_device_registry(hass) + + +@pytest.fixture +def entity_reg(hass): + """Return an empty, loaded, registry.""" + return mock_registry(hass) + + +def _same_triggers(a, b): + if len(a) != len(b): + return False + + for d in a: + if d not in b: + return False + return True + + +async def test_websocket_get_triggers( + hass, hass_ws_client, device_reg, entity_reg): + """Test we get the expected triggers from a light through websocket.""" + await async_setup_component(hass, 'device_automation', {}) + config_entry = MockConfigEntry(domain='test', data={}) + config_entry.add_to_hass(hass) + device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={ + (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF') + }) + entity_reg.async_get_or_create( + 'light', 'test', '5678', device_id=device_entry.id) + expected_triggers = [ + {'platform': 'device', 'domain': 'light', 'type': 'turn_off', + 'device_id': device_entry.id, 'entity_id': 'light.test_5678'}, + {'platform': 'device', 'domain': 'light', 'type': 'turn_on', + 'device_id': device_entry.id, 'entity_id': 'light.test_5678'}, + ] + + client = await hass_ws_client(hass) + await client.send_json({ + 'id': 1, + 'type': 'device_automation/list_triggers', + 'device_id': device_entry.id + }) + msg = await client.receive_json() + + assert msg['id'] == 1 + assert msg['type'] == TYPE_RESULT + assert msg['success'] + triggers = msg['result']['triggers'] + assert _same_triggers(triggers, expected_triggers) diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py index 9a59855e8c14a5..cd518770c5b216 100644 --- a/tests/components/device_tracker/test_init.py +++ b/tests/components/device_tracker/test_init.py @@ -3,7 +3,7 @@ import json import logging import os -from unittest.mock import call +from unittest.mock import Mock, call from asynctest import patch import pytest @@ -12,9 +12,9 @@ import homeassistant.components.device_tracker as device_tracker from homeassistant.components.device_tracker import const, legacy from homeassistant.const import ( - ATTR_ENTITY_ID, ATTR_ENTITY_PICTURE, ATTR_FRIENDLY_NAME, ATTR_HIDDEN, - ATTR_ICON, CONF_PLATFORM, STATE_HOME, STATE_NOT_HOME, - ATTR_LATITUDE, ATTR_LONGITUDE, ATTR_GPS_ACCURACY) + ATTR_ENTITY_ID, ATTR_ENTITY_PICTURE, ATTR_FRIENDLY_NAME, ATTR_GPS_ACCURACY, + ATTR_HIDDEN, ATTR_ICON, ATTR_LATITUDE, ATTR_LONGITUDE, CONF_PLATFORM, + STATE_HOME, STATE_NOT_HOME) from homeassistant.core import State, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import discovery @@ -23,8 +23,8 @@ import homeassistant.util.dt as dt_util from tests.common import ( - assert_setup_component, async_fire_time_changed, mock_restore_cache, - patch_yaml_files) + assert_setup_component, async_fire_time_changed, mock_registry, + mock_restore_cache, patch_yaml_files) from tests.components.device_tracker import common TEST_PLATFORM = {device_tracker.DOMAIN: {CONF_PLATFORM: 'test'}} @@ -321,6 +321,26 @@ async def test_see_service(mock_see, hass): assert mock_see.call_args == call(**params) +async def test_see_service_guard_config_entry(hass, mock_device_tracker_conf): + """Test the guard if the device is registered in the entity registry.""" + mock_entry = Mock() + dev_id = 'test' + entity_id = const.ENTITY_ID_FORMAT.format(dev_id) + mock_registry(hass, {entity_id: mock_entry}) + devices = mock_device_tracker_conf + assert await async_setup_component( + hass, device_tracker.DOMAIN, TEST_PLATFORM) + params = { + 'dev_id': dev_id, + 'gps': [.3, .8], + } + + common.async_see(hass, **params) + await hass.async_block_till_done() + + assert not devices + + async def test_new_device_event_fired(hass, mock_device_tracker_conf): """Test that the device tracker will fire an event.""" with assert_setup_component(1, device_tracker.DOMAIN): diff --git a/tests/components/flux/test_switch.py b/tests/components/flux/test_switch.py index ee4e2e4e77c861..bfd9e3f755116f 100644 --- a/tests/components/flux/test_switch.py +++ b/tests/components/flux/test_switch.py @@ -1,846 +1,909 @@ """The tests for the Flux switch platform.""" -import unittest -from unittest.mock import patch +from asynctest.mock import patch +import pytest -from homeassistant.setup import setup_component +from homeassistant.setup import async_setup_component from homeassistant.components import switch, light from homeassistant.const import ( CONF_PLATFORM, STATE_ON, SERVICE_TURN_ON, SUN_EVENT_SUNRISE) import homeassistant.util.dt as dt_util from tests.common import ( - assert_setup_component, get_test_home_assistant, fire_time_changed, - mock_service) + assert_setup_component, async_fire_time_changed, + async_mock_service) from tests.components.light import common as common_light from tests.components.switch import common -class TestSwitchFlux(unittest.TestCase): - """Test the Flux switch platform.""" - - def setUp(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() +async def test_valid_config(hass): + """Test configuration.""" + assert await async_setup_component(hass, 'switch', { + 'switch': { + 'platform': 'flux', + 'name': 'flux', + 'lights': ['light.desk', 'light.lamp'], + } + }) + + +async def test_valid_config_with_info(hass): + """Test configuration.""" + assert await async_setup_component(hass, 'switch', { + 'switch': { + 'platform': 'flux', + 'name': 'flux', + 'lights': ['light.desk', 'light.lamp'], + 'stop_time': '22:59', + 'start_time': '7:22', + 'start_colortemp': '1000', + 'sunset_colortemp': '2000', + 'stop_colortemp': '4000' + } + }) + + +async def test_valid_config_no_name(hass): + """Test configuration.""" + with assert_setup_component(1, 'switch'): + assert await async_setup_component(hass, 'switch', { + 'switch': { + 'platform': 'flux', + 'lights': ['light.desk', 'light.lamp'] + } + }) - def tearDown(self): - """Stop everything that was started.""" - self.hass.stop() - def test_valid_config(self): - """Test configuration.""" - assert setup_component(self.hass, 'switch', { +async def test_invalid_config_no_lights(hass): + """Test configuration.""" + with assert_setup_component(0, 'switch'): + assert await async_setup_component(hass, 'switch', { 'switch': { 'platform': 'flux', - 'name': 'flux', - 'lights': ['light.desk', 'light.lamp'], + 'name': 'flux' } }) - def test_valid_config_with_info(self): - """Test configuration.""" - assert setup_component(self.hass, 'switch', { - 'switch': { + +async def test_flux_when_switch_is_off(hass): + """Test the flux switch when it is off.""" + platform = getattr(hass.components, 'test.light') + platform.init() + assert await async_setup_component( + hass, light.DOMAIN, + {light.DOMAIN: {CONF_PLATFORM: 'test'}}) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = hass.states.get(dev1.entity_id) + assert STATE_ON == state.state + assert state.attributes.get('xy_color') is None + assert state.attributes.get('brightness') is None + + test_time = dt_util.utcnow().replace(hour=10, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == SUN_EVENT_SUNRISE: + return sunrise_time + return sunset_time + + with patch('homeassistant.components.flux.switch.dt_utcnow', + return_value=test_time), \ + patch('homeassistant.components.flux.switch.get_astral_event_date', + side_effect=event_date): + turn_on_calls = async_mock_service( + hass, light.DOMAIN, SERVICE_TURN_ON) + assert await async_setup_component(hass, switch.DOMAIN, { + switch.DOMAIN: { 'platform': 'flux', 'name': 'flux', - 'lights': ['light.desk', 'light.lamp'], - 'stop_time': '22:59', - 'start_time': '7:22', + 'lights': [dev1.entity_id] + } + }) + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() + + assert not turn_on_calls + + +async def test_flux_before_sunrise(hass): + """Test the flux switch before sunrise.""" + platform = getattr(hass.components, 'test.light') + platform.init() + assert await async_setup_component(hass, light.DOMAIN, { + light.DOMAIN: {CONF_PLATFORM: 'test'}}) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = hass.states.get(dev1.entity_id) + assert STATE_ON == state.state + assert state.attributes.get('xy_color') is None + assert state.attributes.get('brightness') is None + + test_time = dt_util.utcnow().replace(hour=2, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=5) + + def event_date(hass, event, now=None): + if event == SUN_EVENT_SUNRISE: + return sunrise_time + return sunset_time + + await hass.async_block_till_done() + with patch('homeassistant.components.flux.switch.dt_utcnow', + return_value=test_time), \ + patch('homeassistant.components.flux.switch.get_astral_event_date', + side_effect=event_date): + assert await async_setup_component(hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id] + } + }) + turn_on_calls = async_mock_service( + hass, light.DOMAIN, SERVICE_TURN_ON) + await common.async_turn_on(hass, 'switch.flux') + await hass.async_block_till_done() + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() + call = turn_on_calls[-1] + assert call.data[light.ATTR_BRIGHTNESS] == 112 + assert call.data[light.ATTR_XY_COLOR] == [0.606, 0.379] + + +async def test_flux_before_sunrise_known_location(hass): + """Test the flux switch before sunrise.""" + platform = getattr(hass.components, 'test.light') + platform.init() + assert await async_setup_component(hass, light.DOMAIN, { + light.DOMAIN: {CONF_PLATFORM: 'test'}}) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = hass.states.get(dev1.entity_id) + assert STATE_ON == state.state + assert state.attributes.get('xy_color') is None + assert state.attributes.get('brightness') is None + + hass.config.latitude = 55.948372 + hass.config.longitude = -3.199466 + hass.config.elevation = 17 + test_time = dt_util.utcnow().replace( + hour=2, minute=0, second=0, day=21, month=6, year=2019) + + await hass.async_block_till_done() + with patch('homeassistant.components.flux.switch.dt_utcnow', + return_value=test_time): + assert await async_setup_component(hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + # 'brightness': 255, + # 'disable_brightness_adjust': True, + # 'mode': 'rgb', + # 'interval': 120 + } + }) + turn_on_calls = async_mock_service( + hass, light.DOMAIN, SERVICE_TURN_ON) + await common.async_turn_on(hass, 'switch.flux') + await hass.async_block_till_done() + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() + call = turn_on_calls[-1] + assert call.data[light.ATTR_BRIGHTNESS] == 112 + assert call.data[light.ATTR_XY_COLOR] == [0.606, 0.379] + + +# pylint: disable=invalid-name +async def test_flux_after_sunrise_before_sunset(hass): + """Test the flux switch after sunrise and before sunset.""" + platform = getattr(hass.components, 'test.light') + platform.init() + assert await async_setup_component(hass, light.DOMAIN, { + light.DOMAIN: {CONF_PLATFORM: 'test'}}) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = hass.states.get(dev1.entity_id) + assert STATE_ON == state.state + assert state.attributes.get('xy_color') is None + assert state.attributes.get('brightness') is None + + test_time = dt_util.utcnow().replace(hour=8, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == SUN_EVENT_SUNRISE: + return sunrise_time + return sunset_time + + with patch('homeassistant.components.flux.switch.dt_utcnow', + return_value=test_time), \ + patch('homeassistant.components.flux.switch.get_astral_event_date', + side_effect=event_date): + assert await async_setup_component(hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id] + } + }) + turn_on_calls = async_mock_service( + hass, light.DOMAIN, SERVICE_TURN_ON) + await common.async_turn_on(hass, 'switch.flux') + await hass.async_block_till_done() + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() + call = turn_on_calls[-1] + assert call.data[light.ATTR_BRIGHTNESS] == 173 + assert call.data[light.ATTR_XY_COLOR] == [0.439, 0.37] + + +# pylint: disable=invalid-name +async def test_flux_after_sunset_before_stop(hass): + """Test the flux switch after sunset and before stop.""" + platform = getattr(hass.components, 'test.light') + platform.init() + assert await async_setup_component(hass, light.DOMAIN, { + light.DOMAIN: {CONF_PLATFORM: 'test'}}) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = hass.states.get(dev1.entity_id) + assert STATE_ON == state.state + assert state.attributes.get('xy_color') is None + assert state.attributes.get('brightness') is None + + test_time = dt_util.utcnow().replace(hour=17, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == SUN_EVENT_SUNRISE: + return sunrise_time + return sunset_time + + with patch('homeassistant.components.flux.switch.dt_utcnow', + return_value=test_time), \ + patch('homeassistant.components.flux.switch.get_astral_event_date', + side_effect=event_date): + assert await async_setup_component(hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'stop_time': '22:00' + } + }) + turn_on_calls = async_mock_service( + hass, light.DOMAIN, SERVICE_TURN_ON) + common.turn_on(hass, 'switch.flux') + await hass.async_block_till_done() + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() + call = turn_on_calls[-1] + assert call.data[light.ATTR_BRIGHTNESS] == 146 + assert call.data[light.ATTR_XY_COLOR] == [0.506, 0.385] + + +# pylint: disable=invalid-name +async def test_flux_after_stop_before_sunrise(hass): + """Test the flux switch after stop and before sunrise.""" + platform = getattr(hass.components, 'test.light') + platform.init() + assert await async_setup_component(hass, light.DOMAIN, { + light.DOMAIN: {CONF_PLATFORM: 'test'}}) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = hass.states.get(dev1.entity_id) + assert STATE_ON == state.state + assert state.attributes.get('xy_color') is None + assert state.attributes.get('brightness') is None + + test_time = dt_util.utcnow().replace(hour=23, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == SUN_EVENT_SUNRISE: + return sunrise_time + return sunset_time + + with patch('homeassistant.components.flux.switch.dt_utcnow', + return_value=test_time), \ + patch('homeassistant.components.flux.switch.get_astral_event_date', + side_effect=event_date): + assert await async_setup_component(hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id] + } + }) + turn_on_calls = async_mock_service( + hass, light.DOMAIN, SERVICE_TURN_ON) + common.turn_on(hass, 'switch.flux') + await hass.async_block_till_done() + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() + call = turn_on_calls[-1] + assert call.data[light.ATTR_BRIGHTNESS] == 112 + assert call.data[light.ATTR_XY_COLOR] == [0.606, 0.379] + + +# pylint: disable=invalid-name +async def test_flux_with_custom_start_stop_times(hass): + """Test the flux with custom start and stop times.""" + platform = getattr(hass.components, 'test.light') + platform.init() + assert await async_setup_component(hass, light.DOMAIN, { + light.DOMAIN: {CONF_PLATFORM: 'test'}}) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = hass.states.get(dev1.entity_id) + assert STATE_ON == state.state + assert state.attributes.get('xy_color') is None + assert state.attributes.get('brightness') is None + + test_time = dt_util.utcnow().replace(hour=17, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == SUN_EVENT_SUNRISE: + return sunrise_time + return sunset_time + + with patch('homeassistant.components.flux.switch.dt_utcnow', + return_value=test_time), \ + patch('homeassistant.components.flux.switch.get_astral_event_date', + side_effect=event_date): + assert await async_setup_component(hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'start_time': '6:00', + 'stop_time': '23:30' + } + }) + turn_on_calls = async_mock_service( + hass, light.DOMAIN, SERVICE_TURN_ON) + common.turn_on(hass, 'switch.flux') + await hass.async_block_till_done() + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() + call = turn_on_calls[-1] + assert call.data[light.ATTR_BRIGHTNESS] == 147 + assert call.data[light.ATTR_XY_COLOR] == [0.504, 0.385] + + +async def test_flux_before_sunrise_stop_next_day(hass): + """Test the flux switch before sunrise. + + This test has the stop_time on the next day (after midnight). + """ + platform = getattr(hass.components, 'test.light') + platform.init() + assert await async_setup_component(hass, light.DOMAIN, { + light.DOMAIN: {CONF_PLATFORM: 'test'}}) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = hass.states.get(dev1.entity_id) + assert STATE_ON == state.state + assert state.attributes.get('xy_color') is None + assert state.attributes.get('brightness') is None + + test_time = dt_util.utcnow().replace(hour=2, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == SUN_EVENT_SUNRISE: + return sunrise_time + return sunset_time + + with patch('homeassistant.components.flux.switch.dt_utcnow', + return_value=test_time), \ + patch('homeassistant.components.flux.switch.get_astral_event_date', + side_effect=event_date): + assert await async_setup_component(hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'stop_time': '01:00' + } + }) + turn_on_calls = async_mock_service( + hass, light.DOMAIN, SERVICE_TURN_ON) + common.turn_on(hass, 'switch.flux') + await hass.async_block_till_done() + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() + call = turn_on_calls[-1] + assert call.data[light.ATTR_BRIGHTNESS] == 112 + assert call.data[light.ATTR_XY_COLOR] == [0.606, 0.379] + + +# pylint: disable=invalid-name +async def test_flux_after_sunrise_before_sunset_stop_next_day(hass): + """ + Test the flux switch after sunrise and before sunset. + + This test has the stop_time on the next day (after midnight). + """ + platform = getattr(hass.components, 'test.light') + platform.init() + assert await async_setup_component(hass, light.DOMAIN, { + light.DOMAIN: {CONF_PLATFORM: 'test'}}) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = hass.states.get(dev1.entity_id) + assert STATE_ON == state.state + assert state.attributes.get('xy_color') is None + assert state.attributes.get('brightness') is None + + test_time = dt_util.utcnow().replace(hour=8, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == SUN_EVENT_SUNRISE: + return sunrise_time + return sunset_time + + with patch('homeassistant.components.flux.switch.dt_utcnow', + return_value=test_time), \ + patch('homeassistant.components.flux.switch.get_astral_event_date', + side_effect=event_date): + assert await async_setup_component(hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'stop_time': '01:00' + } + }) + turn_on_calls = async_mock_service( + hass, light.DOMAIN, SERVICE_TURN_ON) + common.turn_on(hass, 'switch.flux') + await hass.async_block_till_done() + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() + call = turn_on_calls[-1] + assert call.data[light.ATTR_BRIGHTNESS] == 173 + assert call.data[light.ATTR_XY_COLOR] == [0.439, 0.37] + + +# pylint: disable=invalid-name +@pytest.mark.parametrize("x", [0, 1]) +async def test_flux_after_sunset_before_midnight_stop_next_day(hass, x): + """Test the flux switch after sunset and before stop. + + This test has the stop_time on the next day (after midnight). + """ + platform = getattr(hass.components, 'test.light') + platform.init() + assert await async_setup_component(hass, light.DOMAIN, { + light.DOMAIN: {CONF_PLATFORM: 'test'}}) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = hass.states.get(dev1.entity_id) + assert STATE_ON == state.state + assert state.attributes.get('xy_color') is None + assert state.attributes.get('brightness') is None + + test_time = dt_util.utcnow().replace(hour=23, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == SUN_EVENT_SUNRISE: + return sunrise_time + return sunset_time + + with patch('homeassistant.components.flux.switch.dt_utcnow', + return_value=test_time), \ + patch('homeassistant.components.flux.switch.get_astral_event_date', + side_effect=event_date): + assert await async_setup_component(hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'stop_time': '01:00' + } + }) + turn_on_calls = async_mock_service( + hass, light.DOMAIN, SERVICE_TURN_ON) + common.turn_on(hass, 'switch.flux') + await hass.async_block_till_done() + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() + call = turn_on_calls[-1] + assert call.data[light.ATTR_BRIGHTNESS] == 119 + assert call.data[light.ATTR_XY_COLOR] == [0.588, 0.386] + + +# pylint: disable=invalid-name +async def test_flux_after_sunset_after_midnight_stop_next_day(hass): + """Test the flux switch after sunset and before stop. + + This test has the stop_time on the next day (after midnight). + """ + platform = getattr(hass.components, 'test.light') + platform.init() + assert await async_setup_component(hass, light.DOMAIN, { + light.DOMAIN: {CONF_PLATFORM: 'test'}}) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = hass.states.get(dev1.entity_id) + assert STATE_ON == state.state + assert state.attributes.get('xy_color') is None + assert state.attributes.get('brightness') is None + + test_time = dt_util.utcnow().replace(hour=00, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == SUN_EVENT_SUNRISE: + return sunrise_time + return sunset_time + + with patch('homeassistant.components.flux.switch.dt_utcnow', + return_value=test_time), \ + patch('homeassistant.components.flux.switch.get_astral_event_date', + side_effect=event_date): + assert await async_setup_component(hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'stop_time': '01:00' + } + }) + turn_on_calls = async_mock_service( + hass, light.DOMAIN, SERVICE_TURN_ON) + common.turn_on(hass, 'switch.flux') + await hass.async_block_till_done() + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() + call = turn_on_calls[-1] + assert call.data[light.ATTR_BRIGHTNESS] == 114 + assert call.data[light.ATTR_XY_COLOR] == [0.601, 0.382] + + +# pylint: disable=invalid-name +async def test_flux_after_stop_before_sunrise_stop_next_day(hass): + """Test the flux switch after stop and before sunrise. + + This test has the stop_time on the next day (after midnight). + """ + platform = getattr(hass.components, 'test.light') + platform.init() + assert await async_setup_component(hass, light.DOMAIN, { + light.DOMAIN: {CONF_PLATFORM: 'test'}}) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = hass.states.get(dev1.entity_id) + assert STATE_ON == state.state + assert state.attributes.get('xy_color') is None + assert state.attributes.get('brightness') is None + + test_time = dt_util.utcnow().replace(hour=2, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == SUN_EVENT_SUNRISE: + return sunrise_time + return sunset_time + + with patch('homeassistant.components.flux.switch.dt_utcnow', + return_value=test_time), \ + patch('homeassistant.components.flux.switch.get_astral_event_date', + side_effect=event_date): + assert await async_setup_component(hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'stop_time': '01:00' + } + }) + turn_on_calls = async_mock_service( + hass, light.DOMAIN, SERVICE_TURN_ON) + common.turn_on(hass, 'switch.flux') + await hass.async_block_till_done() + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() + call = turn_on_calls[-1] + assert call.data[light.ATTR_BRIGHTNESS] == 112 + assert call.data[light.ATTR_XY_COLOR] == [0.606, 0.379] + + +# pylint: disable=invalid-name +async def test_flux_with_custom_colortemps(hass): + """Test the flux with custom start and stop colortemps.""" + platform = getattr(hass.components, 'test.light') + platform.init() + assert await async_setup_component(hass, light.DOMAIN, { + light.DOMAIN: {CONF_PLATFORM: 'test'}}) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = hass.states.get(dev1.entity_id) + assert STATE_ON == state.state + assert state.attributes.get('xy_color') is None + assert state.attributes.get('brightness') is None + + test_time = dt_util.utcnow().replace(hour=17, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == SUN_EVENT_SUNRISE: + return sunrise_time + return sunset_time + + with patch('homeassistant.components.flux.switch.dt_utcnow', + return_value=test_time), \ + patch('homeassistant.components.flux.switch.get_astral_event_date', + side_effect=event_date): + assert await async_setup_component(hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], 'start_colortemp': '1000', - 'sunset_colortemp': '2000', - 'stop_colortemp': '4000' + 'stop_colortemp': '6000', + 'stop_time': '22:00' } }) - - def test_valid_config_no_name(self): - """Test configuration.""" - with assert_setup_component(1, 'switch'): - assert setup_component(self.hass, 'switch', { - 'switch': { - 'platform': 'flux', - 'lights': ['light.desk', 'light.lamp'] - } - }) - - def test_invalid_config_no_lights(self): - """Test configuration.""" - with assert_setup_component(0, 'switch'): - assert setup_component(self.hass, 'switch', { - 'switch': { - 'platform': 'flux', - 'name': 'flux' - } - }) - - def test_flux_when_switch_is_off(self): - """Test the flux switch when it is off.""" - platform = getattr(self.hass.components, 'test.light') - platform.init() - assert setup_component(self.hass, light.DOMAIN, - {light.DOMAIN: {CONF_PLATFORM: 'test'}}) - - dev1 = platform.DEVICES[0] - - # Verify initial state of light - state = self.hass.states.get(dev1.entity_id) - assert STATE_ON == state.state - assert state.attributes.get('xy_color') is None - assert state.attributes.get('brightness') is None - - test_time = dt_util.utcnow().replace(hour=10, minute=30, second=0) - sunset_time = test_time.replace(hour=17, minute=0, second=0) - sunrise_time = test_time.replace(hour=5, minute=0, second=0) - - def event_date(hass, event, now=None): - if event == SUN_EVENT_SUNRISE: - return sunrise_time - return sunset_time - - with patch('homeassistant.util.dt.utcnow', return_value=test_time): - with patch('homeassistant.helpers.sun.get_astral_event_date', - side_effect=event_date): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id] - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() - assert 0 == len(turn_on_calls) - - def test_flux_before_sunrise(self): - """Test the flux switch before sunrise.""" - platform = getattr(self.hass.components, 'test.light') - platform.init() - assert setup_component(self.hass, light.DOMAIN, - {light.DOMAIN: {CONF_PLATFORM: 'test'}}) - - dev1 = platform.DEVICES[0] - - # Verify initial state of light - state = self.hass.states.get(dev1.entity_id) - assert STATE_ON == state.state - assert state.attributes.get('xy_color') is None - assert state.attributes.get('brightness') is None - - test_time = dt_util.utcnow().replace(hour=2, minute=30, second=0) - sunset_time = test_time.replace(hour=17, minute=0, second=0) - sunrise_time = test_time.replace(hour=5, minute=0, second=0) - - def event_date(hass, event, now=None): - if event == SUN_EVENT_SUNRISE: - return sunrise_time - return sunset_time - - with patch('homeassistant.util.dt.utcnow', return_value=test_time): - with patch('homeassistant.helpers.sun.get_astral_event_date', - side_effect=event_date): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id] - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - common.turn_on(self.hass, 'switch.flux') - self.hass.block_till_done() - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() - call = turn_on_calls[-1] - assert call.data[light.ATTR_BRIGHTNESS] == 112 - assert call.data[light.ATTR_XY_COLOR] == [0.606, 0.379] - - # pylint: disable=invalid-name - def test_flux_after_sunrise_before_sunset(self): - """Test the flux switch after sunrise and before sunset.""" - platform = getattr(self.hass.components, 'test.light') - platform.init() - assert setup_component(self.hass, light.DOMAIN, - {light.DOMAIN: {CONF_PLATFORM: 'test'}}) - - dev1 = platform.DEVICES[0] - - # Verify initial state of light - state = self.hass.states.get(dev1.entity_id) - assert STATE_ON == state.state - assert state.attributes.get('xy_color') is None - assert state.attributes.get('brightness') is None - - test_time = dt_util.utcnow().replace(hour=8, minute=30, second=0) - sunset_time = test_time.replace(hour=17, minute=0, second=0) - sunrise_time = test_time.replace(hour=5, minute=0, second=0) - - def event_date(hass, event, now=None): - if event == SUN_EVENT_SUNRISE: - return sunrise_time - return sunset_time - - with patch('homeassistant.components.flux.switch.dt_utcnow', - return_value=test_time), \ - patch('homeassistant.helpers.sun.get_astral_event_date', - side_effect=event_date): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id] - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - common.turn_on(self.hass, 'switch.flux') - self.hass.block_till_done() - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() - call = turn_on_calls[-1] - assert call.data[light.ATTR_BRIGHTNESS] == 173 - assert call.data[light.ATTR_XY_COLOR] == [0.439, 0.37] - - # pylint: disable=invalid-name - def test_flux_after_sunset_before_stop(self): - """Test the flux switch after sunset and before stop.""" - platform = getattr(self.hass.components, 'test.light') - platform.init() - assert setup_component(self.hass, light.DOMAIN, - {light.DOMAIN: {CONF_PLATFORM: 'test'}}) - - dev1 = platform.DEVICES[0] - - # Verify initial state of light - state = self.hass.states.get(dev1.entity_id) - assert STATE_ON == state.state - assert state.attributes.get('xy_color') is None - assert state.attributes.get('brightness') is None - - test_time = dt_util.utcnow().replace(hour=17, minute=30, second=0) - sunset_time = test_time.replace(hour=17, minute=0, second=0) - sunrise_time = test_time.replace(hour=5, minute=0, second=0) - - def event_date(hass, event, now=None): - if event == SUN_EVENT_SUNRISE: - return sunrise_time - return sunset_time - - with patch('homeassistant.components.flux.switch.dt_utcnow', - return_value=test_time), \ - patch('homeassistant.helpers.sun.get_astral_event_date', - side_effect=event_date): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id], - 'stop_time': '22:00' - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - common.turn_on(self.hass, 'switch.flux') - self.hass.block_till_done() - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() - call = turn_on_calls[-1] - assert call.data[light.ATTR_BRIGHTNESS] == 146 - assert call.data[light.ATTR_XY_COLOR] == [0.506, 0.385] - - # pylint: disable=invalid-name - def test_flux_after_stop_before_sunrise(self): - """Test the flux switch after stop and before sunrise.""" - platform = getattr(self.hass.components, 'test.light') - platform.init() - assert setup_component(self.hass, light.DOMAIN, - {light.DOMAIN: {CONF_PLATFORM: 'test'}}) - - dev1 = platform.DEVICES[0] - - # Verify initial state of light - state = self.hass.states.get(dev1.entity_id) - assert STATE_ON == state.state - assert state.attributes.get('xy_color') is None - assert state.attributes.get('brightness') is None - - test_time = dt_util.utcnow().replace(hour=23, minute=30, second=0) - sunset_time = test_time.replace(hour=17, minute=0, second=0) - sunrise_time = test_time.replace(hour=5, minute=0, second=0) - - def event_date(hass, event, now=None): - if event == SUN_EVENT_SUNRISE: - return sunrise_time - return sunset_time - - with patch('homeassistant.util.dt.utcnow', return_value=test_time): - with patch('homeassistant.helpers.sun.get_astral_event_date', - side_effect=event_date): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id] - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - common.turn_on(self.hass, 'switch.flux') - self.hass.block_till_done() - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() - call = turn_on_calls[-1] - assert call.data[light.ATTR_BRIGHTNESS] == 112 - assert call.data[light.ATTR_XY_COLOR] == [0.606, 0.379] - - # pylint: disable=invalid-name - def test_flux_with_custom_start_stop_times(self): - """Test the flux with custom start and stop times.""" - platform = getattr(self.hass.components, 'test.light') - platform.init() - assert setup_component(self.hass, light.DOMAIN, - {light.DOMAIN: {CONF_PLATFORM: 'test'}}) - - dev1 = platform.DEVICES[0] - - # Verify initial state of light - state = self.hass.states.get(dev1.entity_id) - assert STATE_ON == state.state - assert state.attributes.get('xy_color') is None - assert state.attributes.get('brightness') is None - - test_time = dt_util.utcnow().replace(hour=17, minute=30, second=0) - sunset_time = test_time.replace(hour=17, minute=0, second=0) - sunrise_time = test_time.replace(hour=5, minute=0, second=0) - - def event_date(hass, event, now=None): - if event == SUN_EVENT_SUNRISE: - return sunrise_time - return sunset_time - - with patch('homeassistant.components.flux.switch.dt_utcnow', - return_value=test_time), \ - patch('homeassistant.helpers.sun.get_astral_event_date', - side_effect=event_date): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id], - 'start_time': '6:00', - 'stop_time': '23:30' - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - common.turn_on(self.hass, 'switch.flux') - self.hass.block_till_done() - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() - call = turn_on_calls[-1] - assert call.data[light.ATTR_BRIGHTNESS] == 147 - assert call.data[light.ATTR_XY_COLOR] == [0.504, 0.385] - - def test_flux_before_sunrise_stop_next_day(self): - """Test the flux switch before sunrise. - - This test has the stop_time on the next day (after midnight). - """ - platform = getattr(self.hass.components, 'test.light') - platform.init() - assert setup_component(self.hass, light.DOMAIN, - {light.DOMAIN: {CONF_PLATFORM: 'test'}}) - - dev1 = platform.DEVICES[0] - - # Verify initial state of light - state = self.hass.states.get(dev1.entity_id) - assert STATE_ON == state.state - assert state.attributes.get('xy_color') is None - assert state.attributes.get('brightness') is None - - test_time = dt_util.utcnow().replace(hour=2, minute=30, second=0) - sunset_time = test_time.replace(hour=17, minute=0, second=0) - sunrise_time = test_time.replace(hour=5, minute=0, second=0) - - def event_date(hass, event, now=None): - if event == SUN_EVENT_SUNRISE: - return sunrise_time - return sunset_time - - with patch('homeassistant.components.flux.switch.dt_utcnow', - return_value=test_time), \ - patch('homeassistant.helpers.sun.get_astral_event_date', - side_effect=event_date): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id], - 'stop_time': '01:00' - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - common.turn_on(self.hass, 'switch.flux') - self.hass.block_till_done() - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() - call = turn_on_calls[-1] - assert call.data[light.ATTR_BRIGHTNESS] == 112 - assert call.data[light.ATTR_XY_COLOR] == [0.606, 0.379] - - # pylint: disable=invalid-name - def test_flux_after_sunrise_before_sunset_stop_next_day(self): - """ - Test the flux switch after sunrise and before sunset. - - This test has the stop_time on the next day (after midnight). - """ - platform = getattr(self.hass.components, 'test.light') - platform.init() - assert setup_component(self.hass, light.DOMAIN, - {light.DOMAIN: {CONF_PLATFORM: 'test'}}) - - dev1 = platform.DEVICES[0] - - # Verify initial state of light - state = self.hass.states.get(dev1.entity_id) - assert STATE_ON == state.state - assert state.attributes.get('xy_color') is None - assert state.attributes.get('brightness') is None - - test_time = dt_util.utcnow().replace(hour=8, minute=30, second=0) - sunset_time = test_time.replace(hour=17, minute=0, second=0) - sunrise_time = test_time.replace(hour=5, minute=0, second=0) - - def event_date(hass, event, now=None): - if event == SUN_EVENT_SUNRISE: - return sunrise_time - return sunset_time - - with patch('homeassistant.components.flux.switch.dt_utcnow', - return_value=test_time), \ - patch('homeassistant.helpers.sun.get_astral_event_date', - side_effect=event_date): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id], - 'stop_time': '01:00' - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - common.turn_on(self.hass, 'switch.flux') - self.hass.block_till_done() - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() - call = turn_on_calls[-1] - assert call.data[light.ATTR_BRIGHTNESS] == 173 - assert call.data[light.ATTR_XY_COLOR] == [0.439, 0.37] - - # pylint: disable=invalid-name - def test_flux_after_sunset_before_midnight_stop_next_day(self): - """Test the flux switch after sunset and before stop. - - This test has the stop_time on the next day (after midnight). - """ - platform = getattr(self.hass.components, 'test.light') - platform.init() - assert setup_component(self.hass, light.DOMAIN, - {light.DOMAIN: {CONF_PLATFORM: 'test'}}) - - dev1 = platform.DEVICES[0] - - # Verify initial state of light - state = self.hass.states.get(dev1.entity_id) - assert STATE_ON == state.state - assert state.attributes.get('xy_color') is None - assert state.attributes.get('brightness') is None - - test_time = dt_util.utcnow().replace(hour=23, minute=30, second=0) - sunset_time = test_time.replace(hour=17, minute=0, second=0) - sunrise_time = test_time.replace(hour=5, minute=0, second=0) - - def event_date(hass, event, now=None): - if event == SUN_EVENT_SUNRISE: - return sunrise_time - return sunset_time - - with patch('homeassistant.util.dt.utcnow', return_value=test_time): - with patch('homeassistant.helpers.sun.get_astral_event_date', - side_effect=event_date): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id], - 'stop_time': '01:00' - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - common.turn_on(self.hass, 'switch.flux') - self.hass.block_till_done() - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() - call = turn_on_calls[-1] - assert call.data[light.ATTR_BRIGHTNESS] == 119 - assert call.data[light.ATTR_XY_COLOR] == [0.588, 0.386] - - # pylint: disable=invalid-name - def test_flux_after_sunset_after_midnight_stop_next_day(self): - """Test the flux switch after sunset and before stop. - - This test has the stop_time on the next day (after midnight). - """ - platform = getattr(self.hass.components, 'test.light') - platform.init() - assert setup_component(self.hass, light.DOMAIN, - {light.DOMAIN: {CONF_PLATFORM: 'test'}}) - - dev1 = platform.DEVICES[0] - - # Verify initial state of light - state = self.hass.states.get(dev1.entity_id) - assert STATE_ON == state.state - assert state.attributes.get('xy_color') is None - assert state.attributes.get('brightness') is None - - test_time = dt_util.utcnow().replace(hour=00, minute=30, second=0) - sunset_time = test_time.replace(hour=17, minute=0, second=0) - sunrise_time = test_time.replace(hour=5, minute=0, second=0) - - def event_date(hass, event, now=None): - if event == SUN_EVENT_SUNRISE: - return sunrise_time - return sunset_time - - with patch('homeassistant.components.flux.switch.dt_utcnow', - return_value=test_time), \ - patch('homeassistant.helpers.sun.get_astral_event_date', - side_effect=event_date): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id], - 'stop_time': '01:00' - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - common.turn_on(self.hass, 'switch.flux') - self.hass.block_till_done() - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() - call = turn_on_calls[-1] - assert call.data[light.ATTR_BRIGHTNESS] == 114 - assert call.data[light.ATTR_XY_COLOR] == [0.601, 0.382] - - # pylint: disable=invalid-name - def test_flux_after_stop_before_sunrise_stop_next_day(self): - """Test the flux switch after stop and before sunrise. - - This test has the stop_time on the next day (after midnight). - """ - platform = getattr(self.hass.components, 'test.light') - platform.init() - assert setup_component(self.hass, light.DOMAIN, - {light.DOMAIN: {CONF_PLATFORM: 'test'}}) - - dev1 = platform.DEVICES[0] - - # Verify initial state of light - state = self.hass.states.get(dev1.entity_id) - assert STATE_ON == state.state - assert state.attributes.get('xy_color') is None - assert state.attributes.get('brightness') is None - - test_time = dt_util.utcnow().replace(hour=2, minute=30, second=0) - sunset_time = test_time.replace(hour=17, minute=0, second=0) - sunrise_time = test_time.replace(hour=5, minute=0, second=0) - - def event_date(hass, event, now=None): - if event == SUN_EVENT_SUNRISE: - return sunrise_time - return sunset_time - - with patch('homeassistant.components.flux.switch.dt_utcnow', - return_value=test_time), \ - patch('homeassistant.helpers.sun.get_astral_event_date', - side_effect=event_date): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id], - 'stop_time': '01:00' - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - common.turn_on(self.hass, 'switch.flux') - self.hass.block_till_done() - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() - call = turn_on_calls[-1] - assert call.data[light.ATTR_BRIGHTNESS] == 112 - assert call.data[light.ATTR_XY_COLOR] == [0.606, 0.379] - - # pylint: disable=invalid-name - def test_flux_with_custom_colortemps(self): - """Test the flux with custom start and stop colortemps.""" - platform = getattr(self.hass.components, 'test.light') - platform.init() - assert setup_component(self.hass, light.DOMAIN, - {light.DOMAIN: {CONF_PLATFORM: 'test'}}) - - dev1 = platform.DEVICES[0] - - # Verify initial state of light - state = self.hass.states.get(dev1.entity_id) - assert STATE_ON == state.state - assert state.attributes.get('xy_color') is None - assert state.attributes.get('brightness') is None - - test_time = dt_util.utcnow().replace(hour=17, minute=30, second=0) - sunset_time = test_time.replace(hour=17, minute=0, second=0) - sunrise_time = test_time.replace(hour=5, minute=0, second=0) - - def event_date(hass, event, now=None): - if event == SUN_EVENT_SUNRISE: - return sunrise_time - return sunset_time - - with patch('homeassistant.components.flux.switch.dt_utcnow', - return_value=test_time), \ - patch('homeassistant.helpers.sun.get_astral_event_date', - side_effect=event_date): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id], - 'start_colortemp': '1000', - 'stop_colortemp': '6000', - 'stop_time': '22:00' - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - common.turn_on(self.hass, 'switch.flux') - self.hass.block_till_done() - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() - call = turn_on_calls[-1] - assert call.data[light.ATTR_BRIGHTNESS] == 159 - assert call.data[light.ATTR_XY_COLOR] == [0.469, 0.378] - - # pylint: disable=invalid-name - def test_flux_with_custom_brightness(self): - """Test the flux with custom start and stop colortemps.""" - platform = getattr(self.hass.components, 'test.light') - platform.init() - assert setup_component(self.hass, light.DOMAIN, - {light.DOMAIN: {CONF_PLATFORM: 'test'}}) - - dev1 = platform.DEVICES[0] - - # Verify initial state of light - state = self.hass.states.get(dev1.entity_id) - assert STATE_ON == state.state - assert state.attributes.get('xy_color') is None - assert state.attributes.get('brightness') is None - - test_time = dt_util.utcnow().replace(hour=17, minute=30, second=0) - sunset_time = test_time.replace(hour=17, minute=0, second=0) - sunrise_time = test_time.replace(hour=5, minute=0, second=0) - - def event_date(hass, event, now=None): - if event == SUN_EVENT_SUNRISE: - return sunrise_time - return sunset_time - - with patch('homeassistant.components.flux.switch.dt_utcnow', - return_value=test_time), \ - patch('homeassistant.helpers.sun.get_astral_event_date', - side_effect=event_date): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id], - 'brightness': 255, - 'stop_time': '22:00' - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - common.turn_on(self.hass, 'switch.flux') - self.hass.block_till_done() - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() - call = turn_on_calls[-1] - assert call.data[light.ATTR_BRIGHTNESS] == 255 - assert call.data[light.ATTR_XY_COLOR] == [0.506, 0.385] - - def test_flux_with_multiple_lights(self): - """Test the flux switch with multiple light entities.""" - platform = getattr(self.hass.components, 'test.light') - platform.init() - assert setup_component(self.hass, light.DOMAIN, - {light.DOMAIN: {CONF_PLATFORM: 'test'}}) - - dev1, dev2, dev3 = platform.DEVICES - common_light.turn_on(self.hass, entity_id=dev2.entity_id) - self.hass.block_till_done() - common_light.turn_on(self.hass, entity_id=dev3.entity_id) - self.hass.block_till_done() - - state = self.hass.states.get(dev1.entity_id) - assert STATE_ON == state.state - assert state.attributes.get('xy_color') is None - assert state.attributes.get('brightness') is None - - state = self.hass.states.get(dev2.entity_id) - assert STATE_ON == state.state - assert state.attributes.get('xy_color') is None - assert state.attributes.get('brightness') is None - - state = self.hass.states.get(dev3.entity_id) - assert STATE_ON == state.state - assert state.attributes.get('xy_color') is None - assert state.attributes.get('brightness') is None - - test_time = dt_util.utcnow().replace(hour=12, minute=0, second=0) - sunset_time = test_time.replace(hour=17, minute=0, second=0) - sunrise_time = test_time.replace(hour=5, minute=0, second=0) - - def event_date(hass, event, now=None): - if event == SUN_EVENT_SUNRISE: - print('sunrise {}'.format(sunrise_time)) - return sunrise_time - print('sunset {}'.format(sunset_time)) - return sunset_time - - with patch('homeassistant.components.flux.switch.dt_utcnow', - return_value=test_time), \ - patch('homeassistant.helpers.sun.get_astral_event_date', - side_effect=event_date): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id, - dev2.entity_id, - dev3.entity_id] - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - common.turn_on(self.hass, 'switch.flux') - self.hass.block_till_done() - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() - call = turn_on_calls[-1] - assert call.data[light.ATTR_BRIGHTNESS] == 163 - assert call.data[light.ATTR_XY_COLOR] == [0.46, 0.376] - call = turn_on_calls[-2] - assert call.data[light.ATTR_BRIGHTNESS] == 163 - assert call.data[light.ATTR_XY_COLOR] == [0.46, 0.376] - call = turn_on_calls[-3] - assert call.data[light.ATTR_BRIGHTNESS] == 163 - assert call.data[light.ATTR_XY_COLOR] == [0.46, 0.376] - - def test_flux_with_mired(self): - """Test the flux switch´s mode mired.""" - platform = getattr(self.hass.components, 'test.light') - platform.init() - assert setup_component(self.hass, light.DOMAIN, - {light.DOMAIN: {CONF_PLATFORM: 'test'}}) - - dev1 = platform.DEVICES[0] - - # Verify initial state of light - state = self.hass.states.get(dev1.entity_id) - assert STATE_ON == state.state - assert state.attributes.get('color_temp') is None - - test_time = dt_util.utcnow().replace(hour=8, minute=30, second=0) - sunset_time = test_time.replace(hour=17, minute=0, second=0) - sunrise_time = test_time.replace(hour=5, minute=0, second=0) - - def event_date(hass, event, now=None): - if event == SUN_EVENT_SUNRISE: - return sunrise_time - return sunset_time - - with patch('homeassistant.components.flux.switch.dt_utcnow', - return_value=test_time), \ - patch('homeassistant.helpers.sun.get_astral_event_date', - side_effect=event_date): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id], - 'mode': 'mired' - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - common.turn_on(self.hass, 'switch.flux') - self.hass.block_till_done() - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() - call = turn_on_calls[-1] - assert call.data[light.ATTR_COLOR_TEMP] == 269 - - def test_flux_with_rgb(self): - """Test the flux switch´s mode rgb.""" - platform = getattr(self.hass.components, 'test.light') - platform.init() - assert setup_component(self.hass, light.DOMAIN, - {light.DOMAIN: {CONF_PLATFORM: 'test'}}) - - dev1 = platform.DEVICES[0] - - # Verify initial state of light - state = self.hass.states.get(dev1.entity_id) - assert STATE_ON == state.state - assert state.attributes.get('color_temp') is None - - test_time = dt_util.utcnow().replace(hour=8, minute=30, second=0) - sunset_time = test_time.replace(hour=17, minute=0, second=0) - sunrise_time = test_time.replace(hour=5, minute=0, second=0) - - def event_date(hass, event, now=None): - if event == SUN_EVENT_SUNRISE: - return sunrise_time - return sunset_time - - with patch('homeassistant.components.flux.switch.dt_utcnow', - return_value=test_time), \ - patch('homeassistant.helpers.sun.get_astral_event_date', - side_effect=event_date): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id], - 'mode': 'rgb' - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - common.turn_on(self.hass, 'switch.flux') - self.hass.block_till_done() - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() - call = turn_on_calls[-1] - rgb = (255, 198, 152) - rounded_call = tuple(map(round, call.data[light.ATTR_RGB_COLOR])) - assert rounded_call == rgb + turn_on_calls = async_mock_service( + hass, light.DOMAIN, SERVICE_TURN_ON) + common.turn_on(hass, 'switch.flux') + await hass.async_block_till_done() + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() + call = turn_on_calls[-1] + assert call.data[light.ATTR_BRIGHTNESS] == 159 + assert call.data[light.ATTR_XY_COLOR] == [0.469, 0.378] + + +# pylint: disable=invalid-name +async def test_flux_with_custom_brightness(hass): + """Test the flux with custom start and stop colortemps.""" + platform = getattr(hass.components, 'test.light') + platform.init() + assert await async_setup_component(hass, light.DOMAIN, { + light.DOMAIN: {CONF_PLATFORM: 'test'}}) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = hass.states.get(dev1.entity_id) + assert STATE_ON == state.state + assert state.attributes.get('xy_color') is None + assert state.attributes.get('brightness') is None + + test_time = dt_util.utcnow().replace(hour=17, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == SUN_EVENT_SUNRISE: + return sunrise_time + return sunset_time + + with patch('homeassistant.components.flux.switch.dt_utcnow', + return_value=test_time), \ + patch('homeassistant.components.flux.switch.get_astral_event_date', + side_effect=event_date): + assert await async_setup_component(hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'brightness': 255, + 'stop_time': '22:00' + } + }) + turn_on_calls = async_mock_service( + hass, light.DOMAIN, SERVICE_TURN_ON) + common.turn_on(hass, 'switch.flux') + await hass.async_block_till_done() + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() + call = turn_on_calls[-1] + assert call.data[light.ATTR_BRIGHTNESS] == 255 + assert call.data[light.ATTR_XY_COLOR] == [0.506, 0.385] + + +async def test_flux_with_multiple_lights(hass): + """Test the flux switch with multiple light entities.""" + platform = getattr(hass.components, 'test.light') + platform.init() + assert await async_setup_component(hass, light.DOMAIN, { + light.DOMAIN: {CONF_PLATFORM: 'test'}}) + + dev1, dev2, dev3 = platform.DEVICES + common_light.turn_on(hass, entity_id=dev2.entity_id) + await hass.async_block_till_done() + common_light.turn_on(hass, entity_id=dev3.entity_id) + await hass.async_block_till_done() + + state = hass.states.get(dev1.entity_id) + assert STATE_ON == state.state + assert state.attributes.get('xy_color') is None + assert state.attributes.get('brightness') is None + + state = hass.states.get(dev2.entity_id) + assert STATE_ON == state.state + assert state.attributes.get('xy_color') is None + assert state.attributes.get('brightness') is None + + state = hass.states.get(dev3.entity_id) + assert STATE_ON == state.state + assert state.attributes.get('xy_color') is None + assert state.attributes.get('brightness') is None + + test_time = dt_util.utcnow().replace(hour=12, minute=0, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == SUN_EVENT_SUNRISE: + print('sunrise {}'.format(sunrise_time)) + return sunrise_time + print('sunset {}'.format(sunset_time)) + return sunset_time + + with patch('homeassistant.components.flux.switch.dt_utcnow', + return_value=test_time), \ + patch('homeassistant.components.flux.switch.get_astral_event_date', + side_effect=event_date): + assert await async_setup_component(hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [ + dev1.entity_id, + dev2.entity_id, + dev3.entity_id] + } + }) + turn_on_calls = async_mock_service( + hass, light.DOMAIN, SERVICE_TURN_ON) + common.turn_on(hass, 'switch.flux') + await hass.async_block_till_done() + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() + call = turn_on_calls[-1] + assert call.data[light.ATTR_BRIGHTNESS] == 163 + assert call.data[light.ATTR_XY_COLOR] == [0.46, 0.376] + call = turn_on_calls[-2] + assert call.data[light.ATTR_BRIGHTNESS] == 163 + assert call.data[light.ATTR_XY_COLOR] == [0.46, 0.376] + call = turn_on_calls[-3] + assert call.data[light.ATTR_BRIGHTNESS] == 163 + assert call.data[light.ATTR_XY_COLOR] == [0.46, 0.376] + + +async def test_flux_with_mired(hass): + """Test the flux switch´s mode mired.""" + platform = getattr(hass.components, 'test.light') + platform.init() + assert await async_setup_component(hass, light.DOMAIN, { + light.DOMAIN: {CONF_PLATFORM: 'test'}}) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = hass.states.get(dev1.entity_id) + assert STATE_ON == state.state + assert state.attributes.get('color_temp') is None + + test_time = dt_util.utcnow().replace(hour=8, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == SUN_EVENT_SUNRISE: + return sunrise_time + return sunset_time + + with patch('homeassistant.components.flux.switch.dt_utcnow', + return_value=test_time), \ + patch('homeassistant.components.flux.switch.get_astral_event_date', + side_effect=event_date): + assert await async_setup_component(hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'mode': 'mired' + } + }) + turn_on_calls = async_mock_service( + hass, light.DOMAIN, SERVICE_TURN_ON) + common.turn_on(hass, 'switch.flux') + await hass.async_block_till_done() + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() + call = turn_on_calls[-1] + assert call.data[light.ATTR_COLOR_TEMP] == 269 + + +async def test_flux_with_rgb(hass): + """Test the flux switch´s mode rgb.""" + platform = getattr(hass.components, 'test.light') + platform.init() + assert await async_setup_component(hass, light.DOMAIN, { + light.DOMAIN: {CONF_PLATFORM: 'test'}}) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = hass.states.get(dev1.entity_id) + assert STATE_ON == state.state + assert state.attributes.get('color_temp') is None + + test_time = dt_util.utcnow().replace(hour=8, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == SUN_EVENT_SUNRISE: + return sunrise_time + return sunset_time + + with patch('homeassistant.components.flux.switch.dt_utcnow', + return_value=test_time), \ + patch('homeassistant.components.flux.switch.get_astral_event_date', + side_effect=event_date): + assert await async_setup_component(hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'mode': 'rgb' + } + }) + turn_on_calls = async_mock_service( + hass, light.DOMAIN, SERVICE_TURN_ON) + await common.async_turn_on(hass, 'switch.flux') + await hass.async_block_till_done() + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() + call = turn_on_calls[-1] + rgb = (255, 198, 152) + rounded_call = tuple(map(round, call.data[light.ATTR_RGB_COLOR])) + assert rounded_call == rgb diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py index f3732c12213716..c7930f3c62f50f 100644 --- a/tests/components/google_assistant/__init__.py +++ b/tests/components/google_assistant/__init__.py @@ -1,6 +1,33 @@ +"""Tests for the Google Assistant integration.""" +from homeassistant.components.google_assistant import helpers -"""Tests for the Google Assistant integration.""" +class MockConfig(helpers.AbstractConfig): + """Fake config that always exposes everything.""" + + def __init__(self, *, secure_devices_pin=None, should_expose=None, + entity_config=None): + """Initialize config.""" + self._should_expose = should_expose + self._secure_devices_pin = secure_devices_pin + self._entity_config = entity_config or {} + + @property + def secure_devices_pin(self): + """Return secure devices pin.""" + return self._secure_devices_pin + + @property + def entity_config(self): + """Return secure devices pin.""" + return self._entity_config + + def should_expose(self, state): + """Expose it all.""" + return self._should_expose is None or self._should_expose(state) + + +BASIC_CONFIG = MockConfig() DEMO_DEVICES = [{ 'id': diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index a65387d48a2026..cfe7b9466119a0 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -11,7 +11,7 @@ ATTR_MIN_TEMP, ATTR_MAX_TEMP, STATE_HEAT, SUPPORT_OPERATION_MODE ) from homeassistant.components.google_assistant import ( - const, trait, helpers, smart_home as sh, + const, trait, smart_home as sh, EVENT_COMMAND_RECEIVED, EVENT_QUERY_RECEIVED, EVENT_SYNC_RECEIVED) from homeassistant.components.demo.binary_sensor import DemoBinarySensor from homeassistant.components.demo.cover import DemoCover @@ -23,9 +23,8 @@ from tests.common import (mock_device_registry, mock_registry, mock_area_registry, mock_coro) -BASIC_CONFIG = helpers.Config( - should_expose=lambda state: True, -) +from . import BASIC_CONFIG, MockConfig + REQ_ID = 'ff36a3cc-ec34-11e6-b1a0-64510650abcf' @@ -57,7 +56,7 @@ async def test_sync_message(hass): # Excluded via config hass.states.async_set('light.not_expose', 'on') - config = helpers.Config( + config = MockConfig( should_expose=lambda state: state.entity_id != 'light.not_expose', entity_config={ 'light.demo_light': { @@ -145,7 +144,7 @@ async def test_sync_in_area(hass, registries): light.entity_id = entity.entity_id await light.async_update_ha_state() - config = helpers.Config( + config = MockConfig( should_expose=lambda _: True, entity_config={} ) diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 6b1b6a7c9f401b..d2d216a9fc5850 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -29,10 +29,8 @@ from homeassistant.core import State, DOMAIN as HA_DOMAIN, EVENT_CALL_SERVICE from homeassistant.util import color from tests.common import async_mock_service, mock_coro +from . import BASIC_CONFIG, MockConfig -BASIC_CONFIG = helpers.Config( - should_expose=lambda state: True, -) REQ_ID = 'ff36a3cc-ec34-11e6-b1a0-64510650abcf' @@ -42,8 +40,7 @@ REQ_ID, ) -PIN_CONFIG = helpers.Config( - should_expose=lambda state: True, +PIN_CONFIG = MockConfig( secure_devices_pin='1234' ) @@ -927,7 +924,7 @@ async def test_lock_unlock_unlock(hass): # Test with 2FA override with patch('homeassistant.components.google_assistant.helpers' - '.Config.should_2fa', return_value=False): + '.AbstractConfig.should_2fa', return_value=False): await trt.execute( trait.COMMAND_LOCKUNLOCK, BASIC_DATA, {'lock': False}, {}) assert len(calls) == 2 diff --git a/tests/components/group/test_cover.py b/tests/components/group/test_cover.py index 04e8f9c964d1bd..8dd9f9bcbb5aed 100644 --- a/tests/components/group/test_cover.py +++ b/tests/components/group/test_cover.py @@ -11,6 +11,7 @@ ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, ATTR_SUPPORTED_FEATURES, CONF_ENTITIES, SERVICE_CLOSE_COVER, SERVICE_CLOSE_COVER_TILT, + SERVICE_TOGGLE, SERVICE_TOGGLE_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, STATE_OPEN, STATE_CLOSED) @@ -52,11 +53,11 @@ async def test_attributes(hass): state = hass.states.get(COVER_GROUP) assert state.state == STATE_CLOSED - assert state.attributes.get(ATTR_FRIENDLY_NAME) == DEFAULT_NAME - assert state.attributes.get(ATTR_ASSUMED_STATE) is None - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 0 - assert state.attributes.get(ATTR_CURRENT_POSITION) is None - assert state.attributes.get(ATTR_CURRENT_TILT_POSITION) is None + assert state.attributes[ATTR_FRIENDLY_NAME] == DEFAULT_NAME + assert ATTR_ASSUMED_STATE not in state.attributes + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 + assert ATTR_CURRENT_POSITION not in state.attributes + assert ATTR_CURRENT_TILT_POSITION not in state.attributes # Add Entity that supports open / close / stop hass.states.async_set( @@ -65,10 +66,10 @@ async def test_attributes(hass): state = hass.states.get(COVER_GROUP) assert state.state == STATE_OPEN - assert state.attributes.get(ATTR_ASSUMED_STATE) is None - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 11 - assert state.attributes.get(ATTR_CURRENT_POSITION) is None - assert state.attributes.get(ATTR_CURRENT_TILT_POSITION) is None + assert ATTR_ASSUMED_STATE not in state.attributes + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 11 + assert ATTR_CURRENT_POSITION not in state.attributes + assert ATTR_CURRENT_TILT_POSITION not in state.attributes # Add Entity that supports set_cover_position hass.states.async_set( @@ -78,10 +79,10 @@ async def test_attributes(hass): state = hass.states.get(COVER_GROUP) assert state.state == STATE_OPEN - assert state.attributes.get(ATTR_ASSUMED_STATE) is None - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 15 - assert state.attributes.get(ATTR_CURRENT_POSITION) == 70 - assert state.attributes.get(ATTR_CURRENT_TILT_POSITION) is None + assert ATTR_ASSUMED_STATE not in state.attributes + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 15 + assert state.attributes[ATTR_CURRENT_POSITION] == 70 + assert ATTR_CURRENT_TILT_POSITION not in state.attributes # Add Entity that supports open tilt / close tilt / stop tilt hass.states.async_set( @@ -90,10 +91,10 @@ async def test_attributes(hass): state = hass.states.get(COVER_GROUP) assert state.state == STATE_OPEN - assert state.attributes.get(ATTR_ASSUMED_STATE) is None - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 127 - assert state.attributes.get(ATTR_CURRENT_POSITION) == 70 - assert state.attributes.get(ATTR_CURRENT_TILT_POSITION) is None + assert ATTR_ASSUMED_STATE not in state.attributes + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 127 + assert state.attributes[ATTR_CURRENT_POSITION] == 70 + assert ATTR_CURRENT_TILT_POSITION not in state.attributes # Add Entity that supports set_tilt_position hass.states.async_set( @@ -103,10 +104,10 @@ async def test_attributes(hass): state = hass.states.get(COVER_GROUP) assert state.state == STATE_OPEN - assert state.attributes.get(ATTR_ASSUMED_STATE) is None - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 255 - assert state.attributes.get(ATTR_CURRENT_POSITION) == 70 - assert state.attributes.get(ATTR_CURRENT_TILT_POSITION) == 60 + assert ATTR_ASSUMED_STATE not in state.attributes + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 255 + assert state.attributes[ATTR_CURRENT_POSITION] == 70 + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 60 # ### Test assumed state ### # ########################## @@ -119,10 +120,10 @@ async def test_attributes(hass): state = hass.states.get(COVER_GROUP) assert state.state == STATE_OPEN - assert state.attributes.get(ATTR_ASSUMED_STATE) is True - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 244 - assert state.attributes.get(ATTR_CURRENT_POSITION) == 100 - assert state.attributes.get(ATTR_CURRENT_TILT_POSITION) == 60 + assert state.attributes[ATTR_ASSUMED_STATE] is True + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 244 + assert state.attributes[ATTR_CURRENT_POSITION] == 100 + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 60 hass.states.async_remove(DEMO_COVER) hass.states.async_remove(DEMO_COVER_POS) @@ -130,10 +131,10 @@ async def test_attributes(hass): state = hass.states.get(COVER_GROUP) assert state.state == STATE_OPEN - assert state.attributes.get(ATTR_ASSUMED_STATE) is None - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 240 - assert state.attributes.get(ATTR_CURRENT_POSITION) is None - assert state.attributes.get(ATTR_CURRENT_TILT_POSITION) == 60 + assert ATTR_ASSUMED_STATE not in state.attributes + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 240 + assert ATTR_CURRENT_POSITION not in state.attributes + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 60 # For tilts hass.states.async_set( @@ -143,10 +144,10 @@ async def test_attributes(hass): state = hass.states.get(COVER_GROUP) assert state.state == STATE_OPEN - assert state.attributes.get(ATTR_ASSUMED_STATE) is True - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 128 - assert state.attributes.get(ATTR_CURRENT_POSITION) is None - assert state.attributes.get(ATTR_CURRENT_TILT_POSITION) == 100 + assert state.attributes[ATTR_ASSUMED_STATE] is True + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 128 + assert ATTR_CURRENT_POSITION not in state.attributes + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 100 hass.states.async_remove(DEMO_COVER_TILT) hass.states.async_set(DEMO_TILT, STATE_CLOSED) @@ -154,17 +155,17 @@ async def test_attributes(hass): state = hass.states.get(COVER_GROUP) assert state.state == STATE_CLOSED - assert state.attributes.get(ATTR_ASSUMED_STATE) is None - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 0 - assert state.attributes.get(ATTR_CURRENT_POSITION) is None - assert state.attributes.get(ATTR_CURRENT_TILT_POSITION) is None + assert ATTR_ASSUMED_STATE not in state.attributes + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 + assert ATTR_CURRENT_POSITION not in state.attributes + assert ATTR_CURRENT_TILT_POSITION not in state.attributes hass.states.async_set( DEMO_TILT, STATE_CLOSED, {ATTR_ASSUMED_STATE: True}) await hass.async_block_till_done() state = hass.states.get(COVER_GROUP) - assert state.attributes.get(ATTR_ASSUMED_STATE) is True + assert state.attributes[ATTR_ASSUMED_STATE] is True async def test_open_covers(hass, setup_comp): @@ -179,13 +180,13 @@ async def test_open_covers(hass, setup_comp): state = hass.states.get(COVER_GROUP) assert state.state == STATE_OPEN - assert state.attributes.get(ATTR_CURRENT_POSITION) == 100 + assert state.attributes[ATTR_CURRENT_POSITION] == 100 assert hass.states.get(DEMO_COVER).state == STATE_OPEN assert hass.states.get(DEMO_COVER_POS) \ - .attributes.get(ATTR_CURRENT_POSITION) == 100 + .attributes[ATTR_CURRENT_POSITION] == 100 assert hass.states.get(DEMO_COVER_TILT) \ - .attributes.get(ATTR_CURRENT_POSITION) == 100 + .attributes[ATTR_CURRENT_POSITION] == 100 async def test_close_covers(hass, setup_comp): @@ -200,13 +201,66 @@ async def test_close_covers(hass, setup_comp): state = hass.states.get(COVER_GROUP) assert state.state == STATE_CLOSED - assert state.attributes.get(ATTR_CURRENT_POSITION) == 0 + assert state.attributes[ATTR_CURRENT_POSITION] == 0 assert hass.states.get(DEMO_COVER).state == STATE_CLOSED assert hass.states.get(DEMO_COVER_POS) \ - .attributes.get(ATTR_CURRENT_POSITION) == 0 + .attributes[ATTR_CURRENT_POSITION] == 0 assert hass.states.get(DEMO_COVER_TILT) \ - .attributes.get(ATTR_CURRENT_POSITION) == 0 + .attributes[ATTR_CURRENT_POSITION] == 0 + + +async def test_toggle_covers(hass, setup_comp): + """Test toggle cover function.""" + # Start covers in open state + await hass.services.async_call( + DOMAIN, SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: COVER_GROUP}, blocking=True) + for _ in range(10): + future = dt_util.utcnow() + timedelta(seconds=1) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get(COVER_GROUP) + assert state.state == STATE_OPEN + + # Toggle will close covers + await hass.services.async_call( + DOMAIN, SERVICE_TOGGLE, + {ATTR_ENTITY_ID: COVER_GROUP}, blocking=True) + for _ in range(10): + future = dt_util.utcnow() + timedelta(seconds=1) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get(COVER_GROUP) + assert state.state == STATE_CLOSED + assert state.attributes[ATTR_CURRENT_POSITION] == 0 + + assert hass.states.get(DEMO_COVER).state == STATE_CLOSED + assert hass.states.get(DEMO_COVER_POS) \ + .attributes[ATTR_CURRENT_POSITION] == 0 + assert hass.states.get(DEMO_COVER_TILT) \ + .attributes[ATTR_CURRENT_POSITION] == 0 + + # Toggle again will open covers + await hass.services.async_call( + DOMAIN, SERVICE_TOGGLE, + {ATTR_ENTITY_ID: COVER_GROUP}, blocking=True) + for _ in range(10): + future = dt_util.utcnow() + timedelta(seconds=1) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get(COVER_GROUP) + assert state.state == STATE_OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 100 + + assert hass.states.get(DEMO_COVER).state == STATE_OPEN + assert hass.states.get(DEMO_COVER_POS) \ + .attributes[ATTR_CURRENT_POSITION] == 100 + assert hass.states.get(DEMO_COVER_TILT) \ + .attributes[ATTR_CURRENT_POSITION] == 100 async def test_stop_covers(hass, setup_comp): @@ -227,13 +281,13 @@ async def test_stop_covers(hass, setup_comp): state = hass.states.get(COVER_GROUP) assert state.state == STATE_OPEN - assert state.attributes.get(ATTR_CURRENT_POSITION) == 100 + assert state.attributes[ATTR_CURRENT_POSITION] == 100 assert hass.states.get(DEMO_COVER).state == STATE_OPEN assert hass.states.get(DEMO_COVER_POS) \ - .attributes.get(ATTR_CURRENT_POSITION) == 20 + .attributes[ATTR_CURRENT_POSITION] == 20 assert hass.states.get(DEMO_COVER_TILT) \ - .attributes.get(ATTR_CURRENT_POSITION) == 80 + .attributes[ATTR_CURRENT_POSITION] == 80 async def test_set_cover_position(hass, setup_comp): @@ -248,13 +302,13 @@ async def test_set_cover_position(hass, setup_comp): state = hass.states.get(COVER_GROUP) assert state.state == STATE_OPEN - assert state.attributes.get(ATTR_CURRENT_POSITION) == 50 + assert state.attributes[ATTR_CURRENT_POSITION] == 50 assert hass.states.get(DEMO_COVER).state == STATE_CLOSED assert hass.states.get(DEMO_COVER_POS) \ - .attributes.get(ATTR_CURRENT_POSITION) == 50 + .attributes[ATTR_CURRENT_POSITION] == 50 assert hass.states.get(DEMO_COVER_TILT) \ - .attributes.get(ATTR_CURRENT_POSITION) == 50 + .attributes[ATTR_CURRENT_POSITION] == 50 async def test_open_tilts(hass, setup_comp): @@ -269,10 +323,10 @@ async def test_open_tilts(hass, setup_comp): state = hass.states.get(COVER_GROUP) assert state.state == STATE_OPEN - assert state.attributes.get(ATTR_CURRENT_TILT_POSITION) == 100 + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 100 assert hass.states.get(DEMO_COVER_TILT) \ - .attributes.get(ATTR_CURRENT_TILT_POSITION) == 100 + .attributes[ATTR_CURRENT_TILT_POSITION] == 100 async def test_close_tilts(hass, setup_comp): @@ -287,10 +341,61 @@ async def test_close_tilts(hass, setup_comp): state = hass.states.get(COVER_GROUP) assert state.state == STATE_OPEN - assert state.attributes.get(ATTR_CURRENT_TILT_POSITION) == 0 + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 0 + + assert hass.states.get(DEMO_COVER_TILT) \ + .attributes[ATTR_CURRENT_TILT_POSITION] == 0 + + +async def test_toggle_tilts(hass, setup_comp): + """Test toggle tilt function.""" + # Start tilted open + await hass.services.async_call( + DOMAIN, SERVICE_OPEN_COVER_TILT, + {ATTR_ENTITY_ID: COVER_GROUP}, blocking=True) + for _ in range(10): + future = dt_util.utcnow() + timedelta(seconds=1) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get(COVER_GROUP) + assert state.state == STATE_OPEN + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 100 + + assert hass.states.get(DEMO_COVER_TILT) \ + .attributes[ATTR_CURRENT_TILT_POSITION] == 100 + + # Toggle will tilt closed + await hass.services.async_call( + DOMAIN, SERVICE_TOGGLE_COVER_TILT, + {ATTR_ENTITY_ID: COVER_GROUP}, blocking=True) + for _ in range(10): + future = dt_util.utcnow() + timedelta(seconds=1) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get(COVER_GROUP) + assert state.state == STATE_OPEN + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 0 + + assert hass.states.get(DEMO_COVER_TILT) \ + .attributes[ATTR_CURRENT_TILT_POSITION] == 0 + + # Toggle again will tilt open + await hass.services.async_call( + DOMAIN, SERVICE_TOGGLE_COVER_TILT, + {ATTR_ENTITY_ID: COVER_GROUP}, blocking=True) + for _ in range(10): + future = dt_util.utcnow() + timedelta(seconds=1) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get(COVER_GROUP) + assert state.state == STATE_OPEN + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 100 assert hass.states.get(DEMO_COVER_TILT) \ - .attributes.get(ATTR_CURRENT_TILT_POSITION) == 0 + .attributes[ATTR_CURRENT_TILT_POSITION] == 100 async def test_stop_tilts(hass, setup_comp): @@ -311,10 +416,10 @@ async def test_stop_tilts(hass, setup_comp): state = hass.states.get(COVER_GROUP) assert state.state == STATE_OPEN - assert state.attributes.get(ATTR_CURRENT_TILT_POSITION) == 60 + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 60 assert hass.states.get(DEMO_COVER_TILT) \ - .attributes.get(ATTR_CURRENT_TILT_POSITION) == 60 + .attributes[ATTR_CURRENT_TILT_POSITION] == 60 async def test_set_tilt_positions(hass, setup_comp): @@ -329,7 +434,7 @@ async def test_set_tilt_positions(hass, setup_comp): state = hass.states.get(COVER_GROUP) assert state.state == STATE_OPEN - assert state.attributes.get(ATTR_CURRENT_TILT_POSITION) == 80 + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 80 assert hass.states.get(DEMO_COVER_TILT) \ - .attributes.get(ATTR_CURRENT_TILT_POSITION) == 80 + .attributes[ATTR_CURRENT_TILT_POSITION] == 80 diff --git a/tests/components/heos/test_config_flow.py b/tests/components/heos/test_config_flow.py index ade0100dbd6c5c..c1c5e308eaee0a 100644 --- a/tests/components/heos/test_config_flow.py +++ b/tests/components/heos/test_config_flow.py @@ -76,7 +76,7 @@ async def test_create_entry_when_friendly_name_valid(hass, controller): async def test_discovery_shows_create_form(hass, controller, discovery_data): """Test discovery shows form to confirm setup and subsequent abort.""" await hass.config_entries.flow.async_init( - DOMAIN, context={'source': 'discovery'}, + DOMAIN, context={'source': 'ssdp'}, data=discovery_data) await hass.async_block_till_done() assert len(hass.config_entries.flow.async_progress()) == 1 @@ -87,7 +87,7 @@ async def test_discovery_shows_create_form(hass, controller, discovery_data): discovery_data[CONF_HOST] = "127.0.0.2" discovery_data[CONF_NAME] = "Bedroom" await hass.config_entries.flow.async_init( - DOMAIN, context={'source': 'discovery'}, + DOMAIN, context={'source': 'ssdp'}, data=discovery_data) await hass.async_block_till_done() assert len(hass.config_entries.flow.async_progress()) == 1 @@ -103,6 +103,6 @@ async def test_disovery_flow_aborts_already_setup( config_entry.add_to_hass(hass) flow = HeosFlowHandler() flow.hass = hass - result = await flow.async_step_discovery(discovery_data) + result = await flow.async_step_ssdp(discovery_data) assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT assert result['reason'] == 'already_setup' diff --git a/tests/components/heos/test_init.py b/tests/components/heos/test_init.py index b709c89121a7f0..6d8d9b7e78ef26 100644 --- a/tests/components/heos/test_init.py +++ b/tests/components/heos/test_init.py @@ -27,7 +27,8 @@ async def test_async_setup_creates_entry(hass, config): assert entry.data == {CONF_HOST: '127.0.0.1'} -async def test_async_setup_updates_entry(hass, config_entry, config): +async def test_async_setup_updates_entry(hass, config_entry, config, + controller): """Test component setup updates entry from config.""" config[DOMAIN][CONF_HOST] = '127.0.0.2' config_entry.add_to_hass(hass) diff --git a/tests/components/homeassistant/test_init.py b/tests/components/homeassistant/test_init.py index b72589e60e3238..0eeabd252fdcef 100644 --- a/tests/components/homeassistant/test_init.py +++ b/tests/components/homeassistant/test_init.py @@ -10,7 +10,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, STATE_ON, STATE_OFF, SERVICE_HOMEASSISTANT_RESTART, SERVICE_HOMEASSISTANT_STOP, SERVICE_TURN_ON, SERVICE_TURN_OFF, - SERVICE_TOGGLE) + SERVICE_TOGGLE, EVENT_CORE_CONFIG_UPDATE) import homeassistant.components as comps from homeassistant.setup import async_setup_component from homeassistant.components.homeassistant import ( @@ -22,7 +22,7 @@ from tests.common import ( get_test_home_assistant, mock_service, patch_yaml_files, mock_coro, - async_mock_service) + async_mock_service, async_capture_events) def turn_on(hass, entity_id=None, **service_data): @@ -371,3 +371,19 @@ async def test_entity_update(hass): assert len(mock_update.mock_calls) == 1 assert mock_update.mock_calls[0][1][1] == 'light.kitchen' + + +async def test_setting_location(hass): + """Test setting the location.""" + await async_setup_component(hass, 'homeassistant', {}) + events = async_capture_events(hass, EVENT_CORE_CONFIG_UPDATE) + # Just to make sure that we are updating values. + assert hass.config.latitude != 30 + assert hass.config.longitude != 40 + await hass.services.async_call('homeassistant', 'set_location', { + 'latitude': 30, + 'longitude': 40, + }, blocking=True) + assert len(events) == 1 + assert hass.config.latitude == 30 + assert hass.config.longitude == 40 diff --git a/tests/components/homekit_controller/specific_devices/test_aqara_gateway.py b/tests/components/homekit_controller/specific_devices/test_aqara_gateway.py index 0c77aa37196eb5..59b5be938d3268 100644 --- a/tests/components/homekit_controller/specific_devices/test_aqara_gateway.py +++ b/tests/components/homekit_controller/specific_devices/test_aqara_gateway.py @@ -51,4 +51,4 @@ async def test_aqara_gateway_setup(hass): assert device.name == 'Aqara Hub-1563' assert device.model == 'ZHWA11LM' assert device.sw_version == '1.4.7' - assert device.hub_device_id is None + assert device.via_device_id is None diff --git a/tests/components/homekit_controller/specific_devices/test_ecobee3.py b/tests/components/homekit_controller/specific_devices/test_ecobee3.py index 10e01437cda8a3..7848ddaacb8d78 100644 --- a/tests/components/homekit_controller/specific_devices/test_ecobee3.py +++ b/tests/components/homekit_controller/specific_devices/test_ecobee3.py @@ -74,7 +74,7 @@ async def test_ecobee3_setup(hass): assert climate_device.name == 'HomeW' assert climate_device.model == 'ecobee3' assert climate_device.sw_version == '4.2.394' - assert climate_device.hub_device_id is None + assert climate_device.via_device_id is None # Check that an attached sensor has its own device entity that # is linked to the bridge @@ -83,7 +83,7 @@ async def test_ecobee3_setup(hass): assert sensor_device.name == 'Kitchen' assert sensor_device.model == 'REMOTE SENSOR' assert sensor_device.sw_version == '1.0.0' - assert sensor_device.hub_device_id == climate_device.id + assert sensor_device.via_device_id == climate_device.id async def test_ecobee3_setup_from_cache(hass, hass_storage): diff --git a/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py b/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py index 8de3d1587b658c..4f18392948bcd4 100644 --- a/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py +++ b/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py @@ -45,7 +45,7 @@ async def test_koogeek_ls1_setup(hass): assert device.name == 'Koogeek-LS1-20833F' assert device.model == 'LS1' assert device.sw_version == '2.2.15' - assert device.hub_device_id is None + assert device.via_device_id is None @pytest.mark.parametrize('failure_cls', [ diff --git a/tests/components/homekit_controller/specific_devices/test_lennox_e30.py b/tests/components/homekit_controller/specific_devices/test_lennox_e30.py index 9825e1ab4abd32..eb8abbd8f7d2ea 100644 --- a/tests/components/homekit_controller/specific_devices/test_lennox_e30.py +++ b/tests/components/homekit_controller/specific_devices/test_lennox_e30.py @@ -38,4 +38,4 @@ async def test_lennox_e30_setup(hass): # The fixture contains a single accessory - so its a single device # and no bridge - assert device.hub_device_id is None + assert device.via_device_id is None diff --git a/tests/components/light/test_device_automation.py b/tests/components/light/test_device_automation.py new file mode 100644 index 00000000000000..31381bfc29b502 --- /dev/null +++ b/tests/components/light/test_device_automation.py @@ -0,0 +1,128 @@ +"""The test for light device automation.""" +import pytest + +from homeassistant.components import light +from homeassistant.const import ( + STATE_ON, STATE_OFF, CONF_PLATFORM) +from homeassistant.setup import async_setup_component +import homeassistant.components.automation as automation +from homeassistant.components.device_automation import ( + async_get_device_automation_triggers) +from homeassistant.helpers import device_registry + + +from tests.common import ( + MockConfigEntry, async_mock_service, mock_device_registry, mock_registry) + + +@pytest.fixture +def device_reg(hass): + """Return an empty, loaded, registry.""" + return mock_device_registry(hass) + + +@pytest.fixture +def entity_reg(hass): + """Return an empty, loaded, registry.""" + return mock_registry(hass) + + +@pytest.fixture +def calls(hass): + """Track calls to a mock serivce.""" + return async_mock_service(hass, 'test', 'automation') + + +def _same_triggers(a, b): + if len(a) != len(b): + return False + + for d in a: + if d not in b: + return False + return True + + +async def test_get_triggers(hass, device_reg, entity_reg): + """Test we get the expected triggers from a light.""" + config_entry = MockConfigEntry(domain='test', data={}) + config_entry.add_to_hass(hass) + device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={ + (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF') + }) + entity_reg.async_get_or_create( + 'light', 'test', '5678', device_id=device_entry.id) + expected_triggers = [ + {'platform': 'device', 'domain': 'light', 'type': 'turn_off', + 'device_id': device_entry.id, 'entity_id': 'light.test_5678'}, + {'platform': 'device', 'domain': 'light', 'type': 'turn_on', + 'device_id': device_entry.id, 'entity_id': 'light.test_5678'}, + ] + triggers = await async_get_device_automation_triggers(hass, + device_entry.id) + assert _same_triggers(triggers, expected_triggers) + + +async def test_if_fires_on_state_change(hass, calls): + """Test for turn_on and turn_off triggers firing.""" + platform = getattr(hass.components, 'test.light') + + platform.init() + assert await async_setup_component(hass, light.DOMAIN, + {light.DOMAIN: {CONF_PLATFORM: 'test'}}) + + dev1, dev2, dev3 = platform.DEVICES + + assert await async_setup_component(hass, automation.DOMAIN, { + automation.DOMAIN: [{ + 'trigger': { + 'platform': 'device', + 'domain': light.DOMAIN, + 'entity_id': dev1.entity_id, + 'type': 'turn_on' + }, + 'action': { + 'service': 'test.automation', + 'data_template': { + 'some': + 'turn_on {{ trigger.%s }}' % '}} - {{ trigger.'.join(( + 'platform', 'entity_id', + 'from_state.state', 'to_state.state', + 'for')) + }, + }}, + {'trigger': { + 'platform': 'device', + 'domain': light.DOMAIN, + 'entity_id': dev1.entity_id, + 'type': 'turn_off' + }, + 'action': { + 'service': 'test.automation', + 'data_template': { + 'some': + 'turn_off {{ trigger.%s }}' % '}} - {{ trigger.'.join(( + 'platform', 'entity_id', + 'from_state.state', 'to_state.state', + 'for')) + }, + }}, + ] + }) + await hass.async_block_till_done() + assert hass.states.get(dev1.entity_id).state == STATE_ON + assert len(calls) == 0 + + hass.states.async_set(dev1.entity_id, STATE_OFF) + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data['some'] == \ + 'turn_off state - {} - on - off - None'.format(dev1.entity_id) + + hass.states.async_set(dev1.entity_id, STATE_ON) + await hass.async_block_till_done() + assert len(calls) == 2 + assert calls[1].data['some'] == \ + 'turn_on state - {} - off - on - None'.format(dev1.entity_id) diff --git a/tests/components/locative/test_init.py b/tests/components/locative/test_init.py index 81248764971713..ba96789007b731 100644 --- a/tests/components/locative/test_init.py +++ b/tests/components/locative/test_init.py @@ -242,6 +242,43 @@ async def test_exit_first(hass, locative_client, webhook_id): assert state.state == 'not_home' +async def test_two_devices(hass, locative_client, webhook_id): + """Test updating two different devices.""" + url = '/api/webhook/{}'.format(webhook_id) + + data_device_1 = { + 'latitude': 40.7855, + 'longitude': -111.7367, + 'device': 'device_1', + 'id': 'Home', + 'trigger': 'exit' + } + + # Exit Home + req = await locative_client.post(url, data=data_device_1) + await hass.async_block_till_done() + assert req.status == HTTP_OK + + state = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN, + data_device_1['device'])) + assert state.state == 'not_home' + + # Enter Home + data_device_2 = dict(data_device_1) + data_device_2['device'] = 'device_2' + data_device_2['trigger'] = 'enter' + req = await locative_client.post(url, data=data_device_2) + await hass.async_block_till_done() + assert req.status == HTTP_OK + + state = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN, + data_device_2['device'])) + assert state.state == 'home' + state = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN, + data_device_1['device'])) + assert state.state == 'not_home' + + @pytest.mark.xfail( reason='The device_tracker component does not support unloading yet.' ) diff --git a/tests/components/manual/test_alarm_control_panel.py b/tests/components/manual/test_alarm_control_panel.py index f0f1072085363a..797b632ab15896 100644 --- a/tests/components/manual/test_alarm_control_panel.py +++ b/tests/components/manual/test_alarm_control_panel.py @@ -48,6 +48,30 @@ async def test_arm_home_no_pending(hass): hass.states.get(entity_id).state +async def test_arm_home_no_pending_when_code_not_req(hass): + """Test arm home method.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'code_arm_required': False, + 'pending_time': 0, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + await common.async_alarm_arm_home(hass, 0) + + assert STATE_ALARM_ARMED_HOME == \ + hass.states.get(entity_id).state + + async def test_arm_home_with_pending(hass): """Test arm home method.""" assert await async_setup_component( @@ -129,6 +153,30 @@ async def test_arm_away_no_pending(hass): hass.states.get(entity_id).state +async def test_arm_away_no_pending_when_code_not_req(hass): + """Test arm home method.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'code_arm_required': False, + 'pending_time': 0, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + await common.async_alarm_arm_away(hass, 0, entity_id) + + assert STATE_ALARM_ARMED_AWAY == \ + hass.states.get(entity_id).state + + async def test_arm_home_with_template_code(hass): """Attempt to arm with a template-based code.""" assert await async_setup_component( @@ -233,6 +281,30 @@ async def test_arm_night_no_pending(hass): hass.states.get(entity_id).state +async def test_arm_night_no_pending_when_code_not_req(hass): + """Test arm night method.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'code_arm_required': False, + 'pending_time': 0, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + await common.async_alarm_arm_night(hass, 0) + + assert STATE_ALARM_ARMED_NIGHT == \ + hass.states.get(entity_id).state + + async def test_arm_night_with_pending(hass): """Test arm night method.""" assert await async_setup_component( @@ -1128,6 +1200,30 @@ async def test_arm_custom_bypass_no_pending(hass): hass.states.get(entity_id).state +async def test_arm_custom_bypass_no_pending_when_code_not_req(hass): + """Test arm custom bypass method.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'code_arm_required': False, + 'pending_time': 0, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + await common.async_alarm_arm_custom_bypass(hass, 0) + + assert STATE_ALARM_ARMED_CUSTOM_BYPASS == \ + hass.states.get(entity_id).state + + async def test_arm_custom_bypass_with_pending(hass): """Test arm custom bypass method.""" assert await async_setup_component( diff --git a/tests/components/manual_mqtt/test_alarm_control_panel.py b/tests/components/manual_mqtt/test_alarm_control_panel.py index f5558331bce60c..a20041401410ea 100644 --- a/tests/components/manual_mqtt/test_alarm_control_panel.py +++ b/tests/components/manual_mqtt/test_alarm_control_panel.py @@ -77,6 +77,32 @@ def test_arm_home_no_pending(self): assert STATE_ALARM_ARMED_HOME == \ self.hass.states.get(entity_id).state + def test_arm_home_no_pending_when_code_not_req(self): + """Test arm home method.""" + assert setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'code': CODE, + 'code_arm_required': False, + 'pending_time': 0, + 'disarm_after_trigger': False, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state', + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + self.hass.states.get(entity_id).state + + common.alarm_arm_home(self.hass, 0) + self.hass.block_till_done() + + assert STATE_ALARM_ARMED_HOME == \ + self.hass.states.get(entity_id).state + def test_arm_home_with_pending(self): """Test arm home method.""" assert setup_component( @@ -164,6 +190,32 @@ def test_arm_away_no_pending(self): assert STATE_ALARM_ARMED_AWAY == \ self.hass.states.get(entity_id).state + def test_arm_away_no_pending_when_code_not_req(self): + """Test arm home method.""" + assert setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'code_arm_required': False, + 'code': CODE, + 'pending_time': 0, + 'disarm_after_trigger': False, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state', + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + self.hass.states.get(entity_id).state + + common.alarm_arm_away(self.hass, 0, entity_id) + self.hass.block_till_done() + + assert STATE_ALARM_ARMED_AWAY == \ + self.hass.states.get(entity_id).state + def test_arm_home_with_template_code(self): """Attempt to arm with a template-based code.""" assert setup_component( @@ -279,6 +331,32 @@ def test_arm_night_no_pending(self): assert STATE_ALARM_ARMED_NIGHT == \ self.hass.states.get(entity_id).state + def test_arm_night_no_pending_when_code_not_req(self): + """Test arm night method.""" + assert setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'code_arm_required': False, + 'code': CODE, + 'pending_time': 0, + 'disarm_after_trigger': False, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state', + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + self.hass.states.get(entity_id).state + + common.alarm_arm_night(self.hass, 0, entity_id) + self.hass.block_till_done() + + assert STATE_ALARM_ARMED_NIGHT == \ + self.hass.states.get(entity_id).state + def test_arm_night_with_pending(self): """Test arm night method.""" assert setup_component( diff --git a/tests/components/met/__init__.py b/tests/components/met/__init__.py new file mode 100644 index 00000000000000..658ed2901ec7ea --- /dev/null +++ b/tests/components/met/__init__.py @@ -0,0 +1 @@ +"""Tests for Met.no.""" diff --git a/tests/components/met/conftest.py b/tests/components/met/conftest.py new file mode 100644 index 00000000000000..47df348102e91d --- /dev/null +++ b/tests/components/met/conftest.py @@ -0,0 +1,24 @@ +"""Fixtures for Met weather testing.""" +from unittest.mock import patch + +import pytest + +from tests.common import mock_coro + + +@pytest.fixture +def mock_weather(): + """Mock weather data.""" + with patch('metno.MetWeatherData') as mock_data: + mock_data = mock_data.return_value + mock_data.fetching_data.side_effect = lambda: mock_coro(True) + mock_data.get_current_weather.return_value = { + 'condition': 'cloudy', + 'temperature': 15, + 'pressure': 100, + 'humidity': 50, + 'wind_speed': 10, + 'wind_bearing': 'NE', + } + mock_data.get_forecast.return_value = {} + yield mock_data diff --git a/tests/components/met/test_config_flow.py b/tests/components/met/test_config_flow.py new file mode 100644 index 00000000000000..b74cc6e9efeaf8 --- /dev/null +++ b/tests/components/met/test_config_flow.py @@ -0,0 +1,149 @@ +"""Tests for Met.no config flow.""" +from unittest.mock import Mock, patch + +from tests.common import MockConfigEntry, mock_coro + +from homeassistant.const import CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.components.met import config_flow + + +async def test_show_config_form(): + """Test show configuration form.""" + hass = Mock() + flow = config_flow.MetFlowHandler() + flow.hass = hass + + result = await flow._show_config_form() + + assert result['type'] == 'form' + assert result['step_id'] == 'user' + + +async def test_show_config_form_default_values(): + """Test show configuration form.""" + hass = Mock() + flow = config_flow.MetFlowHandler() + flow.hass = hass + + result = await flow._show_config_form( + name="test", latitude='0', longitude='0', elevation='0') + + assert result['type'] == 'form' + assert result['step_id'] == 'user' + + +async def test_flow_with_home_location(hass): + """Test config flow . + + Tests the flow when a default location is configured + then it should return a form with default values + """ + flow = config_flow.MetFlowHandler() + flow.hass = hass + + hass.config.location_name = 'Home' + hass.config.latitude = 1 + hass.config.longitude = 1 + hass.config.elevation = 1 + + result = await flow.async_step_user() + assert result['type'] == 'form' + assert result['step_id'] == 'user' + + +async def test_flow_show_form(): + """Test show form scenarios first time. + + Test when the form should show when no configurations exists + """ + hass = Mock() + flow = config_flow.MetFlowHandler() + flow.hass = hass + + with \ + patch.object(flow, '_show_config_form', + return_value=mock_coro()) as config_form: + await flow.async_step_user() + assert len(config_form.mock_calls) == 1 + + +async def test_flow_entry_created_from_user_input(): + """Test that create data from user input. + + Test when the form should show when no configurations exists + """ + hass = Mock() + flow = config_flow.MetFlowHandler() + flow.hass = hass + + test_data = { + 'name': 'home', + CONF_LONGITUDE: '0', + CONF_LATITUDE: '0', + CONF_ELEVATION: '0' + } + + # Test that entry created when user_input name not exists + with \ + patch.object(flow, '_show_config_form', + return_value=mock_coro()) as config_form,\ + patch.object(flow.hass.config_entries, 'async_entries', + return_value=mock_coro()) as config_entries: + + result = await flow.async_step_user(user_input=test_data) + + assert result['type'] == 'create_entry' + assert result['data'] == test_data + assert len(config_entries.mock_calls) == 1 + assert not config_form.mock_calls + + +async def test_flow_entry_config_entry_already_exists(): + """Test that create data from user input and config_entry already exists. + + Test when the form should show when user puts existing name + in the config gui. Then the form should show with error + """ + hass = Mock() + + flow = config_flow.MetFlowHandler() + flow.hass = hass + + first_entry = MockConfigEntry(domain='met') + first_entry.data['name'] = 'home' + first_entry.add_to_hass(hass) + + test_data = { + 'name': 'home', + CONF_LONGITUDE: '0', + CONF_LATITUDE: '0', + CONF_ELEVATION: '0' + } + + with \ + patch.object(flow, '_show_config_form', + return_value=mock_coro()) as config_form,\ + patch.object(flow.hass.config_entries, 'async_entries', + return_value=[first_entry]) as config_entries: + + await flow.async_step_user(user_input=test_data) + + assert len(config_form.mock_calls) == 1 + assert len(config_entries.mock_calls) == 1 + assert len(flow._errors) == 1 + + +async def test_onboarding_step(hass, mock_weather): + """Test initializing via onboarding step.""" + hass = Mock() + + flow = config_flow.MetFlowHandler() + flow.hass = hass + + result = await flow.async_step_onboarding({}) + + assert result['type'] == 'create_entry' + assert result['title'] == 'Home' + assert result['data'] == { + 'track_home': True, + } diff --git a/tests/components/met/test_weather.py b/tests/components/met/test_weather.py new file mode 100644 index 00000000000000..14f7fa2bfbe828 --- /dev/null +++ b/tests/components/met/test_weather.py @@ -0,0 +1,52 @@ +"""Test Met weather entity.""" + + +async def test_tracking_home(hass, mock_weather): + """Test we track home.""" + await hass.config_entries.flow.async_init('met', context={ + 'source': 'onboarding' + }) + await hass.async_block_till_done() + assert len(hass.states.async_entity_ids('weather')) == 1 + assert len(mock_weather.mock_calls) == 3 + + # Test we track config + await hass.config.async_update( + latitude=10, + longitude=20, + ) + await hass.async_block_till_done() + + assert len(mock_weather.mock_calls) == 6 + + entry = hass.config_entries.async_entries()[0] + await hass.config_entries.async_remove(entry.entry_id) + assert len(hass.states.async_entity_ids('weather')) == 0 + + +async def test_not_tracking_home(hass, mock_weather): + """Test when we not track home.""" + await hass.config_entries.flow.async_init('met', context={ + 'source': 'user' + }, data={ + 'name': 'Somewhere', + 'latitude': 10, + 'longitude': 20, + 'elevation': 0, + }) + await hass.async_block_till_done() + assert len(hass.states.async_entity_ids('weather')) == 1 + assert len(mock_weather.mock_calls) == 3 + + # Test we do not track config + await hass.config.async_update( + latitude=10, + longitude=20, + ) + await hass.async_block_till_done() + + assert len(mock_weather.mock_calls) == 3 + + entry = hass.config_entries.async_entries()[0] + await hass.config_entries.async_remove(entry.entry_id) + assert len(hass.states.async_entity_ids('weather')) == 0 diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py index 8bf136c6f0fff3..6ffc0df48096e7 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -11,7 +11,7 @@ SERVICE_CLOSE_COVER_TILT, SERVICE_OPEN_COVER, SERVICE_OPEN_COVER_TILT, SERVICE_SET_COVER_POSITION, SERVICE_SET_COVER_TILT_POSITION, SERVICE_STOP_COVER, STATE_CLOSED, STATE_OPEN, STATE_UNAVAILABLE, - STATE_UNKNOWN) + SERVICE_TOGGLE, SERVICE_TOGGLE_COVER_TILT, STATE_UNKNOWN) from homeassistant.setup import async_setup_component from tests.common import ( @@ -174,6 +174,26 @@ async def test_optimistic_state_change(hass, mqtt_mock): cover.DOMAIN, SERVICE_CLOSE_COVER, {ATTR_ENTITY_ID: 'cover.test'}, blocking=True) + mqtt_mock.async_publish.assert_called_once_with( + 'command-topic', 'CLOSE', 0, False) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get('cover.test') + assert STATE_CLOSED == state.state + + await hass.services.async_call( + cover.DOMAIN, SERVICE_TOGGLE, + {ATTR_ENTITY_ID: 'cover.test'}, blocking=True) + + mqtt_mock.async_publish.assert_called_once_with( + 'command-topic', 'OPEN', 0, False) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get('cover.test') + assert STATE_OPEN == state.state + + await hass.services.async_call( + cover.DOMAIN, SERVICE_TOGGLE, + {ATTR_ENTITY_ID: 'cover.test'}, blocking=True) + mqtt_mock.async_publish.assert_called_once_with( 'command-topic', 'CLOSE', 0, False) state = hass.states.get('cover.test') @@ -534,6 +554,36 @@ async def test_tilt_via_invocation_defaults(hass, mqtt_mock): mqtt_mock.async_publish.assert_called_once_with( 'tilt-command-topic', 0, 0, False) + mqtt_mock.async_publish.reset_mock() + + # Close tilt status would be received from device when non-optimistic + async_fire_mqtt_message(hass, 'tilt-status-topic', '0') + + current_cover_tilt_position = hass.states.get( + 'cover.test').attributes['current_tilt_position'] + assert current_cover_tilt_position == 0 + + await hass.services.async_call( + cover.DOMAIN, SERVICE_TOGGLE_COVER_TILT, + {ATTR_ENTITY_ID: 'cover.test'}, blocking=True) + + mqtt_mock.async_publish.assert_called_once_with( + 'tilt-command-topic', 100, 0, False) + mqtt_mock.async_publish.reset_mock() + + # Open tilt status would be received from device when non-optimistic + async_fire_mqtt_message(hass, 'tilt-status-topic', '100') + + current_cover_tilt_position = hass.states.get( + 'cover.test').attributes['current_tilt_position'] + assert current_cover_tilt_position == 100 + + await hass.services.async_call( + cover.DOMAIN, SERVICE_TOGGLE_COVER_TILT, + {ATTR_ENTITY_ID: 'cover.test'}, blocking=True) + + mqtt_mock.async_publish.assert_called_once_with( + 'tilt-command-topic', 0, 0, False) async def test_tilt_given_value(hass, mqtt_mock): @@ -550,29 +600,191 @@ async def test_tilt_given_value(hass, mqtt_mock): 'payload_stop': 'STOP', 'tilt_command_topic': 'tilt-command-topic', 'tilt_status_topic': 'tilt-status-topic', - 'tilt_opened_value': 400, - 'tilt_closed_value': 125 + 'tilt_opened_value': 80, + 'tilt_closed_value': 25 } }) await hass.services.async_call( - cover.DOMAIN, SERVICE_OPEN_COVER_TILT, {ATTR_ENTITY_ID: 'cover.test'}, - blocking=True) + cover.DOMAIN, SERVICE_OPEN_COVER_TILT, + {ATTR_ENTITY_ID: 'cover.test'}, blocking=True) mqtt_mock.async_publish.assert_called_once_with( - 'tilt-command-topic', 400, 0, False) + 'tilt-command-topic', 80, 0, False) mqtt_mock.async_publish.reset_mock() await hass.services.async_call( - cover.DOMAIN, SERVICE_CLOSE_COVER_TILT, {ATTR_ENTITY_ID: 'cover.test'}, - blocking=True) + cover.DOMAIN, SERVICE_CLOSE_COVER_TILT, + {ATTR_ENTITY_ID: 'cover.test'}, blocking=True) + + mqtt_mock.async_publish.assert_called_once_with( + 'tilt-command-topic', 25, 0, False) + mqtt_mock.async_publish.reset_mock() + + # Close tilt status would be received from device when non-optimistic + async_fire_mqtt_message(hass, 'tilt-status-topic', '25') + + current_cover_tilt_position = hass.states.get( + 'cover.test').attributes['current_tilt_position'] + assert current_cover_tilt_position == 25 + + await hass.services.async_call( + cover.DOMAIN, SERVICE_TOGGLE_COVER_TILT, + {ATTR_ENTITY_ID: 'cover.test'}, blocking=True) mqtt_mock.async_publish.assert_called_once_with( - 'tilt-command-topic', 125, 0, False) + 'tilt-command-topic', 80, 0, False) + mqtt_mock.async_publish.reset_mock() + + # Open tilt status would be received from device when non-optimistic + async_fire_mqtt_message(hass, 'tilt-status-topic', '80') + + current_cover_tilt_position = hass.states.get( + 'cover.test').attributes['current_tilt_position'] + assert current_cover_tilt_position == 80 + + await hass.services.async_call( + cover.DOMAIN, SERVICE_TOGGLE_COVER_TILT, + {ATTR_ENTITY_ID: 'cover.test'}, blocking=True) + + mqtt_mock.async_publish.assert_called_once_with( + 'tilt-command-topic', 25, 0, False) + + +async def test_tilt_given_value_optimistic(hass, mqtt_mock): + """Test tilting to a given value.""" + assert await async_setup_component(hass, cover.DOMAIN, { + cover.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'state-topic', + 'command_topic': 'command-topic', + 'qos': 0, + 'payload_open': 'OPEN', + 'payload_close': 'CLOSE', + 'payload_stop': 'STOP', + 'tilt_command_topic': 'tilt-command-topic', + 'tilt_status_topic': 'tilt-status-topic', + 'tilt_opened_value': 80, + 'tilt_closed_value': 25, + 'tilt_optimistic': True + } + }) + + await hass.services.async_call( + cover.DOMAIN, SERVICE_OPEN_COVER_TILT, + {ATTR_ENTITY_ID: 'cover.test'}, blocking=True) + + current_cover_tilt_position = hass.states.get( + 'cover.test').attributes['current_tilt_position'] + assert current_cover_tilt_position == 80 + + mqtt_mock.async_publish.assert_called_once_with( + 'tilt-command-topic', 80, 0, False) + mqtt_mock.async_publish.reset_mock() + + await hass.services.async_call( + cover.DOMAIN, SERVICE_CLOSE_COVER_TILT, + {ATTR_ENTITY_ID: 'cover.test'}, blocking=True) + + current_cover_tilt_position = hass.states.get( + 'cover.test').attributes['current_tilt_position'] + assert current_cover_tilt_position == 25 + + mqtt_mock.async_publish.assert_called_once_with( + 'tilt-command-topic', 25, 0, False) + + +async def test_tilt_given_value_altered_range(hass, mqtt_mock): + """Test tilting to a given value.""" + assert await async_setup_component(hass, cover.DOMAIN, { + cover.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'state-topic', + 'command_topic': 'command-topic', + 'qos': 0, + 'payload_open': 'OPEN', + 'payload_close': 'CLOSE', + 'payload_stop': 'STOP', + 'tilt_command_topic': 'tilt-command-topic', + 'tilt_status_topic': 'tilt-status-topic', + 'tilt_opened_value': 25, + 'tilt_closed_value': 0, + 'tilt_min': 0, + 'tilt_max': 50, + 'tilt_optimistic': True + } + }) + + await hass.services.async_call( + cover.DOMAIN, SERVICE_OPEN_COVER_TILT, + {ATTR_ENTITY_ID: 'cover.test'}, blocking=True) + + current_cover_tilt_position = hass.states.get( + 'cover.test').attributes['current_tilt_position'] + assert current_cover_tilt_position == 50 + + mqtt_mock.async_publish.assert_called_once_with( + 'tilt-command-topic', 25, 0, False) + mqtt_mock.async_publish.reset_mock() + + await hass.services.async_call( + cover.DOMAIN, SERVICE_CLOSE_COVER_TILT, + {ATTR_ENTITY_ID: 'cover.test'}, blocking=True) + + current_cover_tilt_position = hass.states.get( + 'cover.test').attributes['current_tilt_position'] + assert current_cover_tilt_position == 0 + + mqtt_mock.async_publish.assert_called_once_with( + 'tilt-command-topic', 0, 0, False) + mqtt_mock.async_publish.reset_mock() + + await hass.services.async_call( + cover.DOMAIN, SERVICE_TOGGLE_COVER_TILT, + {ATTR_ENTITY_ID: 'cover.test'}, blocking=True) + + current_cover_tilt_position = hass.states.get( + 'cover.test').attributes['current_tilt_position'] + assert current_cover_tilt_position == 50 + + mqtt_mock.async_publish.assert_called_once_with( + 'tilt-command-topic', 25, 0, False) async def test_tilt_via_topic(hass, mqtt_mock): """Test tilt by updating status via MQTT.""" + assert await async_setup_component(hass, cover.DOMAIN, { + cover.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'state-topic', + 'command_topic': 'command-topic', + 'qos': 0, + 'payload_open': 'OPEN', + 'payload_close': 'CLOSE', + 'payload_stop': 'STOP', + 'tilt_command_topic': 'tilt-command-topic', + 'tilt_status_topic': 'tilt-status-topic' + } + }) + + async_fire_mqtt_message(hass, 'tilt-status-topic', '0') + + current_cover_tilt_position = hass.states.get( + 'cover.test').attributes['current_tilt_position'] + assert current_cover_tilt_position == 0 + + async_fire_mqtt_message(hass, 'tilt-status-topic', '50') + + current_cover_tilt_position = hass.states.get( + 'cover.test').attributes['current_tilt_position'] + assert current_cover_tilt_position == 50 + + +async def test_tilt_via_topic_template(hass, mqtt_mock): + """Test tilt by updating status via MQTT and template.""" assert await async_setup_component(hass, cover.DOMAIN, { cover.DOMAIN: { 'platform': 'mqtt', @@ -585,18 +797,19 @@ async def test_tilt_via_topic(hass, mqtt_mock): 'payload_stop': 'STOP', 'tilt_command_topic': 'tilt-command-topic', 'tilt_status_topic': 'tilt-status-topic', + 'tilt_status_template': '{{ (value | multiply(0.01)) | int }}', 'tilt_opened_value': 400, 'tilt_closed_value': 125 } }) - async_fire_mqtt_message(hass, 'tilt-status-topic', '0') + async_fire_mqtt_message(hass, 'tilt-status-topic', '99') current_cover_tilt_position = hass.states.get( 'cover.test').attributes['current_tilt_position'] assert current_cover_tilt_position == 0 - async_fire_mqtt_message(hass, 'tilt-status-topic', '50') + async_fire_mqtt_message(hass, 'tilt-status-topic', '5000') current_cover_tilt_position = hass.states.get( 'cover.test').attributes['current_tilt_position'] @@ -617,8 +830,6 @@ async def test_tilt_via_topic_altered_range(hass, mqtt_mock): 'payload_stop': 'STOP', 'tilt_command_topic': 'tilt-command-topic', 'tilt_status_topic': 'tilt-status-topic', - 'tilt_opened_value': 400, - 'tilt_closed_value': 125, 'tilt_min': 0, 'tilt_max': 50 } @@ -643,8 +854,8 @@ async def test_tilt_via_topic_altered_range(hass, mqtt_mock): assert current_cover_tilt_position == 50 -async def test_tilt_position(hass, mqtt_mock): - """Test tilt via method invocation.""" +async def test_tilt_via_topic_template_altered_range(hass, mqtt_mock): + """Test tilt status via MQTT and template with altered tilt range.""" assert await async_setup_component(hass, cover.DOMAIN, { cover.DOMAIN: { 'platform': 'mqtt', @@ -657,8 +868,47 @@ async def test_tilt_position(hass, mqtt_mock): 'payload_stop': 'STOP', 'tilt_command_topic': 'tilt-command-topic', 'tilt_status_topic': 'tilt-status-topic', + 'tilt_status_template': '{{ (value | multiply(0.01)) | int }}', 'tilt_opened_value': 400, - 'tilt_closed_value': 125 + 'tilt_closed_value': 125, + 'tilt_min': 0, + 'tilt_max': 50 + } + }) + + async_fire_mqtt_message(hass, 'tilt-status-topic', '99') + + current_cover_tilt_position = hass.states.get( + 'cover.test').attributes['current_tilt_position'] + assert current_cover_tilt_position == 0 + + async_fire_mqtt_message(hass, 'tilt-status-topic', '5000') + + current_cover_tilt_position = hass.states.get( + 'cover.test').attributes['current_tilt_position'] + assert current_cover_tilt_position == 100 + + async_fire_mqtt_message(hass, 'tilt-status-topic', '2500') + + current_cover_tilt_position = hass.states.get( + 'cover.test').attributes['current_tilt_position'] + assert current_cover_tilt_position == 50 + + +async def test_tilt_position(hass, mqtt_mock): + """Test tilt via method invocation.""" + assert await async_setup_component(hass, cover.DOMAIN, { + cover.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'state-topic', + 'command_topic': 'command-topic', + 'qos': 0, + 'payload_open': 'OPEN', + 'payload_close': 'CLOSE', + 'payload_stop': 'STOP', + 'tilt_command_topic': 'tilt-command-topic', + 'tilt_status_topic': 'tilt-status-topic' } }) diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index b0d1de36efea0b..a9310894019d98 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -232,7 +232,7 @@ def test_entity_device_info_schema(self): 'model': 'Glass', 'sw_version': '0.1-beta', }) - # full device info with via_hub + # full device info with via_device mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA({ 'identifiers': ['helloworld', 'hello'], 'connections': [ @@ -243,7 +243,7 @@ def test_entity_device_info_schema(self): 'name': 'Beer', 'model': 'Glass', 'sw_version': '0.1-beta', - 'via_hub': 'test-hub', + 'via_device': 'test-hub', }) # no identifiers with pytest.raises(vol.Invalid): diff --git a/tests/components/mqtt/test_legacy_vacuum.py b/tests/components/mqtt/test_legacy_vacuum.py index 8beceb7d6606cb..f8bef17554093e 100644 --- a/tests/components/mqtt/test_legacy_vacuum.py +++ b/tests/components/mqtt/test_legacy_vacuum.py @@ -612,7 +612,6 @@ async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): vacuum.DOMAIN: { 'platform': 'mqtt', 'name': 'test', - 'state_topic': 'test-topic', 'json_attributes_topic': 'attr-topic' } }) @@ -629,7 +628,6 @@ async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): vacuum.DOMAIN: { 'platform': 'mqtt', 'name': 'test', - 'state_topic': 'test-topic', 'json_attributes_topic': 'attr-topic' } }) @@ -647,7 +645,6 @@ async def test_update_with_json_attrs_bad_json(hass, mqtt_mock, caplog): vacuum.DOMAIN: { 'platform': 'mqtt', 'name': 'test', - 'state_topic': 'test-topic', 'json_attributes_topic': 'attr-topic' } }) @@ -766,7 +763,6 @@ async def test_entity_device_info_update(hass, mqtt_mock): config = { 'platform': 'mqtt', 'name': 'Test 1', - 'state_topic': 'test-topic', 'command_topic': 'test-command-topic', 'device': { 'identifiers': ['helloworld'], diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index bcd70b82a2493f..e99b7abe22efc6 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -693,7 +693,7 @@ async def test_entity_device_info_with_hub(hass, mqtt_mock): 'state_topic': 'test-topic', 'device': { 'identifiers': ['helloworld'], - 'via_hub': 'hub-id', + 'via_device': 'hub-id', }, 'unique_id': 'veryunique' }) @@ -702,4 +702,4 @@ async def test_entity_device_info_with_hub(hass, mqtt_mock): device = registry.async_get_device({('mqtt', 'helloworld')}, set()) assert device is not None - assert device.hub_device_id == hub.id + assert device.via_device_id == hub.id diff --git a/tests/components/mqtt/test_state_vacuum.py b/tests/components/mqtt/test_state_vacuum.py index ecd63a1dcdc942..588a808ecfb45d 100644 --- a/tests/components/mqtt/test_state_vacuum.py +++ b/tests/components/mqtt/test_state_vacuum.py @@ -331,8 +331,7 @@ async def test_discovery_removal_vacuum(hass, mqtt_mock): data = ( '{ "name": "Beer",' - ' "command_topic": "test_topic",' - ' "component": "state" }' + ' "command_topic": "test_topic"}' ) async_fire_mqtt_message(hass, 'homeassistant/vacuum/bla/config', @@ -357,13 +356,11 @@ async def test_discovery_broken(hass, mqtt_mock, caplog): data1 = ( '{ "name": "Beer",' - ' "command_topic": "test_topic#",' - ' "component": "state" }' + ' "command_topic": "test_topic#"}' ) data2 = ( '{ "name": "Milk",' - ' "command_topic": "test_topic",' - ' "component": "state" }' + ' "command_topic": "test_topic"}' ) async_fire_mqtt_message(hass, 'homeassistant/vacuum/bla/config', @@ -391,13 +388,11 @@ async def test_discovery_update_vacuum(hass, mqtt_mock): data1 = ( '{ "name": "Beer",' - ' "command_topic": "test_topic",' - '"component": "state" }' + ' "command_topic": "test_topic"}' ) data2 = ( '{ "name": "Milk",' - ' "command_topic": "test_topic",' - ' "component": "state"}' + ' "command_topic": "test_topic"}' ) async_fire_mqtt_message(hass, 'homeassistant/vacuum/bla/config', @@ -425,7 +420,6 @@ async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): vacuum.DOMAIN: { 'platform': 'mqtt', 'name': 'test', - 'state_topic': 'test-topic', 'json_attributes_topic': 'attr-topic' } }) @@ -442,7 +436,6 @@ async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): vacuum.DOMAIN: { 'platform': 'mqtt', 'name': 'test', - 'state_topic': 'test-topic', 'json_attributes_topic': 'attr-topic' } }) @@ -460,7 +453,6 @@ async def test_update_with_json_attrs_bad_json(hass, mqtt_mock, caplog): vacuum.DOMAIN: { 'platform': 'mqtt', 'name': 'test', - 'state_topic': 'test-topic', 'json_attributes_topic': 'attr-topic' } }) @@ -579,7 +571,6 @@ async def test_entity_device_info_update(hass, mqtt_mock): config = { 'platform': 'mqtt', 'name': 'Test 1', - 'state_topic': 'test-topic', 'command_topic': 'test-command-topic', 'device': { 'identifiers': ['helloworld'], diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index 4e253741286eb0..3f26f5f42e6481 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -9,10 +9,17 @@ from homeassistant.components.onboarding import const, views from tests.common import CLIENT_ID, register_auth_provider +from tests.components.met.conftest import mock_weather # noqa from . import mock_storage +@pytest.fixture(autouse=True) +def always_mock_weather(mock_weather): # noqa + """Mock the Met weather provider.""" + pass + + @pytest.fixture(autouse=True) def auth_active(hass): """Ensure auth is always active.""" @@ -224,3 +231,21 @@ async def test_onboarding_integration_requires_auth(hass, hass_storage, }) assert resp.status == 401 + + +async def test_onboarding_core_sets_up_met(hass, hass_storage, hass_client): + """Test finishing the core step.""" + mock_storage(hass_storage, { + 'done': [const.STEP_USER] + }) + + assert await async_setup_component(hass, 'onboarding', {}) + + client = await hass_client() + + resp = await client.post('/api/onboarding/core_config') + + assert resp.status == 200 + + await hass.async_block_till_done() + assert len(hass.states.async_entity_ids('weather')) == 1 diff --git a/tests/components/qld_bushfire/__init__.py b/tests/components/qld_bushfire/__init__.py new file mode 100644 index 00000000000000..83b096c758f34c --- /dev/null +++ b/tests/components/qld_bushfire/__init__.py @@ -0,0 +1 @@ +"""Tests for the qld_bushfire component.""" diff --git a/tests/components/qld_bushfire/test_geo_location.py b/tests/components/qld_bushfire/test_geo_location.py new file mode 100644 index 00000000000000..43f2ac22ef6eee --- /dev/null +++ b/tests/components/qld_bushfire/test_geo_location.py @@ -0,0 +1,189 @@ +"""The tests for the Queensland Bushfire Alert Feed platform.""" +import datetime +from unittest.mock import patch, MagicMock, call + +from homeassistant.components import geo_location +from homeassistant.components.geo_location import ATTR_SOURCE +from homeassistant.components.qld_bushfire.geo_location import ( + ATTR_EXTERNAL_ID, SCAN_INTERVAL, ATTR_CATEGORY, + ATTR_STATUS, ATTR_PUBLICATION_DATE, ATTR_UPDATED_DATE) +from homeassistant.const import EVENT_HOMEASSISTANT_START, \ + CONF_RADIUS, ATTR_LATITUDE, ATTR_LONGITUDE, ATTR_FRIENDLY_NAME, \ + ATTR_UNIT_OF_MEASUREMENT, ATTR_ATTRIBUTION, CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.setup import async_setup_component +from tests.common import assert_setup_component, async_fire_time_changed +import homeassistant.util.dt as dt_util + +CONFIG = { + geo_location.DOMAIN: [ + { + 'platform': 'qld_bushfire', + CONF_RADIUS: 200 + } + ] +} + +CONFIG_WITH_CUSTOM_LOCATION = { + geo_location.DOMAIN: [ + { + 'platform': 'qld_bushfire', + CONF_RADIUS: 200, + CONF_LATITUDE: 40.4, + CONF_LONGITUDE: -3.7 + } + ] +} + + +def _generate_mock_feed_entry(external_id, title, distance_to_home, + coordinates, category=None, attribution=None, + published=None, updated=None, status=None): + """Construct a mock feed entry for testing purposes.""" + feed_entry = MagicMock() + feed_entry.external_id = external_id + feed_entry.title = title + feed_entry.distance_to_home = distance_to_home + feed_entry.coordinates = coordinates + feed_entry.category = category + feed_entry.attribution = attribution + feed_entry.published = published + feed_entry.updated = updated + feed_entry.status = status + return feed_entry + + +async def test_setup(hass): + """Test the general setup of the platform.""" + # Set up some mock feed entries for this test. + mock_entry_1 = _generate_mock_feed_entry( + '1234', 'Title 1', 15.5, (38.0, -3.0), + category='Category 1', + attribution='Attribution 1', + published=datetime.datetime(2018, 9, 22, 8, 0, + tzinfo=datetime.timezone.utc), + updated=datetime.datetime(2018, 9, 22, 8, 10, + tzinfo=datetime.timezone.utc), + status='Status 1') + mock_entry_2 = _generate_mock_feed_entry( + '2345', 'Title 2', 20.5, (38.1, -3.1)) + mock_entry_3 = _generate_mock_feed_entry( + '3456', 'Title 3', 25.5, (38.2, -3.2)) + mock_entry_4 = _generate_mock_feed_entry( + '4567', 'Title 4', 12.5, (38.3, -3.3)) + + # Patching 'utcnow' to gain more control over the timed update. + utcnow = dt_util.utcnow() + with patch('homeassistant.util.dt.utcnow', return_value=utcnow), \ + patch('georss_qld_bushfire_alert_client.' + 'QldBushfireAlertFeed') as mock_feed: + mock_feed.return_value.update.return_value = 'OK', [mock_entry_1, + mock_entry_2, + mock_entry_3] + with assert_setup_component(1, geo_location.DOMAIN): + assert await async_setup_component( + hass, geo_location.DOMAIN, CONFIG) + # Artificially trigger update. + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + # Collect events. + await hass.async_block_till_done() + + all_states = hass.states.async_all() + assert len(all_states) == 3 + + state = hass.states.get("geo_location.title_1") + assert state is not None + assert state.name == "Title 1" + assert state.attributes == { + ATTR_EXTERNAL_ID: "1234", + ATTR_LATITUDE: 38.0, + ATTR_LONGITUDE: -3.0, + ATTR_FRIENDLY_NAME: "Title 1", + ATTR_CATEGORY: "Category 1", + ATTR_ATTRIBUTION: "Attribution 1", + ATTR_PUBLICATION_DATE: + datetime.datetime( + 2018, 9, 22, 8, 0, tzinfo=datetime.timezone.utc), + ATTR_UPDATED_DATE: + datetime.datetime( + 2018, 9, 22, 8, 10, tzinfo=datetime.timezone.utc), + ATTR_STATUS: 'Status 1', + ATTR_UNIT_OF_MEASUREMENT: "km", + ATTR_SOURCE: 'qld_bushfire'} + assert float(state.state) == 15.5 + + state = hass.states.get("geo_location.title_2") + assert state is not None + assert state.name == "Title 2" + assert state.attributes == { + ATTR_EXTERNAL_ID: "2345", + ATTR_LATITUDE: 38.1, + ATTR_LONGITUDE: -3.1, + ATTR_FRIENDLY_NAME: "Title 2", + ATTR_UNIT_OF_MEASUREMENT: "km", + ATTR_SOURCE: 'qld_bushfire'} + assert float(state.state) == 20.5 + + state = hass.states.get("geo_location.title_3") + assert state is not None + assert state.name == "Title 3" + assert state.attributes == { + ATTR_EXTERNAL_ID: "3456", + ATTR_LATITUDE: 38.2, + ATTR_LONGITUDE: -3.2, + ATTR_FRIENDLY_NAME: "Title 3", + ATTR_UNIT_OF_MEASUREMENT: "km", + ATTR_SOURCE: 'qld_bushfire'} + assert float(state.state) == 25.5 + + # Simulate an update - one existing, one new entry, + # one outdated entry + mock_feed.return_value.update.return_value = 'OK', [ + mock_entry_1, mock_entry_4, mock_entry_3] + async_fire_time_changed(hass, utcnow + SCAN_INTERVAL) + await hass.async_block_till_done() + + all_states = hass.states.async_all() + assert len(all_states) == 3 + + # Simulate an update - empty data, but successful update, + # so no changes to entities. + mock_feed.return_value.update.return_value = 'OK_NO_DATA', None + async_fire_time_changed(hass, utcnow + 2 * SCAN_INTERVAL) + await hass.async_block_till_done() + + all_states = hass.states.async_all() + assert len(all_states) == 3 + + # Simulate an update - empty data, removes all entities + mock_feed.return_value.update.return_value = 'ERROR', None + async_fire_time_changed(hass, utcnow + 3 * SCAN_INTERVAL) + await hass.async_block_till_done() + + all_states = hass.states.async_all() + assert len(all_states) == 0 + + +async def test_setup_with_custom_location(hass): + """Test the setup with a custom location.""" + # Set up some mock feed entries for this test. + mock_entry_1 = _generate_mock_feed_entry( + '1234', 'Title 1', 20.5, (38.1, -3.1), category="Category 1") + + with patch('georss_qld_bushfire_alert_client.' + 'QldBushfireAlertFeed') as mock_feed: + mock_feed.return_value.update.return_value = 'OK', [mock_entry_1] + + with assert_setup_component(1, geo_location.DOMAIN): + assert await async_setup_component( + hass, geo_location.DOMAIN, CONFIG_WITH_CUSTOM_LOCATION) + + # Artificially trigger update. + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + # Collect events. + await hass.async_block_till_done() + + all_states = hass.states.async_all() + assert len(all_states) == 1 + + assert mock_feed.call_args == call( + (40.4, -3.7), filter_categories=[], filter_radius=200.0) diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index 7460a65b0ce9e3..8f0ec7b39291ba 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -7,6 +7,7 @@ from homeassistant.core import callback from homeassistant.const import MATCH_ALL +from homeassistant.setup import async_setup_component from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.const import DATA_INSTANCE from homeassistant.components.recorder.util import session_scope @@ -202,3 +203,22 @@ def test_recorder_setup_failure(): rec.join() hass.stop() + + +async def test_defaults_set(hass): + """Test the config defaults are set.""" + recorder_config = None + + async def mock_setup(hass, config): + """Mock setup.""" + nonlocal recorder_config + recorder_config = config['recorder'] + return True + + with patch('homeassistant.components.recorder.async_setup', + side_effect=mock_setup): + assert await async_setup_component(hass, 'history', {}) + + assert recorder_config is not None + assert recorder_config['purge_keep_days'] == 10 + assert recorder_config['purge_interval'] == 1 diff --git a/tests/components/remote/common.py b/tests/components/remote/common.py index d03cf5d6d16139..30b158bca4b986 100644 --- a/tests/components/remote/common.py +++ b/tests/components/remote/common.py @@ -4,8 +4,9 @@ components. Instead call the service directly. """ from homeassistant.components.remote import ( - ATTR_ACTIVITY, ATTR_COMMAND, ATTR_DELAY_SECS, ATTR_DEVICE, - ATTR_NUM_REPEATS, DOMAIN, SERVICE_SEND_COMMAND) + ATTR_ACTIVITY, ATTR_ALTERNATIVE, ATTR_COMMAND, ATTR_DELAY_SECS, + ATTR_DEVICE, ATTR_NUM_REPEATS, ATTR_TIMEOUT, DOMAIN, + SERVICE_LEARN_COMMAND, SERVICE_SEND_COMMAND) from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON) from homeassistant.loader import bind_hass @@ -53,3 +54,26 @@ def send_command(hass, command, entity_id=None, device=None, data[ATTR_DELAY_SECS] = delay_secs hass.services.call(DOMAIN, SERVICE_SEND_COMMAND, data) + + +@bind_hass +def learn_command(hass, entity_id=None, device=None, command=None, + alternative=None, timeout=None): + """Learn a command from a device.""" + data = {} + if entity_id: + data[ATTR_ENTITY_ID] = entity_id + + if device: + data[ATTR_DEVICE] = device + + if command: + data[ATTR_COMMAND] = command + + if alternative: + data[ATTR_ALTERNATIVE] = alternative + + if timeout: + data[ATTR_TIMEOUT] = timeout + + hass.services.call(DOMAIN, SERVICE_LEARN_COMMAND, data) diff --git a/tests/components/remote/test_init.py b/tests/components/remote/test_init.py index 2315dc1cf64514..2d1419c66aead3 100644 --- a/tests/components/remote/test_init.py +++ b/tests/components/remote/test_init.py @@ -13,6 +13,7 @@ TEST_PLATFORM = {remote.DOMAIN: {CONF_PLATFORM: 'test'}} SERVICE_SEND_COMMAND = 'send_command' +SERVICE_LEARN_COMMAND = 'learn_command' class TestRemote(unittest.TestCase): @@ -53,7 +54,7 @@ def test_turn_on(self): self.hass.block_till_done() - assert 1 == len(turn_on_calls) + assert len(turn_on_calls) == 1 call = turn_on_calls[-1] assert remote.DOMAIN == call.domain @@ -68,12 +69,12 @@ def test_turn_off(self): self.hass.block_till_done() - assert 1 == len(turn_off_calls) + assert len(turn_off_calls) == 1 call = turn_off_calls[-1] - assert remote.DOMAIN == call.domain - assert SERVICE_TURN_OFF == call.service - assert 'entity_id_val' == call.data[ATTR_ENTITY_ID] + assert call.domain == remote.DOMAIN + assert call.service == SERVICE_TURN_OFF + assert call.data[ATTR_ENTITY_ID] == 'entity_id_val' def test_send_command(self): """Test send_command.""" @@ -87,9 +88,28 @@ def test_send_command(self): self.hass.block_till_done() - assert 1 == len(send_command_calls) + assert len(send_command_calls) == 1 call = send_command_calls[-1] - assert remote.DOMAIN == call.domain - assert SERVICE_SEND_COMMAND == call.service - assert 'entity_id_val' == call.data[ATTR_ENTITY_ID] + assert call.domain == remote.DOMAIN + assert call.service == SERVICE_SEND_COMMAND + assert call.data[ATTR_ENTITY_ID] == 'entity_id_val' + + def test_learn_command(self): + """Test learn_command.""" + learn_command_calls = mock_service( + self.hass, remote.DOMAIN, SERVICE_LEARN_COMMAND) + + common.learn_command( + self.hass, entity_id='entity_id_val', + device='test_device', command=['test_command'], + alternative=True, timeout=20) + + self.hass.block_till_done() + + assert len(learn_command_calls) == 1 + call = learn_command_calls[-1] + + assert call.domain == remote.DOMAIN + assert call.service == SERVICE_LEARN_COMMAND + assert call.data[ATTR_ENTITY_ID] == 'entity_id_val' diff --git a/tests/components/samsungtv/test_media_player.py b/tests/components/samsungtv/test_media_player.py index b175a7220364de..b42250e26136a6 100644 --- a/tests/components/samsungtv/test_media_player.py +++ b/tests/components/samsungtv/test_media_player.py @@ -318,7 +318,7 @@ async def sleep(duration, loop): device.send_key = mock.Mock() await device.async_play_media(MEDIA_TYPE_CHANNEL, "576") - exp = [call("KEY_5"), call("KEY_7"), call("KEY_6")] + exp = [call("KEY_5"), call("KEY_7"), call("KEY_6"), call("KEY_ENTER")] assert device.send_key.call_args_list == exp assert len(sleeps) == 3 @@ -347,3 +347,21 @@ async def test_play_media_channel_as_non_positive(hass, samsung_mock): device.send_key = mock.Mock() await device.async_play_media(MEDIA_TYPE_CHANNEL, "-4") assert device.send_key.call_count == 0 + + +async def test_select_source(hass, samsung_mock): + """Test for select_source.""" + device = SamsungTVDevice(**WORKING_CONFIG) + device.hass = hass + device.send_key = mock.Mock() + await device.async_select_source("HDMI") + exp = [call("KEY_HDMI")] + assert device.send_key.call_args_list == exp + + +async def test_select_source_invalid_source(hass, samsung_mock): + """Test for select_source with invalid source.""" + device = SamsungTVDevice(**WORKING_CONFIG) + device.send_key = mock.Mock() + await device.async_select_source("INVALID") + assert device.send_key.call_count == 0 diff --git a/tests/components/scene/test_init.py b/tests/components/scene/test_init.py index 99364d51e6c5ef..94746cce0f00dc 100644 --- a/tests/components/scene/test_init.py +++ b/tests/components/scene/test_init.py @@ -90,7 +90,7 @@ def test_config_yaml_bool(self): self.light_1.entity_id, self.light_2.entity_id) with io.StringIO(config) as file: - doc = yaml_loader.yaml.load(file) + doc = yaml_loader.yaml.safe_load(file) assert setup_component(self.hass, scene.DOMAIN, doc) common.activate(self.hass, 'scene.test') diff --git a/tests/components/somfy/__init__.py b/tests/components/somfy/__init__.py new file mode 100644 index 00000000000000..05f5cbcf4f0085 --- /dev/null +++ b/tests/components/somfy/__init__.py @@ -0,0 +1 @@ +"""Tests for the Somfy component.""" diff --git a/tests/components/somfy/test_config_flow.py b/tests/components/somfy/test_config_flow.py new file mode 100644 index 00000000000000..4184e984d05ec9 --- /dev/null +++ b/tests/components/somfy/test_config_flow.py @@ -0,0 +1,77 @@ +"""Tests for the Somfy config flow.""" +import asyncio +from unittest.mock import Mock, patch + +from pymfy.api.somfy_api import SomfyApi + +from homeassistant import data_entry_flow +from homeassistant.components.somfy import config_flow, DOMAIN +from homeassistant.components.somfy.config_flow import \ + register_flow_implementation +from tests.common import MockConfigEntry, mock_coro + +CLIENT_SECRET_VALUE = "5678" + +CLIENT_ID_VALUE = "1234" + +AUTH_URL = 'http://somfy.com' + + +async def test_abort_if_no_configuration(hass): + """Check flow abort when no configuration.""" + flow = config_flow.SomfyFlowHandler() + flow.hass = hass + result = await flow.async_step_user() + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'missing_configuration' + + +async def test_abort_if_existing_entry(hass): + """Check flow abort when an entry already exist.""" + flow = config_flow.SomfyFlowHandler() + flow.hass = hass + MockConfigEntry(domain=DOMAIN).add_to_hass(hass) + result = await flow.async_step_import() + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'already_setup' + result = await flow.async_step_user() + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'already_setup' + + +async def test_full_flow(hass): + """Check classic use case.""" + hass.data[DOMAIN] = {} + register_flow_implementation(hass, CLIENT_ID_VALUE, CLIENT_SECRET_VALUE) + flow = config_flow.SomfyFlowHandler() + flow.hass = hass + hass.config.api = Mock(base_url='https://example.com') + flow._get_authorization_url = Mock( + return_value=mock_coro((AUTH_URL, 'state'))) + result = await flow.async_step_import() + assert result['type'] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP + assert result['url'] == AUTH_URL + result = await flow.async_step_auth("my_super_code") + assert result['type'] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP_DONE + assert result['step_id'] == 'creation' + assert flow.code == 'my_super_code' + with patch.object(SomfyApi, 'request_token', + return_value={"access_token": "super_token"}): + result = await flow.async_step_creation() + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['data']['refresh_args'] == { + 'client_id': CLIENT_ID_VALUE, + 'client_secret': CLIENT_SECRET_VALUE + } + assert result['title'] == 'Somfy' + assert result['data']['token'] == {"access_token": "super_token"} + + +async def test_abort_if_authorization_timeout(hass): + """Check Somfy authorization timeout.""" + flow = config_flow.SomfyFlowHandler() + flow.hass = hass + flow._get_authorization_url = Mock(side_effect=asyncio.TimeoutError) + result = await flow.async_step_auth() + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'authorize_url_timeout' diff --git a/tests/components/switcher_kis/conftest.py b/tests/components/switcher_kis/conftest.py index 9f961f72401a0d..d820f11cea6015 100644 --- a/tests/components/switcher_kis/conftest.py +++ b/tests/components/switcher_kis/conftest.py @@ -98,7 +98,9 @@ async def mock_queue(): patchers = [ patch('aioswitcher.bridge.SwitcherV2Bridge.start', new=mock_bridge), patch('aioswitcher.bridge.SwitcherV2Bridge.stop', new=mock_bridge), - patch('aioswitcher.bridge.SwitcherV2Bridge.queue', get=mock_queue) + patch('aioswitcher.bridge.SwitcherV2Bridge.queue', get=mock_queue), + patch('aioswitcher.bridge.SwitcherV2Bridge.running', + return_value=True) ] for patcher in patchers: @@ -130,3 +132,22 @@ async def mock_queue(): for patcher in patchers: patcher.stop() + + +@fixture(name='mock_api') +def mock_api_fixture() -> Generator[CoroutineMock, Any, None]: + """Fixture for mocking aioswitcher.api.SwitcherV2Api.""" + mock_api = CoroutineMock() + + patchers = [ + patch('aioswitcher.api.SwitcherV2Api.connect', new=mock_api), + patch('aioswitcher.api.SwitcherV2Api.disconnect', new=mock_api) + ] + + for patcher in patchers: + patcher.start() + + yield + + for patcher in patchers: + patcher.stop() diff --git a/tests/components/switcher_kis/consts.py b/tests/components/switcher_kis/consts.py index 47efe8d03c9e9b..852f5e521f7382 100644 --- a/tests/components/switcher_kis/consts.py +++ b/tests/components/switcher_kis/consts.py @@ -17,6 +17,9 @@ DUMMY_POWER_CONSUMPTION = 2780 DUMMY_REMAINING_TIME = '01:29:32' +# Adjust if any modification were made to DUMMY_DEVICE_NAME +SWITCH_ENTITY_ID = "switch.switcher_kis_device_name" + MANDATORY_CONFIGURATION = { DOMAIN: { CONF_PHONE_ID: DUMMY_PHONE_ID, diff --git a/tests/components/switcher_kis/test_init.py b/tests/components/switcher_kis/test_init.py index 33d24903f9435c..b0d98dd6267e4e 100644 --- a/tests/components/switcher_kis/test_init.py +++ b/tests/components/switcher_kis/test_init.py @@ -1,16 +1,32 @@ """Test cases for the switcher_kis component.""" -from typing import Any, Generator +from datetime import timedelta +from typing import Any, Generator, TYPE_CHECKING -from homeassistant.components.switcher_kis import (DOMAIN, DATA_DEVICE) +from pytest import raises + +from homeassistant.const import CONF_ENTITY_ID +from homeassistant.components.switcher_kis import ( + CONF_AUTO_OFF, DOMAIN, DATA_DEVICE, SERVICE_SET_AUTO_OFF_NAME, + SERVICE_SET_AUTO_OFF_SCHEMA, SIGNAL_SWITCHER_DEVICE_UPDATE) +from homeassistant.core import callback, Context +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.exceptions import Unauthorized, UnknownUser from homeassistant.setup import async_setup_component +from homeassistant.util import dt + +from tests.common import async_mock_service, async_fire_time_changed from .consts import ( DUMMY_AUTO_OFF_SET, DUMMY_DEVICE_ID, DUMMY_DEVICE_NAME, DUMMY_DEVICE_STATE, DUMMY_ELECTRIC_CURRENT, DUMMY_IP_ADDRESS, DUMMY_MAC_ADDRESS, DUMMY_PHONE_ID, DUMMY_POWER_CONSUMPTION, - DUMMY_REMAINING_TIME, MANDATORY_CONFIGURATION) + DUMMY_REMAINING_TIME, MANDATORY_CONFIGURATION, SWITCH_ENTITY_ID) + +if TYPE_CHECKING: + from tests.common import MockUser + from aioswitcher.devices import SwitcherV2Device async def test_failed_config( @@ -49,3 +65,81 @@ async def test_discovery_data_bucket( assert device.power_consumption == DUMMY_POWER_CONSUMPTION assert device.electric_current == DUMMY_ELECTRIC_CURRENT assert device.phone_id == DUMMY_PHONE_ID + + +async def test_set_auto_off_service( + hass: HomeAssistantType, mock_bridge: Generator[None, Any, None], + mock_api: Generator[None, Any, None], hass_owner_user: 'MockUser', + hass_read_only_user: 'MockUser') -> None: + """Test the set_auto_off service.""" + assert await async_setup_component(hass, DOMAIN, MANDATORY_CONFIGURATION) + + await hass.async_block_till_done() + + assert hass.services.has_service(DOMAIN, SERVICE_SET_AUTO_OFF_NAME) + + await hass.services.async_call( + DOMAIN, SERVICE_SET_AUTO_OFF_NAME, + {CONF_ENTITY_ID: SWITCH_ENTITY_ID, + CONF_AUTO_OFF: DUMMY_AUTO_OFF_SET}, + blocking=True, context=Context(user_id=hass_owner_user.id)) + + with raises(Unauthorized) as unauthorized_read_only_exc: + await hass.services.async_call( + DOMAIN, SERVICE_SET_AUTO_OFF_NAME, + {CONF_ENTITY_ID: SWITCH_ENTITY_ID, + CONF_AUTO_OFF: DUMMY_AUTO_OFF_SET}, + blocking=True, context=Context(user_id=hass_read_only_user.id)) + + assert unauthorized_read_only_exc.type is Unauthorized + + with raises(Unauthorized) as unauthorized_wrong_entity_exc: + await hass.services.async_call( + DOMAIN, SERVICE_SET_AUTO_OFF_NAME, + {CONF_ENTITY_ID: "light.not_related_entity", + CONF_AUTO_OFF: DUMMY_AUTO_OFF_SET}, + blocking=True, context=Context(user_id=hass_owner_user.id)) + + assert unauthorized_wrong_entity_exc.type is Unauthorized + + with raises(UnknownUser) as unknown_user_exc: + await hass.services.async_call( + DOMAIN, SERVICE_SET_AUTO_OFF_NAME, + {CONF_ENTITY_ID: SWITCH_ENTITY_ID, + CONF_AUTO_OFF: DUMMY_AUTO_OFF_SET}, + blocking=True, context=Context(user_id="not_real_user")) + + assert unknown_user_exc.type is UnknownUser + + service_calls = async_mock_service( + hass, DOMAIN, SERVICE_SET_AUTO_OFF_NAME, SERVICE_SET_AUTO_OFF_SCHEMA) + + await hass.services.async_call( + DOMAIN, SERVICE_SET_AUTO_OFF_NAME, + {CONF_ENTITY_ID: SWITCH_ENTITY_ID, + CONF_AUTO_OFF: DUMMY_AUTO_OFF_SET}) + + await hass.async_block_till_done() + + assert len(service_calls) == 1 + assert str(service_calls[0].data[CONF_AUTO_OFF]) \ + == DUMMY_AUTO_OFF_SET.lstrip('0') + + +async def test_signal_dispatcher( + hass: HomeAssistantType, + mock_bridge: Generator[None, Any, None]) -> None: + """Test signal dispatcher dispatching device updates every 4 seconds.""" + assert await async_setup_component(hass, DOMAIN, MANDATORY_CONFIGURATION) + + await hass.async_block_till_done() + + @callback + def verify_update_data(device: 'SwitcherV2Device') -> None: + """Use as callback for signal dispatcher.""" + pass + + async_dispatcher_connect( + hass, SIGNAL_SWITCHER_DEVICE_UPDATE, verify_update_data) + + async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=5)) diff --git a/tests/components/template/test_cover.py b/tests/components/template/test_cover.py index 703ef787ec76be..83ba7bdf8162f7 100644 --- a/tests/components/template/test_cover.py +++ b/tests/components/template/test_cover.py @@ -7,6 +7,7 @@ ATTR_POSITION, ATTR_TILT_POSITION, DOMAIN) from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_CLOSE_COVER, SERVICE_CLOSE_COVER_TILT, + SERVICE_TOGGLE, SERVICE_TOGGLE_COVER_TILT, SERVICE_OPEN_COVER, SERVICE_OPEN_COVER_TILT, SERVICE_SET_COVER_POSITION, SERVICE_SET_COVER_TILT_POSITION, SERVICE_STOP_COVER, STATE_CLOSED, STATE_OPEN) @@ -464,6 +465,20 @@ async def test_set_position(hass, calls): state = hass.states.get('cover.test_template_cover') assert state.attributes.get('current_position') == 0.0 + await hass.services.async_call( + DOMAIN, SERVICE_TOGGLE, + {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) + await hass.async_block_till_done() + state = hass.states.get('cover.test_template_cover') + assert state.attributes.get('current_position') == 100.0 + + await hass.services.async_call( + DOMAIN, SERVICE_TOGGLE, + {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) + await hass.async_block_till_done() + state = hass.states.get('cover.test_template_cover') + assert state.attributes.get('current_position') == 0.0 + await hass.services.async_call( DOMAIN, SERVICE_SET_COVER_POSITION, {ATTR_ENTITY_ID: ENTITY_COVER, ATTR_POSITION: 25}, blocking=True) @@ -626,6 +641,20 @@ async def test_set_position_optimistic(hass, calls): state = hass.states.get('cover.test_template_cover') assert state.state == STATE_OPEN + await hass.services.async_call( + DOMAIN, SERVICE_TOGGLE, + {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) + await hass.async_block_till_done() + state = hass.states.get('cover.test_template_cover') + assert state.state == STATE_CLOSED + + await hass.services.async_call( + DOMAIN, SERVICE_TOGGLE, + {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) + await hass.async_block_till_done() + state = hass.states.get('cover.test_template_cover') + assert state.state == STATE_OPEN + async def test_set_tilt_position_optimistic(hass, calls): """Test the optimistic tilt_position mode.""" @@ -675,6 +704,20 @@ async def test_set_tilt_position_optimistic(hass, calls): state = hass.states.get('cover.test_template_cover') assert state.attributes.get('current_tilt_position') == 100.0 + await hass.services.async_call( + DOMAIN, SERVICE_TOGGLE_COVER_TILT, + {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) + await hass.async_block_till_done() + state = hass.states.get('cover.test_template_cover') + assert state.attributes.get('current_tilt_position') == 0.0 + + await hass.services.async_call( + DOMAIN, SERVICE_TOGGLE_COVER_TILT, + {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) + await hass.async_block_till_done() + state = hass.states.get('cover.test_template_cover') + assert state.attributes.get('current_tilt_position') == 100.0 + async def test_icon_template(hass, calls): """Test icon template.""" diff --git a/tests/components/tradfri/test_config_flow.py b/tests/components/tradfri/test_config_flow.py index 8fcc72dd4a585c..490f8484bbf679 100644 --- a/tests/components/tradfri/test_config_flow.py +++ b/tests/components/tradfri/test_config_flow.py @@ -258,7 +258,7 @@ async def test_discovery_duplicate_aborted(hass): async def test_import_duplicate_aborted(hass): - """Test a duplicate discovery host is ignored.""" + """Test a duplicate import host is ignored.""" MockConfigEntry( domain='tradfri', data={'host': 'some-host'} @@ -271,3 +271,20 @@ async def test_import_duplicate_aborted(hass): assert flow['type'] == data_entry_flow.RESULT_TYPE_ABORT assert flow['reason'] == 'already_configured' + + +async def test_duplicate_discovery(hass, mock_auth, mock_entry_setup): + """Test a duplicate discovery in progress is ignored.""" + result = await hass.config_entries.flow.async_init( + 'tradfri', context={'source': 'zeroconf'}, data={ + 'host': '123.123.123.123' + }) + + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + + result2 = await hass.config_entries.flow.async_init( + 'tradfri', context={'source': 'zeroconf'}, data={ + 'host': '123.123.123.123' + }) + + assert result2['type'] == data_entry_flow.RESULT_TYPE_ABORT diff --git a/tests/components/unifi/test_controller.py b/tests/components/unifi/test_controller.py index d1db25a23cd4e1..b708a69bb67998 100644 --- a/tests/components/unifi/test_controller.py +++ b/tests/components/unifi/test_controller.py @@ -4,8 +4,7 @@ import pytest from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.components.unifi.const import ( - CONF_POE_CONTROL, CONF_CONTROLLER, CONF_SITE_ID) +from homeassistant.components.unifi.const import CONF_CONTROLLER, CONF_SITE_ID from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME, CONF_VERIFY_SSL) from homeassistant.components.unifi import controller, errors @@ -22,8 +21,7 @@ } ENTRY_CONFIG = { - CONF_CONTROLLER: CONTROLLER_DATA, - CONF_POE_CONTROL: True + CONF_CONTROLLER: CONTROLLER_DATA } @@ -171,30 +169,6 @@ async def test_reset_unloads_entry_if_setup(): assert len(hass.config_entries.async_forward_entry_unload.mock_calls) == 1 -async def test_reset_unloads_entry_without_poe_control(): - """Calling reset while the entry has been setup.""" - hass = Mock() - entry = Mock() - entry.data = dict(ENTRY_CONFIG) - entry.data[CONF_POE_CONTROL] = False - api = Mock() - api.initialize.return_value = mock_coro(True) - - unifi_controller = controller.UniFiController(hass, entry) - - with patch.object(controller, 'get_controller', - return_value=mock_coro(api)): - assert await unifi_controller.async_setup() is True - - assert not hass.config_entries.async_forward_entry_setup.mock_calls - - hass.config_entries.async_forward_entry_unload.return_value = \ - mock_coro(True) - assert await unifi_controller.async_reset() - - assert not hass.config_entries.async_forward_entry_unload.mock_calls - - async def test_get_controller(hass): """Successful call.""" with patch('aiounifi.Controller.login', return_value=mock_coro()): diff --git a/tests/components/unifi/test_device_tracker.py b/tests/components/unifi/test_device_tracker.py index 0fb0751c5b6248..5bc24c6c26980f 100644 --- a/tests/components/unifi/test_device_tracker.py +++ b/tests/components/unifi/test_device_tracker.py @@ -1,8 +1,6 @@ """The tests for the Unifi WAP device tracker platform.""" from unittest import mock from datetime import datetime, timedelta -from pyunifi.controller import APIError - import pytest import voluptuous as vol @@ -13,13 +11,20 @@ from homeassistant.const import (CONF_HOST, CONF_USERNAME, CONF_PASSWORD, CONF_PLATFORM, CONF_VERIFY_SSL, CONF_MONITORED_CONDITIONS) + +from tests.common import mock_coro +from asynctest import CoroutineMock +from aiounifi.clients import Clients + DEFAULT_DETECTION_TIME = timedelta(seconds=300) @pytest.fixture def mock_ctrl(): """Mock pyunifi.""" - with mock.patch('pyunifi.controller.Controller') as mock_control: + with mock.patch('aiounifi.Controller') as mock_control: + mock_control.return_value.login.return_value = mock_coro() + mock_control.return_value.initialize.return_value = mock_coro() yield mock_control @@ -33,7 +38,7 @@ def mock_scanner(): @mock.patch('os.access', return_value=True) @mock.patch('os.path.isfile', mock.Mock(return_value=True)) -def test_config_valid_verify_ssl(hass, mock_scanner, mock_ctrl): +async def test_config_valid_verify_ssl(hass, mock_scanner, mock_ctrl): """Test the setup with a string for ssl_verify. Representing the absolute path to a CA certificate bundle. @@ -46,12 +51,9 @@ def test_config_valid_verify_ssl(hass, mock_scanner, mock_ctrl): CONF_VERIFY_SSL: "/tmp/unifi.crt" }) } - result = unifi.get_scanner(hass, config) + result = await unifi.async_get_scanner(hass, config) assert mock_scanner.return_value == result assert mock_ctrl.call_count == 1 - assert mock_ctrl.mock_calls[0] == \ - mock.call('localhost', 'foo', 'password', 8443, - version='v4', site_id='default', ssl_verify="/tmp/unifi.crt") assert mock_scanner.call_count == 1 assert mock_scanner.call_args == mock.call(mock_ctrl.return_value, @@ -59,7 +61,7 @@ def test_config_valid_verify_ssl(hass, mock_scanner, mock_ctrl): None, None) -def test_config_minimal(hass, mock_scanner, mock_ctrl): +async def test_config_minimal(hass, mock_scanner, mock_ctrl): """Test the setup with minimal configuration.""" config = { DOMAIN: unifi.PLATFORM_SCHEMA({ @@ -68,12 +70,10 @@ def test_config_minimal(hass, mock_scanner, mock_ctrl): CONF_PASSWORD: 'password', }) } - result = unifi.get_scanner(hass, config) + + result = await unifi.async_get_scanner(hass, config) assert mock_scanner.return_value == result assert mock_ctrl.call_count == 1 - assert mock_ctrl.mock_calls[0] == \ - mock.call('localhost', 'foo', 'password', 8443, - version='v4', site_id='default', ssl_verify=True) assert mock_scanner.call_count == 1 assert mock_scanner.call_args == mock.call(mock_ctrl.return_value, @@ -81,7 +81,7 @@ def test_config_minimal(hass, mock_scanner, mock_ctrl): None, None) -def test_config_full(hass, mock_scanner, mock_ctrl): +async def test_config_full(hass, mock_scanner, mock_ctrl): """Test the setup with full configuration.""" config = { DOMAIN: unifi.PLATFORM_SCHEMA({ @@ -96,12 +96,9 @@ def test_config_full(hass, mock_scanner, mock_ctrl): 'detection_time': 300, }) } - result = unifi.get_scanner(hass, config) + result = await unifi.async_get_scanner(hass, config) assert mock_scanner.return_value == result assert mock_ctrl.call_count == 1 - assert mock_ctrl.call_args == \ - mock.call('myhost', 'foo', 'password', 123, - version='v4', site_id='abcdef01', ssl_verify=False) assert mock_scanner.call_count == 1 assert mock_scanner.call_args == mock.call( @@ -137,7 +134,7 @@ def test_config_error(): }) -def test_config_controller_failed(hass, mock_ctrl, mock_scanner): +async def test_config_controller_failed(hass, mock_ctrl, mock_scanner): """Test for controller failure.""" config = { 'device_tracker': { @@ -146,13 +143,12 @@ def test_config_controller_failed(hass, mock_ctrl, mock_scanner): CONF_PASSWORD: 'password', } } - mock_ctrl.side_effect = APIError( - '/', 500, 'foo', {}, None) - result = unifi.get_scanner(hass, config) + mock_ctrl.side_effect = unifi.CannotConnect + result = await unifi.async_get_scanner(hass, config) assert result is False -def test_scanner_update(): +async def test_scanner_update(): """Test the scanner update.""" ctrl = mock.MagicMock() fake_clients = [ @@ -161,21 +157,20 @@ def test_scanner_update(): {'mac': '234', 'essid': 'barnet', 'last_seen': dt_util.as_timestamp(dt_util.utcnow())}, ] - ctrl.get_clients.return_value = fake_clients - unifi.UnifiScanner(ctrl, DEFAULT_DETECTION_TIME, None, None) - assert ctrl.get_clients.call_count == 1 - assert ctrl.get_clients.call_args == mock.call() + ctrl.clients = Clients([], CoroutineMock(return_value=fake_clients)) + scnr = unifi.UnifiScanner(ctrl, DEFAULT_DETECTION_TIME, None, None) + await scnr.async_update() + assert len(scnr._clients) == 2 def test_scanner_update_error(): """Test the scanner update for error.""" ctrl = mock.MagicMock() - ctrl.get_clients.side_effect = APIError( - '/', 500, 'foo', {}, None) + ctrl.get_clients.side_effect = unifi.aiounifi.AiounifiException unifi.UnifiScanner(ctrl, DEFAULT_DETECTION_TIME, None, None) -def test_scan_devices(): +async def test_scan_devices(): """Test the scanning for devices.""" ctrl = mock.MagicMock() fake_clients = [ @@ -184,12 +179,13 @@ def test_scan_devices(): {'mac': '234', 'essid': 'barnet', 'last_seen': dt_util.as_timestamp(dt_util.utcnow())}, ] - ctrl.get_clients.return_value = fake_clients - scanner = unifi.UnifiScanner(ctrl, DEFAULT_DETECTION_TIME, None, None) - assert set(scanner.scan_devices()) == set(['123', '234']) + ctrl.clients = Clients([], CoroutineMock(return_value=fake_clients)) + scnr = unifi.UnifiScanner(ctrl, DEFAULT_DETECTION_TIME, None, None) + await scnr.async_update() + assert set(await scnr.async_scan_devices()) == set(['123', '234']) -def test_scan_devices_filtered(): +async def test_scan_devices_filtered(): """Test the scanning for devices based on SSID.""" ctrl = mock.MagicMock() fake_clients = [ @@ -204,13 +200,13 @@ def test_scan_devices_filtered(): ] ssid_filter = ['foonet', 'barnet'] - ctrl.get_clients.return_value = fake_clients - scanner = unifi.UnifiScanner(ctrl, DEFAULT_DETECTION_TIME, ssid_filter, - None) - assert set(scanner.scan_devices()) == set(['123', '234', '890']) + ctrl.clients = Clients([], CoroutineMock(return_value=fake_clients)) + scnr = unifi.UnifiScanner(ctrl, DEFAULT_DETECTION_TIME, ssid_filter, None) + await scnr.async_update() + assert set(await scnr.async_scan_devices()) == set(['123', '234', '890']) -def test_get_device_name(): +async def test_get_device_name(): """Test the getting of device names.""" ctrl = mock.MagicMock() fake_clients = [ @@ -226,15 +222,16 @@ def test_get_device_name(): 'essid': 'barnet', 'last_seen': '1504786810'}, ] - ctrl.get_clients.return_value = fake_clients - scanner = unifi.UnifiScanner(ctrl, DEFAULT_DETECTION_TIME, None, None) - assert scanner.get_device_name('123') == 'foobar' - assert scanner.get_device_name('234') == 'Nice Name' - assert scanner.get_device_name('456') is None - assert scanner.get_device_name('unknown') is None + ctrl.clients = Clients([], CoroutineMock(return_value=fake_clients)) + scnr = unifi.UnifiScanner(ctrl, DEFAULT_DETECTION_TIME, None, None) + await scnr.async_update() + assert scnr.get_device_name('123') == 'foobar' + assert scnr.get_device_name('234') == 'Nice Name' + assert scnr.get_device_name('456') is None + assert scnr.get_device_name('unknown') is None -def test_monitored_conditions(): +async def test_monitored_conditions(): """Test the filtering of attributes.""" ctrl = mock.MagicMock() fake_clients = [ @@ -254,16 +251,17 @@ def test_monitored_conditions(): 'essid': 'barnet', 'last_seen': dt_util.as_timestamp(dt_util.utcnow())}, ] - ctrl.get_clients.return_value = fake_clients - scanner = unifi.UnifiScanner(ctrl, DEFAULT_DETECTION_TIME, None, - ['essid', 'signal', 'latest_assoc_time']) - assert scanner.get_extra_attributes('123') == { + ctrl.clients = Clients([], CoroutineMock(return_value=fake_clients)) + scnr = unifi.UnifiScanner(ctrl, DEFAULT_DETECTION_TIME, None, + ['essid', 'signal', 'latest_assoc_time']) + await scnr.async_update() + assert scnr.get_extra_attributes('123') == { 'essid': 'barnet', 'signal': -60, 'latest_assoc_time': datetime(2000, 1, 1, 0, 0, tzinfo=dt_util.UTC) } - assert scanner.get_extra_attributes('234') == { + assert scnr.get_extra_attributes('234') == { 'essid': 'barnet', 'signal': -42 } - assert scanner.get_extra_attributes('456') == {'essid': 'barnet'} + assert scnr.get_extra_attributes('456') == {'essid': 'barnet'} diff --git a/tests/components/unifi/test_init.py b/tests/components/unifi/test_init.py index d2d19204b40fa2..fffdcb5fb98e35 100644 --- a/tests/components/unifi/test_init.py +++ b/tests/components/unifi/test_init.py @@ -4,8 +4,7 @@ from homeassistant.components import unifi from homeassistant.components.unifi import config_flow from homeassistant.setup import async_setup_component -from homeassistant.components.unifi.const import ( - CONF_POE_CONTROL, CONF_CONTROLLER, CONF_SITE_ID) +from homeassistant.components.unifi.const import CONF_CONTROLLER, CONF_SITE_ID from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME, CONF_VERIFY_SSL) @@ -146,7 +145,8 @@ async def test_flow_works(hass, aioclient_mock): flow.hass = hass with patch('aiounifi.Controller') as mock_controller: - def mock_constructor(host, username, password, port, site, websession): + def mock_constructor( + host, username, password, port, site, websession, sslcontext): """Fake the controller constructor.""" mock_controller.host = host mock_controller.username = username @@ -185,8 +185,7 @@ def mock_constructor(host, username, password, port, site, websession): CONF_PORT: 1234, CONF_SITE_ID: 'default', CONF_VERIFY_SSL: True - }, - CONF_POE_CONTROL: True + } } @@ -254,7 +253,8 @@ async def test_user_permissions_low(hass, aioclient_mock): flow.hass = hass with patch('aiounifi.Controller') as mock_controller: - def mock_constructor(host, username, password, port, site, websession): + def mock_constructor( + host, username, password, port, site, websession, sslcontext): """Fake the controller constructor.""" mock_controller.host = host mock_controller.username = username diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py index 5a04b415f5dc8c..4eba3aca61e700 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -4,22 +4,21 @@ import pytest +from tests.common import mock_coro + import aiounifi from aiounifi.clients import Clients from aiounifi.devices import Devices from homeassistant import config_entries from homeassistant.components import unifi -from homeassistant.components.unifi.const import ( - CONF_POE_CONTROL, CONF_CONTROLLER, CONF_SITE_ID) +from homeassistant.components.unifi.const import CONF_CONTROLLER, CONF_SITE_ID from homeassistant.setup import async_setup_component from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME, CONF_VERIFY_SSL) import homeassistant.components.switch as switch -from tests.common import mock_coro - CLIENT_1 = { 'hostname': 'client_1', 'ip': '10.0.0.1', @@ -180,8 +179,7 @@ } ENTRY_CONFIG = { - CONF_CONTROLLER: CONTROLLER_DATA, - CONF_POE_CONTROL: True + CONF_CONTROLLER: CONTROLLER_DATA } CONTROLLER_ID = unifi.CONTROLLER_ID.format(host='mock-host', site='mock-site') @@ -190,12 +188,9 @@ @pytest.fixture def mock_controller(hass): """Mock a UniFi Controller.""" - controller = Mock( - available=True, - api=Mock(), - spec=unifi.UniFiController - ) - controller.mac = '10:00:00:00:00:01' + controller = unifi.UniFiController(hass, None) + + controller.api = Mock() controller.mock_requests = [] controller.mock_client_responses = deque() @@ -224,6 +219,9 @@ async def setup_controller(hass, mock_controller): config_entry = config_entries.ConfigEntry( 1, unifi.DOMAIN, 'Mock Title', ENTRY_CONFIG, 'test', config_entries.CONN_CLASS_LOCAL_POLL) + mock_controller.config_entry = config_entry + + await mock_controller.async_update() await hass.config_entries.async_forward_entry_setup(config_entry, 'switch') # To flush out the service call to update the group await hass.async_block_till_done() @@ -242,6 +240,7 @@ async def test_platform_manually_configured(hass): async def test_no_clients(hass, mock_controller): """Test the update_clients function when no clients are found.""" mock_controller.mock_client_responses.append({}) + mock_controller.mock_device_responses.append({}) await setup_controller(hass, mock_controller) assert len(mock_controller.mock_requests) == 2 assert not hass.states.async_all() diff --git a/tests/components/zha/common.py b/tests/components/zha/common.py index cd2eb53c3fe767..4cc7dec1edfaf1 100644 --- a/tests/components/zha/common.py +++ b/tests/components/zha/common.py @@ -82,6 +82,8 @@ def __init__(self, ieee, manufacturer, model): self.initializing = False self.manufacturer = manufacturer self.model = model + from zigpy.zdo.types import NodeDescriptor + self.node_desc = NodeDescriptor() def make_device(in_cluster_ids, out_cluster_ids, device_type, ieee, diff --git a/tests/components/zha/test_binary_sensor.py b/tests/components/zha/test_binary_sensor.py index 1d6b4fd3e01eb2..1a7ec667472d16 100644 --- a/tests/components/zha/test_binary_sensor.py +++ b/tests/components/zha/test_binary_sensor.py @@ -11,8 +11,7 @@ async def test_binary_sensor(hass, config_entry, zha_gateway): """Test zha binary_sensor platform.""" from zigpy.zcl.clusters.security import IasZone from zigpy.zcl.clusters.measurement import OccupancySensing - from zigpy.zcl.clusters.general import OnOff, LevelControl, Basic - from zigpy.profiles.zha import DeviceType + from zigpy.zcl.clusters.general import Basic # create zigpy devices zigpy_device_zone = await async_init_zigpy_device( @@ -23,17 +22,6 @@ async def test_binary_sensor(hass, config_entry, zha_gateway): zha_gateway ) - zigpy_device_remote = await async_init_zigpy_device( - hass, - [Basic.cluster_id], - [OnOff.cluster_id, LevelControl.cluster_id], - DeviceType.LEVEL_CONTROL_SWITCH, - zha_gateway, - ieee="00:0d:6f:11:0a:90:69:e7", - manufacturer="FakeManufacturer", - model="FakeRemoteModel" - ) - zigpy_device_occupancy = await async_init_zigpy_device( hass, [OccupancySensing.cluster_id, Basic.cluster_id], @@ -63,46 +51,20 @@ async def test_binary_sensor(hass, config_entry, zha_gateway): DOMAIN, zigpy_device_occupancy, occupancy_cluster) occupancy_zha_device = zha_gateway.get_device(zigpy_device_occupancy.ieee) - # dimmable binary_sensor - remote_on_off_cluster = zigpy_device_remote.endpoints.get( - 1).out_clusters[OnOff.cluster_id] - remote_level_cluster = zigpy_device_remote.endpoints.get( - 1).out_clusters[LevelControl.cluster_id] - remote_entity_id = make_entity_id(DOMAIN, zigpy_device_remote, - remote_on_off_cluster, - use_suffix=False) - remote_zha_device = zha_gateway.get_device(zigpy_device_remote.ieee) - # test that the sensors exist and are in the unavailable state assert hass.states.get(zone_entity_id).state == STATE_UNAVAILABLE - assert hass.states.get(remote_entity_id).state == STATE_UNAVAILABLE assert hass.states.get(occupancy_entity_id).state == STATE_UNAVAILABLE await async_enable_traffic(hass, zha_gateway, - [zone_zha_device, remote_zha_device, - occupancy_zha_device]) + [zone_zha_device, occupancy_zha_device]) # test that the sensors exist and are in the off state assert hass.states.get(zone_entity_id).state == STATE_OFF - assert hass.states.get(remote_entity_id).state == STATE_OFF assert hass.states.get(occupancy_entity_id).state == STATE_OFF # test getting messages that trigger and reset the sensors await async_test_binary_sensor_on_off(hass, occupancy_cluster, occupancy_entity_id) - await async_test_binary_sensor_on_off(hass, remote_on_off_cluster, - remote_entity_id) - - # test changing the level attribute for dimming remotes - await async_test_remote_level( - hass, remote_level_cluster, remote_entity_id, 150, STATE_ON) - await async_test_remote_level( - hass, remote_level_cluster, remote_entity_id, 0, STATE_OFF) - await async_test_remote_level( - hass, remote_level_cluster, remote_entity_id, 255, STATE_ON) - - await async_test_remote_move_level( - hass, remote_level_cluster, remote_entity_id, 20, STATE_ON) # test IASZone binary sensors await async_test_iaszone_on_off(hass, zone_cluster, zone_entity_id) @@ -127,26 +89,6 @@ async def async_test_binary_sensor_on_off(hass, cluster, entity_id): assert hass.states.get(entity_id).state == STATE_OFF -async def async_test_remote_level(hass, cluster, entity_id, level, - expected_state): - """Test dimmer functionality from the remote.""" - attr = make_attribute(0, level) - cluster.handle_message(False, 1, 0x0a, [[attr]]) - await hass.async_block_till_done() - assert hass.states.get(entity_id).state == expected_state - assert hass.states.get(entity_id).attributes.get('level') == level - - -async def async_test_remote_move_level(hass, cluster, entity_id, change, - expected_state): - """Test move to level command.""" - level = hass.states.get(entity_id).attributes.get('level') - cluster.listener_event('cluster_command', 1, 1, [1, change]) - await hass.async_block_till_done() - assert hass.states.get(entity_id).state == expected_state - assert hass.states.get(entity_id).attributes.get('level') == level - change - - async def async_test_iaszone_on_off(hass, cluster, entity_id): """Test getting on and off messages for iaszone binary sensors.""" # binary sensor on diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index e46f1849fa128b..a05de08f804140 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -1,7 +1,7 @@ """Tests for ZHA config flow.""" from asynctest import patch from homeassistant.components.zha import config_flow -from homeassistant.components.zha.const import DOMAIN +from homeassistant.components.zha.core.const import DOMAIN from tests.common import MockConfigEntry diff --git a/tests/components/zha/test_light.py b/tests/components/zha/test_light.py index e9d6370575b7a8..02a0eba46a389d 100644 --- a/tests/components/zha/test_light.py +++ b/tests/components/zha/test_light.py @@ -57,9 +57,9 @@ async def test_light(hass, config_entry, zha_gateway, monkeypatch): level_device_on_off_cluster = zigpy_device_level.endpoints.get(1).on_off level_device_level_cluster = zigpy_device_level.endpoints.get(1).level on_off_mock = MagicMock(side_effect=asyncio.coroutine(MagicMock( - return_value=(sentinel.data, Status.SUCCESS)))) + return_value=[sentinel.data, Status.SUCCESS]))) level_mock = MagicMock(side_effect=asyncio.coroutine(MagicMock( - return_value=(sentinel.data, Status.SUCCESS)))) + return_value=[sentinel.data, Status.SUCCESS]))) monkeypatch.setattr(level_device_on_off_cluster, 'request', on_off_mock) monkeypatch.setattr(level_device_level_cluster, 'request', level_mock) level_entity_id = make_entity_id(DOMAIN, zigpy_device_level, @@ -137,7 +137,7 @@ async def async_test_on_off_from_hass(hass, cluster, entity_id): from zigpy.zcl.foundation import Status with patch( 'zigpy.zcl.Cluster.request', - return_value=mock_coro([Status.SUCCESS, Status.SUCCESS])): + return_value=mock_coro([0x00, Status.SUCCESS])): # turn on via UI await hass.services.async_call(DOMAIN, 'turn_on', { 'entity_id': entity_id @@ -154,7 +154,7 @@ async def async_test_off_from_hass(hass, cluster, entity_id): from zigpy.zcl.foundation import Status with patch( 'zigpy.zcl.Cluster.request', - return_value=mock_coro([Status.SUCCESS, Status.SUCCESS])): + return_value=mock_coro([0x01, Status.SUCCESS])): # turn off via UI await hass.services.async_call(DOMAIN, 'turn_off', { 'entity_id': entity_id diff --git a/tests/components/zha/test_lock.py b/tests/components/zha/test_lock.py new file mode 100644 index 00000000000000..4951c3537a0c4c --- /dev/null +++ b/tests/components/zha/test_lock.py @@ -0,0 +1,88 @@ +"""Test zha lock.""" +from unittest.mock import patch +from homeassistant.const import ( + STATE_LOCKED, STATE_UNLOCKED, STATE_UNAVAILABLE) +from homeassistant.components.lock import DOMAIN +from tests.common import mock_coro +from .common import ( + async_init_zigpy_device, make_attribute, make_entity_id, + async_enable_traffic) + +LOCK_DOOR = 0 +UNLOCK_DOOR = 1 + + +async def test_lock(hass, config_entry, zha_gateway): + """Test zha lock platform.""" + from zigpy.zcl.clusters.closures import DoorLock + from zigpy.zcl.clusters.general import Basic + + # create zigpy device + zigpy_device = await async_init_zigpy_device( + hass, [DoorLock.cluster_id, Basic.cluster_id], [], None, zha_gateway) + + # load up lock domain + await hass.config_entries.async_forward_entry_setup( + config_entry, DOMAIN) + await hass.async_block_till_done() + + cluster = zigpy_device.endpoints.get(1).door_lock + entity_id = make_entity_id(DOMAIN, zigpy_device, cluster) + zha_device = zha_gateway.get_device(zigpy_device.ieee) + + # test that the lock was created and that it is unavailable + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + # allow traffic to flow through the gateway and device + await async_enable_traffic(hass, zha_gateway, [zha_device]) + + # test that the state has changed from unavailable to unlocked + assert hass.states.get(entity_id).state == STATE_UNLOCKED + + # set state to locked + attr = make_attribute(0, 1) + cluster.handle_message(False, 1, 0x0a, [[attr]]) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == STATE_LOCKED + + # set state to unlocked + attr.value.value = 2 + cluster.handle_message(False, 0, 0x0a, [[attr]]) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == STATE_UNLOCKED + + # lock from HA + await async_lock(hass, cluster, entity_id) + + # unlock from HA + await async_unlock(hass, cluster, entity_id) + + +async def async_lock(hass, cluster, entity_id): + """Test lock functionality from hass.""" + from zigpy.zcl.foundation import Status + with patch( + 'zigpy.zcl.Cluster.request', + return_value=mock_coro([Status.SUCCESS, ])): + # lock via UI + await hass.services.async_call(DOMAIN, 'lock', { + 'entity_id': entity_id + }, blocking=True) + assert cluster.request.call_count == 1 + assert cluster.request.call_args[0][0] is False + assert cluster.request.call_args[0][1] == LOCK_DOOR + + +async def async_unlock(hass, cluster, entity_id): + """Test lock functionality from hass.""" + from zigpy.zcl.foundation import Status + with patch( + 'zigpy.zcl.Cluster.request', + return_value=mock_coro([Status.SUCCESS, ])): + # lock via UI + await hass.services.async_call(DOMAIN, 'unlock', { + 'entity_id': entity_id + }, blocking=True) + assert cluster.request.call_count == 1 + assert cluster.request.call_args[0][0] is False + assert cluster.request.call_args[0][1] == UNLOCK_DOOR diff --git a/tests/components/zha/test_switch.py b/tests/components/zha/test_switch.py index b0bbc103a9e3a0..2120bd6baf550c 100644 --- a/tests/components/zha/test_switch.py +++ b/tests/components/zha/test_switch.py @@ -54,7 +54,7 @@ async def test_switch(hass, config_entry, zha_gateway): # turn on from HA with patch( 'zigpy.zcl.Cluster.request', - return_value=mock_coro([Status.SUCCESS, Status.SUCCESS])): + return_value=mock_coro([0x00, Status.SUCCESS])): # turn on via UI await hass.services.async_call(DOMAIN, 'turn_on', { 'entity_id': entity_id @@ -66,7 +66,7 @@ async def test_switch(hass, config_entry, zha_gateway): # turn off from HA with patch( 'zigpy.zcl.Cluster.request', - return_value=mock_coro([Status.SUCCESS, Status.SUCCESS])): + return_value=mock_coro([0x01, Status.SUCCESS])): # turn off via UI await hass.services.async_call(DOMAIN, 'turn_off', { 'entity_id': entity_id diff --git a/tests/components/zwave/test_cover.py b/tests/components/zwave/test_cover.py index ce34111c6129fd..4d4d537e4b4071 100644 --- a/tests/components/zwave/test_cover.py +++ b/tests/components/zwave/test_cover.py @@ -3,7 +3,7 @@ from homeassistant.components.cover import SUPPORT_OPEN, SUPPORT_CLOSE from homeassistant.components.zwave import ( - const, cover, CONF_INVERT_OPENCLOSE_BUTTONS) + const, cover, CONF_INVERT_OPENCLOSE_BUTTONS, CONF_INVERT_PERCENT) from tests.mock.zwave import ( MockNode, MockValue, MockEntityValues, value_changed) @@ -141,6 +141,34 @@ def test_roller_commands(hass, mock_openzwave): assert value_id == open_value.value_id +def test_roller_invert_percent(hass, mock_openzwave): + """Test position changed.""" + mock_network = hass.data[const.DATA_NETWORK] = MagicMock() + node = MockNode() + value = MockValue(data=50, node=node, + command_class=const.COMMAND_CLASS_SWITCH_MULTILEVEL) + open_value = MockValue(data=False, node=node) + close_value = MockValue(data=False, node=node) + values = MockEntityValues(primary=value, open=open_value, + close=close_value, node=node) + device = cover.get_device( + hass=hass, + node=node, + values=values, + node_config={CONF_INVERT_PERCENT: True}) + + device.set_cover_position(position=25) + assert node.set_dimmer.called + value_id, brightness = node.set_dimmer.mock_calls[0][1] + assert value_id == value.value_id + assert brightness == 75 + + device.open_cover() + assert mock_network.manager.pressButton.called + value_id, = mock_network.manager.pressButton.mock_calls.pop(0)[1] + assert value_id == open_value.value_id + + def test_roller_reverse_open_close(hass, mock_openzwave): """Test position changed.""" mock_network = hass.data[const.DATA_NETWORK] = MagicMock() diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index e0bd509d33045f..6124699d88e347 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -17,11 +17,14 @@ def test_boolean(): """Test boolean validation.""" schema = vol.Schema(cv.boolean) - for value in ('T', 'negative', 'lock'): + for value in ( + None, 'T', 'negative', 'lock', 'tr ue', + [], [1, 2], {'one': 'two'}, test_boolean): with pytest.raises(vol.MultipleInvalid): schema(value) - for value in ('true', 'On', '1', 'YES', 'enable', 1, True): + for value in ('true', 'On', '1', 'YES', ' true ', + 'enable', 1, 50, True, 0.1): assert schema(value) for value in ('false', 'Off', '0', 'NO', 'disable', 0, False): @@ -943,34 +946,6 @@ def test_comp_entity_ids(): schema(invalid) -def test_schema_with_slug_keys_allows_old_slugs(caplog): - """Test schema with slug keys allowing old slugs.""" - schema = cv.schema_with_slug_keys(str) - - with patch.dict(cv.INVALID_SLUGS_FOUND, clear=True): - for value in ('_world', 'wow__yeah'): - caplog.clear() - # Will raise if not allowing old slugs - schema({value: 'yo'}) - assert "Found invalid slug {}".format(value) in caplog.text - - assert len(cv.INVALID_SLUGS_FOUND) == 2 - - -def test_entity_id_allow_old_validation(caplog): - """Test schema allowing old entity_ids.""" - schema = vol.Schema(cv.entity_id) - - with patch.dict(cv.INVALID_ENTITY_IDS_FOUND, clear=True): - for value in ('hello.__world', 'great.wow__yeah'): - caplog.clear() - # Will raise if not allowing old entity ID - schema(value) - assert "Found invalid entity_id {}".format(value) in caplog.text - - assert len(cv.INVALID_ENTITY_IDS_FOUND) == 2 - - def test_uuid4_hex(caplog): """Test uuid validation.""" schema = vol.Schema(cv.uuid4_hex) diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 444bd44133bcf7..80f617e654330a 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -250,71 +250,71 @@ async def test_removing_area_id(registry): assert entry_w_area != entry_wo_area -async def test_specifying_hub_device_create(registry): - """Test specifying a hub and updating.""" - hub = registry.async_get_or_create( +async def test_specifying_via_device_create(registry): + """Test specifying a via_device and updating.""" + via = registry.async_get_or_create( config_entry_id='123', connections={ (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF') }, identifiers={('hue', '0123')}, - manufacturer='manufacturer', model='hub') + manufacturer='manufacturer', model='via') light = registry.async_get_or_create( config_entry_id='456', connections=set(), identifiers={('hue', '456')}, manufacturer='manufacturer', model='light', - via_hub=('hue', '0123')) + via_device=('hue', '0123')) - assert light.hub_device_id == hub.id + assert light.via_device_id == via.id -async def test_specifying_hub_device_update(registry): - """Test specifying a hub and updating.""" +async def test_specifying_via_device_update(registry): + """Test specifying a via_device and updating.""" light = registry.async_get_or_create( config_entry_id='456', connections=set(), identifiers={('hue', '456')}, manufacturer='manufacturer', model='light', - via_hub=('hue', '0123')) + via_device=('hue', '0123')) - assert light.hub_device_id is None + assert light.via_device_id is None - hub = registry.async_get_or_create( + via = registry.async_get_or_create( config_entry_id='123', connections={ (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF') }, identifiers={('hue', '0123')}, - manufacturer='manufacturer', model='hub') + manufacturer='manufacturer', model='via') light = registry.async_get_or_create( config_entry_id='456', connections=set(), identifiers={('hue', '456')}, manufacturer='manufacturer', model='light', - via_hub=('hue', '0123')) + via_device=('hue', '0123')) - assert light.hub_device_id == hub.id + assert light.via_device_id == via.id async def test_loading_saving_data(hass, registry): """Test that we load/save data correctly.""" - orig_hub = registry.async_get_or_create( + orig_via = registry.async_get_or_create( config_entry_id='123', connections={ (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF') }, identifiers={('hue', '0123')}, - manufacturer='manufacturer', model='hub') + manufacturer='manufacturer', model='via') orig_light = registry.async_get_or_create( config_entry_id='456', connections=set(), identifiers={('hue', '456')}, manufacturer='manufacturer', model='light', - via_hub=('hue', '0123')) + via_device=('hue', '0123')) assert len(registry.devices) == 2 @@ -326,10 +326,10 @@ async def test_loading_saving_data(hass, registry): # Ensure same order assert list(registry.devices) == list(registry2.devices) - new_hub = registry2.async_get_device({('hue', '0123')}, set()) + new_via = registry2.async_get_device({('hue', '0123')}, set()) new_light = registry2.async_get_device({('hue', '456')}, set()) - assert orig_hub == new_hub + assert orig_via == new_via assert orig_light == new_light diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 95e1af403d4bb2..e1e3d16c914266 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -706,11 +706,11 @@ async def test_entity_registry_updates_invalid_entity_id(hass): async def test_device_info_called(hass): """Test device info is forwarded correctly.""" registry = await hass.helpers.device_registry.async_get_registry() - hub = registry.async_get_or_create( + via = registry.async_get_or_create( config_entry_id='123', connections=set(), - identifiers={('hue', 'hub-id')}, - manufacturer='manufacturer', model='hub' + identifiers={('hue', 'via-id')}, + manufacturer='manufacturer', model='via' ) async def async_setup_entry(hass, config_entry, async_add_entities): @@ -726,7 +726,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): 'model': 'test-model', 'name': 'test-name', 'sw_version': 'test-sw', - 'via_hub': ('hue', 'hub-id'), + 'via_device': ('hue', 'via-id'), }), ]) return True @@ -754,7 +754,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): assert device.model == 'test-model' assert device.name == 'test-name' assert device.sw_version == 'test-sw' - assert device.hub_device_id == hub.id + assert device.via_device_id == via.id async def test_device_info_not_overrides(hass): diff --git a/tests/test_config.py b/tests/test_config.py index 8e983c673c5ae1..1adb127cfb02eb 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -281,7 +281,7 @@ def test_remove_lib_on_upgrade(mock_docker, mock_os, mock_shutil, hass): @mock.patch('homeassistant.config.is_docker_env', return_value=True) def test_remove_lib_on_upgrade_94(mock_docker, mock_os, mock_shutil, hass): """Test removal of library on upgrade from before 0.94 and in Docker.""" - ha_version = '0.94.0b5' + ha_version = '0.93.0.dev0' mock_os.path.isdir = mock.Mock(return_value=True) mock_open = mock.mock_open() with mock.patch('homeassistant.config.open', mock_open, create=True): diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 752cb5eb277c5a..9de92f88557bc4 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -5,7 +5,7 @@ import pytest -from homeassistant import config_entries, data_entry_flow +from homeassistant import config_entries, data_entry_flow, loader from homeassistant.core import callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.setup import async_setup_component @@ -934,3 +934,17 @@ async def test_entry_reload_error(hass, manager, state): assert len(async_setup_entry.mock_calls) == 0 assert entry.state == state + + +async def test_init_custom_integration(hass): + """Test initializing flow for custom integration.""" + integration = loader.Integration(hass, 'custom_components.hue', None, { + 'name': 'Hue', + 'dependencies': [], + 'requirements': [], + 'domain': 'hue', + }) + with pytest.raises(data_entry_flow.UnknownHandler): + with patch('homeassistant.loader.async_get_integration', + return_value=mock_coro(integration)): + await hass.config_entries.flow.async_init('bla') diff --git a/tests/test_loader.py b/tests/test_loader.py index 8af000c5d05502..cd0cb69270232f 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -152,6 +152,16 @@ def test_integration_properties(hass): assert integration.domain == 'hue' assert integration.dependencies == ['test-dep'] assert integration.requirements == ['test-req==1.0.0'] + assert integration.is_built_in is True + + integration = loader.Integration( + hass, 'custom_components.hue', None, { + 'name': 'Philips Hue', + 'domain': 'hue', + 'dependencies': ['test-dep'], + 'requirements': ['test-req==1.0.0'], + }) + assert integration.is_built_in is False async def test_integrations_only_once(hass): diff --git a/tests/test_setup.py b/tests/test_setup.py index 1dae51966beb8e..410d97b288d461 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -108,37 +108,6 @@ def test_validate_platform_config(self, caplog): 'platform_conf.whatever', MockPlatform(platform_schema=platform_schema)) - with assert_setup_component(1): - assert setup.setup_component(self.hass, 'platform_conf', { - 'platform_conf': { - 'platform': 'whatever', - 'hello': 'world', - 'invalid': 'extra', - } - }) - assert caplog.text.count('Your configuration contains ' - 'extra keys') == 1 - - self.hass.data.pop(setup.DATA_SETUP) - self.hass.config.components.remove('platform_conf') - - with assert_setup_component(2): - assert setup.setup_component(self.hass, 'platform_conf', { - 'platform_conf': { - 'platform': 'whatever', - 'hello': 'world', - }, - 'platform_conf 2': { - 'platform': 'whatever', - 'invalid': True - } - }) - assert caplog.text.count('Your configuration contains ' - 'extra keys') == 2 - - self.hass.data.pop(setup.DATA_SETUP) - self.hass.config.components.remove('platform_conf') - with assert_setup_component(0): assert setup.setup_component(self.hass, 'platform_conf', { 'platform_conf': { @@ -206,21 +175,6 @@ def test_validate_platform_config_2(self, caplog): MockPlatform('whatever', platform_schema=platform_schema)) - with assert_setup_component(1): - assert setup.setup_component(self.hass, 'platform_conf', { - # fail: no extra keys allowed in platform schema - 'platform_conf': { - 'platform': 'whatever', - 'hello': 'world', - 'invalid': 'extra', - } - }) - assert caplog.text.count('Your configuration contains ' - 'extra keys') == 1 - - self.hass.data.pop(setup.DATA_SETUP) - self.hass.config.components.remove('platform_conf') - with assert_setup_component(1): assert setup.setup_component(self.hass, 'platform_conf', { # pass @@ -235,9 +189,6 @@ def test_validate_platform_config_2(self, caplog): } }) - self.hass.data.pop(setup.DATA_SETUP) - self.hass.config.components.remove('platform_conf') - def test_validate_platform_config_3(self, caplog): """Test fallback to component PLATFORM_SCHEMA.""" component_schema = PLATFORM_SCHEMA_BASE.extend({ @@ -258,20 +209,6 @@ def test_validate_platform_config_3(self, caplog): MockPlatform('whatever', platform_schema=platform_schema)) - with assert_setup_component(1): - assert setup.setup_component(self.hass, 'platform_conf', { - 'platform_conf': { - 'platform': 'whatever', - 'hello': 'world', - 'invalid': 'extra', - } - }) - assert caplog.text.count('Your configuration contains ' - 'extra keys') == 1 - - self.hass.data.pop(setup.DATA_SETUP) - self.hass.config.components.remove('platform_conf') - with assert_setup_component(1): assert setup.setup_component(self.hass, 'platform_conf', { # pass @@ -286,9 +223,6 @@ def test_validate_platform_config_3(self, caplog): } }) - self.hass.data.pop(setup.DATA_SETUP) - self.hass.config.components.remove('platform_conf') - def test_validate_platform_config_4(self): """Test entity_namespace in PLATFORM_SCHEMA.""" component_schema = PLATFORM_SCHEMA_BASE diff --git a/tests/util/test_dt.py b/tests/util/test_dt.py index 61f10ab1bf6f17..19d96227a44664 100644 --- a/tests/util/test_dt.py +++ b/tests/util/test_dt.py @@ -213,7 +213,7 @@ def find(dt, hour, minute, second): assert datetime(2018, 10, 7, 10, 30, 0) == \ find(datetime(2018, 10, 7, 10, 30, 0), '*', '/30', 0) - assert datetime(2018, 10, 7, 12, 30, 30) == \ + assert datetime(2018, 10, 7, 12, 0, 30) == \ find(datetime(2018, 10, 7, 10, 30, 0), '/3', '/30', [30, 45]) assert datetime(2018, 10, 8, 5, 0, 0) == \ diff --git a/tests/util/test_package.py b/tests/util/test_package.py index 3751c0569074b2..623d79ddfe0872 100644 --- a/tests/util/test_package.py +++ b/tests/util/test_package.py @@ -178,7 +178,7 @@ def test_install_find_links(mock_sys, mock_popen, mock_env_copy, mock_venv): mock_popen.call_args == call([ mock_sys.executable, '-m', 'pip', 'install', '--quiet', - TEST_NEW_REQ, '--find-links', link + TEST_NEW_REQ, '--find-links', link, '--prefer-binary' ], stdin=PIPE, stdout=PIPE, stderr=PIPE, env=env) ) assert mock_popen.return_value.communicate.call_count == 1 diff --git a/virtualization/Docker/scripts/tellstick b/virtualization/Docker/scripts/tellstick index c9658d14029f23..d35e1cac2dbd92 100755 --- a/virtualization/Docker/scripts/tellstick +++ b/virtualization/Docker/scripts/tellstick @@ -6,7 +6,7 @@ set -e PACKAGES=( # homeassistant.components.tellstick - libtelldus-core2 + libtelldus-core2 socat ) # Add Tellstick repository